Redis基础

数据结构与对象

SDS

Redis里,C字符串只会作为字符串字面量用在一些无需对字符串值进行修改的地方,比如打印日志。Redis构建了 简单动态字符串(simple dynamic string,SDS)来表示字符串值。

在Redis里,包含字符串值的键值对在底层都是由SDS实现的。除此之外,SDS还被用作缓冲区:AOF缓冲区,客户端状态中的输入缓冲区。

SDS的定义

每个sds.h/sdshdr结构表示一个SDS值:

c 复制代码
struct sdshdr {
  // 记录buf数组中已使用字节的数量
  // 等于SDS所保存字符串的长度
  int len;
  
  // 记录buf数组中未使用字节的数量
  int free;
  
  // 字节数组,用于保存字符串
  char buf[];
}

SDS遵循C字符串以空字符结尾的管理,空字符不计算在len属性中。这样,SDS可以重用一部分C字符串函数库,如printf。

SDS与C字符串的区别

  • 常数复杂度获取字符串长度

  • C字符串必须遍历整个字符串才能获得长度,复杂度是O(N)。

  • SDS在len属性中记录了SDS的长度,复杂度为O(1)。

  • 杜绝缓冲区溢出

  • C字符串不记录长度的带来的另一个问题是缓冲区溢出。假设s1和s2是紧邻的两个字符串,对s1的strcat操作,有可能污染s2的内存空间。

  • SDS的空间分配策略杜绝了缓冲区溢出的可能性:但SDS API修改SDS时,会先检查SDS的空间是否满足修改所需的要求,不满足的话,API会将SDS的空间扩展至执行修改所需的大小,然后再执行实际的修改操作。

  • 减少修改字符串时带来的内存重分配次数

  • 每次增长或缩短一个C字符串,程序都要对保存这个C字符串的数组进行一次内存重分配操作。

  • Redis作为数据库,数据会被频繁修改,如果每次修改字符串都会执行一次内存重分配的话,会对新嗯呢该造成影响。SDS通过未使用空间接触了字符串长度和底层数组长度的关联:在SDS中,buf数组的长度不一定就是字符数量+1,数组里面可以包含未使用的字节,由free属性记录。对于未使用空间,SDS使用了空间预分配和惰性空间释放两种优化策略:

    • 空间预分配:当SDS的API对SDS修改并需要空间扩展时,程序不仅为SDS分配修改所需的空间,还会分配额外的未使用空间(取决于长度是否小于1MB)。
    • 惰性空间释放:当SDS的API需要缩短时,程序不立即触发内存重分配,而是使用free属性将这些字节的数量记录下来,并等待将来使用。与此同时,SDS API也可以让我们真正师范未使用空间,防止内存浪费。
  • 二进制安全

  • C字符串中的字符必须复合某种编码(如ASCII),除了字符串末尾之外,字符串里不能包含空字符。这些限制使得C字符串只能保存文本,而不是不能保存二进制数据。

  • SDS API会以处理二进制的方式处理SDS存放在buf数组中的数据,写入时什么样,读取时就是什么样。

  • 兼容部分C字符串函数

  • 遵循C字符串以空字符结尾的管理,SDS可以重用<string.h>函数库。

总结:

C字符串 SDS
获取长度的复杂度O(N) O(1)
API不安全,缓冲区溢出 API安全,不会缓冲区溢出
修改字符串长度必然导致内存重分配 修改字符串长度不一定导致内存重分配
只能保存文本数据 可以保存文本或二进制数据
可使用所有<string.h>库的函数 可使用部分<string.h>库的函数

链表

Redis构建了自己的链表实现。列表键的底层实现之一就是链表。发布、订阅、慢查询、监视器都用到了链表。Redis服务器还用链表保存多个客户端的状态信息,以及构建客户端输出缓冲区。

链表和链表节点的实现

链表节点用adlist.h/listNode结构来表示。

arduino 复制代码
typedef struct listNode {
  struct listNode *prev;
  struct listNode *next;
  void *value;
} listNode;

adlist.h/list来持有链表:

c 复制代码
typedef struct list {
  listNode *head;
  listNode *tail;
  unsigned long len;
  void *(dup)(void *ptr); // 节点复制函数
  void (*free)(void *ptr); // 节点释放函数
  int (*match)(void *ptr, void *key); // 节点值对比函数
} list;

Redis的链表实现可总结如下:

  1. 双向
  2. 无环。表头节点的prev和表尾节点的next都指向NULL
  3. 带表头指针和表尾指针
  4. 带链表长度计数器
  5. 多态。使用void*指针来保存节点值,并通过list结构的dup、free。match三个属性为节点值设置类型特定函数

字典

Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查都是构建在字典的操作之上。

字典还是哈希键的底层实现之一,但一个哈希键包含的键值对比较多,又或者键值对中的元素都是较长的字符串时,Redis就会用字典作为哈希键的底层实现。

字典的实现

Redis的字典使用哈希表作为底层实现,每个哈希表节点就保存了字典中的一个键值对。

Redis字典所用的哈希表由dict.h/dictht结构定义:

arduino 复制代码
typedef struct dictht {
  // 哈希表数组
  dict Entry **table;
  // 哈希表大小
  unsigned long size;
  // 哈希表大小掩码,用于计算索引值,总是等于size - 1
  unsigned long sizemask;
  // 该哈希表已有节点的数量
  unsigned long used;
} dictht;

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

arduino 复制代码
typedef struct dictEntry {
  void *key; // 键
  
  // 值
  union {
    void *val;
    uint64_t u64;
    int64_t s64;
  } v;
  
  // 指向下个哈希表节点,形成链表。一次解决键冲突的问题
  struct dictEntry *next;
}

Redis中的字典由dict.h/dict结构表示:

arduino 复制代码
typedef struct dict {
  dictType *type; // 类型特定函数
  void *privdata; // 私有数据
  
  /*
  哈希表
  一般情况下,字典只是用ht[0]哈希表,ht[1]只会在对ht[0]哈希表进行rehash时是用
  */
  dictht ht[2]; 
  
  // rehash索引,但rehash不在进行时,值为-1
  // 记录了rehash的进度
  int trehashidx; 
} dict;

type和privdata是针对不同类型大家键值对,为创建多态字典而设置的:

  • type是一个指向dictType结构的指针,每个dictType都保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • privdata保存了需要传给那些类型特定函数的可选参数。
arduino 复制代码
typedef struct dictType {
  // 计算哈希值的函数
  unsigned int (*hashFunction) (const void *key);
  
  // 复制键的函数
  void *(*keyDup) (void *privdata, const void *obj);
  
  // 对比键的函数
  void *(*keyCompare) (void *privdata, const void *key1, const void *key2);
  
  // 销毁键的函数
  void (*keyDestructor) (void *privdata, void *key);
  
  // 销毁值的函数
  void (*valDestructor) (void *privdata, void *obj);
} dictType;

哈希算法

Redis计算哈希值和索引值的方法如下:

python 复制代码
# 使用字典设置的哈希函数,计算key的哈希值
hash = dict.type.hashFucntion(key)
# 使用哈希表的sizemask属性和哈希值,计算出索引值
# 根据情况的不同,ht[x]可以使ht[0]或ht[1]
index = hash & dict.ht[x].sizemask

当字典被用作数据库或哈希键的底层实现时,使用MurmurHash2算法来计算哈希值,即使输入的键是有规律的,算法人能有一个很好的随机分布性,计算速度也很快。

解决键冲突

Redis使用链地址法解决键冲突,每个哈希表节点都有个next指针。

rehash

随着操作的不断执行,哈希表保存的键值对会增加或减少。为了让哈希表的负载因子维持在合理范围,需要对哈希表的大小进行扩展或收缩,即通过执行rehash(重新散列)来完成:

  1. 为字典的ht[1]哈希表分配空间:

    1. 如果执行的是扩展操作,ht[1]的大小为第一个大于等于ht[0].used * 2 的2^n
    2. 如果执行的是收缩操作,ht[1]的大小为第一个大于等于ht[0].used的2^n
  2. 将保存在ht[0]中的所有键值对rehash到ht[1]上。rehash是重新设计的计算键的哈希值和索引值

  3. 释放ht[0],将ht[1]设置为ht[0],并为ht[1]新建一个空白哈希表

哈希表的扩展与收缩

满足以下任一条件,程序会自动对哈希表执行扩展操作:

  1. 服务器目前没有执行BGSAVE或BGREWRITEAOF,且哈希表负载因子大于等于1
  2. 服务器正在执行BGSAVE或BGREWRITEAOF,且负载因子大于5

其中负载因子的计算公式:

ini 复制代码
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

注:执行BGSAVE或BGREWRITEAOF过程中,Redis需要创建当前服务器进程的子进程,而多数操作系统都是用写时复制来优化子进程的效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间扩展哈希表,避免不避免的内存写入,节约内存。

渐进式rehash

将ht[0]中的键值对rehash到ht[1]中的操作不是一次性完成的,而是分多次渐进式的:

  1. 为ht[1]分配空间
  2. 在字典中维持一个索引计数器变量rehashidx,设置为0,表示rehash工作正式开始
  3. rehash期间,每次对字典的增删改查操作,会顺带将ht[0]在rehashidx索引上的所有键值对rehash到ht[1],rehash完成之后,rehashidx属性的值+1
  4. 最终ht[0]会全部rehash到ht[1],这是将rehashidx设置为-1,表示rehash完成

渐进式rehash过程中,字典会有两个哈希表,字典的增删改查会在两个哈希表上进行。

跳跃表

跳跃表是一种有序数据结构 ,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的。跳跃表支持平均O(logN) 、最坏O(N) 的查找,还可以通过顺序性操作来批量处理节点。

Redis使用跳跃表作为有序集合键的底层实现之一,如果有序集合包含的元素数量较多,或者有序集合中元素的成员是比较长的字符串时,Redis使用跳跃表来实现有序集合键。

在集群节点中,跳跃表也被Redis用作内部数据结构。

跳跃表的实现

Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode代表跳跃表节点,zskiplist保存跳跃表节点的相关信息,比如节点数量、以及指向表头/表尾结点的指针等。

arduino 复制代码
typedef struct zskiplist {
  struct zskiplistNode *header, *tail;
  unsigned long length;
  int leve;
} zskiplist;

zskiplist结构包含:

  • header:指向跳跃表的表头结点
  • tail:指向跳跃表的表尾节点
  • level:记录跳跃表内,层数最大的那个节点的层数(表头结点不计入)
  • length:记录跳跃表的长度, 即跳跃表目前包含节点的数量(表头结点不计入)
arduino 复制代码
typedef struct zskiplistNode {
  struct zskiplistLevel {
    struct zskiplistNode *forward;
    unsigned int span; // 跨度
  } level[];
  
  struct zskiplistNode *backward;
  double score;
  robj *obj;
} zskiplistNode;

zskiplistNode包含:

  • level:节点中用L1、L2、L3来标记节点的各个层,每个层都有两个属性:前进指针和跨度。前进指针用来访问表尾方向的其他节点,跨度记录了前进指针所指向节点和当前节点的距离(图中曲线上的数字)。

    • level数组可以包含多个元素,每个元素都有一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点。层数越多,访问速度就越快。没创建一个新节点的时候,根据幂次定律(越大的数出现的概率越小)随机生成一个介于1-32之间的值作为level数组的大小。这个大小就是层的高度。
    • 跨度用来计算排位(rank):在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到就是目标节点的排位。
  • 后退指针:BW,指向位于当前节点的前一个节点。只能回退到前一个节点,不可跳跃。

  • 分值(score):节点中的1.0/2.0/3.0保存的分值,节点按照各自保存的分值从小到大排列。节点的分值可以相同。

  • 成员对象(obj):节点中的o1/o2/o3。它指向一个字符串对象,字符串对象保存着一个SDS值。

注:表头结点也有后退指针、分值和成员对象,只是不被用到。

遍历所有节点的路径:

  1. 访问跳跃表的表头,然后从第四层的前景指正到表的第二个节点。
  2. 在第二个节点时,沿着第二层的前进指针到表中的第三个节点。
  3. 在第三个节点时,沿着第二层的前进指针到表中的第四个节点。
  4. 当程序沿着第四个程序的前进指针移动时,遇到NULL。结束遍历。

整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且数量不多时,Redis采用整数集合作为集合键的底层实现。

整数集合的实现

整数集合,可以保存int16_t、int32_t或者int64_t的整数值,且元素不重复,intset.h/intset结构表示一个整数集合:

arduino 复制代码
typedef struct intset {
  uint32_t encoding; // 决定contents保存的真正类型
  uint32_t length;
  int8_t contents[]; // 各项从小到大排序
} inset;

上图中,contents数组的大小为sizeof(int16_t) * 5 = 80位。

升级

每当添加一个新元素到整数集合中,且新元素的类型比现有所有元素的类型都要长时,整数集合需要先升级(update),然后才能添加新元素:

  1. 根据新元素的类型,扩展底层数组的空间大小,并未新元素分配空间。
  2. 将底层数组现有元素转换成与新元素相同的类型,并放置在正确的位置上(从后向前遍历)。放置过程中,维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里。

因为每次升级都可能对所有元素进行类型转换,所以复杂度为O(N)

PS. 因为引发升级的新元素长度比当前元素都大,所以它的值要么大于当前所有元素,要么就小于。前种情况放置在底层数组的末尾,后种情况放置在头部。

升级的好处

升级有两个好处

  1. 提升整数集合的灵活性

    1. 我们可以随意地将int16_t、int32_t添加到集合中,不必担心出现类型错误,毕竟C是个静态语言。
  2. 尽可能节约内存

    1. 避免用一个int64_t的数组包含所有元素

降级

整数集合不支持降级

压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度较短的字符串,那么Redis就会使用压缩列表来实现列表键。

当一个哈希键只包含少量键值对,并且每个键值对要么是小整数值,要么是长度较短的字符串,Redis就会使用压缩列表来实现哈希键。

压缩列表的构成

压缩列表是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

压缩列表的各组成部分:

zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend

其中,

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录压缩列表占用的内存字节数:在内存重分配,或计算zlend的位置时使用
zltail uint32_t 4字节 记录表尾结点距离起始地址的字节数:通过这个偏移量,程序可以直接确定表尾结点的地址
zllen uint16_t 2字节 记录节点数量:当这个属性小于UINT16_MAX(65535)时,这个属性的值就是节点的数量。如果等于UINT16_MAX,节点的真实数量要遍历整个压缩列表才能得到
entryX 列表节点 不定 各个节点,节点的长度由保存的内容决定
zlend uint8_t 1字节 特殊值0xFF,标记压缩列表的尾端

压缩列表节点的构成

压缩列表的节点可以保存一个字节数组或者一个整数值。压缩节点的各个组成部分:

previous_entry_length | encoding | content

previous_entry_length

previous_entry_length以字节为单位,记录前一个节点的长度。previous_entry_length属性的长度可以是1字节或5字节:

  1. 若前一节点的长度小于254字节,那么previous_entry_length属性的长度就是1字节。前一节点的长度保存在其中。
  2. 若前一节点的长度大于254字节,那么previous_entry_length属性的长度就是5字节:其中属性的第一个字节被设置为0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度。

程序可以通过指针运算,根据当前节点的起始地址来计算出前一个结点的起始地址。压缩列表的从尾向头遍历就是据此实现的。

encoding

节点的encoding记录了节点的content属性所保存的数据的类型和长度:

  • 1字节、2字节或者5字节长,值的最高位为00、01或10的是字节数组编码:这种编码表示节点的content保存的是字节数组,数组的长度由编码除去最高两位置后的其他位记录。
  • 1字节长。值的最高位以11开头的是整数编码:表示content保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。

content

content保存节点的值,可以使字节数组或整数,值的类型和长度由encoding属性决定。

保存字节数组"hello world"的节点:

previoid_entry_length encoding content
... 00001011 "hello world"

保存整数10086的节点:

previoid_entry_length encoding content
... 11000000 10086

连锁更新

因为previoid_entry_length的长度限制,添加或删除节点都有可能引发「连锁更新」。在最坏的情况下,需要执行N 次重分配操作,而每次空间重分配的最坏复杂度是O(N) ,合起来就是O(N^2)

尽管如此,连锁更新造成性能问题的概率还是比较低的:

  1. 压缩列表里有多个连续的、长度介于250和253字节之间的节点,连锁更新才有可能触发。
  2. 即使出现连锁更新,只要需要更新的节点数量不多,性能也不会受影响。

对象

Redis并没有使用SDS、双端链表、字典、压缩列表、整数集合来实现键值对数据库,而是基于这些数据结构创建了一个对象系统。这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象。

通过这五种类型的对象,Redis可以在执行命令之前,根据对象的类型判断一个对象是否执行给定的命令。使用对象的好处是,可以针对不同的场景,为对象设置多种不同的数据结构的实现,从而优化使用效率。

除此之外,Redis还实现了引用计数的内存回收机制。当程序不再需要某个对象的时候,它所占用的内存会被自动释放。另外,Redis还用引用计数实现了对象共享,让多个数据库键共享同一个对象来节约内存。

最后,Redis的对象带有访问时间记录信息,空转时长较大的键可能被优先删除。

对象的类型和编码

Redis使用对象来表示数据库中的键和值。创建一个新键值对时,至少会创建两个对象,一个对象用作键,一个对象用作值。每个对象都由一个redisObject结构表示:

arduino 复制代码
typedef struct redisObject {
  unsigned type: 4; // 类型
  unsigned encoding: 4; // 编码
  void *ptr; // 指向底层实现数据结构的指针
  // ...
} robj;

类型

对象的type记录了对象的类型,它的值可以是

type常量 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

键总是一个字符串对象,值可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象。

但数据库执行TYPE命令时,返回的结果为数据库键对应的值对象的类型,而不是键对象的类型。

编码和底层实现

对象的ptr指向对象的底层实现数据结构,而这些数据结构由对象的encoding决定,它可以是:

encoding常量 对应的底层数据结构
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_EMBSTR embstr编码的SDS
REDIS_ENCODING_RAW SDS
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双端链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典

每种类型的对象至少使用了两种编码。

使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码。

字符串对象

字符串对象的编码可以使int、raw或embstr。

  1. 如果字符串对象保存的是整数值,且可以用long类型表示,那么字符串对象会将整数值保存在ptr中(将void* 转换成 long),并将编码设置为int。
  2. 如果字符串对象保存到是一个字符串值,且长度大于32字节,那么字符串对象使用SDS来保存这个字符串值,并将编码设置为raw。
  3. 如果字符串对象保存到是一个字符串值,且长度小于等于32字节,那么字符串对象使用embstr编码的方式来存储这个字符串值。

embstr编码是专门用来保存短字符串的优化方式。和raw编码一样,都是用redisObject结构和sdshdr结构来表示字符串对象,但raw会调用两次内存分配函数分别创建redisObject结构和sdshdr结构,而embstr则通过一次内存分配一块连续空间,依次包含两个结构:

| redisObject | sdshdr |
|-----------------|------------|-----|-----|------|-----|-----|
| type | encoding | ptr | ... | free | len | buf |

embstr的好处:

  1. 内存分配次数降为一次。
  2. 释放字符串对象只要一次内存释放函数。
  3. 因为内存连续,可以更好地利用缓存。

PS. 用long double类型表示的浮点数在Redis中也是作为字符串值存储的。程序会先将浮点数转成字符串值,然后再保存转换的字符串值。

编码的转换

int编码和embstr编码的字符串对象可以被转换为raw编码的字符串对象。

  1. 对int编码的字符串对象执行一些命令,可使其不再是整数值,而是字符串值,那么编码也就变为raw了。如APPEND。
  2. 对embstr编码的字符串,执行修改命令,也会变成raw对象。如APPEND。

字符串命令的实现

用于字符串键的所有命令都是针对字符串对象来构建的。

命令 int 编码的实现方法 embstr编码的实现方法 raw编码的实现方法
SET int编码保存值 embstr编码保存值 raw编码保存值
GET 拷贝对象所保存的整数值,将这个拷贝转换为字符串值,然后向客户端返回这个字符串值 直接向客户端返回字符串值 直接向客户端返回字符串值
APPEND 将对象转换为raw编码,然后按raw方式执行此操作 将对象转换为raw编码,然后按raw方式执行此操作 调用sdscatlen函数,将给定字符串追加到现有字符串的末尾
INCBYFLOAT 取出整数值并将其转换为long double的浮点数,对这个浮点数进行加法计算,然后将结果保存起来 取出整数值并将其转换为long double的浮点数,对这个浮点数进行加法计算,然后将结果保存起来。如果字符串值不能被转换为浮点数,那么客户端会报错 取出整数值并将其转换为long double的浮点数,对这个浮点数进行加法计算,然后将结果保存起来。如果字符串值不能被转换为浮点数,那么客户端会报错
INCBY 对整数值进行加法计算,得出的结果作为整数被保存起来 不能执行此命令,客户端报错 不能执行此命令,客户端报错
DECBY 对整数值进行减法计算,得出的结果作为整数被保存起来 不能执行此命令,客户端报错 不能执行此命令,客户端报错
STRLEN 拷贝对象保存的整数值,将这个拷贝转换为字符串值,计算并返回这个字符串值的长度 调用sdslen函数,返回字符串的长度 调用sdslen函数,返回字符串的长度
SETRANGE 将对象转换为raw编码,然后按raw方式执行此命令 将对象转换为raw编码,然后按raw方式执行此命令 将字符串特定索引上的值设置为给定的字符
GETRANGE 拷贝对象保存的整数值,将这个拷贝转换为字符串,然后取出返回字符串指定索引上的字符 直接取出并返回给定索引上的字符 直接取出并返回给定索引上的字符

列表对象

列表对象的编码是ziplist或linkedlist。

使用ziplist时,每个压缩列表的节点保存了一个列表元素。使用linkedlist时,每个链表节点保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。(字符串对象是Redis五种类型的对象中唯一一种会被嵌套的对象。)

编码转换

当列表对象同时满足以下两个条件时,使用ziplist编码:

  1. 保存的字符串对象的长度都小于64字节。
  2. 保存的元素数量小于512个。

否则就是用linkedlist编码。

以上两个条件的上限可以修改,使用list-max-ziplist-value选项和list-max-ziplist-entries选项。

列表命令的实现

命令 ziplist编码的实现 linkedlist编码的实现
LPUSH 调用ziplistPush函数,将新元素压入表头 调用listAddNodeHead函数,将新元素压入表头
RPUSH 调用ziplistPush函数,将新元素压入表尾 调用listAddNodeTail函数,将新元素压入表尾
LPOP 调用ziplistIndex定位表头节点,返回节点保存的元素后,调用ziplistDelete删除表头节点 调用lsitFrist定位表头节点,返回节点保存的元素后,调用listDelNode删除表头节点
RPOP 调用ziplistIndex定位表尾节点,返回节点保存的元素后,调用ziplistDelete删除表尾节点 调用listLast定位表尾节点,返回节点保存的元素后,调用listDelNode删除表尾节点
LINDEX 调用ziplistIndex 调用listIndex
LLEN 调用ziplistLen 调用listLength
LINSERT 插入新节点到表头或表尾时,使用ziplistPush;其他位置使用ziplistInsert 调用listInsertNode
LREM 遍历节点,调用ziplistDelete删除包含给定元素的节点 遍历节点,调用listDelNode删除包含给定元素的节点
LTRIM 调用ziplistDeleteRange函数删除不再指定索引范围内的节点 遍历节点,调用listDelNode
LSET 调用ziplistDelete,先删除给定索引上的节点,然后调用ziplistInsert插入新节点 调用listIndex函数,定位给定索引上的节点,然后通过赋值操作更新节点的值

哈希对象

哈希对象的编码可以是ziplist或hashtable。

使用ziplist时,每当有新的键值对要加入哈希对象时,程序先保将存了 的压缩列表对象推入到表尾,然后再将保存了的节点推入到表尾。因此:

  1. 保存了同一键值对的两个节点总是挨在一起。
  2. 先添加的键值对会被放在表头,后添加的在表尾。、

使用hashtable时,哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象,对象中保存了键值对的键。
  • 字典的每个值都是一个字符串独显,对象中保存了键值对的值。

编码转换

当哈希对象同时满足以下两个条件时,使用ziplist编码:

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节。
  2. 哈希对象保存的键值对数量小于512个。

否则就使用hashtable编码。

以上两个条件的上限可以修改,使用hash-max-ziplist-value选项和hah-max-ziplist-entries选项。

哈希命令的实现

命令 ziplist编码的实现 hashtable编码的实现
HSET ziplistPush将元素压入表尾,然后再ziplistPush将值压入表尾 dictAdd添加新节点
HGET ziplistFind查找指定键对应的节点,再ziplistNext将指针移动到键节点旁边的值节点,返回直值节点 dictFind查找给定键,然后dictGetVal返回对应的值
HEXISTS ziplistFind查找指定键对应的节点 dictFind
HDEL ziplistFind,然后删除键节点和值节点 dictDelete
HLEN ziplistLen,然后除以2 dictSize
HGETALL 遍历ziplist,ziplistGet返回所有的键和值 遍历字典,dictGetKey返回键,dictGetVal返回值

集合对象

集合对象的编码可以使intset或hashtable。

  1. inset编码,集合对象的所有元素都被保存在整数集合中。

  2. hashtable编码,字典的每个键都是一个字符串对象,每个字符串对象都包含了一个集合元素,字典的值全部为NULL。

编码的转换

当集合对象同时满足以下两个条件时,使用inset编码:

  1. 所有元素都是整数值。
  2. 元素数量不超过512个。

第二个的上限修改,查看set-max-intset-entries选项。

集合命令的实现

命令 intset编码的实现 hashtable编码的实现
SADD intsetAdd dictAdd
SCARD intsetLen dictSize
SISMEMBER intsetFind dictFind
SMEMBERS 遍历集合,使用intsetGet返回元素 遍历字典,使用dictGetKey返回元素
SRANDMEMBER intsetRandom随机返回一个元素 dictGetRandomKey
SPOP intsetRandom,然后intsetRemove dictGetRandomKey,然后dictDelete
SREM intsetRemove dictDelete

有序集合的对象

有序集合的编码是ziplist或skiplist。

  1. ziplist编码:每个集合元素使用两个紧挨在一起的ziplist节点来存储。第一个节点保存元素的成员(member),第二元素保存元素的分值(score)。元素按分值的从小到大排序。
  2. skiplist编码:一个zset结构同时包含一个字典和一个跳跃表。跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素,节点的object保存了元素的成员,score保存了元素的分值。字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素,键保存了元素的成员,值保存了元素的分值。

编码的转换

有序集合满足以下两个条件时,使用ziplist编码:

  1. 元素数量小于128。
  2. 元素成员的长度小于64个字节。

两个条件的上限参考zset-max-ziplist-entries和zset-max-ziplist-value选项。

有序集合命令的实现

命令 ziplist编码的实现 zset编码的实现
ZADD ziplistInsert将成员和分值两个节点分别插入 zslInsert,将新元素插入跳跃表,然后dictAdd将新元素关联到字典
ZCARD ziplistLen,然后除以2 访问跳跃表的length
ZCOUNT 遍历列表,统计分值在给定范围内的节点的数量 遍历跳跃表
ZRANGE 从头到尾遍历 从头到尾遍历跳跃表
ZREVRANGE 从尾向头遍历 从尾向头遍历
ZRANK 从头到尾遍历,查找给定成员,并记录经过节点的数量 从头到尾遍历,查找给定成员,并记录经过节点的数量
ZREVRANK 从尾向头遍历,查找给定成员,并记录经过节点的数量 从尾向头遍历,查找给定成员,并记录经过节点的数量
ZREM 遍历,删除包含给定成员的节点及旁边的分值节点 遍历跳跃表,删除节点,并在字典中解除被删除元素的成员和分值的关联
ZSCORE 遍历查找成员节点,返回旁边的分值节点 从字典中取出给定成员的分值

类型检查与命令多态

在执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行。类型检查是通过redisObject的type属性来的。

除此之外,Redis还会根据值对象的编码方式,选择正确的实现命令来执行。这就是多态。

LLEN命令的执行过程:

内存回收

Redis为对象系统构建了一个引用计数垃圾回收。每个对象的引用计数由redisObject结构的refcount保存。

操作 引用计数的变化
创建一个新对象 初始化为1
对象被一个新程序使用 +1
对象不再被一个程序使用 -1

当计数变为0时,对象占用的内存就会被释放。

对象共享

refcount还可用于对象共享:

  1. 将数据库键的值指向现有的值对象。
  2. refcount++。

Redis在初始化服务器时,创建了10000个字符串对象,包含0 ~ 9999的所有整数值,用于共享。

数量通过redis.h/REDIS_SHARED_INTSETGERS常量控制。

使用OBJECT REFCOUNT可查看值对象的引用计数。

但Redis只对包含整数值的字符串对象共享。即只有共享对象和目标对象完全相同的情况下。一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同的操作也就越复杂。

对象的空转时长

redisObject最后一个属性lru,记录了对象最后一次被访问的时间,用OBJECT IDLETIME可查看。

如果服务器打开了maxmemory属性,lru对象可用于回收内存。

单机数据库的实现

数据库

服务器中的数据库

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库:

arduino 复制代码
struct redisServer {
  // ...
  redisDb *db;
  int dbnum; // 数据库的数量
  // ...
};

其中dbnum的值由服务器配置的database选项决定,默认为16。

切换数据库

默认情况下,Redis客户端的目标数据库是0号数据库,客户端可以执行SELECT命令来切换。

服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库:

arduino 复制代码
typedef struct redisClient {
  redisDb *db; // 指向redisServer.db数组中的一个元素
} redusClient;

数据库键空间

Redis是一个键值对(key-value pair)数据库服务器。redisDb结构的dict字典保存了数据库的所有键值对,这个字典就是键空间:

arduino 复制代码
typedef struct redisDb {
  // ...
  dict *dict;
  // ...
} redisDb;

键空间和用户所见的数据库是直接对应的:

  • 键空间的键也就是数据库的键。每个键都是一个字符串对象。
  • 键空间的值也是数据库的值。每个值可以使字符串对象、列表对象、哈希表对象、集合对象、有序集合对象。

所有针对数据库的操作,实际上都是通过键空间字典来实现。

添加新键

添加一个新键值对到数据库,就是将新键值对添加到键空间字典中。

删除键

删除数据库中的一个键,就是在键空间中删除键所对应的键值对对象。

更新键

更新数据库的一个键,就是对键空间里键所对应的值对象进行更新。根据值对象类型的不同,更新的具体方法也不同。

对键取值

对一个数据库键取值,就是在键空间中取出键所对应的值对象。

读写键空间时的维护操作

当Redis对数据库读写时,不仅对键空间执行指定的操作,还会执行一些额外的维护:

  1. 读取一个键后,更新服务器的键命中次数或不命中次数。这两个值可通过INFO stats命令查看。
  2. 读取一个键后,更新LRU时间。OBJECT idletime <key>查看。
  3. 读取键时发现已过期,删除。
  4. 如果有客户端WATCH了某个键,修改后将键标记为dirty,从而让事物程序注意到它。
  5. 每次修改一个键后,将dirty键计数器的值+1,这个计数器会触发服务器的持久化和赋值操作。
  6. 如果服务器开启了通知功能,键修改后,服务器会按照配置发送通知。

设置键的生存时间或过期时间

EXPIREPEXPIRE命令让客户端可以以秒或者毫秒进度为某个键设置生存时间。经过指定的时间后,服务器会自动删除生存时间为0的键。

EXPIREATPEXPIREAT命令,以秒或毫秒精度为某个键设置过期时间,过期时间是一个UNIX时间戳。

TTLPTTL命令可查看某个键的剩余生存时间。

实际上,EXPIREPEXPIREEXPIREAT三个命令都是使用PEXPIREAT来实现的。

保存过期时间

redisDb结构的expires字典保存了所有键的过期时间:

  • 过期字典的键是一个指针,指向键空间中的某个键对象。
  • 过期字典的值是一个long long类型的整数,保存了一个UNIX时间戳。
arduino 复制代码
typedef struct redisDb {
  // ...
  dict *expires;
  // ...
} redisDb;

过期键的判定

通过过期字典,程序可通过以下步骤来判定键是否过期:

  1. 检查给定键是否存在于过期字典,如果存在,取得其过期时间
  2. 检查当前UNIX时间戳是否大于其过期时间

过期键的删除策略

策略 操作 优点 缺点
定时删除 设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时立即执行删除操作。 对内存最友好,保证会尽快释放内存 对CPU时间不友好
惰性删除 每次从键空间获取键时,检查其是否过期,过期则删除;否则就返回该键。 对CPU时间最友好 对内存不友好
定期删除 每隔一段时间,对数据库进行一次检查,删除所有的过期键。 上述两种策略的整合和折中 难点在于确定删除的时长和频率

Redis的过期键删除策略

Redis服务器使用的是惰性删除和定期删除两种策略。

惰性删除的实现

惰性删除的策略由db.c/exipireIfNeeded函数实现,所有读写数据库的Redis命令都会在执行前调用该函数。

定期删除的实现

定期删除的策略由redis.c/activeExpireCycle函数实现,每当Redis服务器周期性操作redis.c/serverCron函数执行时,该函数会被调用。它在规定时间内,分多次遍历各个数据库,检查过期时间并删除过期键。

ini 复制代码
DEFAULT_DB_NUMBERS = 16
DEFAULT_KEY_NUMBERS = 20
current_db = 0

def activeExpireCycle():
    if server.dbnum < DEFAUKT_DB_NUMBERS:
        db_numbers = server.dbnum
    else:
            db_numbers = DEFAULT_DB_NUMBERS
       
    for i in range(db_numbers):
        if current_db == server.dbnum:
            current_db = 0
           
        redisDb = server.db[current_db]
        current_db += 1
        
        for j in range(DEFAULT_KEY_NUMBERS):
            if redisDb.expires.size() == 0:
                break
                
            key_with_ttl = redisBb.expires.get_random_key()
            if is_expired(key_with_ttl):
                delete_key(key_with_ttl)
                
            if reach_time_limit(): 
                return
        

activeExpireCycle的工作模式总结如下:

  • 函数运行时,会从一定数量的数据库中取出一定数量的随机键检查并删除。
  • 全局变量current_db记录当前检查的进度,并在下一次调用时接着处理上一次的进度。
  • 随着activeExpireCycle的不断执行,所有数据库都会被检查一遍,这是current_db重置为0,再次开始新一轮动机检查。

AOF、RDB和复制功能对过期键的处理

RDB文件生成和载入

执行SAVE或BGSAVE命令时会创建一个新的RDB文件,已过期的键不会保存到RDB中。

在启动服务器时,如果开启了RDB功能,服务器会载入RDB文件:

  • 如果服务器以主服务器模式运行,那么载入RDB时,会检查文件中的键,过期键会被忽略。
  • 如果服务器以从服务器模式运行,那么载入RDB时,不管键是否过期,一律载入。其后,在主从服务器同步时,从服务器的数据库就会被清空。

AOF文件写入和重写

服务器以AOF持久化模式运行时,如果某个键已过期,但还没有被删除,那么AOF文件不会因为这个过期键而产生任何影响。但过期键被删除后,程序会向AOF文件追加一条DEL命令,显式记录该键已被删除。

AOF重写过程中,程序会对键进行检查,已过期的键不会被保存到重写后的AOF文件中。

复制

当服务器处于复制模式下时,过期键删除动作由主服务器控制,这就保证了一致性:

  • 主服务器删除一个过期键后,显式向从服务器发送DEL命令
  • 从服务器执行客户端发送的杜明令时,即使碰到过期键也不会删除,而是像初期未过期的键一样
  • 从服务器接到主服务器的DEL命令后,才会删除过期键

数据库通知

数据库通知是Redis 2.8新增加的功能,让客户端通过订阅可给定的频道或模式,来获取数据库中键的变化,以及数据库命令的执行情况。

"某个键执行了什么命令"的通知成为「键空间通知」。"某个命令被什么键执行了"是「键时间通知」。服务器配置的notify-keyspace-events选项决定了服务器发送通知的类型。

发送通知的功能由notify.h/notifyKeyspaceEvent函数实现的:

csharp 复制代码
void notifyKeyspaceEvent(int type, char *event, int dbid);

伪代码如下:

vbnet 复制代码
def notifyKeyspaceEvent(type, event, key, bdid):
    if not (server.notify_keyspace_events & type):
        return
    
    # 发送键空间通知
    if server.notify_keyspace_events & REDIS_NOTIFY_KEYSPACE:
        # 将通知发送给频道 __keyspace@<dbid>__:<key>
        chan = "_keyspace@{bdid}__:{key}".format(dbid_dbid, key=key)
        pubsubPublishMessage(chan, event)
    
    # 发送键时间通知
    if server.notify_keyspace_events & REDIS_NOTIFY_KEYEVENT:
        chan = "_keyspace@{bdid}__:{event}".format(dbid_dbid, event=event)
        pubsubPublishMessage(chan, event)
        pubsubPublishMessage(chan, key)

RDB持久化

RDB持久化可将内存中的数据库状态保存到磁盘上,避免数据丢失。持久化可以手动,也可以根据服务器配置选项定期执行。

RDB持久化生成的RDB文件是一个压缩过的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。

RDB文件的创建与载入

有两个命令可以生成RDB文件:

  1. SAVE。该命令会阻塞Redis服务器进程,直到RDB文件创建完毕,期间拒绝任何命令请求。
  2. BGSAVE。派生出一个子进程来创建RDB文件,服务器进程(父进程)继续处理命令请求。

在BGSAVE命令执行期间,服务器处理SAVE、GBSAVE、BGREWRITEAOF命令会被拒绝执行。

创建RDB文件的操作由rdb.c/rdbSave函数完成。

RDB文件的载入工作在服务器启动时自动执行。

另外,AOF文件的更新频率比RDB文件要高,所以:

  • 如果服务器开启了AOF,那么优先用AOF来还原数据库。
  • 只有在AOF关闭时,服务器才会用RDB来还原数据库。

载入RDB文件的工作由rdb.c/rdbLoad函数完成。载入RDB文件期间,服务器一直处于阻塞状态。

自动间隔性保存

Redis允许用户通过设置服务器配置的save选项,每隔一段时间执行一次BGSAVE命令。配置如下:

save 900 1

save 300 10

save 60 10000

那么上述三个条件只要满足任意一个,BGSAVE命令就会被执行:

  1. 服务器在900秒内,对服务器进行了至少1次修改。
  2. 服务器在300秒内,对服务器进行了至少10次修改。
  3. 服务器在60秒内,对服务器进行了至少10000次修改。

当Redis服务器启动时,用户可以指定配置文件或者传入启动参数的方式设置save选项。如果没有主动设置,服务器默认使用上述三个条件。接着,服务器会根据save的条件,设置redisServer结构的saveParams属性。

arduino 复制代码
struct redisServer {
  // ...
  struct saveparam *saveparams; // 保存条件的数组
  long long dirty;
  time_t lastsave;
  //...
}

struct saveparam {
  time_t seconds; // 秒数
  int changes; // 修改数
}

除此之外,服务器还维持着一个dirty计数器,以及一个lastsave属性。

  • dirty记录上一次成功SAVEBGSAVE之后,服务器对数据库状态进行了多少次修改。
  • lastsave是一个UNIX时间戳,记录了服务器上一次成功SAVEBGSAVE的时间。

检查保存条件是否满足

服务器的周期性操作函数serverCron默认每个100毫秒就会执行一次,其中一项工作是检查save选项所设置的保存条件是否满足。

RDB文件结构

RDB文件的各个部分包括:

REDIS | db_version | databases | EOF | check_sum

REDIS

开头是REDIS部分,长度为5。保存了五个字符,以便载入时确认是否为RDB文件。

db_version

db_version长4字节,是一个字符串表示的整数,记录了RDB文件的版本号。

databases

databases部分包含了0个或多个数据库,以及各个数据库中的键值对数据。一个保存了0号和3号数据库的RDB文件如下:

REDIS | db_version | database 0 | database 3 | EOF | check_sum

每个非空数据库在RDB文件中都可保存为以下三部分:

SELECTDB | db_number | key_value_pairs

  • SELECTEDB。1字节。但程序遇到这个值的时候,它就知道接下来要读入的将是一个数据库号码。
  • db_number。读取号码之后,服务器会调用SELECT命令切换数据库。
  • key_value_pairs。不带过期时间的键值对在RDB文件中包括TYPE、key、value。TYPE的值决定了如何读入和解释value的数据。带过期时间的键值对增加了EXPIRETIME_MS和ms。前者告知程序接下来要读入一个UNIX时间戳。

EOF

长度为1字节,标识RDB文件结束。

check_sum

8字节的无符号整数,保存着一个前面四个部分的校验和。

AOF持久化

AOF(Append Only File)持久化,与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF保存Redis所执行的写命令来记录数据库状态。被写入AOF文件的命令都是以Redis的命令请求协议格式保存的,纯文本格式,打开即可查看。

AOF持久化的实现

AOF持久化功能的实现可分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

命令追加

如果打开AOF功能,服务器在执行完一个写命令后,会以协议格式将被执行的命令追加到服务器状态的aof_buf缓冲区的末尾。

arduino 复制代码
struct redisServer {
  // ...
  sds aof_buf;
  // ...
};

AOF文件的写入与同步

Redis的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接受客户端的请求,并向客户端发送回复,而时间事件则负责执行像serverCron函数这样的定时任务。

服务器在处理文件任务时可能会执行写命令,追加内容到aof_buf缓冲区,所以服务器在每次结束一个事件循环前,都会调用flushAppendOnlyFile,考虑是否将缓冲区的内容写入到AOF文件中。

flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定:always、everysec(默认)、no。

AOF文件的载入与数据还原

服务器只要读入并重新执行一遍AOF文件中的写命令,就可以还原服务器关闭之前的数据库状态:

  1. 创建一个不带连接的伪客户端
  2. 从AOF文件中分析并读取一条写命令。
  3. 使用伪客户端执行被读出的命令
  4. 一直执行步骤2和3,直到AOF文件中的所有命令都被处理完位置。

AOF重写

为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能。通过该功能,Redis可以创建一个新的AOF文件来替代现有的AOF文件,新文件不会包含荣誉命令,体积也会小很多。

实现

AOF文件重写不需要对现有AOF文件做任何读取、分析或写入操作,而是通过读取服务器当前的数据库状态实现的。首先从数据库中读取现在的键,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令。这就是AOF重写的实现原理。

Redis服务器采用单个线程来处理命令请求,所以将AOF重写程序放到子进程中,这样父进程可以继续处理请求。父子进程会出现数据不一致的问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在创建子进程之后开始使用,但Redis服务器执行完一个写命令后,会通知将写命令发送给AOF缓冲区和AOF重写缓冲区。子进程完成AOF重写操作后,向父进程发送一个信号,父进程将执行以下操作:

  1. 将AOF重写缓冲区的内容写入新AOF文件。
  2. 对新的AOF文件改名,覆盖现有的AOF文件。

事件

Redis服务器是一个事件驱动程序,需要处理以下两类事件:

  • 文件事件(file event):Redis服务器通过socket与客户端连接,文件事件就是对套接字操作的对象。服务器与客户端的通信会产生相应的文件事件,服务器监听并处理这些事件来完成一系列的网络通信操作。
  • 时间事件(time event):Redis服务器的一些操作(如serverCron函数)需要在特定时间点执行,时间事件就是对这类定时任务的抽象。

文件事件

Redis基于Reactor模式开发了自己的网络事件处理器,称为『文件事件处理器』,文件事件处理器以单线程方式运行。

文件事件处理器的四个组成部分:

  • 套接字。

    • 当被监听的套接字准备好执行accept、read、write、close等操作时,与操作相对应的文件事件就会产生。
  • I/O多路复用程序。

    • 使用I/O多路复用程序同时监听多个套接字,并向文件分派器传送那些产生了事件的套接字(使用队列)。
  • 文件事件分派器

    • 根据套接字的事件类型,调用相应的事件处理器。
  • 事件处理器

I/O多路复用程序的实现

Redis的I/O多路复用包装了常见的select、poll、evport和kqueue等函数库来实现的,每个函数库的在Redis源码中都有一个独立的文件。

事件的类型

I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE和ae.h/AE_WRITABLE事件。两种事件可以同时监听,但会优先处理AE_READABLE事件。

文件事件的处理器

Redis为文件事件编写了多个处理器,分别用于实现不同的网络通信需求:

  • 连接应答处理器:监听客户端的套接字,并应答。

    • networking.c/acceptTcpHandler函数,具体实现为sys/socket.h/accept函数的包装。服务器初始化时,会将这个处理器与套接字的AE_READABLE事件关联起来。
  • 命令请求处理器:接受来自客户端的命令请求。

    • networking.c/readQueryFromClient函数,具体实现为unistd.h/read函数的包装。
  • 命令回复处理器:向客户端返回命令的执行结果。

    • networking.c/sendReplyToClient函数,具体实现为unistd.h/write函数的包装。
  • 复制处理器:主从服务器的复制操作。

时间事件

Redis的时间事件分为两类:

  • 定时事件:在指定一段时间后执行一次。
  • 周期性事件:每隔一段时间就执行一次。

时间事件主要有三个属性:

  • id
  • when:毫秒进度的UNIX时间戳,事件的到达时间。
  • timeProc:时间事件处理器,事件到达时,负责处理事件。

一个事件是定时事件还是周期性事件,取决于时间事件处理器的返回值:

  • 返回ae.h/AE_NOMORE就是定时事件,到达一次后就删除
  • 返回非AE_NOMORE的整数值就是周期性事件,事件到达后,根据返回值对when属性进行更新。

实现

服务器的所有时间事件存放在一个无序链表(不按when属性排序)中,每当时间事件处理器运行时,遍历整个链表,找到已到达的事件,调用相应的事件处理器。

serverCron函数

serverCron函数的工作包括:

  • 更新服务器的统计信息,如时间、内存占用、数据库占用
  • 清理过期的键值对
  • 关闭和清理失效的连接
  • 尝试AOF或RDB持久化
  • 如果是主服务器,对从服务器定期同步
  • 如果是集群模式,对集群进行同步和测试连接

事件的调度与执行

调度和执行由ae.c/aeProcessEvents函数负责。

ini 复制代码
def aeProcessEvents():
    # 获取最近的事件
    time_event = aeSearchNearestTimer()
    
    # 计算最近的事件还有多少毫秒
    remaind_ms = time_event.when - unix_ts_now()
    
    # 如果事件已到达
    if remaind_ms < 0:
        remaind_ms = 0
        
    # 根据remaind_ms的值,创建timeval结构
    timeval = create_timeval_with_ms(remaind_ms)
    
    # 阻塞并等待文件事件
    # 如果remaind_ms为0,那么aeApiPoll调用之后马上返回,不阻塞
    aeApiPoll(timeval)
    
    # 处理所有已产生的文件事件
    processFileEvents()
    
    # 处理所有已到达的时间事件
    processTimeEvents()

调度和执行的规则如下:

  • aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的事件决定,避免服务器的频繁轮询。
  • 如果处理完一次文件事件后,未有时间事件到达,则再次处理文件事件。
  • 对事件的处理都是同步、有序、原子地执行。不会中断、抢占事件处理。
  • 时间事件的处理时间,通常比其设定的到达时间晚一些。

客户端

Redis服务器为客户端建立了相应的redis.h/redisClient结构,保存了客户端的当前信息,以及执行相关功能需要的数据结构:

  • 客户端的套接字描述符
  • 客户端的名字
  • 客户端的标志值(flag)
  • 客户端正在使用的数据库的指针及号码
  • 客户端当前要执行的命令、参数
  • 客户端的输入输出缓冲区
  • 客户端的复制状态信息
  • 客户端的事务状态
  • 客户端执行发布与订阅功能用到的数据结构
  • 客户端的身份验证标识
  • 客户端的统计信息,如创建时间、最后一次通行时间、缓冲区大小超出限制的时间

redisServer结构保存了一个clients链表,保存了所有连接的客户端的状态信息。

arduino 复制代码
struct redisServer {
  // ...
  list *clients;
  redisClient *lua_client; // Lua伪客户端,服务器运行时一直存在
  // ...
}

客户端的属性

arduino 复制代码
typedef struct redisClient {
   /* 
   fd记录客户端正在使用的套接字描述符
   伪客户端的fd为-1,不需要套接字连接,目前用于 1. AOF还原, 2. 执行Lua脚本的Redis命令
   普通客户端为大于-1的整数。CLIENT list命令可以查看当前正在使用的套接字描述符
   */
  int fd;
  
  // 连接到服务器的客户端默认没有名字,CLIENT setname可以设置一个名字。
  robj *name;
  
  /*
  flags记录了客户端的role,以及目前所处的状态
  所以,flags可以是多个二进制或,所有标志在redis.h中定义
  */
  int flags;
  
  // 输入缓冲区用于保存客户端发送的命令请求
  sds querybuf;
  
  // 解析querybuf的请求,得出命令参数及命令个数
  // argv是个数组,每个元素都是一个字符串对象,其中argv[0]是要执行的命令
  robj **argv;
  int argc;
  
  // redisCommand保存了命令的实现函数,标识、参数个数、总执行次数等统计信息
  struct redisCommand *cmd;
  
  // 输出缓冲区保存命令的回复,其中
  // 1. buf是固定缓冲区,用于保存长度较小的回复
  // 2. reply可变缓冲区,保存长度较大的回复
  char bug[REDIS_REPLY_CHUNK_BYTES];
  int bufpos;
  list *reply;
  
  // 记录客户端是否通过了验证
  int authenticated;
  
  time_t ctime;
  time lastinteraction;
  time_t obuf_soft_limit_reached_time;
  
  // ...
} redisClient;

客户端的创建与关闭

创建客户端

客户端使用connect函数连接到服务器,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并添加到链表的末尾。

关闭客户端

一个普通客户端可因为多种原因关闭:

  • 客户端进程被杀死
  • 发送的协议不符合格式
  • 客户端成了CLIENT KILL命令的目标
  • 服务器配置了timeout选项,客户端空转被断开
  • 超出输入/输出缓冲区限制

输出缓冲区的限制包括:硬性限制、弱性限制。超过软性限制一段时间,客户端也会被关闭。

服务器

Redis服务器负责与多个客户端建立连接,处理客户端的命令请求,在数据库中保存命令产生的数据,并通过资源管理来维持服务器自身的运转。

命令请求的执行过程

SET KEY VALUE命令的执行过程:

  1. 客户端向服务器发送命令请求SET KEY VALUE
  2. 服务器接收并处理命令请求,在数据库中设置操作,并产生命令回复OK
  3. 服务器将OK发送给客户端。
  4. 客户端接收服务器返回的命令OK,并打印给用户。

发送命令请求

用户:键入命令请求

客户端:将命令请求转换为协议格式然后发送给服务器

读取命令请求

当连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器执行以下操作:

  1. 读取套接字协议格式中的命令请求,并将其保存在客户端状态的输入缓冲区里。
  2. 对输入缓冲区的命令请求进行分析,提取命令参数及其个数,保存到客户端状态的argv和argc属性。
  3. 调用命令执行器,执行指定的命令。

命令执行器(1):查找命令实现

命令执行器要做的第一件事是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数指定的命令,并将其保存到客户端状态的cmd属性里。

命令表是一个字典,键是命令名字,值是一个redisCommand结构。命令表使用的是大小写无关的查找算法。

命令执行器(2):执行预备操作

有了执行命令所需的命令实现函数、参数、参数个数,但程序还需要一些预备操作:

  • 检查客户端状态的cmd指针是否为NULL
  • 根据cmd属性指向redisCommand结构的arity属性,检查命令请求的参数个数是否正确。
  • 检查客户端是否通过了身份验证,未通过必须使用AUTH命令。
  • 如果服务器打开了maxmemory功能,检查内存占用情况,有需要时进行内存回收。
  • 如果上一次BGSAVE出错,且服务器打开了stop-writes-on-bgsave-error功能,且服务器要执行一个写命令,拒绝执行。
  • 如果客户端正在用SUBSCRIBE订阅频道,服务器只会执行订阅相关的命令。
  • 如果服务器正在进行输入载入,那么客户端发送的命令必须带有1标识才能被执行。
  • 如果服务器因为Lua脚本而超时阻塞,那么服务器只会执行客户端发来的SHUTDOWN nosaveSCRIPT KILL命令。
  • 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXECDISCARDMULTIWATCH命令,其余命令进入事务队列。
  • 如果服务器打开监视器功能,要将执行的命令和参数等信息发给监视器,其后才真正执行命令。

命令执行器(3):调用命令的实现函数

client->cmd->proc(client);

相当于执行语句:

sendCommand(client);

命令回复会保存在输出缓冲区,之后实现函数还会为套接字关联命令回复处理器,将回复返回给客户端。

命令执行器(4):执行后续工作

  • 如果开启了慢查询,添加新的日志。
  • redisCommand结构的calls计数器+1。
  • 写入AOF缓冲区。
  • 同步从服务器。

将命令回复发送给客户端

当客户端套接字变为可写时,服务器将输出缓冲区的命令发送给客户端。发送完毕后,清空输出缓冲区。

客户端接收并打印命令回复

服务器:回复处理器将协议格式的命令返回给客户端。

客户端:将回复格式化成人类可读的格式,打印。

serverCron函数

更新服务器时间缓存

每次获取系统的当前时间都要执行一次系统调用,为了减少系统调用,服务器状态中保存了当前时间的缓存:

arduino 复制代码
struct redisServer {
  // 秒级的系统当前UNIX时间戳
  time_t unixtime;
  // 毫秒级的系统当前UNIX时间戳
  long long mstime;
};

serverCron默认会100毫秒更新一次这两个属性,所以它们的精确度并不高。对于一些高精度要求的操作,还是会再次执行系统调用。

更新LRU时钟

arduino 复制代码
struct redisServer {
  // 默认10秒更新一次的时钟缓存,用于计算键的空转时长
  // INFO server可查看
  unsigned lruclock:22;
};

// 每个Redis对象都有一个lru属性,计算键的空转时长,就是用服务器的lruclock减去对象的lru时间
typedef struct redisObject {
  unsigned lru:22;
} robj;

更新服务器每秒执行命令次数

serverCron函数中的trackOperationPerSecond函数以每100毫秒一次的频率执行,该函数以抽样计算的方式,估算并记录服务器在最近一秒内处理的命令请求数量,这个值可以用过INFO status命令查看。

arduino 复制代码
struct redisServer {
  // 上一次抽样的时间
  long long ops_sec_last_sample_time;
  
  // 上一次抽样时,服务器已执行命令的数量
  long long ops_sec_last_sample_ops;
  
  // REDIS_OPS_SEC_SAMPLES 大小默认16
  // 环形数组中的每个项记录了一次抽样结果
  long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];
  
  // ops_sec_samples 数组的索引值,每次抽样后自动+1
  // 让 ops_sec_samples 数组构成一个环形数组
  int ops_sec_idx;
};

客户端执行INFO命令,服务器会调用getOperationsPerSecond函数,根据ops_sec_samples中的抽样结果,计算出instantaneous_ops_per_sec属性的值。

更新服务器内存峰值记录

arduino 复制代码
struct redisServer {
  // 已使用内存峰值
  size_t stat_peak_memory;
};

每次serverCron执行,程序都会查看当前的内存数量,更新stat_peak_memoryINFO memory可查看。

处理SIGTERM信号

启动时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数。它在接到该信号后,打开服务器状态的shutdown_asap标识。每次serverCron执行,程序都会检查该标识,并决定是否关闭服务器。

arduino 复制代码
struct redisServer {
  // 关闭服务器的标识:1,关闭;2,不做操作。
  int shutdown_asap;
};

管理客户端资源

serverCron每次都会调用clientsCron函数,后者会对一定数量的客户端作如下检查:

  • 连接是否超时
  • 输入缓冲区是否超过长度,如果是,新建缓冲区

管理数据库资源

serverCron每次都会调用databasesCron函数,检查一部分的数据库,删除过期键,对字典进行收缩等。

执行被延迟的BGREWRITEAOF

服务器执行BGSAVE期间,会阻塞BGREWRITEAOF命令。

arduino 复制代码
struct redisServer {
  // 记录是否有BGREWRITEAOF被延迟
  int aof_rewrite_scheduled;
};

检查持久化操作的运行状态

arduino 复制代码
struct redisServer {
  // 记录执行BGSAVE命令的子进程ID
  // 如果服务器没有执行BGSAVE,值为-1
  pid_t rdb_child_pid;
  
  // 记录执行BGREWRITEAOF命令的子进程ID
  pid_t aof_child_pid;
};

serverCron执行时,只要两个属性有一个为-1,则执行wait3函数,检查是否有信号发来服务器进程:

  • 如果有信号达到,表明新的RDB文件生成完毕,或AOF文件重写完毕,服务器需要执行相应命令的后续操作
  • 没有信号就不做操作

如果两个属性都不为-1,表明服务器没有再做持久化操作,则:

serverCron其他操作

  • 将AOF缓冲区的内容写入AOF文件
  • 关闭异步客户端(超出输入缓冲区限制)
  • 增加cronloops计数器(它的唯一作用就是复制模块中实现『每执行serverCron函数N次就执行一次指定代码』的功能")

初始化服务器

初始化服务器状态结构

初始化服务器的第一步就是创建一个redisServer类型的实例变量server,并为结构中的各个属性设置默认值。这个工作由redis.c/initServerConfig函数完成:

  • 设置服务器运行id
  • 为id加上结尾字符
  • 设置默认的配置文件路径
  • 设置默认服务器频率
  • 设置服务器的运行架构,64位 or 32位
  • 设置服务器的默认端口
  • 设置服务器的默认RDB和AOF条件
  • 初始化服务器的LRU时钟
  • 创建命令表

载入配置选项

启动服务器时,用户可以通过配置参数或者配置文件来修改服务器的默认配置。

redis.c/initServerConfig函数初始化完server变量后,开始载入用户给定的配置。

初始化服务器数据结构

载入用户的配置选项之后,才能正确地初始化数据结构,由initServer函数负责:

  • server.clients链表
  • server.db数组
  • server.pubsub_channels字典
  • server.luaLua环境
  • server.slowlog

除此之外,initServer还:

  • 为服务器设置进程信号处理器
  • 创建共享对象
  • 打开服务器的监听端口,并为套接字关联应答事件处理器
  • serverCron函数创建时间事件
  • 打开或创建的AOF文件
  • 初始化后台I/O模块

还原数据库状态

初始化完server后,服务器要载入RDB或AOF文件,还原数据库状态

执行事件循环

开始执行服务器的loop。

多级数据库的实现

复制

Redis中,用户可以执行SAVEOF命令或设置saveof选项,让一个服务器去复制(replicate)另一个服务器。被复制的服务器叫做master,对master进行复制的服务器叫做slave。

进行复制中的master和slave应该保存相同的数据,这称作"数据库状态一致"。

旧版复制功能的实现

Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作:

  • 同步用于将slave的数据库状态更新至master当前所处的数据库状态。
  • 命令传播用于master的数据块状态被修改,导致和lsave的数据库状态不一致时,让两者的数据库重回一致状态。

同步

复制开始时,slave会先执行同步操作,步骤如下:

  • slave对master发送SYNC命令
  • master收到SYNC执行BGSAVE,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
  • master的BGSAVE执行完毕后,将生成的RDB文件发送给slave,slave接收并载入这个RDB,更新自己的数据库状态
  • master将记录在缓冲区中的所有写命令发送给slave,后者执行这些操作,再次更新自己的数据库状态

命令传播

同步完成后,主从服务器的一致状态仍有可能改变,每当master执行写命令时,主从服务器的状态就会不一致。为此,master执行写命令,并将其发送给slave一并执行。

旧版复制功能的缺陷

Redis的复制可以分为两种情况:

  • 初次复制:slave没有复制过,或者slave要复制的master和上一次复制的master不同。
  • 断线后重复制:处于命令传播阶段的master和slave中断了复制,但重连后,slave继续复制master。

对于初次复制,旧版复制功能可以很好完成。但是断线后复制,效率却很低,因为重连后会浪费一次SYNC操作。

新版复制功能的实现

为了解决旧版复制功能在断线后的低效问题,Redis从2.8之后,使用PSYNC代替SYNC执行复制时的同步操作。PSYNC具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

  • 完整重同步用于处理初次复制,执行步骤和SYNC命令基本一样。
  • 部分重同步用于处理断线后重复制,重连后,如果条件允许,master可以将断开期间的谢明令发送给slave执行。

部分重同步的实现

部分重同步功能有三个部分组成:

  • master和slave的复制偏移量(replication offset)
  • master的复制积压缓冲区(replication backlog)
  • 服务器的运行ID(run ID)

复制偏移量

master和slave分别维护一个复制偏移量:

  • master每次向slave传播N个字节的数据时,就将自己的复制偏移量+N。
  • slave每次收到master的N个字节数据时,就将自己的复制偏移量+N。

对比两者的复制偏移量,就知道它们是否处于一致状态。

复制积压缓冲区

复制积压缓冲区是master维护的一个固定长度的FIFO队列,默认大小为1MB。当服务器进行命令传播时,不仅会将命令发送给所有slave,还会入队到积压缓冲区。因此,积压缓冲区保存了最近被传播的写命令,且为队列中的每个字节记录相应的复制偏移量。

slave重连上master时,slave通过PSYNC将自己的复制偏移量offset发送给master,master会根据这个offset决定slave执行何种同步操作:

  • 如果offset之后的数据仍在复制积压缓冲区中,执行部分重同步操作。
  • 否则,执行完整重同步操作。

服务器运行ID

部分重同步还要用到服务器运行ID,主从服务器都有自己的ID。初次复制时,master将自己的ID传给slave,后者将其保存。

断线重连后,slave向当前连接的master发送之前保存的ID:

  • master发现接收的ID和自己的相同,那么说明断线之前复制的就是自己,继续执行部分重同步。
  • 如果不同,完整重同步啦!

PSYNC命令的实现

PSYNC的调用方式有两种:

  • slave没有复制过任何master,则在开始一个新的复制时向master发送PSYNC ? -1命令,请求完整重同步。
  • slave复制过某个master,则发送PSYNC <runid> <offset>命令,接收到这个命令的master会根据runidoffset来判断执行哪种同步。

复制的实现

通过向slave发送SLAVEOF命令,可以让slave复制master

步骤1:设置master的地址和端口

命令slave 127.0.0.1 6379会设置服务器状态的以下两个属性:

arduino 复制代码
struct redisServer {
  char *masterhost;
  int masterport;
};

步骤2:建立套接字连接

如果slave的套接字能成功连接到master,那么slave会为这个套接字关联一个专门用于处理复制工作的文件事件处理器,它将负责处理后续的复制工作。

master接收到客户端的套接字连接之后,为其创建相应的客户端状态,这时slave同时有server和client两个身份。

步骤3:发送PING命令

slave成为master的客户端之后,紧接着就向其发送PING命令,那么:

步骤4:身份验证

收到master的"PONG"回复后,slave要检查自己的masterauth选项决定是否进行身份验证。如果需要验证,slave会向master发送一条AUTH命令,参数为masterauth选项的值,接下来:

步骤5:发送端口消息

身份验证之后,slave将执行REPLCONF listening-port <port-number>,向master发送slave的监听端口号。master收到后,会将端口号放到客户端状态的slave_listening_port属性中该属性的唯一作用就是master执行INFO replication命令时打印slave的端口号。

arduino 复制代码
typdef struct redisClient {
  int slave_listening_port;
} redisClient;

步骤6:同步

这一步,slave发送PSYNC,执行同步操作。执行同步之后,master也成了slave的客户端,master发送写命令来改变slave的数据库状态。

步骤7:命令传播

完成同步之后,主从服务器就进入命令传播阶段,master将自己执行写命令发送给slave,slave接到后就执行,这样两者的状态就一直保持一致了。

心跳检测

命令传播阶段,slave默认每秒给master发送一次命令:REPLCONF ACK <replication_offset>,其中replication_offset对应当前slave的复制偏移量。该命令有三个作用:

  • 检测网络连接状态

    • 辅助实现min-slaves选项
  • 该选项防止master在不安全的情况下执行写命令,比如slave数量小于3的时候。

    • 检测命令丢失
  • 这个根据复制偏移量来判断,如果两者不一致,master就会把复制积压缓冲区的命令重新发送。

Sentinel

Sentinel(哨兵)是Redis的高可用性解决方案,由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个master以及属下的所有slave。Sentinel在被监视的master下线后,自动将其属下的某个slave升级为新的master,然后由新的master继续处理命令请求。

启动并初始化Sentinel

启动一个Sentinel可以使用命令:

redis-sentinel sentinel.conf

或者

redis-server sentnel.conf ---sentinel

当一个Sentinel启动时,会执行以下几步:

  1. 初始化服务器
  2. 将普通Redis服务器使用的代码替换成Sentinel专用代码
  3. 初始化Sentinel状态
  4. 根据配置文件,初始化监视的master列表
  5. 创建与master的网络连接

初始化服务器

Sentinel本质上是一个运行在特殊模式下的Redis服务器,它的初始化过程与普通Redis服务器并不相同:

功能 Sentinel使用情况
数据库和键值对方面的命令:SET, DEL, FLUSHDB 不使用
事务命令 不使用
脚本命令 不使用
RDB和AOF持久化 不使用
复制命令 Sentinel内部使用,客户端不可用
发布、订阅命令 订阅命令可在Sentinel内部和客户端使用,发布命令只能在Sentinel内部使用
文件事件处理器(发送命令请求,处理命令回复) Sentinel内部使用
时间事件处理器 Sentinel内部使用,serverCron会用sentinel.c/sentinelTimer函数

使用Sentinel专用代码

将一部分普通Redis服务器的代码替换为Sentinel专用代码,比如端口号,命令表。

初始化Sentinel状态

接下来,服务器会初始化一个sentinel.c/sentinelState结构,它保存了服务器有关Sentinel的状态:

arduino 复制代码
struct sentinelState {
  // 当前纪元,用于实现故障转移
  uint64_t current_epoch;
  
  // 保存了所有被监视的master,键是master名字,值是指向 sentinelRedisInstance 结构的指针
  dict *masters;
  
  // 是否进入TILT模式
  int tilt;
  
  // 目前正在执行的脚本数量
  int runing_scripts;
  
  // 进入TILT模式的时间
  mstime_t tilt_start_time;
  
  // 最后一次执行时间处理器的时间
  mstime_t previous_time;
  
  // FIFO队列,包含所有需要执行的用户脚本
  list *scripts_queue;
} sentinel;

初始化Sentinel状态的masters属性

sentinelRedisInstance结构代表一个被监视的Redis服务器实例,可以是master、slave、或者另一个Sentinel。

arduino 复制代码
typedef struct sentinelRedisInstance {
  // 标识符,记录了实例的类型,及其当前状态
  int flags;
  
  // 实例的名字,master的名字由用户配置,slave和Sentinel的名字自动配置
  // 格式为 ip: port
  char *name;
  
  // 实例的运行ID
  char *runid;
  
  // 配置计院,用于实现故障转移
  uint64_t config_epoch;
  
  // 实例的地址
  sentinelAddr *addr;
  
  // SENTINEL down-after-milliseconds 选项设定的值
  // 实例无响应多少毫秒后才会判断为主观下线(subjectively down)
  mstime_t down_after_periods;
  
  // SENTINEL monitor <master-name> <IP> <port> <quorum> 选项的quorum参数
  // 判断这个实例是否为客观下线(objectively down)所需的支持投票数量
  int quorum;
  
  // SENTINEL parallel-sycs <master-name> <number>选项的值
  // 在执行故障转移时,可以同时对新的master进行同步的slave数量
  int parallel_syncs;
  
  // SENTINEL failover-timeout <master-name> <ms>选项的值
  // 判断故障转移状态的最大时限
  mstime_t failover_timeout;
} sentinelRedisInstance;

sentinelRedisInstance.addr指向一个sentinel.c/sentinelAddr结构,它保存着实例的IP地址和端口号:

arduino 复制代码
typedef struct sentinelAddr {
  char *ip;
  int port;
} sentinelAddr;

创建与master的网络连接

连接建立后,Sentinel将成为master的客户端,可以向其发送命令。对于被监视的master来说,Sentinel会创建两个异步网络连接:

  • 命令连接,用于发送和接收命令。
  • 订阅连接。用于订阅master的__sentinel__:hello频道。

获取master信息

Sentinel以默认10秒一次的频率,向master发送INFO命令,获取其当前信息:

  • master本身的信息,包括运行ID、role等。据此,Sentinel更新master实例的结构。
  • master的slave信息。据此,Sentinel更新master实例的slaves字典。

获取slave信息

Sentinel发现master有新的slave时,除了会为这个slave创建相应的实例结构外,还会创建到它的命令连接和订阅连接。

通过命令连接,Sentinel会向slave每10秒发送一次INFO命令,根据回复更新slave的实例结构:

  • slave的运行ID
  • slave的角色role
  • master的地址和端口
  • 主从的连接状态
  • slave的优先级
  • slave的复制偏移量

向master和slave发送信息

默认情况下,Sentinel会以两秒一次的频率,通过命令连接向所有被监视的master和slave发送:

PUBLISH sentinel:hello "<s_ip>, <s_port>, <s_runid>, <s_epoch>, <m_name>, <m_ip>, <m_port>, <m_epoch>"

其中以s_开头的参数表示Sentinel本身的信息,m_开头的参数是master的信息。如果Sentinel正在监视的是slave,那就是slave正在复制的master信息。

接收来自master和slave的频道信息

当Sentinel与一个master或slave建立订阅连接后,会向服务器发送以下命令:

SUBSCRIBE sentinel:hello

Sentinel对__sentinel__:hello频道的订阅会持续到两者的连接断开为止。也就是说,Sentinel既可以向服务器的__sentinel__:hello频道发送信息,又通过订阅连接从__sentinel__:hello频道接收信息。

对于监视同一个server的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel收到。这些信息用于更新其他Sentinel队发送信息Sentinel和被监视Server的认知。

更新sentinels字典

Sentinel为master创建的实力结构中,有sentinels字典保存了其他监视这个master的Sentinel:

  • 键是Sentinel名字,格式为ip: port。
  • 值是Sentinel实例的结构。

当一个Sentinel收到其他Sentinel发来的信息时,目标Sentinel会从信息中提取出:

  • 与Sentinel有关的参数:源Sentinel的IP、端口、运行ID、配置纪元。
  • 与master有关的参数:master的名字、IP、端口、配置纪元。

根据提取的参数,目标Sentinel会在自己的Sentinel状态中更新sentinels和masters字典。

创建连向其他Sentinel的命令连接

Sentinel通过频道信息发现一个新的Sentinel时,不仅会为其创建新的实例结构,还会创建一个连向新Sentinel的命令连接,新的Sentinel也会创建连向这个Sentinel的命令连接,最终,监视同一master的多个Sentinel成为相互连接的网络。各个Sentinel可以通过发送命令请求来交换信息。

检测主观下线状态

默认情况下,Sentinel会每秒一次地向所有与它创建了嘛命令连接的实例(master、slave、其他sentinel)发送PING命令,并通过回复来判断其是否在线。只有+PONG/-LOADING/-MASERDOWN三种有效回复。

Sentinel的配置文件中down-after-milliseconds选项指定了判断实例主观下线所需的时间长度。在down-after-milliseconds毫秒内,如果连续返回无效回复,那么Sentinel会修改这个实例对应的实例结构,将flags属性中打开SRI_S_DOWN标识,标识主观下线。

注意:多个Sentinel设置的down-after-milliseconds可能不同。

检查客观下线时长

当Sentinel将一个master判断为主观下线后,为了确认是真的下线,会向监视这一master的其他Sentinel询问。有足够数量(quorum)的已下线判断后,Sentinel会将master判定为客观下线,并对master执行故障转移。

选举领头Sentinel

master被判定为客观下线后,监视这个master的所有Sentinel会进行协商,选举一个领头Sentinel,并由其对该master执行故障转移。选举的规则如下:

  • 所有Sentinel都可以成为领头。
  • 每次进行领头Sentinel选举后,不论选举是否成功,所有Sentinel的配置纪元都会+1。这个配置纪元就是一个计数器。
  • 一个配置纪元里,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,且局部领头一旦设定,在这个配置纪元内就不可修改。
  • 每个发现master进入客观下线的Sentinel都会要求其他Sentinel将自己设为局部领头Sentinel。
  • 当一个Sentinel向另一个Sentinel发送SENTINEL is-master-down-by-addr,且命令中的runid参数是自己的运行ID,这表明源Sentinel要求目标Sentinel将他设置为局部领头。
  • Sentinel设置局部领头的规则是先到先得。
  • 目标Sentinel收到SENTINEL is-master-down-by-addr后,会返回一条命令回复,恢复中的leader_runidleader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
  • 源Sentinel收到目标Sentinel的回复后,检查回复中的leader_runidleader_epoch是否和自己相同。
  • 如果某个Sentinel被半数以上的Sentinel设置为局部领头,那么这个Sentinel就成为领头Sentinel。
  • 因为领头Sentinel需要半数以上的支持,且每个Sentinel在每个配置纪元里只设置一次局部领头,所以一个配置纪元里,只能有一个领头。
  • 如果给定时限内,没有产生领头Sentinel,那么各个Sentinel过段时间再次选举,知道选出领头为止。

故障转移

领头Sentinel会对已下线的master执行故障转移,包括以下三个步骤:

  • 从已下线master属下的所有slave选出一个新的master。
  • 让已下线master属下的所有slave改为新复制新的master。
  • 让已下线master成为新master的slave,重新上线后就是新slave。

选出新的master

新master的挑选规则:

  • 在线
  • 五秒内回复过领头Sentinel的INFO命令
  • 与已下线master在down-after-milliseconds毫秒内有过通信。
  • salve的自身有优先级
  • 复制偏移量最大

Sentinel向salve发送SLAVEOF no one命令将其转换为master。

修改slave的复制目标

同样通过SLAVEOF命令实现。

将旧的master变为slave

同样通过SLAVEOF命令实现。

集群

Redis集群是分布式的数据库方案,通过分片(sharing)来进行数据共享,并提供复制或故障转移功能。

节点

一个Redis集群通常由多个节点(node)组成。开始时每个node都是独立的,要将其连接起来:

CLUSTER MEET

启动节点

一个节点就是运行在集群模式下的Redis服务器,根据cluster-endabled配置选项是否为yes来决定是否开启集群模式。

节点在集群模式下会继续使用单机模式的组件,如:

  • 文件事件处理器
  • 时间事件处理器
  • 使用数据库来保存键值对数据
  • RDB和AOF持久化
  • 发布与订阅
  • 复制模块
  • Lua脚本

节点会继续使用redisServer结构保存服务器的状态,redisClient结构保存客户端的状态,集群模式下的数据,保存在cluster.h/clusterNodecluster.h/clusterLinkcluster.h/clusterState结构中。

集群数据结构

cluster.h/clusterNode保存了一个节点的当前状态,如节点的创建时间、名字、配置纪元、IP和端口等。每个节点都有一个自己的clusterNode结构,并为集群中的其它节点创建一个相应的clusterNode结构。clusterNode结构的link属性是一个clusterLink结构,保存了连接节点所需的有关信息,如套接字、缓冲区。

每个节点都有一个clusterState,记录了当前节点所在集群的状态。

arduino 复制代码
struct clusterNode {
  // 创建节点的时间
  mstime_t ctime;
  
  // 节点的名字,40个十六进制字符串
  char name[REDIS_CLUSTER_NAMELEN];
  
  // 节点标识,记录节点的角色(主从)、状态(在线或下线)
  int flags;
  
  // 当前的配置纪元
  uint64_t configEpoch;
  
 char ip[REDIS_IP_STR_LEN];
 int port;
  
  // 保存连接节点所需的有关信息
  clusterLink *link;
};

typedef struct clusterLink {
  // 连接的创立时间
  mstime_t ctime;
  
  // TCP 套接字描述符
  itn fd;
  
  // 输出缓冲区
  sds sndbuf;
  
  // 输入缓冲区
  sds recvbuf;
  
  // 与这个连接相关联的节点,没有就为NULL
  struct clusterNode *node;
} clusterLink;

typedef struct clusterState {
  // 指向当前节点的指针
  clusterNode *myself;
  
  // 集群当前的配置纪元,用于故障转移
  uint64_t currentEpoch;
  
  // 集群当前的状态:在线还是下线
  int state;
  
  // 集群中至少处理着一个槽的节点的数量
  int size;
  
  // 集群节点的名单,包括myself,键为节点的名字,值为节点对应的clusterNode结构
  dict *nodes;
} clusterState;

CLUSTER MEET命令的实现

通过向节点A发送CLUSTER MEET命令,客户端可以让接受命令的节点A将另一个节点B接入到A所在的集群中。

收到CLUSTER MEET命令的节点A,会进行以下操作:

  1. 为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典。
  2. 节点A根据CLUSTER MEET命令的IP和端口,先节点B发送MEET消息。
  3. 节点B收到MEET消息,为节点A创建一个clusterNode结构,并加入字典。
  4. 节点B回给节点A一条PONG消息。
  5. 节点A收到PONG,知道节点B已经接收了自己的MEET消息。
  6. 节点A向节点B返回一条PING消息。
  7. 节点B收到PING之后,双方握手完成。

槽指派

Redis集群通过分片的方式保存数据库中的键值对:集群中的整个数据库被分为16384个槽(slot),数据库中的每个键都属于其中的一个,集群中的每个节点可以处理0个或最多16384个槽。

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok),如果任何一个槽都没有得到处理,就处于下线状态(fail)。

CLUSTER MEET只是将节点连接起来,集群仍处于下线状态,通过向节点发送CLUSTER ADDSLOTS,可以为一个或多个槽指派(assign)给节点负责。

CLUSTER ADDSLOTS [slot ...]

记录节点的槽指拍信息

arduino 复制代码
struct clusterNode {
  unsigned char slots[16384/8];
  int numslots;
};

slots数组中的索引i上的二进制位的值来判断节点是否负责处理槽inumslots记录节点负责处理的槽的数量,即slots数组中二进制1的数量。

传播节点的槽指派信息

一个节点除了会将自己处理的槽记录在clusterNode结构中的slotsnumslots属性之外,还会将自己的slots数组通过消息发送给集群中的其它节点。

节点A通过消息从节点B接收到节点B的slots数组会,会在自己的clusterState.nodes字典中查找节点B对应的clusterNode结构,并对结构中的slots数组进行更新。

最终,集群中的每个节点都知道数据库中的16384个槽分别被指派给了哪些节点。

记录集群所有槽的指派信息

clusterState结构中的slots数组记录了所有16384个槽的指派信息:

ini 复制代码
typedef struct clusterState {
  clusterNode *slots[16384];
} clusterState;

如果槽指派信息只保存在各个节点的clusterNode.slots数组中,那么检查某个槽被指派给哪个节点,就需要遍历clusterState.nodes字典中的所有clusterNode结构。clusterState.slots数组就解决了这个问题。

反过来,纵然有了clusterState.slotsclusterNode.slots仍有必要:

  • 传播节点的槽指派信息时,只需要发送clusterNode.slots即可。

CLUSTER ADDSLOTS命令的实现

CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:

yaml 复制代码
def CLUSTER_ADDSLOTS(*all_input_slots):
    # 遍历所有输入槽,检查它们是否都是未指派
    for i in all_input_slots:
        
        # 如果有一个槽已指派,那么返回错误
        if clusterState.slots[i] != NULL:
            reply_error()
            return
        
    # 再次遍历
    for i in all_input_slots:
                
        # 设置clusterState结构的slots数组
        clusterState.slots[i] = clusterState.myself
        
        # 访问代表当前节点的clusterNode结构的slots数组
        setSlotBit(clusterState.myself.slots, i)
        

在集群中执行命令

客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的键属于哪个槽,并检查这个槽是否被指派给了自己:

  • 如果指派给了自己,节点直接执行命令。
  • 否则,节点向客户端返回一个MOVED错误,指引客户端转向(redirect)到正确的节点,再次发送命令。

计算键属于哪个槽

python 复制代码
def slot_number(key):
    return CRC16(key) & 16383

使用CLUSTER KEYSLOT <key>能查看键属于哪个槽。

判断槽是否由当前节点负责处理

节点计算出键所属的槽i之后,会检查自己在clusterState.slots数组中的第i项,判断键所在的槽是不是自己负责。

MOVED错误

MOVED错误的格式为:

MOVED :

客户端通常会与集群中的多个节点创建套接字连接,所谓的节点转向就是换一个套接字来发送命令。

节点数据库的实现

节点与单击服务器的一个区别是:节点只能使用0号数据库。

另外,除了将键值对保存在数据库里之外,节点会用clusterState结构中的slots_to_keys跳跃表来保存槽与键之间的关系:

ini 复制代码
typdef struct clusterState {
  zskiplist *slots_to_keys;
} clusterState;

slots_to_keys的每个分值(score)都是一个槽号,每个节点的成员(member)都是一个数据库键:

  • 每当节点往数据库中添加新的键值对时,节点会将键与槽号关联到slots_to_keys
  • 删除键值对时,节点也会接触slots_to_keys中键与槽号的关联。

通过在slots_to_keys中记录各个数据库键所属的槽,节点可以很方便地对属于某个槽的键进行批量操作,如CLUSTER GETKEYINSLOT <slot> <count>

重新分片

Redis集群的重新分片指的是将任意数量已经指派给某个节点的槽改为指派给另一个节点,且相关槽所属的键也从源节点移动到目标节点。重新分片可以在线(online)进行,分片过程中,集群不需要下线,且源节点和目标节点都可以继续处理命令请求。

重新分片是由Redis的集群管理软件redis-trib负责的,Redis提供了重新分片所需的所有命令,redis-trib则通过向源节点和目标节点发送命令来实现重新分片:

  1. 向目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点准备好导入源节点中属于槽slot的键值对。
  2. 向源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好迁移键值对。
  3. 向源节点发送CLUSTER GETKEYINSLOT <slot> <count>命令,获得最多count个属于槽slot的键值对的键名。
  4. 对于步骤3获得的每个键名,向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>命令,将选中的键原子地从原籍诶单迁移到目标节点。
  5. 充分执行步骤3和4,知道所有键值对都被迁移至目标及诶单
  6. 向集群中的任一节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽slot指派给目标节点,这一指派信息通过消息传送至整个集群。

ASK错误

在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现:属于被迁移槽的一部分键值对保存在源节点中,而另一部分保存在目标节点中。

当客户端向源节点发送一个与数据库键有关的命令,且要处理的键恰好就属于正在被迁移的槽时:

  • 源节点现在自己的数据库中查找键,如果找到,直接执行命令。
  • 否则,源节点向客户端返回ASK错误,指引客户端转向正在导入槽的目标节点,再次发送命令。

CLUSTER SETSLOT IMPORTING 命令的实现

clusterState结构的importing_slots_from数组记录了当前节点正在从其它节点导入的槽:

arduino 复制代码
typedef struct clusterState {
  clusterNode *importing_slots_from[16384;
} clusterState;

如果importing_slots_from[i]指向一个clusterNode结构,表示当前节点正在从clusterNode所代表的节点导入槽i

CLUSTER SETSLOT <i> IMPORTING <source_id> 命令,可以将目标节点的importing_slots_from[i]置为source_id所代表节点的clusterNode结构。

CLUSTER SETSLOT MIGRATING 命令的实现

clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其它节点的槽:

arduino 复制代码
typedef struct clusterState {
  clusterNode *migrating_slots_to[16384;
} clusterState;

如果migrating_slots_to[i]指向一个clusterNode结构,表示当前节点正在将槽i迁移至clusterNode所代表的节点。

CLUSTER SETSLOT <i> MIGRATING <target_id> 命令,可以将源节点的migrating_slots_to[i]置为target_id所代表节点的clusterNode结构。

ASK 错误

节点收到一个关于键key的命令请求,先查找key所属的槽i是否自爱自己的数据库里,如果在,直接执行命令。

如果不在,节点会检查自己的clusterState.migrating_slots_to[i],看槽i是否正在被迁移。如果是,返回客户端一个ASK错误。

接到ASK错误的客户端根据错误提供的IP地址和端口,转向目标节点,先向其发送一个ASKING命令,之后再重新发送原来要执行的命令。如果不先发送一个ASKING命令,那么会被节点拒绝执行,并返回MOVED错误。

ASKING 命令

ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识。该标识是一次性标识,节点执行了一个带有该标识的客户端发来的命令后,标识就被移除。

ASK 错误和MOVED 错误的区别

  • MOVED错误代表槽的负责权已经转移。
  • ASK错误是迁移槽过程中的临时措施。接收ASK指引的转向,不会对客户端今后发送关于槽i的命令请求有任何影响,客户端仍会将请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。

复制与故障转移

Redis集群中的master用于处理槽,slave用于复制某个master,并在被复制的master下线时,代替master继续处理命令请求。

设置slave

向一个节点发送命令:

CLUSTER REPLICATE <node_id>

可以让接受命令的节点成为node_id所指定节点的slave,并开始对master进行复制:

  1. 接收命令的节点先在自己的clusterState.nodes字典中找到node_id对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录正在复制的master。
  2. 节点修改自己在clusterState.myself.flags中的属性,打开REDIS_NODE_SLAVE标识。
  3. 节点调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构保存的IP地址和端口号,对主节点进行复制。

一个节点成为master,并开始复制某个master这一信息会通过消息发送给集群中的其它节点。集群中的所有节点都会在代表主节点的clusterNode结构的slavesnumslaves属性中记录正在复制这个master的slave名单:

arduino 复制代码
struct clusterNode {
  // 正在复制这个master的slave数量
  int numslaves;
  
  // 正在复制这个master的slave的clusterNode结构
  struct clusterNode **slaves;
};

故障检测

集群中的每个节点都会定期向其它节点发送PING消息,检测对方是否在线。各个节点都会通过消息来交换其它节点的状态信息。

当一个master A通过消息得知master B认为master C进入疑似下线状态,A会在自己的clusterState.nodes字典中找到C对应的clusterNode结构,并将B的下线报告添加到clusterNode结构的fail_reposts链表中:

arduino 复制代码
struct clusterNode {
  // 一个链表,记录了所有其它节点对该节点的下线报告
  list *fail_reports;
};

每个下线报告由一个clusterNodeFailReport结构表示:

arduino 复制代码
struct clusterNodeFailReport {
  // 报告目标节点已经下线的节点
  struct clusterNode *node;
  
  // 最后一次从node节点收到下线报告的时间,用这个来检查报告是否过期,过期则删除
  mstime_t time;
} typedef clusterNodeFailReports;

如果在一个集群里,半数以上负责处理槽的master都将某个master X报告为疑似下线,那么X就被标记为下线。将X标记为下线的节点向集群广播关于X的FAIL消息,收到消息的节点会立即将X标记为已下线。

故障转移

当一个slave发现自己正在复制的master已下线,会开始对其进行故障转移:

  1. 复制master的所有从节点里,会有一个slave被选中。
  2. 被选中的slave执行SALVEOF no one命令,成为新的master。
  3. 新master会撤销所有对已下线master的槽指派,并指派给自己。
  4. 新master向集群广播一条PONG消息,宣布自己成为master。
  5. 新master开始接收和处理自己负责的槽有关的命令请求。

选举新的master

新的master是选举产生的:

  1. 集群中的配置纪元是一个自增计数器,初始值为0。
  2. 集群中的某个节点开始一次故障转移操作时,集群配置纪元的值+1。
  3. 对于每个配置纪元,集群中每个负责处理槽的master都有一次投票机会,而第一个向master要求投票的slave将获得投票权。
  4. 当slave发现自己正在复制的master已下线,会广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST,要求收到消息的master给自己投票。
  5. 如果一个master有投票权(正在处理槽),且未投票给其它slave,那么master会向要求投票的slave返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示支持它成为新master。
  6. 每个参与选举的slave都会接收到CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,根据消息的个数来统计自己获得几票。
  7. 一个slave收集到大于N/2+1的支持票后,会当选新master。
  8. 因为每个配置纪元里,拥有投票权的master只有一票,因此新的master只会有一个。
  9. 如果一个配置纪元中没有选举出新master,那么集群进入一个新的配置纪元,继续选举。

消息

集群中的节点通过消息来通信,消息主要分为以下5种:

  • MEET消息:加入当前集群
  • PING消息:检测在线
  • PONG消息:回复MEETPING
  • FAIL消息:进入FAIL状态
  • PUBLISH消息:节点接收到PUBLISH消息,会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。

一个消息由消息头(header)和消息正文(body)组成。

消息头

每个消息头都由一个cluster.h/clusterMsg结构表示:

arduino 复制代码
typedef struct {
  // 消息的长度,包括消息头和消息正文
  uint32_t totlen;
  
  // 消息的类型
  uint16_t type;
  
  // 消息正文包含的节点信息数量
  // 只在发送MEET、PING、PONG这三种Gossip协议的消息时使用
  uint16_t count;
  
  // 发送者所处的配置纪元
  uint64_t currentEpoch;
  
  // 如果发送者是一个master,那么这里记录的是发送者的配置纪元
  // 如果发送者是一个slave,那么这里记录的是发送者正在复制的master的配置纪元
  uint64_t configEpoch;
  
  // 发送者的名字(ID)
  char sender[REDIS_CLUSTER_NAMELEN];
  
  // 发送者目前的槽指派信息
  unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
  
  // 如果发送者是一个slave,那么这里记录的是它正在复制的master的名字
  // 如果发送者是一个master,那么这里记录的是REDIS_NODE_NULL_NAME
  char slaveof[REDIS_CLUSTER_NAMELEN];
  
  // 发送者的端口号
  uint16_t port;
  
  // 发送者的标识值
  uint16_t flags;
  
  // 发送者所处集群的状态
  unsigned char state;
  
  // 消息的正文
  union clusterMsgData data;
} cllusterMsg;

union clusterMsgData {
  struct {
    // 每条 MEET、PING、PONG 消息都包含两个 clusterMsgDataGossip 结构
    clusterMsgDataGossip[1];
  } ping;
  
  // FAIL 消息的正文
  struct {
    clusterMsgDataFail about;
  } fail;
  
  // PUBLISH 消息的正文
  struct {
    clusterMsgDataPublish msg;
  } publish;
};

clusterMsg结构的currentEpochsendermyslots等属性记录了发送者的节点信息,接收者可以根据这些信息,在自己的clusterState.nodes字典中找到发送者对应的clusterNode结构进行更新。

MEET、PING、PONG 消息的实现

Redis集群中的各个节点通过Gossip协议来交换节点的状态信息,其中Gossip协议由MEETPINGPONG三种消息实现,这三种消息的正文都是由两个cluster.h/clusterMsgDataGossip结构组成。

每次发送MEETPINGPONG消息时,发送者从自己的已知节点中随机选出两个,将它们的信息保存到两个cluster.h/clusterMsgDataGossip结构中。

arduino 复制代码
typedef struct {
  // 节点的名字
  char nodename[REDIS_CLUSTER_NAMELEN];
  
  // 最后一次向该节点发送 PING 消息的时间戳
  uint32_t ping_sent;
  
  // 最后一次从该节点接收到 PONG 消息的时间戳
  uint32_t pong_received;
  
  // 节点的IP
  char ip[16];
  
  // 节点的端口
  uint16_t port;
  
  // 节点的标识符
  uint16_t flags;
} clusterMsgDataGossip;

接收者收到信息,访问正文中的两个clusterMsgDataGossip结构,根据自己是否认识其中的被选中节点来选择操作:

  • 被选中节点不存在于接收者的已知节点列表:根据IP和端口跟其握手。
  • 被选中节点存在于接收者的已知节点列表:根据clusterMsgDataGossip记录的信息,更新被选中节点的clusterNode结构。

FAIL 信息的实现

当集群里的master A将master B标记为已下线(FAIL)时,A将集群广播关于B的FAIL消息,接收到消息的节点都将B标记为已下线。为了避免Gossip协议的延迟,FAIL消息正文采用cluster.h/clusterMsgDataFail结构表示:

arduino 复制代码
typedef struct {
  char nodename[REDIS_CLUSTER_NAMELEN];
} clusterMsgDataFail;

PUBLISH 消息的实现

向某个节点发送:

PUBLISH

会导致集群中的所有及诶单都向channel发送message消息。

PUBLISH消息的正文由cluster.h/clusterMsgDataPublish结构表示:

arduino 复制代码
typedef struct {
  uint32_t channel_len;
  uint32_t message_len;
  
  // 8字节是为了对齐其他消息结构,实际长度由保存的内容决定
  // bulk_data 保存了channel参数和message参数
  unsigned char bulk_data[8];
} clusterMsgDataPublish;

独立功能的实现

发布与订阅

Redis的发布与订阅功能由PUBLISHSUBSCRIBEPSUBSCRIBE等命令组成。

通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,成为这些频道的订阅者(subscriber):每当有其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会收到这条消息。

客户端还可以通过PSUBSCRIBE订阅一个或多个模式:每当有其他客户端向某个频道发送消息,消息不仅会发送给这个频道的订阅者,还会发送给与这个频道相匹配的模式的订阅者。

频道的订阅与退订

Redis将所有频道的订阅关系都保存在服务器状态的pubsub_channles字典中,键是某个被订阅的频道,值是一个链表,里面记录了所有订阅这个频道的客户端:

ini 复制代码
struct redisServer {
  dict *pubsub_channels;
};

订阅频道

每当客户端执行SUBSCRIBE命令时,服务器都会将客户端与被订阅的频道在pubsub_channles字典中关联:

  • 如果频道已有其他订阅者,将当前客户端添加到订阅者链表的末尾。
  • 如果频道未有订阅者,则在pubsub_channles字典中创建一个键,并将客户端添加至链表。

退订频道

UNSUBSCRIBE命令让客户端退订某个频道,服务器从pubsub_channles字典中解除关联:

  • 根据被退订频道的名字,在pubsub_channles字典中找到订阅者链表,移除退订客户端的信息。
  • 如果链表变成了空,则从pubsub_channles字典中删除频道对应的键。

订阅与退订模式

服务器将所有模式订阅关系保存在pubsub_patterns属性中:

ini 复制代码
struct redisServer {
  list *pubsub_patterns;
};

pubsub_patterns属性是个链表,每个节点都包含一个pubsubPattern结构:

arduino 复制代码
typedef struct pubsubPattern {
  // 订阅模式的客户端
  redisClient *client;
  
  // 被订阅的模式
  robj *pattern;
} pabsubPattern;

订阅模式

客户端执行PSUBSCRIBE订阅某个模式时,服务器会对被订阅的模式执行以下操作:

  1. 新建一个pubsubPattern结构,初始化patternclient值。
  2. pubsubPattern结构添加到pubsub_patterns链表末尾。

退订模式

客户端执行PUNSUBSCRIBE退订某些模式的时候,服务器在pubsub_patterns链表中查找并删除那些pattern属性为被退订模式,且client属性为执行退订命令的客户端的节点。

发送消息

Redis客户端执行PUBLISH <channel> <message>命令,将消息发送给频道时,服务器执行以下两个操作:

  1. message消息发送给channel频道的所有订阅者。
  2. 如果一个或多个模式pattern与频道channel匹配,那么将消息message发送给pattern模式的订阅者。

将消息发送给频道订阅者

pubsub_channles字典中找到频道channel的订阅者名单,然后将消息发送给名单中的所有客户端。

将消息发送给模式订阅者

遍历整个pubsub_patterns链表,查找那些与channel频道相匹配的模式,然后将消息发送给订阅了这些模式的客户端。

查看订阅消息

PUBSUB命令可以查看频道或模式的相关信息。

PUBSUB CHANNELS

PUBSUB CHANNELS [pattern]用于返回服务器当前被订阅的频道,其中pattern参数可选。这个命令遍历pubsub_channles字典的所有键,然后记录并返回符合条件的频道。

PUBSUB NUMSUB

PUBSUB NUMSUB [channel-1 channel-2 ... channel-N]返回这些频道的订阅者的数量,也是在pubsub_channles字典中找到频道对应的订阅者链表,然后返回链表的长度。

PUBSUB NUMPAT

PUBSUB NUMPAT返回服务器当前被订阅的模式的数量,返回pubsub_patterns链表的长度。

事务

Redis通过MULTIEXECWATCH等命令实现事务(transaction)功能。事务提供一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制。在事务执行期间,服务器不会中断事务去执行其他客户端的命令请求。

事务以MULTI开始,接着是多个命令放入事务之中,最后由EXEC将这个事务提交(commit)到服务器执行。

事务的实现

一个事务从开始到结束经历三个阶段:

  1. 事务开始
  2. 命令入队
  3. 事务执行

事务开始

MULTI命令标志着事务的开始,它将客户端从非事务状态切换到事务状态,即打开客户端状态的flags属性的REDIS_MULTI标识:

csharp 复制代码
def MULTI():
    client.flags |= REDIS_MULTI
    replyOK()

命令入队

客户端切换到事务状态后,服务器会根据不同的命令执行不同的操作:

  • EXECDISCARDWATCHMULTI其中一个,服务器立即执行该命令。
  • 否则,服务器将命令放入一个事务队列,然后向客户端返回QUEUED回复。

事务队列

每个Redis客户端都有自己的事务状态,保存在客户端状态的mstate属性中:

arduino 复制代码
typedef struct redisClient {
        multiState mstate;
} redisClient;

typedef struct multiState {
         // 事务队列,FIFO顺序
        multiCmd *commands;
  
          // 已入队命令计数
          int count;
} multiState;

typedef struct multiCmd {
          // 参数
          robj **argv;
  
   // 参数数量
   int argc;
  
   // 命令指正
   struct redisCommand *cmd;
} multiCmd;

执行事务

服务器收到EXEC命令后,会遍历客户端的事务列表,执行其中的所有命令。最后将执行所得的结果返回给客户端。

ini 复制代码
def EXEC():
    # 创建空白的回复队列
    reply_queue = []
    
    # 遍历事务列表中的每个项
    for argv, argc, cmd in client.mstate.commands:
        
        # 执行命令
        reply = execute_command(cmd, argv, argc)
        reply_quque.append(reply)
     
    # 移除 REDIS_MULTI 标识
    client.flags &= ~REDIS_MULTI
    
    # 清空客户端的事务状态,清零计数器,释放事务队列
    client.mstate.count = 0
    release_transaction_queue(client.mstate.commands)
    
    send_reply_to_client(client, reply_queue)

WATCH命令的实现

WATCH命令是个乐观锁,它可以再EXEC执行之前,监视任意数量的数据库键,并在EXEC执行时,检查被监视的键是否至少有一个已经被修改过了。如果是,服务器将拒绝执行事务,并返回客户端事务执行失败的空回复。

使用WATCH命令监视数据库键

每个Redis数据库都保存了一个watched_keys字典,键是某个被WATCH的数据库键,值是一个链表,记录了所有监视该键的客户端:

arduino 复制代码
typedef struct redisDb {
  dict *watched_keys;
} redisDb;

监视机制触发

所有对数据库进行修改的命令,执行之后都会调用multi.h/touchWatchKey函数对watched_keys字典进行检查。如果被监视的键被修改,那么打开监视该键的客户端的REDIS_DIRTY_CAS标识,表示该客户端的事务安全性已遭破坏。

判断事务安全

服务器收到EXEC命令后,根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务。

事务的ACID

原子性

事务的原子性是指,事务中的多个操作当做一个整体来执行,要么执行所有,要么一个也不执行。

Redis的事务与传统关系型数据库事务的区别在于,Redis不支持事务的回滚机制(rollback),即使事务队列中的某个命令执行出现错误,整个事务也会继续执行下去,直到所有命令执行完毕。

一致性

事务的一致性是指,如果数据库在事务执行前是一致的,那么执行后,无论事务是否执行成功,数据库也应该是一致的。「一致」是数据符合数据库本身的定义和要求,没有包含非法或无效的错误数据。

Redis通过谨慎的错误检测和简单的设计来保证事务的一致性。

  1. 入队错误
  2. 如果事务在入队命令的过程中,出现了命令不存在,或者命令格式不正确等情况,Redis会拒绝执行该事务。
  3. 执行错误
  4. 执行过程中的错误是不能在入队时被服务器发现的,这些错误只会在命令实际执行时被触发。事务的执行过程中出现错误,服务器也不会中断事务的执行,而是继续执行其他命令,一致性的命令不会被出错的命令影响。
  5. 服务器停机
  6. 执行事务的过程中停机,不管服务器使用的何种持久化模式,Redis总能保持重启后的数据库一致性。

隔离性

事务的隔离性是指,即使数据库中有多个事务并发执行,各个事务之间不会相互影响,且与串行执行的结果相同。

Redis采用单线程执行事务,所以事务总是以串行的方式执行,也当然具有隔离性。

持久性

事务的持久性是指,一个事务执行完毕后,结果已经被保存到永久性存储介质中。即使服务器停机,执行事务所得的结果也不会丢失。

Redis没有为事务提供额外的持久化功能,事务的持久化由Redis使用的持久化模式决定的:

  • 无持久化:事务不具持久性,一旦停机,所有服务器的数据都将丢失。
  • RDB持久化:只有执行BGSAVE才会对数据库进行保存,且异步执行的BGSAVE不能保证事务数据在第一时间被保存。因此RDB持久化也不能保证事务的持久性。
  • AOF持久化,且appendfsync选项为always时:程序执行命令后会调用同步操作,将命令数据保存到硬盘。这时事务是有持久性的。
  • AOF持久化,且appendfsync选项为everysec时:每秒一次同步命令数据到硬盘,事务也不具有持久性。
  • AOF持久化,且appendfsync选项为no时:程序交由操作系统来决定何时同步到硬盘,事务也不具有持久性。

Lua脚本

Redis从2.6版本开始引入对Lua脚本的支持,通过在服务器中嵌入Lua环境,Redis客户端可以使用Lua脚本,直接在服务器原子地执行多个Redis命令。

EVAL命令可以直接对输入的脚本进行求值:

EVAL "return 'hello world'" 0

"hello world"

EVALSHA命令可以根据脚本的SHA1校验和来对脚本进行求值,但这个命令要求校验和对应的脚本至少被EVAL命令执行过一次,或者被SCRIPT LOAD命令载入过。

创建并修改Lua环境

Redis服务器创建并修改Lua环境的整个过程有以下步骤:

  1. 创建一个基础的Lua环境
  2. 载入多个函数库到Lua环境
  3. 创建全局表格redis,表格包含了对Redis进行操作的函数,如redis.call
  4. 使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数
  5. 创建排序辅助函数
  6. 创建redis.pcall函数的错误报告辅助函数,这个函数可以提供更详细的出错信息
  7. 对Lua环境中的全局变量进行保护,防止用户在执行Lua脚本时添加额外的全局变量
  8. 将完成修改的Lua环境保存到服务器状态的lua属性中,等待服务器传来的Lua脚本

创建Lua环境

服务器调用Lua的C API函数lua_open,创建一个新的Lua环境。

载入函数库

  • 基础库(base library):包含Lua的核心函数,如asserterror等。为了防止用户从外部文件引入不安全的代码,loadfile函数被删除。
  • 表格库(table library)
  • 字符串库(string library)
  • 数学库(math library)
  • 调试库(debug library)
  • Lua CJSON库
  • Struct库:用于Lua值和C结构的转换
  • Lua cmsgpack库:处理MessagePack格式的数据

创建Redis全局表格

redis表格包含以下函数:

  • 用于执行Redis命令的redis.callredis.pcall
  • 用于记录日志的redis.log
  • 用于计算SHA1校验和的redis.sha1hex
  • 用于返回错误信息的redis.error_repylredis.status_reply

使用Redis自制的随机函数来替换Lua·原有的随机函数

Redis服务器要求传入的Lua脚本都是纯函数(pure function)。

Redis用自制的随机函数替换了原有的math.randommath.randomseed函数,自制函数有如下特征:

  • 对于相同的seed,math.random总是相同的随机数序列。
  • 对除非在脚本中使用math.randsomseed显式修改seed,否则每次运行脚本时,Lua环境都是用固定的math.randomseed(0)来初始化seed。

创建排序辅助函数

当Lua脚本执行完一个不确定性的命令后,会使用__redis__compare_helper函数作为对比函数,自动调用table.sort函数对命令的返回值排序,以保证相同的数据集总是产生相同是输出。

创建redis.pcall函数的报错报告辅助函数

Redis服务器为Lua环境创建一个名为__redis__err__handler的错误处理函数。当脚本调用redis.pcall执行Redis命令,且执行的命令出现错误,__redis__err__handler函数会打印出错代码的来源和行数。

保护Lua的全局环境

确保传入服务器的脚本不会因为忘记使用local关键字而将额外的全局变量添加到Lua环境。

将Lua环境保存到服务器状态的lua属性中

这一步,服务器将Lua环境与服务器状态的lua属性关联起来。

因为Redis使用串行化的方式来执行命令,所以任意时刻,最多只会有一个脚本能够被放入Lua环境执行。因此,整个Redis服务器只需要一个Lua环境即可。

Lua环境协作组件

除了创建并修改Lua环境之外,Redis服务器还创建了两个用于与Lua环境写作的组件:

  • 负责执行Lua脚本中的Redis命令的伪客户端。
  • 用于保存Lua脚本的lua_scripts字典。

伪客户端

Lua脚本使用redis.callredis.pcall执行命令,需要以下几个步骤:

  • Lua环境将redis.callredis.pcall函数想要执行的命令传送给伪客户端。
  • 伪客户端将命令传送给命令执行器。
  • 命令执行器执行命令,并将结果返回给伪客户端。
  • 伪客户端接收到结果,将结果返回Lua环境。
  • Lua环境接收到命令结果后,将结果返回给redis.callredis.pcall函数。
  • 接收到结果的redis.callredis.pcall函数将结果作为函数返回值返回给脚本的调用者。

lua_sripts字典

ini 复制代码
struct redisServer {
        dict *lua_scripts;
};

Redis服务器会将所有被EVAL执行过的Lua脚本,和所有被SCRIPT LOAD再如果的Lua脚本都保存到lua_scripts字典中。

EVAL命令的实现

EVAL命令执行过程分为三个步骤:

  1. 根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数。
  2. 将客户端给定的脚本保存到lua_scripts字典中。
  3. 执行刚刚在Lua环境中定义的函数。

定义脚本函数

函数名字由f_前缀加上脚本的SHA1校验和组成,函数体则是脚本本身。使用函数来保存客户端传入的脚本有以下好处:

  • 执行脚本的步骤很简单,只要调用与脚本对应的函数即可。
  • 通过函数的局部性让Lua环境保持清洁,减少垃圾回收,避免使用全局变量。
  • 如果某脚本使用的函数在Lua环境中被定义过一次,那么只要记得这个脚本的校验和,服务器就可以直接调用Lua函数来执行脚本。这就是EVALSHA的实现原理。

将脚本保存到lua_scripts字典

服务器在lua_scripts字典中新添加一个键值对。

执行脚本函数

lua_scripts字典中保存脚本之后,服务器还需要一些准备工作,才能开始执行脚本:

  1. EVAL命令传入的键名参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入Lua环境。
  2. 为Lua环境装载超时处理钩子(hook),在脚本出现超时后,hook可以让客户端执行SCRIPT SKILL函数停止脚本,或SHUTDOWN命令关闭服务器。
  3. 执行脚本函数。
  4. 移除之前装载的超时钩子。
  5. 将执行脚本函数的结果保存到客户端状态的输入缓冲区。
  6. 对Lua环境执行垃圾回收操作。

EVALSHA命令的实现

python 复制代码
def EVALSHA(sha1):
    # 拼接函数的名字
    func_name = "f_" + sha1
    
    # 查看这个函数是否在Lua环境中
    if function_exits_in_lua_env(func_name):
        execute_lua_funciton(func_name)
    else:
            send_script_error("SCRIPT NOT FOUND")        

脚本管理命令的实现

SCRIPT FLUSH

SCRIPT FLUSH命令用于清除服务器中所有与Lua脚本有关的信息,它会释放并重建lua_scripts字典,关闭现有的Lua环境并重建一个新的Lua环境。

SCRIPT EXISTS

SCRIPT EXISTS命令根据输入的SHA1校验和,检查其对应的脚本是否存在于服务器中。它是通过检查lua_scripts字典实现的。

SCRIPT LOAD

SCRIPT LOAD命令所做的事情和EVAL的前两步一样:

  • 在Lua环境中为脚本创建相应的函数
  • 将脚本保存到lua_scripts字典中。

SCRIPT KILL

如果服务器设置了lua-time-limit选项,那么每次执行Lua脚本前,服务器都会在Lua环境中设置一个超时钩子。

一旦钩子发现脚本的运行超时,那么将会定期在脚本执行期间的间隙,检查是否有SCRIPT KILLSHUTDOWN命令到达服务器。

如果超时的脚本从未执行过写入操作,那么客户端可以通过SCRIPT KILL命令来停止执行脚本,并向客户端返回一个错误回复。

如果超时的脚本执行过写入操作,那么客户单只能用SHUTDOWN nosave命令来停止服务器,防止被不合法的数据写入。

脚本复制

服务器运行在复制模式下,具有写性质的脚本也会被复制到slave,如EVALEVALSHASCRIPT FLUSHSCRIPT LOAD

复制EVAL、SCRIPT FLUSH、SCRIPT LOAD

Redis复制EVALSCRIPT FLUSHSCRIPT LOAD的方法和其他普通命令一样。master执行完上述命令后,会将其传播到所有slave。

复制EVALSHA

因为主从服务器载入Lua脚本的情况不同,EVALSHA命令不能直接传播给slave。

Redis要求master在传播EVALSHA命令的时候,必须确保EVALSHA要执行的脚本已经在slave中载入过。如果不能保证,那么master会将EVALSHA替换为等价的EVAL命令传播给slave。

  1. 判断EVALSHA命令是否安全

master使用服务器状态的repl_scriptcache_dict字典记录自己已经将哪些脚本传播给了所有slave。

ini 复制代码
struct redisServer {
  dict *repl_scriptcache_dict;
};

repl_scriptcache_dict的键是一个Lua脚本的SHA1校验和,值全部是NULL。

如果一个脚本的SHA1出现在lua_scripts字典,却没有出现在repl_scriptcache_dict字典,说明对应的的Lua脚本已被master载入,却没有传播给所有slave。

  1. 清空repl_scriptcache_dict字典

每当master添加一个新的slave时,都会清空自己的repl_scriptcache_dict字典。

  1. EVALSHA命令换成EVAL

通过EVALSHA指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器总可以将 EVALSHA命令换成EVAL命令。

  1. 传播EVALSHA命令

当master在本机执行完一个EVALSHA命令后,根据其SHA1校验和是否存在于repl_scriptcache_dict字典,决定是向所有slave传播EVALSHA还是EVAL命令。

排序

Redis的SORT命令可以对列表键、集合键或者有序集合键的值进行排序。

SORT 命令的实现

SORT <key> 可以对一个包含数字值的键key进行排序,假设:

PRUSH numbers 3 1 2

SORT numbers

  1. 创建一个和numbers长度相同的数组,每个元素都是一个redis.h/redisSortObject结构。

  2. 遍历数组,将每个元素的obj指针指向numbers列表的各个项,构成一一对应关系。

  1. 遍历数组,将各个obj指针所指向的列表项转换为一个double类型的浮点数,并将这个浮点数保存在相应数组项的u.score属性中。
  1. 根据数组项u.score的值,对数组进行数字值排序。
  1. 遍历数组,将各个数组项的obj指针所指向的列表项作为排序结果返回给客户端。
arduino 复制代码
typedef struct _redisSortObject {
  // 被排序的值
  robj *obj;
  
  // 权重
  union {
    // 排序数字值时使用
    double score;
    
    // 排序带有BY选项的字符串值使用
    robj *cmpobj;
  } u;
} redisSortObject;

ALPHA选项的实现

SORT ALPHA

可以对包含字符串值的键进行排序,例如:

SADD fruits apple banana cherry

SORT fruits ALPHA

  1. 创建一个redisSortObject数组,长度等于fruits集合。
  2. 遍历数组,将各个元素的obj指针指向fruits集合的各个元素。
  3. 根据obj指针所指向的集合元素,对数组进行字符串排序。
  4. 遍历数组,一次将数组项的obj指针指向的元素返回给客户端。

ASC和DESC选项的实现

SORT默认是升序排序,所以SORT <key>SORT <key> ASC是等价的。DESC可以降序排序。

升序和降序都是使用快速排序完成的。

BY选项的实现

默认情况下,SORT命令使用被排序键包含的元素作为排序的权重,元素本身决定了元素排序后的位置。

通过BY选项,SORT可以指定某些字符串键或某个哈希键所包含的某些域(field)作为元素的权重。

不同的是,排序用到的redisSortObject数组元素指向权重键。

带有ALPHA和BY选项的实现

BY选项默认权重键保存的是数字值,针对字符串值还是要配合ALPHA选型。

LIMIT选项的实现

默认情况下,SORT返回排序后的所有元素。LIMIT选项可以只返回一部分已排序的元素:

SORT ALPHA LIMIT

其中:

  • offset表示要跳过的已排序元素数量。
  • count表示跳过给定数量的已排序元素后,要返回的元素数量。

LIMIT生效,还是要排序伸个数组,最后返回元素的时候,根据offsetcount的索引。

GET选项的实现

默认情况下,SORT排序之后,总是返回被排序键所包含的元素。GET可以返回指定模式的键的值。

STORE选项的实现

默认情况下,SORT只向客户端返回结果,要保存结果,使用SORTE选项。

多个选项的执行顺序

如果按照选项来划分,SORT命令可以分为四步:

  1. 排序:使用ALPHAASCDESCBY选项。
  2. 限制结果集的长度:使用LIMIT选项。
  3. 获取外部键:使用GET选项。
  4. 保存结果集:使用STORE选项。
  5. 先客户端返回结果集。

调用SORT命令时,除了GET选项之外,改变选项的位置不会影响SORT的顺序。

二进制位数组

Redis提供了SETBITGETBITBITCOUNTBITOP四个命令用于处理二进制位数组。

  • SETBIT,为位数组指定偏移量上的二进制位设置值0或1。
  • GETBIT,获取位数组指定偏移量上的二进制位的值。
  • BITCOUNT,统计位数组中1的个数。
  • BITOP,既可以对多个位数组进行按位与、按位或、按位异或运算,也可以对给定位数组取反。

位数组的表示

Redis使用字符串来表示位数组,并使用SDS结构的操作函数来处理位数组。

  • redisObject.type的值为REDIS_STRING,表示字符串对象。
  • sdshdr.len值为1,表示这个SDS保存了一个一字节长的位数组。
  • buf数组的buf[0]字节保存了一个一字节长的位数组。
  • buf数组的buf[1]字节保存了SDS程序自动追加到值的末尾的'\0'。

GETBIT命令的实现

GETBIT

用于返回位数组bitarrayoffset偏移量上的二进制位的值:

  1. 计算 byte = (offset / 8)byte记录了offset偏移量指定的二进制保存在位数组的哪个字节。
  2. 计算 bit = (offset mode 8) + 1bit记录offset指定的二进制位是byte字节的第几个二进制位。
  3. 根据 bytebit 值,在位数组 bitarray中定位offset指定的二进制位,并返回这个位的值。

SETBIT命令的实现

SETBIT

用于将位数组bitarrayoffset偏移量上的二进制位设置为value

  1. 计算len = (offset / 8) + 1len记录了offset指定的二进制位至少需要多少个字节。
  2. 检查bitarray键保存的位数组长度是否小于len。如果是,扩展,并将新空间的二进制位置为0
  3. 计算 byte = (offset / 8)byte记录了offset偏移量指定的二进制保存在位数组的哪个字节。
  4. 计算 bit = (offset mode 8) + 1bit记录offset指定的二进制位是byte字节的第几个二进制位。
  5. 根据 bytebit 值,在位数组 bitarray中定位offset指定的二进制位,首先将现在的值保存在oldvalue变量,然后将value设置为新值。
  6. 向客户端返回oldvalue的值。

BITCOUNT命令的实现

BITCOUNT用于统计给定位数组中,值为1的二进制位的个数。它的实现用到了查表和variable-precision SWAR两种算法:

  • 查表算法使用键长为8的表,记录了从0000 00001111 1111在内的汉明重量。
  • variable-precision SWAR算法方面,BITCOUNT在每次循环时载入128个二进制,调用四次32位variable-precision SWAR算法来计算这个128个二进制位的汉明重量。

根据二进制位的长度是否大于128,来决定使用哪种算法。

ini 复制代码
# 一个表,记录了所有8位长位数组的汉明重量
# 程序将8位长的位数组转换为无符号整数,并在表中进行索引
# 例如,对于输入0000 0011,程序将二进制转换为无符号整数 3
# 然后取出 weight_in_byte[3]的值 2,2 就是 0000 0011 的汉明重量
weight_in_byte = [0, 1, 1, 2, 1, 2, 2, ..., 7, 7, 8]

def BITCOUNT(bits):
    # 计算位数组中包含了多少个二进制位
    count = count_bit(bits)
    
    # 初始汉明重量为0
    weight = 0
    
    # 如果未处理的二进制位大于等于 128 位
    # 那么使用 variable-precision SWAR 算法
    while count >= 128:
        
        # 4个swar调用,每个调用计算32位二进制位的汉明重量
        # 注意:bits[i:j]中的索引j是不包含在取值范围之内的
        weight += swar (bits[0:32])
        weight += swar (bits[32:64])
        weight += swar (bits[64:96])
        weight += swar (bits[96:128])
        
        # 移动指针,略过已处理的位
        bits = bits[128:]
        
        # 减少未处理位的长度
        count -= 128
       
    # 如果执行到这里,说明未处理的位数量不足128,那么使用查表法
    while count:
        index = bits_to_unsigned_int(bits[0:8])
        weight += weight_in_byte[index]
        
        # 移动指正,略过未处理的位
        bits = bits[8:]
        
        # 减少未处理位的长度
        count -= 8
        
    return weight

BITOP命令的实现

BITOP命令直接使用C语言的逻辑运算。

慢查询日志

Redis的慢查询日志,用于记录执行时间超过给定时长的命令请求,用户可以通过这个日志来监视和优化查询速度。

服务器有两个选项和慢查询有关:

  • slowlog-log-slower-than,指定执行时间超过多少微妙的命令请求会被记录到日志上。
  • slowlog-max-len,指定服务器上最多保存多少条慢查询日志。数量超过,则先入先出。

SLOWLOG GET可以查看服务器保存的慢查询日志。

慢查询日志的保存

arduino 复制代码
struct redisServer {
    // 下一条日志的ID
    long long slowlog_entry_id; 

    // 保存了所有日志的链表
    lisg *slowlog;

    long long  slowlog_log_slower_than;
    unsigned long slowlog_max_len;
};

// slowlog链表保存了所有慢查询日志,每个节点都保存了一个slowlogEntry结构,代表一条日志
typedef struct slowlogEntry {
    long long id;

    // 命令执行时的时间
    time_t time;

    // 执行命令的消耗时间,微妙级
    long long duration;

    // 命令与命令参数
    robj **argv;

    // 命令与命令参数的个数
    int argc;
} slowlogEntry;

慢查询日志的阅览与删除

python 复制代码
def SLOTLOG_GET(number=None):
    # 用户没有给定number惨呼,那么打印全部日志
    if number is None:
        number = SLOWLOG_LEN()

    # 遍历所有日志
    for log in redisServer.slowlog:
        if number <= 0:
            break;
        else:
            number -= 1
            printLog(log)

def SLOTLOG_LEN():
    return len(redisServer.slowlog)

def SLOWLOG_RESET():
    for log in redisServer.slowlog:
        deleteLog(log)

添加新日志

每次命令执行前后,程序都会记录时间戳,两者之差就是命令执行的耗时。服务器会把这个时长传递给函数slowlogPushEntryIfNeeded,它负责检查是否需要创建慢查询日志:

  1. 如果执行时长超过slowlog-log-slower-than选项,为其创建新日志,添加到slowlog链表的表头。
  2. 如果慢查询日志的长度超过了slowlog-max-len的限制,那么将多余的日志从slowlog链表删除。

监视器

通过执行MONITOR命令,客户端可以将自己变成一个监视器,实时接收并打印出服务器正在处理的命令请求的相关信息。

成为监视器

ruby 复制代码
def MONITOR():
    # 打开客户端的监视器标识
    client.flags != REDIS_MONITOR

    # 将客户端添加到服务器状态的monitors链表的末尾
    server.monitors.append(client)

    # 向客户端返回OK
    send+reply("OK")

向监视器发送命令消息

服务器每次处理命令请求前,会调用replicationFeedMonitors函数,由它将被处理的命令的请求的相关信息发送给各个监视器。

ini 复制代码
def replicationFeedMonitors(client, monitors, dbid, argv, argc):

    # 创建要发送的消息
    msg = create_msg(client, dbid, argv, argc)

    # 遍历所有监视器
    for monitor in monitors:
        send_msg(monitor, msg)
相关推荐
Hello.Reader1 小时前
Redis大Key问题全解析
数据库·redis·bootstrap
B1nna4 小时前
Redis学习(三)缓存
redis·学习·缓存
A227410 小时前
Redis——缓存雪崩
java·redis·缓存
weisian15110 小时前
Redis篇--应用篇3--数据统计(排行榜,计数器)
数据库·redis·缓存
言之。10 小时前
Redis单线程快的原因
数据库·redis·缓存
LYX369314 小时前
Docker 安装mysql ,redis,nacos
redis·mysql·docker
计算机毕设定制辅导-无忧学长14 小时前
Redis 持久化机制详解
redis
奋斗的老史16 小时前
Spring Retry + Redis Watch实现高并发乐观锁
java·redis·spring
loop lee18 小时前
Redis - Token & JWT 概念解析及双token实现分布式session存储实战
java·redis
大G哥20 小时前
k8s创建单例redis设置密码
数据库·redis·云原生·容器·kubernetes