闲暇之余,通读了《Redis 设计与实现》,个人比较喜欢第一版,小记几笔,以便查阅,如果单纯为了使用,请移步:《命令查询手册》,共勉~
简单动态字符串
Redis中使用的并不是传统的C字符串,还是使用其特有的数据结构Sds(Simple Dynamic String,简单动态字符串)作为char*
的替代品,因为传统字符串类型无法高效支持一些Redis常用操作,如:
- 计算字符串长度,传统的字符串时间复杂度为O(N)
- 对字符串进行N次追加,必定需要低字符串进行N次内存重分配(realloc)
所以,Redis中的Sds做了类似于下面的定义:
typedef char * sds;
struct sdshdr {
// buf 已占用长度
int len;
// buf 剩余可用长度
int free;
// 实际保存字符串数据的地方
char buf[];
};
通过额外的字段记录,Sds的字符串长度的复杂度则变为了O(1),而buf则采用的是内存预分配的策略,比如当前分配了1KB的空间,当追加后的大小小于1KB,则不会引起内存的重新分配,若是大于1KB,则Redis会为他们额外分配1KB的空间,伪代码实现如下:
def sdsMakeRoomFor(sdshdr, required_len):
# 预分配空间足够,无须再进行空间分配
if (sdshdr.free >= required_len):
return sdshdr
# 计算新字符串的总长度
newlen = sdshdr.len + required_len
# 如果新字符串的总长度小于 SDS_MAX_PREALLOC
# 那么为字符串分配 2 倍于所需长度的空间
# 否则就分配所需长度加上 SDS_MAX_PREALLOC 数量的空间
if newlen < SDS_MAX_PREALLOC:
newlen *= 2
else:
newlen += SDS_MAX_PREALLOC
# 分配内存
newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)
# 更新 free 属性
newsh.free = newlen - sdshdr.len
# 返回
return newsh
链表
链表作为一种常用的数据结构,在很多高级编程语言中均有内置,但由于Redis所使用的C语言并没有内置这种结构,所以Redis自己构建了链表的实现,链表在Redis中的应用非常广泛,比如列表、发布订阅,慢查询等等。
链表节点定义伪代码:
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
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;
Redis列表中使用双端链表和压缩列表作为底层实现,因为双端链表占用的内存比压缩列表要多,所以当创建新的列表时,Redis会优先考虑压缩列表作为底层实现,在有需要的时候,才会从压缩列表转换到双端链表实现。该结构特性可总结如下:
- 由于listNode带有prev和next指针,所以获取某个节点的前后节点的复杂度都是O(1)。
- list保存了head和tail两个指针,所以对表头和表尾的复杂度都有O(1),所以list可以高效执行LPUSH、RPOP、RPOPLPUSH等命令。
- list使用len来对节点进行技术,所以程序获取链表中节点数量的复杂度为O(1)。
字典
字典的结构想必大家并不陌生,也是Redis中应用广泛的结构之一,使用频率和Sds及双端链表不相上下,主要的用途有两个:
- 作为数据库键空间。
- 作为Hash类型键的底层实现之一。
与双端链表一样,虽然字典作为一种常见的数据结构内置在很多高级编程语言里,但Redis里使用的C语言并没有内置这种结构,因此Redis自己构建了字典的实现,实现的方案有多种:
- 最简单就是使用链表或数组,但只适用于元素个数不多的情况下。
- 要兼顾高效和简单性,可以使用哈希表。
- 如果追求更为稳定的性能特征,并希望高效的实现排序操作,则可使用更为复杂的平衡树。
Redis选择高效和简单荐股的哈希表,作为字典的底层实现。
/*
* 字典
*
* 每个字典使用两个哈希表,用于实现渐进式 rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;
因为压缩列表比字典更节省内存,所以在创建Hash键时,默认使用压缩列表作为底层实现,当有需要是,程序才会将底层实现从列表转换到字典。值得关注的是dict类型中使用了两个指针,分别指向两个哈希表,其中,0号哈希表(ht[0])是字典主要使用的哈希表,而1号哈希表(ht[1])则只有在程序对0号号系表进行rehash时才使用。Redis目前使用的哈希算法有两种:
- MurmurHash2 32bit算法:这种算法的分步率和速度都非常好:http://code.google.com/p/smhasher/ ;
- 基于djb算法实现的一个大小写无关散列算法:http://www.cse.yorku.ca/~oz/hash.html
尽管使用了哈希算法,但不同的两个键仍然可能拥有相同的哈希值,我们称之为碰撞,所以哈希表必须想办法对碰撞进行处理,字典哈希表所使用碰撞解决方法被称之为链地址法:就是使用链表将多个哈希值相同的节点串联在一起,从而解决冲突问题,如果哈希表的大小与节点数量保持在1:1时,哈希表性能最好,但是如果节点数量远大于哈希表的大小的话,那么哈希表就会退化成多个链表,那么性能就会明显下降。所以当字典的键值对不断增多的情况下,为了保持字典的性能,就需要对哈希表(ht[0])进行rehash操作,在不修改任何键值对的情况下,对哈希表进行扩容,尽量将比率维持在1:1左右。
通过查看dictht的定义我们可以发现其定义了size(指针数组大小)和used(哈希表现有节点数量)两个属性,当他们之间的比率被定义为(ratio=used/size),当满足下列条件,rehash操作就会被激活:
- 自然rehash:ratio>=1且变量dict_can_resize==true;
- 强制rehash:ratio>dict_force_resize_ratio(在2.6版本默认为5)。
Rehash 的执行过程
- 设置字典的rehashidx为0,标识rehash开始,创建一个比ht[0]->table更大的 ht[1]-->table,大小至少为ht[0]-->used的两倍;
- 将ht[0]->table中的所有键值迁移到ht[1]-->table;
- 将原有 ht[0]的数据清空,并将ht[1]替换为新的ht[0];
也许你会有疑问,如果说在rehash的过程中,有新的值写入怎么办?如果直接阻塞,等rehash过程完成,这样是非常不友好的,所以Redis采用了渐进式(incremental)的rehash方式,主要由_dictRehashStep和dictRehashMilliseconds两个函数进行:
- _dictRehashStep用于对数据库字典以及哈希键的字典被动rehash,每次执行_dictRehashStep,哈希表ht[0]-->table第一个不为空的索引上的所有节点就会全部迁移到ht[1]-->table,每一次执行添加、查找、删除操作,_dictRehashStep都会被执行一次,因为字典会保持哈希大小和节点的ratio在一个很小的范围内,所以每个索引上的节点数量不会很多,在执行操作的同时,对单个索引上的节点进行迁移,几乎不会对响应时间造成影响;
- dictRehashMilliseconds则由Redis服务器常规任务程序(service cron job)执行,可以在指定的毫秒数内对数据库字典进行主动rehash,从而加速数据库字典的rehash过程;
当然,为了保证rehash的顺利、正确执行,还需要采取一些特别的措施:
- 在rehash未完成时,字典会同时使用两个哈希表,所以在这期间的查找、删除操作,除了在ht[0]上进行,还需要在ht[1]上进行;
- 在执行添加操作时,新的节点会直接添加到ht[1]而不是ht[0],这样保证ht[0]的节点数量在整个rehash的过程中都只减不增。
当然,如果因为大量的删除节点,导致了哈希表的可用节点数比已用节点数大很多的话,那么也可以通过rehash来收缩(shrink)字典,操作过程和上述过程类似,不过不同于扩展的是,字典的收缩是需要手动执行的,一般来说当字典的填充率小于10%,我们就可以对这个字典进行收缩操作了。
跳跃表
什么是跳跃表
首先我们先谈谈单链表,比如一个链表L:1->2->3->4->5->6->7->8->9,如果我们想查找某个数据,就只能从头到尾遍历,时间复杂度为O(n),似乎有点难以接受,本着空间换时间的准则,大佬们为链表建立了索引L1:1->3->5->7->9,这样我们要查找6时,就现在L1中查找,当发现6在5到7之间时,在下降到L中进行查找,当加了一层索引后,我们就会发现,查找一个节点需要遍历的节点个数减少了,为了进一步提高效率,我们可以再加一级索引L2:1->5->7,这样效率就又会进一步提升,当有大量数据时,我们就可以通过这种多级索引的方式,使查找效率大大提升,这种多级索引的结构就是跳跃表。跳跃表的效率和平衡树媲美,在Redis主要用于实现有序数据类型,主要由以下几个部分构成:
- 表头:负责维护跳跃表的节点指针;
- 跳跃表节点:保存着元素值,以及多个层;
- 层:保存着指向其他元素的指针。高层的指针越过的元素数量>=低层的指针,为了提高查找效率,程序总是从高层先开始访问,然后随着元素范围的缩小,慢慢降低层次;
- 表尾:全部由NULL组成,标识跳跃表的末尾。
仅仅从文字上难以形象的说明跳跃表,还是直接上图来的形象,不过本人又是个惫懒货,就直接引用了原图,各位大佬还是移步原文去看吧,比我抄的好多啦。
Redis中的跳跃表
为了满足自身需要,Redis对跳跃表进行了修改:
- 允许重复的score值:多个不同的member的score值可以相同;
- 进行对比操作时,不进要检查score值,还要检查member,因为score重复时需要查member才行;
- 每个加点都有一个高度为1的后退指针,用于从表尾方向向表头方向迭代。
//表示跳跃节点
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数(表头节点不计算在内)
int level;
} zskiplist;
//保存跳跃节点的相关信息
typedef struct zskiplistNode {
// 成员对象:在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但多个节点保存的分值可以是相同的
robj *obj;
// 分值:在跳跃表中,节点按各自所保存的分值从小到大排序
double score;
// 后退指针:它指向位于当前节点的前一个节点,用于程序从表尾向表头遍历时使用
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 这个层跨越的节点数量
unsigned int span;
} level[];
} zskiplistNode;