免责声明: 本人水平有限,难免有疏漏的地方。如果读者遇到文章中需要改进或者看不懂,甚至是觉得错误的地方,可以给我留言。我想做一个比较全面由浅入深去讲解redis原理和进阶的系列文章,内容偏源码较硬核,但我会尽量使用流程图和画图去配合源码讲解,让文章显得更通俗点。
本实验平台主要是基于本人的MacbookPro
- redis 6.0.4 目前以单机版为主(均以默认参数为例),不会涉及集群或哨兵
- macOS Catalina 10.15 内存 8 GB 2133 MHz LPDDR3
- 我工作中是个
Javaer
,所以有些概念我会以java
中的概念来类比,可能和实际有偏差但是便于理解。
今天来聊一聊,redis
中一个比较重要的数据结构:压缩列表。 压缩列表本身并没有作为一种数据结构通过对外的接口供客户端使用,但是redis
内部却使用到了这一种节约存储性能的数据结构,比如hash
、zset
底层都使用了压缩列表来实现,看过我之前的文章的同学一定听过我提过一个编码(encoding)的名次,打一个比方,像string
,hash
,set
,list
,zset
,stream
是redis
服务端提供给客户端使用的数据结构,提供了统一供访问的API,但这几个只是一个接口(类比java
里的概念),具体到服务端的实现可以由不同的数据结构去处理,在hash
和zset
中当整个数据结构中的元素数较少时,redis
就会采用压缩列表来实现,具体的阀值相关的配置,我稍后再介绍。
简介
先用一句话概括:压缩列表就是一个字节数组。 新创建的空压缩列表大概长这样,图中每一格都是一个字节:这
11
个字节相当于压缩列表本身的元信息 填上初始信息后就是:
解释下,空的压缩列表是11
个字节,因为没有元素,所以只有元信息的11
个字节。11
转换成二进制就是1011
(前面的0
我都省略了)填入了前4个字节中。之后的第5-8的字节记录的是从压缩列表起始位置开始到尾节点的偏移量,因为现在0
个元素,所以偏移量就是11-1=10
,减去了最后的那一个字节。10
对应的二进制就是1010
填入第5-8个字节中。而元素现在是0,所以再往后两个字节就是0。
添加
有了这样一个空的压缩列表后,我们尝试往其中加入一些元素 ,以zset
的命令为例,假设现在客户端输入一串这样的命令:zadd test 7 xjj
,我们希望添加一个字符串是xjj
,它对应的分数是7
,但是这里得说对于客户端来说其实这是一个元素,只是这是一个附加的分数的元素,但是对于压缩列表来说,这属于往里面添加两个元素,一个是xjj
,一个是7
。 先添加xjj
,添加完压缩列表长这样(我直接写十进制或者字符串了,反正知道存储的是字节就行):加入一个元素后就比较清楚了,通过第5-8的字节记录的长度,可以从压缩列表起始位置往后10个字节就定位到了整个压缩列表的尾节点(所以压缩列表实际是从后往前遍历的)。 再添加
7
:大家一定有很多疑问,特别是
7
,我明明存储的是数字7
,但是压缩列表中并没有体现出来。 我现在来解释下关于两次元素添加中的细节,里面的0
,3
,5
,248
到底是什么? 除了初始的11个字节,前面10个加最后1个,他们中间的那些字节存储的是压缩列表真正的元素,在源码中被称为entry
,每一个entry
其实也分成了3个部分去存储分别是:
- 前一个节点的总长度
- 当前节点的编码信息及长度
- 当前节点的真实数据
所以0 3 x j j
中的0
,指的就是前一个节点的长度,因为xjj
就是压缩列表中的第一个节点,它没有前一个节点了,所以这个部分存储的是0
。 而5 248
分数7
这个节点,它的前一个节点xjj
所占的总长度是5
,所以它的这个部分存储的就是5
,关于entry
中的第一部分,大家也应该能明白了,压缩列表就是靠这个部分去获取到上一个节点的起始位置,从而进行其他操作的,这个部分的字节长度根据存储的长度来改变,如果存储的数字小于254,就用一个字节存,否则就用5个字节存,源码如下:
// ziplist.c
#define ZIP_BIG_PREVLEN 254
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do { \
if ((ptr)[0] < ZIP_BIG_PREVLEN) { \
(prevlensize) = 1; \
} else { \
(prevlensize) = 5; \
} \
} while(0);
而第二部分(当前节点的编码信息及长度),是entry中最复杂的部分,redis
的开发者为了尽可能的节省内存的占用,对不同类型和不同长度的对象都安排了不同的存储方式,对应关系如下:图片出处地址
先来看xjj
因为只有3个字母,所以符合表中ZIP_STR_06B
存储格式,所以最终使用一个字节存储长度00000011
,对应十进制为3,之后的content
部分就很简单了,直接使用ASCII存储3个字母对应的数字即可。 再看数字7
,符合表中最后一条1111 xxxx
存储0-12的无符号整数,这样的小数字直接连entry
的第三部分都省略了,那么248
是怎么来的呢? 源码中有这么一段:
// ziplist.c
int zipTryEncoding(unsigned char *entry, unsigned int entrylen, long long *v, unsigned char *encoding) {
long long value;
if (entrylen >= 32 || entrylen == 0) return 0;
if (string2ll((char*)entry,entrylen,&value)) {
// 这个字符串可以使用数字格式进行存储
if (value >= 0 && value <= 12) {
// ZIP_INT_IMM_MIN = 241 = 11110001
// value = 7 = 00000111
// encoding等于两者相加就是 241 + 7 = 248 = 11111000
*encoding = ZIP_INT_IMM_MIN+value;
...
}
如果是一个0
-12
之间的数字就会加上241
后再存入encoding
的字段中,所以0
-12
存入之后就会变成241
-253
那么之后要解码这个数字7
的话,源码中有这么一段:
// ziplist.c
int64_t zipLoadInteger(unsigned char *p, unsigned char encoding) {
...
} else if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX) {
// 如果当前的encoding在241至253之间
// ZIP_INT_IMM_MASK = 15 = 00001111
// 通过 &15 再 减1,获得原来的值
ret = (encoding & ZIP_INT_IMM_MASK)-1;
} else {
assert(NULL);
}
return ret;
}
我们以7
举例的话,存入encoding
是248
11111000
& 00001111
------------------
00001000
- 00000001
------------------
00000111 // 7
就可以解码得到7
了。 我们再往这个zset
中添加一个元素(字符串长度64,超过63的限制)
zadd test 199 dfjlkadjflajdflkasjdflkasjdflkajsdfljasdlkfjasldkfjaslkdfjaslkdj
存储完毕后压缩列表长这样(我调整下图的比例):
图中的比例和字节长度无关!只是为了说明排列的顺序。 前两个entry
就不说了,重点说说后面两个entry
先是2 64 64 字符串
2
很简单因为前一个entry
就占两个字节,所以就是2
- 两个
64
要一起看,虽然都是64
但是含义并不一样,将他们转换成二进制是01 00000001000000
,高两位是01
所以采用的是之前图中ZIP_STR_14B
的编码,剩下就是字符串长度,对应的十进制就是64
。 - 之后就是
64
个字节用来存储真正的字符串
再来是67 192 199
67
因为前一个entry
使用了67
个字节去存储信息,3 + 64。192
换成二进制是11000000
对应到表中编码格式为ZIP_INT_16B
,代表之后用2个字节来存储数字199
就是实际存储的数字,这里得说明下虽然199
可以使用1个字节存储,但是编码表中一个字节存储的是有符号的数字,所以最大只能存储到127
,所以199
只能使用两个字节来存储。
其他编码格式的例子就不举了。
删除
删除元素的话,这样的数据格式就比较麻烦了,因为只能通过寻址的方式从尾节点开始向前遍历,并且在通过解码存储的字符串格式并比较字符串是否相等,相等了之后才能开始删除,因为zset
中是字符串和分数成为一对一起存的,所以删除的时候也得一起删掉。 继续用刚刚的例子,假设现在我要删除xjj
这个元素。 先通过尾节点字节数偏移量,找到尾节点然后比较字符串,不符合的话就一直向前遍历直到找到
xjj
,并且记录下当前节点的偏移量(这里的xjj
是10
)以及待删除的节点的下一个节点的开始地址(黄色箭头)然后就是从下一个节点的开始地址至压缩列表的最后,整个复制并覆盖至offset的位置,并且修改元信息的数据及下一个节点entry的第一部分信息。
然后接着要删除分数节点,因为
offset
已经记录着下一个分数的位置了,所以如法炮制继续删除分数的entry
即可,删完以后就变成了这样:
更新
之前的跳跃表中有提到更新操作,需要查看新分数是否在原区间内,如果在的话就直接更新分数即可。但是压缩列表就不行,因为底层中,字符串和分数的存储是分开的,算做两个节点,所以压缩列表的更新都是走的删除+新增的操作,源码如下:
// t_zset.c
int zsetAdd(robj *zobj, double score, sds ele, int *flags, double *newscore) {
...
if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
unsigned char *eptr;
if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {
// 元素已经存在
...
if (score != curscore) {
// 新旧分数不同,直接就是删除+新增
zobj->ptr = zzlDelete(zobj->ptr,eptr);
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
*flags |= ZADD_UPDATED;
}
return 1;
} else if (!xx) {
...
}
每次将新增的节点添加到末尾其实很简单,但是压缩列表还支持在指定节点后面添加元素,这个问题就复杂了很多,举个例子(zset
不会这么加元素,因为不能把字符串和分数拆开):
原来的该位置节点entry
记录的是分数199
,并且该entry
需要记录下上一个节点的长度67
,但是新加的元素长度未必是67
,所以这个字段需要更新,更可怕的是,更新也就算了,如果新插入的元素超过了254长度,那么记录该字段的部分的字节长度也会跟着变长,从1个字节变成5个字节,那就会导致这个199
分数节点的下一个节点的用于记录199
的长度部分也跟着要改,这就是压缩列表中的连锁更新,连锁更新非常消耗性能,因为需要从插入的节点开始向后寻找,每一个节点,直到找到一个满足不需要扩展字段去更新的那个节点为止,但是这个情况比较极端,只会发生在连续的节点的长度本身就比254小一点点(250-253),因为加了4个字节后长度就会超过254,从而发生下一个节点的更新。
总结
- 压缩列表就是一段连续的内存,或者理解为字节数组。
- 压缩列表有固定的11个字节用于存储元信息。
- 压缩列表在存入字符串(数字)的时候会尝试使用数字进行存储,节约内存。
- 压缩列表针对不同长度的字符串或者数字采用了不同的编码(约定),以最大程度节约内存。
- 若插入元素不在尾节点处,可能会发生连锁更新,性能消耗巨大。
这次更的拖了久了点,打心眼里佩服那些可以不停输出的原创作者,牛逼!