- 压缩列表(ziplist)是 Redis 的一种内存紧凑型的数据结构,它是一个字节数组,可以包含任意多个元素,每个元素可以是一个字节数组或一个整数。
- 压缩列表的结构由多个字段组成,包括 zlbytes(压缩列表的字节长度),zltail(压缩列表尾元素的偏移量),zllen(压缩列表的元素数目),entryX(压缩列表存储的若干个元素),zlend(压缩列表的结尾)。
- 压缩列表的每个元素由三个属性组成,分别是 previous_entry_length(前一个元素的长度),encoding(元素的类型和长度),content(元素的值)。
- 压缩列表的 encoding 可以表示字节数组或整数,字节数组的长度可以是小于等于63、16383或4294967295字节,整数的类型可以是4位无符号整数、1字节有符号整数、3字节有符号整数、int16_t、int32_t或int64_t。
- 压缩列表的查找操作是顺序查找,时间复杂度为 O(n) 。
- 压缩列表的更新操作可能会导致内存重分配和连锁更新,影响性能。
- 压缩列表的遍历操作可以从头到尾或从尾到头进行,时间复杂度为 O(n) 。
- 压缩列表适用于元素数量少且长度小的场景,如有序集合或哈希。
前言
为什么会出现ziplist?有两个原因促进它的出现:
- 对于普通的双端列表(linked list),它有指向前后的两个指针,对于存储数据小的情况下,通常指针占用的空间将超过数据占用空间;对于redis这种内存型结构,对于这种情况不能容忍
- Linked list,是一种链表结构,在内存中通常不是连续的,从而导致遍历效率低下
ziplist的出现,解决了以上两个问题
- ziplist 的最大特点,是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,以达到节省内存的目的。
解决问题
- 内存紧凑型列表,节省内存空间、提升内存使用率
应用场景
1)hash字典: 其中,hash使用ziplist条件:
- 数据长度小于64
- 列表长度小于512
可以通过redis.conf中的hash-max-ziplist-entries=512, hash-max-ziplist-value=64调整
2)zset有序集合:
- 数据长度小于64
- 列表长度小于128
可以通过redis.conf中的zset-max-ziplist-entries=128, zset-max-ziplist-value=64调整
存在问题
- 查询效率O(n)
- 内存重分配
- 连锁更新
深入分析
ziplist列表项结构
ziplist列表结构如下:
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
- zlbytes: zl列表总字节数,32bits
- zltail: zl列表最后一个entry的指针,32bits
- zllen: zl列表entry总数,16bits
- entry: zl列表元素
- zlend: zl列表结束标志,8bits
ziplist元素entry包括三部分内容:
- prevlen:前一项的长度。方便快速找到前一个元素地址,如果当前元素地址是x,(x-prelen)则是前一个元素的地址
- encoding:当前项长度信息的编码结果。比较复杂,稍后介绍
- data:当前项的实际存储数据
prevlen: 为了方便查找,每个列表项中都会记录前一项的长度。因为每个列表项的长度不一样,所以如果使用相同的字节大小来记录 prevlen,就会造成内存空间浪费。
假设统一使用 4 字节记录 prevlen,如果前一个列表项只是一个字符串“redis”,长度为 5 个字节,那么我们用 1 个字节(8 bits)就能表示 256 字节长度(2 的 8 次方等于 256)的字符串了。此时,prevlen 用 4 字节记录,其中就有 3 字节是浪费掉了。
因此,ziplist 在对 prevlen 编码时,会先调用 zipStorePrevEntryLength 函数,用于判断前一个列表项是否小于 254 字节。如果是的话,那么 prevlen 就使用 1 字节表示;否则,zipStorePrevEntryLength 函数就调用 zipStorePrevEntryLengthLarge 函数进一步编码。
zipStorePrevEntryLengthLarge 函数会先将 prevlen 的第 1 字节设置为 254,然后使用内存拷贝函数 memcpy,将前一个列表项的长度值拷贝至 prevlen 的第 2 至第 5 字节。最后,zipStorePrevEntryLengthLarge 函数返回 prevlen 的大小,为 5 字节。
encoding:
* The encoding field of the entry depends on the content of the
* entry. When the entry is a string, the first 2 bits of the encoding first
* byte will hold the type of encoding used to store the length of the string,
* followed by the actual length of the string. When the entry is an integer
* the first 2 bits are both set to 1. The following 2 bits are used to specify
* what kind of integer will be stored after this header. An overview of the
* different types and encodings is as follows. The first byte is always enough
* to determine the kind of entry.
*
* |00pppppp| - 1 byte
* String value with length less than or equal to 63 bytes (6 bits).
* "pppppp" represents the unsigned 6 bit length.
* |01pppppp|qqqqqqqq| - 2 bytes
* String value with length less than or equal to 16383 bytes (14 bits).
* IMPORTANT: The 14 bit number is stored in big endian.
* |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes
* String value with length greater than or equal to 16384 bytes.
* Only the 4 bytes following the first byte represents the length
* up to 2^32-1. The 6 lower bits of the first byte are not used and
* are set to zero.
* IMPORTANT: The 32 bit number is stored in big endian.
* |11000000| - 3 bytes
* Integer encoded as int16_t (2 bytes).
* |11010000| - 5 bytes
* Integer encoded as int32_t (4 bytes).
* |11100000| - 9 bytes
* Integer encoded as int64_t (8 bytes).
* |11110000| - 4 bytes
* Integer encoded as 24 bit signed (3 bytes).
* |11111110| - 2 bytes
* Integer encoded as 8 bit signed (1 byte).
* |1111xxxx| - (with xxxx between 0001 and 1101) immediate 4 bit integer.
* Unsigned integer from 0 to 12. The encoded value is actually from
* 1 to 13 because 0000 and 1111 can not be used, so 1 should be
* subtracted from the encoded 4 bit value to obtain the right value.
* |11111111| - End of ziplist special entry.
encoding字段的值取决于entry的内容
- 当entry是一个字符串的时候,前2个bit表示存储字符串长度的类型
- 当entry是一个整型时,前2个bit都被设置为1
具体编码如下:
- |00pppppp| : 1个字节。字符串长度小于等于63个字节(6bit),"pppppp"表示6位无符号长度
- |01pppppp|qqqqqqqq|:2个字节。字符串长度小于等于16383个字节(14bit)
- |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt|:5个字节。当字符串长度大于16384字节时使用,其中后四个字节用来表示长度,最大为2^32-1;第一个字节的后6bit不使用并设置为0
- |11000000|:3 个字节。被编码为int16_t (2 bytes)的整型。
- |11010000|:5 个字节。被编码为int32_t (4 bytes)的整型
- ...
- |11111111|: ziplist entry结束标识
所谓的编码技术,就是指用不同数量的字节来表示保存的信息。在 ziplist 中,编码技术主要应用在列表项中的 prevlen 和 encoding 这两个元数据上。而当前项的实际数据 data,则正常用整数或是字符串来表示
ziplist的查找
ziplist 头尾元数据的大小是固定的,并且在 ziplist 头部记录了最后一个元素的位置,所以,当在 ziplist 中查找第一个或最后一个元素的时候,就可以很快找到。但是,当要查找列表中间的元素时,ziplist 就得从列表头或列表尾遍历才行。
当 ziplist 保存的元素过多时,查找中间数据的复杂度就增加了。更糟糕的是,如果 ziplist 里面保存的是字符串,ziplist 在查找某个元素时,还需要逐一判断元素的每个字符,这样又进一步增加了复杂度。查询效率O(n)
因此,在使用 ziplist 保存 Hash 或 Sorted Set 数据时,都会在 redis.conf 文件中,通过 hash-max-ziplist-entries 和 zset-max-ziplist-entries 两个参数,来控制保存在 ziplist 中的元素个数。
ziplist的更新
除了查找复杂度高以外,ziplist 在插入元素时,如果内存空间不够了,ziplist 还需要重新分配一块连续的内存空间,而这还会进一步引发连锁更新的问题。
从entry的组成可以看到
<entry><prevlen><encoding><data></entry>
内存重分配:ziplist是一个完整内存块,因此,当进行插入或者更新entry的时候,需要进行重新计算内存块的大小并进行重新分配内存
每一个entry都会记录前一个entry的长度,因此从当前位置插入一个entry的时候,prevlen的大小也会随之改变,如果当前entry大小发生了变化,后一个entry的prevlen也需要改变;以此类推,就有可能造成连锁更新
例子,当在一个元素 A 前插入一个新的元素 B 时,A 的 prevlen 和 prevlensize 都要根据 B 的长度进行相应的变化。
那么现在,我们假设 A 的 prevlen 原本只占用 1 字节(即 prevlensize 等于 1),而能记录的前一项长度最大为 253 字节。此时,如果 B 的长度超过了 253 字节,A 的 prevlen 就需要使用 5 个字节来记录(参考prevlen的编码方式),就需要申请额外的 4 字节空间了。不过,如果元素 B 的插入位置是列表末尾,那么插入元素 B 时,就不用考虑后面元素的 prevlen。
源码分析:
创建压缩列表
创建压缩列表的API定义如下,函数无输入参数,返回参数为压缩列表首地址
unsigned char *ziplistNew(void)
创建空的压缩列表,只需要分配初始存储空间11(4+4+2+1)个字节,并对zlbytes、zltail、zllen和zlend字段初始化即可
unsigned char *ziplistNew(void) {
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
ZIPLIST_LENGTH(zl) = 0;
// 结尾标识
zl[bytes-1] = ZIP_END;
return zl;
}
插入元素
压缩列表插入元素的API定义如下,函数输入参数zl表示压缩列表首地址,p指向元素插入位置,s表示数据内容,slen表示数据长度,返回参数为压缩列表首地址
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen)
插入元素可以简要分为3个步骤:① 将元素内容编码;② 重新分配空间;③ 复制数据
1. 编码
编码即计算previous_entry_length字段、encoding字段和content字段的内容。那么如何获取前一个元素的长度呢?此时就需要根据元素的插入位置分情况讨论
- 当压缩列表为空、插入位置为P0时,不存在前一个元素,即前一个元素的长度为0。
- 当插入位置为P1时,需要获取entryX元素的长度,而entryX+1元素的previous_entry_length字段存储的就是entryX元素的长度,比较容易获取。
- 当插入位置为P2时,需要获取entryN元素的长度,entryN是压缩列表的尾元素。计算长度时需要前一个entry的三部分相加
/* Find out prevlen for the entry that is inserted. */
if (p[0] != ZIP_END) {
ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
} else {
unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
if (ptail[0] != ZIP_END) {
prevlen = zipRawEntryLengthSafe(zl, curlen, ptail);
}
}
encoding字段标识的是当前元素存储的数据类型和数据长度。编码时首先尝试将数据内容解析为整数,如果解析成功,则按照压缩列表整数类型编码存储;如果解析失败,则按照压缩列表字节数组类型编码存储。
/* See if the entry can be encoded */
if (zipTryEncoding(s,slen,&value,&encoding)) {
/* 'encoding' is set to the appropriate integer encoding */
reqlen = zipIntSize(encoding);
} else {
/* 'encoding' is untouched, however zipStoreEntryEncoding will use the
* string length to figure out how to encode it. */
reqlen = slen;
}
/* We need space for both the length of the previous entry and
* the length of the payload. */
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
上述尝试按照整数解析新添加元素的数据内容,数值存储在变量value中,编码存储在变量encoding中。如果解析成功,还需要计算整数所占字节数。变量reqlen最终存储的是当前元素所需空间大小,初始赋值为元素content字段所需空间大小,再累加previous_entry_length和encoding字段所需空间大小。
2. 重新分配空间
由于新插入了元素,压缩列表所需空间增大,因此需要重新分配存储空间。那么空间大小是不是添加元素前的压缩列表长度与新添加元素长度之和呢?并不完全是。 插入元素前,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相对于压缩列表首地址的偏移量,待分配空间之后再偏移即可。
int forcelarge = 0;
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
// 这里的判断其实和连锁更新实现策略有关
// 并且这个判断是一次bug修复提交,[详见](https://github.com/wenfh2020/redis/commit/c495d095ae495ea5253443ee4562aaa30681a854?diff=unified))
// 文末讨论该问题
if (nextdiff == -4 && reqlen < 4) {
nextdiff = 0;
forcelarge = 1;
}
/* Store offset because a realloc may change the address of zl. */
// 存储偏移量
offset = p-zl;
newlen = curlen+reqlen+nextdiff;
// 重新分配空间
zl = ziplistResize(zl,newlen);
// 重新定位偏移量到插入位置
p = zl+offset;
curlen表示插入元素前压缩列表的长度;reqlen表示新插入元素的长度;而nextdiff表示entryX+1元素长度的变化,取值可能为0(长度不变)、4(长度增加4)或-4(长度减少4)。
知识点:realloc重新分配空间时,返回的地址可能不变(当前位置有足够的内存空间可供分配),当重新分配的空间减小时,realloc可能会将多余的空间回收,导致数据丢失。因此需要避免这种情况的发生,这里通过重新赋值nextdiff=0,同时使用forcelarge标记这种情况。
这里 nextdiff == -4 && reqlen < 4 判断究竟什么意思?
- 插入元素导致压缩列表所需空间减小了,即函数ziplistResize内部调用realloc重新分配的空间小于指针zl指向的空间。
- 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。这里比较复杂,文末讨论!
3. 数据复制
重新分配空间之后,需要将位置P后的元素移动到指定位置,将新元素插入到位置P。假设entryX+1元素的长度增加4(即nextdiff=4)。 可以看到,位置P后的所有元素都需要移动,移动的偏移量是待插入元素entryNEW的长度,移动的数据块长度是位置P后所有元素的长度之和加上nextdiff的值,数据移动之后还需要更新entryX+1元素的previous_entry_length字段。
/* Subtract one because of the ZIP_END bytes */
// 为什么需要减1?zlend结束恒为0XFF,不需要移动
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
/* Encode this entry's raw length in the next entry. */
// 更新entryX+1的previous_entry_length字段
if (forcelarge)
// entryNEW小于4字节,但是entryX+1的previous_entry_length依然占5字节
zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
zipStorePrevEntryLength(p+reqlen,reqlen);
/* Update offset for tail */
// 更新zltail字段
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
/* When the tail contains more than one entry, we need to take
* "nextdiff" in account as well. Otherwise, a change in the
* size of prevlen doesn't have an effect on the *tail* offset. */
assert(zipEntrySafe(zl, newlen, p+reqlen, &tail, 1));
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
第一次更新尾元素偏移量之后,为什么指向的元素可能不是尾元素呢?因为当entryX+1元素就是尾元素时,只需要更新一次尾元素的偏移量;但是当entryX+1元素不是尾元素且entryX+1元素的长度发生了改变时,尾元素偏移量还需要加上nextdiff的值。
删除元素
压缩列表删除元素的API定义如下,函数输入参数zl指向压缩列表首地址;*p指向待删除元素的首地址;返回参数为压缩列表首地址。
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p)
底层继续调用:
unsigned char *__ziplistDelete(unsigned char *zl, unsigned char *p, unsigned int num)
_ziplistDelete函数可以同时删除多个元素,输入参数p指向的是首个待删除元素的地址;num表示待删除元素数目。
删除元素同样可以简要分为三个步骤:① 计算待删除元素的总长度;② 数据复制;③ 重新分配空间。
1. 计算待删除元素的总长度
// 解码第一个待删除元素
zipEntry(p, &first); /* no need for "safe" variant since the input pointer was validated by the function that returned it. */
// 遍历所有待删除元素,同时指针向后移
for (i = 0; p[0] != ZIP_END && i < num; i++) {
p += zipRawEntryLengthSafe(zl, zlbytes, p);
deleted++;
}
assert(p >= first.p);
// 待删除元素总长度
totlen = p-first.p; /* Bytes taken by the element(s) to delete. */
2. 数据复制
第1步完成之后,指针first与指针p之间的元素都是待删除的。当指针p恰好指向zlend字段时,不再需要复制数据,只需要更新尾节点的偏移量即可。下面分析另一种情况,即指针p指向的是某一个元素,而不是zlend字段。
删除元素时,压缩列表所需空间减小,减小的量是否仅为待删除元素的总长度呢?答案同样是否定的。 删除元素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长度的变化量有关。
/* Storing `prevrawlen` in this entry may increase or decrease the
* number of bytes required compare to the current `prevrawlen`.
* There always is room to store this, because it was previously
* stored by an entry that is now being deleted. */
// 计算entryN长度的变化量
nextdiff = zipPrevLenByteDiff(p,first.prevrawlen);
/* Note that there is always space when p jumps backward: if
* the new previous entry is large, one of the deleted elements
* had a 5 bytes prevlen header, so there is for sure at least
* 5 bytes free and we need just 4. */
// 更新entryN的previous_entry_length字段
p -= nextdiff;
assert(p >= first.p && p<zl+zlbytes-1);
zipStorePrevEntryLength(p,first.prevrawlen);
/* Update offset for tail */
// 更新zltail
set_tail = intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen;
/* When the tail contains more than one entry, we need to take
* "nextdiff" in account as well. Otherwise, a change in the
* size of prevlen doesn't have an effect on the *tail* offset. */
assert(zipEntrySafe(zl, zlbytes, p, &tail, 1));
if (p[tail.headersize+tail.len] != ZIP_END) {
set_tail = set_tail + nextdiff;
}
/* Move tail to the front of the ziplist */
/* since we asserted that p >= first.p. we know totlen >= 0,
* so we know that p > first.p and this is guaranteed not to reach
* beyond the allocation, even if the entries lens are corrupted. */
size_t bytes_to_move = zlbytes-(p-zl)-1;
// 数据复制
memmove(first.p,p,bytes_to_move);
当entryN元素就是尾元素时,只需要更新一次尾元素的偏移量;但是当entryN元素不是尾元素且entryN元素的长度发生了改变时,尾元素偏移量还需要加上nextdiff的值。
3. 重新分配空间
/* Resize the ziplist */
offset = first.p-zl;
zlbytes -= totlen - nextdiff;
zl = ziplistResize(zl, zlbytes);
p = zl+offset;
在插入元素时,调用ziplistResize函数重新分配空间时,如果重新分配的空间小于指针zl指向的空间时,可能会出现问题。
而删除元素时,压缩列表的长度肯定是减小的。因为删除元素时,先复制数据,再重新分配空间,即调用ziplistResize函数时,多余的那部分空间存储的数据已经被复制了,此时回收这部分空间并不会造成数据丢失
遍历压缩列表
遍历就是从头到尾(后向遍历)或者从尾到头(前向遍历)访问压缩列表中的每个元素。
// 向前遍历
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p)
// 向后遍历
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p)
函数输入参数zl指向压缩列表首地址,p指向当前访问元素的首地址;ziplistNext函数返回后一个元素的首地址,ziplistPrev返回前一个元素的首地址。
压缩列表每个元素的previous_entry_length字段存储的是前一个元素的长度,因此压缩列表的前向遍历相对简单,表达式p-previous_entry_length即可获取前一个元素的首地址。
后向遍历时,需要解码当前元素,计算当前元素的长度,才能获取后一个元素首地址
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p) {
((void) zl);
size_t zlbytes = intrev32ifbe(ZIPLIST_BYTES(zl));
/* "p" could be equal to ZIP_END, caused by ziplistDelete,
* and we should return NULL. Otherwise, we should return NULL
* when the *next* element is ZIP_END (there is no next entry). */
if (p[0] == ZIP_END) {
return NULL;
}
p += zipRawEntryLength(p);
if (p[0] == ZIP_END) {
return NULL;
}
zipAssertValidEntry(zl, zlbytes, p);
return p;
}
总结
优点:
内存紧凑型列表,节省内存空间、提升内存使用率。
ziplist的entry的数据size过大会有什么影响?
- 首先,如果列表有很多大对象,那么,每次新增都需要分配较大的内存块,内存分配开销大
- 其次,由于更新操作经常涉及内存拷贝来调整entry的位置,因此,对象太大,处理成本就高
redis提供了参数控制ziplist列表的长度和单个entry的大小
适用场景:
- 列表数量较少
- 元素size较小
缺点:
虽然 ziplist 节省了内存开销,可它也存在两个设计代价:
- 一是不能保存过多的元素,否则访问性能会降低;
- 二是不能保存过大的元素,容易引发连锁更新的问题
题外话:
在插入元素时提到操作:
if (nextdiff == -4 && reqlen < 4) {
nextdiff = 0;
forcelarge = 1;
}
这里理解起来比较费解,具体可以查看github commit记录:Ziplist: insertion bug under particular conditions fixed.
主要是连锁更新引起:
// prevrawlensize插入位置元素长度
// prevlensize当前元素长度
if (cur.prevrawlensize >= prevlensize) {
// 正好相等,那很愉快~
if (cur.prevrawlensize == prevlensize) {
zipStorePrevEntryLength(p, prevlen);
} else {
/* This would result in shrinking, which we want to avoid.
* So, set "prevlen" in the available bytes. */
// 即使空间有多,但也不进行缩容
zipStorePrevEntryLengthLarge(p, prevlen);
}
// 如果插入位置元素长度>=当前元素长度,说明空间是可以容纳的,因此就直接退出连锁更新操作了
break;
}
为什么不进行缩容?作者考虑到,缩容操作导致更多的连锁更新操作,其中涉及到内存重分配和内存拷贝等操作,影响效率。
链接:https://juejin.cn/post/7092788231900495880
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。