redis是目前常用的由C语言实现的kv集群框架,本文将详细介绍redis底层6种数据结构,并介绍5种对象的实现方案。
1.自定义字符串SDS
struct sdshdr { //记录buf中已使用字节数量 //等于SDS所保存字符串长度 int len; //记录buf中未使用的字节数 int free; //字节数组,用于保存字符串 char buf[];
}
如上所示,可以看出SDS也是以‘\0‘作为字符串结尾,而且没有将空字符计入buf长度,完全对用户透明。
SDS相对于C字符串的优势也很明显:
- O(1)复杂度获取字符串长度,而C字符串的复杂度为O(n)
- 通过free字段实现空间自动拓展,杜绝缓冲区溢出,不会因为C字符串在拼接前因未进行空间校验而导致内存溢出
- 通过未使用空间实现了空间预分配和空间惰性释放两周优化策略,减少修改字符串时带来的内存重新分配次数
- 空间预分配策略优化字符串增长操作,默认策略为当len小于1M时,分配free、len同样大小内存;当len大于1M时,free分配1M内存
- 空间惰性释放策略优化字符串缩短操作,当缩短字符串长度时,并不立即释放空间,而是对free做增加的相应,以便再增长时不必申请空间
- 二进制安全,redis在底层存储的不是字符,而是对应的二进制文件,这样就可以不考虑特殊字符,实现任意存储(视频、音频、图片等)
2.链表
链表提供了高效的节点重排能力及顺序节点访问方式,并且可以通过增删节点来灵活的廖正链表长度。
typedef strcut listNode{ //前置节点 strcut listNode *pre; //后置节点 strcut listNode *pre; //节点的值 void *value; }listNode typedef struct list{
//表头结点 listNode *head; //表尾节点 listNode *tail; //链表长度 unsigned long len; //节点值复制函数 void *(*dup) (viod *ptr); //节点值释放函数 void (*free) (viod *ptr); //节点值对比函数 int (*match) (void *ptr,void *key);
}list
由上结构可知redis实现的链表具有如下特性:
- 双端:获取某个节点的前置节点和后置节点复杂度都是O(1)
- 无环:表头的前置节点和表尾的后置节点都指向NULL
- 表头&表尾指针:获取表头、表尾节点的复杂度为O(1)
- 链表长度计数器:使用list结构的len属性对链表进行计数,获取时间复杂度为O(1)
- 多态:表节点使用void*指针来存储节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以存储各种不同类型的值
3.字典
字典是一种用于保存键值对(key-value pair)的抽象数据结构,字典中的键(key)必须是唯一的,但是每个键可以与一个或多个值(value)关联。
redis的字段使用哈希表作为底层实现,一个哈希表里面有多个哈希表节点,每个哈希表节点保存字典中的一个键值对。
3.1 字典的实现
3.1.1 哈希表
1 typedef struct dictht{ 2 3 //哈希表数组 4 dictEntry **table; 5 6 //哈希表大小 7 unsigned long size; 8 9 //哈希表大小掩码,用于计算索引值 10 //总是等于size-1 11 unsigned long sizemark; 12 13 //哈希表已有节点数量 14 unsigned long used; 15 16 }dictht
其中,table是一个数组,每个元素指向哈希表节点;size记录哈希表大小,及table数组大小;used表示哈希节点已有节点的数量;sizemark总是size-1,这个属性和哈希值一起决定一个键放到table数组的那个索引。
3.1.2 哈希表节点
typedef struct dictEntry { //键 void *key; //值 union { void *value; uint64_tu64; int64_ts64; }v; //指向下个哈希节点,组成链表 struct dictEntry *next; }dictEntry;
其中,key属性保存键值对的键,v属性则保存着键值对中的值,其中键值对的值可以是指针、uint64_t整数、int64_t整数;next属性是指向另一个哈希表节点的指针,用来解决冲突问题(图上的k0、k1)
3.1.3 字典
typedef struct dict { //类型特定函数 dictType *type; //私有数据 void *privdata; //哈希表 dictht ht[2]; //rehash索引 //当rehash不进行时,值为-1 int rehashidx; }dict; typedef struct dictType{ //计算哈希值的函数 unsigned int (*hashFunction)(const void * key); //复制键的函数 void *(*keyDup)(void *private, const void *key); //复制值得函数 void *(*valDup)(void *private, const void *obj); //对比键的函数 int (*keyCompare)(void *privdata , const void *key1, const void *key2) //销毁键的函数 void (*keyDestructor)(void *private, void *key); //销毁值的函数 void (*valDestructor)(void *private, void *obj); }dictType
上图为普通状态(未rehash的字典)其中,type是一个指向dictType的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,redis为不同用途的字典设置不同的类型特定函数;privdata保存了需要传给那些类型特定函数的可选参数;ht包含了两个项的数组,其中每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0],ht[1]只在ht[0]进行rehash时使用;rehashidx则记录现在rehash的进度,如未进行则为-1。
3.2 哈希算法
如果要将一个新的键值对添加到字典里,首先需要对兼职对的键计算出哈希值(哈希算法是MurmurHash2)和索引值,然后再根据索引值将键值对的哈希表节点放到哈希表数组指定的索引上,具体流程如下:
3.3 解决键冲突
当有两个或以上的键被分配到了哈希表上的同一个索引值时,就是所谓的发生冲突。redis采用链地址法解决冲突每个哈希节点都会有一个next指针,多个哈希表节点可以使用next指针组成一个单链表。由于节点没有指向链表表尾的指针,处于效率考虑,新增加的节点总是放在表头位置,具体如下:
3.4 rehash
随着操作的不断增多,哈希表保存的键值对会增多或者减少,为了让负载因子(load_factor = ht[0].used / ht[0].size)维持在一个合理的水平, redis会通过执行rehash对哈希表进行拓展或者收缩,具体步骤如下:
- 为字典的ht[1]哈希表分配空间,当执行拓展操作时,空间大小为第一个大于等于ht[0].used*2的2n(2的n次幂);当执行收缩操作时,空间大小为第一个大于等于ht[0].used的2n。
- 将保存在ht[0]上的键值对rehash到ht[1]上,rehash是指重新计算哈希值和索引值,然后再将键值对移到ht[1]。
- 迁移完成后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新建一个空白哈希表,为下次rehash做准备。
3.5 渐进式rehash
当字典中的量级较大时,如果一次性将全部键值对进行rehash会导致服务器停服一段时间,因此,redis采用分多次、渐进式的方式将ht[0]的键值对慢慢rehash到ht[1]。具体步骤如下:
- 为ht[1]分配空间,字典中同时存在ht[0]和ht[1]
- 在字典中维持一个索引计数器变量rehashidx,并将设置为0,表示rehash开始
- 在rehash期间每次对字典进行增加、查询、删除和更新操作时,除了执行指定命令外;还会将ht[0]中rehashidx索引上的值rehash到ht[1],操作完成后rehashidx+1
- 字典操作不断执行,最终在某个时间点,所有的键值对完成rehash,这时将rehashidx设置为-1,表示rehash完成
在渐进式rehash过程中,字典会同时使用两个哈希表ht[0]和ht[1],所有的更新、删除、查找操作也会在两个哈希表进行。例如要查找一个键的话,服务器会优先查找ht[0],如果不存在,再查找ht[1],诸如此类。此外当执行新增操作时,新的键值对一律保存到ht[1],不再对ht[0]进行任何操作,以保证ht[0]的键值对数量只减不增,直至变为空表。