精髓总结(抄)
- 压缩列表是一种为节约空间而实现的线性数据结构,本质上是字节数组。
- 压缩列表元素可以为整数或字符串。
- 压缩列表在快速列表、列表对象和哈希对象中都有使用。
- 压缩列表添加(平均复杂度O(n))与删除节点(平均复杂度O(n)),可能会触发连锁更新(平均复杂度O(n^2)),因为触发机率不高所以不影响性能。
- 因为节点存储数据可能为字符串,而字符串匹配为O(n)复杂度,所以压缩列表查找节点平均复杂度为O(n^2)。
压缩列表的构成
压缩列表由:
- 总字节长度(4字节)
- 尾节点偏移量(4字节)
- 节点数量(2字节)
节点以及值为255的特殊结束符(1字节)组成,通过列表的开始地址向后偏移尾节点偏移量个字节,可以以O(1)时间复杂度获取尾节点信息。
源码分析
这是创建压缩列表的函数和相关宏
#define ZIP_END 255 /* 末尾设为255 */
#define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl))) // 获取zl总字节长度指针
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t)))) //获取zl尾节点偏移量指针
#define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2))) //获取zl节点数量指针
#define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t)) //zl头的长度
#define ZIPLIST_END_SIZE (sizeof(uint8_t)) //zl尾巴的长度
|uint32|uint32|uint16|.......entrys.....|uint8|
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;
}
列表节点构成
一段数组,从前往后分别是
- 前面节点长度信息,占1-5个字节,如果前面长度小于254就一个字节,如果大于254就5个字节,且第一个填254
- 本节点编码信息,占一个字节,表明数据是string or int
- 长度信息,如果存的是整数,0位,如果存的是string,1-8位,存int的话,直接根据encoding得到int类型就知道数据多长了,不需要再记录长度信息
-
本节点数据,长度不定
注意一点!在实际存储中,并不是用zlentry结构体存节点,而是用一段连续数组的形式,做函数操作的时候调用zipEntry()把指向数组的指针p转化成zlentry结构体形式,相当于是一个解压
下面看源码
typedef struct zlentry {
// 前一节点长度信息的长度,(有点绕,就是本节点prevrawlen的长度)
unsigned int prevrawlensize;
// 前一节点长度
unsigned int prevrawlen;
// 当前节点长度信息长度(len的长度)
unsigned int lensize;
// 当前节点长度
unsigned int len;
// 当前节点头部信息长度
unsigned int headersize;
// 当前节点数据编码
unsigned char encoding;
unsigned char *p;
} zlentry;
// 相关宏,几个解析的j函数
// 解析节点指针ptr[0~4],得到要用几个字节来存ptr前面节点的长度信息
// 1个字节,或者5个字节
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do { \
if ((ptr)[0] < ZIP_BIG_PREVLEN) { \
(prevlensize) = 1; \
} else { \
(prevlensize) = 5; \
} \
} while(0)
// 解析prevlensize后,解析prevlen
// 前一节点长度大于254时,使用5个字节保存前一节点的长度信息。
// 首个字节固定为254,后续的4个字节用来存储长度信息
#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do { \
ZIP_DECODE_PREVLENSIZE(ptr, prevlensize); \
if ((prevlensize) == 1) { \
(prevlen) = (ptr)[0];
// \
} else { /* prevlensize == 5 */ \
(prevlen) = ((ptr)[4] << 24) | \
((ptr)[3] << 16) | \
((ptr)[2] << 8) | \
((ptr)[1]); \
} \
} while(0)
// 解析encoding,如果小于1100 0000,就&11000000后得到encoding
#define ZIP_ENTRY_ENCODING(ptr, encoding) do { \
(encoding) = ((ptr)[0]); \
if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \
} while(0)
//把指针解压成结构体,上面的4部分依次解析
static inline void zipEntry(unsigned char *p, zlentry *e) {
// 解析前节点长度信息
ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen);
// 解析本节点编码信息,编码信息从p[prevrawlensize]开始,占一个字节
ZIP_ENTRY_ENCODING(p + e->prevrawlensize, e->encoding);
//解析本节点长度信息
ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len);
//填写本节点数据
assert(e->lensize != 0); /* check that encoding was valid. */
e->headersize = e->prevrawlensize + e->lensize;
e->p = p;
}
encoding规则
encoding表明了数组里长度信息的长度
ZIP_STR_MASK = 11000000
大于ZIP_STR_MASK的都是整数,小于ZIP_STR_MASK是字符串
看一下解析长度的宏定义
#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do { \
if ((encoding) < ZIP_STR_MASK) { \
if ((encoding) == ZIP_STR_06B) { \
(lensize) = 1; \
(len) = (ptr)[0] & 0x3f; \
} else if ((encoding) == ZIP_STR_14B) { \
(lensize) = 2; \
(len) = (((ptr)[0] & 0x3f) << 8) | (ptr)[1]; \
} else if ((encoding) == ZIP_STR_32B) { \
(lensize) = 5; \
(len) = ((ptr)[1] << 24) | \
((ptr)[2] << 16) | \
((ptr)[3] << 8) | \
((ptr)[4]); \
} else { \
(lensize) = 0; /* bad encoding, should be covered by a previous */ \
(len) = 0; /* ZIP_ASSERT_ENCODING / zipEncodingLenSize, or */ \
/* match the lensize after this macro with 0. */ \
} \
} else { \
(lensize) = 1; \
if ((encoding) == ZIP_INT_8B) (len) = 1; \
else if ((encoding) == ZIP_INT_16B) (len) = 2; \
else if ((encoding) == ZIP_INT_24B) (len) = 3; \
else if ((encoding) == ZIP_INT_32B) (len) = 4; \
else if ((encoding) == ZIP_INT_64B) (len) = 8; \
else if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX) \
(len) = 0; /* 4 bit immediate */ \
else \
(lensize) = (len) = 0; /* bad encoding */ \
} \
} while(0)
可以看到,如果是字符串,长度信息有6位,14位,32位情况
- 如果编码是ZIP_STR_06B,说明长度信息6位,1字节
- ZIP_STR_14B,说明长度信息14位,2字节,左移操作就是相加
- 其他同理
对于整数,1位就可以表示长度了,因为整数最大就64位,8字节,长度信息最大填个8就行,sizeof(8) = 1
压缩列表相关操作
插入
ziplist提供了两个插入接口,push和insert
- push选择插入到头或者尾巴
- insert插入到中间,插入节点至p指针指向元素前方
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where) {
unsigned char *p;
p = (where == ZIPLIST_HEAD) ? ZIPLIST_ENTRY_HEAD(zl) : ZIPLIST_ENTRY_END(zl);
return __ziplistInsert(zl,p,s,slen);
}
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
//插入节点至p指针指向元素前方
return __ziplistInsert(zl,p,s,slen);
}
两者都是调用__ziplistInsert 【可以把非接口函数写成__xxx的形式】
一段一段看这个函数
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);
}
}
第一步,解析插入节点前面节点的长度,因为要插在p前面,插入前p存的就是前面节点长度信息
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;
}
第二步,尝试能不能编码成整数类型,reqlen初始化成数据的长度zipTryEncoding是编码函数
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
第三步,计算需要存储前节点长度和encoding的长度,
- zipStorePrevEntryLength的答案是1或者5
- 如果编码是整数,zipStoreEntryEncoding的答案是1,字符串则返回1+表示字符串的长度,到此,新节点的长度就全算出来了
int forcelarge = 0;
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
if (nextdiff == -4 && reqlen < 4) { //1
nextdiff = 0;
forcelarge = 1;
}
第四步zipPrevLenByteDiff(p,reqlen)算的是:
p用来编码插入新节点长度信息需要的长度 - p原来用来编码前一节点的长度
返回可能是0, 4, -4
代码1处,nextdiff = -4 说明p.prevrawlensize多了4字节,reqlen<4说明新节点正好填补上,不用向右偏移腾位置,这一步说到底就是计算偏移量
- forcelarge代表着本来可以使用1个字节表示prevLength的情况下使用5个字节表示
- 该操作在插入的时候只会让ziplist占用的bytes增加而不会减少
offset = p-zl;
newlen = curlen+reqlen+nextdiff;
zl = ziplistResize(zl,newlen);
p = zl+offset;
第五步,给zl重新分配内存,p指向新内存地址的对应节点(插入在p前面)
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
第六步,memmove()挪位置,给新插入节点腾地方,p被挪到了p+reqlen
if (forcelarge)
zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
zipStorePrevEntryLength(p+reqlen,reqlen);
第七步,在新p的位置上填写插入节点的长度信息
-zipStorePrevEntryLengthLarge(p, len)是填写大节点的,把p[0]填上254,len填在p[1-4]上
-zipStorePrevEntryLength(p, len) 根据实际大小填写长度信息
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
第8步,更新zl记录的尾巴偏移量,加上reqlen即可
assert(zipEntrySafe(zl, newlen, p+reqlen, &tail, 1));
检查下偏移后的p是否越界
if (nextdiff != 0) {
offset = p-zl;
zl = __ziplistCascadeUpdate(zl,p+reqlen);
p = zl+offset;
}
如果nextdiff不等于0,说明插入节点后的节点p的长度变了,后续节点都要更新,这叫连锁更新。下个小节详细介绍
/* Write the entry */
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);
return zl;
【这个时候p指向插入节点位置】
- 写插入节点的前面节点长度信息
- 写p的数据
- 增加zl节点数
连锁更新
在zl中,如果前一节点长度发生变化,可能会引起后一节点的长度发生变化(如果前一节点的长度之前小于254,变更后大于254,则后一节点的 前一节点长度信息占用字节会从1个字节变为5个字节)。考虑一种极端情况,插入节点后续的节点长度都是介于250~253之间,此时插入一个节点长度大于254的节点,会引发连锁更新,后面的节点全都要变长
redis中连锁更新的函数
// 从p开始连锁更新zl ,p是处理好的,不用改变长度
unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p)
我们逐步看一下怎么进行连锁更新的
zipEntry(p, &cur); /* no need for "safe" variant since the input pointer was validated by the function that returned it. */
firstentrylen = prevlen = cur.headersize + cur.len;
prevlensize = zipStorePrevEntryLength(NULL, prevlen);
prevoffset = p - zl;
p += prevlen;
第一步,把p解析道cur,算出所有需要改变节点前面那个节点的信息,存下来,然后p指向第一个需要更新的节点p1,如下图:
while (p[0] != ZIP_END) {
// 本轮循环p指向的节点信息更新到cur里,并检查是否越界
assert(zipEntrySafe(zl, curlen, p, &cur, 0));
// 发现记录正确,当前节点记录的前面节点的长度真的等于前面节点的长度
// 说明当前节点以及后续都无需更新,停止记录
if (cur.prevrawlen == prevlen) break;
// 如果当前节点用来存前面节点长度信息的长度够用,就把当前节点的前面长度信息更新成正确的,并且确保不会缩小当前节点长度
// 更新后,后续也无需更新了,停止记录
if (cur.prevrawlensize >= prevlensize) {
if (cur.prevrawlensize == prevlensize) {
zipStorePrevEntryLength(p, prevlen);
} else {
zipStorePrevEntryLengthLarge(p, prevlen);
}
break;
}
// 确保解析正确
assert(cur.prevrawlen == 0 || cur.prevrawlen + delta == prevlen);
rawlen = cur.headersize + cur.len; // 当前节点的长度,下一轮作为前一节点更新前长度
prevlen = rawlen + delta; // 当前节点更新后的长度,下一轮循环是根据这个来判断当前节点需要的长度
prevlensize = zipStorePrevEntryLength(NULL, prevlen); // 记录当前节点更新后需要的长度 (1/5)
prevoffset = p - zl; // 当前节点更新前偏移,最后指向最后一个需要更新的节点的偏移量
p += rawlen; //下一个节点的位置
extra += delta; // 最后需要扩大的总长度,每个+4字节
cnt++; //要更新的节点数
}
第二步,记录总共有多少节点要更新,并且更新一些变量后续使用
这里需要注意这些变量
- prevoffset: 每一轮更新成当前节点更新前偏移,最后指向最后一个需要更新的节点的偏移量
- extra:最后需要扩大的总字节数,数值上等于需要更新的节点数乘4
- cnt:需要更新的节点数
if (tail == zl + prevoffset) { //如果更新前的tail(最后一个节点)正好是最后一个节点,更新tail偏移量时要减去4
/* When the the last entry we need to update is also the tail, update tail offset
* unless this is the only entry that was updated (so the tail offset didn't change). */
if (extra - delta != 0) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra-delta);
}
} else {
/* Update the tail offset in cases where the last entry we updated is not the tail. */
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
}
判断最后一个要更新的节点是不是tail(最后一个节点),来更新zl的tail偏移
如果是tail,tail偏移量要减4,画个图就明白了
可以看到,上面的tail后移了3个delta,下面的后移2个delta
offset = p - zl;
zl = ziplistResize(zl, curlen + extra);
p = zl + offset;
memmove(p + extra, p, curlen - offset - 1);
p += extra;
这个时候,p指向的是第一个不需要更新的节点
这里为zl重新计算并分配了内存,并将后面不用更新的节点都往后移动
查找
ziplist中搜索元素是通过下面这个函数,参数在注释里:
// zl:列表头指针
// p:从p开始搜索
// vstr:查找的字符串
// vlen:查找字符串的长度
// skip:要跳过几个节点
unsigned char *ziplistFind(unsigned char *zl, unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip)
函数不难,看一下就知道啥意思,需要注意的是:
- 如果节点存储的是string,直接 memcmp(q, vstr, vlen)判定是否相同
- 如果节点存储的是int,zipTryEncoding(vstr, vlen, &vll, &vencoding)把查找的char编码成long long存在vll,再long long ll = zipLoadInteger(q, e.encoding);解码节点,最后比较vll和ll
删除
zl提供了两个接口删除节点
// 1.删除*p,并且把p指向删除节点后面的节点,为了入参传入**p
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p)
// 2.删除下标在 [index,index+num)区间的节点
unsigned char *ziplistDeleteRange(unsigned char *zl, int index, unsigned int num)
删除的最后要连锁更新
if (nextdiff != 0)
zl = __ziplistCascadeUpdate(zl,p);
Q&A
为什么要压缩列表?
参考
https://juejin.cn/post/6844904012819464206#heading-2
http://www.lucienxian.top/2019/08/16/redis%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0%E2%80%94%E2%80%94%E5%8E%8B%E7%BC%A9%E5%88%97%E8%A1%A8/