前言
简介
官方文档中关于 ziplist 的介绍如下:
/* The ziplist is a specially encoded dually linked list that is designed
* to be very memory efficient. It stores both strings and integer values,
* where integers are encoded as actual integers instead of a series of
* characters. It allows push and pop operations on either side of the list
* in O(1) time. However, because every operation requires a reallocation of
* the memory used by the ziplist, the actual complexity is related to the
* amount of memory used by the ziplist.
*/
翻译过来是:ziplist 是一个经过特殊编码的双向链表,它的设计目标是节约内存。它可以存储字符串或者整数。其中整数是按二进制进行编码的,而不是字符串序列。它能以 O(1) 的时间复杂度在列表的两端进行 push 和 pop 操作。但是由于每个操作都需要对 ziplist 所使用的内存进行重新分配,所以实际操作的复杂度与 ziplist 占用内存大小有关。
文档中描述 ziplist 是一个 dually linked list
,这句话其实不太容易理解。
因为 ziplist 的设计目标是为了 节约内存,而链表的各项之间需要使用指针连接起来,这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存,这与 ziplist 的设计初衷不符。而且后面我们看了 ziplist 的数据结构就会发现,ziplist 实际上是一块连续的内存。
因此我们可以这么理解:ziplist 是一个特殊的双向链表,特殊之处在于:没有维护双向指针,prev、next,而是存储了上一个 entry 的长度和当前 entry 的长度,通过长度推算下一个元素。
总结一下:
- 压缩列表本质上就是一个字节数组
- 是 Redis 为了节约内存而设计的一种线性结构
- 可以包含多个元素,每个元素可以是一个字节数组或一个整数
用途
Redis 的 有序集合、散列 和 列表 都直接或者间接使用了压缩列表。当有序集合或散列表的元素个数比较少,且元素都是短字符串时,Redis 便使用压缩列表作为其底层数据存储。列表使用快速链表(quicklist)数据结构存储,而快速链表就是双向链表与压缩列表的组合。
比如我们使用下面命令创建个 hash 表,并查看其编码
127.0.0.1:6379> hmset person name wys gender 1 age 24
OK
127.0.0.1:6379> object encoding person
"ziplist"
可以看到,当 hash 表元素个数较少且都是短字符串时,其数据类型确实是 ziplist。
数据结构
压缩列表数据结构
压缩列表是 Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
下图展示了压缩列表的各个组成部分:
图中各字段含义如下:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算 zlend 的位置时使用。 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无需遍历整个压缩列表就可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量,当这个属性的值小于 UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于 UINT16_MAX 时,节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF(十进制 255),用于标记压缩列表的末端。 |
我们来看一个包含三个节点的压缩列表示例:
- 列表 zlbytes 属性的值为 0x50(十进制 80),表示压缩列表的总长为 80 字节。
- 列表 zltail 属性的值为 0x3c(十进制 60),这表示如果我们有一个指向压缩列表起始地址的指针 p,那么只要用指针 p 加上偏移量 60,就可以计算出表尾节点 entry3 的地址。
- 列表 zllen 属性的值为 0x3(十进制 3),表示压缩列表包含三个节点。
假如 char * zl
指向压缩列表的首地址,Redis 可通过以下宏定义实现压缩列表的各个字段的存取操作。
#define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl))) // zl 指向 zlbytes 字段
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t)))) // zl+4 指向 zltail 字段
#define ZIPLIST_ENTRY_TAIL(zl) ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) // zl+zltail 指向尾元素首地址;intrev32fifbe 使得数据存储统一采用小端法
#define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2))) // zl+8 指向 zllen 字段
#define ZIPLIST_ENTRY_END(zl) ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1) // 压缩列表的最后一个字段即为 zllend 字段
了解了压缩列表的数据结构,我们可以很容易的获得压缩列表的字节长度、元素个数等,那么如何遍历压缩列表呢?对于任意一个元素,我们如何判断其存储的是什么类型呢?我们又如何获取字节数组的长度呢?
回答这些问题之前,我们需要了解压缩列表元素的数据结构。
压缩列表元素数据结构
压缩列表元素结构如下图所示:
previous_entry_length
字段表示前一个元素的字节长度,占 1 个或者 5 个字节:- 当前一个元素的长度小于 254 字节时,用 1 个字节表示;
- 当前一个元素的长度大于或等于 254 字节时,用 5 个字节来表示。而此时
previous_entry_length
字段的第一个字节是固定的 0xFE(十进制为 254),后面 4 个字节才真正表示前一个元素的长度。 - 假设已知当前元素的首地址为 p,那么
p-previous_entry_length
就是前一个元素的首地址,从而实现压缩列表从尾到头的遍历。
encoding
字段表示当前元素的编码,记录了节点的 content 字段所保存数据的类型以及长度:- 1 字节、2 字节或者 5 字节长,值的最高位为 00、01 或者 10 的是字节数组编码:这种编码表示节点的 content 属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;
- 1 字节长,值的最高位以 11 开头的是整数编码:这种编码表示节点的 content 字段保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;
content
字段存储节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的 encoding 属性决定。
下表记录了压缩列表的元素编码,表格中的下划线“_”表示留空,而 b、x 等变量则代表实际的二进制数据,为了方便阅读,多个字节之间用空格隔开。
encoding 编码 | encoding 长度 | content 类型 |
---|---|---|
00 bbbbbb(6 比特表示 content 长度) | 1 字节 | 最大长度为 63 的字节数组 |
01 bbbbbb xxxxxxxx(14 比特表示 content 长度) | 2 字节 | 最大长度为 2^14 - 1 的字节数组 |
10_ _ _ _ _ _ aaaaaaaa bbbbbbbb cccccccc dddddddd(32 比特表示 content 长度) | 5 字节 | 最大长度为 2^32 - 1 的字节数组 |
11 00 0000 | 1 字节 | int16 整数 |
11 01 0000 | 1 字节 | int32 整数 |
11 10 0000 | 1 字节 | int64 整数 |
11 11 0000 | 1 字节 | 24 位整数 |
11 11 1110 | 1 字节 | 8 位整数 |
11 11 xxxx | 1 字节 | 没有 content 字段,xxxx 表示 0~12 的整数 |
可以看出,根据 encoding 字段第一个字节的前 2 位,可以判断 content 字段存储的是整数或者字节数组(及其最大长度)。
- 当 content 存储的是字节数组时,后续字节标识字节数组的实际长度;
- 当 content 存储的是整数时,可根据第 3、第 4 位判断整数的具体类型。
- 当 encoding 字段标识当前元素存储的是第 0~12 的立即数时,数据直接存储在 encoding 字段的最后 4 位,此时没有 content 字段。
参照 encoding 字段的编码表格,Redis 预定义了以下常量对应 encoding 字段的各编码类型:
#define ZIP_STR_06B (0 << 6)
#define ZIP_STR_14B (1 << 6)
#define ZIP_STR_32B (2 << 6)
#define ZIP_INT_16B (0xc0 | 0<<4)
#define ZIP_INT_32B (0xc0 | 1<<4)
#define ZIP_INT_64B (0xc0 | 2<<4)
#define ZIP_INT_24B (0xc0 | 3<<4)
#define ZIP_INT_8B 0xfe
现在我们来思考一个问题,前面说到前一个字节的长度大于或者等于 254 的时候,previous_entry_length
就会使用 5 字节来表示。为什么要用 254 这个数字,而不是 255 呢?
这是因为,255 已经被定义为 ziplist 结束标记 zlend 的值了。在 ziplist 的很多操作的实现中,都会根据数据项的第一个字节是不是 255 来判断当前是不是到达 ziplist 的结尾了,因此一个正常数据的第一个字节是不能够取 255 这个值的,否则就冲突了。
结构体
上面我们学习了压缩列表的存储结构,相信我们已经发现了,对于压缩列表的任意元素,获取前一个元素的长度、判断存储的数据类型、获取数据内容都需要经过复杂的解码运算。解码后的结果应该被缓存起来,因此定义了结构体 zlentry
,用于表示解码后的压缩列表元素。
zlentry 结构体结构如下:
typedef struct zlentry {
unsigned int prevrawlensize; /* 存储上一个元素的长度数值所需要的字节数 */
unsigned int prevrawlen; /* 前一个元素的长度 */
unsigned int lensize; /* 存储元素的长度数值所需要使用的字节数,可以是 1、2 或者 5 字节,整数总是使用 1 字节*/
unsigned int len; /* 表示元素的长度 */
unsigned int headersize; /* prevrawlensize + lensize. */
unsigned char encoding; /* 标记是字节数组还是整数数组 */
unsigned char *p; /* 压缩链表以字符串的形式保存,该指针指向当前元素起始位置 */
} zlentry;
我们前面讲到的压缩列表元素的结构只有三个属性:分别是:
previous_entry_length
:前一个元素的字节长度encoding
:当前元素的编码content
:当前元素的内容
实际上这三个属性都是可变的。
previous_entry_length
属性记录了前一个元素的字节长度,却有 1 字节还是 5 字节之分,前一个元素的长度也根据字节数的不同,获取方式也不同。因此我们可以把这个可变属性拆成 2 个属性:
prevrawlensize
:存储前一个元素的长度所需要的字节数prevrawlen
:前一个元素的长度
encoding
属性表示当前元素的编码,记录着当前节点存储数据的类型以及长度。encoding
字段存储数据的长度也是采用变长的方式,可以是 1、2 或者 5 字节,整数恒为 1 字节。encoding
值的前两位则能表示当前元素存储数据的类型。当 encoding
为 11 11 xxxx
时,xxxx
还表示值的大小,此时 content
字段内容为空,所以连同 content
字段一起,我们可以把 encoding
属性拆分成三个属性:
lensize
:存储元素的长度数值所需要的字节数,可以为 1、2 或者 5 字节,整数恒为 1 字节len
:表示元素的长度encoding
:标识是字节数组还是整数数组
那么如何获取当前元素的内容呢,Redis 是这么做的:
*p
:定义一个 char 类型指针,该指针指向当前元素的起始位置headersize
:headersize 表示当前元素的首部长度,即prevrawlensize + lensize
。
通过指针 p 偏移 headersize 即可得到元素内容。
那么 Redis 是如何对压缩列表元素进行解码的呢?答:是通过 zipEntry
函数来解码压缩列表元素,并存于 zlentry
结构体。
zipEntry
函数代码如下:
/* 返回一个包含 entry 所有信息的结构体 */
void zipEntry(unsigned char *p, zlentry *e) {
ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen); /* 解码 previous_entry_length 字段 */
ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len); /* 解码 encoding 字段 */
e->headersize = e->prevrawlensize + e->lensize;
e->p = p;
}
可以看到,解码主要分为两个步骤:
- 解码 previous_entry_length 字段,此时入参 ptr 指向元素首地址
#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do { \
// 设置 prevlensize 的值
ZIP_DECODE_PREVLENSIZE(ptr, prevlensize); \
// 如果 prevlensize 的值为 1,则 ptr 的第一个字节即为上一个节点的长度
if ((prevlensize) == 1) { \
(prevlen) = (ptr)[0]; \
} else if ((prevlensize) == 5) { \
assert(sizeof((prevlen)) == 4); \
// 如果 prevlensize 的值为 5,取后面 4 个字节作为上一节点的长度
memcpy(&(prevlen), ((char*)(ptr)) + 1, 4); \
memrev32ifbe(&prevlen); \
} \
} while(0);
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do { \
// 如果第 1 个字节小于 254
if ((ptr)[0] < ZIP_BIG_PREVLEN) { \
// 如果是 1 个字节 prevlensize 值设置为 1
(prevlensize) = 1; \
} else { \
// 否则设置为 5
(prevlensize) = 5; \
} \
} while(0);
#define ZIP_BIG_PREVLEN 254
- 解码 encoding 字段,此时入参 ptr 指向元素首地址偏移 previous_entry_length 字段长度的位置。
#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do { \
// 获取当前元素编码类型
ZIP_ENTRY_ENCODING((ptr), (encoding)); \
// 如果编码类型为字节数组
if ((encoding) < ZIP_STR_MASK) { \
// encoding == 00000000
if ((encoding) == ZIP_STR_06B) { \
// 存储元素的长度数值所需要的字节数设置为 1
(lensize) = 1; \
// 元素长度为 (ptr)[0] 和 111111 做位运算
(len) = (ptr)[0] & 0x3f; \
// encoding == 10000000
} else if ((encoding) == ZIP_STR_14B) { \
// 存储元素的长度数值所需要的字节数设置为 2
(lensize) = 2; \
// 元素长度为 高八位:(ptr)[0] 和 111111 做位运算 低八位:(ptr)[1]
(len) = (((ptr)[0] & 0x3f) << 8) | (ptr)[1]; \
// encoding == 11000000
} else if ((encoding) == ZIP_STR_32B) { \
// 存储元素的长度数值所需要的字节数设置为 5
(lensize) = 5; \
// 元素长度为后 4 位
(len) = ((ptr)[1] << 24) | \
((ptr)[2] << 16) | \
((ptr)[3] << 8) | \
((ptr)[4]); \
} else { \
panic("Invalid string encoding 0x%02X", (encoding)); \
} \
} else { \
// 是数值类型 存储元素长度只需 1 字节
(lensize) = 1; \
// 获取元素长度
(len) = zipIntSize(encoding); \
} \
} while(0);
// 从 ptr 所指向的字节中提取编码类型,并将其设置为zlentry结构的'encoding'字段
#define ZIP_ENTRY_ENCODING(ptr, encoding) do { \
// 取 ptr 所指向的的字节
(encoding) = (ptr[0]); \
// ptr[0] < 11000000 说明是字节数组,同 11000000 做 与 运算,前两个比特位是字节数组编码类型
if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \
} while(0)
#define ZIP_STR_MASK 0xc0 // 二进制位 11000000
字节数组只根据 ptr[0] 的前 2 个比特即可判断类型,而判断整数类型需要 ptr[0] 的前 4 个比特。zipIntSize
根据当前编码类型,返回该整型的字节数,代码如下:
unsigned int zipIntSize(unsigned char encoding) {
switch(encoding) {
case ZIP_INT_8B: return 1; // ZIP_INT_8B == 1111 1110
case ZIP_INT_16B: return 2; // ZIP_INT_16B == 1100 0000 | 0000 0000 = 1100 0000
case ZIP_INT_24B: return 3; // ZIP_INT_24B == 1100 0000 | 0011 0000 = 1111 0000
case ZIP_INT_32B: return 4; // ZIP_INT_32B == 1100 0000 | 0001 0000 = 1101 0000
case ZIP_INT_64B: return 8; // ZIP_INT_64B == 1100 0000 | 0010 0000 = 1110 0000
}
// ZIP_INT_IMM_MIN 0xf1 11110001
// ZIP_INT_IMM_MAX 0xfd 11111101
if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX)
return 0; /* 4 位立即数 0 ~ 12 */
panic("Invalid integer encoding 0x%02X", encoding);
return 0;
}
连锁更新
前面说过,每个节点的 previous_entry_length
属性都记录了前一个节点的长度:
- 如果前一节点的长度小于 254 字节,那么
previous_entry_length
属性需要用 1 字节长的空间来保存这个长度值。 - 如果前一节点的长度大于等于 254 字节,那么
previous_entry_length
属性需要用 5 字节长的空间来保存这个长度值。
现在,考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于 250 字节到 253 字节之间的节点 e1 至 eN,如下图所示:
因为 e1 至 eN 的所有节点的长度都小于 254 字节,所以记录这些节点的长度只需要 1 字节长的 previous_entry_length
属性,换句话说,e1 至 eN 的所有节点的 previous_entry_length
属性都是 1 字节长的。这时,如果我们将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的表头节点,那么 new 将成为 e1 的前置节点,如下图所示:
因为 e1 的 previous_entry_length
属性仅长 1 字节,它没办法保存新节点 new 的长度,所以程序将对压缩列表执行空间重分配操作,并将 e1 节点的 previous_entry_length
属性从原来的 1 字节长扩展为 5 字节长。
现在,麻烦的事情来了,e1 原本的长度介于 250 字节至 253 字节之间,在为 previous_entry_length
属性新增 4 个字节的空间之后,e1 的长度就变成了介于 254 字节至 257 字节之间,而这种长度使用 1 字节长的 previous_entry_length
属性是没办法保存的。
因此,为了让 e2 的 previous_entry_length
属性可以记录下 e1 的长度,程序需要再次对压缩列表执行空间重分配操作,并将 e2 节点的 previous_entry_length
属性从原来的 1 字节长扩展为 5 字节长。
正如扩展 e1 引发了对 e2 的扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展……为了让每个节点的 previous_entry_length
属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到 eN 位置。
Redis 将这种在特殊情况下产生的连续多次空间扩展操作称之为“连续更新”(cascade update),下图展示了这一过程。
除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。
考虑到上图所示的压缩列表,如果 e1 至 eN 都是大小介于 250 字节至 253 字节的节点,big 节点的长度大于等于 254 字节(需要 5 字节的 previous_entry_length
来保存),而 small 节点的长度小于 254 字节(只需要 1 字节的 previous_entry_length
来保存),那么当我们将 small 节点从压缩列表中删除之后,为了让 e1 的 previous_entry_length
属性可以记录 big 节点的长度,程序将扩展 e1 的空间,并由此引发之后的连锁更新。
因为连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配操作,而每次空间重分配的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N^2)。
要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:
- 首先,压缩列表里要恰好有多个连续的、长度介于 250 字节至 253 字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;
- 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的;
因为以上原因,ziplistPush 等命令的平均复杂度仅为 O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。
基本操作
创建压缩列表
创建压缩列表的代码如下:
unsigned char *ziplistNew(void) {
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE; // ZIPLIST_HEADER_SIZE = zlbytes + zltail + zllen
unsigned char *zl = zmalloc(bytes);
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes); // 初始化 zlbytes 字段
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE); // 初始化 zltail 字段
ZIPLIST_LENGTH(zl) = 0; // 初始化长度为 0
zl[bytes-1] = ZIP_END; // 结尾标识符
return zl;
}
#define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t)) // 压缩列表头部大小
#define ZIPLIST_END_SIZE (sizeof(uint8_t)) // 压缩列表尾部大小
#define ZIP_END 255
创建压缩列表的代码很简单,函数无输入参数,只需要分配初始化存储空间 11(4+4+2+1)个字节,并对 zlbytes、zltail、zllen 和 zlend 字段初始化值,最后返回压缩列表的首地址。
插入元素
插入元素的代码如下:
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { // 参数为 压缩列表首地址 zl,元素插入位置 p,数据内容 s,数据长度 slen,返回压缩列表首地址
return __ziplistInsert(zl,p,s,slen); // 调用 __ziplistInsert 方法
}
unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen; // curlen 表示插入元素前压缩列表的长度,reqlen 表示新插入元素的长度
unsigned int prevlensize, prevlen = 0; // prevlensize 表示前一个字节的长度,prevlen 表示存储前一个字节需要的字节数
size_t offset; // 偏移量,重新分配空间时使用
int nextdiff = 0; // nextdiff 表示插入元素后一个元素长度的变化,取值可能为 0(长度不变),4(长度增加 4)或 -4(长度减少 4)
unsigned char encoding = 0; // encoding 用来存储当前元素编码
long long value = 123456789; // 为了避免警告,初始化其值,该值应该很容易看出我们是不是初始化的
zlentry tail;
/* 找出待插入节点的前置节点长度,包含三种场景 */
if (p[0] != ZIP_END) { // (1):如果 p[0] 不指向列表末尾,说明列表非空,并且 p 指向其中一个节点,所以新插入节点的前置节点长度可以通过节点 p 指向的节点信息中获得
ZIP_DECODE_PREVLEN(p, prevlensize, prevlen); // 通过 ZIP_DECODE_PREVLEN 方法获取 prevlen 长度
} else {
unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl); //获取尾节点位置,用来判断当前压缩列表是否为空列表
if (ptail[0] != ZIP_END) { // (2):如果尾节点指针不指向压缩列表末尾,说明当前压缩列表不空,那么新插入节点的前置节点长度就是尾节点的长度
prevlen = zipRawEntryLength(ptail); // 计算尾节点的长度
}
/*
(3):第三种情况就是 p 指向压缩列表末尾,但是压缩列表中节点为空,所以 p 的前置节点长度为 0,因为 prevlen 初始值即为 0,所以这里就不做处理 了
*/
}
if (zipTryEncoding(s,slen,&value,&encoding)) { // 尝试将数据内容解析为整数。数值存储在变量 value 中,编码存储在变量 encoding 中
reqlen = zipIntSize(encoding); // 解析成功,还需要计算整数所占字节数。reqlen 表示使用整数保存字符串时使用的字节长度
} else {
reqlen = slen; // 解析失败,直接保存字符串,reqlen 即为字符串长度
}
/* 当前节点的字节数 = 编码前置节点长度所需字节数 + 编码当前字符串长度所需字节数 + 当前字符串长度(reqlen) */
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
/* 当插入的位置不是尾部时,我们需要确保新插入节点的下一个节点的 prevlen 字段能够保存该节点的长度 */
int forcelarge = 0; // 在 nextdiff == -4 && reqlen < 4 时候使用,该条件说明,插入元素导致压缩列表变小了,即函数 ziplistResize 内部调用 realloc 重新分配空间小于 zl 指向的空间,此时 realloc 会将多余空间回收,导致数据丢失(丢掉了尾部),所以为了避免这种情况,我们使用 forcelarge 来标记这种情况,并将 nextdiff 置为 0
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0; // nextdiff 表示 entryx+1 元素长度的变化,取值可能为 0(长度不变)、4(长度增加 4)、-4(长度减少 4)
if (nextdiff == -4 && reqlen < 4) { // 在连锁更新的时候会出现
nextdiff = 0; // 将 nextdiff 设置为 0,此时内存重分配不会出现回收空间的情况,造成数据丢失
forcelarge = 1; // 将 forcelarge 标志位置为 1
}
offset = p-zl; // 偏移量,用来表示 p 相对于压缩列表首地址的偏移量。由于重新分配了空间,新元素插入的位置指针 P 会失效,可以预先计算好指针 P 相对于压缩列表首地址的偏移量,待分配空间之后再偏移。
zl = ziplistResize(zl,curlen+reqlen+nextdiff); // 重新分配空间,分配空间大小为 当前压缩列表大小 + 插入元素大小 + entryx+1 元素长度变化
p = zl+offset; // 分配完空间后,计算新插入元素 p 的位置
/* 在必要的时候需要进行内存移动和更新尾部指针位置 */
if (p[0] != ZIP_END) {
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); /* 复制移动元素,为新节点提供位置,
偏移量是 p+reqlen,这个比较好理解,就是将原来的数据移动到新插入节点之后,
curlen-offset-1+nextdiff 移动的长度,是位置 P 之后的所有元素的长度 -1(结束符大小,恒为 0XFF,不需要移动),再加上 nextdiff(下一个元素长度的变化)
p-nextdiff 是表示从哪个位置需要复制移动,因为下一个元素长度会发生变化,所以需要提前预留出这部分空间,就多复制一块空间,到时候覆盖即可
*/
/* 更新 entryX+1 元素的 previous_entry_length 字段 */
if (forcelarge) // entryX+1 元素的 previous_entry_length 字段依旧占 5 个字节,但是 entryNew 元素少于 4 个字节
zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
zipStorePrevEntryLength(p+reqlen,reqlen);
/* 更新 zltail 字段 */
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
zipEntry(p+reqlen, &tail);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
} else {
/* 该元素即为新的尾节点 */
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
}
/* 当 nextdiff != 0 时,下一个节点的长度已经发生改变,因为我们需要判断一下是否需要连锁更新 */
if (nextdiff != 0) {
offset = p-zl;
zl = __ziplistCascadeUpdate(zl,p+reqlen);
p = zl+offset;
}
/* 写入节点前置节点长度 */
p += zipStorePrevEntryLength(p,prevlen);
/* 节点值的长度写入节点 */
p += zipStoreEntryEncoding(p,encoding,slen);
if (ZIP_IS_STR(encoding)) {
memcpy(p,s,slen); // 写入节点值
} else {
zipSaveInteger(p,value,encoding);
}
ZIPLIST_INCR_LENGTH(zl,1); // 更新 zllen 字段
return zl;
}
插入元素可以简要分为 3 个步骤:
- 将元素内容编码;
- 重新分配空间;
- 复制数据;
下面我们来详细介绍每个步骤的实现。
编码
编码即计算 previous_entry_length
字段,encoding
字段和 content
字段的内容。那么如何获取前一个元素的长度呢?此时就需要根据元素的插入位置分情况讨论了。插入元素的位置如下图所示:
- 当压缩列表为空、插入位置为 P0 时,不存在前一个元素,即前一个元素的长度为 0;
- 当插入位置为 P1 时,需要获取 entryX 元素的长度,而 entryX+1 元素的 previous_en 字段存储的就是 entryX 元素的长度,比较容易获取;
- 当插入位置为 P2 时,需要获取 entryN 元素的长度,entryN 是压缩列表的尾元素,计算元素长度时需要将其 3 个字段长度相加。
找出元素插入位置代码如下:
/* 找出待插入节点的前置节点长度,包含三种场景 */
if (p[0] != ZIP_END) { // (1):如果 p[0] 不指向列表末尾,说明列表非空,并且 p 指向其中一个节点,所以新插入节点的前置节点长度可以通过节点 p 指向的节点信息中获得
ZIP_DECODE_PREVLEN(p, prevlensize, prevlen); // 通过 ZIP_DECODE_PREVLEN 方法获取 prevlen 长度
} else {
unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl); //获取尾节点位置,用来判断当前压缩列表是否为空列表
if (ptail[0] != ZIP_END) { // (2):如果尾节点指针不指向压缩列表末尾,说明当前压缩列表不空,那么新插入节点的前置节点长度就是尾节点的长度
prevlen = zipRawEntryLength(ptail); // 计算尾节点的长度
}
/*
(3):第三种情况就是 p 指向压缩列表末尾,但是压缩列表中节点为空,所以 p 的前置节点长度为 0,因为 prevlen 初始值即为 0,所以这里就不做处理 了
*/
}
当插入位置为压缩列表末尾且压缩列表不空时,计算前一个元素长度代码如下所示:
unsigned int zipRawEntryLength(unsigned char *p) {
unsigned int prevlensize, encoding, lensize, len;
ZIP_DECODE_PREVLENSIZE(p, prevlensize);
ZIP_DECODE_LENGTH(p + prevlensize, encoding, lensize, len);
return prevlensize + lensize + len;
}
其中解码的逻辑同上面介绍的类似,这里不详细介绍。
encoding 字段标识的是当前元素存储的数据类型和数据长度。
- 编码时首先尝试将数据内容解析为整数,如果解析成功,则按照压缩列表整数类型编码存储;
- 如果解析失败,则按照压缩列表字节数组类型编码存储。
if (zipTryEncoding(s,slen,&value,&encoding)) { // 尝试将数据内容解析为整数。数值存储在变量 value 中,编码存储在变量 encoding 中
reqlen = zipIntSize(encoding); // 解析成功,还需要计算整数所占字节数。reqlen 表示使用整数保存字符串时使用的字节长度
} else {
reqlen = slen; // 解析失败,直接保存字符串,reqlen 即为字符串长度
}
/* 当前节点的字节数 = 编码前置节点长度所需字节数 + 编码当前字符串长度所需字节数 + 当前字符串长度(reqlen) */
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
上述代码尝试按照整数解析新添加元素的数据内容,数值存储在变量 value 中,编码存储在变量 encoding 中。如果解析成功,还需要计算整数所占字节数。变量 reqlen 最终存储的是当前元素所需空间大小,初始赋值为元素 content 字段所需空间大小,再累加 previous_entry_length 和 encoding 字段所需空间大小。
重新分配空间
由于新插入了元素,压缩列表所需空间增大,因此需要重新分配存储空间。那么空间大小是不是添加元素前的压缩列表长度与新添加元素长度之和呢?并不完全是,因为 previous_entry_length 字段长度是根据前一个字段长度变化的。
我们假设插入元素前,entryX 元素的长度为 128 字节,entryX+1 元素的 previous_entry_length 字段占 1 字节;添加元素 entryNEW,元素长度为 1024 字节,此时 entryX+1 元素的 previous_entry_length 字段需要占 5 个字节,即压缩列表的长度不仅增加了 1024 字节,还要加上 entryX+1 元素扩展的 4 个字节。而 entryX+1 元素的长度可能增加 4 个字节、减少 4 个字节或者不变。压缩列表长度变化如下图所示:
由于重新分配了空间,新元素插入的位置指针 P 会失效,可以预先计算好指针 P 相对于压缩列表首地址的偏移量,待分配空间之后再偏移即可。
我们来看一下代码实现:
/* 当插入的位置不是尾部时,我们需要确保新插入节点的下一个节点的 prevlen 字段能够保存该节点的长度 */
int forcelarge = 0; // 在 nextdiff == -4 && reqlen < 4 时候使用,该条件说明,插入元素导致压缩列表变小了,即函数 ziplistResize 内部调用 realloc 重新分配空间小于 zl 指向的空间,此时 realloc 会将多余空间回收,导致数据丢失(丢掉了尾部),所以为了避免这种情况,我们使用 forcelarge 来标记这种情况,并将 nextdiff 置为 0
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0; // nextdiff 表示 entryx+1 元素长度的变化,取值可能为 0(长度不变)、4(长度增加 4)、-4(长度减少 4)
if (nextdiff == -4 && reqlen < 4) { // 在连锁更新的时候会出现
nextdiff = 0; // 将 nextdiff 设置为 0,此时内存重分配不会出现回收空间的情况,造成数据丢失
forcelarge = 1; // 将 forcelarge 标志位置为 1
}
offset = p-zl; // 偏移量,用来表示 p 相对于压缩列表首地址的偏移量。由于重新分配了空间,新元素插入的位置指针 P 会失效,可以预先计算好指针 P 相对于压缩列表首地址的偏移量,待分配空间之后再偏移。
zl = ziplistResize(zl,curlen+reqlen+nextdiff); // 重新分配空间,分配空间大小为 当前压缩列表大小 + 插入元素大小 + entryx+1 元素长度变化
p = zl+offset; // 分配完空间后,计算新插入元素 p 的位置
nextdiff
用来表示 entryX+1 元素长度变化,取值可能为 0(长度不变)、4(长度增加 4)、-4(长度减少 4);forcelarge
用来标识一种特殊的情况,即nextdiff == -4 && reqlen < 4
,该情况有可能会导致内存重分配时回收内存空间,进而数据丢失。所以标识这一种情况,并做处理。offset
表示指针 P 相对于压缩列表首地址的偏移位置,内存重新分配后计算指针 P 的新的位置时使用。
下面详细说一下 nextdiff == -4 && reqlen < 4
这个情况。
nextdiff == -4 && reqlen < 4
时会发生什么呢?没错,插入元素导致压缩列表所需空间减小了。即函数 ziplistResize 内部调用 realloc 重新分配的空间小于指针 zl 指向的空间。我们知道 realloc 重新分配空间时,返回的地址可能不变(当前位置有足够的内存空间可供分配),当重新分配的空间减少时,realloc 可能会将多余的空间回收,导致数据丢失(压缩列表后面一部分数据丢失了)。因此需要避免这种情况的发生,Redis 采用的方法是重新赋值 nextdiff=0
,同时使用 forcelarge
标记这种情况。
那么,nextdiff=-4 时,reqlen 会小于 4 吗?nextdiff=-4 说明插入元素之前 entryX+1 元素的 previous_entry_length 字段的长度是 5 字节,即 entryX 元素的总长度大于或等于 254 字节,所以 entryNEW 元素的 previous_entry_length 字段同样需要5个字节,即 entryNEW 元素的总长度肯定是大于 5 个字节的,reqlen 又怎么会小于 4 呢?正常情况下是不会出现这种情况的,但是由于存在连锁更新,可能会出现 nextdiff=-4 但 entryX 元素的总长度小于 254 字节的情况,此时 reqlen 可能会小于 4。
数据复制
重新分配空间之后,需要将位置 P 后的元素移动到指定位置,将新元素插入到位置 P。我们假设 entryX+1 元素的长度增加 4(即 nextdiff=4)。我们来看一下数据复制的代码。
/* 在必要的时候需要进行内存移动和更新尾部指针位置 */
if (p[0] != ZIP_END) {
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); /* 复制移动元素,为新节点提供位置,
偏移量是 p+reqlen,这个比较好理解,就是将原来的数据移动到新插入节点之后,
curlen-offset-1+nextdiff 移动的长度,是位置 P 之后的所有元素的长度 -1(结束符大小,恒为 0XFF,不需要移动),再加上 nextdiff(下一个元素长度的变化)
p-nextdiff 是表示从哪个位置需要复制移动,因为下一个元素长度会发生变化,所以需要提前预留出这部分空间,就多复制一块空间,到时候覆盖即可
*/
/* 更新 entryX+1 元素的 previous_entry_length 字段 */
if (forcelarge) // entryX+1 元素的 previous_entry_length 字段依旧占 5 个字节,但是 entryNew 元素少于 4 个字节
zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
zipStorePrevEntryLength(p+reqlen,reqlen);
/* 更新 zltail 字段 */
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
zipEntry(p+reqlen, &tail);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
} else {
/* 该元素即为新的尾节点 */
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
}
数据复制分为两种情况:
- 一种是当前插入的位置不是最后,因此需要进行数据复制;
- 另一种是当前插入的位置为尾节点后面,就不需要进行数据复制了。
我们主要看第一种情况,其中下面这段代码需要仔细理解下:
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
C 库函数 void *memmove(void *str1, const void *str2, size_t n)
从 str2 复制 n 个字符到 str1。
p+reqlen
很好理解,就是将 P 后面的数据复制到 新节点 之后。curlen-offset-1+nextdiff
表示复制的字符数量。即 P 后面元素的长度 + entryX+1 元素长度的变化 nextdiff,再减去 1,因为 结束符恒为 0XFF,不需要移动。p-nextdiff
我个人的理解是首先需要将 P 后面的内容复制过去,然后多复制一块,因为下一个元素的长度空间会发生变化,供下一个元素的 previous_entry_length 使用。
我们再来看看为什么第一次更新尾元素偏移量之后,为什么指向的元素可能不是尾元素呢?因为当 entryX+1 元素就是尾元素时,只需要更新一次尾元素的偏移量;但是当 entryX+1 不是尾元素时且 entryX+1 元素的长度发生了改变时,尾元素偏移量还需要加上 nextdiff 的值。
删除元素
unsigned char *__ziplistDelete(unsigned char *zl, unsigned char *p, unsigned int num) {
unsigned int i, totlen, deleted = 0;
size_t offset; // 偏移量
int nextdiff = 0;
zlentry first, tail;
zipEntry(p, &first); // 解码第一个待删除元素
// 遍历所有待删除元素,同时指针 p 后移
for (i = 0; p[0] != ZIP_END && i < num; i++) {
p += zipRawEntryLength(p); // zipRawEntryLength 用来计算一个元素长度
deleted++;
}
totlen = p-first.p; /* 待删除元素的总长度 */
if (totlen > 0) {
if (p[0] != ZIP_END) { // 删除的最后一个元素 entryN-1 之后的元素 entryN 不是尾元素
nextdiff = zipPrevLenByteDiff(p,first.prevrawlen); // 计算删除的 最后一个元素 entryN-1 之后的元素 entryN 的长度变化量
// 更新 entryN 的 previous_entry_length 字段
p -= nextdiff;
zipStorePrevEntryLength(p,first.prevrawlen);
// 更新 zltail
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen);
zipEntry(p, &tail);
if (p[tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
// 数据复制
memmove(first.p,p,
intrev32ifbe(ZIPLIST_BYTES(zl))-(p-zl)-1);
} else {
/* 不需要复制数据,只需要更新尾节点的偏移量即可 */
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe((first.p-zl)-first.prevrawlen);
}
offset = first.p-zl;
zl = ziplistResize(zl, intrev32ifbe(ZIPLIST_BYTES(zl))-totlen+nextdiff);
ZIPLIST_INCR_LENGTH(zl,-deleted);
p = zl+offset;
if (nextdiff != 0)
zl = __ziplistCascadeUpdate(zl,p);
}
return zl;
}
ziplistDelete 函数可以同时删除多个元素,输入参数 p 指向的是首个待删除元素的地址,num 表示待删除元素数目。
删除元素同样可以简要分为三个步骤:
- 计算待删除元素的总长度;
- 数据复制;
- 重新分配空间;
下面我们分别讨论每个步骤的实现逻辑。
计算待删除元素的总长度
相关代码如下:
zipEntry(p, &first); // 解码第一个待删除元素
// 遍历所有待删除元素,同时指针 p 后移
for (i = 0; p[0] != ZIP_END && i < num; i++) {
p += zipRawEntryLength(p); // zipRawEntryLength 用来计算一个元素长度
deleted++;
}
totlen = p-first.p; /* 待删除元素的总长度 */
即遍历压缩列表,计算待删除元素的长度之和。
数据复制
第 1 步完成之后,指针 first 与指针 p 之间的元素都是待删除的。当指针 p 恰好指向 zlend 字段时,不在需要复制数据,只需要更新尾节点的偏移量即可。
接下来我们再来看另外一种情况,即指针 p 指向的是某一个元素,而不是 zlend 字段。删除元素时,压缩列表所需空间减少,那么减少的量是否仅为待删除元素的总长度呢?那肯定不是了,因为需要更新下一个节点的 previous_entry_length 的值。我们来看一下下面这张图。
删除元素 entryX+1 到元素 entryN-1 之间的 N-X-1 个元素,元素 entryN-1 的长度为 12 字节,因此元素 entryN 的 previous_entry_length 字段占 1 字节;删除这些元素之后,entryX 成为了 entryN 的前一个元素,元素 entryX 的长度为 512 字节,因此元素 entryN 的 previous_entry_length 字段需要占 5 个字节,即删除元素之后的压缩列表的总长度还与元素 entryN 长度的变化量有关。
// 计算元素 entryN 长度的变化量
nextdiff = zipPrevLenByteDiff(p,first.prevrawlen);
// 更新元素 entryN 的 previous_entry_length 字段
p -= nextdiff;
zipStorePrevEntryLength(p,first.prevrawlen);
// 更新 zltail
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen);
zipEntry(p, &tail);
if (p[tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
// 数据复制
memmove(first.p,p,
intrev32ifbe(ZIPLIST_BYTES(zl))-(p-zl)-1);
重新分配空间
offset = first.p-zl;
zl = ziplistResize(zl, intrev32ifbe(ZIPLIST_BYTES(zl))-totlen+nextdiff);
ZIPLIST_INCR_LENGTH(zl,-deleted);
p = zl+offset;
重新分配空间与插入元素的类似,这里略过。
另外,我们想一下,在插入元素时,调用 ziplistResize 函数重新分配空间时,如果重新分配的空间小于指针 zl 指向的空间时,可能会出现问题。而删除元素时,压缩列表的长度肯定是减小的。
因为删除元素时,先复制数据,再重新分配空间,即调用 ziplistResize 函数时,多余的那部分空间存储的数据已经被复制,此时回收这部分空间并不会造成数据的损失。
压缩列表遍历
遍历就是从头到尾(后向遍历)或者从尾到头(前向遍历)访问压缩列表中的每个元素。压缩列表的遍历 API 定义如下,函数输入参数 zl 指向压缩列表首地址,p 指向当前访问元素的首地址; ziplistNext 函数返回后一个元素的首地址,ziplistPrev 返回前一个元素的首地址。
// 向后遍历
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
// 向前遍历
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);
压缩列表每个元素的 previous_entry_length 字段存储的是前一个元素的长度,因此压缩列表的前向遍历相对简单,表达式 p-previous_entry_length 即可获取前一个元素的首地址。后向遍历时,需要解码当前元素,计算当前元素的长度,才能获取后一个元素首地址;
基本原理同上面类似,这里不做赘述。
总结
今天我们学习了 Redis 的 压缩列表(ziplist)数据结构。
我们知道了压缩列表实际上是一个特殊编码的双向链表,其设计目的是 节约内存,其内存地址是连续的,普通链表是使用指针连接的,而指针也会占用内存。
然后我们知道了 Redis 中压缩列表的用途,Redis 中的有序集合、散列、和列表都直接或者间接使用了压缩列表。当有序集合或者散列表元素较少,且元素都是短字符串时,Redis 便使用了压缩列表作为其底层存储。列表使用快速列表(quicklist)数据结构存储,而快速列表就是双向链表和压缩列表的组合。
接着,我们学习了压缩列表的数据结构,是一个连续内存块组成的顺序型数据结构。
其中压缩列表的数据结构定义如下:
<zlbytes> <zltail> <zllen> <entry> <…> <entry> <zlend>
其中:
- zlbytes 记录整个压缩列表占用的字节数;
- zltail 记录压缩列表尾节点距离压缩列表起始地址有多少个字节;
- zllen 记录了压缩列表的元素数量。这个需要特别注意,zllen 最多只能记录 65535 个元素数量,当超过这个值后,该字段就不准确了,需要手动查询计算;
- entry 就是压缩列表的一个元素,一个压缩列表可以有多个元素;
- zlend 是一个特殊值 0xFF(十进制 255),用来标记压缩列表的尾端。
然后我们学习了压缩列表元素的数据结构。其结构定义如下:
<previous_entry_length> <encoding> <content>
其中:
- previous_entry_length 表示前一个节点的元素长度,可能是 1 字节或者 5 字节;
- encoding 表示当前元素的编码,是字符串还是数字,以及存储的内容的长度;
- content 表示当前元素存储的值的内容。
因为这些值都是可变的,我们又定义了一个结构体,用来存储元素解码后的值。
接着我们讲了可能存在连锁更新的问题,因为 previous_entry_length 字段是可变的,所以前一个节点插入或者删除是会影响到后一个节点的 previous_entry_length 字段的,那么当一系列连续的节点的长度都是介于 250 至 253 节点之间,当插入一个元素的长度大于 254 之后,由于 previous_entry_length 的长度都是 1 字节,插入新节点之后,就需要 5 字节来存储前一个节点的长度,那么该节点本身就会变长,继而影响下一个节点,便造成了连锁更新。
不过我们不必担心压缩列表的性能问题,因为这种情况很少见,只有连续多个这样的节点才可能会造成性能问题。
最后我们介绍了压缩列表的一些重要的方法,比如插入节点。插入节点可以简化为三个步骤:
- 编码
- 重新分配空间
- 复制
具体细节可以翻一下上面的源码解读,有助于加深我们对压缩列表结构的理解。
参考文档
- 《Redis 5 设计与源码分析》—— 陈雷编著
- 《Redis 的设计与实现》—— 黄健宏著