压缩列表
压缩列表是列表键和哈希键的底层实现之一,当一个列表键值包含少量列表项,并且每个列表项,要么是小整数,要么是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现.
7.1 压缩列表的构成
zlbytes 4字节 | zltail 4字节 | zllen 2字节 | entryX 列表字节 | entryX 列表字节 | … | zlend 1字节 |
---|
//zlbytes + zltail + zllen
#define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t))
unsigned char *ziplistNew(void) {
// ZIPLIST_HEADER_SIZE 是 ziplist 表头的大小
// 1 字节是表末端 ZIP_END 的大小
unsigned int bytes = ZIPLIST_HEADER_SIZE+1;
// 为表头和表末端分配空间
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;
}
zlbytes 用来记录整个压缩列表的内存字节数.
zltail 用来记录压缩列表节点距离起始位置有多少字节.
zlen 用来揭露压缩列表的节点数量,但该字段只有2个字节,所以当字节数超过65535时,节点的真是数量需要遍历整个压缩表表才能知晓.
entryX 节点的内容,真正用来储存内容的独享.
zlend 标记作用,0XFF用于标记压缩列表的末端.
7.2 压缩列表节点的构成
typedef struct zlentry {
// prevrawlen :前置节点的长度
// prevrawlensize :编码 prevrawlen 所需的字节大小
unsigned int prevrawlensize, prevrawlen;
// len :当前节点值的长度
// lensize :编码 len 所需的字节大小
unsigned int lensize, len;
// 当前节点 header 的大小
// 等于 prevrawlensize + lensize
unsigned int headersize;
// 当前节点值所使用的编码类型
unsigned char encoding;
// 指向当前节点内容的指针
unsigned char *p;
} zlentry;
节点由以下部分组成
prevrawlensize+ prevrawlen | lensize + len | headersize | encoding | p |
---|
书上的结构与最新的代码还是有区别,但大体思路是不变的,一部分来指向前一个节点,一部分来表示当前节点的编码方式,一部分来指向节点的真正内容
7.2.1 prevrawlensize+ prevrawlen|lensize + len|headersize
用来记录前一个节点的长度,本节点的起始指针减去该长度就位前一个节点的指针.
如果前一个节点的长度小于254字节, previous_entry_length 的长度为1个字节,剩余保存的是前一个字节的长度
如果前一个字节长度大于254字节, previous_entry_length 属性的长度将为5个字节,第一个字节设置为0xFE,而之后的四个字节则用于保存前一个节点的长度
7.2.2 encoding
encoding 用来记录当前编码类型,表示content中记录的是类型的元素规则如下
/* Different encoding/length possibilities */
/*
* 字符串编码和整数编码的掩码
*/
#define ZIP_STR_MASK 0xc0
#define ZIP_INT_MASK 0x30
/*
* 字符串编码类型
*/
#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
ZIP_STR_06B (0 << 6) 表示长度小于等于 63字节 的字节数组
ZIP_STR_14B (1 << 6) 表示长度小于等于 16383字节 的字节数组
ZIP_STR_32B (2 << 6) 表示长度小于等于 4294967295字节 的字节数组
ZIP_INT_16B (0xc0 | 0<<4) int16_t类型的整数
ZIP_INT_32B (0xc0 | 1<<4) int32_t类型的整数
ZIP_INT_64B (0xc0 | 2<<4) int64_t类型的整数
ZIP_INT_24B (0xc0 | 3<<4) 24位由符号整数
ZIP_INT_8B 0xfe
7.2.3 content = p
在最新代码中, p 单纯只用来存放内容
7.3 连锁更新
当多个节点大小处于254时,当插入一个大于254节点时,插入节点的后一个节点无法用1个字节来进行前一个节点长度的描述,所以需要进行更新操作,当一连串的节点都需要更新时,就会发生连锁更新.
/* When an entry is inserted, we need to set the prevlen field of the next
* entry to equal the length of the inserted entry. It can occur that this
* length cannot be encoded in 1 byte and the next entry needs to be grow
* a bit larger to hold the 5-byte encoded prevlen. This can be done for free,
* because this only happens when an entry is already being inserted (which
* causes a realloc and memmove). However, encoding the prevlen may require
* that this entry is grown as well. This effect may cascade throughout
* the ziplist when there are consecutive entries with a size close to
* ZIP_BIGLEN, so we need to check that the prevlen can be encoded in every
* consecutive entry.
*
* 当将一个新节点添加到某个节点之前的时候,
* 如果原节点的 header 空间不足以保存新节点的长度,
* 那么就需要对原节点的 header 空间进行扩展(从 1 字节扩展到 5 字节)。
*
* 但是,当对原节点进行扩展之后,原节点的下一个节点的 prevlen 可能出现空间不足,
* 这种情况在多个连续节点的长度都接近 ZIP_BIGLEN 时可能发生。
*
* 这个函数就用于检查并修复后续节点的空间问题。
*
* Note that this effect can also happen in reverse, where the bytes required
* to encode the prevlen field can shrink. This effect is deliberately ignored,
* because it can cause a "flapping" effect where a chain prevlen fields is
* first grown and then shrunk again after consecutive inserts. Rather, the
* field is allowed to stay larger than necessary, because a large prevlen
* field implies the ziplist is holding large entries anyway.
*
* 反过来说,
* 因为节点的长度变小而引起的连续缩小也是可能出现的,
* 不过,为了避免扩展-缩小-扩展-缩小这样的情况反复出现(flapping,抖动),
* 我们不处理这种情况,而是任由 prevlen 比所需的长度更长。
* The pointer "p" points to the first entry that does NOT need to be
* updated, i.e. consecutive fields MAY need an update.
*
* 注意,程序的检查是针对 p 的后续节点,而不是 p 所指向的节点。
* 因为节点 p 在传入之前已经完成了所需的空间扩展工作。
*
* T = O(N^2)
*/
static unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) {
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), rawlen, rawlensize;
size_t offset, noffset, extra;
unsigned char *np;
zlentry cur, next;
// T = O(N^2)
while (p[0] != ZIP_END) {
// 将 p 所指向的节点的信息保存到 cur 结构中
cur = zipEntry(p);
// 当前节点的长度
rawlen = cur.headersize + cur.len;
// 计算编码当前节点的长度所需的字节数
// T = O(1)
rawlensize = zipPrevEncodeLength(NULL,rawlen);
/* Abort if there is no next entry. */
// 如果已经没有后续空间需要更新了,跳出
if (p[rawlen] == ZIP_END) break;
// 取出后续节点的信息,保存到 next 结构中
// T = O(1)
next = zipEntry(p+rawlen);
/* Abort when "prevlen" has not changed. */
// 后续节点编码当前节点的空间已经足够,无须再进行任何处理,跳出
// 可以证明,只要遇到一个空间足够的节点,
// 那么这个节点之后的所有节点的空间都是足够的
if (next.prevrawlen == rawlen) break;
if (next.prevrawlensize < rawlensize) {
/* The "prevlen" field of "next" needs more bytes to hold
* the raw length of "cur". */
// 执行到这里,表示 next 空间的大小不足以编码 cur 的长度
// 所以程序需要对 next 节点的(header 部分)空间进行扩展
// 记录 p 的偏移量
offset = p-zl;
// 计算需要增加的节点数量
extra = rawlensize-next.prevrawlensize;
// 扩展 zl 的大小
// T = O(N)
zl = ziplistResize(zl,curlen+extra);
// 还原指针 p
p = zl+offset;
/* Current pointer and offset for next element. */
// 记录下一节点的偏移量
np = p+rawlen;
noffset = np-zl;
/* Update tail offset when next element is not the tail element. */
// 当 next 节点不是表尾节点时,更新列表到表尾节点的偏移量
//
// 不用更新的情况(next 为表尾节点):
//
// | | next | ==> | | new next |
// ^ ^
// | |
// tail tail
//
// 需要更新的情况(next 不是表尾节点):
//
// | next | | ==> | new next | |
// ^ ^
// | |
// old tail old tail
//
// 更新之后:
//
// | new next | |
// ^
// |
// new tail
// T = O(1)
if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
}
/* Move the tail to the back. */
// 向后移动 cur 节点之后的数据,为 cur 的新 header 腾出空间
//
// 示例:
//
// | header | value | ==> | header | | value | ==> | header | value |
// |<-->|
// 为新 header 腾出的空间
// T = O(N)
memmove(np+rawlensize,
np+next.prevrawlensize,
curlen-noffset-next.prevrawlensize-1);
// 将新的前一节点长度值编码进新的 next 节点的 header
// T = O(1)
zipPrevEncodeLength(np,rawlen);
/* Advance the cursor */
// 移动指针,继续处理下个节点
p += rawlen;
curlen += extra;
} else {
if (next.prevrawlensize > rawlensize) {
/* This would result in shrinking, which we want to avoid.
* So, set "rawlen" in the available bytes. */
// 执行到这里,说明 next 节点编码前置节点的 header 空间有 5 字节
// 而编码 rawlen 只需要 1 字节
// 但是程序不会对 next 进行缩小,
// 所以这里只将 rawlen 写入 5 字节的 header 中就算了。
// T = O(1)
zipPrevEncodeLengthForceLarge(p+rawlen,rawlen);
} else {
// 运行到这里,
// 说明 cur 节点的长度正好可以编码到 next 节点的 header 中
// T = O(1)
zipPrevEncodeLength(p+rawlen,rawlen);
}
/* Stop here, as the raw length of "next" has not changed. */
break;
}
}
return zl;
}
连锁更新的复杂度较高,空间最坏复杂度为 O(N) ,再执行迁移操作的话,连锁更新的醉话复杂度为 O(N^2).
但是实际上可能不会这么可怕,该原因造成性能问题的几率是很低的.
首先,压缩列表里要恰好有多个连续的,长度介于250字节至253之间的节点连锁更新才有可能被引发,这种情况并不多见.
其次,即使出现连锁更新,但是只要更新的节点数量不多,就不会对性能造成任何影响.
综上,ziplistpush 命令的平均复杂度仅为 O(N),可以放心使用
总结
压缩列表是一种为节约内存而开发的顺序性数据结构.
压缩列表被用作列表键和哈希键的底层实现之一.
压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值.
添加新节点到压缩列表,或者从压缩列表中删除节点,都可能还会引发连锁更新操作,但是概率不高,可以放心使用