本文根据《redis设计与实现》,浅谈redis的数据结构实现原理。
引言
redis在业界受到越来越多的青睐,以其优秀的性能广受欢迎,抽空看了下《redis的设计与实现》,记录下读书笔记,根据书中的篇幅,一共分为下面几个部分,数据结构与对象,单机数据库的实现,多机数据库的实现。本文是数据结构与对象篇。
目前最新的redis的代码可能和书中有所出入。
字符串
结构
- redis设计与实现的sds版本
1
2
3
4
5
6
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
graph LR subgraph SDS len(len int 长度) free(free int 未使用的字节数) buf(buf char 字符串) end buf --> str('a''b''\0')
自动扩容&空间预分配
- 例子,当要保存一个10字节的字符串
graph LR subgraph SDS len(len int 长度) free(free int 未使用的字节数) buf(buf char 字符串) end len --> len10(10) free --> free10(10) buf --> str('0''1'...'9''\0')
当修改需要扩容的时候,如果小于1MB
,会预分配同等大小的free
空间,最后分配的是2X+1
,其中多1字节用于保存结尾的'\0'
惰性删除
删除操作不会真的释放空间,而是改变free
和len
的大小。下次需要使用的时候,空间足够不用再次分配内存。
杜绝缓冲区溢出
c语言的字符串,需要调用方来保证分配足够的内存空间,防止出现溢出,但这其实是很危险的,由于sds保存了字符串的长度信息,可以高效的进行检查,扩容,保证不出现溢出。
二进制安全
对于c语言的字符串,'\0'
是末未结束符,意味着,字符串内不能出现结束符,否则字符串将被截断,所以针对二进制数据,c语言的字符串无法保存。sds则不同。由于sds不再使用'\0'
来判断字符串结束,并且所有api都是二进制安全的,因此可以直接保存二进制数据。
部分兼容c字符串
由于sds的末尾还是以'\0'
结束,因此,当保存文本的数据的时候,sds可以兼容c语言的字符串操作函数。
链表
结构
1 | typedef struct listNode { |
graph LR subgraph List head(head listNode* 头) tail(tail listNode* 尾) len(len unsigned long 节点个数) dup(dup 复制节点的接口) free(free 释放节点的接口) match(match 判断节点相等的接口) end head --> node1 node1 --prev--> NULL1(NULL) node1 --next--> node2 node2 --prev--> node1 node2 --next--> node3 node3 --prev--> node2 node3 --next--> NULL tail --> node3 len --> 3
双向链表
redis的链表实现是双向链表,头结点前置和尾节点的后置都是NULL
接口实现
为了让链表更加通用,提供了三个接口函数以便使用者实现,分别是复制,释放,比较相等的操作,外部使用者可以根据存储的不同的对象,进行不同的实现,一种多态的编程方式。
字典
1 | typedef struct dictEntry { |
hash表
- 这是一个没有
rehash
的字典
graph LR subgraph 字典 type privdate ht rehashindex end subgraph hash表 table(table-hash表数组) size(size-hash表大小) sizemask used(used-已有节点数) end subgraph DictEntry*数组-桶 0 1 2 3 end subgraph 键值对 k1 v1 end subgraph DictEntry1 k2 v2 end subgraph hash表1 table1 size1 sizemask1 used1 end ht --> ht0 ht --> ht1 ht0 --> table ht1 --> table1 table --> 0 2 --> NULL(NULL) 1 --> NULL1(NULL) 3 --> NULL2(NULL) 0 --> k1 k1 --next--> k2 k2 --next--> NULL3(NULL) size --> 4 sizemask --> 3num(3) used --> 2num(2) table1 --> NULL4(NULL) size1 --> 0num(0) sizemask1 --> 0num1(0) used1 --> 1num2(0) rehashindex --> -1
接口
1 | typedef struct dictType { |
hash算法
提到hash表,不可避免的要提到hash算法,redis的字典的hash算法是通过上面的定义的接口,让使用者传入具体的实现,也是一种多态的方式,比如计算hash值的时候,会根据上面的hashFunction
的具体实现,调用不同的计算方式。
MurmurHash
默认采用的murmurhash2算法
解决冲突
从上面的图可以看出,redis采用的是拉链式解决冲突,在键值对后面通过next指针进行索引。
rehash
当hash表负载过大或者过低时,会触发rehash的动作。试图增大或者缩小hash表的大小。
负载因子
load_factor=used/size
- 负载因子越大,说明使用的越多,需要扩容。
- 负载因子越小,说明空闲的越多,需要缩容。
扩缩容
- 若负载因子小于0.1,则进行缩容渐进式rehash
rehash
不是一次性完成的,因为,redis中的hash表可能非常大,如果一次性rehash
,会严重阻塞服务器,所以redis的做法是分批次进行,也叫渐进式rehash。这就是为什么ht
会有两个大小的原因。
- rehashindex=0表示rehash开始,每次加1
- 不是集中式的rehash,而是分布在每次查找,删除,更新操作中
- 读取会同时读取ht[0]和ht[1]
- 写入,不会再写入ht[0]
跳跃表
graph LR subgraph 跳表 header(header) tail(tail) level(level) length1(length) end subgraph Node L132(L32) L_(...) L5(L5) L4(L4) L3(L3) L2(L2) L1(L1) end subgraph Node1 L14(L4) L13(L3) L12(L2) L11(L1) Score1(Score=1) Obj1(obj) end subgraph Node2 L22(L2) L21(L1) Score2(Scor=2) Obj2(obj) end subgraph Node3 L35(L5) L34(L4) L33(L3) L32(L2) L31(L1) Score3(Scor=3) Obj3(obj) end L5 --3--> L35 L4 --1--> L14 L14 --2--> L34 L3 --1--> L13 L13 --2--> L33 L2 --1--> L12 L12 --1--> L22 L22 --1--> L32 L1 --1--> L11 L11 --1--> L21 L21 --1--> L31 L31 --0--> NULL1(NULL) L32 --0--> NULL2(NULL) L33 --0--> NULL3(NULL) L34 --0--> NULL4(NULL) L35 --0--> NULL5(NULL) L132 --> NULL6(NULL) header --> L1 tail --> Obj3 level --> 5 length1 --> 3
- 上面是跳表的结构,其实如果把跳表退化到只有一层,跨度全部是1,就可以看成是链表,同样,跳表可以看成是对一个节点有多层的描述的数据结构,每一层是链表。
- 跳表是在分数的维度组织的,obj是关联的对象。分数相同,但是obj不同,属于不同的节点。
- 遍历的时候,只需要找level中跨度最小的的层,依次往后走就能遍历整个跳表。
- 跨度值可以帮助快速定位范围的节点。
整数集合
graph LR subgraph 整数集合 encoding(encdoding) length1(length) contents(contents) end subgraph contents 1 2 3 end contents --> 1
- 有序
- 整数集合可以根据元素范围,选择合适的编码格式,针对小元素集合,可以选择更少的bit来保存,节约内存
- 自动进行编码升级,当集合无法表示一个大的数字,就会触发升级,使用更多的bit来保存,所以添加的复杂度是O(N)
- 不支持降级