一、压缩列表(ziplist)的定义与实现
压缩列表是一种为了节约内存而开发的顺序数据结构,是列表及哈希对象的底层实现之一。当列表中只有少量的列表元素,且列表元素是小整数值或者较短的字符串,Redis就会用压缩列表作为列表对象的底层实现;当哈希对象中只包含少量键值对,而且键值都是小整数或者长度较短的字符串,Redis就用压缩列表作为哈希的实现。
src中的ziplist.c和ziplist.h包含了压缩列表的定义:
/* Create a new empty ziplist.
*
* 创建并返回一个新的 ziplist
*
* T = O(1)
*/
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;
returnzl;
}
一个压缩列表由以下部分组成:
1)ZIPLIST_BYTES:记录压缩列表占用的内存字节
2)ZIPLIST_TAIL_OFFSET:记录压缩列表尾节点剧烈表头起始地址的偏移值,这样就可以在O(1)的时间内访问表尾的节点
3)ZIPLIST_LENGTH:记录压缩列表节点的数目,节点结构在下面介绍
4)ZIP_END:表示压缩列表末端,在ziplist.c开头宏定义了此属性,表示255,即0XFF
压缩列表节点结构如下:
/*
* 保存 ziplist 节点信息的结构
*/
typedefstruct 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;
zlentry中的prevrawlen属性保存着前一个节点的长度,如果要访问前一个节点,可以根据当前节点的地址-prevrawlen即可得到前一节点的地址,当前一节点长度小于ZIP_BIGLEN(默认254字节),那么prevrawlen长度为1字节,否则其长度为5字节:第一字节设置为0XFE(254)已做标识,之后一字节表示真正的长度,逻辑如下:
staticunsigned int zipPrevEncodeLength(unsigned char *p, unsigned int len) {
// 仅返回编码 len 所需的字节数量
if (p == NULL) {
return (len < ZIP_BIGLEN) ? 1 :sizeof(len)+1;
// 写入并返回编码 len 所需的字节数量
} else {
// 1 字节
if (len < ZIP_BIGLEN) {
p[0] = len;
return 1;
// 5 字节
} else {
// 添加 5 字节长度标识
p[0] = ZIP_BIGLEN;
// 写入编码
memcpy(p+1,&len,sizeof(len));
// 如果有必要的话,进行大小端转换
memrev32ifbe(p+1);
// 返回编码长度
return 1+sizeof(len);
}
}
}
lensize属性记录了当前节点值的长度;
encoding保存着当前节点的编码方式,可以有字节数组编码方式与整数编码方式,所有的编码定义如下:
/*Different encoding/length possibilities */
/*
* 字符串编码和整数编码的掩码
*/
#defineZIP_STR_MASK 0xc0
#defineZIP_INT_MASK 0x30
/*
* 字符串编码类型
*/
#defineZIP_STR_06B (0 << 6)
#defineZIP_STR_14B (1 << 6)
#defineZIP_STR_32B (2 << 6)
/*
* 整数编码类型
*/
#defineZIP_INT_16B (0xc0 | 0<<4)
#defineZIP_INT_32B (0xc0 | 1<<4)
#defineZIP_INT_64B (0xc0 | 2<<4)
#defineZIP_INT_24B (0xc0 | 3<<4)
#defineZIP_INT_8B 0xfe
二、压缩列表中的重要逻辑
连锁更新
prevrawlensize属性保存着前一节点的长度,而且其属性的大小与前一节点的长度有关。那么当压缩列表中有多个连续的、长度介于250-253字节之间的节点z1至zn,因为n个节点的长度均小于254,那么prevrawlensize属性的长度只需1字节。那么考虑一种情况:这个时候在z1前面插入一个长度大于254的节点,那么此时z1中prevrawlensize的长度将变成5字节,这就导致z1的长度也超过了254,那么就会影响到节点z2,z3直至zn,这会导致一系列连锁的更新。
Redis将这种连续多次控件扩展的操作称为“连锁更新”(cascade update),代码逻辑如下:
staticunsigned 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" hasnot changed. */
// 后续节点编码当前节点的空间已经足够,无须再进行任何处理,跳出
// 可以证明,只要遇到一个空间足够的节点,
// 那么这个节点之后的所有节点的空间都是足够的
if (next.prevrawlen == rawlen) break;
if (next.prevrawlensize | | 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 inshrinking, 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(N2),这看似是一个严重的性能问题。然而真正造成性能问题的概率很低:只要不是多个连续,长度介于250-253字节之间的节点的情况连锁更新就不会被引发——即使真的引起连锁更新,只要更新的节点数目不多,依然不会引起性能问题。