前言
Redis是一个key-value存储系统,由C语言编写。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set –有序集合)和hash(哈希类型),这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。
在此基础上,Redis支持各种不同方式的排序。与memcached一样,为了保证效率,数据都是缓存在内存中。区别的是Redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。
Redis是一个高性能的key-value数据库。 Redis的出现,很大程度补偿了memcached这类key/value存储的不足,在部分场合可以对关系数据库起到很好的补充作用。它提供了Java,C/C++,C#,PHP,JavaScript,Perl,Object-C,Python,Ruby,Erlang等客户端,使用很方便。
Redis支持主从同步。数据可以从主服务器向任意数量的从服务器上同步,从服务器可以是关联其他从服务器的主服务器。这使得Redis可执行单层树复制。存盘可以有意无意的对数据进行写操作。由于完全实现了发布/订阅机制,使得从数据库在任何地方同步树时,可订阅一个频道并接收主服务器完整的消息发布记录。同步对读取操作的可扩展性和数据冗余很有帮助。
Redis的作者叫Salvatore Sanfilippo,来自意大利的西西里岛,现在居住在卡塔尼亚。目前供职于Pivotal公司。他使用的网名是antirez。
Redis的5种对象与8种数据结构
Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(key对象),另一个对象用作键值对的值(value对象)。
Redis的每种数据类型全都是套用一种结构的对象(redisObject)。
Redis支持5种对象类型,分别是字符串(string)、列表(list)、哈希(hash)、集合(set)、有序集合(zset),redisObject使用type字段记录自身属于哪种类型。
而每种对象类型至少使用了两种底层数据结构来实现,redisObject使用编码字段(encoding字段)记录了自己使用的是哪种底层数据结构实现。而*ptr指针则会直接指向这个对应的底层数据结构。
每个对象会用到的编码以及对应的数据结构详见下表,即共8种底层数据结构:
Redis中的键,都是用字符串对象来存储的,即对于Redis数据库中的键值对来说,键总是一个字符串对象,而值可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象中的其中一种。
那么,我们首先先从底层开始,了解一下Redis的8种数据结构。
Reids的8种底层数据结构
1 整数
如果保存的字符串是整数值,并且这个整数值可以用long类型来表示,那么ptr指针的void*则转化为C语言源生的long类型,这个无须多言。
2 简单动态字符串SDS
在Redis中,只有在使用到不会被修改的字符串字面量时(比如打印日志),Redis才会采用c语言传统的字符串(以空字符结尾的字符数组),而在Redis数据库中,所有的字符串在底层都由SDS来实现的。
2.1 SDS数据结构
被重新定义过的字符串对象(SDS)是Redis的基本存储类型,一个SDS字符串的完整结构,由在内存地址上前后相邻的两部分组成(header和char数组)。如下图,SDS字符串有多种类型,不同类型的SDS字符串是为了保存不同长度的内容。
header——我们把上图中非char数组(变量名为buf)的部分都统称为header,其成员有:
第一个成员变量len记录的是为buf分配的内存空间已使用的长度,即我们看见的,有效的字符串;
第二个成员变量alloc记录的是为buf分配的内存空间的总长度,alloc – len 就是未使用的空间,当然这长度不包括SDS字符串头和结尾NULL。
第三个字符flags只使用了低三位表示类型,值为0-4,分别表示sdshdr5到sdshdr64这五种类型。高五位没有用处,目的是根据字符串的长度的不同选择不同的sds结构体。
为何要定义不同的结构体: 结构体的主要区别是len和alloc的类型(uint8,uint16等等),定义不同的结构体是为了存储不同长度的字符串,根据不同长度定义不同的类型是为了节省一部分空间大小,毕竟在Redis字符串非常多,哪怕一点点优化积累起来都很可观。
flags字段的用处:由于SDS字符串结构的设计,在我们需要访问header中成员变量时,需要通过sds指针向前回溯一个头结构体的长度,然后通过这个地址去访问。至于回溯多长,则要视该SDS字符串的类型而定,而这个信息就保存在sds指针前一个unsigned char长度的空间中——即flags。
char数组
这是一个没有指明长度的字符数组,这是C语言中定义字符数组的一种特殊写法,称为柔性数组(flexible array member),只能定义在一个结构体的最后一个字段上。它在这里只是起到一个标记的作用,表示在flags字段后面就是一个字符数组,或者说,它指明了紧跟在flags字段后面的这个字符数组在结构体中的偏移位置。而程序在分配内存的时候,一开始它并不占用内存空间。
这个字符数组的长度等于最大容量+1。之所以字符数组的长度比最大容量多1个字节,就是为了在字符串长度达到最大容量时仍然有1个字节NULL结束符,即ASCII码为0的’\0’字符,这样字符串可以和c语言源生的字符串兼容。
与其他的结构体不同,sdshdr5没有定义char数组和alloc字段,他的值存储在flag没有被使用的高五位中,所以sdshdr5对应的SDS_TYPE_5类型字符串只能保存原串长度小于等于2^5 = 32,因此,它不能为字符串分配空余空间。如果字符串需要动态增长,那么它就必然要重新分配内存才行。所以说,这种类型的sds字符串更适合存储静态的短字符串
2.2 sds的代码
sdsnewlen()方法可以用来申请sds。
1 | sds sdsnewlen(const void *init, size_t initlen); |
第一个参数是sds中字符串的内容,initlen则是第一次初始化的长度。
首先会根据第一次初始化所需要的长度根据其所占位数通过sdsReqType()得到内存的结构体。
1 | static inline char sdsReqType(size_t string_size) { |
但是,如果一开始申请的是一个初试长度为0的空字符串,那么并不是按照最小的5位容量,还是为了方便这类空字符串的后续添加,直接申请8位容量。
1 | if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8; |
sds的扩容,如果原有结构的长度已经无法满足扩容后的需要的长度,那么会根据新扩容的长度重新确定存储的结构体。
1 | if (type == SDS_TYPE_5) type = SDS_TYPE_8; |
2.3 使用sds结构的优点
1.有利于减少内存碎片,提高存储效率
- 在各个header的定义中使用了attribute ((packed)),是为了让编译器以紧凑模式来分配内存。如果没有这个属性,编译器可能会为struct的字段做优化对齐,在其中填充空字节。那样的话,就不能保证header和sds的数据部分紧紧前后相邻,也不能按照固定向低地址方向偏移1个字节的方式来获取flags字段了。这样利于获取header字段,提高性能。
2.常数复杂度获取字符串长度
- C语言源生的获取字符串长度的方式是遍历整个char数组,因此复杂度为O(N),SDS采用len字段记录长度,且header和char数组紧凑排列,获取的复杂度为O(1)。设置和更新SDS长度的工作是由SDS的api在执行时自动完成的。
3.杜绝缓冲区溢出
- C语言字符串不记录自身长度,也容易造成缓冲区溢出。而当SDS对自身字符串进行修改时,API会先检查SDS的剩余空间是否满足需要(获取alloc减len),如果不满足,则会先拓展空间,再执行API。
4.空间预分配
SDS在重新分配空间的时候,会预分配一些空间来作为冗余。当SDS的len属性长度小于1MB时,Redis会分配和len相同长度的free空间。至于为什么这样分配呢,上次用了len长度的空间,那么下次程序可能也会用len长度的空间,所以Redis就为你预分配这么多的空间。
但是当SDS的len属性长度大于1MB时,程序将多分配1M的未使用空间。这个时候我在根据这种惯性预测来分配的话就有点得不偿失了。所以Redis是将1MB设为一个风险值,没过风险值你用多少我就给你多少,过了的话那这个风险值就是我能给你临界值。
5.惰性空间释放
- Redis的内存回收采用惰性回收,即你把字符串变短了,那么多余的内存空间也不会立刻还给操作系统,先留着,用header的字段将其记录下来,以防接下来又要被使用呢。
6.二进制安全
embstr编码是专门用来保存短字符串的一种优化编码方式,其实他和raw编码一样,底层都会使用SDS,只不过raw编码是调用两次内存分配函数分别创建redisObject和SDS,而embstr只调用一次内存分配函数来分配一块连续的空间,embstr编码的的redisObject和SDS是紧凑在一起的。
其优势是:
embstr的创建只需分配一次内存,而raw为两次(一次为sds分配对象,另一次为objet分配对象,embstr省去了第一次)。
相对地,释放内存的次数也由两次变为一次。
embstr的objet和sds放在一起,更好地利用缓存带来的优势。
不过很显然,紧凑型的方式只适合短字符串,长字符串占用空间太大,就没有优势了。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。否则采用raw编码的SDS来存储。这在3.0以上版本的Redis出现。
至于为什么是39?
embstr是一块连续的内存区域,由redisObject和sdshdr组成。其中redisObject占16个字节,当buf内的字符串长度是39时,sdshdr的大小为8+8+39+1=56,那一个字节是’\0’。加起来刚好64。
从2.4版本开始,Redis开始使用jemalloc内存分配器。在这里可以简单理解,jemalloc会分配8,16,32,64等字节的内存。embstr中即便sdshdr的buf为空,最小空间占用也为16+8+8+1=33,所以jemalloc低三档的分配粒度无法满足embstr,最少也要分配64字节。故而当字符数小于39时,都会分配64字节。默认39就是这么来的。
4 双端链表 linkedlist
C语言中没有内置链表结构,Redis构建了自己的链表实现。list的容量是2的32次方减1个元素,即最多有4294967295个元素数量。
4.1 链表的数据结构
列表的节点(注意不是列表的定义)定义如上,除了双向链表必须的前后指针外,为了实现通用性,支持不同类型数据的存储,Redis将节点类型的数据域定义为void *类型,从而模拟了“泛型”。
整个列表定义如下:
在链表结构中,Redis定义了三个字段和三个函数:
- 字段:
- listNode *head; // 指向链表的头结点
- listNode *tail; // 指向链表的尾节点
- unsigned long len; // 链表长度
- 函数:
- void *(*dup)(void *ptr); // 节点值复制函数,用于复制某个节点的值
- void (*free)(void *ptr); // 节点值释放函数,用于释放某个节点的值
- int (*match)(void *ptr, void *key); // 节点值对比函数,用于对比节点的值和另一个输入值是否相等
5 字典 dict
在Redis中,字典的结构可以简单归纳如下:
5.1 Dict的数据结构
Redis定义了dictEntry、dictType、dictht和dict四个结构体来实现哈希表的功能。它们具体定义如下:
5.1.1 dictEntry结构体
1 | /* 保存键值(key - value)对的结构体,类似于STL的pair。*/ |
5.1.2 dictType结构体
1 | /* 定义了字典操作的公共方法,类似于adlist.h文件中list的定义,将对节点的公共操作方法统一定义。搞不明白为什么要命名为dictType */ |
5.1.3 dictht结构体
1 | /* 哈希表结构 */ |
5.1.4 dict结构体
1 | /* 字典的主操作类,对dictht结构再次包装 */ |
5.1.5 dict结构总结
上面的结构体如果看得你头昏脑胀,没有关系,下面两张图让你理清他们的关系:
可以很清楚的看到,字典通过“拉链法”来解决冲突问题的,dictEntry结构体的*next指针指向了其拉链列表的下一个节点。
上图中,dict是字典的包装对象,居于最外层。
ht[2]是包含两个项的哈希表的数组,一般情况下,只使用h[0],h[1]只有在rehash的时候才会使用
dictht是哈希表的结构,他除了一个数组table用来存放键值对以外,还有used字段表示目前已有键值对,size表示数组大小,sizemark=size-1,用来hash索引。
dictType是类型特定函数,上图中从上到下,依次是:
- HashFunction 计算哈希值的函数
- KeyDup 复制键的函数
- ValDup 复制值的函数
- KeyCompare 对比键的函数
- KeyDestructor 销毁键的函数
- ValDestructor 销毁值的函数
5.2 dict的哈希算法
Redis提供了三种不同的散列函数,分别是:
- 使用Thomas Wang’s 32 bit Mix哈希算法,对一个整型进行哈希,该方法在dictIntHashFunction函数中实现。
1
2
3
4
5
6
7
8
9
10unsigned int dictIntHashFunction(unsigned int key) //用于计算int整型哈希值的哈希函数
{
key += ~(key << 15);
key ^= (key >> 10);
key += (key << 3);
key ^= (key >> 6);
key += ~(key << 11);
key ^= (key >> 16);
return key;
} - 使用MurmurHash2哈希算法对字符串进行哈希,该方法在dictGenHashFunction函数中实现。(当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis用MurmurHash2算法来计算哈希值,能产生32-bit或64-bit哈希值。)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33unsigned int dictGenHashFunction(const void *key, int len) { //用于计算字符串的哈希值的哈希函数
//m和r这两个值用于计算哈希值,只是因为效果好。
uint32_t seed = dict_hash_function_seed;
const uint32_t m = 0x5bd1e995;
const int r = 24;
/* Initialize the hash to a 'random' value */
uint32_t h = seed ^ len; //初始化
/* Mix 4 bytes at a time into the hash */
const unsigned char *data = (const unsigned char *)key;
//将字符串key每四个一组看成uint32_t类型,进行运算的到h
while(len >= 4) {
uint32_t k = *(uint32_t*)data;
k *= m;
k ^= k >> r;
k *= m;
h *= m;
h ^= k;
data += 4;
len -= 4;
}
/* Handle the last few bytes of the input array */
switch(len) {
case 3: h ^= data[2] << 16;
case 2: h ^= data[1] << 8;
case 1: h ^= data[0]; h *= m;
};
/* Do a few final mixes of the hash to ensure the last few
* bytes are well-incorporated. */
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return (unsigned int)h;
} - 在dictGenCaseHashFunction函数中提供了一种比较简单的djb哈希算法,对字符串进行哈希。(djb哈希算法,算法的思想是利用字符串中的ascii码值与一个随机seed,通过len次变换,得到最后的hash值。)
1
2
3
4
5
6
7unsigned int dictGenCaseHashFunction(const unsigned char *buf, int len) { //用于计算字符串的哈希值的哈希函数
unsigned int hash = (unsigned int)dict_hash_function_seed;
while (len--)
hash = ((hash << 5) + hash) + (tolower(*buf++)); /* hash * 33 + c */
return hash;
}
5.3 dict的rehash
当哈希表的大小不能满足需求,就可能会有两个或者以上数量的键被分配到了哈希表数组上的同一个索引上,于是就发生冲突(collision),在Redis中解决冲突的办法我们提到过是拉链法(separate chaining)。
但是我们仍然需要尽可能避免冲突,希望哈希表的负载因子(load factor),维持在一个合理的范围之内,就需要对哈希表进行扩展或收缩。
Rehsh会根据负载因子(load_factor = ht[0].used/ht[0].size)调整,当满足如下任意条件时,哈希表会rehash拓展:
- 在服务器没有执行BGSAVE或BGREWRITEAOF,即没有持久化数据的时候,如果负载因子大于等于1
- 在服务器正在执行BGSAVE或BGREWRITEAOF时,如果负载因子大于等于5
Rehash扩展有三个步骤:
- 扩展备用的ht[1],将它的容量扩张到第一个大于ht[0].used*2的 2的n次方
- 将ht[0]的值重新经过hash索引之后迁移到ht[1]上。
- 释放ht[0],将ht[1]设为ht[0],创建新的空表ht[1]。
注意:当负载因子小于0.1时,进行收缩操作,步骤将上述三步中的大于变为小于就是
5.3.1 Rehash是渐进式的
Rehash不是一步完成的,而是在操作过程中渐进式的。字典维持一个索引计数器rehashidx用来记录当前正在操作的索引,从ht[0]的0号索引上开始,一个项一个项的迁移到ht[1],直到完成所有迁移,rehashidx变成-1。
在rehash期间,所有新增字段添加在ht[1]中,而删除,更新操作会在两个表上同时进行。查找时先找ht[0],再找ht[1]。
6 跳跃表 skiplist
跳跃表是有序集合zset的底层实现之一(另一个是压缩列表),当元素数量比较多,或者元素成员是比较长的字符串时,底层实现采用跳跃表。
跳跃表是一种有序数据结构,他在一个节点中维持多个指向其他节点的指针
跳跃表的平均复杂度为O(logN),最坏为O(N),其效率可以和平衡树相媲美,而且跟平衡树相比,实现简单;
6.1 平衡的跳跃表
如图:每一个竖列其实是一个节点。如果能通过在节点中维持多个指向不同节点的指针(比如node4(值为21)就有三个指针,分别指向node5(33),node6(37),node8(55)),那么就会得到一个平衡的跳跃表。
在平衡的跳跃表中是左右对称的,node2两层,node4三层,node8四层。这样查到某一个节点的复杂度都为O(logN):
- 比如要查46,可以走L4到55,再用55的后退指针得到46。
- 比如要查37,先走L2层到21,再前进一步得到33。
可以看到,上述表中,找到任何一个节点,时间复杂度不超过两次跳跃。
但是,跳跃表最难的,就是保持平衡,维持平衡的跳跃表难度要大于维持平衡的二叉树。故而易于实现的,是实现概率平衡,而不是强制平衡
6.1.1 跳跃表的查询
跳跃表的查询是从顶层往下找,那么会先从第顶层开始找,方式就是循环比较,如过顶层节点的下一个节点为空说明到达末尾,会跳到第二层,继续遍历,直到找到对应节点。
例子:查找元素 117
- 比较 21, 比 21 大,且21有后继,向后面找
- 比较 37, 比 37大,且37节点同层没有后继了,则从 37 的下面一层开始找
- 比较 71, 比 71 大,且71节点同层没有后继了,则从 71 的下面一层开始找
- 比较 85, 比 85 大,且85有后继,向后面找
- 比较 117, 等于 117, 找到了节点。
6.1.2 跳表的删除
使用标准的 delete from list 方法删除该节点。
6.2 Redis中的跳跃表的实现
为了尽可能的维持理想的跳跃表,Redis根据幂次定律来使跳跃表尽可能的平衡,我们先看Redis中跳跃表和跳跃表节点的结构:
我们逐个分析
zskiplistNode 表示跳跃表节点结构
- ele是个SDS,是有序集合的值element。
- Score是double结构,存储分数值。
- Backward,后退指针,指向列表前一个node。
- Level [ ]数组,表示一个节点可以有多个层。
- 数组里面的项是zskiplistLevel结构,可以看到,每一层都有一个跳跃指针forward。
- 跨度span,顾名思义,就是用来记录跨度的,相邻的节点跨度为1。
- 注意:跨度的用处是用来计算某个节点在跳跃表中的排位的,zset的排序按score从小到大排序。比如我查找到node7,通过将沿途的所有跨度累加,我们可以得到其排在列表中的序列。
zskiplist 表示跳跃表结构
- zskiplist中有指向整个跳跃表两端的head指针和tail指针
- 记录跳跃表长度的leng字段。
- Int型的level用来记录目前整个跳跃表中最高的层数。
6.3 一般情况下维持平衡跳跃表的实现
- 在跳跃表中插入一个新的节点时,程序需要确定两个要素:该节点的位置,以及层数
- 因为有序集合按照score排序,故而位置可以按照score比出,确定位置。
- 确定了位置后,再确定node的层数,可以采用抛硬币的方式,一次正面,层数+1,直到反面出现为止。因为抛硬币会使层数L的值满足参数为 p = 1/2 的几何分布,在数量足够大时,可以近似平衡。
- 用抛硬币的方式,可以使level+1的概率为2分之一,也就是说,k层节点的数量是k+1层的1/2 ,你可以把它看成是一个二叉树。
6.4 Redis维持平衡跳跃表的实现
与上述抛硬币的方式大同小异,Redis根据幂次定律维持一个尽可能理想的跳跃表(即节点数尽可能大时,整个链表尽可能平衡。)
6.4.1 幂次定律
- 含义是:如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。
- 表现是:少数几个事件的发生频率占了整个发生频率的大部分, 而其余的大多数事件只占整个发生频率的一个小部分。
- 说人话版:越大的数,出现的概率越小。
6.4.2 实现算法
当Redis在跳跃表中插入一个新的节点时,程序需要确定两个要素:该节点的位置,以及层数
Redis的实现与一般维持平衡跳跃表的实现大同小异,Redis中跳跃表的层数也是在插入的时候确定,按照分数找好位置后,Redis会生成一个1-32的数作为层数。
Redis的level+1的概率是1/4,所以Redis的跳跃表是一个四叉树。
1 | level = zslRandomLevel(); |
ZSKIPLIST_P=0.25,所以Redis的跳跃表是一个四叉树。
7 整数集合 intset
整数集合是set的底层实现之一,当一个集合中只包含整数值,并且元素数量不多时,redis使用整数集合作为set的底层实现。
7.1 数据结构
- Encoding 存储编码方式
- Length inset的长度,即元素数量
- Content Int数组,用来保存元素,各个项在数组中按数值从小到大排序,不包含重复项
注意:虽然content数组的结构是int8_t,但其实他不会存储任何int8_t类型的值,当encoding=INTSET_ENC_INT16,那么他存的就是int16_t。以此类推,还有int32和int64。
7.2 整数集合的升级
当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型。
整个过程有三步:
根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。
将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
最后改变encoding的值,length+1。
举个例子, 假设现在有一个INTSET_ENC_INT16
编码的整数集合, 集合中包含三个 int16_t 类型的元素。
因为每个元素都占用 16 位空间, 所以整数集合底层数组的大小为 3 * 16 = 48 位, 图 6-4 展示了整数集合的三个元素在这 48 位里的位置。
现在, 假设我们要将类型为 int32_t 的整数值 65535 添加到整数集合里面, 因为 65535 的类型 int32_t 比整数集合当前所有元素的类型都要长, 所以在将 65535 添加到整数集合之前, 程序需要先对整数集合进行升级。
升级首先要做的是, 根据新类型的长度, 以及集合元素的数量(包括要添加的新元素在内), 对底层数组进行空间重分配。
整数集合目前有三个元素, 再加上新元素 65535 , 整数集合需要分配四个元素的空间, 因为每个 int32_t 整数值需要占用 32 位空间, 所以在空间重分配之后, 底层数组的大小将是 32 * 4 = 128 位, 如图 6-5 所示。
虽然程序对底层数组进行了空间重分配, 但数组原有的三个元素 1 、 2 、 3 仍然是 int16_t 类型, 这些元素还保存在数组的前 48 位里面, 所以程序接下来要做的就是将这三个元素转换成 int32_t 类型, 并将转换后的元素放置到正确的位上面, 而且在放置元素的过程中, 需要维持底层数组的有序性质不变。
首先, 因为元素 3 在 1 、 2 、 3 、 65535 四个元素中排名第三, 所以它将被移动到 contents 数组的索引 2 位置上, 也即是数组 64 位至 95位的空间内。因为元素 2 在 1 、 2 、 3 、 65535 四个元素中排名第二, 所以它将被移动到 contents 数组的索引 1 位置上, 也即是数组的 32 位至 63 位的空间内, 如图 6-7 所示。
之后, 因为元素 1 在 1 、 2 、 3 、 65535 四个元素中排名第一, 所以它将被移动到 contents 数组的索引 0 位置上, 也即是数组的 0 位至 31位的空间内, 如图 6-8 所示。
然后, 因为元素 65535 在 1 、 2 、 3 、 65535 四个元素中排名第四, 所以它将被添加到 contents 数组的索引 3 位置上, 也即是数组的 96 位至 127 位的空间内, 如图 6-9 所示。
最后, 程序将整数集合 encoding 属性的值从 INTSET_ENC_INT16 改为 INTSET_ENC_INT32 , 并将 length 属性的值从 3 改为 4 , 设置完成之后的整数集合如图 6-10 所示。
因为每次向整数集合添加新元素都可能会引起升级, 而每次升级都需要对底层数组中已有的所有元素进行类型转换, 所以向整数集合添加新元素的时间复杂度为 O(N) 。
注意,整数集合只支持升级操作,不支持降级操作
升级之后新元素的摆放位置如何确定?因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素(负数):
- 在新元素小于所有现有元素的情况下, 新元素会被放置在底层数组的最开头(索引 0 );
- 在新元素大于所有现有元素的情况下, 新元素会被放置在底层数组的最末尾(索引 length-1 )。
8 压缩列表 ziplist
压缩列表是list,hash和zset的底层实现之一,当一个列表只包含少量元素,并且每个元素要么就是小整数值,要么就是长度比较短的字符串,那么Redis使用ziplist作为列表实现。
压缩表是为了节约内存而开发的,压缩表可以包含任意个节点,每个节点保存一个字节数组(字符串)或一个整数值。
8.1 压缩表数据结构
- Zlbytes 类型:uint32_t 记录整个压缩表占用的内存字节数,对压缩表进行内存重分配和或者计算zlend位置时被使用
- Zltail_offset 类型:uint32_t 记录压缩列表尾节点entryN距离压缩列表的起始地址的字节数。用来快速确定表尾节点的地址。
- Zllength 类型:uint16_t 若不超过uint16的极值65535,就是记录着压缩表节点的数量。否则,真实的节点数量需要遍历压缩表才能得出
- Zlend 类型:uint8_t 特殊值0xFF(十进制255),用于标记表的末端。
- Entry char[]或uint 长度不定,节点的长度随保存的内容而改变。
8.2 压缩表节点的结构
- prevrawlen:前置节点的长度(以字节为单位)
- prevrawlensize:存储 prevrawlen 的值所需的字节大小
- len:当前节点的长度
- lensize:存储 len 的值所需的字节大小
- headersize:当前节点 header 的大小,等于 prevrawlensize + lensize
- encoding:当前节点值所使用的编码类型
- p:指向当前节点的指针
虽然定义了这个结构体,但是Redis根本就没有使用zlentry结构来作为压缩列表中用来存储数据节点中的结构,这个结构总共在32位机占用了28个字节(32位机),在64位机占用了32个字节。这不符合压缩列表的设计目的:提高内存的利用率。
ziplist在存储节点信息时,并没有将zlentry数据结构所有属性保存,而是做了简化。
虽然在压缩列表中使用的是”压缩版”的zlentry结构,但是在对节点操作时,还是要将”压缩版” “翻译”到zlentry结构中,因为我们无法对着一串字符直接进行操作。
因此,就有了下面的函数:
1 | /* Return a struct with all information about an entry. */ |
8.2.1 prev_entry_len
prev_entry_len成员实际上就是zlentry结构中prevrawlensize(记录存储prevrawlen值的所需的字节个数)和prevrawlen(记录着上一个节点的长度)这两个成员的压缩版。
- 如果前一节点的长度小于254(即2^8-1)字节,则pre_entry_len用一个字节记录其长度。
- 当前驱节点的长度大于等于255(即2^8-1)字节,那么prev_entry_len使用5个字节表示。
- 并且用5个字节中的最高8位(最高1个字节)用 0xFE来标志prev_entry_len占用了5个字节,后四个字节才是真正保存前驱节点的长度值。
- pre_entry_len最大的用处是用来从后向前遍历,因为前一个节点的指针c = 当前节点指针p –pre_entry_len,可以快速往前上溯。
因为,对于访问的指针都是char 类型,它能访问的范围为1个字节,如果这个字节的大小等于0xFE,那么就会继续向后访问四个字节来获取前驱节点的长度,如果该字节的大小小于0xFE,那么该字节就是要获取的前驱节点的长度。因此这样就使prev_entry_len同时具有了prevrawlen和prevrawlensize的功能,而且更加节约内存。
8.2.2 encoding
prev_entry_len一样,encoding成员同样可以看做成zlentry结构中lensize(记录存储 len 所需的字节大小)和len(当前节点的长度)的压缩版。
Encoding记录了节点内容(value)的类型和长度。value可存的类型有两种,整数和字符串(字节数组)。Redis对字节数组和整数编码提供了一组宏定义,定义在ziplist.c中:
1 | /* Different encoding/length possibilities */ |
上面这些常量被如下代码使用:
1 | //从ptr中取出节点信息,并将其保存在encoding、lensize和len中 |
看不懂没关系,简单归纳就是:
编码格式 | value类型 | encoding长度 | value保存的值长度 | 解释 |
---|---|---|---|---|
00xxxxxx | 字节数组 | 1字节 | 长度小于等于 2^6−1 字节 | encoding长8bit,后6个bit,最多承载数量2^6−1的数字,说明其最多能为长度为2^6−1的字节数组计数 |
01xxxxxx xxxxxxxx | 字节数组 | 2字节 | 长度小于等于2^14−1字节 | encoding长16bit,后14个bit,最多承载数量2^14−1的数字,说明其最多能为长度为2^14−1的字节数组计数 |
10—— xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx | 字节数组 | 5字节 | 长度小于等于2^32−1字节 | encoding长40bit,前两位bit10表示该encoding5字节,然后6bit留空,最后32个bit,最多承载数量2^32−1的数字,说明其最多能为长度为2^32−1的字节数组计数 |
1100 0000 | 整数 | 1字节 | int16_t类型整数 | —– |
1101 0000 | 整数 | 1字节 | int32_t类型整数 | —– |
1110 0000 | 整数 | 1字节 | int64_t类型整数 | —– |
1111 0000 | 整数 | 1字节 | 24 bit 有符号整数 | —– |
1111 1110 | 整数 | 1字节 | 8 bit 有符号整数 | —– |
1111 xxxx | 整数 | 1字节 | 4 bit 无符号整数,[0,12] | encoding为该值的节点是没有value的,因为xxxx已经足够存储0-12的值了,value直接存在encoding中。xxxx首先最小值应该是0001(0000已经被占用),最大值应该是1101(1110与1111均已经被占用),因此,可被编码的值实际上只能是 1 至 13,由于还需要减1,所以实际只能编码[0,12],至于减1的理由,我的理解是方便编码0。 |
8.2.3 value
根据encoding来保存字节数组或整数。我们举例说明:
假设这是一个压缩列表的头两个节点,因此:
- 第一个节点信息:
- prev_entry_len成员值为0,占1字节空间,因为前驱节点长度为0,小于254。
- encoding成员值为0000 0101,最高两位为00,因此encoding占1个字节且可以算出value为字符数组,根据剩下的6位00 0101,可以算出value长度为5字节。
- value成员根据encoding成员算出长度为5字节,因此,会读5个字节的字节数组,值为”Redis”。
- 第二个节点信息:
- prev_entry_len成员值为0x07,占一个字节,因为前驱节点长度为7,小于254。
encoding成员编码值为1101 0000,最高两位为11,因此encoding占1个字节且可以算出value为整数,在根据encoding编码可以得出value值为占32位,4个字节int32_t类型的有符号整数。 - value成员根据encoding编码,读出4个字节的整数,值为 1234。
- prev_entry_len成员值为0x07,占一个字节,因为前驱节点长度为7,小于254。
- 压缩列表的表头信息:
- zlbytes为整个压缩列表所占字节数24。
- zltail_offset为从压缩列表的首地址到最后一个entry节点的偏移量17。
- zlength为节点个数2。
- zlend为常数255(0xFF)。
8.3 连锁更新
因为有如下的前提,所以才会出现连锁更新的场景:
如果前驱节点的长度小于254(2^8-1),那么prev_entry_len成员需要用1字节长度来保存这个长度值。
如果前驱节点的长度大于等于254(2^8-1),那么prev_entry_len成员需要用5字节长度来保存这个长度值。
如果在一个压缩列表中,有多个连续、长度介于250字节到253字节之间的节点,因此记录这些节点只需要1个字节的prev_entry_len,如果要插入一个长度大于等于254的新节点e0到压缩列表的头部,然而原来的头节点e1的prev_entry_len成员长度仅仅为1个字节,无法保存新节点的长度,这会使得e1的prev_entry_len必须扩容到5个节点。e1的长度本来就在[250,254]之间,一扩容又大于了254,使得e2又要扩容,以此类推,引发连锁扩展。
反之,也会引发连锁收缩。
参考资料
为什么redis小等于39字节的字符串是embstr编码,大于39是raw编码?