说明:本文章为阅读《Redis设计与实现》笔记。Redis设计与实现
1、简单动态字符串(SDS)
Redis是由C语言开发,没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了一种名为 简单动态字符串 (simple dynamic string,SDS) 的抽象类型。
SDS是Redis的默认字符串,但是C字符串也会被使用,比如打印日志上。
SDS字符串使用
redis> SET msg "hello world"
OK
-
键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串“msg”的SDS。
-
键值对的值也是一个字符串对象,对象的底层实现是一个保存着 字符串“hello world”的SDS。
SDS结构
struct sdshdr {
// 数组中已使用字节的数量 等于SDS 所保存字符串的长度
int len;
// 记录buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
下图展示了一个SDS示例
- free属性的值为0,表示这个SDS没有分配未使用空间。
- len属性的值为5,表示这个SDS保存了一个5字节长的字符串
- ·buf属性是一个char类型的数组,数组的前五个字节分别保存 了’R’、‘e’、‘d’、‘i’、‘s’五个字符,而最后一个字节则保存了空字符’\0’。
保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,由SDS函数自动完成的。
为什么要这么做呢?
答案: SDS可以直接重用一部分C字符串函数库里面的函数。
例如:我们有一个指向图2-1所示SDS的指针s,那么我们 可以直接使用<stdio.h>/printf函数,通过执行以下语句
printf("%s", s->buf)
来打印出SDS保存的字符串值“Redis”,而无须为SDS编写专门的打印函数。
为什么不使用直接使用C字符串?
1、常数复杂度获取字符串长度
C字符串并不记录自身的长度信息,获取一个C字符串的长度,程序必须遍历整个字符串,这个操作的复杂度O(N),和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所 以获取一个SDS长度的复杂度仅为O(1)。
Redis将获取字符串长度所需的复杂 度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈
例如,因为字符串键在底层使用SDS来实现,所以 即使我们对一个非常长的字符串键反复执行STRLEN命令,也不会对系 统性能造成任何影响,因为STRLEN命令的复杂度仅为O(1)。
2、杜绝缓冲区溢出
C字符串不记录自身长度带 来的另一个问题是容易造成缓冲区溢出(buffer overflow)。举个栗子,<string.h>/strcat 函数可以将src字符串中的内容拼接到dest字符串的末尾:
char *strcat(char *dest, const char *src);
假设程序员在执行strcat函数前已经为dest分配足够的内存,但是在拼接src字符串时一旦超过了dest分配空间的最大长度则会产生缓冲区溢出。
再举个栗子
假设程序里有两个在内存中紧邻着的C字符串s1和s2, 其中s1保存了字符串"Redis",而s2则保存了字符串"MongoDB",如图所示。
接下来执行
strcat(s1, " Cluster");
将s1的内容修改为"Redis Cluster",但粗心的他却忘了在执行strcat 之前为s1分配足够的空间,那么在strcat函数执行之后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外地修改,
如果程序执行的是缩短字符串的操作,比如截断操作(trim),那 么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使 用的那部分空间——如果忘了这一步就会产生内存泄漏。
与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:
当SDS API需要对SDS进行修改时,API会先检查SDS的空间 是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改 所需的大小,然后才执行实际的修改操作。
举个例子,SDS的API里面也有一个用于执行拼接操作的sdscat函 数,它可以将一个C字符串拼接到给定SDS所保存的字符串的后面,但是在执行拼接操作之前,sdscat会先检查给定SDS的空间是否足够,如果不够的话,sdscat就会先扩展SDS的空间,然后才执行拼接操作。
sdscat(s, " Cluster");
sdscat不仅对这个SDS进行了拼接操作, 它还为SDS分配了13字节的未使用空间,并且拼接之后的字符串也正好是13字节长这种现象既不是bug也不是巧合,它和SDS的空间分配策略。
SDS的空间分配策略
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所 以它通常是一个比较耗时的操作:
在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的。
但是Redis作为数据库,经常被用于速度要求严苛、数据被频繁修 改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响。
为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串 长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是 字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就 由SDS的free属性记录。
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。
1、空间预分配
空间预分配用于优化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+1byte。
举个例子:
接着我们执行
sdscat(s, " Cluster");
那么sdscat将执行一次内存重分配操作,将SDS的长度修改为13字节,并将SDS的未使用空间同样修改为13字节,如图2-12所示。
接着执行
sdscat(s, " Tutorial");
那么这次sdscat将不需要执行内存重分配,因为未使用空间里面的 13字节足以保存9字节的"Tutorial",执行sdscat之后的SDS如图2-13所 示。
![](https://i-blog.csdnimg.cn/blog_migrate/6a0c7baad6a7c883b3549608f0d992f7.png)
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配。
2、惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要 缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后 多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
sdstrim(s, "XY"); // 移除SDS 字符串中的所有'X' 和'Y'
注意执行sdstrim之后的SDS并没有释放多出来的8字节空间,而是 将这8字节空间作为未使用空间保留在了SDS里面,如果将来要对SDS进 行增长操作的话,这些未使用空间就可能会派上用场。
2、链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。C语言并没有内置这种数据结构,所以Redis构建了自己 的链表实现。
举个例子,以下展示的integers列表键包含了从1到1024共一千零二 十四个整数:
redis> LLEN integers
(integer) 1024
redis> LRANGE integers 0 10
1)"1"
2)"2"
3)"3"
4)"4"
5)"5"
6)"6"
7)"7"
8)"8"
9)"9"
10)"10"
11)"11"
在redis3.0中,List的底层是通过双向链表和压缩列表来实现的
结构设计
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
因为有前置节点和后置节点,所以可以看出这是一个双向链表。不过,Redis 在 listNode 结构体基础上又封装了 list 这个数据结构,这样操作起来会更方便。
typedef struct list {
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值比较函数
int (*match)(void *ptr, void *key);
//链表节点数量
unsigned long len;
} list;
list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型。
- dup函数用于复制链表节点所保存的值;
- free函数用于释放链表节点所保存的值;
- match函数则用于对比链表节点所保存的值和另一个输入值是否相等。
特点
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL, 对链表的访问以NULL为终点。
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序 获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:程序使用list结构的len属性来对list持有的链表 节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
3、字典
在字典中,字典中的每个键都是独一无二的, 一个键(key)可以和一个值(value)进行关联(或者 说将键映射为值),这些关联的键和值就称为键值对。
举个例子,当我们执行命令:
redis> SET msg "hello world"
OK
在数据库中创建一个键为"msg",值为"hello world"的键值对时,这个键值对就是保存在代表数据库的字典里面的。
哈希表
Redis的字典使用哈希表作为底层实现一个哈希表里面可以有多 个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
结构定义
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值 // 总是等于size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table属性是一个数组,数组中的每个元素都是一个指向 dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size 属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于 size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
下面展示了一个大小为4的空哈希表(没有包含任何键值对)
哈希表节点
dictEntry结构
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)的问题。
举个例子,下图就展示了如何通过next指针,将两个索引值相同的键k1和k0连接在一起。
字典
Redis中的字典由dict.h/dict结构表示:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引 // 当rehash 不在进行时,值为-1
int rehashidx;
} dict;
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:
- type 属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
- privdata 属性则保存了需要传给那些类型特定函数的可选参数。
- ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
- rehashidx 它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。
示意图
哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对 的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈 希表节点放到哈希表数组的指定索引上面。
Redis计算哈希值和索引值的方法如下:
// 使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
// 使用哈希表的sizemask属性和哈希值,计算出索引值 ,根据情况不同,ht[x]可以是ht[0] 或者ht[1]
index = hash & dict->ht[x].sizemask;
对于图4-4所示的字典来说,如果我们要将一个键值对k0和v0添加到字典里面,那么程序会先使用语句:
- 计算键k0的哈希值。
hash = dict->type->hashFunction(k0);
- 假设计算得出的哈希值为8,那么程序会继续使用语句:
index = hash&dict->ht[0].sizemask = 8 & 3 = 0;
计算出键k0的索引值0,这表示包含键值对k0和v0的节点应该被放 置到哈希表数组的索引0位置上,如图所示。
当字典被用作数据库的底层实现,或者哈希键的底层实现时, Redis使用MurmurHash2算法来计算键的哈希值。关于MurmurHash算法的更多信息可以参考该算法的 主页:MurmurHash算法
解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面 时,我们称这些键发生了冲突(collision)。
Redis的哈希表使用链地址法
。每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表。类似于jdk1.7的hashmap。
假设程序要将键值对k2和v2添加到哈希表里面,并且计算得出k2的索引值为2,那么键k1和k2将产生冲突,而解决冲突的办法就是使用next指针将键k2和k1所在的节点连接起来,如图所示。
rehash
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减 少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之 内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的 大小进行相应的扩展或者收缩
Redis对字典的哈希表执行rehash的步骤如下:
1)为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于 要执行的操作,以及ht[0]当前包含的键值对数量(也即是 ht[0].used 属性的值):
- 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于
ht[0].used*2的2 (2的n次方幂
); - 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于
ht[0].used的2
。
2)将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
3)当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
1)图4-8 ht[0].used当前的值为4,4*2=8,而8(2 )恰好是第一个大于等于4的2的n次方,所以程序会将ht[1]哈希表的大小设置为8。图4-9展示 了ht[1]在分配空间之后,字典的样子。
2)将ht[0]包含的四个键值对都rehash到ht[1],如图4-10所示。
3)释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈 希表,如图4-11所示。至此,对哈希表的扩展操作执行完毕,程序成功 将哈希表的大小从原来的4改为了现在的8。
哈希表的扩展与收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行 扩展操作:
1)服务器目前没有在执行BGSAVE
命令或者BGREWRITEAOF
命 令,并且哈希表的负载因子大于等于1。
2)服务器目前正在执行BGSAVE
命令或者BGREWRITEAOF
命 令,并且哈希表的负载因子大于等于5。
其中哈希表的负载因子可以通过公式:
load_factor = ht[0].used / ht[0].size
负载因子= 哈希表已保存节点数量/ 哈希表大小
例如,对于一个大小为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为:load_factor = 4 / 4 = 1
例如,对于一个大小为512,包含256个键值对的哈希表来说,这 个哈希表的负载因子为:load_factor = 256 / 512 = 0.5
那为什么这两个命令运行不运行的负载因子不同?
Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操 作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
另一方面,当哈希表的 负载因子小于0.1时,程序自动开始对哈希 表执行收缩操作。
渐进式rehash
扩展或收缩哈希表需要将ht[0]里面的所有键值对 rehash到ht[1]里面,但是,这个rehash动作并不是一次性、集中式地完成 的,而是分多次、渐进式
地完成的。
这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器 可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保 存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那 么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
以下是哈希表渐进式rehash的详细步骤:
1)为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2)在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
3)在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在 rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
4)随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表 示rehash操作已完成。
说的有些抽象,下面用图来展示
4、整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合 作为集合键的底层实现。
eg:
redis> SADD numbers 1 3 5 7 9
(integer) 5
redis> OBJECT ENCODING numbers
"intset"
结构
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
- contents :整数集合的每个元素都是 contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
- length :整数集合包含的元素数量,contents数组的长度。
- encoding: 虽然intset结构将contents属性声明为int8_t类型的数组,但实际上 contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值( 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是 一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值 (最小值为-32768,最大值为32767))。
例如:
- encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实 现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值。
- length属性的值为5,表示整数集合包含五个元素。
- contents数组按从小到大的顺序保存着集合中的五个元素。 ·因为每个集合元素都是int16_t类型的整数值,所以contents数组的 大小等于sizeof(int16_t)✖️5=16✖️5=80位。
升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级 (upgrade),然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
1)根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需 要继续维持底层数组的有序性质不变。
3)将新元素添加到底层数组里面。
说的有些抽象,下面用图来展示,,假设现在有一个INTSET_ENC_INT16编码的整数集合, 集合中包含三个int16_t类型的元素
因为每个元素都占用16位空间,所以整数集合底层数组的大小为 3*16=48位,每个元素所处的位置
现在将int_32位的65535整数值添加到整数集合中。因为int_16位的长度容不下int_32的长度所以要对底层数组中所有的元素进行重分配。
现在集合中有3个元素,再加上新添加进来的元素是4个元素,因为int_32位的65535要占用32位的空间,4个元素总共占用32*4=128位,所以接下来要将数组中有的元素转成32位,在放置过程中需要保持元素的顺序不变
首先,因为元素3在1、2、3、65535四个元素中排名第三,所以它 将被移动到contents数组的索引2位置上,也即是数组64位至95位的空间 内,如图所示。
接着,因为元素2在1、2、3、65535四个元素中排名第二,所以它将被移动到contents数组的索引1位置上,也即是数组的32位至63位的空间内,如图所示。
之后,因为元素1在1、2、3、65535四个元素中排名第一,所以它 将被移动到contents数组的索引0位置上,即数组的0位至31位的空间内
然后,因为元素65535在1、2、3、65535四个元素中排名第四,所 以它将被添加到contents数组的索引3位置上,也即是数组的96位至127位的空间内
最后,程序将整数集合encoding属性的值从INTSET_ENC_INT16改为INTSET_ENC_INT32,并将length属性的值从3改为4。
升级的好处
提升灵活性
因为整数集合可以通过自动升级底层数组来适应新元素,所 以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合 中,而不必担心出现类型错误,这种做法非常灵活
节约内存
如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会一直是int16_t类型的数组,只有在我们要将int32_t 类型或者int64_t类型的值添加到集合时,程序才会对数组进行升级。
降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。(即使我们将集合里唯 一一个真正需要使用int64_t类型来保存的元素4294967295删除了,整数集合的编码仍然会维持INTSET_ENC_INT64,底层数组也仍然会是 int64_t类型的)
5、压缩列表
压缩列表(ziplist)是列表键
和哈希键
的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
例如,执行以下命令将创建一个压缩列表实现的列表键:
redis> RPUSH lst 1 3 5 10086 "hello" "world"
(integer)6
redis> OBJECT ENCODING lst
"ziplist"
执行以下命令将创建一个压缩列表实现的哈希键:
redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
OK
redis> OBJECT ENCODING profile
"ziplist"
压缩列表的构成
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的 连续内存块组成的顺序型数据结构。一个压缩列表可以包 含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
图7-1展示了压缩列表的各个组成部分,表7-1则记录了各个组成部 分的类型、长度以及用途。
单看概念有些蒙,下面举个例子
- 列表zlbytes属性的值为0x50(十进制80),表示压缩列表的总长为80字节。
- 列表zltail属性的值为0x3c(十进制60),这表示如果我们有一个 指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可 以计算出表尾节点entry3的地址。
- 列表zllen属性的值为0x3(十进制3),表示压缩列表包含三个节点。
压缩列表节点的构成
每个压缩列表节点可以保存一个字节数组或者一个整数值,每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成,如图7-4所示。
previous_entry_length:
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:
- 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
- 如果前一节点的长度大于等于254字节,那么previous_entry_length 属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值 254),而之后的四个字节则用于保存前一节点的长度。
举个例子:
- 表示前一节点的长度为5字节。
- 其中值的最高位字节0xFE表示这是一个五字节长的previous_entry_length属性,而之后的四字节 0x00002766(十进制值10086)才是前一节点的实际长度。
为什么要记录上一个节点的长度呢?
程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及 这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。
encoding
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度
content
节点的content属性负责保存节点的值,节点值可以是一个字节数组 或者整数,值的类型和长度由节点的encoding属性决定
举个例子:
- 编码的最高两位00表示节点保存的是一个字节数组; 编码的后六位001011记录了字节数组的长度11; content属性保存着节点的值"hello world"
- 编码11000000表示节点保存的是一个int16_t类型的整数值;content属性保存着节点的值10086。
连锁更新
previous_entry_length属性。小于254字节,用1字节长的空间来保存这个长度值,大于等于254字节,用5字节长的空间来保存这个长度值。
现在,考虑这样一种情况:在一个压缩列表中,有多个连续的、长 度介于250字节到253字节之间的节点e1至eN,如果我们将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点,如图所示
e1的previous_entry_length属性仅长1字节,它没办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长。
现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之 间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度使用1字节长的 previous_entry_length属性是没办法保存的。
因此,为了让e2的previous_entry_length属性可以记录下e1的长度, 程序需要再次对压缩列表执行空间重分配操作,并将e2节点的 previous_entry_length属性从原来的1字节长扩展为5字节长。
正如扩展e1引发了对e2的扩展一样,扩展e2也会引发对e3的扩展, 而扩展e3又会引发对e4的扩展……为了让每个节点的 previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。
Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update)
6、快速列表
上面讲了链表和压缩列表两种数据结构。他们是Redis List(列表)对象的底层实现方式。但是考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。因此Redis3.2版本开始对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist.
基本结构
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 : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
head: 指向头节点(左侧第一个节点)的指针。
tail: 指向尾节点(右侧第一个节点)的指针。
count: 所有ziplist数据项的个数总和。
len: quicklist节点的个数。
fill: 16bit,ziplist大小设置,存放list-max-ziplist-size参数的值。
compress: 16bit,节点压缩深度设置,存放list-compress-depth参数的值。
typedef struct quicklistNode {
struct quicklistNode *prev; //上一个node节点
struct quicklistNode *next; //下一个node
unsigned char *zl; //保存的数据 压缩前ziplist 压缩后压缩的数据
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;
prev: 指向链表前一个节点的指针。
next: 指向链表后一个节点的指针。
zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
sz: 表示zl指向的ziplist的总大小(包括zlbytes, zltail, zllen, zlend和各个数据项)。需要注意的是:如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
count: 表示ziplist里面包含的数据项个数。这个字段只有16bit。稍后我们会一起计算一下这16bit是否够用。
encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。
container: 是一个预留字段。本来设计是用来表明一个quicklist节点下面是直接存数据,还是使用ziplist存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫container)。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。
recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
attempted_compress: 这个值只对Redis的自动化测试程序有用。我们不用管它。
extra: 其它扩展字段。目前Redis的实现里也没用上。
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
quicklistLZF结构表示一个被压缩过的ziplist。其中:
sz: 表示压缩后的ziplist大小。
compressed: 是个柔性数组(flexible array member),存放压缩后的ziplist字节数组。
⚠️ quicklist 默认压缩是0。也就是不压缩,压缩深度可以有配置参数list-compress-depth决定。为了支持快速的push、pop操作,quicklist的首位两个ziplist 不压缩。
常用操作
插入
quicklist可以选择在头部或者尾部进行插入(quicklistPushHead和quicklistPushTail),而不管是在头部还是尾部插入数据,都包含两种情况:
如果头节点(或尾节点)上ziplist大小没有超过限制(即_quicklistNodeAllowInsert返回1),那么新数据被直接插入到ziplist中(调用ziplistPush)。
如果头节点(或尾节点)上ziplist太大了,那么新创建一个quicklistNode节点(对应地也会新创建一个ziplist),然后把这个新创建的节点插入到quicklist双向链表中。
也可以从任意指定的位置插入。quicklistInsertAfter和quicklistInsertBefore就是分别在指定位置后面和前面插入数据项。这种在任意指定位置插入数据的操作,要比在头部和尾部的进行插入要复杂一些。
当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了;
当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中;
当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入。
对于插入位置所在的ziplist大小超过了限制的其它情况(主要对应于在ziplist中间插入数据的情况),则需要把当前ziplist分裂为两个节点,然后再其中一个节点上插入数据。
查找
list的查找操作主要是对index的我们的quicklist的节点是由一个一个的ziplist构成的每个ziplist都有大小。所以我们就只需要先根据我们每个node的个数,从而找到对应的ziplist,调用ziplist的index就能成功找到。
删除
区间元素删除的函数是 quicklistDelRange
quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的 count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除完成为止。
quicklistDelRange 函数的返回值为 int 类型,当返回 1 时表示成功的删除了指定区间的元素,返回 0 时表示没有删除任何元素。
7、跳表
跳表参考博客Redis—跳跃表对源码感兴趣的同学可参考阅读。
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
以下是一个典型的跳跃表例子:
为什么使用跳跃表?
首先,因为 zset 要支持随机的插入和删除,所以它不宜使用数组来实现,关于排序问题,我们也很容易就想到 红黑树/ 平衡树这样的树形结构,为什么 Redis 不使用这样一些结构呢?
-
性能考虑: 在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部;
-
实现考虑: 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观;
我们先来看一个普通的链表结构:
我们需要这个链表按照 score 值进行排序,这也就意味着,当我们需要添加新的元素时,我们需要定位到插入点,这样才可以继续保证链表是有序的,通常我们会使用 二分查找法,但二分查找是有序数组的,链表没办法进行位置定位,我们除了遍历整个找到第一个比给定数据大的节点为止。
但假如我们每相邻两个节点之间就增加一个指针,让指针指向下一个节点,如下图:
这样所有新增的指针连成了一个新的链表,但它包含的数据却只有原来的一半。
可以想象,当链表足够长,这样的多层链表结构可以帮助我们跳过很多下层节点,从而加快查找的效率。
zskiplist结构
typedef struct zskiplist {
// 表头节点和表尾节点
structz skiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
zskiplistNode 结构
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
} zskiplistNode;
插入问题:
但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点 (也包括新插入的节点) 重新进行调整,这会让时间复杂度重新蜕化成 O(n)。删除数据也有同样的问题。
skiplist 为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是 为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是 3,那么就把它链入到第 1 层到第 3 层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个 skiplist 的过程:
从上面的创建和插入的过程中可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点并不会影响到其他节点的层数,因此,插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整,这就降低了插入操作的复杂度。
现在我们假设从我们刚才创建的这个结构中查找 23 这个不存在的数,那么查找路径会如下图
现在假设我们想要查找数据时,可以根据这条新的链表查找,如果碰到比待查找数据大的节点时,再回到原来的链表中进行查找,比如,我们想要查找 7,查找的路径则是沿着下图中标注出的红色指针所指向的方向进行的:
– 完 —