一.数据结构与对象
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;
}
- 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.链表的结构?
链表的优点
- 链表提供了高效的节点重排能力,
- 以及顺序生的节点访问方式,
- 并且可以通过增删节点来来灵活地调整链表的长度
如上图所示,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 的索引。
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)的问题。
11.解决键冲突
- 当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)
- Redis 的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有 一个 next 指针,多个哈希表节点可以用 next 指针构成一个单向链表,被分配到同一个索引上 的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题
12.rehash 的过程?
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(loadfactor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。扩展和收缩哈希表的工作可以通过执行 rehash(重新散列)操作来完成,Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的 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。
- 将保存在 ht[0]中的所有键值对 rehash 到 ht[1]上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1]哈希表的指定位置上。
- 当 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 是节点所保存的成员对象
初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位.注意不要和分值 score 弄混淆了.
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。
19.压缩列表
压缩列表(ziplist)是列表和哈希的底层实现之一。
-
当一个列表只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表的底层实现。
-
当一个哈希只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做哈希的底层实现。
压缩列表是 Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结枃。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值,如下图。
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 类型整数
节点的 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-xr11gHEn-1672365181682)(http://qinyingjie.top/blogImg/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQxNDUzMjg1,size_16,color_FFFFFF,t_70-20220926104214792.png)]
OBJECT ENCODING 命令
- 可以查看一个数据库键的值对象的编码。下表列出了不同编码的对象所对应的 OBJECT ENCODING 命令输出
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 编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:
-
保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
-
先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
hashtable 编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
-
字典的每个键都是一个字符串对象,对象中保存了键值对的键;
-
字典的每个值都是一个字符串对象,对象中保存了键值对的值。
编码转换
当哈希对象可以同时满足以下两个条件时,哈希对象使用 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
整数集合
hashtable
编码转换
当集合对象可以同时满足以下两个条件时,对象使用 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 选择了同时使用字典和跳跃表两种数据结构来实现有有序集合。
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 中,让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。**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 对象。
其他键空间操作
除了上面列出的添加、删除、更新、取值操作之外,还有很多针对数据库本身的 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 命令一样。
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 多路复用程序传来的套接字,并根据套接字产生的事件的类型, 调用相应的事件处理器
- **事件处理器:**服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数, 它们定义了某个事件发生时,服务器应该执行的动作
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 多路复用程序的底层实现是可以互换的,如图所示。
-
如果套接字没有任何事件被监听,那么函数返回 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 复用模型。
Linux 系统有三种方式实现 IO 多路复用:select、poll 和 epoll。
例如 epoll 方式是将用户 socket 对应的 fd 注册进 epoll,然后 epoll 帮你监听哪些 socket 上有消息到达,这样就避免了大量的无用操作。此时的 socket 应该采用非阻塞模式。
这样,整个过程只在进行 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的 reactor 模式。
13.一次完整的客户端与服务器连接事件示例
- ① 假设一个 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 属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。
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 跳出写入循环,将 余下的数据留到下次再写;另外,时间事件也会将非常耗时的持久化操作放到子线程或者子 进程执行
- ④ 因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚一些
18.daemonize
-
daemonize 介绍
- redis.conf 配置文件中 daemonize 守护线程,默认是 NO。
- daemonize 是用来指定 redis 是否要用守护线程的方式启动。
-
daemonize 设置
yes
或者no
区别daemonize:yes
:redis 采用的是单进程多线程的模式。当 redis.conf 中选项 daemonize 设置成 yes 时,代表开启守护进程模式。在该模式下,redis 会在后台运行,并将进程 pid 号写入至 redis.conf 选项 pidfile 设置的文件中,此时 redis 将一直运行,除非手动 kill 该进程。daemonize:no
: 当 daemonize 选项设置成 no 时,当前界面将进入 redis 的命令行界面,exit 强制退出或者关闭连接工具(putty,xshell 等)都会导致 redis 进程退出。
19.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 文件保存和载入
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 文件为二进制格式保存,下面我们为了演示效果,采用字符串的形式演示
- 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 文件
每个非空数据库在 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 号数据库
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 的结构和长度也会有所不同
下图展示了一个没有过期时间的字符串键值对:
带有过期时间的键值对结构
带有过期时间的键值对在 RDB 文件中由 EXPIRETIME_MS、ms、TYPE、key、value 五部分组成:
- EXPIRETIME_MS 常量:长度为 1 字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间
- ms:是一个 8 字节长的带符号整数,记录着一个以毫秒为单位的 UNIX 时间戳,这个时间戳就是键值对的过期时间
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fi08OX2W-1672365188515)(null)]
假设 0 号数据库中有 2 个不带有过期时间的键值对,数据库 3 含有 1 个不带有过期时间的键值对和 1 个带有过期时间的键值对
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 文件保存这种对象的结构如下图所示:
- list_length 记录了列表的长度,它记录列表保存了多少个项(item),读入程序可以通过这个长度知道自己应该读入多少个列表项
- 图中以 item 开头的部分代表列表的项,因为每个列表项都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入列表项
示例
- 第一个列表项的长度为 5,内容为字符串"hello"。
- 第二个列表项的长度也为 5,内容为字符串"world"。
- 第三个列表项的长度为 1,内容为字符串"!"
9.3.集合对象
如果 TYPE 的值为 REDIS_RDB_TYPE_SET,那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象
RDB 文件保存这种对象的结构如下图所示:
- set_size 是集合的大小,它记录集合保存了多少个元素,读入程序可以通过这个大小知道自己应该读入多少个集合元素
- 以 elem 开头的部分代表集合的元素,因为每个集合元素都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入集合元素
示例,下图展示了一个包含四个元素的集合,结构中的第一个数字 4 记录了集合的大小,之后跟着的是集合的四个元素:
- 第一个元素的长度为 5,值为"apple"
- 第二个元素的长度为 6,值为"banana"
- 第三个元素的长度为 3,值为"cat"
- 第四个元素的长度为 3,值为"dog”
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NGzKG7UR-1672365181687)(http://qinyingjie.top/blogImg/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=-20220912235141288.png)]
9.4.哈希表对象
如果 TYPE 的值为 REDIS_RDB_TYPE_HASH,那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象
RDB 文件保存这种对象的结构如下图所示:
- hash_size 记录了哈希表的大小,也即是这个哈希表保存了多少键值对,读入程序可以通过这个大小知道自己应该读入多少个键值对
- 以 key_value_pair 开头的部分代表哈希表中的键值对,键值对的键和值都是字符串对象,所以程序会以处理字符串对象的方式来保存和读入键值对
结构中的每个键值对都以键紧挨着值的方式排列在一起,如下图所示:
因此,从更详细的角度看,哈希表对象的结构可以表示为下图所示:
作为示例,下图展示了一个包含两个键值对的哈希表,第一个数字 2 记录了哈希表的键值对数量,之后跟着的是两个键值对:
- 第一个键值对的键是长度为 1 的字符串"a",值是长度为 5 的字符串"apple"
- 第二个键值对的键是长度为 1 的字符串"b",值是长度为 6 的字符串"banana"
9.5.有序集合对象
如果 TYPE 的值为 REDIS_RDB_TYPE_ZSET,那么 value 保存的就是一个 REDIS_ENCODING_SKIPLIST 编码的有序集合对象
RDB 文件保存这种对象的结构如下图所示:
sorted_set_size 记录了有序集合的大小,也即是这个有序集合保存了多少元素,读入程序需要根据这个值来决定应该读入多少有序集合元素
以 element 开头的部分代表有序集合中的元素,每个元素又分为成员(member)和分值(score)两部分,成员是一个字符串对象,分值则是一个 double 类型的浮点数,程序在保存 RDB 文件时会先将分值转换成字符串对象,然后再用保存字符串对象的方法将分值保存起来
有序集合中的每个元素都以成员紧挨着分值的方式排列,下图所示:
因此,从更详细的角度看,有序集合对象的结构可以表示为下图所示:
作为示例,下图展示了一个带有两个元素的有序集合,第一个数字 2 记录了有序集合的元素数量,之后跟着的是两个有序集合元素:
- 第一个元素的成员是长度为 2 的字符串"pi",分值被转换成字符串之后变成了长度为 4 的字符串"3.14"
- 第二个元素的成员是长度为 1 的字符串"e",分值被转换成字符串之后变成了长度为 3 的字符串"2.7"
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 服务器所执行的写命令来记录数据库状态的
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 文件并还原数据库状态的详细步骤如下:
- 创建一个不带网络连接的伪客户端(fake client):因为 Redis 的命令只能在客户端上下文中执行,而载入 AOF 文件时所使用的命令直接来源于 AOF 文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行 AOF 文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
- 从 AOF 文件中分析并读取出一条写命令。
- 使用伪客户端执行被读出的写命令。
- 一直执行步骤 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 重写期间,服务器进程(父进程)可以继续处理命令请求
- 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况 下,保证数据的安全性
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 链表的样子
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 数组的长度。
命令表中使用字典保存着 redis 的命令字典.且不区分大小写.
8.命令的实现函数
当服务器从协议内容中分析并得出 argv 属性和 argc 属性的值之后,服务器将根据项 argv[0]的值,在命令表中查找命令所对应的命令实现函数
命令表格式,下图展示了一个命令表示例,该表是一个字典,字典的键是一个 SDS 结构,保存了命令的名字,字典的值是命令所对应的 redisCommand 结构,这个结构保存了命令的实现函数、 命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息
- 当程序在命令表中成功找到 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.读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面。
- 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里面。
- 调用命令执行器,执行客户端指定的命令。
4.命令执行器
查找命令
第一件事根据 argv[0] 参数在命令表中查找命令,并保存到客户端状态的 cmd 属性中命令表是一个字典,键为字符串对象,值为 redisCommand 结构记录 redis 命令实现信息 redisCommand 结构主要属性:
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-2da7KdKf-1672365181690)(http://qinyingjie.top/blogImg/webp)]
执行预操作
-
执行命令之前还需要一些预操作
-
检查客户端状态的 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 的状态
- 主服务器将记录在缓冲区里的所有写命令发送给从服务器,从服务器执行写命令,更新指服务器所处的状态
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MEyQmwOG-1672365181690)(http://qinyingjie.top/blogImg/%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-K1obdjlQ-1672365181691)(http://qinyingjie.top/blogImg/PSYNC%E8%BF%87%E7%A8%8B.b55p88goz8o.png)]
6.部分重同步实现
部分重同步功能由以下三个部分构成:
- 主服务器的复制偏移量和从服务器的复制偏移量(replication offset)
- 主服务器的复制积压缓冲区(replication backlog)
- 服务器的运行 ID(run ID)
复制偏移量
主服务器和从服务器会分别维护一个复制偏移量:
- 主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值加上 N
- 从服务器每次收到主服务器传播的 N 个字节数据时,就将自己的复制偏移量加上 N
通过对比偏移量可以知道主从服务器是否处于一致状态
复制积压缓冲区
复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认为 1 MB,模型是滑动窗口.
当主服务器进行命令传播时,不仅会将写命令发给所有从服务器,还会将写命令入队到复制积压缓冲区里面,且队列中每个字节记录相应的复制偏移量
当从服务器重新连上主服务器后,从服务器会通过 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-y0ey1RNC-1672365181691)(http://qinyingjie.top/blogImg/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-OkqzRrJC-1672365181691)(http://qinyingjie.top/blogImg/%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-dSlij4oJ-1672365181691)(http://qinyingjie.top/blogImg/%E6%AD%A5%E9%AA%A42.8dpjsasewso.png)]
步骤 3:发送 PING 命令
从服务器成为客户端后第一件事是向主服务器发送一个 PING 命令
-
虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信, 通过发送 PING 命令可以检查套接字的读写状态是否正常
-
因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送 PING 命令可以检查主服务器能否正常处理命令请求
从服务器将遇到三种情况:
- 主服务器返回命令回复,但从服务器不能在规定时间内读取命令回复的内容,则表示主从服务器之间的网络连接状态不稳定,需要从服务器断开并重新创建套接字
- 主服务器返回错误。表示主服务器无法处理从服务器命令请求,则从服务器断开并重新创建套接字
- 从服务器读到 “PONG” 回复,表示主从服务器状态正常,可以进行后续操作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mzpQhTuz-1672365181691)(http://qinyingjie.top/blogImg/%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-m6PfTVu4-1672365181691)(http://qinyingjie.top/blogImg/%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 不存在
通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在干主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。
七.Sentinel
1.Sentinel 哨兵模式功能
哨兵功能:哨兵的核心功能是主节点的自动故障转移。
- 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
- 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
- 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
- 通知(Notification):哨兵可以将故障转移的结果发送给客户端。
2.哨兵模式架构图
Sentinel 哨兵组成了一个 Redis 高可用性的方案:
由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器及其所有从服务器,并在主服务器下线时将其从服务器升级为新的主服务器,继续执行要求,当先前的主服务器再次上线时则成为新的从服务器
2.启动并初始化 Sentinel
redis-sentinel /path/to/yourt/setinel.conf
# or
redis-server /path/to/yourt/setinel.conf -- sentinel
启动时执行以下:
- 初始化服务器
- 将普通 Redis 服务器使用的代码替换 Sentinel 专用代码
- 初始化 Sentinel 代码
- 根据给定的配置文件,初始化 Sentinel 的监视主服务器列表
- 创建连向主服务器的网络连接
3.初始化服务器
初始化 Sentinel 服务器与普通服务器的区别:
- 首先,因为 Sentinel 本质上只是一个运行在特殊模式下的 Redis 服务器,所以启动 Sentinel 的第一步,就是初始化一个普通的 Redis 服务器。
- 不过,因为 Sentinel 执行的工作和普通 Redis 服务器执行的工作不同,所以 Sentinel 的初始化过程和普通 Redis 服务器的初始化过程并不完全相同
- 例如,普通服务器在初始化时会通过载入 RDB 文件或者 AOF 文件来还原数据库状态,但 是因为 Sentinel 并不使用数据库,所以初始化 Sentinel 时就不会载入 RDB 文件或者 AOF 文件
Sentinel 本质是一个运行在特殊模式下的 Redis 服务器,所以启动第一步也是初始化一个 Redis 服务器.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vUFllGfh-1672365181692)(http://qinyingjie.top/blogImg/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 创建命令连接和订阅连接的例子:
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 命令
根据 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 只要使用命令连接来进行通信就足够了
13.检测主观下线状态
默认情况下,Sentinel 会默认每秒一次的频率向所有与它创建了命令连接的实例(主服务器、从服务器、其他 Sentinel ) 发送 PING 命令,通过回复判断实例是否在线
实例对 PING 命令的回复有两种:
- 有效回复:+PONG、-LOADING、-MASTERDOWN 三种
- 无效回复:返回有效回复外的其他回复,或在指定时间内没有任何回复
Sentinel 配置文件中的 down-after-millisecond 的值为 50000 毫秒,当主服务器 master 连续 50000 毫秒内都向 Sentinel 返回无效回复,则会被标记为主观下线,对应的实例结构的 flags 被标识为 SRI_S_DOWN
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 是否同意主服务器已下线
接收 SENTIENL is-master-down-by-addr 命令
目标 Sentinel 接收到源 Sentinel 发来的命令后,目标会分析提取参数,根据主服务器 IP 和端口检查主服务器是否已下线,然后向源返回一个包含三个参数的 Multi Bulk 作为回复
- <down_state>
- <leader_runid>
- <leader_epoch>
接收 SENTIENL is-master-down-by-addr 命令的回复
根据其他 Sentinel 发回的命令回复,统计同意已下线的数量,达到配置的数量则判断为客观下线,flags 标志为 SRI_O_DOWN
-
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.哨兵模式故障转移
三个步骤:
-
在已下线的主服务器的从服务器中选出一个从服务器,成为新的主服务器
-
让其他从服务器改为复制新的主服务器
-
将已下线的主服务器设置为新的主服务器的从服务器,当重新上线时自动成为主服务器的从服务器
选出新的主服务器
挑选一个状态良好、数据完整的从服务器,发送 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。
八.集群
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 节点当前所在的集群中。
节点加入到集群
启动节点
一个节点就是一个运行在集群模式下的 Redis 服务器,Redis 服务器在启动时会根据 cluster-enabled 配置选项是否为 yes 来决定是否开启服务器的集群模式
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;
6.CLUSTER MEET 命令
通过向节点 A 发送 CLUSTER MEET 命令,客户端可以让接收命令的节点 A 将另一个节点 B 添加到节点 A 当前所在的集群里面:
CLUSTER MEET <ip> <port>
收到命令的节点 A 将与节点 B 进行握手(hand shake),以此来确认彼此的存在,并为将来的进一步通信打好基础:
- 节点 A 会为节点 B 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里面。
- 之后,节点 A 将根据 CLUSTER MEET 命令给定的 IP 地址和端口号,向节点 B 发送一条 MEET 消息(message)
- 如果一切顺利,节点 B 将接收到节点 A 发送的 MEET 消息,节点 B 会为节点 A 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里面。
- 之后,节点 B 将向节点 A 返回一条 PONG 消息。
- 如果一切顺利,节点 A 将接收到节点 B 返回的 PONG 消息,通过这条 PONG 消息节点 A 可以知道节点 B 已经成功地接收到了自己发送的 MEET 消息。
- 之后,节点 A 将向节点 B 返回一条 PING 消息。
- 如果一切顺利,节点 B 将接收到节点 A 返回的 PING 消息,通过这条 PING 消息节点 B 可以知道节点 A 已经成功地接收到了自己返回的 PONG 消息,握手完成。
之后,节点 A 会将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点,让其他节点也与节点 B 进行握手,最终,经过一段时间之后,节点 B 会被集群中的所有节点认识。
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。
图 17-9 展示了一个 slots 数组示例:这个数组索引 0 至索引 7 上的二进制位的值都为 1,其余所有二进制位的值都为 0,这表示节点负责处理槽 0 至槽 7。
因为取出和设置 slots 数组中的任意一个二进制位的值的复杂度仅为 O(1),所以对于一个给定节点的 slots 数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是 O (1)
至于 numslots 属性则记录节点负责处理的槽的数量,也即是 slots 数组中值为 1 的二进制位的数量。
传播节点的槽指派信息
一个节点除了会将自己负责处理的槽记录在 clusterNode 结构的 slots 属性和 numslots 属性之外,它还会将自己的 slots 数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
当节点 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)
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 数组的关键区别所在。
10.CLUSTER ADDSLOTS
CLUSTER ADDSLOTS 命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责;
对 1,2 执行命令后
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2wnqKWgC-1672365181695)(http://qinyingjie.top/blogImg/image-20220918225912032.png)]
- clusterState.slots 数组在索引 1 和索引 2 上的指针指向了代表当前节点的 clusterNode 结构。
- 并且 clusterNode.slots 数组在索引 1 和索引 2.上的位被设置成了 1。
- 最后,在 CLUSTER ADDSLOTS 命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽。
11.集群执行命令
在对数据库中的 16384 个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个 MOVED 错误,指引客户端转向(redirect) 至正确的节点,并再次发送之前想要执行的命令。
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 进行存储。
- 一致性哈希算法对于容错性和扩展性有非常好的支持。但一致性哈希算法也有一个严重的问题,就是数据倾斜
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 跳跃表解除被删除键与槽号的关联。
15.重新分片
-
Redis 集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点) ,并且相关槽所属的键值对也会从源节点被移动到目标节点。
-
重新分片操作可以在线(online) 进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
重新分片原理
Redis 集群的重新分片操作是由 Redis 的集群管理软件 redis-trib 负责执行的,Redis 提供了进行重新分片所需的所有命令,而 redis-trib 则通过向源节点和目标节点发送命令来进行重新分片操作。
redis-trib 対集群的単个槽 slot 迸行重新分片的歩驟如下:
- redis-trib 対目棕节点发送 CLUSTER SETSLOTIMPORTING<source_id/> 命令,让目标节点准备好从源节点导入(import) 属于槽 slot 的键値対 。
- redis-trib 対源节点发送 CLUSTE R SETSLOTMIGRATING<target_id/> 命令,沚源节点准备好将属于槽 slot 的鍵値対迁移(migrate) 至目标节点。
- redis-trib 向源节点发送 CLUSTER GETKEYSINSLOT命令,荻得最多 count 个属于槽 slot 的鍵値対的鍵名(key name)。
- 対于歩驟 3 获得的毎个鍵名,redis-trib 都向源节点发送一个 MIGRATE<target* ip/><target_port <key* name/>0命令,將被选中的鍵原子地从源节点迁移至目标节点。
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 )
故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:
- 复制下线主节点的所有从节点里面,会有一个从节点被选中。
- 被选中的从节点会执行 SLAVEOF no one 命令, 成为新的主节点。
- 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
- 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
- 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
21.选举新的主节点
新的主节点是通过选举产生的,Redis 中是通过 currentEpoch 和 configEpoch 来解决这些信息更新问题,epoch 的概念与 raft 协议中的 epoch 类似,表示逻辑时钟,每次集群状态更新之后,epoch 都会+1,谁的 epoch 大,谁的 gossip 消息就可信。
currentEpoch 表示当前集群的 epoch,configEpoch 表示当前节点看到的其他节点的 epoch。
以下是集群选举新的主节点的方法:
- 集群的配置纪元是一个自增计数器,它的初始值为 0
- 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一
- 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一 个向主节点要求投票的从节点将获得主节点的投票
- 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票
- 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其 他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,表示这个主节点支持从节点成为新的 主节点
- 每个参与选举的从节点都会接收 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消 息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持
- 如果集群里有 N 个具有投票权的主节点,那么当一个从节点收集到大于等于 N/2+1 张 支持票时,这个从节点就会当选为新的主节点
- 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有 N 个主节点进行投票,那么具有大于等于 N/2+1 张支持票的从节点只会有一个,这确保了新的 主节点只会有一个
- 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个 新的配置纪元,并再次进行选举,直到选出新的主节点为止
这个选举新主节点的方法和前面文章介绍的选举领头 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 命令计算坐标之间的距离
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;
模式的订阅实现
前面说过,服务器将所有频道的订阅关系都保存在服务器状态的 pubsub_channels 属性里面,与此类似,服务器也将所有模式的订阅关系都保存在服务器状态的 pubsub_channels 属 性里面:
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 回复。
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;
监视机制的触发
所有对数据库进行修改的命令,比如 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 环境的整个过程由以下步骤组成:
- 创建一个基础的 L ua 环境,之后的所有修改都是针对这个环境进行的。
- 载入多个函数库到 Lua 环境里面,让 Lua 脚本可以使用这些函数库来进行数据操作。
- 创建全局表格 redis,这个表格包含了对 Redis 进行操作的函数,比如用于在 L ua 脚本中执行 Redis 命令的 redis.call 函数。
- 使用 Redis 自制的随机函数来替换 L ua 原有的带有副作用的随机函数,从而避免在脚本中引入副作用。
- 创建排序辅助函数,Lua 环境使用这个辅佐函数来对一部分 Redis 命令的结果进行排序,从而消除这些命令的不确定性。
- 创建 redis.pcall 函数的错误报告辅助函数,这个函数可以提供更详细的出错信息。
- 对 L ua 环境中的全局环境进行保护,防止用户在执行 Lua 脚本的过程中,将额外的全局变量添加到 L ua 环境中。
- 将完成修改的 Lua 环境保存到服务器状态的 Iua 属性中,等待执行服务器传来的 Lua 脚本。
lua 脚本通信步骤
14.EVAL 命令的实现
EVAL 命令的执行过程可以分为以下三个步骤:
- 根据客户端给定的 Lua 脚本,在 Lua 环境中定义一个 Lua 函数。
- 将客户端给定的脚本保存到 lua_scripts 字典,等待将来进一步使用。
- 执行刚刚在 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
实现原理
- 创建一个和 numbers 列表长度相同的数组,该数组的每个项都是一个 redis.h/redisSortObject 结构,如图 21-1 所示。
- 遍历数组,将各个数组项的 obj 指针分别指向 numbers 列表的各个项,构成 obj 指针和列表项之间的一对一关系,如图 21- -2 所示。
- 遍历数组,将各个 obj 指针所指向的列表项转换成一个 double 类型的浮点数,并将这个浮点数保存在相应数组项的 u.score 属性里面,如图 21- 3 所示。
- 根据数组项 u.score 属性的值,对数组进行数字值排序,排序后的数组项按 u.score 属性的值从小到大排列,如图 21- -4 所示。
- 遍历数组,将各个数组项的 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
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"
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 命令的实现
19.GETBIT 的实现
- 计算 byte= loffset/8」 ,byte 值记录了 offset 偏移量指定的 1 二进制位保存在位数组的哪个字节。
- 计算 bit= (offset mod 8) +1, bit 值记录了 offset 偏移量指定的二进制位是 byte 字节的第几个二进制位。
- 根据 byte 值和 bit 值,在位数组 bitarray 中定位 offset 偏移量指定的二进制位,并返回这个位的值。
20.SETBIT 的实现
- 计算 len= offset/8 +1,len 值记录了保存 offset 偏移量指定的二进制位至少需要多少字节。
- 检查 bitarray 键 保存的位数组(也即是 SDS)的长度是否小于 len,如果是的话,将 SDS 的长度扩展为 len 字节,并将所有新扩展空间的二进制位的值设置为 0。
- 计算 byte= offset/8 ,byte 值 记录了 offset 偏移量指定的二进制位保存在位数组的哪个字节。
- 计算 bit= (offset mod 8) +1, bit 值记录了 offset 偏移量指定的二进制位是 byte 字节的第几个二进制位。
- 根据 byte 值和 bit 值, 在 bitarray 键保存的位数组中定位 offset 偏移量指定的二进制位,首先将指定二进制位现在值保存在 oldvalue 变量,然后将新值 value 设置为这个二进制位的值。
- 向客户端返回 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")
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 中实现,同时根据执行频率确定每个任务的执行间隔,有些任务还会严格的限制执行时间
十.常见问题
1.Redis 应用场景
并不是所有的接口都适合增加缓存,但是对于类似字典表中的数据我们完全可以进行缓存,还有一些不经常变化的数据也可以进行缓存。
- 缓存
- 排行榜
- pv 统计
- uv 统计
- 定时取消订单
- 简单分布式锁
- 唯一 id
2.Redis 为什么快?
- 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于 HashMap,HashMap 的优势就是查找和操作的时间复杂度都是
O(1)
; - 数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的;
- 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
- 使用多路 I/O 复用模型,非阻塞 IO;这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程
- 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
- Redis6.0 的多线程是用多线程来处理数据的读写和协议解析,但是 Redis执行命令还是单线程的。
3.什么是缓存击穿?
一个并发访问量比较大的 key 在某个时间过期,导致所有的请求直接打在 DB 上。
解决方案
- 加锁更新,⽐如请求查询 A,发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。
- 将过期时间组合写在 value 中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。
4.什么是缓存穿透?
缓存穿透
一般的缓存系统,都是按照 key 去缓存查询,如果不存在对应的 value,就应该去后端系统查找(比如 DB)。一些恶意的请求会故意查询不存在的 key,请求量很大,就会对后端系统造成很大的压力,就叫做缓存穿透。
避免
- 对查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该 key 对应的数据 insert 了之后清理缓存。
- 对一定不存在的 key 进行过滤。可以把所有的可能存在的 key 放到一个大的
Bitmap
中,查询时通过该 bitmap 过滤。
5.什么是缓存雪崩?
缓存雪崩
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,会给后端系统带来很大压力。导致系统崩溃。
避免
- 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个 key 只允许一个线程查询数据和写缓存,其他线程等待。
- 做二级缓存,A1 为原始缓存,A2 为拷贝缓存;A1 失效时,可以访问 A2,A1 缓存失效时间设置为短期,A2 设置为长期
- 不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
6.如何处理热点 key?
对热 key 的处理,最关键的是对热点 key 的监控,可以从这些端来监控热点 key:
- 客户端 客户端其实是距离 key“最近”的地方,因为 Redis 命令就是从客户端发出的,例如在客户端设置全局字典(key 和调用次数),每次调用 Redis 命令时,使用这个字典进行记录。
- 代理端 像 Twemproxy、Codis 这些基于代理的 Redis 分布式架构,所有客户端的请求都是通过代理端完成的,可以在代理端进行收集统计。
- Redis 服务端 使用 monitor 命令统计热点 key 是很多开发和运维人员首先想到,monitor 命令可以监控到 Redis 执行的所有命令。
只要监控到了热 key,对热 key 的处理就简单了:
- 把热 key 打散到不同的服务器,降低压⼒
- 加⼊⼆级缓存,提前加载热 key 数据到内存中,如果 redis 宕机,⾛内存查询
7.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 脚本
- 加锁(SETNX)
- 多个 redis(Redlock)
- 思想:让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败
- 加锁
- 加锁步骤
- 获取客户端时间
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作
- 一旦客户端完成 了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时
- 加锁成功条件
- 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁
- 客户端获取锁的总耗时没有超过锁的有效时间
- 加锁步骤
- 解锁
- 执行 N 个解锁的 Lua 脚本
redlock
多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)
- 防止了 单节点故障造成整个服务停止运行的情况;
- 在多节点中锁的设计,及多节点同时崩溃等各种意外情况有自己独特的设计方法
- 使用 redession 实现分布锁的过程
8.集群脑裂
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-03YED7l1-1672365181697)(http://qinyingjie.top/blogImg/793c2d5f39382a84aafe9703e3c36f55259d5c89.png@942w_432h_progressive.webp)]
在了解了上面的两种数据丢失场景后,我们如何保证数据可以不丢失呢?在分布式系统中,衡量一个系统的可用性,我们一般情况下会说 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 中的数据。
通过上面两个参数的设置我们尽可能的减少数据的丢失,具体的值还需要在特定的环境下进行测试设置。
9.排行榜功能
后端程序实现的时候,第一个想到的肯定是排序算法,把要进行排行的数据收集起来,使用排序算法排好序,第一个元素排名为 1,依次类推。我们知道,排序算法,使用快排的平均时间复杂度为 O(nlogn), 获取玩家的排名,需要遍历计算排名,时间复杂度为 O(n)。 那么更新玩家积分并获取玩家最新排名,这个操作的时间复杂度为 O(nlogn) + O(n), 对于少量玩家排行榜系统来说,是可以接受的。但是对于 100 万,甚至 1000 万玩家的排行榜系统来说,每次更新都要重新排序,显然不可接受,我们需要更快的算法。
更快的算法,基本思路就是空间换时间,摒除每次都将所有数据重新排序的思路,改为部分数据调整,Redis 的 Sorted Set 数据结构使用了 跳表 和 哈希表 结合的方式,更新数据的时间复杂度为 O(logn), 获取排行的时间复杂度为 O(logn), 即使数据量很大,也能高效排序。
10.淘汰策略
//缓存淘汰策略
#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
11.性能问题排查与优化
从资源使用角度来看,包含的知识点如下:
- CPU 相关:使用复杂度过高命令、数据的持久化,都与耗费过多的 CPU 资源有关
- 内存相关:bigkey 内存的申请和释放、数据过期、数据淘汰、碎片整理、内存大页、内存写时复制都与内存息息相关
- 磁盘相关:数据持久化、AOF 刷盘策略,也会受到磁盘的影响
- 网络相关:短连接、实例流量过载、网络流量过载,也会降低 Redis 性能
- 计算机系统:CPU 结构、内存分配,都属于最基础的计算机系统知识
- 操作系统:写时复制、内存大页、Swap、CPU 绑定,都属于操作系统层面的知识
12.如何无阻塞找 key
假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如何将它们全部找出来?
使用 keys
指令可以扫出指定模式的 key 列表。但是要注意 keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan
指令,scan
指令可以无阻塞的提取出指定模式的 key
列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys
指令长。
13.Template 区别
RedisTemplate 和 StringRedisTemplate 的区别
使用 RedisTemplate 和 StringRedisTemplate 之所以有所差异,**是因为 StringRedisTemplate 在对 Key 和 Value 进行序列化时,都使用字符串的方式进行序列化。**因此,我们选择 StringRedisTemplate 会方便一些。
- 两者的关系是 StringRedisTemplate 继承 RedisTemplate。
- 两者的数据是不共通的;也就是说 StringRedisTemplate 只能管理 StringRedisTemplate 里面的数据,RedisTemplate 只能管理 RedisTemplate 中的数据。
- SDR 默认采用的序列化策略有两种,一种是 String 的序列化策略,一种是 JDK 的序列化策略。
- StringRedisTemplate 默认采用的是 String 的序列化策略,保存的 key 和 value 都是采用此策略序列化保存的。
- RedisTemplate 默认采用的是 JDK 的序列化策略,保存的 key 和 value 都是采用此策略序列化保存的。
14.缓存一致性?
三个经典的缓存模式
缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的缓存模式:
- Cache-Aside Pattern
- Read-Through/Write through
- Write behind
Cache-Aside Pattern
Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。
读流程
- 读的时候,先读缓存,缓存命中的话,直接返回数据
- 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。
写流程
更新的时候,先更新数据库,然后再删除缓存。
Read-Through/Write-Through(读写穿透)
Read/Write Through模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。
- 从缓存读取数据,读到直接返回
- 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。
Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新
Write behind (异步缓存写入)
Write behind跟Read-Through/Write-Through有相似的地方,都是由Cache Provider
来负责缓存和数据库的读写。它两又有个很大的不同:Read/Write Through是同步更新缓存和数据的,Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。
这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL 的InnoDB Buffer Pool 机制就使用到这种模式。
延迟双删
- 先删除缓存
- 再更新数据库
- 休眠一会(比如 1 秒),再次删除缓存。
这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。 为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
删除缓存重试机制
不管是延时双删还是Cache-Aside 的先操作数据库再删除缓存,如果第二步的删除缓存失败呢,删除失败会导致脏数据哦~
删除失败就多删除几次呀,保证删除缓存成功呀~ 所以可以引入删除缓存重试机制
读取 biglog 异步删除缓存
重试删除缓存机制还可以,就是会造成好多业务代码入侵。其实,还可以通过数据库的 binlog 来异步淘汰 key。
15.resp 协议
为了满足Redis
高性能的要求,Redis
特地设计了RESP
(全称REdis Serialization Protocol
)协议,用来作为Redis
客户端与服务端的通讯协议,RESP
协议有以下优点实现简单,解析高效,可读性好
注意:RESP
底层用的连接方式还是TCP
,RESP
只定义了客户端与服务端的数据交互格式
RESP 数据类型
在RESP
中,总共定义了5
种数据类型,分别是Simple String
、Errors
、Intergers
、Bulk Strings
和Arrays
,第一个字节与数据类型的映射关系如下:
- 对于
Simple Strings
类型,第一个字节为+
Errors
类型,第一个字节为-
Integers
类型,第一个字节为:
Bulk Strings
类型,第一个字节为$
Arrays
类型,第一个字节为*
16.Redis 的全称是什么?
Remote Dictionary Server
17.NoSQL 介绍
NoSQL 是指非关系型数据库 ,非关系型数据库和关系型数据库两者存在许多显著的不同点,其中最重要的是 NoSQL 不使用 SQL 作为查询语言。其数据存储可以不需要固定的表格模式,一般都有水平可扩展性的特征。
NoSQL 主要有如下几种不同的分类:
- key/value 键值存储。这种数据存储通常都是无数据结构的, 一般被当作字符串或者二进制数据,但是数据加载速度快,典型的使用场景是处理高并发或者用于日志系统等,这一类的数据库有 Redis、Tokyo Cabinet 等。
- 列存储数据库。列存储数据库功能相对局限,但是查找速度快,容易进行分布式扩展,一般用于分布式文件系统中,这一类的数据库有 HBase、Cassandra 等。
- 文档型数据库 和 key/value 键值存储类似,文档型数据库也没有严格的数据格式,这既是缺点也是是优势,因为不需要预先创建表结构,数据格式更加灵活,一般可用在 We 应用中,这一类数据库有 MongoDB、CouchDB 等。
- 图形数据库 。图形数据库专注于构建关系图谱,例如社交网络,推荐系统等,这一类的数据库有 Neo4J、 DEX 等。