当前位置: 首页 > article >正文

Redis,从数据结构到集群的知识总结

Redis基础部分


2. 数据结构

redis底层使用C语言实现,这里主要分析底层数据结构

2.1 动态字符串(SDS)

由于C底层的字符串数组一旦遇到’\0’就会认为这个字符串数组已经结束,意味着无法存储二进制数据(如图片、音频等),为此,redis底层维护了一种数据结构:

struct __attribute__ ((__packed__)) sdshdr8 {
    
    uint8_t len;       // 当前字符串长度(不包含终止符 '\0')
    
    uint8_t alloc;     // 分配的总容量(不包含 '\0')
    
    unsigned char flags; // 低 3 位用于标识 SDS 类型(8/16/32/64)
    
    char buf[];        // 字符串数据(最后包含 '\0')
};

这样,SDS便能自己控制字符串数组的结束,事实上,为了兼容C语言,在buf中的最后也会有’\0’。

另外,除了8bit位的sds,向上还有16/32/64bit位类型的sds数据结构,用于保存不同长度的字符串。

除此之外,SDS具有动态扩容的能力,在追加字符串的时候,会分配新的内存空间,并且和go中的切片类似,在字符串很小的时候,扩容的比例很大,字符串更大的时候,扩容的比例就会逐渐变小,这就是内存预分配

优势

  1. 获取字符串长度时间复杂度为O(1)
  2. 支持动态扩容,同时允许缩短字符串,但不会主动释放空间,而是等待未来的追加操作。
  3. 由于分配的内存空间会在一定程度上大于使用的空间,所以会减少内存分配次数,提高性能
  4. 二进制安全

2.2 IntSet

字面意思,就是整数的Set集合,基于c语言中的整数数组实现,有序(二分查找实现),支持动态扩容,数据结构如下:

typedef struct intset {
    
    uint32_t encoding;	//编码方式,支持16/32/64位整数
    
    uint32_t length;	//元素个数
    
    int8_t contents[];	//保存集合的数据
    
} intset;

这里值得注意的是,数据的类型并不是由contents的类型int8_t决定的,而是由encoding字段决定的.

虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的。

由于这里没有遵循C语言的规范,所以之后的关于IntSet的增删改查都是由redis自己实现的。

IntSet还支持动态升级,当新插入的数字超出了当前数字编码所能表示的范围,当前集合的所有数据都会被升级,比如说,当前集合存储的所有数据都是int16,而当前插入一个114514,超出了这个集合能表示的范围,此时,集合中的所有数据的数据类型都会升级为int32,并更新编码方式为32位编码,具体过程如下:

  1. 更新编码方式,扩容。
  2. 倒序将元素重新插入数组(正序插入会导致数据覆盖)。
  3. 将待添加的元素插入在数组中合适的位置

2.3 Dict(字典)

redis中的K-V键值对的映射关系,正是Dict这一数据结构实现的,也正相当于我们所熟知的哈希表。

一个节点的数据结构:

struct dictEntry {
    void *key;  // 存储键(Key)

    union {  // 存储值(Value),支持不同类型
        void *val;      // 通用指针,可存储字符串、结构体等
        uint64_t u64;   // 存储无符号整数
        int64_t s64;    // 存储有符号整数
        double d;       // 存储双精度浮点数
    } v;

    struct dictEntry *next;  // 指向下一个 `dictEntry`,用于解决哈希冲突(拉链法法)
};

一个节点很好懂,并且可以看出,redis底层的哈希表是通过拉链法来实现的,当产生哈希冲突的时候,采取的是头插法,效率更高。

在最新的版本中,dictht被合并到了dict结构体中,数据结构如下:

struct dict {
    dictType *type;           //哈希函数

    dictEntry **ht_table[2];  // 两个哈希表指针(ht_table[0] 正常使用,ht_table[1] 进行 rehash)
    unsigned long ht_used[2]; // 记录两个哈希表中存储的 entry 数量

    long rehashidx;           // 记录 rehash 进度,-1 表示没有进行 rehash

    //为了减少结构体的填充
    unsigned pauserehash : 15;  // 是否暂停 rehash
    unsigned useStoredKeyApi : 1; // 是否使用存储的 key API(内部优化)

    signed char ht_size_exp[2]; // 存储哈希表大小的指数(size = 1 << exp)
    int16_t pauseAutoResize;   // 是否暂停自动调整大小
    void *metadata[];          // 额外的元数据(可扩展)
};

为什么这里会有两个哈希表指针?

和go语言类似,这里涉及到我们的扩容操作,redis中也有着负载因子(存储元素和桶的比值)的概念,每次插入新的kv的时候,都会检查负载因子,同时在以下情况会触发rehash:

  1. 当哈希表的负载因子 > 1.0,并且此时没有执行后台进程的时候
  2. 负载因子 > 5.0的时候,会强制扩容

除了扩容,当然也有缩容的操作,当负载因子小于1/8,允许缩容(可能会受到其他条件的限制,而导致无法缩容),当负载因子小于1/32时,会强制缩容。

两个方法的终点都是int _dictResize(dict *d, unsigned long size, int* malloc_failed)这个方法,在这里,会重新分配给h_table[1]新的空间,而h_table[0]作为我们旧的哈希表继续存在,一般来说,扩容时当前已经使用空间used + 1距离最近的一个2的n次方,而缩容则是used距离最近的2的n次方,此时,rehash才要刚刚开始。

Rehash

Rehash这一步的时候,扩容已经完成,此时首先为h_table[0]中的元素重新计算哈希值,并迁移到h_table[1]中,随后将h_table[0]的指针指向h_table[1]指向的空间,随后将h_table[1]指针置为NULL,此时我们的rehash就算完成了。

重点来了,事实上,重新分配内存之后,并没有直接进行数据迁移,而是进行渐进式rehash,每次在进行增删查改的时候,只迁移一个桶中的数据,所以并不会带给cpu过大的压力,因为如果一次性迁移完成的话,会导致cpu阻塞而无法执行其他操作,对用户十分不友好。

同时,在执行增删查改的时候,增只在新表中执行;删则是先从旧表中找,如果找到了直接删除,没找到再从新表中找;查询操作则是先查旧表,如果没找到,则查新表;修改操作则是先在旧表中找数据,找到则修改,没找到就在新表中找。


2.4 ZipList

被称作双端链表,可以在头部和尾部实现O(1)的插入和删除,根据这个性质我感觉更像双端队列

ziplist是由一段特殊编码的连续内存块组成的,结构如下:

zlbytes(存储链表占用的内存长度) -> zltail(记录头节点到尾节点的距离) -> zllen(节点数量) -> entry(节点) -> entry -> entry… -> zlend(结束标识)

其中,每个节点(entry)的长度并不固定,虽然源码中的结构体并不是真正的编码方式,还是在这里列一下吧:

/* 该结构体用于解析 ziplist 中的条目元信息(非实际存储格式,仅为操作方便而设计) */
typedef struct zlentry {
    unsigned int prevrawlensize; // 存储【前一个节点长度】所需的字节数(1或5字节)
                                 // 规则:若前节点长度<254则用1字节,否则首字节0xFE+4字节长度
    
    unsigned int prevrawlen;     // 前一个节点的实际长度(字节数)
                                 // 作用:通过【当前地址 - prevrawlen】可快速定位前节点
    
    unsigned int lensize;        // 存储【当前节点类型/长度】所需的字节数
                                 // 字符串:1/2/5字节头 | 整数:固定1字节(类型和长度合并编码)
    
    unsigned int len;            // 当前节点数据的实际长度(字节数)
                                 // 字符串:字符数量(如"abc"为3)| 整数:根据类型占1/2/3/4/8字节
    
    unsigned int headersize;     // 节点头总大小 = prevrawlensize + lensize
                                 // 关键用途:p + headersize 直接跳转到数据区域
    
    unsigned char encoding;      // 编码类型标记(ZIP_STR_* 或 ZIP_INT_*)
                                 // 注意:0~12的4位小整数直接存于encoding,需特殊处理
    
    unsigned char *p;            // 指向当前节点起始地址(即prevrawlensize字段的位置)
                                 // 用途:修改节点时可直接操作原始内存
} zlentry;

看起来很复杂,我们可以简化为以下结构:

previous_entry_length -> encoding(字符串/整数/长度) -> content

以上的数据在实际的存储结构中都可以灵活改变,也就是说entry长度并不固定,这节省了很大的内存

我们可以看到,每个节点事实上并没有存储前一个节点的指针和后一个节点的指针,而是记录了前一段节点和当前节点的长度来实现了一个链表,可以满足逆序和正序遍历,为什么不用指针呢?因为指针更占空间,我们知道,redis是内存存储数据,所以内存很宝贵!

值得一提的是,当存储类型为整数,且在一定范围内的时候,会直接将数值存储在encoding中!

连锁更新问题

连锁更新问题是什么?比方说,我们有很多长度为250~253的entry节点,此时,往前面插入一个254长度的节点,那么此时previous_entry_length需要记录的长度>253,所以需要升级为5个字节来表示前一个节点的长度,那么此时,这个节点的总长度也会变,也就是说,之后的长度在250~253之间的节点都会发送连锁的改变,进而导致插入更新一个节点会导致整个ziplist的变化,每次变化都会涉及到内存的申请,迁移,销毁,还有可能会牵扯到内核态的切换,进而带来性能开销。

虽然ziplist这个问题并没有被官方解决掉,但是却引入了一个新的数据结构listpack来解决这个问题,同时,这个问题发生的概率也是极低的,但是不代表不会发生!


2.5 QuickList

是一个真正的双端链表,每一个节点都是一个ZipList

为什么要这样做?

ziplist虽然节省内存,但是如果内存占用过多,申请内存的效率就会很低,我们需要限制ziplist的大小,而怎么来存储大量的数据呢?答案就是使用多个ziplist,将他进行分片存储数据,然后由QuickList统一管理,同时,通过list-max-ziplist-size这个配置项来管理ziplist的最大大小,同时还会有一个压缩的配置选项list-compress-depth来控制压缩,压缩算法采取LZF,压缩可以减少内存占用,提高存储效率


2.6 SkipList(跳表)

跳表首先是链表,以上两种表,虽然在头尾读写的时候性能优异,但是如果要在中间随机查找,性能就会大打折扣了,因此,还有一种数据结构叫做跳表,他有以下特点:

  1. 元素升序排列
  2. 每个节点可能包含多个指针,且跨度不同(跨越多个节点),最多32层指针。

由于是升序排列,所以性能比较高,再加上多个指针的优势,查找的性能就更高了,这里加个图,来源于黑马程序员的redis教程的ppt,这个结构看起来其实很像B+树,而上方的指针则相当于索引,插入和查找的效率均为O(logN)

另外的,skiplist的底层存储的数据就是SDS,而排序则是利用的索引,有一个字段score来帮助排序,性能和红黑树差不多,但是实现更为简单。(这很明显)

在这里插入图片描述


2.7 RedisObject

redis中任意数据类型的键和值都会被封装非RedisObject,也叫做redis对象,数据结构如下:

struct redisObject {
    unsigned type:4;      // 4 位表示对象的类型,zset,string,hash等类型
    unsigned encoding:4;  // 4 位表示对象的编码方式
    unsigned lru:LRU_BITS; // 最近一次被访问的时间
    int refcount;         // 引用计数,为0时,可以被回收
    void *ptr;            // 指向底层数据的指针
};

同时如果string类型被频繁的使用,那么每一个string都会有一个redisObject头来表示string,这就造成了更多的空间浪费,所以如果存储大量数据的时候,最好还是采取集合的形式。

同时,redis中的基本数据类型都是由这些底层设计的数据结构衍生而来:

Redis 数据类型小数据量的底层结构大数据量的底层结构
String(字符串)SDS(简单动态字符串)SDS(同样是 SDS,只是更大)
List(列表)listpack(列表包)quicklist(快速列表,listpack + 双端链表)
Hash(哈希)listpack(列表包)dict(哈希表)
Set(集合)intset(整数集合)dict(哈希表,值为 NULL)
Zset(有序集合)listpack(列表包)skiplist(跳表)+ dict(哈希表)

对于redis7.0之前的版本,那么listpack替换为Ziplist即可。

--------------
2.8 String

基本编码方式为Raw,存储上限为512MB,如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head和SDS是一段连续的空间,申请内存只需要调用一次内存分配函数,具有更高的效率,所以使用string时尽量不要超过这个值。

如果存储的字符串是整数,并且在范围内,则会采取INT编码,直接保存在RedisObject head的ptr指针上面,这样更加节省了内存。

2.9 List

Redis中的list支持快速操作首尾的元素。

因此,list由Quicklist和ZipList(最新版为listPack)实现

当数据量很小的时候,会采用ListPack作为底层数据结构,而在数据逐渐增多的时候,则会升级为QuickList,在源代码中,list底层结构的升级和降级具体体现在static void listTypeTryConversionRaw(参数),会根据当前list的状态,判断是否需要升级或者降级操作。


2.10 Set

Set,也就是集合,具有无序性,唯一性(需要判断元素是否存在),可以求交集并集和差集。(梦回高中数学)

综上,Set对查询的要求比较高,于是底层采用了Dict(字典)编码,Key来存储数值,而Value统一为NULL,与此同时,当存储的数据都为整数的时候,如果元素数量不超过一定值,统一采取IntSet编码,这个定值可以通过修改set-max-intset-entries来修改。

2.11 ZSet

有序的集合,每个元素都需要设置一个member和score值,member就相当于是Set中的元素,具有唯一性,顺序通过score来确定。

有序,同时还需要member->score的映射,具有快速查找的功能,Skiplist可以存储member和score,实现有序性,而Dict可以存储member到score的映射,实现快速查找,因此,ZSet底层采用的是SkipList和Dict结合的形式来实现这样的有序集合

除此之外,当元素数量比较小的时候,跳表和字典的优势并不明显,且更消耗内存,所以在元素数量比较小,且元素长度不大的时候,会使用ZipList(最新版为ListPack)来作为ZSet的底层实现,以此来节省内存,但是由于ziplist本身并没有排序功能,怎么办呢?此时会按照两个原则进行存储:

  1. score和element紧挨一起
  2. score越小,越靠近队首,越大,越靠近队尾。

这样也能实现ZSet的需求。


2.12 Hash

和ZSet很类似,但是它的value可以存储任何值,且无序。

所以在数据量较少的时候,也是通过ZipList编码,以节省内存,而数据量较大(超过了hash-max-ziplist-entries)或者某个entry过大(超出hash-max-ziplist-value)的时候,就会将ziplist转换成Dict编码。


3. 网络模型

3.1 用户空间和内核空间

为了避免用户应用导致重读甚至内核崩溃,用户应用和内核是分离的,而进程的寻址空间会划分为两部分:内核空间和用户空间。

命令分有等级,r0,r1, r2, r3,用户空间只能执行受限的命令,而想要执行特权命令,只能通过内核提供的接口来访问;而内核空间可以调用一切系统资源。

Linux为了提高IO效率,用户空间和内核空间都有缓冲区,写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备,读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。

tips:用户和内核的读写操作很像Client和Server之间的读写交互,以我现在的知识来看,和Orpc是很相似的,各种地方都很相似。

3.2 I/O

阻塞I/O

阻塞I/O,字面意思,即在发起读,或者写请求的时候都会陷入阻塞等待的状态,期间无法执行其他操作。

非阻塞I/O

非阻塞I/O则是会立即返回结果,如果没有读取到数据,则会立刻返回错误,但是用户应用会不停地去重新调用读取,直到数据准备就绪,但是在拷贝数据的时候,依旧是阻塞的状态,但是这种性能不仅没有提高,反而还降低了。

在这个时候,就需要结合我们的I/O多路复用技术,同时,这里还需要引入一个**文件描述符(fd)**的概念,IO多路复用就是利用单个线程同时监听多个fd,并在某个fd可读/可写的时候得到通知,从而避免了无效的等待,充分利用cpu的资源。

另外,监听FD的方式,通知的方式还有以下常见的实现:

select

  • 只会监听一组fd(最多1024个)的状态,并且每次select都会将所有要监听的fd拷贝到内核,之后再拷贝回来,而且虽然能够知道返回了信号,但是无法知道信号来自哪个fd,只能线性扫描所有的fd来判断。

poll

  • 可以同时监听任意数量的fd,但是局限性依旧与select一样,无法知道信号来自那里,只能线性扫描fd。

epoll

  • 高效,不仅能够监听大量fd,同时用红黑树维护所有被监听的fd,fd的插入/删除/修改操作均为O(logN),除此之外,不像以上两种轮询模型,epoll采取的是事件驱动模型,在事件返回时,内核会将该就绪的fd放入就绪链表,然后拷贝回用户空间,这样一来就避免了遍历所有fd,实现了O(1)的查找。
  • 每个fd都只会执行一次epoll_ctl来将fd添加到红黑树中,而epoll_wait无需传递任何参数,无需重复拷贝。
3.3 事件通知

epoll默认的通知模式是LT(Level Triggered),在有事件返回的时候,会不断通知用户有事件准备就绪,直到事件全部处理完成,然后还有一种事件通知模式叫做ET(Edge Triggered),只有在事件从无事件到有事件的时候,才会通知一次,在之后就不会反复触发了,这个情况下,可能导致缓冲区的数据没有被读取完,从而造成数据丢失。

那么ET怎么解决这个问题呢?事实上,我们在每次读取数据之后,如果没有读取完,可以再调用epoll_ctl将没有被读取完的数据再放回到内核的就绪链表中,然后再次通知,但是这就相当于LT的模式了,并不是标准的处理方式。

思想很单纯,由于通知只有一次,所以在通知的时候,直接将所有的数据都读取完就可以了,如何实现?

实现的方法一般是非阻塞I/O + recv()循环读取,当没有数据返回时,就会立即返回EAGAIN,从而结束循环。

这样,ET就能防止反复调用epoll_wait,从而具有更高的性能和稳定性,但是实现更加复杂。

3.4 Web服务流程

在web服务中,通常都是基于tcp协议,而在tcp协议中,服务端在linux内部,也会被看成一个文件,叫做ssfd(server socket fd),随后将ssfd通过epoll_ctl注册到红黑树中来监听,随后进入到epoll_wait去检查就绪链表中是否存在就绪的fd,如果存在,还需要判断当前时间就绪的对象,如果是ssfd(这表示新的客户端来了),那么则调用accept()去拿到客户端的fd,随后注册到红黑树上,继续监听;如果当前就绪的是client_fd,则说明客户端socket有数据需要读取,随后读取其中的请求参数去处理业务逻辑,处理完成后,发生http相应给客户端。

这里借用一下黑马程序员的流程图:在这里插入图片描述

除此之外,go的netpoll也是基于epoll实现的。

信号驱动I/O

首先执行系统调用sigaction与内核建立sigio的信号关联并且设置回调函数,等待期间可以执行其他业务,真正的阻塞,当内核有fd就绪时,会发送sigio信号通知用户,同时,在接受数据的时候,也是阻塞的。但是,这种模式看起来很好,但是事实上的SIGIO是在内核中以队列的形式维护的,一旦并发量过大,就可能会造成信号丢失,并且内核和用户态频繁的信号交互性能也会受到影响,所以这种方法不太常用。

异步I/O

完全的非阻塞,从发送信号到等待事件就绪,到数据拷贝完成,都是非阻塞的。但是,这种方法也是不太常用的,因为也是在高并发的情况下,用户态会不停的向内核发送信号,使得内核的任务不断积压,而内核的读写效率是比较慢的,最终可能会因为内核内存占用过大而导致崩溃。

我们又提到了异步,关于同步还是异步,他们的区别就在于向内核发起事件之后的第二阶段(数据拷贝)是阻塞还是非阻塞的。


3.5 Redis的网络模型

Q:redis到底是单线程还是多线程的?

A:Redis 核心数据操作是单线程的**,但 某些操作是多线程的,具体如下:

组件单线程 / 多线程说明
数据读写(SET/GET/DEL)单线程主要的数据操作是单线程的,避免了锁争用,提高性能。
事件处理(网络 I/O)单线程采用 epoll(Linux)等多路复用技术,单线程处理多个连接。
持久化(RDB / AOF)多线程RDB 快照和 AOF 重写是由 后台线程 进行的,不影响主线程性能。
IO 线程(Redis 6.0+)多线程io-threads 选项允许多个线程处理网络 I/O,提高吞吐量。

Redis V4.0 的时候,引入多线程异步处理耗时较长的任务(比如删除bigkey)

Redis V6.0 的时候,在网络模型中引入多线程,提高对于多核CPU的利用率。

此时又有一个问题,为什么Redis选择了单线程?

  1. Redis是纯内存操作,执行速度非常快,其性能瓶颈是网络延迟而不是执行速度,多线程不会带来巨大的性能提升。
  2. 多线程频繁的上下文切换回带来不必要的性能开销
  3. 多线程面临线程安全问题,会引入锁不仅导致复杂度提高,性能也会下降

Redis的运行流程

  1. 在src/server.c中的main函数中,会调用initServer()来初始化Redis服务端,在这个函数中,会调用aeCreateEventLoop()来创建一个事件循环,也就是epoll的实例,多路复用的程序。
  2. 回到main函数,由 initListeners() 设置监听套接字并注册到事件循环,同时,在这里会为服务端事件监听注册回调函数,这个回调函数会将监听到的客户端也注册到事件之中,同时该客户端的回调函数和服务端的回调函数并不相同,客户端的回调函数是将返回的信息写入,返回给客户端,
  3. 随后调用aeMain()来运行事件循环,开始监听服务端的事件,一旦有服务端准备就绪,此时说明有客户端请求连接,需要调用tcpAccepthandler将客户端fd注册到事件中,那么当客户端发送数据请求的时候,触发readQueryFromClient()从客户端读取数据,开始处理请求,随后写入到写出缓冲区,当然,如果缓冲区不够写了,就会写入到reply链表中(理论无限大),然后去处理其他请求,注意,此时并没有将响应写回给客户端!
  4. 而在beforeSleep这个函数中,会再次注册一个客户端的socketfd事件,当监听到客户端可写的时候,就会调用sendReplyToClient真正的将缓冲区的数据写回到客户端的socket中。

而在Redis 6.0之后,虽然核心命令依旧是单线程执行,但是为了提高I/O读写效率,在解析客户端命令,写响应结果的时候采用了多线程执行。

源于黑马的整个流程图:

在这里插入图片描述

4. 通信协议和内存回收

4.1 RESP协议

redis是一个C(Client)/S(Server)架构的软件,通信分两步:

  1. Client向Server发送命令
  2. Server解析并执行,返回相应结果给Client

因此发送命令,返回响应的格式需要有一个规范,这就是通信协议

虽然在Redis 6.0版本中,RESP2协议升级到了RESP3,但是默认依旧是RESP2。

数据类型

RESP中,通过首字节的字符来区分不同的数据类型,比如:

  • 单行字符串的首字节为’+',以"\r\n"结尾,返回"OK",则表示为"+OK\r\n"
  • 错误信息:首字节为’-',其余都和单行字符串一样。
  • 数值:首字节为’:',结尾"\r\n"。
  • 多行字符串:以’$'开头,二进制安全的字符串,通过记录字符串的长度来维护字符串,无需担心特殊字符,大小为0,表示空字符串,大小为-1,表示不存在。
  • 数组:首字节为’*',记录数组元素个数,再跟上元素,数据类型不限。比如像set name 原神这个命令就是以数组的格式发送的。

了解完这些,就可以用socket去手撕一个redis客户端了~

4.2 内存回收

Redis具有强大的性能,最主要的原因就是基于内存存储,然而单节点的redis内存不宜过大,会影响主从同步和持久化的性能,我们可以通过maxmemory来修改内存上限。

我们能够通过给Redis的Key设置TTL过期时间,从而达到释放对应的内存,那么此时有这两个问题:

  1. Redis是如何知道有一个Key要过期了?

  2. TTL到期就立刻删除key吗?

下面来解释一下这两个问题。

首先要看看RedisDB的结构体:

/* Redis 数据库结构表示
 * 每个 Redis 实例可以包含多个数据库(默认 16 个),
 * 通过从 0 开始的整数 ID 进行标识(默认数据库 ID 为 0)。
 */
typedef struct redisDb {
    kvstore *keys;  /* 该数据库的 key-value 存储(取代旧版的 dict 结构) */

    kvstore *expires;  /* 存储 key 的过期时间(取代旧版的 dict *expires) */

    ebuckets hexpires;  /* 哈希类型 key 的过期时间存储结构:记录下一个最早要过期的字段 */

    dict *blocking_keys;  /* 记录因阻塞操作(如 BLPOP)而等待数据的 key */

    dict *blocking_keys_unblock_on_nokey;  /* 记录因 `XREADGROUP` 等命令阻塞的 key,
                                            * 并且在 key 被删除时应解除阻塞 */

    dict *ready_keys;  /* 记录已经解除阻塞的 key(因为有新的数据被 PUSH 进来) */

    dict *watched_keys;  /* 事务中使用的 WATCH 机制,存储被监视的 key */

    int id;  /* 数据库 ID(默认 0 号数据库,最大值由配置决定) */

    long long avg_ttl;  /* 该数据库 key 的平均 TTL(仅用于统计) */

    unsigned long expires_cursor;  /* 过期 key 主动清理时使用的游标 */

    list *defrag_later;  /* 记录需要逐步进行碎片整理的 key */
} redisDb;

在这个结构体中,第一个dict字典保存的是所有的键值对的信息(包括具有过期属性的key),而expires字典仅保存设置了过期时间的key,这里我们重点关注这两个字段。知道了这一点,我们就能够回答第一个问题了。

第二个问题,由于在大量的key环境下,去监听每一个key的过期时间会造成巨大的性能损耗,所以redis中的Key执行的是惰性删除,当一个Key到期的时候,我们不需要去管他,直到我们需要访问这个Key的时候,去判断他过没过期,如果过期了,就直接删除,返回nil,如此,便能减少性能损耗,但是如果一个Key已经过期了,而且很久都不去访问,内存很宝贵,这时候惰性删除就无法满足我们的需求,Redis又引入了一个周期删除的策略,顾名思义,周期性的抽样部分过期的key,然后执行删除,有以下两种方式:

  • 设置定时任务serverCron(),按照server.hz的频率(默认为10)执行过期的key清理,模式为SLOW。(耗时较长,所以低频长期清理)
    • SLOW模式:执行频率默认为0,每个执行周期100ms;执行清理耗时不超过一次执行周期的25%;逐个遍历db中的bucket,保证每个key都能够被抽样到;如果没有达到时间上限没有超过25ms,并且当前过期的key的比例比例过大,则会再执行一次抽样。
  • Redis每个事件循环前都会调用beforeSleep(),执行过期的Key清理,模式为FAST。(用时短,所以频率较高)
    • FAST模式:执行频率就是beforeSleep()的频率,两次FAST间隔不低于2ms;执行清理耗时不超过1ms;同样也是逐个遍历bucket;如果没有达到1ms,并且过期key比例过大,则再执行一次抽样。

简单来说,Redis的删除就是惰性删除 + 周期删除的模式,在节省内存和性能优化方面做到了平衡。

4.3 内存淘汰策略

当redis内存使用到达设置的阈值时,Redis需要主动挑选部分Key来释放内存,而如何挑选,就是我们需要了解的redis的淘汰策略了。

在任何命令执行之前,都会执行内存的检查,如果超出了限制,则会删除部分Key来释放内存,当然,如果释放失败了,则会拒绝命令的执行。

Redis支持的淘汰策略:

  • 默认不淘汰任何Key,内存满时不允许写入新数据。
  • 过期时间越短,越先淘汰。
  • 随机淘汰。
  • 有过期时间的Key随机淘汰。
  • 全体LRU(最少最近使用)/LFU(最少频率使用)。
  • 设置过期时间的Key进行LRU/LFU。

那么,LRU和LFU是如何统计的呢?这就需要回到我们的RedisObject对象了:

struct redisObject {
    unsigned type:4;          // 4 位表示对象的类型,zset,string,hash等类型
    
    unsigned encoding:4;     // 4 位表示对象的编码方式
    
    unsigned lru:LRU_BITS;  // LRU:以秒为单位最近一次被访问的时间
    							//LFU:高16位以分钟为单位记录最近一次访问的时间,低								  //八位保存逻辑访问次数(每次访问+1的概率越来越小)
    							//且会随时间进行衰减。
    int refcount;         // 引用计数,为0时,可以被回收
    
    void *ptr;            // 指向底层数据的指针
};

虽然有这样一个字段帮助我们来统计过期时间,但是如果有成千上万个Key,我们应该逐个去比较然后执行LRU/LFU吗?实际上Redis并没有这样做,而是维护了一个eviction_pool淘汰池,在其中会抽样挑选一些Key来进行LRU/LFU的淘汰策略,总体的流程图如下(源自黑马程序员):

在这里插入图片描述


5. 分布式缓存

单点redis具有数据丢失,并发能力,存储能力和故障恢复的问题,所以就需要我们提供一定的解决方案。

5.1 持久化

RDB持久化

全称Redis Database Backup file,就是将redis所有数据记录到磁盘上,当redis实例故障重启后,从磁盘读取快照文件,恢复数据,RDB文件默认保存在当前运行目录。

通过save命令可以阻塞地保存快照(不推荐),bgsave可以开启子进程执行RDB。

虽然Redis每次停机都会自动做RDB保存,但是如果故障宕机,是不会进行保存的,这样数据就全丢了!所以我们需要redis定期进行RDB保存。

而在redis.conf文件中,可以找到形如

//900秒内,若至少有一个Key被修改,则执行bgsave.
save 900 1
save 300 10
save 60 10000
//是否压缩,不推荐,压缩会消耗cpu,且硬盘不值钱
rdbcompression yes
//RDB文件名称
dbfilename dump.rdb
//文件保存的路径目录
dir ./

的配置文件,如果是save ""则表示禁用RDB。

除此之外,bgsave是通过fork()来得到一个子进程,在子进程中读取内存数据,然后写入RDB文件,在这里事实上就是操作系统的知识了,值得一提的是,fork()采取了写时复制(Copy On Write)的策略,在fork()一个进程的时候,仅仅是复制了页表,并不会真正将数据拷贝到新的地址空间,事实上这两个进程在底层最开始是共享地址空间的,而当其中一个进程进行写入操作时,才会真正的将部分数据(数据被修改的页)进行拷贝,让这部分数据迁移到新的地址空间,从而实现父进程和子进程互不干扰,Redis采取fork() + COW这样避免大规模内存复制,从而提高了性能,但是这种模式可能会导致脏页数据不一致的情况发生。

AOF持久化

AOF,全称Append Only File(追加文件)。redis处理的每一个写命令都会追加到AOF文件中,可以看成是日志文件。

而故障宕机,再恢复,只需要一个一个再将AOF文件中保存的命令再执行一遍即可,默认关闭。

相比与RDB,数据具有一致性,不会产生脏页的情况,配置文件如下:

appendonly yes //是否开启AOF功能
appendfilename "appendonly.aof"  //AOF文件的名称
    
appendfsync always  //是否每执行一条写命令,立即记录到aof文件(性能差)
appendfsync everysec   //写命令执行完先放入aof缓冲区,然后每隔一秒将缓冲区数据写道AOF(默认,性能大于always)
appendfsync no		//写命令执行完放入AOF缓冲区,由操作系统决定何时写回磁盘,(性能最高,但是可靠性差)

缺点是数据冗余更多,如果多次对一个key进行复制,那么就会产生冗余的记录,但是可以通过执行bgrewriteaof(后台异步处理)命令让AOF文件执行重写功能,对命令进行优化,尽管如此,AOF文件依旧很大。

bgrewriteaof也存在配置文件使得可以自动重写aif文件:

auto-aof-rewirte-percentage 100  //AOF文件比上次增长超过多少百分比触发重写
auto-aof-rewrite-min-size 64mb  //AOF文件体积最小多大以上才触发重写

两者对比

特性RDBAOF
持久化方式定时对整个内存做快照记录每一次执行的命令
数据完整性不完整,两次备份之间会丢失相对完整,取决于刷盘策略
文件大小会有压缩,文件体积小记录命令,文件体积很大
宕机恢复速度很快
数据恢复优先级低,因为数据完整性不如AOF高,因为数据完整性更高
系统资源占用高,大量CPU和内存消耗低,主要是磁盘IO资源,但AOF重写时会占用大量CPU和内存资源
使用场景可以容忍数分钟的数据丢失,追求更快的启动速度对数据安全性要求较高常见

5.2 主从集群

首先如何快速搭建一个redis集群?docker-compose才是版本答案,相关配置文件在我的Github里面。

networks:
  redis-net: 

services:
  redis-master:
    image: redis:latest
    container_name: redis-master
    restart: always
    networks:
      - redis-net
    ports:
      - "6379:6379"
    command: "redis-server /etc/redis-config/redis.conf"
    volumes:
      - "./data/master:/data/"
      - "./config/redis-master:/etc/redis-config"
      
  redis-slave1:
    image: redis:latest
    container_name: redis-slave1
    restart: always
    depends_on:
      - redis-master
    networks:
      - redis-net
    ports:
      - "6380:6379"
    command: "redis-server /etc/redis-config/redis.conf --replicaof redis-master 6379"
    volumes:
      - "./data/slave1:/data/"
      - "./config/redis-slave:/etc/redis-config"

  redis-slave2:
    image: redis:latest
    container_name: redis-slave2
    restart: always
    depends_on:
      - redis-master
    networks:
      - redis-net
    ports:
      - "6381:6379"
    command: "redis-server /etc/redis-config/redis.conf --replicaof redis-master 6379"
    volumes:
      - "./data/slave2:/data/"
      - "./config/redis-slave:/etc/redis-config"


  sentinel1:
    image: redis:latest
    container_name: redis-sentinel1
    networks:
      - redis-net
    ports:
      - "26379:26379"
    command: "redis-server /etc/redis-config/redis1.conf --sentinel"
    volumes:
      - "./config/redis-sentinel:/etc/redis-config"
    depends_on:
      - redis-master
      - redis-slave1
      - redis-slave2

  sentinel2:
    image: redis:latest
    container_name: redis-sentinel2
    networks:
      - redis-net
    ports:
      - "26380:26379"
    command: "redis-server /etc/redis-config/redis2.conf --sentinel"
    volumes:
      - "./config/redis-sentinel:/etc/redis-config"
    depends_on:
      - redis-master
      - redis-slave1
      - redis-slave2
      
  sentinel3:
    image: redis:latest
    container_name: redis-sentinel3
    networks:
      - redis-net
    ports:
      - "26381:26379"
    command: "redis-server /etc/redis-config/redis3.conf --sentinel"
    volumes:
      - "./config/redis-sentinel:/etc/redis-config"
    depends_on:
      - redis-master
      - redis-slave1
      - redis-slave2
      

volumes:
  redis-master-data:
  redis-slave1-data:
  redis-slave2-data:
  sentinel_data1:
  sentinel_data2:
  sentinel_data3:

如果不想要用docker部署咋办?看这里。


为什么需要主从集群?

单节点的Redis并发能力是有上限的,为了提高redis的并发能力,就需要这样一个集群,从而实现读写分离。

主从同步原理

主从第一次同步是全量同步,我们可以看到,在docker-compose里面有一个replicaof命令,这就是建立主从同步的连接,在执行这个命令的时候,先会尝试增量同步,如果判断为无法进行增量同步,即判断为不是第一次执行该命令,此时则先要返回主节点数据的版本信息,然后从节点将版本信息保存,确保版本信息的记录,这样可以做一个版本控制,然后主节点执行bgsave命令,生成RDB文件之后,将RDB文件发给从节点,保持数据基本一致,我们知道RDB是可能产生数据不一致的情况的,所以我们的Redis还有一个缓冲区repl_baklog记录我们在执行bgsave之后输入的命令,随后发送给从节点。

master如何知道从节点是否为第一次同步?,这里有两个概念:

replid(Replication Id),id一致说明是同一个数据集,每一个master都有唯一的replid,slave则会继承master的replid,如果请求同步的时候,ID不一致,主节点会拒绝增量同步,同时将RDB文件发给slave做全量同步,然后slave清空本地数据,加载master的RDB

offset:随着记录在repl_baklog的数据增多,offset也会增大,这表示slave同步的位置,有了它,master便能持续向slave同步数据。

如此,master便能判断如何同步数据(replid),是否需要同步数据,需要同步哪些数据(offset)。

除此之外,repl_baklog实际上是一个环形数组,大小具有上限,记录也是以环形的方式记录,主从的差异不超过repl_baklog缓冲区的大小,理论上都能进行增量同步,但是一旦slave宕机,master不断记录数据,使得两者的差距超过了缓冲区,此时,只能去做全量同步。

但是可以通过以下方式减小影响:

  1. 在master中配置repl-diskless-sync yes来启动无磁盘复制,这里直接在写入数据的时候,将数据写到网络中,发送给slave(适用于网络带宽快的场景)
  2. 尽量减小Redis单节点内存占用,防止RDB导致的过多的磁盘IO。
  3. 适当提高repl_baklog的大小,尽快恢复宕机的slave。
  4. 限制一个master的slave节点,可以采取主-从-从的链式结构扩展集群。

全量和增量的区别?

  • 全量同步:master将完成内存数据生成RDB发送给slave,过程中写入的命令存入repl_baklog,逐个发送给slave。

  • 增量同步:slave提交自己的offset给master,master获取repl_baklog在offset之后的命令同步给slave。

何时执行全量同步?

  • 主从节点第一次连接时(两者的replid不一样)
  • slave节点断开太久,offset在repl_baklog中(主从命令差距过大)已经被覆盖的时候。

何时执行增量同步?

  • slave断开又恢复,同时offset未被覆盖。
5.3 哨兵(Sentinel)

综上所述,在主从集群中,虽然slave宕机之后,还可以持续进行同步,但是如果master宕机之后怎么办?这里就需要引入我们的哨兵(Sentinel)集群来监控我们的主从集群了,在刚刚的docker-compose文件中,我已经引入了哨兵节点。

  • 监控:sentinel会不断检查master和slave是否按预期工作。
  • 自动故障恢复:若master故障,sentinel会主动的选拔一个slave为新的master,故障恢复之后,一会以这个新的master为主。
  • 通知:通过go-redis通知我们新的master。

而Sentinel是基于心跳机制检测服务状态,每隔1s会向集群的每个实例发送ping命令,如果没有响应,则该Sentinel主观地认为该实例下线,如果超过quorum配置设定的数量的sentinel认为该节点下线,则该实例客观下线

但是如果我们的golang-redis客户端需要连接到redis就会出现问题,因为地址已经写死了,但是go-redis是支持通过sentinel访问当前的主节点的!go-redis

func main() {
	client := redis.NewFailoverClient(&redis.FailoverOptions{
		MasterName:    "mymaster",
		SentinelAddrs: []string{":26379", ":26380", ":26381"},
	})
	fmt.Println("Redis client created")
	defer client.Close()
	ctx := context.Background()
	pong, err := client.Ping(ctx).Result()
	if err != nil {
		panic(err)
	}
	fmt.Println("Ping result:", pong)
}

在终端输入go run main.go就可以得到:

Redis client created
redis: 2025/03/16 18:52:28 sentinel.go:745: sentinel: discovered new sentinel="172.21.0.5:26379" for master="mymaster"
redis: 2025/03/16 18:52:28 sentinel.go:745: sentinel: discovered new sentinel="172.21.0.6:26379" for master="mymaster"
redis: 2025/03/16 18:52:28 sentinel.go:709: sentinel: new master="mymaster" addr="172.21.0.4:6379"
Ping result: PONG

这一流程也可以看作是服务的发现和注册,相当于是sentinel将用户的命令分发给了相对应的master。

而当我们主动的去docker-compose stop redis-master哨兵集群可以重新执行选举。

Redis client created
redis: 2025/03/16 19:45:30 sentinel.go:745: sentinel: discovered new sentinel="172.21.0.6:26379" for master="mymaster"
redis: 2025/03/16 19:45:30 sentinel.go:745: sentinel: discovered new sentinel="172.21.0.7:26379" for master="mymaster"
redis: 2025/03/16 19:45:30 sentinel.go:709: sentinel: new master="mymaster" addr="172.21.0.3:6379"
Ping result: PONG

如果失败的话,很有可能是防火墙没有开启,当然如果有其他问题,欢迎留言。

在仓库的config中,给定了相对应的配置文件,在其中可以配置判断master断连的时间长度,以及故障转移地超时时间

slave-priority也是一个从节点被选举为主节点的权重,如果为0,永远不参与选举,除此之外就是offset参数,值越大,代表数据越新,优先级也越高。

故障转移的过程是怎么样的?

  1. sentinel给被选拔的slave1节点发送slaveof no one命令,使其成为master
  2. sentinel给所有其他的slave发送slave of [新master的ip] [新master的port],让这些slave成为新master的从节点,开始从新的master上同步数据。
  3. 最后,sentinel将故障节点标记为slave,恢复之后会成为新的master的slave。

5.4 分片集群

如何用docker-compose启动集群?

services:
  redis-7001:
    image: redis:latest
    container_name: redis-7001
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./7001:/usr/local/etc/redis
      - ./7001/data:/data
    ports:
      - "7001:7001"
    networks:
      redis-cluster:
        ipv4_address: 172.21.0.2

  redis-7002:
    image: redis:latest
    container_name: redis-7002
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./7002:/usr/local/etc/redis
      - ./7002/data:/data
    ports:
      - "7002:7002"
    networks:
      redis-cluster:
        ipv4_address: 172.21.0.3
      
  redis-7003:
    image: redis:latest
    container_name: redis-7003
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./7003:/usr/local/etc/redis
      - ./7003/data:/data
    ports:
      - "7003:7003"
    networks:
      redis-cluster:
        ipv4_address: 172.21.0.4

  redis-7004:
    image: redis:latest
    container_name: redis-7004
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./7004:/usr/local/etc/redis
      - ./7004/data:/data
    ports:
      - "7004:7004"
    networks:
      redis-cluster:
        ipv4_address: 172.21.0.5
      
  redis-7005:
    image: redis:latest
    container_name: redis-7005
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./7005:/usr/local/etc/redis
      - ./7005/data:/data
    ports:
      - "7005:7005"
    networks:
      redis-cluster:
        ipv4_address: 172.21.0.6

  redis-7006:
    image: redis:latest
    container_name: redis-7006
    command: redis-server /usr/local/etc/redis/redis.conf
    volumes:
      - ./7006:/usr/local/etc/redis
      - ./7006/data:/data
    ports:
      - "7006:7006"
    networks:
      redis-cluster:
        ipv4_address: 172.21.0.7
      
networks:
  redis-cluster:
    driver: bridge
    ipam:
      config:
        - subnet: 172.21.0.0/24

将上面的内容写入docker-compose.yml,然后放在我给定的配置文件中,docker-compose up -d之后,输入

docker exec -it redis-7001 redis-cli --cluster create redis-7001:7001 redis-7002:7002 redis-7003:7003 redis-7004:7004 redis-7005:7005 redis-7006:7006 --cluster-replicas 1,然后输入yes,即可成功搭建一主一从的分片集群。

Go Redis官方也提供了分片集群的连接方式,而当我们运行以下程序的时候,我们就会看到key,val存入读取成功,并且主从节点分明:

func main() {
	ctx := context.Background()
	rdb := redis.NewClusterClient(&redis.ClusterOptions{
		Addrs: []string{
			"localhost:7001",
			"localhost:7002",
			"localhost:7003",
			"localhost:7004",
			"localhost:7005",
			"localhost:7006",
		},
	})
	if err := rdb.Set(ctx, "key", "value", 0).Err(); err != nil {
		panic(err)
	}
	val, err := rdb.Get(ctx, "key").Result()
	if err != nil {
		panic(err)
	}
	fmt.Println(val)
	err = rdb.ForEachMaster(ctx, func(ctx context.Context, master *redis.Client) error {
		fmt.Println(master, "is master")
		return master.Ping(ctx).Err()
	})
	if err != nil {
		panic(err)
	}
	err = rdb.ForEachSlave(ctx, func(ctx context.Context, slave *redis.Client) error {
		fmt.Println(slave, "is slave")
		return slave.Ping(ctx).Err()
	})
	if err != nil {
		panic(err)
	}
}

而当我们进入到容器的内部(可以直接用tiny RDB远程连接,更方便),我们会看见key只被存入了其中一组主从节点,到这里,我们还需要docker-compose stop来让我们的主节点全部挂掉,然后再次执行上面这个程序,我们会发现,所有的从节点都成为了主节点!并且数据的输入也成功了,如果你没有成功,不要着急,也许是你运行的太快了,还没有来得及选举,那么到这里,我们的分片集群的搭建就完成了。

但是我这里实践的时候,我第一次弄集群,没有做固定的ip,在第一次主节点全部挂掉之后,虽然单独重新启动这些节点还能运行,但是一旦所有重新启动一次,节点的地址就会刷新,然后就用不了了(?),目前的解决办法就是指定一个ip来避免这个问题,这个我已经在配置文件里面放好了


主从集群的优势

主从集群可以去应对读多写少的问题,但是应对海量的数据存储和高并发写就显得不尽人意了。

而分片集群,则可以解决这两个问题,下面是分片集群的特点:

  • 集群中有多个master,每个master保存不同的数据(海量存储和高并发写)
  • 每个master都可以由多个slave节点(应对高并发读)
  • master之间可以通过ping检测彼此健康状态(无需哨兵节点)
  • 客户端可以访问集群任意节点,而最终请求会被转发到正确的节点。

散列插槽

redis会将每一个master节点映射到0~16383的插槽上,这个在我们创建集群或者查看集群信息的时候可以看见。

我们知道,在分片集群中,不同的key会被映射到不同的主从集群上,而实现这一点的,正是插槽,事实上,key并非是映射到主从集群上,通过哈希算法计算哈希值并对16384取模,随后映射到了插槽上面,而这些插槽则对应了相应的主从集群,这跟我们的一致性哈希有点类似。

散列插槽在在master宕机时,可以将该matser对应的插槽转移,扩容时,会将插槽重新分配,同时Key也会转移到新的节点上,使得过去存储的key不会因为扩容的影响而失效。

除此之外,当我们需要将同一类数据保存在同一个redis实例上的时候,我们可以在这个Key上加上一些相同的部分比如说{类型id}:商品id作为key,然后使用类型id来计算哈希值,而Redis Cluster默认只对大括号里面的内容计算哈希值。

集群伸缩

redis cluster集群支持扩容功能,我们可以通过redis-cli cluster addnode来添加节点,需要执行被添加的节点的和集群主节点的ip:port,同时也可以执行是否为slave,是谁的slave。

但是在加入集群的时候,这个节点还没有被分配任何插槽,我们可以通过redis-cli cluster reshard [从哪里分配的ip:port]来获取新的插槽,然后输入分配的数量,以及分配到哪里,这样就可以完成我们的插槽分配了,这里再提一下,即便之前输入的数据是存在旧集群里面的节点上,但是在这里,我们如果将某个key对应的插槽分配给新的节点,那么我们的数据会被迁移到这个新的节点上,简单来说,数据是跟着插槽走的,不是跟着节点。

如果要删除一个节点该怎么做?首先,我们不能直接删除,我们需要先将这个节点的插槽移给其他节点,然后执行redis-cli -p [port] cluster delslots [所有该节点管理过的槽],来让该master取消对这些槽的管理,随后执行redis-cli -p [主节点的port] cluster forget [被删的node_id]当然,如果删除一个从节点就简单得多,直接执行forget命令即可。

故障转移

即便Redis Cluster没有哨兵,但是他依旧能够实现故障转移的功能,当集群中一个master宕机,其slave会被升级为一个新的master。

当然有时我们也需要通过手动来实现故障转移,比如说新机器替换老旧机器,在新的slave中执行cluster failover来手动让其master宕机,从而该slave成为新的master,而这样默认的安全模式可以实现无感知的数据迁移,如果是机器自己宕机或者说是force/takeover模式的话,就可能有部分的数据无法同步,造成数据丢失

6. 多级缓存

多级缓存就是充分利用请求处理的每个环节,分别增加缓存,减少数据库的压力。

浏览器客户端缓存->Nginx本地缓存->本地缓存->Redis缓存->数据库

本地缓存

如哈希表之类的数据结构,可以作为本地的缓存,优点是读取本地内存,没有网络开销,但是在微服务分布式架构中不适用,无法在多台机器中共享,可能导致数据不一致。

在golang中,我们可以使用go-cache/BigCache这些第三方库来实现本地缓存

6.1 Lua

Ubuntu:sudo apt install lua5.3

lua的打印,语法貌似跟js一样不太严格:

print("Hello, Lua!")

编译运行输入lua 文件名称

数据类型:

数据类型描述
nil这个最简单,只有值 nil 属于该类,表示一个无效值(在条件表达式中相当于 false)。
boolean布尔值
number双精度浮点数
string字符串由一对双引号或单引号来表示
function由 C 或 Lua 编写的函数,也是一个类型
table可以当作数组,也可以当作哈希表,同一个table的键值可以是不同类型的,很灵活,甚至可以看成是结构体,值也可以是函数,t[n]可以写作t.n
t = {}
t['golang'] = "awesome";
print(t.golang)

同时,可以用type()来判断一个数据的类型

print(type(t), type(t['golang']))

声明变量前无需数据类型,使用local字段(local表示局部,否则是全局)

for循环遍历数组,这里只能遍历数组格式的元素,

for index, value in ipairs(arr) do
    print(index, value)
end

而这里只能遍历key-value格式的元素。

for key, value in pairs(t) do
    print(key, value)
end

如何实现for i := 0; i <= n; i ++的循环?如下,step默认为1

start = 1
stop = 5
step = 2
for x = start, stop, step  do
    print(x, stop, step)
end

这里的终止条件是取等号的,请注意。

函数,无需定义返回值,可以选择返回也可以不返回,同时,貌

function add(a, b)
    return a + b
end

if语句,同时,lua不支持&&之类的运算符,取而代之的是not/and/or这些关键字。

if flag then
    print("flag is true")
    else
    print("flag is false")
    end

------- |
| nil | 这个最简单,只有值 nil 属于该类,表示一个无效值(在条件表达式中相当于 false)。 |
| boolean | 布尔值 |
| number | 双精度浮点数 |
| string | 字符串由一对双引号或单引号来表示 |
| function | 由 C 或 Lua 编写的函数,也是一个类型 |
| table | 可以当作数组,也可以当作哈希表,同一个table的键值可以是不同类型的,很灵活,甚至可以看成是结构体,值也可以是函数,t[n]可以写作t.n |

t = {}
t['golang'] = "awesome";
print(t.golang)

同时,可以用type()来判断一个数据的类型

print(type(t), type(t['golang']))

声明变量前无需数据类型,使用local字段(local表示局部,否则是全局)

for循环遍历数组,这里只能遍历数组格式的元素,

for index, value in ipairs(arr) do
    print(index, value)
end

而这里只能遍历key-value格式的元素。

for key, value in pairs(t) do
    print(key, value)
end

如何实现for i := 0; i <= n; i ++的循环?如下,step默认为1

start = 1
stop = 5
step = 2
for x = start, stop, step  do
    print(x, stop, step)
end

这里的终止条件是取等号的,请注意。

函数,无需定义返回值,可以选择返回也可以不返回,同时,貌

function add(a, b)
    return a + b
end

if语句,同时,lua不支持&&之类的运算符,取而代之的是not/and/or这些关键字。

if flag then
    print("flag is true")
    else
    print("flag is false")
    end

http://www.kler.cn/a/590690.html

相关文章:

  • 基于javaweb的SpringBoot智能相册管理系统图片相册系统设计与实现(源码+文档+部署讲解)
  • 分布式锁: 并发时,redis如何避免删别人的锁
  • 如何用DeepSeek进行项目管理?AI重构项目全生命周期的实践指南
  • C51 Proteus仿真实验17:数码管显示4×4键盘矩阵按键
  • 力扣No.376.摆动序列
  • 【从零开始学习计算机科学】设计模式(一)设计模式概述
  • 蓝桥杯嵌入式赛道复习笔记2(按键控制LED灯,双击按键,单击按键,长按按键)
  • Mysql篇——SQL优化
  • Excel进阶篇:数据透视表详解 数据透视表进阶 切片器 配色
  • 如何使用HACS一键集成米家与果家设备到HomeAssistant玩转智能家居
  • 《我的Python觉醒之路》之转型Python(十五)——控制流
  • 智能化营销:唤醒沉睡客户,驱动企业利润增长
  • C++Qt开发流程图效果,包括保存、加载功能
  • 使用redis客户端中对于json数据格式的存储和读取
  • DR-CAN 卡尔曼滤波笔记
  • leetcode每日一题:使字符串平衡的最小交换次数
  • 【软件工程】06_软件设计
  • Carto 无尽旅图 for Mac v1.0.7.6 (51528)冒险解谜游戏 支持M、Intel芯片
  • 微软 AI 发布 LongRoPE2:近乎无损地将大型语言模型上下文窗口扩展至 128K 标记,保持 97% 短上下文准确性
  • 14.使用各种读写包操作 Excel 文件:辅助模块