前言
Redis系列博客为对黄健宏老师《Redis设计与实现》一书内容的整理
引言
Redis优势——它内置了集合数据类型,并支持对集合执行交集、并集、差集等集合计算操作,之前需要使用一段甚至一大段SQL查询才能实现的功能,现在只需要调用一两个Redis命令就能够实现了,整个模块的可读性得到了极大的提高。
通过了解Redis的内部实现,理解每一个特性和命令背后的运作机制,可以帮助我们更高效地使用Redis,并避开那些会引发性能问题的陷阱。
Redis数据库里面的每个键值对(key-value pair)都是由对象(object)组成的,其中:
* 数据库键总是一个字符串对象(string object)
* 而数据库键的值则可以是字符串对象、列表对象、哈希对象、集合对象、有序集合这五种对象中的其中一种。
第2章 简单动态字符串
Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。
SDS的定义
struct sdshdr {
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
SDS依然遵守空字符结尾这一惯例,好处就是可以复用C语言库中很多字符串相关的函数。
SDS与C字符串的区别
SDS比C字符串更适用于Redis主要有以下几个原因
1、常数复杂度获取字符串长度
C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行技术,直到遇到代表字符串结尾的空字符位置,这个操作的复杂度为O(N)。
因为SDS在len属性中记录了SDS的长度,所以获取一个SDS长度的复杂度仅为O(1)。这确保了获取字符串长度的工作不会称为Redis的性能瓶颈。
2、杜绝缓冲区溢出
C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。举例而言,strcat函数可以将src字符串中的内容拼接到dest字符串的末尾。
char *strcat(char *dest, const char *src);
因为C字符串不记录自身的长度, 所以strcat假定用户在执行这个函数时, 已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时, 就会产生缓冲区溢出,添加的内容可能会覆盖到其他字符串的位置,意外修改对应的内容产生连锁错误。
与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。
3、减少修改字符串时带来的内存重分配次数
* 如果程序执行的是增长字符串的操作,比如拼接操作,那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出。
* 如果程序执行的是缩短字符串的操作,比如截断操作,那么在执行这个操作之前,程序需要先通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄露。
Redis作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分。
因此Redis设计中使用未使用空间,让SDS实现了空间预分配和惰性空间释放两种优化策略。
空间预分配:
在对SDS进行空间扩展的时候,程序不仅会为SDS分配修改时所必须要的空间,还会为SDS分配额外的未使用空间(先扩展到必须大小,然后额外提供空间)。
具体策略:
如果SDS修改之后长度小于1M,那么程序将分配和len属性同样大小的未使用空间
如果SDS修改后长度大于等于1M,那么程序会分配1M的未使用空间
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无需执行内存重分配。
SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。
惰性空间释放
当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这额字节的数量记录起来,并等待将来使用。
二进制安全
C字符串中的字符必须符合某种编码,且字符串里面不能包含空字符,,否则会提前终止。因此C字符串只能用于保存文本文件而不能保存图片、音频、视频、压缩文件等二进制文件。
虽然数据库一般用于保存文本数据,但是用数据库来保存二进制数据的场景也不少。为了确保Redis可以适用于不同的应用场景,API规定了Redis程序不会对数据进行任何处理,写入时怎样就被记录为怎样。
因为Redis用len属性而非空字符来统计字符串是否结束,因此redis可以保存任意格式的二进制数据。
兼容部分C字符串函数
Redis使用空字符结尾,这使得保存文本文件时,SDS可以重用string.h的各种函数。
第3章 链表
因为Redis使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。
链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。
除了列表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。
链表结构:
特点:
双端:每个节点都有prev和next指针
无环:表头节点的prev和表尾节点的next都指向null
可以直接获得表头节点和表尾结点
带链表长度计数器,可直接获取链表长度
多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
第4章 字典
字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典作为底层实现的,对数据库的增删改查操作也是建立在对字典的操作之上的。
除了用来表示数据库之外,字典还是哈希键的底层实现之一。
字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
对于Redis字典的哈希表而言,使用了链表法来解决冲突哈希的问题。
哈希表结构体如下所示:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
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;
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了提升插入的速度,程序总是将新节点插入到链表的表头位置。
值得一提的是,字典结构体里保存了两个哈希表,哈希表0为实际使用的哈希表,哈希表1为进行rehash操作时备份的哈希表。
字典结构体如下所示:
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会为用途不同的字典设置不同类型的特定函数。
而privadata属性则保存了需要传给那些类型特定函数的可选参数。
ht属性是一个包含了两个项的数组,数组中每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,而ht[1]哈希表只对ht[0]哈希表进行rehash
时使用。
另一个与rehash有关的就是rehashidx属性,它积累了rehash目前的进度,如果没有进行rehash,则它的值为-1。
参考链接:https://www.jianshu.com/p/bfecf4ccf28b
rehash
随着操作的不断进行,哈希表保存的键值对会逐渐增多或者减少。为了让哈希表的负载因子(load buffer)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
当然,表里可能储存了好几亿条数据,因此rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的(类似隐式锁、写时复制等思路),即渐进式rehash。
在rehash执行期间(rehash何时启动根据load buffer数值以及条件决定),每次对字典进行CRUD操作时,会把ht[0]相应的全部键值对rehash到ht[1],同时执行期间会同时检查维护两边的数据,分而治之,直到彻底完成,极大程度提升了程序的性能。
第5章 跳跃表
本章好的参考资料
17 | 跳表:为什么Redis一定要用跳表来实现有序集合?-极客时间
概述
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。
Redis使用功能跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。
Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。
跳跃表的实现
zskiplist结构:
header:指向跳跃表的表头节点
tail:指向跳跃表的表尾节点
level:记录目前跳跃表内,层数最大的那个节点的层数
length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)
zskiplistNode结构:
层:越高的层越稀疏,每一层会指向下一个相同的层
后退指针:指向当前节点的前一个节点
分值:保存节点对应的分值,自左向右从小到大排列
成员对象(obj):对应各个节点保存的成员对象
一般而言,层的数量越多,访问其他节点的速度越快
每次创建一个新跳跃表节点时,程序根据幂次定律随机生成一个1-32的值作为level的大小。因此越小的层数出现次数越多,第一层每个节点都有,从而实现了逐层索引的功能。
跨度:level.span反馈了从一层到相同层之间的节点数量了,如果指向NULL的话span为0
跳跃表优势分析
时间复杂度:O(log N)
每层最多遍历3个节点,整体时间复杂度为O(log n)
上面是每两个节点取一个索引的分析
如果是每k个节点取一个索引,复杂度为(k+1)*(logkN),依然为logN
空间复杂度:O(N)
多了O(N)级别的节点,且多的节点与正常的节点相比仅包含指针,而无需保存具体的复杂对象,因此内存消耗可以忽略。
空间性能时间性能都很好
第6章 整数集合
整数集合是集合键的底层实现之一
整数集合的实现
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t,int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。
intset的结构如下所示:
整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
* contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。
升级
根据整数集合的升级规则,当向一个底层为int16_t数组的整数集合添加一个int64_t类型的整数值时,整数集合已有的所有元素都会被转换为int64_t类型,所以contents数组保存的四个整数值都会变成int64_t类型。
升级的好处
1、提升灵活性
因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t,int32_t,int64_t的类型整数添加到集合中,而不必担心出现类型错误。
2、节约内存
整数结合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在需要的时候进行,这可以尽量节省内存。
降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
第7章 压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一
压缩列表的构成
一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
previous_entry_length:因为节点的 previous_entry_length属性保存了前一个节点的长度,所以程序可以通过指针运算,通过当前节点的起始地址来计算出前一个节点的起始地址。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针和prev_length就可以逐个节点回溯直到到达压缩列表的表头节点。
连锁更新
prevLength长度不固定,与前一个节点的大小相关,有1字节和5字节两种可能。
因此可能出现插入一个数据或者删除一个数据,多个节点prev_length大小变化,连锁更新。
因为连锁更新在最坏情况需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)
尽管连锁更新复杂度较高,但它很难造成性能问题
* 首先,压缩列表里要恰好有多个连续的、长度介于250-253字节的节点,连锁更新才可能被触发,现实中这种情况并不多见;
* 其次,即使出现连锁更新,只要被更新的节点数量不多,就不会对性能造成任何影响。