第一章 数据结构
1. 简单动态字符串
下图是简单动态字符串(simple dynamic string, SDS)的结构表示

- free属性的值为0,表示这个SDS没有分配任何未使用空间
- len属性的值为5,表示这个SDS保存了一个5字节长的字符串
- buf属性是一个char类型的数组,数组的前五个字节分别保存了R, e, d, i, s五个字符,最后一个字节则保存了空字符'\o'
SDS遵循C语言字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,这个空字符对SDS的使用者来说是完全透明的。遵循空字符结尾的好处是,可以重用一些C字符串函数库中的函数。例如,有一个指向图2-1所示SDS的指针s,那么可以直接使用<stdio.h>/printf函数,通过执行以下语句:printf("%s", s->buf);
1.1 SDS与C字符串的区别
1.1.1 常数复杂度获得字符串长度
因为C字符串并不记录自身长度信息,所以为了获取一个C字符串长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N),如下图所示。

通过访问SDS的len属性,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1)。
1.1.2 杜绝缓冲区溢出
<string.h>/strcat函数可以将src字符串中的内容拼接到dest字符串的末尾: char *strcat (char *dest, const char *src)
strcat函数假定用户在执行这个函数时,已经为dest分配了足够多的内存,如果假设不成立,则会造成缓冲区溢出,导致s2保存的内容被意外修改了。


当SDS API需要对SDS进行修改时,API先检查SDS的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后再进行修改。用户不需要手动修改SDS空间的大小。
1.1.3 减少修改字符串时带来的内存重分配次数
对于一个包含N个字符的C字符串,底层实现是一个N+1个字符长的数组。每次缩短或者增加C字符串,都需要对数组做一次内存重分配:
- 如果程序执行的是增长字符串的操作,比如拼接(append),那么执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——不然会造成缓冲区溢出
- 对于缩短字符串操作,比如截断(trim)操作,需要释放不再需要的内存空间,不然会造成内存泄漏
由于内存重分配涉及复杂的算法,比较耗时。SDS通过空间预分配和惰性空间释放两种优化策略减少内存重分配次数
1. 空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并需要对SDS进行空间扩展,程序不仅会分配必须的空间,还会为SDS分配额外的未使用空间。如果SDS长度小于1MB,程序分配和len属性同样大小的未使用空间;如果长度大于1MB,程序分配1MB的未使用空间。

2. 惰性空间释放
当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,以备将来使用。
1.1.4 二进制安全
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾。这些限制使得C字符串只能保存文本数据,而不能保存图片、音频、视频、压缩文件这样的二进制数据。
SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤或者假设,数据写入是什么样子,读取时就是什么样子。
1.1.5 兼容部分C字符串函数
通过在buf数组分配空间时多分配一个字节来容纳空字符,使得SDS可以重用一部分<string.h>库定义的函数。表2-2列出了SDS主要操作API。

2. 链表
每个链表节点使用一个adlist.h/listNode结构来表示:
多个listNode可以通过prev和next指针组成双端链表:

list结构表示:

Redis链表特性:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
- 带链表长度计数器:程序获取链表中节点数量的复杂度为O(1)
- 多态:节点使用void*指针保存节点值,所以链表可以保存各种不同类型的值
3. 字典
3.1 字典结构
Redis字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
哈希表结构及哈希表节点定义:
数据存放如下所示:
Redis中字典由dict.h/dict结构表示:

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:
- type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
- privdata属性则保存了需要传给那些类型特定函数的可选参数

ht[1]哈希表会在对ht[0]哈希表进行rehash时使用。
3.2 哈希算法
Redis计算哈希值和索引值的方法如下:
hash = dict->type->hashFunction(key);
index = hash & dict->ht[x].sizemask;
index表示放在dictEntry中的哪个槽内
3.3 解决键冲突
当两个或以上的键被分配到了哈希表数组的同一个索引上面时,称为发生了哈希碰撞。Redis的哈希表通过使用链地址法来解决键冲突,被分配到同一个索引上的多个节点可以用这个单向链表连接起来。
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将节点添加到链表的表头位置(复杂度为O(1)),排在其他所有节点前面。
3.4 rehash
哈希表的扩展和收缩
满足下面任一条件,哈希表会进行扩展操作:
- 服务器没有执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
- 服务器正在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
负载因子的计算:哈希表已保存节点数量/哈希表大小
当负载因子小于0.1时,程序自动对哈希表进行收缩操作。
Redis对字典的哈希表执行rehash的步骤如下:
1) 为字典ht[1]哈希表分配空间:
- 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2n
- 如果执行的是收缩操作,那么ht[1]的大小也为第一个大于等于ht[0].used*2n
2) 将保存在ht[0]中的所有键值对rehash到ht[1]上面
3) 当ht[0]包含的所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
示例步骤如下所示:

3.5 渐进式rehash
扩展或收缩哈希表需要将ht[0]里面的所有键值对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操作结束。

字典主要API操作
4. 跳跃表
如果一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。例如,fruit-price是一个有序集合键,以水果名为成员,水果价钱为分值。fruit-price有序集合的所有数据都保存在一个跳跃表里面,其中每个跳跃表节点会保存一款水果的信息,所有水果按价钱由低到高排序。
4.1 跳跃表的实现
Redis跳跃表由redis.h/zskiplistNode(用于表示跳跃表节点)和redis.h/zskiplist(保存跳跃表节点相关信息)两个结构定义,示例如下:

4.2 跳跃表节点
跳跃表节点的实现由redis.h/zskiplistNode结构定义:

1. 层
每次创建一个新的跳跃表节点时,程序都根据幂次定律(power low,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,即层的高度,如图5-2所示。
跳跃表节点中每个元素包含一个指向其他节点的指针,程序可以通过这些层加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
2. 前进指针
每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。图5-3用虚线表示了程序从表头向表尾方向,遍历跳跃表中所有节点的路径。
3. 跨度
层的跨度(level[i].span)用于记录两个节点之间的距离:
- 两个节点之间的跨度越大,相距的越远
- 指向NULL的所有前进指针的跨度都为0
遍历操作使用前进指针即可完成,跨度是用来计算排位的(节点在跳跃表中的位置)。
4. 后退指针
节点的后退指针(backward属性)用于从表尾向表头访问节点。
5. 分值和成员
节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(obj属性,唯一的)是一个指针,它指向一个字符串对象,而字符串对象则保存一个SDS值。
4.3 跳跃表
zskiplist结构的定义如下:


header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1).
length属性记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。
level属性用于获得跳跃表中层高最大的节点的层数量,表头节点的层高不计算在内。
4.4 跳跃表常用API
5. 整数集合
5.1 整数集合的实现
其中encoding有三种取值:
- INTSET_ENC_INT16 每个项是int16_t类型的整数值(取值范围:-215~215-1)
- INTSET_ENC_INT32 每个项是int32_t类型的整数值(取值范围:-231~231-1)
- INTSET_ENC_INT64 每个项是int64_t类型的整数值(取值范围:-263~263-1)


5.2 升级
如果新添加的元素类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后再将新元素添加到整数集合里面。升级分三步进行:
1) 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
2) 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位置上,而且在放置元素过程中,需要维持底层数组的有序性质不变
3) 将元素添加到底层数组里面
contents数组中原先包含3个,每个占用16位空间的元素。之后需要插入一个占用32位空间的元素65535,其过程如下所示:


5.3 升级的好处
5.3.1 提升灵活性
整数集合可以通过自动升级底层数组来适应新元素,可以随意地将int16_t,int32_t或int64_t类型的整数添加到集合中,不必担心出现类型错误。
5.3.2 节约内存
尽量先用int16_t类型来保存元素,在必要时进行升级,以节约内存。
5.4 降级
整数集合不支持降级。
5.5 整数集合API
6. 压缩列表
6.1 压缩列表的构成
压缩列表是Redis为了节约内存而开发的,由一系列特殊编码地连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
下图为压缩列表各组成部分:


图7-2展示了一个压缩列表实例:
- zlbytes属性地值位0x50(十进制80),表示压缩列表总长80字节
- zltail属性值为0x3c(十进制60),这表示如果我们有一个指向压缩列表起始地址地指针p,那么p+60,就可以计算出表尾节点entry3的地址
- zllen属性值为0x3,表示压缩列表包含三个节点
6.2 压缩列表节点的构成
下图为压缩列表各个组成部分:

1. previous_entry_length
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度:
- 如果前一个节点长度小于254字节,previous_entry_length属性的长度为1字节
- 如果前一个字节长度大于等于254字节,previous_entry_length的长度为5字节,第一个字节会被设置为0xFE(十进制254),之后的四个字节用于保存前一节点的长度

如果有一个指向当前节点起始地址的指针c,可以根据下图计算出前一个节点的指针p。压缩列表从表尾向表头遍历操作就是基于这一原理实现的。
2. encoding
节点encoding属性记录了节点的content属性所保存数据的类型及长度。
值的最高位为00、01、10是字节数组编码,数组的长度由编码除去最高两位之后的其他位记录

值的最高位以11开头的是整数编码
3. content
6.3 连锁更新
下面讨论一种对列表插入或删除节点时,会发生的极端情况——连锁更新。
前面小节提到过,如果前一个节点长度小于254字节,那么previous_entry_length属性需要用1字节来保存长度值;如果前一个节点长度大于254字节,那么previous_entry_length属性需要用5字节来保存长度值。
现在,考虑这样一种情况,在一个压缩列表中,存在多个连续的、长度介于250字节到253字节之间的节点e1至eN,如下图所示:

如果将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,如下图所示:

此时e1节点的previous_entry_length属性从原来的1字节扩展为5字节,e1原本的长度介于250字节到253字节之间,由于previous_entry_length长度改变,e1的长度变为介于254字节到257字节,从而引发e2的更新,e2又会引发e3的扩展,直到eN为止,并将此过程称为连锁更新。下图是另一种引起连锁更新的情况:

因为连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度是O(N),所以连锁更新的最坏复杂度为O(N2) 。
但是,尽管连锁更新的复杂度较高,但是真正造成性能问题的几率很低:
- 压缩列表里面需要恰好有多个连续的、长度介于250字节到253字节之间的节点
- 即使出现连锁更新,只要被更新的节点不多,就不会对性能造成太大影响
压缩列表API


浙公网安备 33010602011771号