Redis 数据结构底层实现
String
- 如果一个字符串对象保存的是整数值,并且这个整数值可以用
long
类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr
属性里面(将void*
转换成long
),并将字符串对象的编码设置为int
- 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于
39
字节,那么字符串对象将使用一个简单动态字符串(SDS
)来保存这个字符串值,并将对象的编码设置为raw
- 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于
39
字节,那么字符串对象将使用embstr
编码的方式来保存这个字符串值
List
- 列表对象的编码可以是
ziplist
或者linkedlist
- 列表对象保存的所有字符串元素的长度都小于
64
字节并且保存的元素数量小于512
个,使用ziplist
编码;否则使用linkedlist
Hash
- 哈希对象的编码可以是
ziplist
或者hashtable
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64
字节并且保存的键值对数量小于512
个,使用ziplist
编码;否则使用hashtable
Set
- 集合对象的编码可以是
intset
或者hashtable
- 集合对象保存的所有元素都是整数值并且保存的元素数量不超过
512
个,使用intset
编码;否则使用hashtable
Sorted Set
- 有序集合的编码可以是
ziplist
或者skiplist
- 有序集合保存的元素数量小于
128
个并且保存的所有元素成员的长度都小于64
字节。使用ziplist
编码;否则使用skiplist
简单动态字符串(SDS)
Redis
自己构建了一种名为简单动态字符串(simple dynamic string,SDS
)的抽象类型, 并将SDS
用作Redis
的默认字符串表示;
key 和 value 底层都是用SDS
来实现的
SDS
的结构:
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串,最后一个字节则保存了空字符 '\0' 兼容C语言
char buf[];
};
为什么不直接使用C语言的字符串呢?SDS对比C语言的字符串有如下优势:
SDS
获取字符串长度时间复杂度O(1)
因为
SDS
通过len
字段来存储长度,使用时直接读取就可以;C语言要想获取字符串长度需要遍历整个字符串,时间复杂度O(N)
SDS
能杜绝缓冲区的溢出
因为当
SDS API
要对SDS
进行修改时,会先检查SDS
的空间是否足够,如果不够的话SDS
会自动扩容,所以不会造成缓冲区溢出。而C语言及其容易造成缓冲区溢出问题;
比如用strcat()
,在用这个函数之前必须要先给目标变量分配足够的空间,否则就会溢出
SDS
能减少修改字符串时带来的内存重分配次数
空间预分配:当
SDS
扩容时不只是会增加需要的空间大小,还会额外的分配一些未使用的空间。分配的规则是:如果分配后SDS
的长度小于1MB
,那么会分配所需双倍大小的空间,简单说就是,SDS
动态分配后是16KB
,那么就会多分配16KB
的未使用空间;
如果大于 1MB,那么就分配申请的空间大小。
惰性空间释放:用于优化
SDS
的字符串缩短操作:当SDS
的API
需要缩短SDS
保存的字符串时,并不会立即内存重分配来回收多出来的字节,而是用free
来记录未使用空间。
C字符串的每次变更(曾长或缩短)都会对数组作内存重分配。同样,如果是缩短,没有处理好多余的空间,也会造成内存泄漏
链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。链表在
Redis
中的应用非常广泛,
比如List
的底层实现之一链表,当一个List
包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis
就会使用链表作为List
的底层实现。
除了用作List
的底层实现之外,发布与订阅、慢查询、监视器等动能也用到了链表,Redis
服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区
结构:
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode
多个 listNode
可以通过 prev
和 next
指针组成双端链表, 如下图所示:
list结构:
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;
由一个 list
结构和三个 listNode
结构组成的链表:
list
特性总结:
- 双端
链表节点带有
prev
和next
指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
- 无环
表头节点的
prev
指针和表尾节点的next
指针都指向NULL
,对链表的访问以NULL
为终点
- 带表头指针和表尾指针
通过
list
结构的head
指针和tail
指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
- 带链表长度计数器
程序使用
list
结构的len
属性来对list
持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)
- 多态
链表节点使用
void*
指针来保存节点值,并且可以通过list
结构的dup
、free
、match
三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
字典
Redis
数据库底层就是用字典实现的,对数据库的增、删、改、查操作都是构建在对字典的操作之上;字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis
就会使用字典作为哈希键的底层实现
哈希表
结构:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
一个大小为 4 的空哈希表:
哈希表节点
结构:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表,用于解决hash冲突(链地址法)
struct dictEntry *next; // 单链表结构
} dictEntry;
举个例子, 下图就展示了如何通过 next
指针, 将两个索引值相同的键 k1 和 k0 连接在一起:
字典
结构:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
type
属性和 privdata
属性是针对不同类型的键值对,为创建多态字典而设置的:
type
属性是一个指向 dictType
结构的指针,每个 dictType
结构保存了一簇用于操作特定类型键值对的函数,Redis
会为用途不同的字典设置不同的类型特定函数; 而 privdata
属性则保存了需要传给那些类型特定函数的可选参数
ht
属性是一个包含两个项的数组,数组中的每个项都是一个 dictht
哈希表,一般情况下,字典只使用 ht[0]
哈希表, ht[1]
哈希表只会在对 ht[0]
哈希表进行 rehash
时使用
除了 ht[1]
之外,另一个和 rehash
有关的属性就是 rehashidx
,它记录了 rehash
目前的进度, 如果目前没有在进行 rehash
, 那么它的值为 -1
下面展示了一个普通状态下(没有进行 rehash
)的字典:
哈希算法
当将一个新的键值对插入到字典中,需要计算索引值,Redis
计算索引值的方法是:
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
rehash
随着不断的操作,
hash
表中的键值对可能会增多或减少,为了让哈希表的负载因子保持在一个范围内,需要对hash
表进行扩容或收缩,收缩和扩容的过程就叫rehash
rehash
过程:
为字典的
ht[1]
哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]
当前包含的键值对数量(也即是ht[0].used
属性的值):- 如果执行的是扩展操作,那么
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
做准备
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行
BGSAVE
命令或者BGREWRITEAOF
命令, 并且哈希表的负载因子大于等于 1 - 服务器目前正在执行
BGSAVE
命令或者BGREWRITEAOF
命令, 并且哈希表的负载因子大于等于 5
哈希表的负载因子可以通过公式:
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
另一方面,当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作
- 渐进式
rehash
rehash
时会将 ht[0]
所有的键值对迁移到 ht[1]
中,但这个动作不是一次性的,而是分多次、渐进式地完成。
这样的原因是:当数据量大的时候一次性迁移会造成服务器在一段时间内停止服务(类似一些语言执行GC时的STW)。为了避免发生这样的事就出现了 渐进式rehash
详细步骤:
- 为
ht[1]
分配空间,让字典同时持有ht[0]
和ht[1]
两个哈希表 - 在字典中维持一个索引计数器变量
rehashidx
,并将它的值设置为 0 ,表示rehash
工作正式开始 - 在
rehash
进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]
哈希表在rehashidx
索引上的所有键值对rehash
到ht[1]
,当rehash
工作完成之后,程序将rehashidx
属性的值增一 - 随着字典操作的不断执行,最终在某个时间点上,
ht[0]
的所有键值对都会被rehash
至ht[1]
,这时程序将rehashidx
属性的值设为 -1 ,表示rehash
操作已完成
在 rehash
期间,字典的删改查操作都是同时作用在 ht[0]
以及 ht[1]
上的。 如查找一个键,会现在 ht[0]
查找,找不到就去 ht[1]
查找,注意的是增加操作,新增的键值对只会保存到 ht[1]
上,不会保存到 ht[0]
上,这一措施保证了 ht[0]
的键值只减不增,随着 rehash
操作 ht[0]
最终会变成空表
跳跃表
跳跃表(
skiplist
)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。 跳跃表支持平均O(log N)
最坏O(N)
复杂度的节点查找,还可以通过顺序性操作来批量处理节点
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树
skiplist
与AVL
对比:
- 从算法实现难度上来比较,
skiplist
比平衡树要简单得多。 - 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而
skiplist
的插入和删除只需要修改相邻节点的指针,操作简单又快速。 - 查找单个key,
skiplist
和平衡树的时间复杂度都为O(log n)
,大体相当。 - 在做范围查找的时候,平衡树比
skiplist
操作要复杂。 skiplist
和各种平衡树(如AVL、红黑树等)的元素是有序排列的
Redis
使用跳跃表作为有序集合键的底层实现之一: 如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member
)是比较长的字符串时,Redis
就会使用跳跃表来作为有序集合键的底层实现
跳跃表节点结构:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
跳跃表结构:
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量,也即是跳跃表目前包含节点的数量(表头节点不计算在内)
unsigned long length;
// 表中层数最大的节点的层数,层数最大的那个节点的层数(表头节点的层数不计算在内)
int level;
} zskiplist;
示例:
位于 zskiplist
结构右方的是四个 zskiplistNode
结构,该结构包含以下属性:
层(level
)
节点中用 L1 、L2 、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。
前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行
跳跃表节点的 level
数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快
每次创建一个新跳跃表节点的时候,程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level
数组的大小,这个大小就是层的“高度”
跨度
层的跨度(level[i].span
属性)用于记录两个节点之间的距离,两个节点之间的跨度越大,它们相距得就越远,指向 NULL
的所有前进指针的跨度都为 0 ,因为它们没有连向任何节点
初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank
)的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位
后退(backward
)指针
节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用 节点的后退指针(
backward
属性)用于从表尾向表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点
分值(score
)
各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列
节点的分值(score
属性)是一个 double
类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
成员对象(obj
)
各个节点中的 o1 、o2 和 o3 是节点所保存的成员对象
节点的成员对象(obj
属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS
值
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的
分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)
整数集合
整数集合(
intset
)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis
就会使用整数集合作为集合键的底层实现
它可以保存类型为int16_t
、int32_t
或者int64_t
的整数值,并且保证集合中不会出现重复元素
结构:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
虽然 intset
结构将 contents
属性声明为 int8_t
类型的数组,但实际上 contents
数组并不保存任何 int8_t
类型的值,contents
数组的真正类型取决于 encoding
属性的值:如果 encoding
属性的值为 INTSET_ENC_INT16
,那么 contents
就是一个 int16_t
类型的数组,数组里的每个项都是一个 int16_t
类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade
),然后才能将新元素添加到整数集合里面
升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
- 将新元素添加到底层数组里面
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态
压缩列表
当一个列表键、哈希键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 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
为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型(sequential
)数据结构
一个压缩列表可以包含任意多个节点(entry
),每个节点可以保存一个字节数组或者一个整数值
ziplist
没有明确定义结构体,这里只作大概的演示:
typedef struct entry {
/*前一个元素长度需要空间和前一个元素长度*/
unsigned int prevlengh;
/*元素内容编码*/
unsigned char encoding;
/*元素实际内容*/
unsigned char *data;
}zlentry;
entry结构体里面有三个重要的字段:
previous_entry_length
这个字段记录了ziplist
中前一个节点的长度,什么意思?就是说通过该属性可以进行指针运算达到表尾向表头遍历,这个字段还有一个大问题下面会讲。encoding
记录了数据类型(int16? string?)和长度。data/content
记录数据
上面有说到,previous_entry_length
这个字段存放上个节点的长度,那默认长度给分配多少呢?Redis
是这样分的,如果前节点长度小于254,就分配1字节,大于的话分配5字节,那问题就来了;
如果前一个节点的长度刚开始小于254字节,后来大于254,那不就存放不下了吗? 这就涉及到previous_entry_length
的更新,但是改一个肯定不行阿,后面的节点内存信息都需要改。所以就需要重新分配内存,然后连锁更新包括该受影响节点后面的所有节点; 除了增加新节点会引发连锁更新、删除节点也会触发
typedef struct ziplist{
/*ziplist分配的内存大小*/
uint32_t zlbytes;
/*达到尾部的偏移量*/
uint32_t zltail;
/*存储元素实体个数*/
uint16_t zllen;
/*存储内容实体元素*/
unsigned char* entry[];
/*尾部标识*/
unsigned char zlend;
}ziplist;
压缩列表的各个组成部分:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配,或者计算 zlend 的位置时使用 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量,当这个属性的值小于 UINT16_MAX (65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于 UINT16_MAX 时,节点的真实数量需要遍历整个压缩列表才能计算得出 |
entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端 |
quicklist
一个由
ziplist
组成的双向链表。但是一个quicklist
可以有多个quicklist
节点,它很像B树的存储方式。是在redis3.2
版本中新加的数据结构,用在列表的底层实现
表头结构:
typedef struct quicklist {
//指向头部(最左边)quicklist节点的指针
quicklistNode *head;
//指向尾部(最右边)quicklist节点的指针
quicklistNode *tail;
//ziplist中的entry节点计数器
unsigned long count; /* total count of all entries in all ziplists */
//quicklist的quicklistNode节点计数器
unsigned int len; /* number of quicklistNodes */
//保存ziplist的大小,配置文件设定,占16bits
int fill : 16; /* fill factor for individual nodes */
//保存压缩程度值,配置文件设定,占16bits,0表示不压缩
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
节点结构:
typedef struct quicklistNode {
struct quicklistNode *prev; //前驱节点指针
struct quicklistNode *next; //后继节点指针
//不设置压缩数据参数recompress时指向一个ziplist结构
//设置压缩数据参数recompress指向quicklistLZF结构
unsigned char *zl;
//压缩列表ziplist的总长度
unsigned int sz; /* ziplist size in bytes */
//ziplist中包的节点数,占16 bits长度
unsigned int count : 16; /* count of items in ziplist */
//表示是否采用了LZF压缩算法压缩quicklist节点,1表示压缩过,2表示没压缩,占2 bits长度
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
//表示一个quicklistNode节点是否采用ziplist结构保存数据,2表示压缩了,1表示没压缩,默认是2,占2bits长度
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
//标记quicklist节点的ziplist之前是否被解压缩过,占1bit长度
//如果recompress为1,则等待被再次压缩
unsigned int recompress : 1; /* was this node previous compressed? */
//测试时使用
unsigned int attempted_compress : 1; /* node can't compress; too small */
//额外扩展位,占10bits长度
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
Redis相关配置
list-max-ziplist-size -2
list-compress-depth 0
list-max-ziplist-size
参数
当取正值的时候,表示按照数据项个数来限定每个quicklist
节点上的ziplist
长度。比如,当这个参数配置成5的时候,表示每个quicklist
节点的ziplist
最多包含5个数据项。
当取负值的时候,表示按照占用字节数来限定每个quicklist
节点上的ziplist
长度。这时,它只能取-1
到-5
这五个值,每个值含义如下:
-5
: 每个quicklis
t节点上的ziplist
大小不能超过64 Kb。(注:1kb => 1024 bytes)
-4
: 每个quicklist
节点上的ziplist
大小不能超过32 Kb。
-3
: 每个quicklist
节点上的ziplist
大小不能超过16 Kb。
-2
: 每个quicklist
节点上的ziplist
大小不能超过8 Kb。(默认值)
list-compress-depth
参数
这个参数表示一个quicklist
两端不被压缩的节点个数。 注:这里的节点个数是指quicklist
双向链表的节点个数,而不是指ziplist
里面的数据项个数。实际上,一个quicklist
节点上的ziplist
,如果被压缩,就是整体被压缩的
取值含义如下:0
: 是个特殊值,表示都不压缩。这是Redis
的默认值。1
: 表示quicklist
两端各有1个节点不压缩,中间的节点压缩。2
: 表示quicklist
两端各有2个节点不压缩,中间的节点压缩。3
: 表示quicklist
两端各有3个节点不压缩,中间的节点压缩。 依此类推
Redis
对于quicklist
内部节点的压缩算法,采用的LZF
(一种无损压缩算法)