【檀越剑指大厂--redis】redis高阶篇

发布于:2022-12-24 ⋅ 阅读:(438) ⋅ 点赞:(0)

目录

文章目录

一.数据结构与对象

1.什么是 SDS?

Redis 没有直接使用 C 语言传统的字符吕表示 (以空字符结尾的字符数组,以下简称 C 字符串),而是自己构建了 一种名为简单动态字符串(simple dynamic string,SDS)的抽象象类型,并将 SDS 用作 Redis 的默认字符串表示。

存储 String 类型的 key-value 时,key 和 value 都是 SDS 类型的.字符串键值都用 SDS 表示.

redis> SET msg "hello world"
OK

**当做缓冲区使用:**除了用来保存数据库中的字符串值之外,SDS还被用作缓冲区(buffer)AOF模块中的 AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的

2.SDS 定义?

struct sdshdr{
  //字节数组
  char buf[];
  //buf数组中已使用字节数量
  int len;
  //buf数组中未使用字节数量
  int free;
}

image-20220907164932228

  • free 属性的值为 0,表示这个 SDS 没有分配任何未使用空间。
  • len 属性的值为 5,表示这个 SDS 保存了一个五字节长的字符串。
  • buf 属性是一个 char 类型的数组,数组的前五个字节分别保存了’R’、‘e’、‘d’、i’、‘s’五个字符,而最后一个字节则保存了空字符’\0’。遵循 C 字符串的惯例,以空字符结尾.对于 SDS 使用者是透明的.

3.SDS和C字符串的区别?

根据传统,C 语言使用长度为 N+1 的字符数组来来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符’\0’。

SDS 优点:

  • 常数复杂度获取字符串长度
  • 杜绝缓冲区溢出,出现数据覆盖
  • 空间预分配策略与惰性空间释放策略,减少修改字符串时带来的内存重分配次数
  • 二进制安全,可以有特殊字符,SDS 通过 len 判断是否结束
  • 兼容部分 C 字符串函数

4.什么是空间预分配?

空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。

其中,额外分配的未使用空间数量由以下公式决定:

  • 如果对 SDS 进行修改之后,SDS 的长度(也即是 len 属性的值)将小于 1MB,那么程序分配和 len 属性同样大小的未使用空间,这时 SDS len 属性的值将和 free 属性的值相同。举个例子,如果进行修改之后,SDS 的 len 将变成 13 字节,那么程序也会分配 13 字节的未使用空间,SDS 的 buf 数组的实际长度将变成 13+13+1=27 字节(额外的一字节用于保存空字符)。
  • 如果对 SDS 进行修改之后,SDS 的长度将大于等于 1MB,那么程序会分配 1MB 的未使用空间。举个例子,如果进行修改之后,SDS 的 len 将变成 30MB,那么程序会分配 1MB 的未使用空间,SDS 的 buf 数组的实际长度将为 30MB+1MB+1bvte。

通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。需要注意的是字符串最大长度为 512M。

5.什么是惰性空间释放?

惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时时,程序并不立即使用内内存重分配来回收缩短后多出来的字节,而是是使用 free 属性将这些字 2 节的数量记录起来,并等待将来使用。

通过惰性空间释放策略,SDS 避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。

与此同时,SDS 也提供了相应的 API,让我们可以在有需要时,真正地释放 SDS 的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

6.链表的结构?

链表的优点

  • 链表提供了高效的节点重排能力,
  • 以及顺序生的节点访问方式,
  • 并且可以通过增删节点来来灵活地调整链表的长度

image-20220908190920433

如上图所示,Redis 链表由一个 list 和任意个 listNode 结构组成;

Redis 链表结构特点:双向,无环,具备表头和表尾指针,链表节点计算器,可保存不同类型

typedef struct listNode{
	//前指针
	struct listNode *prev;
	//后指针
	struct listNode *next;
	void *value;
}ListNode;
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;

7.Redis的链表实现的特性

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。

  • 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL 对链表的访问以 NULL 为终点。

  • 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1)。

  • 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。

  • 多态:链表节点使用 void*指针来保存节点值,并且可以通过 list 结构的 dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

8.字典

  • 字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构

  • 在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。

  • 字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对。

字典用哈希表做底层实现,哈希表由哈希表节点产生,每个节点保存了字典的键值对。

typedef struct dict {
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    //哈希表
    dictht ht[2];
    //rehash 索引。当rehash 不在进行时,值为-1
    in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
 
 
typedef struct dictType {
    //计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);
    //复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
    //复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
    //对比键的函数
    int (*keyCompare)
    (void *privdata, const void *key1, const void *key2);
    //销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    //销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

成员说明

  • type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的
  • type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定 类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
  • 而privdata属性则保存了需要传给那些类型特定函数的可选参数
  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下, 字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
  • 除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1

hash 算法

当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis 使用 MurmurHash2 算法来计算键的哈希值。

发生冲突时(键被分到哈信表数组同一个索引上),用拉链法解决,next 指向,新接待那添加到链表表头(O (1))

#使用字典设置的哈希函数,计算键key 的哈希值hash = dict->type->hashFunction(key);
 
#使用哈希表的sizemask 属性和哈希值,计算出索引值
#根据情况不同,ht[x] 可以是ht[0] 或者ht[1]
index = hash & dict->ht[x].sizemask;

9.哈希表

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

成员介绍

  • table属性:是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个 dictEntry结构保存着一个键值对
  • size属性:记录了哈希表的大小,也即是table数组的大小
  • used属性:则记录了哈希表目前已有节点(键值对)的数量
  • sizemask属性:的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面

table 指向 dictEntry(哈希表节点,保存键值对),size 记录大小,sizemask 则是 size-,用于(n-1&hash)运算放到 table 的索引。

img

10.哈希表节点

typedef struct dictEntry {
    //键
    void *key;
    //值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    //指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
  • key 属性保存着键值对中的键,而 v 属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个 uint64_t 整数,又或者是个 int64_t 整数。
  • next 属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。

img

11.解决键冲突

  • 当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)
  • Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有 一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上 的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题

12.rehash 的过程?

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(loadfactor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。扩展和收缩哈希表的工作可以通过执行 rehash(重新散列)操作来完成,Redis 对字典的哈希表执行 rehash 的步骤如下:

  1. 为字典的 ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0]当前包含的键值对数量(也即是 ht[0].used 属性的值):
    • 如果执行的是扩展操作,那么 ht[1]的大小为第一个大于等于 ht[0].used2 的 2 n(2 的 n 次方幂)
    • 如果执行的是扩展操作,那么 ht[1]的大小为第一个大于等于 ht[0].used*2 的 2 n(2 的 n 次方幂);如果执行的是收缩操作,那么 ht[1]的大小为第一个大于等于 ht[0].used 的 2 n。
  2. 将保存在 ht[0]中的所有键值对 rehash 到 ht[1]上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1]哈希表的指定位置上。
  3. 当 ht[0]包含的所有键值对都迁移到了 ht[1]之后(ht[0]变为空表),释放 ht[0],将 ht[1]设置为 ht[0],并在 ht[1]新创建一个空白哈希表,为下一次 rehash 做准备。

rehash 为渐进式,分多次渐进完成扩展收缩,因为如果键值对数量庞大,全部一次性 rehash 会停止服务,通过 rehashidx 来实现.

因为在进行渐进式 rehash 的过程中,字典会同时使用 ht[0]和 ht[1]两个哈希表,所以在渐进式 rehash 进行期间,字典的删除(delete)、查找(fnd)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在 ht[0]里面进行查找,如果没找到的话,就会继续到 ht[1]里面进行查找。诸如此类。

另外,在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到 ht[1]里面,而 ht[0]则不再进行任何添加操作,这一措施保证了 ht[0]包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。

13.哈希表的扩展与收缩

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  • 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1。
  • 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5。
//负载因子的计算
load_factor=ht[0].used/ht[0].size
  • 根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis 需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技 术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负 载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内 存写入操作,最大限度地节约内存
  • 另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作

14.整数集合?

  • 整数集合是集合键的底层实现之一。

  • 整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素, 在有需要时, 程序会根据新添加元素的类型, 改变这个数组的类型。

  • 升级操作为整数集合带来了操作上的灵活性, 并且尽可能地节约了内存。

  • 整数集合只支持升级操作, 不支持降级操作。

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

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。

length 属性记录了整数集合包含的元素数量,也即是 contents 数组的长度。

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

  • 如果 encoding 属性的值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组里的每个项都是一个 int16_t 类型的整数值(最小值为-32768,最大值为 32767)。

  • 如果 encoding 属性的值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组里的每个项都是一个 int32_t 类型的整数值(最小值为-2147483648,最大值为 2147483647)。

  • 如果 encoding 属性的值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组里的每个项都是一个 int64_t 类型的整数值(最小值为-9223372036854775808,最大值为 9223372036854775807)。

15.整数集合的升级?

每当我们要将一个新元素添加到整数集合里面, 并且新元素的类型比整数集合现有所有元素的类型都要长时, 整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。

升级整数集合并添加新元素共分为三步进行:

  • 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。

  • 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。

  • 将新元素添加到底层数组里面。

整数集合不支持降级操作, 一旦对数组进行了升级, 编码就会一直保持升级后的状态。

16.自动升级的好处

  • 提升灵活性:
    • 因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放 在同一个数据结构里面
    • 例如,我们一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数 组来保存int32_t类型的值,诸如此类
    • 但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将 int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非 常灵活
  • 节约内存:
    • 当然,要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法 就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数 集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它 们,从而出现浪费内存的情况
    • 而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操 作只会在有需要的时候进行,这可以尽量节省内存
    • 例如,如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会 一直是int16_t类型的数组,只有在我们要将int32_t类型或者int64_t类型的值添加到集合时,程 序才会对数组进行升级。

17.跳跃表简介与应用

  • 跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

  • 跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

  • 在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

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

跳跃表的应用

  • 实现有序集合键
  • 集群节点用作内部结构

18.跳跃表结构

下图展示了一个跳跃表示例,位于图片最左边的是zskiplist结构,该结构包含以下属性:

typedef struct zskiplist {
    //head 指向跳跃表的表头节点,tail 指向跳跃表的表尾节点
    struct zskiplistNode *header, *tail;
    //记录跳跃表的长度,即跳跃表目前包含节点的数量,不包含头结点
    unsigned long length;
    //记录目前跳跃表内,层数最大的那个节点的层数
    int level;
} zskiplist;
  • **header:**指向跳跃表的表头节点
  • **tail:**指向跳跃表的表尾节点
  • **level:**记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)
  • **length:**记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)

位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性

typedef struct zskiplistNode {
    //成员对象(o1,o2,o3) 是一个指针,指向sds值
    sds ele;
    //分值,节点按各自所保存的分值从小到大排列 double类型
    double score;
    //后退指针在程序从表尾向表头遍历时使用 只有一个
    struct zskiplistNode *backward;
    //层数组 (每一层)
    struct zskiplistLevel {
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned long span;
    } level[];
} zskiplistNode;
  • 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表 第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾 方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片 中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进 行遍历时,访问会沿着层的前进指针进行。
  • 后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点 的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  • 分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按 各自所保存的分值从小到大排列。
  • 成员对象(obj):各个节点中的o1、o2和o3是节点所保存的成员对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b6Gr9WBB-1664247547949)(http://qinyingjie.cn/pic/bc9e221754c641c2aaaa334a4b57045e.png)]

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA54ix5Zyo5YeM5pmo,size_20,color_FFFFFF,t_70,g_se,x_16

初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位.注意不要和分值 score 弄混淆了.

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

19.压缩列表

压缩列表(ziplist)是列表和哈希的底层实现之一。

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

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

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

img

20.压缩列表节点构成

每个压缩列表节点可以保存一个字节数组或者一个整数值。其中,字节数组可以是以下三种长度中的一种。

  • 长度小于等于 63(2^6-1)字节的字节数组;

  • 长度小于等于 16383(2^14-1)字节的字节数组

  • 长度小于等于 4294967295(2^32-1)字节的字节数组

整数值可以是以下 6 种长度中的一种

  • 4 位长,介于 0 至 12 之间的无符号整数

  • 1 字节长的有符号整数

  • 3 字节长的有符号整数

  • int16_t 类型整数

  • int32_t 类型整数

  • int64_t 类型整数

image-20220909000301004

节点的 previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。 previous_entry_length 属性的长度可以是 1 字节或者 5 字节。

  • 如果前一节点的长度小于 254 字节,那么 previous_entry_length 属性的长度为 1 字节,前一节点的长度就保存在这一个字节里面。

  • 如果前一节点的长度大于等于 254 字节,那么 previous_entry_length 属性的长度为 5 字节:其中属性的第一字节会被设置为 0xFE(十进制值 254),而之后的四个字节则用于保存前一节点的长度.

节点的encoding属性记录了节点的 content 属性所保存数据的类型以及长度。

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

节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的 encoding 属性决定。

21.连锁更新

多米诺牌的效应

Redis 将这种在特殊情况下产生的连续多次空间扩展操作称之为连锁更新”(cascade update)

要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:

  • 首先,压缩列表里要恰好有多个连续的 长度介于 250 字节至 253
    字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的;

因为以上原因,ziplistPush 等命令的平均复杂度仅为 O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。

因为 ziplistPush、ziplistlnsert、ziplistDelete 和 ziplistDeleteRange 四个函数都有可能会引发连锁更新,所以它们的最坏复杂度都是 O(N2) 。

虽说如此,Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

typedef struct quicklistIter {
    const quicklist *quicklist;
    quicklistNode *current;
    unsigned char *zi;
    long offset; /* offset in current ziplist */
    int direction;
} quicklistIter;

可以看出

quicklistNode就是一个ziplist,同时加入prev、next前后指针将其串联到quicklist中

  • 每个quicklist中维护首尾节点
  • 本质上quicklist和list结构一样,只不过每个节点是一个ziplist
  • 另外quicklist支持节点压缩,当quicklist中的节点数量超过quicklist::compress规定的长度时,就会将quicklist中的中间节点压缩,以此来节省空间;但是需要读取节点内容时,会增加一次解压的代价。
  • quicklist::fill用来控制扇出,当插入的entry总大小超过这个阈值时,需要新建quicknode,这样一定程度上可以避免连锁更新

quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题

22.redis 对象?

Redis 用到的所有主要数据结构,比如简单动态字符串(SDS)、双端链表、字典、跳跃表,压缩列表、整数集合等等。Redis 并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们前面所介绍的数据结构。

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

除此之外,Redis 的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;另外,Redis 还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。

最后,Redis 的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了 MaxMemory 功能的情况下,空转时长较大的那些键可能会优先被服务器删除。

其中常见的键值类型包括String、List、Hash、Set和Sorted Set这这五种,同时还支持BitMap、HyperLogLog、Geo和Stream这四种扩展类型

23.redis 对象结构?

typedef struct redisObject {
    // 类型 0-string 1-list 2-set 3-zset 4-hash,在宏中定义。
    unsigned type:4; // :4 是位域(位段),表示只占用4bit,2^4 
    // 对象编码。某些类型的对象(如字符串和哈希)可以通过多种方式在内部表示。ENCODING表明表示方式。
    unsigned encoding:4;
  	//LRU_BITS=24,共24位,高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)
    unsigned lru:LRU_BITS; 
    // 引用次数
    int refcount;
    // 指针指向具体数据,void * 类型,从而可以执行那六大数据结构
    void *ptr;
} robj;
  • type:type 字段表示对象的类型,占 4 个比特;当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型;

    • REDIS_STRING(字符串)
    • REDIS_LIST (列表)
    • REDIS_HASH(哈希)
    • REDIS_SET(集合)
    • REDIS_ZSET(有序集合)
  • encoding表示对象的内部编码,占 4 个比特。对于 redis 支持的每种类型都至少有两种编码,对于字符串有 int、embstr、row 三种通过 encoding 属性,redis 可以根据不同的使用场景来对对象使用不同的编码,大大提高的 redis 的灵活性和效率。

  • ptr:指针指向对象的底层实现的数据结构.

以列表对象为例,有压缩列表和双端链表两种编码方式;如果列表中的元素较少,
Redis 倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。3.2 版本以后都采用 quicklist, 是压缩链表和双端链表的结合.

type

/* The actual Redis Object */
#define OBJ_STRING 0    /* String object. */
#define OBJ_LIST 1      /* List object. */
#define OBJ_SET 2       /* Set object. */
#define OBJ_ZSET 3      /* Sorted set object. */
#define OBJ_HASH 4      /* Hash object. */

encoding

#define OBJ_ENCODING_RAW 0     	 // 编码为字符串 c语言类型
#define OBJ_ENCODING_INT 1     	 // 编码为整数
#define OBJ_ENCODING_HT 2      	 // 编码为哈希表
#define OBJ_ENCODING_ZIPMAP 3  	 // 编码为 zipmap
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 	 // 编码为压缩列表
#define OBJ_ENCODING_INTSET 6  	 // 编码为整数集合
#define OBJ_ENCODING_SKIPLIST 7  // 编码为跳跃表
#define OBJ_ENCODING_EMBSTR 8  	 // 编码为SDS字符串
#define OBJ_ENCODING_QUICKLIST 9 /* 快速列表 压缩列表+链表 */
#define OBJ_ENCODING_STREAM 10 	 /* Encoded as a radix tree of listpacks */

24.redis对象底层数据结构

redisObject 使用到的底层数据结构 encoding:

在这里插入图片描述

encoding属性

  • 记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现,这个属性的值可以是下表列出的常量的其中一个

img

  • 每种类型的对象都至少使用了两种不同的编码,下表列出了每种类型的对象可以使用的编码

img

OBJECT ENCODING命令

  • 可以查看一个数据库键的值对象的编码。下表列出了不同编码的对象所对应的OBJECT ENCODING命令输出

img

25.字符串对象

字符吕对象的编码可以是 int、raw 或者 embstr。

  • 如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void*转换成 long),并将字符串对象的编码设置为 int。
  • 如果字符串对象保存的是一个字符吕值,并且且这个字符串值的长度大于 32 字节,那么字符吕对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw。
  • 如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度 小于等于 32 字节,那么字符吕对象将使用 embstr 编码的方式来保存这个字符串值。

embstr 编码的字符串对象在执行命令时,产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的,但使用 embstr 编码的字符串对象来保存短字符串值有以下好处:

  • embstr 编码是专门用于保存短字符串的一种优化编码方式,这种编码和 raw 编码一样,都使用 redisObiect 结构和 sdshdr 结构来表示字符串对象,但 raw 编码会调用两次内存分配函数来分别创建 redisObiect 结构和 sdshdr 结构,而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含 redisObiect 和 sdshdr 两个结构
  • embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。
  • 释放 embstr 编码的字符串对象只需要调用一次内存释放函数,而释放 raw 编码的字符串对象需要调用两次内存释放函数。
  • 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。

26.编码转换

  • int 转 raw,本来是 int 类型,使用了 append 方法,会转换为 raw 类型.

  • embstr 没有修改的 api,默认是只读的.当修改时,先转为 raw 再进行修改.

27.列表对象

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

  • ziplist 编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。

  • linkedlist 编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。

编码转换

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

  • 列表对象保存的所有字符串元素的长度都小于 64 字节;

  • 列表对象保存的元素数量小于 512 个;不能满足这两个条件的列表对象需要使用 linkedlist 编码。

以上 2 个值是可以修改配置的.

28.哈希对象

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

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

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;

  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

img

hashtable 编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zr6D6v6e-1664247547962)(http://qinyingjie.cn/pic/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQxNDUzMjg1,size_16,color_FFFFFF,t_70-20220926110316427.png)]

编码转换

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

  • 哈希对象保存的所有键值对的键和值的字符吕长度都小于 64 字节;
  • 哈希对象保存的键值对时数量小于 512 个;不能满足这两个条件的哈希对象需要使用 hashta ble 编码。

29.集合对象

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

  • intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
  • hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为 NULL。
127.0.0.1:6379> sadd numbers 1 3 5
(integer) 3
127.0.0.1:6379> sadd fruits apple cherry
(integer) 2

整数集合

img

hashtable

image-20220912131447869

编码转换

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

  • 集合对象保存的所有元素都是整数值;

  • 集合对象保存的元素数量不超过 512 个。

30.有序集合对象

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

  • ziplist
    • ziplist 编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score).

    • 压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

  • skiplist
    • skiplist 编码的有序集合对象使用 zset 结构作为底层实现,一个 zset 结构同时包含一个字典典和一个跳跃表:
    • zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的 object 属性保存了元素的成员,而跳跃表节点的 score 属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如 ZRANK、ZRANGE 等命令就是基于跳跃表 API 来实现的。
    • 除此之外,zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用 O(1)复杂度查找给定成员的分值,ZSCORE 命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。
typedef struct zset {
    dict *dict;         //缓存
    zskiplist *zsl;     //排序结构
} zset;

有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个 double 类型的浮点数。值得一提是,虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。

编码转换

当有序集合对象可以以同时满足以下两个条件中时,对象使用 ziplist 编码:

  • 有序集合保存的元素数量小于 128 个;

  • 有序集合保存的所有元素成员的长度都小于 64 字节;

31.为什么需要两种数据结构?

为什么有序集合需要同时使用跳跃表和字典来实现

  • 在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。
  • 举个例子,如果我们只使用字典来实现有序集合,那么虽然以 O(1)复杂度查找成员的分值这一特性会被保留,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作–比如 ZRANK、ZRANGE 等命令时,程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少 O(NlogN)时间复杂度。以及额外的 O(N)内存空间(因为要创建一个数组来保存排序后的元素)。
  • 另一方面,如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从 O(1)上升为 O(logN)。因为以上原因,为了让有序集合的查找和范围型操作都尽可能快地执行,Redis 选择了同时使用字典和跳跃表两种数据结构来实现有有序集合。

Redis五种数据类型

32.类型检查

类型检查通过 type 命令来实现的

Redis 中用于操作键的命令基本上可以分为两种类型。

其中一种命令可以对任何类型的键执行,

  • DEL
  • EXPIRE
  • RENAME
  • TYPE
  • OBJECT

而另一种命令只能对特定类型的键执行,比如说:

  • SET、GET、APPEND、STRLEN 等命令只能对字符串键执行;

  • HDEL、HSET、HGET、HLEN 等命令只能对哈希键执行;

  • RPUSH、LPOP、LINSERT、LLEN 等命令只能对列表键执行;

  • SADD、SPOP、SINTER、SCARD 等命令只能对集合键执行;

  • ZADD、ZCARD、ZRANK、ZSCORE 等命令只能对有序集合键执行;

多态命令,自动选择可执行的命令.内部自己做筛选.

33.内存回收

通过引用计数法来进行内存回收,底层是通过 redisObject 结构的 refcount 属性记录每个对象的引用计数信息;

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为 1;

  • 当对象被一个新程序使用时,它的引用计数值会被增一;

  • 当对象不再被一个程序使用时,它的引用计数值会被减一;

  • 当对象的引用计数值至变为 0 时,对象所占用的内存会被释放。

typedef struct redisObject {
    // ...
    //引用计数
    int refcount;
    // ...
} robj;

34.对象共享

**概念:**除了用于实现引用计数内存回收机制之外,对象的引用计数属性(refcount属性)还带有对象共享的作用

值对象共享 整数型对象,Redis只对字符串对象进行共享,并且只对包含整数值的字符串对象进行共享

在 Redis 中,让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。*REDIS_SHARED_INTEGERS*

只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的 CPU 时间也会越多:

  • 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为 O(1);

  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为 O(N);

  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是 O(N2)

因此,尽管共享更复杂的对象可以节约更多的内存,但受到 CPU 时间的限制,Redis 只对包含整数值的字符串对象进行共享。

35.对象的空转时长

typedef struct redisObject {
    // ...
    unsigned lru:22;
    // ...
} robj;

redisObject 结构包含了属性为 Iru 属性,该属性记录了对象最后一次被命令程序访问的时间:

OBJECT IDLETIME 命令可以打印出给定键白的空转时长,这一空转时长就是通过将当前时间减去键的值对象的 Iru 时间计算得出的:

127.0.0.1:6379> OBJECT IDLETIME msg
(integer) 295277
127.0.0.1:6379> OBJECT IDLETIME price
(integer) 10747

除了可以被 OBJECT IDLETIME 命令打印出来之外,键的空转时长还有另外一项作用:如果服务器打开了 max_memory 选项,并且服务器用于回收内存的算法为 volatile-Iru 或者 allkeys-Iru,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

36.常见的数据结构

常见的数据结构以及与之对应的键值类型如下

  • 字符串 (sds.h/c): String
  • 双向列表(adlist.h/c):List
  • 压缩列表(ziplist.h/c):List、Hash、Sorted Set
  • 压缩Map(zipmap.h/c):Hash
  • quickList(quicklist.h/c):List、Hash、Sorted Set,quickList的实现原理有点像stl中的deque
  • 跳表(t_zset.c):Sorted Set
  • 哈希表(dict.h/c):Hash
  • 位图(bitops.c): BitMap
  • GeoHash(geohash.h/c、geo.h/c、geohelper.h/c): Geo
  • HyperLogLog(hyperloglog.c):HyperLogLog
  • 流数据(rax.h/c):Stream

BitMap和布隆过滤器比较

  • 布隆过滤器不能确定一个值是否真的存在,而bitmap可以
  • 布隆过滤器空间利用率比bitmap高,一个位可以代表多个值
  • 布隆过滤器可以输入字符串,而bitmap必须转换为数值才可以.

HyperLogLog

  • 统计注册 IP 数

  • 统计每日访问 IP 数

  • 统计页面实时 UV 数

  • 统计在线用户数

  • 统计用户每天搜索不同词条的个数

说明:基数不大,数据量不大就用不上,会有点大材小用浪费空间

有局限性,就是只能统计基数数量,而没办法去知道具体的内容是什么

和bitmap相比,属于两种特定统计情况,简单来说,HyperLogLog 去重比 bitmap 方便很多

一般可以bitmap和hyperloglog配合使用,bitmap标识哪些用户活跃,hyperloglog计数

Redis集成的HyperLogLog使用语法主要有pfadd和pfcount,顾名思义,一个是来添加数据,一个是来统计的.

  • HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。

  • 操作命令

    • pfadd key value 向set集合中添加一个元素
    • pfcount key 计算set集合中元素个数(不重复)
    • pfmerge key1 key2 … 将n个集合聚合为一个
  • 适用场景

    • 去重计数

37.redis 对象总结

  • Redis 数据库中的每个键值对的键和值都是一个对象。

  • Redis 共有字符串、列表、哈希、集合、有序集合五种类型的对象,每种类型的对象至少都有两种或以上的编码方式,不同的编码可以在不同的使用场景上优化对象的使用效率。

  • 服务器在执行某些命令之前,会先检查给定键的类型能否执行指定的命令,而检查一个键的类型就是检查键的值对象的类型。

  • Redis 的对象系统带有引用计数实现的内存回收机制,当一个对象不再被使用时,该对象所占用的内存就会被自动释放。

  • Redis 会共享值为 0 到 9999 的字符串对象。

  • 对象会记录自己的最后一次被访问的时间,这个时间可以用于计算对象的空转时间。

二.数据库与事件

Redis(全称:Remote Dictionary Server 远程字典服务)

1.redisServer

struct redisServer {
        /* General */

        //配置文件路径
        char *configfile;           /* Absolute config file path, or NULL */
        //serverCron()调用频率
        int hz;                     /* serverCron() calls frequency in hertz */
        //数据库对象数组指针
        redisDb *db;
        //支持的命令列表
        dict *commands;             /* Command table */
        //没有转化的命令
        dict *orig_commands;        /* Command table before command renaming. */
        //事件
        aeEventLoop *el;
        //每分钟增加一次
        unsigned lruclock:22;       /* Clock incrementing every minute, for LRU */
        unsigned lruclock_padding:10;
        int shutdown_asap;          /* SHUTDOWN needed ASAP */
        int activerehashing;        /* Incremental rehash in serverCron() */

        //验证密码
        char *requirepass;          /* Pass for AUTH command, or NULL */
        char *pidfile;              /* PID file path */
        int arch_bits;              /* 32 or 64 depending on sizeof(long) */
        int cronloops;              /* Number of times the cron function run */
        char runid[REDIS_RUN_ID_SIZE+1];  /* ID always different at every exec. */
        int sentinel_mode;          /* True if this instance is a Sentinel. */


        /* Networking */
        int port;                   /* TCP listening port */
        int tcp_backlog;            /* TCP listen() backlog */
        char *bindaddr[REDIS_BINDADDR_MAX]; /* Addresses we should bind to */
        int bindaddr_count;         /* Number of addresses in server.bindaddr[] */
        char *unixsocket;           /* UNIX socket path */
        mode_t unixsocketperm;      /* UNIX socket permission */
        int ipfd[REDIS_BINDADDR_MAX]; /* TCP socket file descriptors */
        int ipfd_count;             /* Used slots in ipfd[] */
        int sofd;                   /* Unix socket file descriptor */
        int cfd[REDIS_BINDADDR_MAX];/* Cluster bus listening socket */
        int cfd_count;              /* Used slots in cfd[] */

    	// 连接的客户端
        list *clients;              /* List of active clients */
        list *clients_to_close;     /* Clients to close asynchronously */
        list *slaves, *monitors;    /* List of slaves and MONITORs */
        redisClient *current_client; /* Current client, only used on crash report */
        int clients_paused;         /* True if clients are currently paused */
        mstime_t clients_pause_end_time; /* Time when we undo clients_paused */
        char neterr[ANET_ERR_LEN];   /* Error buffer for anet.c */
        dict *migrate_cached_sockets;/* MIGRATE cached sockets */


        /* RDB / AOF loading information */
        int loading;                /* We are loading data from disk if true */
        off_t loading_total_bytes;
        off_t loading_loaded_bytes;
        time_t loading_start_time;
        off_t loading_process_events_interval_bytes;
        /* Fast pointers to often looked up command */
        struct redisCommand *delCommand, *multiCommand, *lpushCommand, *lpopCommand,
        *rpopCommand;


        /* Fields used only for stats */
        time_t stat_starttime;          /* Server start time */
        long long stat_numcommands;     /* Number of processed commands */
        long long stat_numconnections;  /* Number of connections received */
        long long stat_expiredkeys;     /* Number of expired keys */
        long long stat_evictedkeys;     /* Number of evicted keys (maxmemory) */
        long long stat_keyspace_hits;   /* Number of successful lookups of keys */
        long long stat_keyspace_misses; /* Number of failed lookups of keys */
        size_t stat_peak_memory;        /* Max used memory record */
        long long stat_fork_time;       /* Time needed to perform latest fork() */
        long long stat_rejected_conn;   /* Clients rejected because of maxclients */
        long long stat_sync_full;       /* Number of full resyncs with slaves. */
        long long stat_sync_partial_ok; /* Number of accepted PSYNC requests. */
        long long stat_sync_partial_err;/* Number of unaccepted PSYNC requests. */

        //保存慢日志命令
        list *slowlog;                  /* SLOWLOG list of commands */
        long long slowlog_entry_id;     /* SLOWLOG current entry ID */
        long long slowlog_log_slower_than; /* SLOWLOG time limit (to get logged) */
        unsigned long slowlog_max_len;     /* SLOWLOG max number of items logged */
        /* The following two are used to track instantaneous "load" in terms
        * of operations per second. */
        long long ops_sec_last_sample_time; /* Timestamp of last sample (in ms) */
        long long ops_sec_last_sample_ops;  /* numcommands in last sample */
        long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];
        int ops_sec_idx;


        /* Configuration */
        int verbosity;                  /* Loglevel in redis.conf */
        int maxidletime;                /* Client timeout in seconds */
        int tcpkeepalive;               /* Set SO_KEEPALIVE if non-zero. */
        int active_expire_enabled;      /* Can be disabled for testing purposes. */
        size_t client_max_querybuf_len; /* Limit for client query buffer length */
        int dbnum;                      /* Total number of configured DBs */
        int daemonize;                  /* True if running as a daemon */
        clientBufferLimitsConfig client_obuf_limits[REDIS_CLIENT_LIMIT_NUM_CLASSES];


        /* AOF persistence */
        int aof_state;                  /* REDIS_AOF_(ON|OFF|WAIT_REWRITE) */
        int aof_fsync;                  /* Kind of fsync() policy */
        char *aof_filename;             /* Name of the AOF file */
        int aof_no_fsync_on_rewrite;    /* Don't fsync if a rewrite is in prog. */
        int aof_rewrite_perc;           /* Rewrite AOF if % growth is > M and... */
        off_t aof_rewrite_min_size;     /* the AOF file is at least N bytes. */
        off_t aof_rewrite_base_size;    /* AOF size on latest startup or rewrite. */
        off_t aof_current_size;         /* AOF current size. */
        int aof_rewrite_scheduled;      /* Rewrite once BGSAVE terminates. */
        pid_t aof_child_pid;            /* PID if rewriting process */
        list *aof_rewrite_buf_blocks;   /* Hold changes during an AOF rewrite. */
        sds aof_buf;      /* AOF buffer, written before entering the event loop */
        int aof_fd;       /* File descriptor of currently selected AOF file */
        int aof_selected_db; /* Currently selected DB in AOF */
        time_t aof_flush_postponed_start; /* UNIX time of postponed AOF flush */
        time_t aof_last_fsync;            /* UNIX time of last fsync() */
        time_t aof_rewrite_time_last;   /* Time used by last AOF rewrite run. */
        time_t aof_rewrite_time_start;  /* Current AOF rewrite start time. */
        int aof_lastbgrewrite_status;   /* REDIS_OK or REDIS_ERR */
        unsigned long aof_delayed_fsync;  /* delayed AOF fsync() counter */
        int aof_rewrite_incremental_fsync;/* fsync incrementally while rewriting? */
        int aof_last_write_status;      /* REDIS_OK or REDIS_ERR */
        int aof_last_write_errno;       /* Valid if aof_last_write_status is ERR */


        /* RDB persistence */
        long long dirty;                /* Changes to DB from the last save */
        long long dirty_before_bgsave;  /* Used to restore dirty on failed BGSAVE */
        pid_t rdb_child_pid;            /* PID of RDB saving child */
        struct saveparam *saveparams;   /* Save points array for RDB */
        int saveparamslen;              /* Number of saving points */
        char *rdb_filename;             /* Name of RDB file */
        int rdb_compression;            /* Use compression in RDB? */
        int rdb_checksum;               /* Use RDB checksum? */
        time_t lastsave;                /* Unix time of last successful save */
        time_t lastbgsave_try;          /* Unix time of last attempted bgsave */
        time_t rdb_save_time_last;      /* Time used by last RDB save run. */
        time_t rdb_save_time_start;     /* Current RDB save start time. */
        int lastbgsave_status;          /* REDIS_OK or REDIS_ERR */
        int stop_writes_on_bgsave_err;  /* Don't allow writes if can't BGSAVE */
        /* Propagation of commands in AOF / replication */
        redisOpArray also_propagate;    /* Additional command to propagate. */


        /* Logging */
        char *logfile;                  /* Path of log file */
        int syslog_enabled;             /* Is syslog enabled? */
        char *syslog_ident;             /* Syslog ident */
        int syslog_facility;            /* Syslog facility */


        /* Replication (master) */
        int slaveseldb;                 /* Last SELECTed DB in replication output */
        long long master_repl_offset;   /* Global replication offset */
        int repl_ping_slave_period;     /* Master pings the slave every N seconds */
        char *repl_backlog;             /* Replication backlog for partial syncs */
        long long repl_backlog_size;    /* Backlog circular buffer size */
        long long repl_backlog_histlen; /* Backlog actual data length */
        long long repl_backlog_idx;     /* Backlog circular buffer current offset */
        long long repl_backlog_off;     /* Replication offset of first byte in the
        backlog buffer. */
        time_t repl_backlog_time_limit; /* Time without slaves after the backlog
        gets released. */
        time_t repl_no_slaves_since;    /* We have no slaves since that time.
        Only valid if server.slaves len is 0. */
        int repl_min_slaves_to_write;   /* Min number of slaves to write. */
        int repl_min_slaves_max_lag;    /* Max lag of <count> slaves to write. */
        int repl_good_slaves_count;     /* Number of slaves with lag <= max_lag. */


        /* Replication (slave) */
        char *masterauth;               /* AUTH with this password with master */
        char *masterhost;               /* Hostname of master */
        int masterport;                 /* Port of master */
        int repl_timeout;               /* Timeout after N seconds of master idle */
        redisClient *master;     /* Client that is master for this slave */
        redisClient *cached_master; /* Cached master to be reused for PSYNC. */
        int repl_syncio_timeout; /* Timeout for synchronous I/O calls */
        int repl_state;          /* Replication status if the instance is a slave */
        off_t repl_transfer_size; /* Size of RDB to read from master during sync. */
        off_t repl_transfer_read; /* Amount of RDB read from master during sync. */
        off_t repl_transfer_last_fsync_off; /* Offset when we fsync-ed last time. */
        int repl_transfer_s;     /* Slave -> Master SYNC socket */
        int repl_transfer_fd;    /* Slave -> Master SYNC temp file descriptor */
        char *repl_transfer_tmpfile; /* Slave-> master SYNC temp file name */
        time_t repl_transfer_lastio; /* Unix time of the latest read, for timeout */
        int repl_serve_stale_data; /* Serve stale data when link is down? */
        int repl_slave_ro;          /* Slave is read only? */
        time_t repl_down_since; /* Unix time at which link with master went down */
        int repl_disable_tcp_nodelay;   /* Disable TCP_NODELAY after SYNC? */
        int slave_priority;             /* Reported in INFO and used by Sentinel. */
        char repl_master_runid[REDIS_RUN_ID_SIZE+1];  /* Master run id for PSYNC. */
        long long repl_master_initial_offset;         /* Master PSYNC offset. */


        /* Replication script cache. */
        dict *repl_scriptcache_dict;        /* SHA1 all slaves are aware of. */
        list *repl_scriptcache_fifo;        /* First in, first out LRU eviction. */
        int repl_scriptcache_size;          /* Max number of elements. */
        /* Synchronous replication. */
        list *clients_waiting_acks;         /* Clients waiting in WAIT command. */
        int get_ack_from_slaves;            /* If true we send REPLCONF GETACK. */


        /* Limits */
        unsigned int maxclients;        /* Max number of simultaneous clients */
        unsigned long long maxmemory;   /* Max number of memory bytes to use */
        int maxmemory_policy;           /* Policy for key eviction */
        int maxmemory_samples;          /* Pricision of random sampling */


        /* Blocked clients */
        unsigned int bpop_blocked_clients; /* Number of clients blocked by lists */
        list *unblocked_clients; /* list of clients to unblock before next loop */
        list *ready_keys;        /* List of readyList structures for BLPOP & co */
        /* Sort parameters - qsort_r() is only available under BSD so we
        * have to take this state global, in order to pass it to sortCompare() */
        int sort_desc;
        int sort_alpha;
        int sort_bypattern;
        int sort_store;


        /* Zip structure config, see redis.conf for more information  */
        size_t hash_max_ziplist_entries;
        size_t hash_max_ziplist_value;
        size_t list_max_ziplist_entries;
        size_t list_max_ziplist_value;
        size_t set_max_intset_entries;
        size_t zset_max_ziplist_entries;
        size_t zset_max_ziplist_value;
        time_t unixtime;        /* Unix time sampled every cron cycle. */
        long long mstime;       /* Like 'unixtime' but with milliseconds resolution. */


        /* Pubsub */
        dict *pubsub_channels;  /* Map channels to list of subscribed clients */
        list *pubsub_patterns;  /* A list of pubsub_patterns */
        int notify_keyspace_events; /* Events to propagate via Pub/Sub. This is an
        xor of REDIS_NOTIFY... flags. */


        /* Cluster */
        int cluster_enabled;      /* Is cluster enabled? */
        mstime_t cluster_node_timeout; /* Cluster node timeout. */
        char *cluster_configfile; /* Cluster auto-generated config file name. */
        struct clusterState *cluster;  /* State of the cluster */
        int cluster_migration_barrier; /* Cluster replicas migration barrier. */


        /* Scripting */
        lua_State *lua; /* The Lua interpreter. We use just one for all clients */
        redisClient *lua_client;   /* The "fake client" to query Redis from Lua */
        redisClient *lua_caller;   /* The client running EVAL right now, or NULL */
        dict *lua_scripts;         /* A dictionary of SHA1 -> Lua scripts */
        mstime_t lua_time_limit;  /* Script timeout in milliseconds */
        mstime_t lua_time_start;  /* Start time of script, milliseconds time */
        int lua_write_dirty;  /* True if a write command was called during the
        execution of the current script. */
        int lua_random_dirty; /* True if a random command was called during the
        execution of the current script. */
        int lua_timedout;     /* True if we reached the time limit for script
        execution. */
        int lua_kill;         /* Kill the script if true. */


        /* Assert & bug reporting */
        char *assert_failed;
        char *assert_file;
        int assert_line;
        int bug_report_start; /* True if bug report header was already logged. */
        int watchdog_period;  /* Software watchdog period in ms. 0 = off */
};

涉及的点:

  • 通用参数(General)
  • 模块(Modules)
  • 网络(Networking)
  • 常见的命令回调函数
  • 统计相关(stat)
  • 配置信息(Configuration)
  • AOF持久化相关(包括aof rewrite过程中,父子进程用于消除数据差异的管道)
  • RDB持久化相关(包括RDB过程中,父子进程用于通信的管道)
  • AOF或者主从复制下,命令传播相关
  • 日志相关(logging)
  • 主从复制(Replication (master)+Replication (slave))
  • 主从复制的脚本缓存(Replication script cache)
  • 主从同步相关(Synchronous replication)
  • 系统限制(Limits)
  • 阻塞客户端(Blocked clients)
  • sort命令相关(Sort)
  • 数据结构转换参数
  • 时间缓存(time cache)
  • 发布订阅(Pubsub)
  • 集群(Cluster)
  • Lazy free(表示删除过期键,是否启用后台线程异步删除)
  • LUA脚本
  • 延迟监控相关
  • 服务端中互斥锁信息
  • 系统硬件信息

2.redis 数据库

  • 默认创建 16 个库,服务端有 16 个库
  • 使用 select 切换数据库 0~15
  • 客户端一次只能对应一个数据库
  • 目前为止没有显示客户端数据库的命令
typedef struct redisDb {
  //id是本数据库的序号,为0-15(默认Redis有16个数据库)
  int id;   
  //存储数据库所有的key-value
  dict *dict;   
  //键的过期时间,字典的键为键,字典的值为过期 UNIX 时间戳
  dict *expires;      
  //blpop 存储阻塞key和客户端对象
  dict *blocking_keys;   
  //阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象
  dict *ready_keys;  
  //存储watch监控的的key和客户端对象
  dict *watched_keys;  
  //存储的数据库对象的平均ttl(time to live),用于统计
  long long avg_ttl; 
  //List of key names to attempt to defrag one by one, gradually.
  list *defrag_later;        
} redisDb;

3.redis-db 常用命令

# 切换数据库 。默认情况下,一个客户端连接到数据库0
select index
# 清除当前数据库下的所有数据
flushdb
# 清楚当前实例下所有的数据库内的数据
flushall
# ping指令测试当前数据库是否联通
ping
# echo指定输出语句
echo [要输出的字符]
# move指令将指定名称的key移动到指定数据库索引下的数据库
move key_name db_Index
# dbsize指令查看当前数据库下的key数量
dbsize

4.redis 键空间

Redis是一个键值对(key-value pair)数据库服务器,服务器中的每个数据库都由一 redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space)

typedef struct redisDB(
  //…//数据库的键空间
  dict *dict;
  //…
)redisDB;

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

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象。

  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种 Redis 对象。

digraph {      label = "\n图 IMAGE_DB_EXAMPLE    数据库键空间例子";      rankdir = LR;      node [shape = record];      //      redisDb [label = "redisDb | ... |  dict | ..."];      dict [label = " dict |  StringObject \n "alphabet" |  StringObject \n "book" |  StringObject \n "message""];      subgraph cluster_alphabet {          a [label = " StringObject \n "a" "];         b [label = " StringObject \n "b" "];         c [label = " StringObject \n "c" "];          a -> b -> c;          label = "ListObject";      }      //alphabet [label = " ListObject | { StringObject \n "a" | "b" | "c" }"];      book [label = " HashObject |  StringObject \n "name" |  StringObject \n "author" |  StringObject \n "publisher""];      //name [label = " StringObject \n "Redis in Action""];     name [label = " StringObject \n "Redis in Action""];      author [label = " StringObject \n "Josiah L. Carlson""];      publisher [label = " StringObject \n "Manning""];      message [label = " StringObject \n "hello world""];      //      redisDb:dict -> dict:dict;      dict:alphabet -> a;     dict:book -> book:head;     dict:message -> message;      book:name -> name;     book:publisher -> publisher;     book:author -> author;  }其他键空间操作
除了上面列出的添加、删除、更新、取值操作之外,还有很多针对数据库本身的Redis命令,也是通过对键空间进行处理来完成的
比如说:

  • 用于清空整个数据库的FLUSHDB命令,就是通过删除键空间中的所有键值对 来实现的
  • 用于随机返回数据库中某个键的RANDOMKEY命令,就是通过在键 空间中随机返回一个键来实现的
  • 用于返回数据库键数量的DBSIZE命令,就是通过返回键空间中包含的键值对的 数量来实现的
  • 类似的命令还有EXISTS、RENAME、KEYS等,这些命令都是通过对键空间 进行操作来实现的
//通用命令,帮助命令
127.0.0.1:6379> help @generic

//删除key
127.0.0.1:6379> del test

//删除多个key
127.0.0.1:6379> del test1 test2
(integer) 2

//key是否存在
127.0.0.1:6379> EXISTS test1
(integer) 1

//key的过期秒数
127.0.0.1:6379> expire test1 3
(integer) 1

//移除过期时间
127.0.0.1:6379> persist url
(integer) 1

//key重命名
127.0.0.1:6379> rename url url1234
OK

//类型检查
127.0.0.1:6379> type url
string

5.过期时间实现

键的生存时间或过期时间介绍

  • 生存时间(Time To Live,TTL):在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键
  • 过期时间(expire time):是一个UNIX时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键

redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳
typedef struct redisDB(
  //…//数据库的键空间
  dict *dict;

  //过期字典,保存着键的过期时间
  dict *expires;
  //…
)redisDB;

过期相关命令

通过 EXPIRE 命令或者 PEXPIRE 命令,客户端市可以以秒或者毫秒精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为 0 的键;

Redis 有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候会被删除):

  • EXPIRE命令用于将键 key 的生存时间设置为 ttl 秒。

  • PEXPIRE命令用于将键 key 的生存时间设置为 ttl 毫秒。

  • EXPIREAT命令用于将键 key 的过期时间设置为 timestamp 所指定的秒数时间戳。

  • PEXPIREAT命令用于将键 key 的过期时间设置为 timestamp 所指定的毫秒数时间戳。

虽然有多种不同单位和不同形式的设置命令,但实际上 EXPIRE、PEXPIRE、EXPIREAT 三个命令都是使用 PEXPIREAT 命令来实现的:无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果都和执行 PEXPIREAT 命令一样。

image-20220912212755151

6.解除过期

  • PERSIST命令可以移除一个键的过期时间
  • PERSIST命令就是PEXPIREAT命令的反操作:PERSIST命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联

7.返回过期时间

127.0.0.1:6379> ttl msg
(integer) -1

在为键设置了生存时间或者过期时间之后,用户可以使用 TTL 命令或者 PTTL 命令查看键的剩余生存时间,即键还有多久才会因为过期而被移除。

  • 其中,TTL 命令将以秒为单位返回键的剩余生存时间;
  • 而 PTTL 命令则会以毫秒为单位返回键的剩余生存时间;

8.过期删除策略

如果一个键过期了,那么它什么时候会被删除呢?

这个问题有三种可能的答案,它们分别代表了三种不同的删除策略:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。

  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。

  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

Redis 服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用 CPU 时间和避免浪费内存空间之间取得平衡。

惰性删除实现

因为每个被访问的键都可能因为过期而被 expirelfNeeded 函数删除,所以每个命令的实现函数都必须能同时处理键存在以及键不存在这两种情况:

  • 当键存在时,命令按照键存在的情况执行。

  • 当键不存在或者键因为过期而被 expirelfNeeded 函数删除时,命令按照键不存在的情况执行。

定期删除实现

过期键的定期删除策略由 redisc/activeExpireCycle 函数实现,每当 Redis 的服务器周期性操作 redisc/serverCron 函数执行时, activeExpireCycle 函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,并删除其中的过期键。

activeExpireCycle 函数的工作模式可以总结如下:

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
  • 全局变量 currentdb 会记录当前 activeExpireCycle 函数检查的进度,并在下一次 activeExpireCvcle 函数调用时,接着上一次的进度进行处理。比如说,如果当前 activeExpireCycle 函数在遍历 10 号数据库时返回了,那么下次 activeExpireCycle 函数执行时,将从 11 号数据库开始查找并删除过期键。
  • 随着 activeExpireCycle 函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将 currentdb 变量重置为 0,然后再次开始新一轮的检查工作。

9.redis 事件

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

  • 文件事件(fileevent):Redis 服务器通过套接字与客户端(或者其他 Redis 服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。

  • 时间事件(timeevent):Redis 服务器中的一些操作(比如 serverCron 函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

10.文件事件

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(fileeventhandler):

  • 文件事件处理器使用 1/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对
    应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 1/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

11.文件事件处理器

文件事件处理器的四个组成部分,它们分别是

  • 套接字、
  • I/0 多路复用程序
  • 文件事件分派器(dispatcher)
  • 事件处理器

套接字会有序同步到同一个队列

  • 文件事件(套接字):是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、写 入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接 字,所以多个文件事件有可能会并发地出现
  • I/O多路复用程序:负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字
  • 尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序(sequentially)、同步 (synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字 产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),I/O多路复用 程序才会继续向文件事件分派器传送下一个套接字
  • **文件事件分派器:**接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型, 调用相应的事件处理器
  • **事件处理器:**服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数, 它们定义了某个事件发生时,服务器应该执行的动作

image-20220913002848669

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

  • 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器
  • 为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器
  • 为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器
  • 当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器

12.I/O 多路复用实现

Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 select、epoll、evport 和 kqueue 这些!/O 多路复用函数库来实现的,每个 I/O 多路复用函数库在 Redis 源码中都对应一个单独的文件,比如 ae_select.c、ae_epoll.c、ae_kqueue.c,诸如此类。

因为 Redis 为每个 I/O 多路复用函数库都实现了相同的 API,所以 I/O 多路复用程序的底层实现是可以互换的,如图所示。

image-20220913003337017

  • 如果套接字没有任何事件被监听,那么函数返回 AE_NONE。

  • 如果套接字的读事件正在被蓝听,那么函数返回 AE_READABLE。

  • 如果套接字的写事件正在被监听,那么函数返回 AE_WRITABLE。

  • 如果套接字的读事件和写事件正在被监听,那么函数返回 AE_READABLE | AE_WRITABLE.

Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时 自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现

/* Include the best multiplexing layer supported by this system.
* The following should be ordered by performances, descending. */
# ifdef HAVE_EVPORT
# include "ae_evport.c"
# else
    # ifdef HAVE_EPOLL
    # include "ae_epoll.c"
    # else
        # ifdef HAVE_KQUEUE
        # include "ae_kqueue.c"
        # else
        # include "ae_select.c"
        # endif
    # endif
# endif

引用知乎上一个高赞的回答来解释什么是I/O多路复用。假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:

  • 第一种选择:按顺序逐个检查,先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误。这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
  • 第二种选择:你创建30个分身,每个分身检查一个学生的答案是否正确。这种类似于为每一个用户创建一个进程或者- 线程处理连接。
  • 第三种选择,你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。

第一种就是阻塞IO模型,第三种就是I/O复用模型。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QHnuNtcn-1664247547964)(http://qinyingjie.cn/pic/640-20220926183652624.png)]

Linux系统有三种方式实现IO多路复用:select、poll和epoll。

例如epoll方式是将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。

这样,整个过程只在进行select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。

13.一次完整的客户端与服务器连接事件示例

img

  • ①假设一个Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该正处于监听状态之下,而该事件所对应的处理器为连接应答处理器
  • ②如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答, 然后创建客户端套接字,以及客户端状态,并将客户端套接字的AE_READABLE事件与命令 请求处理器进行关联,使得客户端可以向主服务器发送命令请求
  • ③之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给 相关程序去执行
  • ④执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联。当客户端尝试读取命令回复的 时候,客户端套接字将产生AE_WRITABLE事件,触发命令回复处理器执行,当命令回复处 理器将命令回复全部写入到套接字之后,服务器就会解除客户端套接字的AE_WRITABLE事 件与命令回复处理器之间的关联

14.时间事件

Redis 的时间事件分为以下两类:

  • 定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序 X 在当前时间的 30 毫秒之后执行一次。

  • 周期性事件:让一段程序每隔指定时间就执行一次。比如说,让程序 Y 每隔 30 毫秒就执行一次。

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一 ID(标识号)。ID 号按从小到大的顺序递增,新事件的 ID 号比旧事件的 ID 号要大。

  • when:毫秒精度的 UNIX 时间戳,记录了时间事件的到达(arrive)时间。

  • timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件。

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

  • 如果事件处理器返回 ae.h/AE_NOMORE,那么这个事件为定时事件:该事件在达到一次之后就会被删除,之后不再到达。

  • 如果事件处理器返回一个非 AE_NOMORE 的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的 when 属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如说,如果一个时间事件的处理器返回整数值 30,那么服务器应该对这个时间事件进行更新,让这个事件在 30 毫秒之后再次到达。

15.时间事件实现原理

用链表连接起来的三个时间事件

注意,我们说保存时间事件的链表为无序链表,指的不是链表不按 ID 排序,而是说,该链表不按 when 属性的大小排序。正因为链表没有按 when 属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。

image-20220913004355431

16.serverCorn 函数

时间事件应用实例:serverCron函数

持续运行的 Redis 服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由 redis.c/serverCron 函数负责执行,它的主要工作包括:

  • 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。

  • 清理数据库中的过期键值对。

  • 关闭和清理连接失效的客户端。

  • 尝试进行 AOF 或 RDB 持久化操作。

  • 如果服务器是主服务器,那么对从服务器进行定期同步。

  • 如果处于集群模式,对集群进行定期同步和连接测试。

Redis 服务器以周期性事件的方式来运行 serverCron 函数,在服务器运行期间,每隔一段时间,serverCron 就会执行一次,直到服务器关闭为止。

在 Redis2.6 版本,服务器默认规定 serverCron 每秒运行 10 次,平均每间隔 100 毫秒运行一次。

从 Redis28 开始,用户可以通过修改 hz 选项来调整 serverCron 的每秒执行次数,具体信息请参考示例配置文件 redisconf 关于 hz 选项的说明。

17.服务器的事件调度与执行

以下是事件的调度和执行规则:

  • ①aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会 阻塞过长时间
  • ②因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐 渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到 达的时间事件了
  • ③对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处 理器,它们都会尽可地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成 事件饥饿的可能性。比如说,在命令回复处理器将一个命令回复写入到客户端套接字时,如 果写入字节数超过了一个预设常量的话,命令回复处理器就会主动用break跳出写入循环,将 余下的数据留到下次再写;另外,时间事件也会将非常耗时的持久化操作放到子线程或者子 进程执行
  • ④因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚一些

image-20220926093941178

18.redis 事件总结

  • Redis 服务器是一个事件驱动程序,服务器处理的事件分为时间事件和文件事件两类。

  • 文件事件处理器是基于 Reactor 模式实现的网络通信程序。

  • 文件事件是对套接字操作的抽象:每次套接字变为可应答(acceptable)、可写(writable)或者可读(readable)时,相应的文件事件就会产生。

  • 文件事件分为 AEREADABLE 事件(读事件)和 AEWRITABLE 事件(写事件)两类。

  • 时间事件分为定时事件和周期性事件:定时事件只在指定的时间到达一次,而周期性事件则每隔一段时间到达一次。

  • 服务器在一般情况下只执行 serverCron 函数一个时间事件,并且这个事件是周期性事件。

  • 文件事件和时间事件之间是合作关系,服务器会轮流处理这两种事件,并且处理事件的过程中也不会进行抢占。

  • 时间事件的实际处理时间通常会比设定的到达时间晚一些。

三.redis 持久化

1.RDB持久化

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

有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。

  • SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求;
  • 和SAVE命令直接阻塞服务器进程的做法不同,BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求;

SAVE命令执行时服务器的状态

  • 当SAVE命令执行时,Redis服务器会被阻塞,所以当SAVE命令正在执行时,客户端发送的所有命令请求都会被拒绝。
  • 只有在服务器执行完SAVE命令、重新开始接受命令请求之后,客户端发送的命令才会被处理。

RDB文件保存和载入

img

2.BGSAVE命令

因为BGSAVE命令的保存工作是由子进程执行的,所以在子进程创建RDB文件的过程中,Redis服务器仍然可以继续处理客户端的命令请求,但是,在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式会和平时有所不同。

  • 首先,在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件。
  • 其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件。
  • 最后,BGREWRITEAOF和BGSAVE两个命令不能同时执行:
    • 如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行。
    • 如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝。

因为BGREWRITEAOF和BGSAVE两个命令的实际工作都由子进程执行,所以这两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑–并发出两个子进程,并且这两个子进程都同时执行大量的磁盘写入操作,

3.BGSAVE 默认配置

那么只要满足以下三个条件中的任意一个,BGSAVE 命令就会被执行:

  • 服务器在 900 秒之内,对数据库进行了至少 1 次修改。

  • 服务器在 300 秒之内,对数据库进行了至少 10 次修改。

  • 服务器在 60 秒之内,对数据库进行了至少 10000 次修改。

对应的配置文件信息在saveparams数组中

save 900 1  //900秒内如果超过1个key被修改,则发起快照保存
save 300 10  //300秒内容如超过10个key被修改,则发起快照保存
save 60 10000  
struct redisServer {
    // ...
    struct saveparam *saveparams;//记录了save保存条件的数组
    // ...
};
struct saveparam {
    time_t seconds;//秒数
    int changes;//修改数
};

4.dirty和lastsave

dirty计数器和lastsave属性

除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。
struct redisServer {
    // ...
    long long dirty;//修改计数器
    time_t lastsave;//上一次执行保存的时间
    // ...
};

5.检查函数

Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。

def serverCron():
  # ...
  # 遍历所有保存条件
  for saveparam in server.saveparams:
    # 计算距离上次执行保存操作有多少秒
    save_interval = unixtime_now()-server.lastsave

    # 如果数据库状态的修改次数超过条件所设置的次数
    # 并且距离上次保存的时间超过条件所设置的时间
    # 那么执行保存操作
    if server.dirty >= saveparam.changes and \
    save_interval > saveparam.seconds:
      BGSAVE()
      # ...

6.RDB 文件结构

RDB 文件为二进制格式保存,下面我们为了演示效果,采用字符串的形式演示

image-20220912225748864

  • REDIS(常量):RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着“REDIS”五个字符。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否RDB文件
  • db_version(变量):长度为4字节,它的值是一个字符串表示的整数,这个整数记录了RDB文件的版本号,比如"0006"就代表RDB文件的版本为第六版
  • databases(变量):databases部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据
    • 如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为0字节
    • 如果服务器的数据库状态为非空(有至少一个数据库非空),那么这个部分也为非空,根据数据库所保存键值对的数量、类型和内容不同,这个部分的长度也会有所不同
  • EOF(常量):EOF常量的长度为1字节,这个常量标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对都已经载入完毕了
  • check_sum(变量):check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四个部分的内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现

7.database 结构

一个RDB文件的databases部分可以保存任意多个非空数据库。例如,如果服务器的0号数据库和3号数据库非空,那么服务器将创建一个如下图所示的RDB文件

img

img

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

  • SELECTDB常量:长度为1字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码
  • db_number:保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是1字节、2字节或者5字节。当程序读入db_number部分之后,服务器会调用SELECT命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中
  • key_value_pairs部分:保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key_value_pairs部分的长度也会有所不同

下图则展示了一个完整的RDB文件,文件中包含了0号数据库和3号数据库

img

8.key_value_pairs结构

key_value_pairs都保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对的过期时间也会被保存在内

不带过期时间的键值对结构
不带过期时间的键值对在RDB文件中由TYPE、key、value3部分组成:

  • TYPE:记录了value的类型,长度为1字节,值可以是以下常量的其中一个(以下列出的每个TYPE常量都代表了一种对象类型或者底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE的值来决定如何读入和解释value的数据。key和value分别保存了键值对的键对象和值对象):
    • REDIS_RDB_TYPE_STRING
    • REDIS_RDB_TYPE_LIST
    • REDIS_RDB_TYPE_SET
    • REDIS_RDB_TYPE_ZSET
    • REDIS_RDB_TYPE_HASH
    • REDIS_RDB_TYPE_LIST_ZIPLIST
    • REDIS_RDB_TYPE_SET_INTSET
    • REDIS_RDB_TYPE_ZSET_ZIPLIST
    • REDIS_RDB_TYPE_HASH_ZIPLIST
  • key:其中key总是一个字符串对象,它的编码方式和REDIS_RDB_TYPE_STRING类型的value一样。根据内容长度的不同,key的长度也会有所不同
  • value:根据TYPE类型的不同,以及保存内容长度的不同,保存value的结构和长度也会有所不同

下图展示了一个没有过期时间的字符串键值对:

img

带有过期时间的键值对结构
带有过期时间的键值对在RDB文件中由EXPIRETIME_MS、ms、TYPE、key、value五部分组成:

  • EXPIRETIME_MS常量:长度为1字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间
  • ms:是一个8字节长的带符号整数,记录着一个以毫秒为单位的UNIX时间戳,这个时间戳就是键值对的过期时间

img

假设0号数据库中有2个不带有过期时间的键值对,数据库3含有1个不带有过期时间的键值对和1个带有过期时间的键值对

img

9.TYPE字段与value的编码

RDB 文件中的每个 value 部分都保存了一个值对象,每个值对象的类型都由与之对应的 TYPE 记录,根据类型的不同,value 部分的结构、长度也会有所不同

9.1.字符串对象

如果TYPE的值为REDIS_RDB_TYPE_STRING,那么value保存的就是一个字符串对象,字符串对象的编码可以是REDIS_ENCODING_INT或者REDIS_ENCODING_RAW
①如果字符串对象的编码为REDIS_ENCODING_INT

那么说明对象中保存的是长度不超过32位的整数,这种编码的对象将以下图所示的结构保存

其中,ENCODING的值可以是REDIS_RDB_ENC_INT8、REDIS_RDB_ENC_INT16或者REDIS_RDB_ENC_INT32三个常量的其中一个,它们分别代表RDB文件使用8位(bit)、16位或者32位来保存整数值integer。举个例子,如果字符串对象中保存的是可以用8位来保存的整数123,那么这个对象在RDB文件中保存的结构将如下图所示

②如果字符串对象的编码为REDIS_ENCODING_RAW

那么说明对象所保存的是一个字符串值,根据字符串长度的不同,有压缩和不压缩两种方法来保存这个字符串:

  • 如果字符串的长度小于等于20字节,那么这个字符串会直接被原样保存
  • 如果字符串的长度大于20字节,那么这个字符串会被压缩之后再保存

备注:以上两个条件是在假设服务器打开了RDB文件压缩功能的情况下进行的,如果服务器关闭了RDB文件压缩功能,那么RDB程序总以无压缩的方式保存字符串值,对于没有被压缩的字符串,RDB程序会以下图所示的结构来保存该字符串

  • string部分保存了字符串值本身
  • len保存了字符串值的长度

对于压缩后的字符串,RDB程序会以下图所示的结构来保存该字符串
其中,REDIS_RDB_ENC_LZF常量标志着字符串已经被LZF算法(http://liblzf.plan9.de)压缩过了,读入程序在碰到这个常量时,会根据之后的compressed_len、origin_len和compressed_string三部分,对字符串进行解压缩:其中compressed_len记录的是字符串被压缩之后的长度,而origin_len记录的是字符串原来的长度,compressed_string记录的则是被压缩之后的字符串

9.2.列表对象

如果TYPE的值为REDIS_RDB_TYPE_LIST,那么value保存的就是一个REDIS_ENCODING_LINKEDLIST编码的列表对象
RDB文件保存这种对象的结构如下图所示:

img

  • list_length记录了列表的长度,它记录列表保存了多少个项(item),读入程序可以通过这个长度知道自己应该读入多少个列表项
  • 图中以item开头的部分代表列表的项,因为每个列表项都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入列表项

示例

img

  • 第一个列表项的长度为5,内容为字符串"hello"。
  • 第二个列表项的长度也为5,内容为字符串"world"。
  • 第三个列表项的长度为1,内容为字符串"!"
9.3.集合对象

如果TYPE的值为REDIS_RDB_TYPE_SET,那么value保存的就是一个REDIS_ENCODING_HT编码的集合对象
RDB文件保存这种对象的结构如下图所示:

img

  • set_size是集合的大小,它记录集合保存了多少个元素,读入程序可以通过这个大小知道自己应该读入多少个集合元素
  • 以elem开头的部分代表集合的元素,因为每个集合元素都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入集合元素

示例,下图展示了一个包含四个元素的集合,结构中的第一个数字4记录了集合的大小,之后跟着的是集合的四个元素:

  • 第一个元素的长度为5,值为"apple"
  • 第二个元素的长度为6,值为"banana"
  • 第三个元素的长度为3,值为"cat"
  • 第四个元素的长度为3,值为"dog”

img

9.4.哈希表对象

如果TYPE的值为REDIS_RDB_TYPE_HASH,那么value保存的就是一个REDIS_ENCODING_HT编码的集合对象
RDB文件保存这种对象的结构如下图所示:

img

  • hash_size记录了哈希表的大小,也即是这个哈希表保存了多少键值对,读入程序可以通过这个大小知道自己应该读入多少个键值对
  • 以key_value_pair开头的部分代表哈希表中的键值对,键值对的键和值都是字符串对象,所以程序会以处理字符串对象的方式来保存和读入键值对

结构中的每个键值对都以键紧挨着值的方式排列在一起,如下图所示:

img

因此,从更详细的角度看,哈希表对象的结构可以表示为下图所示:

img

作为示例,下图展示了一个包含两个键值对的哈希表,第一个数字2记录了哈希表的键值对数量,之后跟着的是两个键值对:

  • 第一个键值对的键是长度为1的字符串"a",值是长度为5的字符串"apple"
  • 第二个键值对的键是长度为1的字符串"b",值是长度为6的字符串"banana"

img

9.5.有序集合对象

如果TYPE的值为REDIS_RDB_TYPE_ZSET,那么value保存的就是一个REDIS_ENCODING_SKIPLIST编码的有序集合对象
RDB文件保存这种对象的结构如下图所示:

img

sorted_set_size记录了有序集合的大小,也即是这个有序集合保存了多少元素,读入程序需要根据这个值来决定应该读入多少有序集合元素
以element开头的部分代表有序集合中的元素,每个元素又分为成员(member)和分值(score)两部分,成员是一个字符串对象,分值则是一个double类型的浮点数,程序在保存RDB文件时会先将分值转换成字符串对象,然后再用保存字符串对象的方法将分值保存起来

有序集合中的每个元素都以成员紧挨着分值的方式排列,下图所示:

img

因此,从更详细的角度看,有序集合对象的结构可以表示为下图所示:

img

作为示例,下图展示了一个带有两个元素的有序集合,第一个数字2记录了有序集合的元素数量,之后跟着的是两个有序集合元素:

  • 第一个元素的成员是长度为2的字符串"pi",分值被转换成字符串之后变成了长度为4的字符串"3.14"
  • 第二个元素的成员是长度为1的字符串"e",分值被转换成字符串之后变成了长度为3的字符串"2.7"

img

9.6.INTSET 编码的集合
  • 如果TYPE的值为REDIS_RDB_TYPE_SET_INTSET,那么value保存的就是一个整数集合对象
  • RDB文件保存这种对象的方法是,先将整数集合转换为字符串对象,然后将这个字符串对象保存到RDB文件里面
  • 如果程序在读入RDB文件的过程中,碰到由整数集合对象转换成的字符串对象,那么程序会根据TYPE值的指示,先读入字符串对象,再将这个字符串对象转换成原来的整数集合对象
9.7.ZIPLIST 编码的列表、哈希表或者有序集合
  • 如果TYPE的值为REDIS_RDB_TYPE_LIST_ZIPLIST、REDIS_RDB_TYPE_HASH_ZIPLIST或者REDIS_RDB_TYPE_ZSET_ZIPLIST
  • 那么value保存的就是一个压缩列表对象,RDB文件保存这种对象的方法是:
    • 将压缩列表转换成一个字符串对象
    • 将转换所得的字符串对象保存到RDB文件。
  • 如果程序在读入RDB文件的过程中,碰到由压缩列表对象转换成的字符串对象,那么程序会根据TYPE值的指示,执行以下操作:
    • 读入字符串对象,并将它转换成原来的压缩列表对象
    • 根据TYPE的值,设置压缩列表对象的类型:如果TYPE的值为REDIS_RDB_TYPE_LIST_ZIPLIST,那么压缩列表对象的类型为列表;如果TYPE的值为REDIS_RDB_TYPE_HASH_ZIPLIST,那么压缩列表对象的类型为哈希表;如果TYPE的值为REDIS_RDB_TYPE_ZSET_ZIPLIST,那么压缩列表对象的类型为有序集合(从这个步骤可以看出,由于TYPE的存在,即使列表、哈希表和有序集合三种类型都使用压缩列表来保存,RDB读入程序也总可以将读入并转换之后得出的压缩列表设置成原来的类型)

10.RDB 对过期处理

在执行 SAVE 命令或者 BGSAVE 命令创建一个新新的 RDB 文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中。

将对 RDB 文件进行载入:

  • 如果服务器以主服务器模式运行,那么在载入 RDB 文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入 RDB 文件的主服务器不会造成影响。
  • 如果服务器以从服务器模式运行,那么在载入 RDB 文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入 RDB 文件的从服务器也不会造成影响。

11.AOF 持久化

除了 RDB 持久化功能之外,Redis 还提供了 AOF(Append Only File)持久化功能。与 RDB 持久化通过保存数据库中的键值对来记录数据库状态不同,AOF 持久化是通过保存 Re dis 服务器所执行的写命令来记录数据库状态的

image-20220912235833110

12.AOF实现

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

命令追加

当 AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾;

文件写入

Redis 的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像 serverCron 函数这样需要定时运行的函数。因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到 aof buf 缓冲区里面,所以在服务器每次结束一个事件循环之前,它都会调用 flushAppendOnlyFile 函数,考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面.

文件同步

flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定,各个不同同值产生的行为如表:

  • appendfsync no: 当设置 appendfsync 为 no 时,Redis 不会主动调用 fsync 去将 aof 日志同步到磁盘,完全依赖于操作系统

  • appendfsync everysec: 当设置为 appendfsync 为 everysec 的时候,Redis 会默认每隔一秒进行一次 fsync 调用,将缓冲区中的数据写到磁盘。默认是这种方式.

  • appendfsync always: 当设置 appendfsync 为 always 时,每一次写操作都会调用一次 fsync,这时数据是最安全的,当然,由于每次都会执行 fsync,所以其性能也会受到影响。

系统提供了 fsync 和 fdatasync 两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。

appendonly yes           #启用aof持久化方式
# appendfsync always     #每次收到写命令就立即强制写入磁盘,最慢的,但是保证完全的持久化,不推荐使用
appendfsync everysec     #每秒钟强制写入磁盘一次,在性能和持久化方面做了很好的折中,推荐
# appendfsync no         #完全依赖os,性能最好,持久化没保证

13.AOF数据还原

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态

Redis读取AOF文件并还原数据库状态的详细步骤如下:

  1. 创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
  2. 从AOF文件中分析并读取出一条写命令。
  3. 使用伪客户端执行被读出的写命令。
  4. 一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止。

14.AOF重写

因为AOF持久化是通过保存被执行的写命令来记录数据库状态的。所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。
为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写(rewrite)功能。通过该功能,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新日两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比旧AOF文件的体积要小得多。节省空间.

15.AOF重写实现

虽然 Redis 将生成新 AOF 文件替换旧 AOF 文件的功能命名为 AOF 文件重写”,但实际上,AOF 文件重写并不需要对现有的 AOF 文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。copy on write

所有类型的键都可以用同样的方法去减少 AOF 文件中的命令数量。 首先从数据库中读取键现在的值,然后用一条命令去记录键值对, 代替之前记录这个键值对的多条命令,这就是 AOF 重写功能的实现原理。

因为 aof_rewrite 函数生成的新 AOF 文件只包含还原当前数据库状态所必须的命令,所以新 AOF 文件不会浪费任何硬盘空间。

在实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键所包含的元素数量,如果元素的数量超过了 redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD 常量的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。

在目前版本中,REDIS_AOF_REWRITE_ITEMS_PER_CMD 常量的值为 64,这也就是说,如果一个集合键包含了超过 64 个元素,那么重写程序会用多条 SADD 命令来记录这个集合,并且每条命令设置的元素数量也为 64 个;

为了解决服务器进程和子进程这种数据不一致问题Redis 服务器设置了一个 AOF 重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当 Redis 服务器执行完一个写命令之后,它会同时将这个写命令发送给 AOF 缓冲区和 AOF 重写缓冲区.

这也就是说,在子进程执行 AOF 重写期间,服务器进程需要执行以下三个工作:

  • 1)执行客户端发来的命令。

  • 2)将执行后的写命令追加到 AOF 缓冲区。

  • 3)将执行后的写命令追加到 AOF 重写缓冲区。

16.AOF 保存的命令

  • 对键值对的修改命令
  • PUBSUB 命令
  • SCRIPT LOAD 命令

AOF工作原理

  • AOF重写和rdb创建快照一样,都是巧妙的利用了写时复制机制:

  • redis调用fork ,现在有父子两个进程子进程

  • 根据内存中的数据库快照,往临时文件中写入重建数据库状态的命令

  • 父进程继续处理client请求,除了把写命令写入到原来的aof文件中。同时把收到的写命令缓存起来。这样就能保证如果子进程重写失败的话并不会出问题。

  • 当子进程把快照内容写入已命令方式写到临时文件中后,子进程发信号通知父进程。然后父进程把缓存的写命令也写入到临时文件。

  • 现在父进程可以使用临时文件替换老的aof文件,并重命名,后面收到的写命令也开始往新的aof文件中追加。

17.AOF后台重写

BGREWRITEAOF 实现原理

aof_rewrite函数的缺点:上面介绍的AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务, 但是,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因 为Redis服务器使用单个线程来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的 话,那么在重写AOF文件期间,服务期将无法处理客户端发来的命令请求
很明显,作为一种辅佐性的维护手段,Redis不希望AOF重写造成服务器无法处理请求, 所以Redis决定将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况 下,保证数据的安全性

image-20220913002434231

18.AOF 对过期处理

当服务器以 AOF 持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么 AOF 文件不会因头这个过期键而产生任何影响。

当过期键被惰性册除或者定期删除之后,程序会向 AOF 文件追加(append)一条 DEL 命令,来显式地记录该键已被删除。

19.RDB 和 AOF 选择

redis使用了2种方式进行持久化:

  • Snapshotting(快照,默认方式):能够在指定的时间间隔对你的操作进程快照存储
  • Append-only file(缩写aof):记录每次对服务器写的操作,当服务器重启时会重新执行这些命令来恢复原始的数据

RDB和AOF兼容性:

  • 默认情况下,Redis将数据库快照保存在一个dump.rdb的二进制文件中。

  • 如果服务器开启了 AOF 持久化功能,那么服务器会优先使用 AOF 文件来还原数据库状态。

  • 只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态。

20.copy-on-write技术

写时复制(copy-on-write/COW)技术:

写入时复制(Copy-on-write)是一个被使用在程式设计领域的最佳化策略。其基础的观念是,如果有多个呼叫者(callers)同时要求相同资源,他们会共同取得相同的指标指向相同的资源,直到某个呼叫者(caller)尝试修改资源时,系统才会真正复制一个副本(private copy)给该呼叫者,以避免被修改的资源被直接察觉到,这过程对其他的呼叫只都是通透的(transparently)。此作法主要的优点是如果呼叫者并没有修改该资源,就不会有副本(private copy)被建立。

这是一种简单的读写分离思想,适用于读多写少的并发场景。比如黑白名单,热点文章等等。正常情况下我们说cow,指的是修改共享资源时,将共享资源copy一份,加锁后修改,再将原容器的引用指向新的容器。对于java来说,是有线程的cow容器的,比如CopyOnWriteArrayList。另外就是cow保证的是最终一致性而不是强一致。

  • RDB
  • aof重写

Redis的cow

  • Redis创建子进程以后,根本不进行数据的copy,主进程与子线程是共享数据的。主进程继续对外提供读写服务。
  • 虽然不copy数据,但是kernel会把主进程中的所有内存页的权限都设为read-only,主进程和子进程访问数据的指针都指向同一内存地址。
  • 主进程发生写操作时,因为权限已经设置为read-only了,所以会触发页异常中断(page-fault)。在中断处理中,需要被写入的内存页面会复制一份,复制出来的旧数据交给子进程使用,然后主进程该干啥就干啥。
  • 也就是说,在进行IO操作写盘的过程中(on write),对于没有改变的数据,主进程和子进程资源共享;只有在出现了需要变更的数据时(写脏的数据),才进行copy操作。
  • 在最理想的情况下,也就是生成RDB文件的过程中,一直没有写操作的话,就根本不会发生内存的额外占用。

21.混合模式

  • 4.0之后新增混合模式,aof文件中保存rdb二进制数据+少量aof
  • 体积小,可读性差
  • aof-use-rdb-preamble true

四.redis客户端

1.服务端客户端交互

  • Redis服务器是典型的一对多服务器程序:一个服务器可以与多个客户端建立网络连接, 每个客户端可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复
  • Redis服务器通过使用由I/O多路复用技术实现的文件事件处理器,Redis服务器使用单线程单进程的方式来处理命令请求,并与多个客户端进行网络通信
struct redisServer {
    // ...
    list *clients;// 一个链表,保存了所有客户端状态
    // ...
};
  • Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客 户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成
  • 作为例子,左图展示了一个与三个客户端进行连接的服务器,而右图则展示了这个服务器的clients链表的样子

image-20220923104220097

Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成;

2.redisClient 结构

typedef struct client {
   uint64_t id; 	// 客户端唯一ID,通过全局变量server.next_client_id实现。
   int fd; 				// socket的文件描述符。
   redisDb *db; 	// select命令选择的数据库对象
   robj *name; 		// 客户端名称,可以使用命令CLIENT SETNAME设置。
   time_t lastinteraction // 客户端上次与服务器交互的时间,以此实现客户端的超时处理。
   sds querybuf;  //输入缓冲区,recv函数接收的客户端命令请求会暂时缓存在此缓冲区。
   int argc;
   robj **argv;
   struct redisCommand *cmd;
   list *reply;
   unsigned long long reply_bytes;
   size_t sentlen;
   char buf[PROTO_REPLY_CHUNK_BYTES];
   int bufpos;
} client;

属性说明

  • id为客户端唯一ID,通过全局变量server.next_client_id实现。

  • fd为客户端socket的文件描述符。

  • db为客户端使用select命令选择的数据库对象。

  • name:客户端名称,可以使用命令CLIENT SETNAME设置。

  • lastinteraction:客户端上次与服务器交互的时间,以此实现客户端的超时处理。

  • querybuf:输入缓冲区,recv函数接收的客户端命令请求会暂时缓存在此缓冲区。

  • argc:输入缓冲区的命令请求是按照Redis协议格式编码字符串,需要解析出命令请求的所有参数,参数个数存储在argc字段,参数内容被解析为robj对象,存储在argv数组。

  • cmd:待执行的客户端命令;解析命令请求后,会根据命令名称查找该命令对应的命令对象,存储在客户端cmd字段,可以看到其类型为struct redisCommand。

  • reply:输出链表,存储待返回给客户端的命令回复数据。链表节点存储的值类型为clientReplyBlock

typedef struct clientReplyBlock {
 size_t size, used;
 char buf[];
} clientReplyBlock;

可以看到链表节点本质上就是一个缓冲区(buffffer),其中size表示缓冲区空间总大小,used表示缓冲区已使用空间大小。

  • reply_bytes:表示输出链表中所有节点的存储空间总和;
  • sentlen:表示已返回给客户端的字节数;
  • buf:输出缓冲区,存储待返回给客户端的命令回复数据,
  • bufpos表示输出缓冲区中数据的最大字节位置,显然sentlen~bufpos区间的数据都是需要返回给客户端的。可以看到reply和buf都用于缓存待返回给客户端的命令回复数据,为什么同时需要reply和buf的存在呢?其实二者只是用于返回不同的数据类型而已,将在后面讲解。

对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的 redis.h/redisClient 结构(客户端状态),这个结构保存了客户端当前的状态信息,以及执行相关功能时需要用到的数据结构,其中包括:

  • 客户端的套接字描述符。

  • 客户端的名字。

  • 客户端的标志值(flag)。

  • 指向客户端正在使用的数据库的指针,以及该数据库的号码。

  • 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针。

  • 客户端的输入缓冲区和输出缓冲区。

  • 客户端的复制状态信息,以及进行复制所需的数据结构。

  • 客户端执行 BRPOP、BLPOP 等列表阻塞命令时使用的数据结构。

  • 客户端的事务状态,以及执行 WATCH 命令时用到的数据结构。

  • 客户端执行发布与订阅功能时用到的数据结构。

  • 客户端的身份验证标志。

  • 客户端的创建时间,客户端和服务器最后一次通信的时间,以及客户端的输出缓冲区大小超出软性限制(soft limit)的时间。

3.套接字描述符

客户端状态的fd属性记录了客户端正在使用的套接字描述符:

根据客户端类型的不同,fd属性的值可以是-1或者是大于-1的整数:

  • 伪客户端(fake client)的fd属性的值为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis服务器会在两个地方用到伪客户端,一个用于载入AOF文件并还原数据库状态,而另一个则用于执行Lua脚本中包含的Redis命令。
  • 普通客户端的fd属性的值为大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。因为合法的套接字描述符不能是-1,所以普通客户端的套接字描述符的值必然是大于-1的整数。
127.0.0.1:6379> client list
id=9 addr=127.0.0.1:56659 laddr=127.0.0.1:6379 fd=8 name= age=5 idle=0 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 qbuf=26 qbuf-free=16864 argv-mem=10 multi-mem=0 rbs=1024 rbp=0 obl=0 oll=0 omem=0 tot-mem=18682 events=r cmd=client|list user=default redir=-1 resp=2

4.客户端名字

名字(name属性),在默认情况下,一个连接到服务器的客户端是没有名字的,name属性为空。可以手动设置.使用CLIENT setname命令可以为客户端设置一个名字。

5.客户端标志

flags标志:客户端的标志属性flags记录了客户端的角色(role),以及客户端目前所处的状态;

每个标志使用一个常量表示,一部分标志记录了客户端的角色:

在主从服务器进行复制操作时,主服务器会成为从服务器的客户端,而从服务器也会成为主服务器的客户端。

  • REDIS_MASTER标志表示客户端代表的是一个主服务器,REDIS_SLAVE标志表示客户端代表的是一个从服务器。
  • REDIS_PRE_PSYNC标志表示客户端代表的是一个版本低于Redis2.8的从服务器,主服务器不能使用PSYNC命令与这个从服务器进行同步。这个标志只能在REDIS_SLAVE标志处于打开状态时使用。
  • REDIS_LUA_CLIENT标识表示客户端是专门用于处理Lua脚本里面包含的Redis命令的伪客户端。而另外一部分标志则记录了客户端目前所处的状态;
  • REDIS_MONITOR标志表示客户端正在执行MONITOR命令。
  • REDIS_UNIX_SOCKET标志表示服务器使用UNIX套接字来连接客户端。
  • REDIS_BLOCKED标志表示客户端正在被BRPOP、BLPOP等命令阻塞。
  • REDIS_UNBLOCKED标志表示客户端已经从REDIS_BLOCKED标志所表示的阻塞状态中脱离出来,不再阻塞。REDIS_UNBLOCKED标志只能在REDIS_BLOCKED标志已经打开的情况下使用。
  • REDIS_MULTI标志表示客户端正在执行事务。
  • REDIS_DIRTY_CAS标志表示事务使用WATCH命令监视的数据库键已经被修改,REDIS_DIRTY_EXEC标志表示事务在命令入队时出现了错误,以上两个标志都表示事务的安全性已经被破坏,只要这两个标记中的任意一个被打开,EXEC命令必然会执行失败。这两个标志只能在客户端打开了REDIS_MULTI标志的情况下使用。
  • REDIS_CLOSE_ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围,服务器会在下一次执行serverCron函数时关闭这个客户端,以免服务器的稳定性受到这个客户端影响。积存在输出缓冲区中的所有内容会直接被释放,不会返回给客户端。
  • REDIS_CLOSE_AFTER_REPLY标志表示有用户对这个客户端执行了CLIENT KILL命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存在输出缓冲区中的所有内容发送给客户端,然后关闭客户端。
  • REDIS_ASKING标志表示客户端向集群节点(运行在集群模式下的服务器)发送了ASKING命令。
  • REDIS_FORCE_AOF标志强制服务器将当前执行的命令写入到AOF文件里面,REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。执行PUBSUB命令会使客户端打开REDIS_FORCE_AOF标志,执行SCRIPT LOAD命令会使客户端打开REDIS_FORCE_AOF标志和REDIS_FORCE_REPL标志。

在主从服务器进行命令传播期间,从服务器需要向主服务器发送 REPLICATION_ACK 命令,在发送这个命令之前,从服务器必须打开主服务器对应的客户端的 REDIS_MASTER_FORCE_REPLY 标志,否则发送操作会被拒绝执行。

两种特殊客户端

  • 服务器会在初始化时创建负责执行 Lua 脚本中包含的 Redis 命令的伪客户端,并将这个伪客户端关联在服务器状态结构的 luaclient 属性中:
  • lua_client 伪客户端在服务器运行的整个生命!期中会一直存在,只有服务器被关闭时,这 个客户端才会被关闭。
  • 服务器在载入 AOF 文件时,会创建用于执行 AOF 文件包含的 Redis 命令的伪客户端,并在 载入完成之后,关闭这 个伪客户端。

6.输入缓冲区

querybuf属性,对应输入缓冲区

客户端状态的输入缓中区用于保存客户端发送送的命令请求;可以动态的扩大和缩小,最大不能超过 1G,否则会关闭这个客户端.

7.命令与命令参数

在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分机斤,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:

argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。

argc属性则负责记录argv数组的长度。

image-20220913223259791

命令表中使用字典保存着 redis 的命令字典.且不区分大小写.

8.命令的实现函数

当服务器从协议内容中分析并得出argv属性和argc属性的值之后,服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数

命令表格式,下图展示了一个命令表示例,该表是一个字典,字典的键是一个SDS结构,保存了命令的名字,字典的值是命令所对应的redisCommand结构,这个结构保存了命令的实现函数、 命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息

image-20220923112329755

  • 当程序在命令表中成功找到argv[0]所对应的redisCommand结构时,它会将客户端状态的cmd指针指向这个结构
  • 之后,服务器就可以使用cmd属性所指向的redisCommand结构,以及argv、argc属性中保存的命令参数信息,调用命令实现函数,执行客户端指定的命令
  • 服务器在argv[0]为"SET"时,查找命令表并将客户端状态的cmd指针指向目标redisCommand结构
  • 针对命令表的查找操作不区分输入字母的大小写,所以无论argv[0]是"SET"、“set”、或 者"SeT"等等,查找的结果都是相同的

9.输出缓冲区

执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的:

  • 固定大小的缓冲区用干保存那些长度比较小的回复,比如 OK、简短的字符串值、整数值、错误回复等等。
  • 可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值,一个由很多项组成的列表,一个包含了很多元素的集合等等。

客户端的固定大小缓冲区由 buf 和 bufpos 两个属性组成:

  • buf 是一个大小为 REDIS_REPLY_CHUNK_BYTES 字节的字节数组,而 bufpos 属性则记录了 buf 数组目前已使用的字节数量。
  • REDIS_REPLY_CHUNK_BYTES 常量目前的默认值为 16*1024,也即是说,buf 数组的默认大小为 16KB。

通过使用链表来连接多个字符串对象,服务器可以为客户端保存一个非常长的命令回复,而不必受到固定大小缓冲区 16KB 大小的限制。

服务器使用两种模式来限制客户端输出缓冲区的大小:

  • 硬性限制(hardlimit):如果输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器立即关闭客户端。
  • 软性限制(softlimit):如果输出缓冲区的大小超过了软性限制所设置的大小,但还没超过硬性限制,那么服务器将使用客户端状态结构的 obuf_soft_limit_reached_time 属性记录下客户端到达软性限制的起始时间;之后服务器会继续监视客户端,如果输出缓冲区的大小一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端;相反地,如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么客户端就不会被关闭,并且 obuf_soft_limit_reachedtime 属性的值也会被清零。

10.身份验证

客户端状态的 authenticated 属性用于记录客户 端是否通过了身份验证:

  • 如果 authenticated 的的值为 0,那么表示客户端未通过身份验证;
  • 如果 authenticated 的值为 1,那么表示客户端已经通过了身份验证。

当客户端 authenticated 属性的值为 0 时,除除了 AUTH 命令之外,客户端发送的所有其他命令都会被服务器拒绝执行;

11.客户端时间属性

typedef struct redisClient {
    // ...
    time_t ctime;
    time_t lastinteraction;
    time_t obuf_soft_limit_reached_time;
    // ...
} redisClient;

ctime 属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连接了多少秒,CLIENT Iist 命令的 age 域记录了这个秒数;

lastinteraction 属性

  • 记录了客户端与服务器最后一次进行互动(interaction)的时间,这里的互动可以是客户端向服务器发送命令请求,也可以是服务器向客户端发送命令回复
  • 可以用来计算客户端的空转(idle)时间,也即是,距离客户端与服务器最后一次进行互动以来,已经过去了多少秒,CLIENT list 命令的 idle 域记录了这个秒数

obuf_soft_limit_reachedtime 属性记录了输出缓冲区大小第一次到达软性限制(soft limit)的时间.

12.客户端关闭原因

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

  • 如果客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而 造成客户端被关闭
  • 如果客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端也会被服 务器关闭
  • 如果客户端成为了CLIENT KILL命令的目标,那么它也会被关闭
  • 如果用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout选项 设置的值时,客户端将被关闭。不过timeout选项有一些例外情况:如果客户端是主服务器 (打开了REDIS_MASTER标志),从服务器(打开了REDIS_SLAVE标志),正在被BLPOP 等命令阻塞(打开了REDIS_BLOCKED标志),或者正在执行SUBSCRIBE、PSUBSCRIBE等 订阅命令,那么即使客户端的空转时间超过了timeout选项的值,客户端也不会被服务器关 闭
  • 如果客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1 GB),那么这个客户端会被服务器关闭
  • 如果要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小,那么这个客户端 会被服务器关闭

13.客户端总结

  • 服务器状态结构使用 clients 链表连接起多个客户端状态,新添加的客户端状态会被放到链表的末尾。

  • 客户端状态的 flags 属性使用不同标志来表示客户端的角色,以及客户端当前所处的状态。

  • 输入缓冲区记录了客户端发送的命令请求,这个缓冲区的大小不能超过 1GB。

  • 命令的参数和参数个数会被记录在客户端状态的 argv 和 argc 属性里面,而 cmd 属性则记录了客户端要执行命令的实现函数。

  • 客户端有固定大小缓冲区和可变大小缓冲区两种缓冲区可用,其中固定大小缓冲区的最大大小为 16KB,而可变大小缓冲区的最大大小不能超过服务器设置的硬性限制值。

  • 输出缓冲区限制值有两种,如果输出缓冲区的大小超过了服务器设置的硬性限制,那么客户端会被立即关闭;除此之外,如果客户端

  • 在一定时间内,一直超过服务器设置的软性限制,那么客户端也会被关闭。

  • 当一个客户端通过网络连接连上服务器时,服务器会为这个客户端创建相应的客户端状态。网络连接关闭、发送了不合协议格式的命令请求、成为 CLIENT KILL 命令的目标、空转时间超时、输出缓冲区的大小超出限制,以上这些原因都会造成客户端被关闭。

  • 处理 Lua 脚本的伪客户端在服务器初始化时创建,这个客户端会一直存在,直到服务器关闭。

  • ·载入 AOF 文件时使用的伪客户端在载入工作开始时动态创建,载入工作完毕之后关闭。

五.redis服务端

1.服务端命令的执行过程

  • 客户端向服务器发送命 令请求 SET KEY VALUE。

  • 服务器接收并处理客户端发来的命令请求 SET KEY VALUE,在数据库中进行设置操作,并产生命令回复 OK。

  • 服务器将命令回复 OK 发送给客户端。

  • 客户端接收服务器返回的命令回复 OK,并将这个回复打印给用户观看。

2.发送命令请求

客户端从用户得到命令请求后转换为协议格式,再连接服务器的套接字,再将协议格式的命令发送给服务器

3.读取命令请求

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

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

4.命令执行器

查找命令

第一件事根据 argv[0] 参数在命令表中查找命令,并保存到客户端状态的 cmd 属性中命令表是一个字典,键为字符串对象,值为 redisCommand 结构记录 redis 命令实现信息 redisCommand 结构主要属性:

img

struct redisCommand {
  char *name;//命令名
  redisCommandProc *proc;//命令执行函数
  int arity; //参数个数,-N代表参数个数>=N,正数表示参数个数为N
  char *sflags; //命令的sflags属性字符串
  int flags;    //从sflags获取的整数mask值
  //获取key参数的可选函数,当下面3种情况都无法确定key参数的时候才需要使用该函数
  redisGetKeysProc *getkeys_proc;
  int firstkey; //第一个key的位置,0表示没有key
  int lastkey;  //最后一个key的位置;负数计算为正数第(argc+lastkey)个
  int keystep;  //参数为 key,val,key,val,...格式,第一个和最后一个key之间的key跨步
  long long microseconds;//命令从服务启动到现在的执行时间,单位:微秒
  long long calls;//命令从服务启动到现在的执行的次数
};

属性说明

  • name:命令名称。

  • proc:命令处理函数。

  • arity:命令参数数目,用于校验命令请求格式是否正确;当arity小于0时,表示命令参数数目大于等于arity;当arity大于0时,表示命令参数数目必须为arity;注意命令请求中,命令的名称本身也是一个参数,如get命令的参数数目为2,命令请求格式为get key。

  • sflags:命令标志,例如标识命令时读命令还是写命令,详情参见表9-2;注意到sflags的类型为字符串,此处只是为了良好的可读性。

  • flags:命令的二进制标志,服务器启动时解析sflags字段生成。

  • calls:从服务器启动至今命令执行的次数,用于统计。

  • microseconds:从服务器启动至今命令总的执行时间,

  • microseconds/calls即可计算出该命令的平均处理时间,用于统计。

属性中的 sflags 属性可以使用的标识符有:

img

执行预操作

  • 执行命令之前还需要一些预操作

  • 检查客户端状态的 cmd 是否指向 NULL,是则找不到命令实现,返回错误

  • 根据 cmd 指向的 redisCommand 结构的 arity 属性,检查命令的个数是否匹配,否则不执行返回错误

  • 检查客户端是否通过了身份验证,没有则只能执行 AUTH 命令,否则则返回错误

  • 如果服务器打开了 maxmemory 功能,会先检查服务器的内存占用情况,并在有需要时进行内存回收,如果回收失败则不再执行后续步骤,返回错误

  • 如果服务器上一次执行 BGSAVE 命令出差,且打开了 stop-writes-on-bgsave-error 功能

  • 如果客户端在使用 SUBSCRIBE 命令订阅频道,或者在使用 PSUBSCRIBE 模式,则只会执行 SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE 四个命令,其他则拒绝

  • 如果服务器在进行数据载入,客户端发送的命令需要带有 l 标识(INFO、SHUTDOWN、PUBLISH)才会被服务器执行

  • 如果服务器执行 Lua 脚本超时而阻塞,则只会执行 SHUTDOWN nosave 和 SCRIPT KILL 命令

  • 如果客户端在执行事务,服务器只会执行客户端的 EXEC、DISCARD、MULTI、WATCH 命令,其他会被放进事务队列

  • 如果服务器打开了监视器功能,服务器则先把执行的命令和参数发给监视器,然后才真正执行命令

调用命令实现函数

由于 cmd 已经保存了命令实现,命令参数、个数已经保存在 argv、argc 中,只需要执行

client -> cmd -> proc(client)

命令实现函数执行指定操作,并参数相应回复,保存在客户端输出缓冲区 (buf、reply 属性),再为客户端的套接字关联命令回复处理器

执行后续工作

  • 如果服务器开启慢查询,慢查询模块需要检查是否为执行完的命令添加一条新的慢查询日志

  • 根据耗费时长,更新 redisCommand 结构的 milliseconds 属性,并将 calls 计数器 + 1

  • 如果 AOF 开启了,则会将命令写入 AOF 缓冲区

  • 如果有其他服务器正在复制当前的服务器,也会将命令传播给所有从服务器

将命令发送给客户端

命令实现函数会将输出回复保存在输出缓冲区里面,并为客户端套接字关联命令回复处理器,当客户端套接字可写时,服务器则会执行命令回复处理器,将保存在客户端输出缓冲区的命令回复发送给客户端

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

当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人类可读的格式,并打印给用户观看

5.serverCron 函数

默认每隔 100 毫秒运行一次,负责管理服务器资源,并保持服务器自身的良好运作

更新服务器时间缓存
获取系统当前时间需要执行系统调用,为了减少次数,服务器中的 unixtmie 和 mstime 属性被用作当前时间的缓存,因为serverCron函数默认会以每100毫秒一次的频率更新unixtime属性和mstime属性,所以这两个属性记录的时间的精确度并不高

  • 服务器只会在打印日志、更新服务器的 LRU 时钟、决定是否执行持久化任务、计算服务器上线时间等对时间精确度要求不高的功能上使用上述两个属性

  • 对于键过期时间、添加慢查询日志这种高精度时间的功能仍旧执行系统调用

struct redisServer {
    // ...
    //保存了秒级精度的系统当前UNIX 时间戳
    time_t unixtime;
    //保存了毫秒级精度的系统当前UNIX 时间戳
    long long mstime;
    // ...
};

更新 LRU 时钟

struct redisServer {
    // ...
    //默认每10 秒更新一次的时钟缓存,
    //用于计算键的空转(idle )时长。
    unsigned lruclock:22;
    // ...
};
  • lruclock 保存了服务器的 LRU 时钟,为时间缓存的一种,默认每 10 秒更新一次

  • 每个对象都有一个 lru 属性,保存了对象最后一次被命令访问的时间

  • 数据库键的空转时间(减法):

lruclock - lru
typedef struct redisObject {
    // ...
    unsigned lru:22;
    // ...
} robj;

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

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 数组的索引值,
    //每次抽样后将值自增一,
    //在值等于16 时重置为0 ,
    //让ops_sec_samples 数组构成一个环形数组。
    int ops_sec_idx;
    // ...
};

serverCron 中的 trackOpearationPerSecond 函数会以每 100 毫秒频率执行,功能是以抽样方式方式估算记录服务器在最近一秒钟处理的命令请求数量,通过 Info stats 命令的 instanceous_ops_sec 域查看

127.0.0.1:6379> INFO stats
#Stats
total_connections_received:2
total_commands_processed:3
instantaneous_ops_per_sec:0

trackOpearationPerSecond 函数和服务器状态中四个 opssec 开头的属性有关

trackOpearationPerSecond 函数每次运行会根据 ops_sec_last_sample_time 记录的上一次抽样时间和服务器当前时间,以及 ops_sec_last_sample_ops 记录的上一次抽样的已执行命令数量和服务器当前的已执行数量,计算两次调用之间服务器平均每毫秒处理了多少请求,再计算一秒钟服务器能处理多少请求的估计值,再作为新数组项放进 ops_sec_samples 环形数组里

执行 INFO 命令时服务器调用 getOperationPerSecond 函数,根据 ops_sec_samples 环形数组中抽样结果,计算 instanceous_ops_per_sec 属性的值

更新服务器内存峰值记录

struct redisServer {
    // ...
    //已使用内存峰值
    size_t stat_peak_memory;
    // ...
};

服务器状态的 stat_peak_memory 记录了服务器内存峰值大小

serverCron 函数执行时服务器则查看当前使用内存数量,与 stat_peak_memory 进行大小对比

127.0.0.1:6379> info memory
# Memory
used_memory_peek:692456
used_memory_peek_human:676.23K

used_memory_peek 和 used_memory_peek_human 是两种不同格式记录

处理 SIGTERM 信号
服务器启动后服务器进程的 SIGTERM 信号会关联 sigtermHandler 函数,负责在服务器接到 SIGTERM 信号时,打开服务器状态的 shutdown_asap 标识

// SIGTERM 信号的处理器
static void sigtermHandler(int sig) {
    //打印日志
    redisLogFromHandler(REDIS_WARNING,"Received SIGTERM, scheduling shutdown...");
    //打开关闭标识
    server.shutdown_asap = 1;
}

serverCron 函数则是对服务器状态的 shutdown_asap 属性检查,根据只决定是否关闭服务器

  • 1 :关闭服务器
  • 0: 不做动作

服务器在关闭自身之前会进行 RDB 持久化操作,对 SIGTERM 信号拦截

管理客户端资源
serverCron 函数会调用 clientsCron 函数对一定数量的客户端进行检查:

  • 连接超时,即很长时间没有互动,释放客户端

  • 输入缓冲区超过一定长度,释放客户端当前输入缓冲区,重新创建一个默认大小的输入缓冲区,防止耗费内存

管理数据库资源
serverCron 函数会调用 databasesCron 函数对一部分数据库检查,删除过期键,并在有需要时对字典进行收缩操作

执行被延迟的 BGREWRITEAOF

  • 在执行 BGSAVE 期间,如果客户端向服务器发来 BGREWRITEAOF 命令,则会延迟到 BGSAVE 命令结束之后

  • 服务器的 aof_rewrite_scheduled 标识了是否延迟,1 为延迟

  • serverCron 函数会检查 BGSAVE 或 BGREWRITEAOF 是否在运行,如果没有且 aof_rewrite_scheduled 为 1,则执行被延迟的 BGREWRITEAOF 命令

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

服务器状态使用 rdb_child_pid 属性和 aof_child_pid 属性记录了执行 BGSAVE 和 BGREWRITEAOF 命令子进程的 ID,可以用于检查 BGSAVE 和 BGREWRITEAOF 是否正在执行,如果为 -1 表示没有在执行

serverCron 函数执行时会检查这两个属性,如果有一个不为 -1,则程序会执行 wait3 函数,检查子进程是否有信号发来服务器进程:

  • 有信号到达,则新的 RDB 文件生成,或者 AOF 文件重写完成,服务器需要进行新的操作,如新的文件代替旧的文件

  • 没有则持久化未完成,不做动作

如果都为 -1,则服务器没有在持久化,则

  • 查看是否有 BGREWRITEAOF 被延迟,有则进行 BGREWRITEAOF 操作
  • 检查服务器自动保存条件是否满足,是的情况且服务器没有执行其他持久化操作,则进行 BGSAVE 操作
  • 检查 AOF 条件是否满足,是且没有其他持久化操作,则进行 BGREWRITEAOF 操作判断是否持久化

将 AOF 缓冲区中的内容写入 AOF 文件

如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么 serverCron函数会调用相应的程序,将AOF缓冲区中的内容写入到AOF文件里面

关闭异步客户端
关闭输出缓冲区大小超过限制的客户端

增加 cronloops 计数值
服务器状态的 cronloops 记录了 serverCron 函数执行次数,作用为在复制模块实现每执行 N 次操作执行一次指定代码

struct redisServer {
    // ...
    // serverCron 函数的运行次数计数器
    // serverCron 函数每执行一次,这个属性的值就增一。
    int cronloops;
    // ...
};

6.初始化服务器

int main(int argc, char **argv) {
    ...
    initServerConfig();
    ...
    loadServerConfig(configfile,options);
    ...
    initServer();
    ...
    loadDataFromDisk();
    ...
    InitServerLast();
    ...
    aeMain(server.el);
}

redis服务初始化分为六个阶段:

  • ①初始化服务配置;
  • ②载入配置选项;
  • ③服务器初始化;
  • ④还原数据库状态;
  • ⑤服务器最终初始化;
  • ⑥启动event loop

初始化服务器状态结构
第一步是创建一个 struct redisServer 类型的实例变量 server 作为服务器状态,并为结构中的各个属性设置默认值

初始化 server 由 redis.c/initServerConfig 完成,主要工作:

  • 设置运行 ID

  • 设置默认运行频率

  • 设置默认配置文件路径

  • 设置运行架构

  • 设置默认端口号

  • 设置默认 RDB 持久化条件和 AOF 持久化条件

  • 初始化服务器的 LRU 时钟

  • 创建命令表

void initServerConfig(void){
   
    //设置服务器的运行id
    getRandomHexChars(server.runid,REDIS_RUN_ID_SIZE);
    
    //为运行id 加上结尾字符
    server.runid[REDIS_RUN_ID_SIZE] = '\0';
    
    //设置默认配置文件路径
    server.configfile = NULL;
    
    //设置默认服务器频率
    server.hz = REDIS_DEFAULT_HZ;
    
    //设置服务器的运行架构
    server.arch_bits = (sizeof(long) == 8) ? 64 : 32;
    
    //设置默认服务器端口号
    server.port = REDIS_SERVERPORT;
    // ...
}

载入配置选项
启动服务器时可以给定配置参数或者指定配置文件来修改服务器的默认配置

在 initServerConfig 函数初始化完成后,就会载入用户给定的参数对 server 变量的属性进行修改

服务器在用initServerConfig函数初始化完server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的值进行修改

初始化服务器数据结构(initServer函数)
initServerConfig 函数只创建了命令表,但还有部分数据结构需要创建

  • server.clients 链表:客户端状态链表,redisClient 结构

  • server.db 数组:服务器所有数据库

  • server.pubsub_channels 字典:保存频道订阅信息

  • server.subpub_patterns 链表:保存模式订阅信息

  • server.lua:保存 Lua 脚本的环境

  • server.slowlog:保存慢查询日志

调用 initServer 函数为以上数据结构分配数据,并在有需要时设置默认值或关联初始化值,服务器到这一步才初始化数据结构是因为服务器需要载入用户指定的配置选项才能对数据结构正确初始化,如果在 initServerConfig 就初始化,而用户配置不同的值,导致重新调整和修改

initServer 函数还进行了:

  • 为服务器设置进程信号处理器
  • 创建共享对象:这些对象包含Redis服务器经常用到的一些值,比如包含"OK"回复的字 符串对象,包含"ERR"回复的字符串对象,包含整数1到10000的字符串对象等等,服务器通 过重用这些共享对象来避免反复创建相同的对象
  • 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运 行时接受客户端的连接
  • 为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数
  • 如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么 创建并打开一个新的AOF文件,为AOF写入做好准备。
  • 初始化服务器的后台I/O模块(bio),为将来的I/O操作做好准备

初始完成则在日志中打印 Redis 图标及相关版本信息

还原数据库状态

  • 在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的内容来还原服务器的数据库状态
  • 根据服务器是否启用了AOF持久化功能,服务器载入数据时所使用的目标文件会有所不同:
    • 如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库状态
    • 相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库状态
  • 当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长

执行事件循环

在初始化的最后一步,服务器将打印出以下日志,并开始执行服务器的事件循环(loop)

7.服务端总结

  • 一个命令请求从发送到完成主要包括以下步骤:1)客户端将命令请求发送给服务器;2)服务器读取命令请求,并分析出命令参数;3)命令执行器根据参数查找命令的实现函数,然后执行实现函数并得出命令回复;4)服务器将命令回复返回给客户端。

  • serverCron 函数默认每隔 100 毫秒执行一次,它的工作主要包括更新服务器状态信息,处理服务器接收的 SIGTERM 信号,管理客户端资源和数据库状态,检查并执行持久化操作等等。

  • 服务器从启动到能够处理客户端的命令请求需要执行以下步骤:

    • 1)初始化服务器状态;
    • 2)载入服务器配置;
    • 3)初始化服务器数据结构;
    • 4)还原数据库状态;
    • 5)执行事件循环。

六.主从复制

1.什么是复制?

执行SLAVOF命令或设置slaveof选项,让一个服务器去复制另外一个服务器,被复制的为主服务器,对主服务器进行复制的是从服务器

进行复制的主从服务器双方的数据库将保存相同的数据,即数据库状态一致,简称“一致”

复制功能分为同步和命令传播两个操作

  • 同步操作(sync):作用于服务器的数据库状态更新至主服务器当前所处的数据库状态

  • 命令传播(command propagate):则作用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态

2.说说同步过程?

旧版复制功能(SYNC命令)

客户端向从服务器发送SLAVEOF命令复制主服务器时,首先从服务器执行的是同步操作,更新至主服务器当前所处的数据库状态

实际操作是从服务器向主服务器发送SYNC命令:

  • 从服务器向主服务器发送SYNC命令
  • 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
  • 当主服务器的BGSAVE命令执行完,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器收到后载入RDB文件,更新至主服务器执行BGSAVE的状态
  • 主服务器将记录在缓冲区里的所有写命令发送给从服务器,从服务器执行写命令,更新指服务器所处的状态

image-20220917114718353

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0h35pYZ4-1664247547970)(http://qinyingjie.cn/pic/%E5%90%8C%E6%AD%A5%E4%BE%8B%E5%AD%90.45dj1qzhcii0.png)]

3.说说命令传播

同步操作之后,主从服务器两者的数据库状态将达到一致状态,但当主服务器执行客户端的命令时,主服务器的状态可能修改,不再一致

为了让主从服务器再一次回到一致状态,主服务器需要对从服务器执行命令传播操作:

将造成主从服务器不一致的写命令发送给从服务器执行,执行后再一次回到一致状态

4.旧版复制缺陷?

从服务器对主服务器的复制可以分为以下两种情况:

  • 初次复制:从服务器没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同

  • 断线后重复制:处于命令传播阶段的主从服务器因网络原因中断了复制,但从服务器通风自动重连接重新连上了主服务器,并继续复制主服务器

初次复制使用旧版复制功能你那个很好地完成任务,但是对于断线后重复制来说,旧版复制功能效率低

在命令传播阶段某个时间点断线后主服务器执行了部分命令修改了数据库状态,而从服务器还在重连,成功后是执行 SYNC 命令重新开始操作,但是实际上不是很需要的,因为主从服务器在断线之前是一致状态的

SYNC命令

  • 主服务器执行 BGSAVE 命令生成 RDB 文件,耗费主服务器 CPU、内存、磁盘 I/O 资源
  • 发送 RDB 文件耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应命令请求时间产生影响
  • 从服务器需要载入 RDB 文件,进入阻塞状态无法处理命令请求
  • 是非常消耗资源的操作

5.新版复制?

使用 PSYNC 命令代替 SYNC 命令解决断线重复制的低效问题,

PSYNC 命令有完整重同步和部分重同步两种模式:

  • 完整重同步(full resy nchronization):初次复制情况,步骤和 SYNC 命令执行步骤一致
  • 部分重同步(partial resynchronization):断线后重复制情况,主服务器将断线期间执行的写命令发送给从服务器,从服务器接收并执行,更新至服务器当前状态

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1n27CSpk-1664247547970)(http://qinyingjie.cn/pic/PSYNC%E8%BF%87%E7%A8%8B.b55p88goz8o.png)]

PSYNC

6.部分重同步实现

部分重同步功能由以下三个部分构成:

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

复制偏移量
主服务器和从服务器会分别维护一个复制偏移量:

  • 主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值加上 N
  • 从服务器每次收到主服务器传播的 N 个字节数据时,就将自己的复制偏移量加上 N

通过对比偏移量可以知道主从服务器是否处于一致状态

复制积压缓冲区
复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认为 1 MB,模型是滑动窗口.

当主服务器进行命令传播时,不仅会将写命令发给所有从服务器,还会将写命令入队到复制积压缓冲区里面,且队列中每个字节记录相应的复制偏移量

image-20220917115130074

当从服务器重新连上主服务器后,从服务器会通过 PSYNC 命令将自己发复制偏移量 offset 发送给主服务器,主服务器根据此来决定对服务器执行何种同步操作:

  • 如果 offset 之后的数据仍在复制积压缓冲区中,则进行部分重同步操作
  • 相反,已经不存在复制缓冲区中则进行完整重同步操作

根据需要调整复制缓冲区大小

默认为 1 MB,最小跟由公式 second * write_size_per_second 估算

second:服务器断线后重新连上主服务器的平均时间,单位秒
write*size_per_second:服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)
安全起见,实际大小可以设置为:2 * second _ write_size_per_second,可以保证绝大部分断线情况都能用部分重同步处理

服务器运行ID

  • 每个服务器都有自己的运行ID
  • 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成

当从服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器会将其保存,当从服务器断线并重新连上一个主服务器时,从服务器将当前连接的主服务器发送之前保存的运行ID:

  • 如果ID与当前连接的主服务器ID相同,则说明断线之前是这个主服务器与之连接,则继续尝试部分重同步操作
  • 否则则不同,断线之前不是这个主服务器,执行完整重同步操作

7.PSYNC 命令的实现

PSYNC 命令的调用方法有两种:

  • 如果从服务器没有复制过任何主服务器,或者之前执行了 SLAVEOF no one 命令,则在开始一次新的复制时向主服务器发送 PSYNC ? -1 命令,主动请求主服务器进行完整重同步

  • 否则发送 PSYNC 命令

    • runid:上一次复制的主服务器 ID
    • offset:从服务器当前复制偏移量
    • 服务器根据这两个参数决定对从服务器执行什么同步操作

服务器会返回以下三种回复之一:

  • 如果主服务器返回+FULLRESYNC 回复,那么表示主服务器将与从服务器执行完整重同步操作:其中runid是这个主服务器的运行ID,从服务器会将这个ID保存起 来,在下一次发送PSYNC命令时使用;而offset则是主服务器当前的复制偏移量,从服务器 会将这个值作为自己的初始化偏移量
  • 如果主服务器返回+CONTINUE回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了
  • 如果主服务器返回-ERR回复,那么表示主服务器的版本低于Redis 2.8,它识别不了PSYNC命令,从服务器将向主服务器发送SYNC命令,并与主服务器执行完整同步操作

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RqG8jt6U-1664247547972)(http://qinyingjie.cn/pic/PSYNC%E5%91%BD%E4%BB%A4%E5%AE%8C%E6%95%B4%E8%BF%87%E7%A8%8B.170urt0l2uw0.png)]

8.复制的实现

SLAVEOF <master_ip> <master_port>

步骤 1:设置主服务器的地址和端口
从服务器将客户端输入的主服务器的 IP 地址和端口保存到服务器状态的 masterhost 和 masterport 属性中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YtPxiG78-1664247547972)(http://qinyingjie.cn/pic/%E4%BB%8E%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%8A%B6%E6%80%81.3j1v68mqeq20.png)]

SLAVEOF 是一个异步命令,完成以上两个属性的设置之后,从服务器向客户端返回 OK,表示复制指令已经被接收,但实际的复制工作将在 OK 之后才真正执行

struct redisServer {
    // ...
    //主服务器的地址
    char *masterhost;
    //主服务器的端口
    int masterport;
    // ...
};

步骤 2:建立套接字连接
根据 IP 地址和端口,从服务器创建连向主服务器的套接字连接,如果连接成功,从服务器将为此套接字关联一个专门用于处理复制工作的文件事件处理器,负责后续的工作

主服务器在接收从服务器的套接字之后为该套接字创建相应的客户端状态,并将从服务器看作一个连接到主服务器的客户端看待,此时从服务器将具有服务器和客户端两种状态:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xHPy0d8g-1664247547972)(http://qinyingjie.cn/pic/%E6%AD%A5%E9%AA%A42.8dpjsasewso.png)]

步骤 3:发送 PING 命令
从服务器成为客户端后第一件事是向主服务器发送一个 PING 命令

  • 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信, 通过发送PING命令可以检查套接字的读写状态是否正常

  • 因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送PING命令可以检查主服务器能否正常处理命令请求

从服务器将遇到三种情况:

  • 主服务器返回命令回复,但从服务器不能在规定时间内读取命令回复的内容,则表示主从服务器之间的网络连接状态不稳定,需要从服务器断开并重新创建套接字
  • 主服务器返回错误。表示主服务器无法处理从服务器命令请求,则从服务器断开并重新创建套接字
  • 从服务器读到 “PONG” 回复,表示主从服务器状态正常,可以进行后续操作

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Tnx9pYuw-1664247547973)(http://qinyingjie.cn/pic/%E6%AD%A5%E9%AA%A43.7upf6jfq2pc.png)]

步骤 4:身份验证
下一步则是决定是否身份验证:根据是否设置了 masterauth 选项

从服务器向主服务器发送了一条 AUTH 命令,参数为从服务器 masterauth 选项的值

可能遇到几种情况:

  • 主服务器没有设置 requirepass 选项,从服务器也没有设置 masetrauth 选项,主服务器继续执行从服务器发送的命令
  • 从服务器通过 AUTH 发送的密码和主服务器 requirepass 选项设置的密码相同,继续执行,否则则返回 invalid password 错误
  • 主服务器有 requirepass 选项,从服务器无 masetrauth 选项,主服务器返回 NOAUTH 选项
  • 主服务器无 requirepass 选项,从服务器有 masetrauth 选项,主服务器返回 no password is set 错误

错误情况令从服务器中止目前的复制工作,并从创建套接字重新开始,直到身份验证或者从服务器放弃执行复制

步骤 5:发送端口信息
从服务器执行 REPLCONF listening-port ,向主服务器发送从服务器的监听端口号

主服务器接收后记录在从服务器对应的客户端状态的 slave_listening_port 中,其唯一作用就是主服务器执行 INFO replication 时打印从服务器端口

typedef struct redisClient {
    // ...
    //从服务器的监听端口号
    int slave_listening_port;
    // ...
} redisClient;

步骤 6:同步
从服务器向主服务器发送 PSYNC 命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态,执行之后主服务器也会成为从服务器的客户端

  • 如果是完整重同步,将保存在缓冲区里面的写命令发送给从服务器执行
  • 如果是部分重同步,发送保存在复制积压缓冲区中的命令

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KZfpG5I7-1664247547973)(http://qinyingjie.cn/pic/%E4%BA%92%E7%9B%B8%E6%88%90%E4%B8%BA%E5%AE%A2%E6%88%B7%E7%AB%AF.5nc4ilscoq40.png)]

正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础

步骤 7:命令传播

当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了

9.复制心跳检测

命令传播阶段,从服务器默认以每秒一次频率向服务器发送命令:

REPLCONF ACK <replication_offset>

replication_offset:从服务器当前的复制偏移量

三个作用:

  • 检测主从服务器的网络连接状态
  • 辅助实现 min-slaves 选项
  • 检测命令丢失

检测主从服务器的网络连接状态
通过接受命令来检查网络连接是否正常,如果没有收到则连接出现问题

辅助实现 min-slaves 配置选项
min-slaves-to-write 和 min-slaves-max-lag 可以防止主服务器在不安全的情况下执行写命令

min-slaves-to-write3
min-slaves-max-lag10

那么在从服务器的数量少于 3 个,或者三个从服务器的延迟(lag)值都大于或等于 10 秒时,主服务器将拒绝执行写命令,这里的延迟值就是上面提到的 INFO replication 命令的 lag 值。

检测命令丢失
根据两个 REPLCONF ACK 命令可以得出从服务器的写命令是否在半路丢失,从复制偏移量的比较得出

10.复制模式下对过期处理

当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:

  • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个 DEL 命令,告知从服务器删除这个过期键。

  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。

  • 从服务器只有在接到主服务器发来的 DEL 命令之后,才会删除过期键。

  • 3.2 版本后 从库判断已过期会返回空值

  • 因为 master 无法及时提供 DEL 命令,为了解决这个问题,slave 使用了逻辑时钟来报告 key 不存在

通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在干主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。

七.redis Sentinel

1.Sentinel哨兵模式功能

哨兵功能:哨兵的核心功能是主节点的自动故障转移。

  • 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
  • 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
  • 通知(Notification):哨兵可以将故障转移的结果发送给客户端。

2.哨兵模式架构图

Sentinel 哨兵组成了一个 Redis 高可用性的方案:

由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器及其所有从服务器,并在主服务器下线时将其从服务器升级为新的主服务器,继续执行要求,当先前的主服务器再次上线时则成为新的从服务器

1

image-20220918204858042

2.启动并初始化 Sentinel

redis-sentinel /path/to/yourt/setinel.conf
# or
redis-server /path/to/yourt/setinel.conf -- sentinel

启动时执行以下:

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

3.初始化服务器

初始化 Sentinel 服务器与普通服务器的区别:

  • 首先,因为Sentinel 本质上只是一个运行在特殊模式下的Redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的Redis服务器。
  • 不过,因为 Sentinel 执行的工作和普通 Redis 服务器执行的工作不同,所以 Sentinel 的初始化过程和普通 Redis 服务器的初始化过程并不完全相同
  • 例如,普通服务器在初始化时会通过载入 RDB 文件或者 AOF 文件来还原数据库状态,但 是因为 Sentinel 并不使用数据库,所以初始化 Sentinel 时就不会载入 RDB 文件或者 AOF 文件

Sentinel 本质是一个运行在特殊模式下的 Redis 服务器,所以启动第一步也是初始化一个 Redis 服务器.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VP6XVswU-1664247547973)(http://qinyingjie.cn/pic/Sentinel%E6%A8%A1%E5%BC%8F%E4%B8%8B%E7%9A%84Redis%E6%9C%8D%E5%8A%A1%E5%99%A8.2lgtr1qzz4c0.png)]

4.使用 Sentinel 专用代码

例如使用不同端口和不同的命令列表

#define REDIS_SERVERPORT 6379

#define REDIS_SENTINEL_PORT 26379

除此之外,普通 Redis 服务器使用 redis.c/redisCommandTable 作为服务器的命令表,而 Sentinel 则使用 sentinel.c/sentinelcmds 作为服务器的命令表:

  • 例如:其中的 INFO 命令会使用 Sentinel 模式下的专用实现 sentinel.c/sentinelInfoCommand 函数,而不是普通 Redis 服务器使 用的实现 redis.c/infoCommand 函数
  • sentinelcmds 命令表也解释了为什么在 Sentinel 模式下,Redis 服务器不能执行诸如 SET、 DBSIZE、EVAL 等等这些命令,因为服务器根本没有在命令表中载入这些命令。
  • Sentinel 执行的全部命令
    • PING
    • SENTINEL
    • INFO
    • SUBSCRIBE
    • UNSUBSCRIBE
    • PSUBSCRIBE
    • PUNSUBSCRIBE
struct redisCommand redisCommandTable[] = {
    {"get",getCommand,2,"r",0,NULL,1,1,1,0,0},
    {"set",setCommand,-3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
    {"setnx",setnxCommand,3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
    // ...
    {"script",scriptCommand,-2,"ras",0,NULL,0,0,0,0,0},
    {"time",timeCommand,1,"rR",0,NULL,0,0,0,0,0},
    {"bitop",bitopCommand,-4,"wm",0,NULL,2,-1,1,0,0},
    {"bitcount",bitcountCommand,-2,"r",0,NULL,1,1,1,0,0}
}

struct redisCommand sentinelcmds[] = {
    {"ping",pingCommand,1,"",0,NULL,0,0,0,0,0},
    {"sentinel",sentinelCommand,-2,"",0,NULL,0,0,0,0,0},
    {"subscribe",subscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"unsubscribe",unsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"psubscribe",psubscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"punsubscribe",punsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0}
};

5.初始化 Sentinel 状态

服务器会初始化一个 sentinel.c/sentinelState 结构,保存了服务器中 Sentinel 功能有关的状态,一般状态是

struct sentinelState {
    //当前纪元,用于实现故障转移
    uint64_t current_epoch;

    //保存了所有被这个sentinel 监视的主服务器
    //字典的键是主服务器的名字
    //字典的值则是一个指向sentinelRedisInstance 结构的指针
    dict *masters;

    //是否进入了TILT 模式?
    int tilt;

    //目前正在执行的脚本的数量
    int running_scripts;

    //进入TILT 模式的时间
    mstime_t tilt_start_time;

    //最后一次执行时间处理器的时间
    mstime_t previous_time;

    // 一个FIFO 队列,包含了所有需要执行的用户脚本
    list *scripts_queue;
} sentinel;

6.初始化 Sentinel 状态的 master 属性

  • Sentinel 状态中的 masters 字典记录了所有被 Sentinel 监视的主服务器的相关信息,其中:
    • 字典的键是被监视主服务器的名字
    • 字典的值则是被监视主服务器对应的 sentinel.c/sentinelRedisInstance 结构

struct sentinelRedisInstance

  • 每个 sentinelRedisInstance 结构(后面简称“实例结构”)代表一个被 Sentinel 监视的 Redis 服务器实例(instance),这个实例可以是主服务器、从服务器,或者另外一个 Sentinel
  • 实例结构包含的属性非常多,以下代码展示了实例结构在表示主服务器时使用的其中一 部分属性,本文接下来将逐步对实例结构中的各个属性进行介绍:
  • addr 属性:sentinelRedisInstance.addr 属性是一个指向 sentinel.c/sentinelAddr 结构的指针,这个结构保存着实例的 IP 地址和端口号:
typedef struct sentinelRedisInstance {
    //标识值,记录了实例的类型,以及该实例的当前状态
    int flags;

    //实例的名字
    //主服务器的名字由用户在配置文件中设置
    //从服务器以及Sentinel 的名字由Sentinel 自动设置
    //格式为ip:port ,例如"127.0.0.1:26379"
    char *name;

    //实例的运行ID
    char *runid;

    //配置纪元,用于实现故障转移
    uint64_t config_epoch;

    //实例的地址
    sentinelAddr *addr;

    // SENTINEL down-after-milliseconds 选项设定的值
    //实例无响应多少毫秒之后才会被判断为主观下线(subjectively down )
    mstime_t down_after_period;

    // SENTINEL monitor <master-name> <IP> <port> <quorum> 选项中的quorum 参数
    //判断这个实例为客观下线(objectively down )所需的支持投票数量
    int quorum;

    // SENTINEL parallel-syncs <master-name> <number> 选项的值
    //在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
    int parallel_syncs;

    // SENTINEL failover-timeout <master-name> <ms> 选项的值
    //刷新故障迁移状态的最大时限
    mstime_t failover_timeout;
    // ...
} sentinelRedisInstance;
typedef struct sentinelAddr {
    char *ip;
    int port;
} sentinelAddr;

7.创建连向主服务器的网络连接

  • 初始化 Sentinel 的最后一步是创建连向被监视主服务器的网络连接,Sentinel 将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息
  • 对于每个被 Sentinel 监视的主服务器来说,Sentinel 会创建两个连向主服务器的异步网络连接:
    • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复
    • 另一个是订阅连接,这个连接专门用于订阅主服务器的sentinel:hello 频道

8.为什么有两个连接?

  • 在 Redis 目前的发布与订阅功能中,被发送的信息都不会保存在 Redis 服务器里面, 如果在信息发送时,想要接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条信息。因此,为了不丢失sentinel:hello 频道的任何信息,Sentinel 必须专门用一 个订阅连接来接收该频道的信息(MQ就是干这个事情的,离线消息)
  • 另一方面,除了订阅频道之外,Sentinel 还必须向主服务器发送命令,以此来与主服务器进行通信,所以 Sentinel 还必须向主服务器创建命令连接

因为 Sentinel 需要与多个实例创建多个网络连接,所以 Sentinel 使用的是异步连接

下图展示了一个 Sentinel 向被它监视的两个主服务器 master1 和 master2 创建命令连接和订阅连接的例子:

img

9.获取主服务器信息

Sentinel 会默认每 10 秒一次频率向监视的主服务器通过命令连接发送 INFO 命令,通过分析回复获取当前主服务器的信息

  • 主服务器本身的信息:

    • run_id:服务器运行 ID
    • role 域:记录的服务器角色
  • 主服务器从属的所有从服务器信息

    • 每一个从服务器都以一个 “slave” 字符串开头的行记录
    • ip:记录了从服务器的 IP 地址
    • port:从服务器的端口号

根据 run_id 和 role 域,Sentinel 将对主服务器的实例结构进行更新,例如重启后的主服务器 run_id 将不同,Sentinel 检测后将会对实例结构的 run_id 进行更新

主服务器返回的从服务器信息被用于更新主服务器实例的 slave 字典

  • 主服务器实例结构的 flags 属性的值是 SRI_MASTER,从服务器实例结构的 flags 属性的值是 SRT_SLAVE

  • 主服务器的 name 属性是 Sentinel 配置文件设置的,从服务器实例结构的 name 属性的值是 Sentinel 根据从服务器的 IP 地址、端口号设置的

10.获取从服务器信息

如果监视的主服务器有新的从服务器出现时,Sentinel 会为这个新的从服务器创建相应的实例结构外,还会创建连接到从服务器的命令连接和订阅连接

创建命令连接后,Sentinel 会以每十秒一次的频率通过命令连接向从服务器发送 INFO 命令

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZV6V0jbK-1664247547974)(http://qinyingjie.cn/pic/image-20220918215652876.png)]

根据 INFO 命令的回复,Sentinel 会提取以下信息:

  • 从服务器的 run_id
  • 从服务器的角色 role
  • 主服务器的 IP 地址 master_host,主服务器的端口号 master_port
  • 主从服务器的连接状态 master_link_status
  • 从服务器的优先级 slave_priority
  • 从服务器的复制偏移量 slave_repl_offset

11.向主服务器和从服务器发送信息

默认情况下:Sentinel 会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:

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

这条命令向服务器的sentinel: hello 频道发送了一条信息,信息的内容由多个参数组成:

1.其中以 s_开头的参数记录的是 Sentinel 本身的信息,见下图

2.而 m_开头的参数记录的则是主服务器的信息,各个参数的意义见下图

  • 如果 Sentinel 正在监视的是主服务器,那么这些参数记录的就是主服务器的信息
  • 如果 Sentinel 正在监视的是从服务器,那么这些参数记录的就是从服务器正在复制的主服务器的信息

信息中和 Senti inel 有关的参数

参数 意义
s_ip Sentinel 的 IP 地址
s_port Sentinel 的端端口号
s_runid Sentinel 的运运行 ID
s_epoch Sentinel 当前的配置纪元

和主服务相关

参数 意义
m_name 主服务器的的名字
m_ip 主服务器的 IP 地址
m_port 主服务器的的端口号
m_epoch 主服务器当前的配置纪元

对于监视同一个服务器的多个 Sentinel 来说,一个 Sentinel 发送的信息会被其他 Sentinel 接收到,这些信息会被用于更新其他 Sentinel 对发送信息 Sentinel 的认知,也会被用于更新其他 Sentinel 对被监视服务器的认知。

12.接收来自主服务器和从服务器的频道信息

建立订阅连接后,Sentinel 则会向服务器发送以下命令:

SUBCRIBE _sentinel_:hello

此订阅会持续到与服务器的连接断开,Sentinel 通过此连接接收和发送消息

对于监视同一个服务器的多个 Sentinel,一个 Sentinel 发送的消息也会被其他 Sentinel 接收到,然后被用于更新对发送消息的 Sentinel 的认知,也会更新其监控的服务器的认知

更新 sentinels 字典
Sentinel 的 sentinels 字典 同样监视这个主服务器的其他 Sentinel:

  • 键:其他 Sentinel 的名字,格式为 ip:port

  • 值:对应的 Sentinel 的实例结构

当一个 Sentinel 接收到其他 Sentinel 发来的信息时,目标 Sentinel 会提取出以下参数:

  • 与 Sentinel 有关的参数:源 Sentinel 的 IP、端口、run_id、配置纪元

  • 与主服务器有关的参数:源 Sentinel 正在监视的主服务器的名字、IP、端口、配置纪元

根据主服务器参数,目标 Sentinel 会在自己的 Sentinel 状态的 masters 字典查找对应的主服务器实例的结构,根据参数,检查主服务器实例结构的 Sentinels 字典中 Sentinel 实例结构是否存在:

  • 存在则更新
  • 不存在则源 Sentinel 刚开始监视主服务器,目标 Sentinel 创建新的实例结构,并添加到 sentinels 字典中

创建连向其他 Sentinel 的命令连接
当 Sentinel 通过频道信息发现新的 Sentinel 后不仅创建对应的实例结构,也会创建连向此的命令连接,最终监视同一主服务器的多个 Sentinel 将形成相互连接的网络

Sentinel之间不会创建订阅连接

Sentinel在连接主服务器或者从服务器时,会同时创建命令连接和订阅连接,但是在连接其他Sentinel时,却只会创建命令连接,而不创建订阅连接。这是因为Sentinel需要通过接收主服务器或者从服务器发来的频道信息来发现未知的新Sentinel,所以才需要建立订阅连接,而相互已知的Sentinel只要使用命令连接来进行通信就足够了

image-20220918221023586

13.检测主观下线状态

默认情况下,Sentinel 会默认每秒一次的频率向所有与它创建了命令连接的实例(主服务器、从服务器、其他 Sentinel ) 发送 PING 命令,通过回复判断实例是否在线

实例对 PING 命令的回复有两种:

  • 有效回复:+PONG、-LOADING、-MASTERDOWN 三种
  • 无效回复:返回有效回复外的其他回复,或在指定时间内没有任何回复

Sentinel 配置文件中的 down-after-millisecond 的值为 50000 毫秒,当主服务器 master 连续 50000 毫秒内都向 Sentinel 返回无效回复,则会被标记为主观下线,对应的实例结构的 flags 被标识为 SRI_S_DOWN

image-20220918221007979

down-after-millisecond 的值不仅会被 Sentinel 用来判断主服务器的主观下线状态,还会被用于判断主服务器属下的从服务器,以及所有同样监视这个主服务器的其他 Sentinel 的主观下线状态

且监视同一个主服务器的不同 Sentinel 来说,此值可能不同

14.检查客观下线状态

需要特别注意的是,客观下线是主节点才有的概念;如果从节点和哨兵节点发生故障,被哨兵主观下线后,不会再有后续的客观下线和故障转移操作。

当一个主服务器被认为主观下线后,Sentinel 会询问其他监视此主服务器的 Sentinel,当接收到足够多的下线判断(主观或者客观下线)后,才会将主服务器判断为客观下线,并对主服务器执行故障转移操作

发送 SENTINEL is-master-down-by-addr 命令
Sentinel 使用

SENTINEL IS-master-down-by-addr <current_epoch> <run_id>

来询问其他 Sentinel 是否同意主服务器已下线

image-20220918221124002

接收 SENTIENL is-master-down-by-addr 命令
目标 Sentinel 接收到源 Sentinel 发来的命令后,目标会分析提取参数,根据主服务器 IP 和端口检查主服务器是否已下线,然后向源返回一个包含三个参数的 Multi Bulk 作为回复

  • <down_state>
  • <leader_runid>
  • <leader_epoch>

image-20220918221151397

接收 SENTIENL is-master-down-by-addr 命令的回复
根据其他 Sentinel 发回的命令回复,统计同意已下线的数量,达到配置的数量则判断为客观下线,flags 标志为 SRI_O_DOWN

image-20220918221214164

  • Sentinel 配置的 quorum 参数判断的标志,大于等于则被认为是客观下线

  • 不同的 Sentinel 此参数的配置不同,即对主服务器的客观下线判断不同

15.选举领头 Sentinel

领头 Sentinel 对下线主服务器进行故障转移操作,需要先选举 领头Sentinel

选举领头 Sentinel :

  • 每个监视同一主服务器的 Sentinel 都有资格成为领头
  • 每次选举后不论成功失败,每个 Sentinel 的配置纪元增 1
  • 一个配置纪元里,所有 Sentinel 都有一次机会将某个 Sentinel 设置为局部领头,且设置后在此纪元不会更改
  • 每个发现主服务器进入客观下线状态的 Sentinel 都会要求其他将自己设置为局部领头
  • 源 Sentinel 向目标 Sentinel 发送 SENTIENL is-master-down-by-addr 命令且 runid 参数不是 * 而是自己的 runid 时表示要目标设置源为它的局部领头 Sentinel
  • 设置局部领头的规则是先到先得,后来的都被拒绝
  • 目标接收后将向源回复,其中的 leader_runid 和 leader_epoch 记录目标的局部领头的 runid 和配置纪元
  • 源接收命令的回复后取出参数,如果和自己相同则表示自己成为目标的局部领头
  • 如果某个 Sentinel 被半数以上设置为局部领头,则他成为领头 Sentinel
  • 因为需要半数以上支持且只能设置一次局部领头,则领头 Sentinel 只有一个
  • 在指定时间内没有选出领头 Sentinel,则在一段时间后再进行选择,直到选出

16.哨兵模式故障转移

三个步骤

  1. 在已下线的主服务器的从服务器中选出一个从服务器,成为新的主服务器

  2. 让其他从服务器改为复制新的主服务器

  3. 将已下线的主服务器设置为新的主服务器的从服务器,当重新上线时自动成为主服务器的从服务器

选出新的主服务器
挑选一个状态良好、数据完整的从服务器,发送 SLAVEOF no one 成为主服务器

领头 Sentinel 将所有属于的从服务器保存在列表中,然后按照以下规则过滤

  • 删除下线或断线服务器
  • 删除最近 5 秒没有回复领头 Sentinel 的 INFO 命令的服务器
  • 删除与已下线主服务器连接断开超过 down-after-milliseconds * 10 毫秒的服务器
  • 根据服务器的优先级排序,选出
  • 如果优先级一致,选出复制偏移量最大的
  • 再一致选出运行 ID 最小的

修改从服务器的复制目标
让其他从服务器复制新的主服务器,领头 Sentinel 发送 SLAVEOF 命令

将旧主服务器变为从服务器
领头 Sentinel 发送命令

17.哨兵模式注意事项

(1)哨兵节点的数量应不止一个,一方面增加哨兵节点的冗余,避免哨兵本身成为高可用的瓶颈;另一方面减少对下线的误判。此外,这些不同的哨兵节点应部署在不同的物理机上。

(2)哨兵节点的数量应该是奇数,便于哨兵通过投票做出“决策”:领导者选举的决策、客观下线的决策等。

(3)各个哨兵节点的配置应一致,包括硬件、参数等;此外,所有节点都应该使用ntp或类似服务,保证时间准确、一致。

(4)哨兵的配置提供者和通知客户端功能,需要客户端的支持才能实现,如前文所说的Jedis;如果开发者使用的库未提供相应支持,则可能需要开发者自己实现。

(5)当哨兵系统中的节点在docker(或其他可能进行端口映射的软件)中部署时,应特别注意端口映射可能会导致哨兵系统无法正常工作,因为哨兵的工作基于与其他节点的通信,而docker的端口映射可能导致哨兵无法连接到其他节点。例如,哨兵之间互相发现,依赖于它们对外宣称的IP和port,如果某个哨兵A部署在做了端口映射的docker中,那么其他哨兵使用A宣称的port无法连接到A。

八.redis集群

1.什么是 redis 集群?

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

2.redis 节点

一个 Redis 集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

连接各个节点的工作可以使用 CLUSTER MEET 命令来完成,该命令的格式如下:

CLUSTER MEET <ip> <port>

向一个节点 node 发送 CLUSTER MEET 命令,可以让 node 节点与 ip 和 port 所指定的节点进行握手(hand shake),当握手成功时,node 节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。

节点加入到集群

img

启动节点

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

image-20220918222546581

redis 节点的功能作用

  • 节点会继续使用文件事件处理器来处理命令设请求和返回命令回复。

  • 节点会继续使用时间事件处理器来执行 s erverCron 函数,而 serverCron 函数又会调用集群模式特有的 clusterCron 函数。clusterCron 函数负责执行在集群模式下需要执行的常规操作,例如,向集群中的其他节点发送 Gossip 消息,检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。

  • 节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。

  • 节点会继续使用 RDB 持久化模块和 AOF 持久化模块来执行持久化工
    作。

  • 节点会继续使用发布与订阅模块来执行 PUBLISH、SUBSCRIBE 等
    命令。

  • 节点会继续使用复制模块来进行节点的复制工作。

  • 节点会继续使用 Lua 脚本环境来执行客户端输入的 Lua 脚本。

3.集群数据结构

  • clusterNode 结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的 IP 地址和端口号等等。

  • 每个节点都会使用一个 clusterNode 结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的 clusterNode 结构,以此来记录其他节点的状态;

typedef struct clusterNode {
    mstime_t ctime; /* Node object creation time. */
    char name[REDIS_CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */
    int flags;      /* REDIS_NODE_... */
    uint64_t configEpoch; /* Last configEpoch observed for this node */
    unsigned char slots[REDIS_CLUSTER_SLOTS/8]; /* slots handled by this node */
    int numslots;   /* Number of slots handled by this node */
    int numslaves;  /* Number of slave nodes, if this is a master */
    struct clusterNode **slaves; /* pointers to slave nodes */
    struct clusterNode *slaveof; /* pointer to the master node */
    mstime_t ping_sent;      /* Unix time we sent latest ping */
    mstime_t pong_received;  /* Unix time we received the pong */
    mstime_t fail_time;      /* Unix time when FAIL flag was set */
    mstime_t voted_time;     /* Last time we voted for a slave of this master */
    mstime_t repl_offset_time;  /* Unix time we received offset for this node */
    PORT_LONGLONG repl_offset;      /* Last known repl offset for this node. */
    char ip[REDIS_IP_STR_LEN];  /* Latest known IP address of this node */
    int port;                   /* Latest known port of this node */
    clusterLink *link;          /* TCP/IP link with this node */
    list *fail_reports;         /* List of nodes signaling this as failing */
} clusterNode;

clusterNode 结构的 link 属性是一个 clusterL ink 结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区.

4.clusterLink结构

clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区

typedef struct clusterLink {
    //连接的创建时间
    mstime_t ctime;
 
    // TCP 套接字描述符
    int fd;
 
    //输出缓冲区,保存着等待发送给其他节点的消息(message )。
    sds sndbuf;
 
    //输入缓冲区,保存着从其他节点接收到的消息。
    sds rcvbuf;
 
    //与这个连接相关联的节点,如果没有的话就为NULL
    struct clusterNode *node;
} clusterLink;

redisClient 结构和 clusterLink结构的相同和不同

redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区,这两个结构的区别在于,redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterlink结构中的套接字和缓冲区则是用于连接节点的。

5.clusterState

每个节点都保存着一个 clusterState 结构, 这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:

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

image-20220918223617204

6.CLUSTER MEET命令

image-20220918223756580

通过向节点 A 发送 CLUSTER MEET 命令,客户端可以让接收命令的节点 A 将另一个节点 B 添加到节点 A 当前所在的集群里面:

CLUSTER MEET <ip> <port>

收到命令的节点A将与节点B进行握手(hand shake),以此来确认彼此的存在,并为将来的进一步通信打好基础:

  1. 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  2. 之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message)
  3. 如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  4. 之后,节点B将向节点A返回一条PONG消息。
  5. 如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了自己发送的MEET消息。
  6. 之后,节点A将向节点B返回一条PING消息。
  7. 如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。

之后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TWFAJ8JW-1664247547976)(http://qinyingjie.cn/pic/20191202200732123.png)]

7.集群之槽介绍

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

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

8.槽指派

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

通过向节点发送 CLUSTER ADDSLOTS 命令,我们可以将一个或多个槽指派(assign) 给节点负责:

127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4... 5000
OK
127.0.0.1:7000> CLUSTER NODES

记录指派信息

struct clusterNode {
    // ...
    unsigned char slots[16384/8];
    int numslots;
    // ...
};

clusterNode 结构的 slots 属性和 numslot 属性记录了节点负责处理哪些槽:

slots 属性是一个二进制位数组(bit array),这个数组的长度为 16384/8=2048 个字节,共包含 16384 个二进制位。
Redis 以 0 为起始索引,16383 为终止索引,对 slots 数组中的 16384 个二进制位进行编号,并根据索引 i 上的二进制位的值来判断节点是否负责处理槽 i:

  • 如果 slots 数组在索引 i 上的二进制位的值为 1,那么表示节点负责处理槽 i。
  • 如果 slots 数组在索引 i 上的二进制位的值为 0,那么表示节点不负责处理槽 i。

image-20220918224648457

图 17-9 展示了一个 slots 数组示例:这个数组索引 0 至索引 7上的二进制位的值都为 1,其余所有二进制位的值都为 0,这表示节点负责处理槽 0 至槽 7。

因为取出和设置 slots 数组中的任意一个二进制位的值的复杂度仅为 O(1),所以对于一个给定节点的 slots 数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是 O (1)
至于 numslots 属性则记录节点负责处理的槽的数量,也即是 slots 数组中值为 1 的二进制位的数量。

传播节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在 clusterNode 结构的 slots 属性和 numslots 属性之外,它还会将自己的 slots 数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。

image-20220918224910286

当节点 A 通过消息从节点 B 那里接收到节点 B 的 slots 数组时,节点 A 会在自己的 clusterState.nodes 字典中查找节点 B 对应的 clusterNode 结构,并对结构中的 slots 数组进行保存或者更新。
因为集群中的每个节点都会将自己的 slots 数组通过消息发送给集群中的其他节点,并且每个接收到 slots 数组的节点都会将数组保存到相应节点的 clusterNode 结构里面,因此,集群中的每个节点都会知道数据库中的 16384 个槽分别被指派给了集群中的哪些节点。

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

typedef struct clusterState {
    // ...
    clusterNode *slots[16384];
    // ...
} clusterState;

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

slots 数组包含 16384 个项,每个数组项都是一个指向 clusterNode 结构的指针:

  • 如果 slots[i]指针指向 NULL, 那么表示槽 i 尚未指派给任何节点。
  • 如果 slots[i]指针指向一个 clusterNode 结构,那么表示槽 i 已经指派给了 clusterNode 结构所代表的节点。

如果只将槽指派信息保存在各个节点的 clusterNode slots 数组里,会出现一些无法高效地解决的问题,而 clusterState.slots 数组 的存在解决了这些问题:
如果节点只使用 clusterNode.slots 数组来记录槽的指派信息,那么为了知道槽 i 是否已经被指派,或者槽 i 被指派给了哪个节点,程序需要遍历 clusterState.nodes 字典中的所有 clusterNode 结构,检查这些结构的 slots 数组,直到找到负责处理槽 i 的节点为止,这个过程的复杂度为 O (N),其中 N 为 clusterState.nodes 字典保存的 clusterNode 结构的数量。而通过将所有槽的指派信息保存在 clusterState.slots 数组里面,程序要检查槽 i 是否已经被指派,又或者取得负责处理槽 i 的节点,只需要访问 clusterState. slots[i]的值即可,这个操作的复杂度仅为 O(1)

image-20220918225301695

9.两个slots的区别?

clusterState 和 clusterNode 的 slots 的区别

要说明的一点是,虽然 clusterState.slots 数组记录了集群中所有槽的指派信息,但使用 clusterNode 结构的 slots 数组来记录单个节点的槽指派信息仍然是有必要的:

因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的 clusterNode.slots 数组整个发送出去就可以了。
另一方面,如果 Redis 不使用 clusterNode.slots 数组,而单独使用 clusterState.slots 数组的话,那么每次要将节点 A 的槽指派信息传播给其他节点时,程序必须先遍历整个 clusterState.slots 数组,记录节点 A 负责处理哪些槽,然后才能发送节点 A 的槽指派信息,这比直接发送 clusterNode.slots 数组要麻烦和低效得多。clusterState.slots 数组记录了集群中所有槽的指派信息,而 clusterNode.slots 数组只记录了 clusterNode 结构所代表的节点的槽指派信息,这是两个 slots 数组的关键区别所在。

image-20220918225519133

10.CLUSTER ADDSLOTS

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

image-20220918225823921

对 1,2 执行命令后

image-20220918225912032

  • clusterState.slots 数组在索引 1 和索引 2 上的指针指向了代表当前节点的 clusterNode 结构。
  • 并且 clusterNode.slots 数组在索引 1 和索引 2.上的位被设置成了 1。
  • 最后,在 CLUSTER ADDSLOTS 命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽。

11.集群执行命令

在对数据库中的 16384 个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个 MOVED 错误,指引客户端转向(redirect) 至正确的节点,并再次发送之前想要执行的命令。

image-20220918230151102

12.计算键所属槽

节点使用以下算法来计算给定键 key 属于哪个槽:

def slot_ number(key):
return CRC16(key) & 16383

其中 CRC16 (key) 语句用于计算键 key 的 CRC-16 校验和,而&16383 语句则用于计算出一个介于 0 至 16383 之间的整数作为键 key 的槽号。
使用命令可以查看一个给定键属于哪个槽:

CLUSTER KEYSLOT<key>

判断槽节点是否自己负责

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

  • 如果 clusterState.slots[i]等 于 clusterState.myself,那么说明槽 i 由当前节点负责,节点可以执行客户端发送的命令。
  • 如果 clusterState.slots[i]不等 于 clusterState.myself,那么说明槽 i 并非由当前节点负责,节点会根据 clusterState.slots[i]指向的 clusterNode 结构所记录的节点 IP 和端口号,向客户端返回 MOVED 错误,指引客户端转向至正在处理槽 i 的节点。

moved 错误

  • 相当于 web 中的重定向
  • 集群模式下是隐藏的,可以打印跳转信息,看不见错误
  • 单机模式会抛出 moved 错误

13.一致性哈希算法

一致性哈希算法为的就是解决分布式缓存的问题

一致性哈希算法中,整个哈希空间是一个虚拟圆环,

  • 对于各个Object,它所真正的存储位置是按顺时针找到的第一个存储节点。例如Object A顺时针找到的第一个节点是Node A,所以Node A负责存储Object A,Object B存储在Node B。
  • 假设Node C节点挂掉了,Object C的存储丢失,那么它顺时针找到的最新节点是Node D。也就是说Node C挂掉了,受影响仅仅包括Node B到Node C区间的数据,并且这些数据会转移到Node D进行存储。
  • 一致性哈希算法对于容错性和扩展性有非常好的支持。但一致性哈希算法也有一个严重的问题,就是数据倾斜

img

14.节点数据库

  • 集群节点只能使用 0 号数据库
  • 另外,除了将键值对保存在数据库里面之外,节点还会用 clusterState 结构中的 slots_to_keys 跳跃表来保存槽和键之间的关系:
  • 键的分值和槽的关系(相对应)
typedef struct clusterState {
    /***/
    zskiplist *slots_to_keys;
   /***/
} clusterState;

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

  • 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到 slots_to_keys 跳跃表。
  • 当节点删除数据库中的某个键值对时,节点就会在 slots_to_keys 跳跃表解除被删除键与槽号的关联。

img

15.重新分片

  • Redis 集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点) ,并且相关槽所属的键值对也会从源节点被移动到目标节点。

  • 重新分片操作可以在线(online) 进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

重新分片原理

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

redis-trib 対集群的単个槽 slot 迸行重新分片的歩驟如下:

  1. redis-trib 対目棕节点发送 CLUSTER SETSLOTIMPORTING<source_id> 命令,让目标节点准备好从源节点导入(import) 属于槽 slot 的键値対 。
  2. redis-trib 対源节点发送 CLUSTE R SETSLOTMIGRATING<target_id> 命令,沚源节点准备好将属于槽 slot 的鍵値対迁移(migrate) 至目标节点。
  3. redis-trib 向源节点发送 CLUSTER GETKEYSINSLOT命令,荻得最多 count 个属于槽 slot 的鍵値対的鍵名(key name)。
  4. 対于歩驟 3 获得的毎个鍵名,redis-trib 都向源节点发送一个 MIGRATE<target* ip><target_port <key* name>0命令,將被选中的鍵原子地从源节点迁移至目标节点。

image-20220918232111329

16.ASK 错误

  • ASKING命令功能:唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识

  • 在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。

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

    • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。

    • 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

17.ASK和MOVED的区别

ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:

  • MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送 至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点
  • 与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收 到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发 送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何 影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再 次出现

18.集群的复制

Redis 集群中的节点分为主节点(master) 和从节点(slave) ,其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。

设置从节点

CLUSTER REPLICATE <node_ id>

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

  • 接收到该命令的节点首先会在自己的 clusterState.nodes 字典中找到 node_ id 所对 应节点的 clusterNode 结构,并将自己的 clusterState.myself. slaveof 指针指向这个结构,以此来记录这个节点正在复制的主节点:

  • 然后节点会修改自己在 clusterState.myself.flags 中的属性,关闭原本的 REDIS_NODE_MASTER 标识,打开 REDIS__NODE_SLAVE 标识,表示这个节点已经由原来的主节点变成了从节点。

  • 最后,节点会调用复制代码,并根据 clusterState.myself.slaveof 指向的 clusterNode 结构所保存的 IP 地址和端口号,对主节点进行复制。因为节点的复制功能和单机 Redis 服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令 SLAVEOF。

19.复制信息的传递

  • 概念:一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点
  • 集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单
struct clusterNode {
    // ...
    //正在复制这个主节点的从节点数量
    int numslaves;
 
    // 一个数组
    //每个数组项指向一个正在复制这个主节点的从节点的clusterNode 结构
    struct clusterNode **slaves;
    // ...
};

20.集群故障转移

故障检查

集群中的每个节点都会定期地向集群中的其他节点发送 PING 消息,以此来检测对方是否在线,如果接收 PING 消息的节点没有在规定的时间内,向发送 PING 消息的节点返回 PONG 消息,那么发送 PING 消息的节点就会将接收 PING 消息的节点标记为疑似下线(probable fail, PFAIL )

image-20220918233002345

image-20220918233030335

故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:

  1. 复制下线主节点的所有从节点里面,会有一个从节点被选中。
  2. 被选中的从节点会执行 SLAVEOF no one 命令, 成为新的主节点。
  3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
  4. 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

21.选举新的主节点

新的主节点是通过选举产生的,Redis中是通过currentEpoch和configEpoch来解决这些信息更新问题,epoch的概念与raft协议中的epoch类似,表示逻辑时钟,每次集群状态更新之后,epoch都会+1,谁的epoch大,谁的gossip消息就可信。

currentEpoch表示当前集群的epoch,configEpoch表示当前节点看到的其他节点的epoch。

以下是集群选举新的主节点的方法:

  1. 集群的配置纪元是一个自增计数器,它的初始值为0
  2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一
  3. 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一 个向主节点要求投票的从节点将获得主节点的投票
  4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票
  5. 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其 他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的 主节点
  6. 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消 息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持
  7. 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张 支持票时,这个从节点就会当选为新的主节点
  8. 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N 个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的 主节点只会有一个
  9. 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个 新的配置纪元,并再次进行选举,直到选出新的主节点为止

这个选举新主节点的方法和前面文章介绍的选举领头Sentinel的方法非常相似,因为两者都 是基于Raft算法的领头选举(leader election)方法来实现的

22.redis消息

集群中的各个节点通过发送和接收消息(message) 来进行通信,我们称发送消息的节点为发送者(sender),
接收消息的节点为接收者(receiver)
节点中的消息分为 5 种,消息有消息头和正文组成

  • MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者 发送MEET消息,请求接收者加入到发送者当前所处的集群里面
  • PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节 点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测 被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送的PONG消息的时 间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一半,那么节点A 也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消 息的发送对象而导致对节点B的信息更新滞后
  • PONG消息:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者 确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。另外, 一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新关于这个 节点的认识,例如当一次故障转移操作成功执行之后,新的主节点会向集群广播一条PONG 消息,以此来让集群中的其他节点立即知道这个节点已经变成了主节点,并且接管了已下线 节点负责的槽
  • FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群 广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线
  • PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群 广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命 令

一条消息由**消息头(header)和消息正文(data)**组成
消息头(struct clusterMsg)

  • 节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息,因为这些信息也会被消息接收者用到,所以严格来讲,我们可以 认为消息头本身也是消息的一部分
  • 每个消息头都由一个cluster.h/clusterMsg结构
typedef struct {
    //消息的长度(包括这个消息头的长度和消息正文的长度)
    uint32_t totlen;
    //消息的类型
    uint16_t type;
    //消息正文包含的节点信息数量
    //只在发送MEET 、PING 、PONG 这三种Gossip 协议消息时使用
    uint16_t count;
    //发送者所处的配置纪元
    uint64_t currentEpoch;
    //如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
    //如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
    uint64_t configEpoch;
    //发送者的名字(ID )
    char sender[REDIS_CLUSTER_NAMELEN];
    //发送者目前的槽指派信息
    unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
    //如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
    //如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAME
    //(一个40 字节长,值全为0 的字节数组)
    char slaveof[REDIS_CLUSTER_NAMELEN];
    //发送者的端口号
    uint16_t port;
    //发送者的标识值
    uint16_t flags;
    //发送者所处集群的状态
    unsigned char state;
    //消息的正文(或者说,内容)
    union clusterMsgData data;
} clusterMsg;

消息正文

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

23.gossip协议

gossip过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有的节点都收到消息,但是能够保证最终所有节点都会收到消息,因此它是一个最终一致性协议。

gossip协议使用PING、PONG类型的消息

24.多数据库对比

  • 复制:复制是高可用Redis的基础,哨兵和集群都是在复制基础上实现高可用的。复制主要实现了数据的多机备份,以及对于读操作的负载均衡和简单的故障恢复。缺陷:故障恢复无法自动化;写操作无法负载均衡;存储能力受到单机的限制。
  • 哨兵:在复制的基础上,哨兵实现了自动化的故障恢复。缺陷:写操作无法负载均衡;存储能力受到单机的限制。
  • 集群:通过集群,Redis解决了写操作无法负载均衡,以及存储能力受到单机限制的问题,实现了较为完善的高可用方案。

九.扩展模块

1.处理经纬坐标

通过geo相关的redis命令计算坐标之间的距离

image-20220925231959942

2.数据库通知

键空间通知:“某个键执行了什么命令”的通知称为键空间通知(key-space notification)
键事件通知:键事件通知(key-event notification)关注的是“某个命令被什么键执行了”

notify-keyspace-events选项

  • 服务器配置的notify-keyspace-events选项决定了服务器所发送通知的类型:
  • 可以设置的类型如下:
    • 想让服务器发送所有类型的键空间通知和键事件通知,可以将选项的值设置为AKE
    • 想让服务器发送所有类型的键空间通知,可以将选项的值设置为AK
    • 想让服务器发送所有类型的键事件通知,可以将选项的值设置为AE
    • 想让服务器只发送和字符串键有关的键空间通知,可以将选项的值设置为K$
    • 想让服务器只发送和列表键有关的键事件通知,可以将选项的值设置为El
  • 备注:notify-keyspace-events选项的默认值为空,所以如果不设置上面的值,SUBSCRIBE命令不会有任何效果

3.发布与订阅

Redis 的发布与订阅功能由 PUBLISH、SUBSCRIBE、PSUBSCRIBE 等命令组成。通过执行 SUBSCRIBE 命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscriber):每当有其他客户端向被订阅的频道发送消息(message) 时,频道的所有订阅者都会收到这条消息。

客户端执行命令,订阅频道

SUBSCRIBE "news.it"

客户端执行命令,向频道发消息

PUBLISH "news.it" "hello'
  • PSUBSCRIBE 订阅多个频道
  • 注意频道匹配模式
  • UNSUBSCRIBE 频道退订

发布到频道

  • 当一个客户端执行PUBLISH命令的时候,会将消息message发送给频道channel
  • PUBLISH命令执行完之后,服务器需要执行以下两个动作:
    • 将消息message发送给channel频道的所有订阅者
    • 如果有一个或多个模式pattern与频道channel相匹配,那么将消息message发送给 pattern模式的订阅者

订阅频道实现

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

struct redisServer {
    // ...
    //保存所有频道的订阅关系
    dict *pubsub_channels;
    // ...
};

typedef struct pubsubPattern {
    //订阅模式的客户端
    redisClient *client;
  
    //被订阅的模式
    robj *pattern;
} pubsubPattern;

image-20220918234638957

模式的订阅实现

前面说过,服务器将所有频道的订阅关系都保存在服务器状态的pubsub_channels属性里面,与此类似,服务器也将所有模式的订阅关系都保存在服务器状态的 pubsub_channels属 性里面:

image-20220918234859814

4.查看订阅信息

PUBSUB CHANNELS

PUBSUB CHANNELS [pattern]子命令用于返回服务器当前被订阅的频道,其中 pattern 参 数是可选的:

  • 如果不给定 pattern 参数,那么命令返回服务器当前被订阅的所有频道。
  • 如果给定 pattern 参数,那么命令返回服务器当前被订阅的频道中那些与模式相匹配的频道

PUBSUB NUMSUB

PUBSUB NUMSUB [channel-1 channel-2…channel-n]子命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量。这个子命令是通过在 pubsub_channels 字典 中找到频道对应的订阅者链表,然后返回订阅者链表的长度来实现的(订 阅者链表的长度就是频道订阅者的数量),

PUBSUB NUMPAT

PUBSUB NUMPAT 子命令用于返回服务器当前被订阅模式的数量。这个子命令是通过返回 pubsub_patterns 链表的长度来实现的,因为这个链表的长度就是服务器被订阅模式的数量

5.说说redis事务

Redis 通过 MULTI、EXEC、WATCH 等命令来实现事务(transaction)功能。

  • multi:开始事务;
  • exec:提交事务;
  • discard:取消事务;
  • watch:监视某个 key;
  • unwatch:取消监视某个 key。

事务的特性

  • 事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制
  • 并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。
  • redis事务不支持回滚机制
  • 单机模式默认是关闭事务的
  • 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

一个事务执行的过程,该事务首先以一个 MULT|命令为开始,接着将多个命令放入事务当中,最后由 EXEC 命令将这个事务提交(commit) 给服务器执行

6.事务的错误处理

事务的错误处理

  • 如果事务中的命令是在执行期间出现了错误,事务的后续命令也会继续执行下去,并且之前执行的命令也不会有任何影响
  • 事务因为命令入队出错而被服务器拒绝执行,事务中的所有命令都不会被执行
  • 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。执行和是否成功是2个概念,并不是一个失败报错等,其他就失败。redis对事务是部分支持。如果最开始语法等就有提交错误,就相当于java的编译器都过不了,那么肯定全部不执行。如果在执行过程中报错,已经全部执行了,但是谁报错找谁,其他正常执行放行。各取所需!这里的事务并不是要么全部成功,要么全部失败,全部执行和全部成功(或者都失败)是2个概念
  • 在事务执行的过程中,出错的命令会被服务器识别出来,并进行相应的错误处理, 所以这些出错命令不会对数据库做任何修改,也不会对事务的一致性产生任何影响

7.事务的实现过程

事务实现

  • 事务开始
  • 命令入队
  • 事务执行

事务的开始

MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的

命令入队

  • 当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行
  • 当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不 同命令执行不同的操作:
    • 如果客户端发送的命令为EXEC、DISCARD、WATCH、MULTI四个命令的其中一个, 那么服务器立即执行这个命令
    • 与此相反,如果客户端发送的命令是EXEC、DISCARD、WATCH、MULTI四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回QUEUED回复

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

typedef struct redisClient {
    // ...
    //事务状态
    multiState mstate; /* MULTI/EXEC state */
    // ...
} redisClient;


事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度)

typedef struct multiState {
    //事务队列,FIFO 顺序
    multiCmd *commands;

    //已入队命令计数
    int count;
} multiState;

事务队列是一个multiCmd类型的数组,数组中的每个multiCmd结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量

typedef struct multiCmd {
    //参数
    robj **argv;

    //参数数量
    int argc;

    //命令指针
    struct redisCommand *cmd;
} multiCmd;

事务队列以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的 前面,而较后入队的命令则会被放到数组的后面

事务执行

当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命 令所得的结果全部返回给客户端

8.事务中命令校验

  • 如果客户端发送的命令为 EXEC、DISCARD、 WATCH、MULTI|四个命令的其中一个,那么服务器立即执行这个命令。

  • 如果客户端发送的命令是 EXEC、DISCARD、WATCH、MULTI 四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回 QUEUED 回复。

image-20220918235743777

9.Multi和Pipleline的区别?

Redis中的Multi和Pipleline都可以一次性执行多个命令,

Pipeline只是把多个redis指令一起发出去,redis并没有保证这些指令执行的顺序,且减少了多次网络传递的开销,因而其执行效率很高;

Multi相当于一个redis的transaction,保证整个操作的有序性,通过watch这些key,可以避免这些key在事务的执行过程中被其它的命令修改,从而导致得的到结果不是所期望的。

redis管道命令

redis-cli --pipe 可以大量插入数据,也可以从文件中批量插入数据。对于我们要手动为系统缓存一些数据到 Redis 时,可以通过数据库进行查询,查询后通过管道来进行导入

[root@VM_0_4_centos ~]# cat cmd.txt | redis-cli --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 5

10.watch 命令的实现

WATCH 命令是一个乐观锁(optimistic locking)

功能: EXEC 命令执行之前,监视任意数量的数据库键,并在 EXEC 命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。

每个 Redis 数据库都保存着一个 watched_ keys 字典

  • 字典的键是某个被WATCH命令监视的数据库键
  • 字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端
typedef struct redisDb {
    // ...
    //正在被WATCH 命令监视的键
    dict *watched_keys;
    // ...
} redisDb;

image-20220919000919253

监视机制的触发

所有对数据库进行修改的命令,比如SET、LPUSH、SADD、ZREM、DEL、FLUSHDB等等,在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey函数会将监视被修改键的客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已经被破坏。

判断事务是否安全

当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务:

  • 如果客户端的REDIS_DIRTY_CAS标识已经被打开,那么说明客户端所监视的键当中,至少有一个键已经被修改过了,在这种情况下,客户端提交的事务已经不再安全,所以服务器会拒绝执行客户端提交的事务。
  • 如果客户端的REDIS_DIRTY_CAS标识没有被打开,那么说明客户端监视的所有键都没有被修改过(或者客户端没有监视任何键),事务仍然是安全的,服务器将执行客户端提交的这个事务。

11.事务ACID

事务的ACID性质

  • 在传统的关系式数据库中,常常用ACID性质来检验事务功能的可靠性和安全性
  • 在Redis中,事务总是具有以下的特性:
    • 原子性(Atomicity)
    • 一致性(Consistency)
    • 隔离性 (Isolation)
    • 当Redis运行在某种特定的持久化模式下时,事务也具有耐久性 (Durability)

12.事务的持久性

  • 事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失

  • 因为Redis的事务不过是简单地用队列包裹起了一组Redis命令,Redis并没有为事务提供任何额外的持久化功能,所以

    Redis事务的耐久性由Redis所使用的持久化模式决定:

    • **当服务器在无持久化的内存模式下运作时,事务不具有耐久性:**一旦服务器停机,包括 事务数据在内的所有服务器数据都将丢失
    • 当服务器在RDB持久化模式下运作时,服务器只会在特定的保存条件被满足时,才会执行BGSAVE命令,对数据库进行保存操作,并且异步执行的BGSAVE不能保证事务数据被第 一时间保存到硬盘里面,因此RDB持久化模式下的事务也不具有耐久性。
    • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,程序总会 在执行命令之后调用同步(sync)函数,将命令数据真正地保存到硬盘里面,因此这种配置 下的事务是具有耐久性的
    • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为everysec时,程序会 每秒同步一次命令数据到硬盘。因为停机可能会恰好发生在等待同步的那一秒钟之内,这可 能会造成事务数据丢失,所以这种配置下的事务不具有耐久性
    • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为no时,程序会交由操作系统来决定何时将命令数据同步到硬盘。因为事务数据可能在等待同步的过程中丢失,所 以这种配置下的事务不具有耐久性

13.lua 脚本

先说下使用Lua脚本的好处:

减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延。
原子操作。redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
复用。客户端发送的脚本会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。

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

redis> EVAL  "return 'hello world'" 0
"hello world"

而使用 EVALSHA 命令则可以根据脚本的 SHA1 校验和来对脚本进行求值,但这个命令要求校验和对应的脚本必须至少被 EVAL 命令执行过一次:

创建并修改 lua 环境

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

  1. 创建一个基础的 L ua 环境,之后的所有修改都是针对这个环境进行的。
  2. 载入多个函数库到 Lua 环境里面,让 Lua 脚本可以使用这些函数库来进行数据操作。
  3. 创建全局表格 redis,这个表格包含了对 Redis 进行操作的函数,比如用于在 L ua 脚本中执行 Redis 命令的 redis.call 函数。
  4. 使用 Redis 自制的随机函数来替换 L ua 原有的带有副作用的随机函数,从而避免在脚本中引入副作用。
  5. 创建排序辅助函数,Lua 环境使用这个辅佐函数来对一部分 Redis 命令的结果进行排序,从而消除这些命令的不确定性。
  6. 创建 redis.pcall 函数的错误报告辅助函数,这个函数可以提供更详细的出错信息。
  7. 对 L ua 环境中的全局环境进行保护,防止用户在执行 Lua 脚本的过程中,将额外的全局变量添加到 L ua 环境中。
  8. 将完成修改的 Lua 环境保存到服务器状态的 Iua 属性中,等待执行服务器传来的 Lua 脚本。

lua 脚本通信步骤

image-20220919002055179

14.EVAL命令的实现

EVAL 命令的执行过程可以分为以下三个步骤:

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

Lua环境协作组件

  • 除了创建并修改Lua环境之外,Redis服务器还创建了两个用于与Lua环境进行协作的组件,它们分别是:
    • 负责执行Lua脚本中的Redis命令的伪客户端
    • 用于保存Lua脚本的lua_scripts字典

lua_scripts字典

除了伪客户端之外,Redis服务器为Lua环境创建的另一个协作组件是lua_scripts字典:

  • 字典的键为某个Lua脚本的SHA1校验和(checksum)
  • 字典的值则是SHA1校验和对应 的Lua脚本
struct redisServer {
    // ...
    dict *lua_scripts;
    // ...
};
  • Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里面
  • **lua_scripts字典有两个作用:**一个是实现SCRIPT EXISTS命令,另一个是实现脚本复制功能

15.redis 排序

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

redis> SORT key

image-20220919002640639

image-20220919002745255

实现原理

  1. 创建一个和 numbers 列表长度相同的数组,该数组的每个项都是一个 redis.h/redisSortObject 结构,如图 21-1 所示。
  2. 遍历数组,将各个数组项的 obj 指针分别指向 numbers 列表的各个项,构成 obj 指针和列表项之间的一对一关系,如图 21- -2 所示。
  3. 遍历数组,将各个 obj 指针所指向的列表项转换成一个 double 类型的浮点数,并将这个浮点数保存在相应数组项的 u.score 属性里面,如图 21- 3 所示。
  4. 根据数组项 u.score 属性的值,对数组进行数字值排序,排序后的数组项按 u.score 属性的值从小到大排列,如图 21- -4 所示。
  5. 遍历数组,将各个数组项的 obj 指针所指向的列表项作为排序结果返回给客户端,程序首先访问数组的索引 0,返回 u.score 值为 1.0 的列表项"1";然后访问数组的索引 1,返回 u.score 值为 2.0 的列表项"2";最后访问数组的索引 2,返回 u.score 值为 3.0 的列表 项"3"。
typedef struct _redisSortObject {
    //被排序键的值
    robj *obj;
 
    //权重
    union {
        //排序数字值时使用
        double score;
        //排序带有BY 选项的字符串值时使用
        robj *cmpobj;
    } u;
} redisSortObject;

16.ALPHA 选项的实现

通过使用 ALPHA 选项,SORT 命令可以对包含字符串值的键进行排序:

SORT <key> ALPHA

image-20220919003203446

17.BY 选项的实现

在默认情况下,SORT 命令使用被排序键包含的元素作为排序的权重,元素本身决定了元素在排序之后所处的位置。例如,排序 fruits 集合所使用的权重就是"apple"、“banana” 、"cherry"三个元素本身:

另一方面,通过使用 BY 选项,SORT 命令可以指定某些字符串键,或者某个哈希键所包含的某些域(fheld) 来作为元素的权重,对一个键进行排序。
例如,以下这个例子就使用苹果、香蕉、樱桃三种水果的价钱,对集合键 fruits 进行了排序:

redis> MSET apple- -price 8 banana-price 5.5 cherry-price 7
OK
redis> SORT fruits BY *-price
1) "banana"
2) "cherry"
3) "apple"

image-20220919003600122

18.二进制位数组

Redis 提供了 SETBIT、GETBIT、 BITCOUNT、BITOP 四个命令用于处理二进制位数组(bit array,又称“位数组”)

  • SETBIT 命令 用于为位数组指定偏移量上的二进制位设置值,位数组的偏移量从 0 开始计数,而二进制位的值则可以是 0 或者 1.
  • GETBIT 命令则用于获取位数组指定偏移量.上的二进制位的值.
  • BITCOUNT 命令用于统计位数组里面,值为 1 的二进制位的数量.
  • BITOP 命令既可以对多个位数组进行按位与(and)按位或(or) 、按位异或(xor) 运算.

备注重点

  • buf数组保存二进制位与我们平时表示的二进制为顺序是相反的
  • 例如我们的buf数组第1字节表示的二进制为10110010,实质上其表示的是01001101
  • 使用逆序来保存位数组可以简化SETBIT命令的实现

image-20220919004223402

19.GETBIT 的实现

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

20.SETBIT 的实现

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

21.BITCOUNT 的实现

计算汉明距离

  • 遍历算法
  • 查表算法
  • variable-precision SWAR
  • Redis 的实现(查表和 SWAR 结合,二进制位数量 128 位)

以下是调用 swar (bitarray) 的执行步骤:

  • 步骤 1 计算出的值 i 的二进制表示可以按每两个二进制位为一-组进行分组,各组的十进制表示就是该组的汉明重量。
  • 步骤 2 计算出的值 i 的二进制表示可以按每四个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量。
  • 步骤 3 计算出的值 i 的二进制表示可以按每八个二进制位为一-组进行分组,各组的十进制表示就是该组的汉明重量。
  • 步骤 4 的 i*0x01010101 语句计算出 bitarray 的汉明重量并记录在二_进制位的最高八位,而>>24 语句则通过右移运算,将 bitarray 的汉明重量移动到最低八位,得出的结果就是 bitarray 的汉明重量。
uint32_t swar(uint32_t i) {
    //步骤1
    i = (i & 0x55555555) + ((i >> 1) & 0x55555555);
 
    //步骤2
    i = (i & 0x33333333) + ((i >> 2) & 0x33333333);
 
    //步骤3
    i = (i & 0x0F0F0F0F) + ((i >> 4) & 0x0F0F0F0F);
 
    //步骤4
    i = (i*(0x01010101) >> 24);
    
    return i;
}

总结

  • swar函数每次执行可以计算32个二进制位的汉明重量,它比之前介绍的遍历算法要快32 倍,比键长为8位的查表法快4倍,比键长为16位的查表法快2倍,并且因为swar函数是单纯 的计算操作,所以它无须像查表法那样,使用额外的内存
  • 另外,因为swar函数是一个常数复杂度的操作,所以我们可以按照自己的需要,在一次循环中多次执行swar,从而按倍数提升计算汉明重量的效率:
    • 例如,如果我们在一次循环中调用两次swar函数,那么计算汉明重量的效率就从之前的 一次循环计算32位提升到了一次循环计算64位
    • 又例如,如果我们在一次循环中调用四次swar函数,那么一次循环就可以计算128个二 进制位的汉明重量,这比每次循环只调用一次swar函数要快四倍!
  • 当然,在一个循环里执行多个swar调用这种优化方式是有极限的:一旦循环中处理的位数组的大小超过了缓存的大小,这种优化的效果就会降低并最终消失

22.慢查询日志

Redis 的慢查询日志功能用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来监视和优化查询速度。服务器配置有两个和慢查询日志相关的选项:

  • slowlog-log-slower-than选项指定执行时间超过多少微秒(1 秒等于 1 000 000 微秒)的命令请求会被记录到日志上。

    • 如果这个选项的值为 100,那么执行时间超过 100 微秒的命令就会被记录到慢查询日志;
    • 如果这个选项的值为 500,那么执行时间超过 500 微秒的命令就会被记录到慢查询日志。
  • slowlog-max-len 选项指定服务器最多保存多少条慢查询日志。服务器使用先进先出的方式保存多条慢查询日志,当服务器存储的慢查询日志数量等于 slowlog-max-len 选项的值时,服务器在添加一条新的慢查询日志之前,会先将最旧一条慢查询日志删除。

一般情况下,我们都是通过客户端连接 Redis 服务器,然后发送命令给 Redis 服务器,Redis 服务器会把每个客户端发来的命令缓存入一个队列,然后逐个进行执行,最后再把结果返回给客户端。而我们这里的慢查询指的就是“执行命令”的那部分。而非网络 I/O 或者 命令排队的问题。

命令

  • **SLOWLOG GET:**用来查看服务器所保存的慢查询日志
  • **SLOWLOG RESET:**用于清空所有慢查询日志
  • **SLOWLOG LEN:**查询慢查询日志的数量
#获取慢查询日志
slowlog get

#清除慢查询日志
slowlog reset

慢查询日志的保存

struct redisServer {
    // ...
    //下一条慢查询日志的ID
    long long slowlog_entry_id;
 
    //保存了所有慢查询日志的链表
    list *slowlog;
 
    //服务器配置slowlog-log-slower-than 选项的值
    long long slowlog_log_slower_than;
 
    //服务器配置slowlog-max-len 选项的值
    unsigned long slowlog_max_len;
    // ...
};
  • slowlog_entry_id属性:的初始值为0,每当创建一条新的慢查询日志时,这个属性的值就 会用作新日志的id值,之后程序会对这个属性的值增一
    • 例如,在创建第一条慢查询日志时,slowlog_entry_id的值0会成为第一条慢查询日志的 ID,而之后服务器会对这个属性的值增一;当服务器再创建新的慢查询日志的时候, slowlog_entry_id的值1就会成为第二条慢查询日志的ID,然后服务器再次对这个属性的值增 一,以此类推
  • slowlog链表:保存了服务器中的所有慢查询日志,链表中的每个节点都保存了一个 slowlogEntry结构,每个slowlogEntry结构代表一条慢查询日志
typedef struct slowlogEntry {
    //唯一标识符
    long long id;
 
    //命令执行时的时间,格式为UNIX 时间戳
    time_t time;
 
    //执行命令消耗的时间,以微秒为单位
    long long duration;
 
    //命令与命令参数
    robj **argv;
 
    //命令与命令参数的数量
    int argc;
} slowlogEntry;

23.监视器

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

每当一个客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求之外, 还会将关于这条命令请求的信息发送给所有监视器

redis> MONITOR
OK
def MONITOR():
    # 打开客户端的监视器标志
    client.flags |= REDIS_MONITOR
    # 将客户端添加到服务器状态的monitors 链表的末尾
    server.monitors.append(client)
    # 向客户端返回OK
    send_reply("OK")

image-20220925225656566

24.redis的优化

Redis是一个纯内存的KV数据库,对于内存使用做了相当多的优化,比如:

  • 为了对键值对做优化,很多类型底层都有两种实现方案,比如List和Hash在数据量较少时,采用ziplist和zipmap实现,内存使用效率更高,当数据量增大时,为了查询效率,才转化为quicklist和dict。
  • 在内存分配中,Redis放弃了glibc中的tcmalloc,采用jemalloc,大大减少了内存碎片率。
  • 当数据库所占内存超过配置的阈值时,为了保护系统,Redis会采用对应的规则淘汰KV对,淘汰策略有很多,包括LRU、LFU等算法,这部分在evict.c中。
  • Redis支持对key设置过期时间,当key过期时,会采用相应策略删除KV对,这部分在expire.c中。

同时Redis是个单线程数据库,意味着包括读取解析客户端命令、处理命令、回复响应、大部分后台任务都要在一个线程中完成,这就要求任何步骤都不能造成长时间的阻塞,由此造成了Redis独有的一些处理方式:

  • 当数据库需要扩容时,会逐步rehash,这部分在dict.h/c中
  • 部分后台任务会在单独的线程中处理,例如:删除key、关闭文件、fsync文件,这部分在bio.h/c和lazyfree.c中。
  • 每个模块都需要一些周期性任务,这些任务在server.c中实现,同时根据执行频率确定每个任务的执行间隔,有些任务还会严格的限制执行时间

25.集群脑裂

Redis主从集群切换数据丢失问题如何应对

  • 异步复制同步丢失
  • 集群产生脑裂数据丢失

对于 Redis 主节点与从节点之间的数据复制,是异步复制的,当客户端发送写请求给 master 节点的时候,客户端会返回 OK,然后同步到各个 slave 节点中。如果此时 master 还没来得及同步给 slave 节点时发生宕机,那么 master 内存中的数据会丢失;要是 master 中开启持久化设置数据可不可以保证不丢失呢?答案是否定的。在 master 发生宕机后,sentinel 集群检测到 master 发生故障,重新选举新的 master,如果旧的 master 在故障恢复后重启,那么此时它需要同步新 master 的数据,此时新的 master 的数据是空的(假设这段时间中没有数据写入)。那么旧 master 中的数据就会被刷新掉,此时数据还是会丢失。

首先我们需要理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁来控制呢?在分布式集群中,分布式协作框架 zookeeper 很好的解决了这个问题,通过控制半数以上的机器来解决。那么在 Redis 中,集群脑裂产生数据丢失的现象是怎么样的呢?

假设我们有一个 redis 集群,正常情况下 client 会向 master 发送请求,然后同步到 salve,sentinel 集群监控着集群,在集群发生故障时进行自动故障转移。此时,由于某种原因,比如网络原因,集群出现了分区,master 与 slave 节点之间断开了联系,sentinel 监控到一段时间没有联系认为 master 故障,然后重新选举,将 slave 切换为新的 master。但是 master 可能并没有发生故障,只是网络产生分区,此时 client 任然在旧的 master 上写数据,而新的 master 中没有数据,如果不及时发现问题进行处理可能旧的 master 中堆积大量数据。在发现问题之后,旧的 master 降为 slave 同步新的 master 数据,那么之前的数据被刷新掉,大量数据丢失。

img

在了解了上面的两种数据丢失场景后,我们如何保证数据可以不丢失呢?在分布式系统中,衡量一个系统的可用性,我们一般情况下会说 4 个 9,5 个 9 的系统达到了高可用(99.99%,99.999%,据说淘宝是 5 个 9)。对于 redis 集群,我们不可能保证数据完全不丢失,只能做到使得尽量少的数据丢失。

在 redis 的配置文件中有两个参数我们可以设置:

min-slaves-to-write 1 min-slaves-max-lag 10

min-slaves-to-write 默认情况下是 0
min-slaves-max-lag 默认情况下是 10

以上面配置为例,这两个参数表示至少有 1 个 salve 与 master 的同步复制延迟不能超过 10s,一旦所有的 slave 复制和同步的延迟达到了 10s,那么此时 master 就不会接受任何请求。

我们可以减小 min-slaves-max-lag 参数的值,这样就可以避免在发生故障时大量的数据丢失,一旦发现延迟超过了该值就不会往 master 中写入数据。

那么对于 client,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间后重新写入 master 来保证数据不丢失;也可以将数据写入 kafka 消息队列,隔一段时间去消费 kafka 中的数据。

通过上面两个参数的设置我们尽可能的减少数据的丢失,具体的值还需要在特定的环境下进行测试设置。

26.排行榜功能

后端程序实现的时候,第一个想到的肯定是排序算法,把要进行排行的数据收集起来,使用排序算法排好序,第一个元素排名为1,依次类推。我们知道,排序算法,使用快排的平均时间复杂度为O(nlogn), 获取玩家的排名,需要遍历计算排名,时间复杂度为O(n)。 那么更新玩家积分并获取玩家最新排名,这个操作的时间复杂度为 O(nlogn) + O(n), 对于少量玩家排行榜系统来说,是可以接受的。但是对于100万,甚至1000万玩家的排行榜系统来说,每次更新都要重新排序,显然不可接受,我们需要更快的算法。

更快的算法,基本思路就是空间换时间,摒除每次都将所有数据重新排序的思路,改为部分数据调整,Redis的Sorted Set数据结构使用了 跳表 和 哈希表 结合的方式,更新数据的时间复杂度为 O(logn), 获取排行的时间复杂度为 O(logn), 即使数据量很大,也能高效排序。

27.淘汰策略

//缓存淘汰策略
#define MAXMEMORY_VOLATILE_LRU ((0<<8)|MAXMEMORY_FLAG_LRU)
#define MAXMEMORY_VOLATILE_LFU ((1<<8)|MAXMEMORY_FLAG_LFU)
#define MAXMEMORY_VOLATILE_TTL (2<<8)
#define MAXMEMORY_VOLATILE_RANDOM (3<<8)
#define MAXMEMORY_ALLKEYS_LRU ((4<<8)|MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_ALLKEYS_LFU ((5<<8)|MAXMEMORY_FLAG_LFU|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_ALLKEYS_RANDOM ((6<<8)|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_NO_EVICTION (7<<8)

一共有8种
1、MAXMEMORY_VOLATILE_LRU:从设置了过期时间的键中根据LRU淘汰
2、MAXMEMORY_VOLATILE_LFU:从设置了过期时间的键中根据LFU淘汰
3、MAXMEMORY_VOLATILE_TTL:从设置了过期时间的键中根据最长生存时间淘汰
4、MAXMEMORY_VOLATILE_RANDOM:从设置了过期时间的键中任意选择键淘汰
5、MAXMEMORY_FLAG_LRU:从所有键中根据LRU淘汰
6、MAXMEMORY_FLAG_LFU:从所有键中根据LFU淘汰
7、MAXMEMORY_ALLKEYS_RANDOM:从所有键中任意选择键淘汰
8、MAXMEMORY_NO_EVICTION:不进行键淘汰,如果内存不够,直接OOM

28.性能问题排查与优化

从资源使用角度来看,包含的知识点如下:

  • CPU 相关:使用复杂度过高命令、数据的持久化,都与耗费过多的 CPU 资源有关
  • 内存相关:bigkey 内存的申请和释放、数据过期、数据淘汰、碎片整理、内存大页、内存写时复制都与内存息息相关
  • 磁盘相关:数据持久化、AOF 刷盘策略,也会受到磁盘的影响
  • 网络相关:短连接、实例流量过载、网络流量过载,也会降低 Redis 性能
  • 计算机系统:CPU 结构、内存分配,都属于最基础的计算机系统知识
  • 操作系统:写时复制、内存大页、Swap、CPU 绑定,都属于操作系统层面的知识

图片

29.缓存击穿

一个并发访问量比较大的key在某个时间过期,导致所有的请求直接打在DB上。

解决方案

  • 加锁更新,⽐如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。
  • 将过期时间组合写在value中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。

30.什么是缓存穿透?如何避免?

缓存穿透
一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。一些恶意的请求会故意查询不存在的key,请求量很大,就会对后端系统造成很大的压力,就叫做缓存穿透。

避免

  • 对查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该key对应的数据insert了之后清理缓存。
  • 对一定不存在的key进行过滤。可以把所有的可能存在的key放到一个大的Bitmap中,查询时通过该bitmap过滤。

31.什么是缓存雪崩?何如避免?

缓存雪崩
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,会给后端系统带来很大压力。导致系统崩溃。

避免

  • 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
  • 做二级缓存,A1为原始缓存,A2为拷贝缓存;A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期
  • 不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

32.Redis是单线程的,但为什么这么快?

  1. 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)
  2. 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;
  3. 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
  4. 使用多路I/O复用模型,非阻塞IO;这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程
  5. 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
  6. Redis6.0的多线程是用多线程来处理数据的读写和协议解析,但是Redis执行命令还是单线程的。

33.如何处理热点key?

对热key的处理,最关键的是对热点key的监控,可以从这些端来监控热点key:

  1. 客户端 客户端其实是距离key“最近”的地方,因为Redis命令就是从客户端发出的,例如在客户端设置全局字典(key和调用次数),每次调用Redis命令时,使用这个字典进行记录。
  2. 代理端 像Twemproxy、Codis这些基于代理的Redis分布式架构,所有客户端的请求都是通过代理端完成的,可以在代理端进行收集统计。
  3. Redis服务端 使用monitor命令统计热点key是很多开发和运维人员首先想到,monitor命令可以监控到Redis执行的所有命令。

只要监控到了热key,对热key的处理就简单了:

  1. 把热key打散到不同的服务器,降低压⼒
  2. 加⼊⼆级缓存,提前加载热key数据到内存中,如果redis宕机,⾛内存查询

34.Redis实现分布式锁了解吗?

  • V1:setnx命令

占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。

  • V2:锁超时释放

所以在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。

但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。

  • V3:set指令

这个问题在Redis 2.8 版本中得到了解决,这个版本加入了 set 指令的扩展参数,使得 setnx 和expire 指令可以一起执行。

Redisson分布式锁,就是对唯一管理,获取到了就获取到了锁,没有得到就没获取锁,不能执行,当然了,还涉及到锁的释放,超时等

set lock:fighter3 true ex 5 nx OK ... do something critical ... > del lock:codehole

分布式锁

  • 单个redis
    • 加锁(SETNX)
      • PX,增加锁失效时间,防止占用过久
      • 区分客户端加锁,解锁时进行匹配,防止误解锁
    • 解锁
      • lua脚本
  • 多个redis(Redlock)
    • 思想:让客户端和多个独立的Redis实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败
    • 加锁
      • 加锁步骤
        • 获取客户端时间
        • 客户端按顺序依次向N个Redis实例执行加锁操作
        • 一旦客户端完成 了和所有Redis实例的加锁操作,客户端就要计算整个加锁过程的总耗时
      • 加锁成功条件
        • 客户端从超过半数(大于等于N/2+1)的Redis实例上成功获取到了锁
        • 客户端获取锁的总耗时没有超过锁的有效时间
    • 解锁
      • 执行N个解锁的Lua脚本

redlock

多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)
  • 防止了 单节点故障造成整个服务停止运行的情况;
  • 在多节点中锁的设计,及多节点同时崩溃等各种意外情况有自己独特的设计方法
  • 使用redession实现分布锁的过程

35.如何无阻塞找key

假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?

使用 keys 指令可以扫出指定模式的 key 列表。但是要注意 keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。

36.StringRedisTemplate与RedisTemplate

  • 两者的关系是StringRedisTemplate继承RedisTemplate。
  • 两者的数据是不共通的;也就是说StringRedisTemplate只能管理StringRedisTemplate里面的数据,RedisTemplate只能管理RedisTemplate中的数据。
  • SDR默认采用的序列化策略有两种,一种是String的序列化策略,一种是JDK的序列化策略。
    • StringRedisTemplate默认采用的是String的序列化策略,保存的key和value都是采用此策略序列化保存的。
    • RedisTemplate默认采用的是JDK的序列化策略,保存的key和value都是采用此策略序列化保存的。

37.缓存一致性?

三个经典的缓存模式

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的缓存模式:

  • Cache-Aside Pattern
  • Read-Through/Write through
  • Write behind

Cache-Aside Pattern

Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。

读流程

  1. 读的时候,先读缓存,缓存命中的话,直接返回数据
  2. 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。

写流程

更新的时候,先更新数据库,然后再删除缓存

Read-Through/Write-Through(读写穿透)

Read/Write Through模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。

  1. 从缓存读取数据,读到直接返回
  2. 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。

Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新

Write behind (异步缓存写入)

Write behindRead-Through/Write-Through有相似的地方,都是由Cache Provider来负责缓存和数据库的读写。它两又有个很大的不同:Read/Write Through是同步更新缓存和数据的,Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。

这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

延迟双删

  1. 先删除缓存
  2. 再更新数据库
  3. 休眠一会(比如1秒),再次删除缓存。

这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。 为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

删除缓存重试机制

不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,如果第二步的删除缓存失败呢,删除失败会导致脏数据哦~

删除失败就多删除几次呀,保证删除缓存成功呀~ 所以可以引入删除缓存重试机制

读取biglog异步删除缓存

重试删除缓存机制还可以,就是会造成好多业务代码入侵。其实,还可以通过数据库的binlog来异步淘汰key

38.resp协议

为了满足Redis高性能的要求,Redis特地设计了RESP(全称REdis Serialization Protocol)协议,用来作为Redis客户端与服务端的通讯协议,RESP协议有以下优点实现简单,解析高效,可读性好
注意:RESP底层用的连接方式还是TCPRESP只定义了客户端与服务端的数据交互格式

RESP 数据类型

RESP中,总共定义了5种数据类型,分别是Simple StringErrorsIntergersBulk StringsArrays,第一个字节与数据类型的映射关系如下:

  • 对于Simple Strings类型,第一个字节为+
  • Errors类型,第一个字节为-
  • Integers类型,第一个字节为:
  • Bulk Strings类型,第一个字节为$
  • Arrays类型,第一个字节为*