压缩列表(ziplist)是列表键和哈希键的底层实现之一。当列表键只包含少量列表项,并且每个列表项要么是小整数值,要么是长度较短的字符串时;或者当哈希键只包含少量键值对,并且每个键值对的键和值要么是小整数值,要么是长度较短的字符串时,那么Redis就会使用压缩列表来做为列表键或哈希键的底层实现。
压缩列表是Redis为了节约内存而开发的,可用于存储字符串和整数值。它是一个顺序型数据结构,由一系列特殊编码的连续内存块组成。一个压缩列表可以包含任意多个结点(entry),每个entry的大小不定,每个entry可保存一个字符串或一个整数值。
ziplist的相关实现在都在ziplist.c中。
一:ziplist结构
ziplist的结构如下:
zlbytes是一个uint32_t类型的整数,表示整个ziplist占用的内存字节数;
zltail是一个uint32_t类型的整数,表示ziplist中最后一个entry的偏移量,通过该偏移量,无需遍历整个ziplist就可以确定尾结点的地址;
zllen是一个uint16_t类型的整数,表示ziplist中的entry数目。如果该值小于UINT16_MAX(65535),则该属性值就是ziplist的entry数目,若该值等于UINT16_MAX(65535),则还需要遍历整个ziplist才能得到真正的entry数目;
entryX表示ziplist的结点,每个entry的长度不定;
zlend是一个uint8_t类型的整数,其值为0xFF(255),表示ziplist的结尾。
在ziplist.c中,定义了一系列的宏,可以分别获取ziplist中存储的各个属性,比如:
#define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl))) #define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t)))) #define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2))) #define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t)) #define ZIPLIST_ENTRY_HEAD(zl) ((zl)+ZIPLIST_HEADER_SIZE) #define ZIPLIST_ENTRY_TAIL(zl) ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) #define ZIPLIST_ENTRY_END(zl) ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)
ZIPLIST_BYTES获取ziplist中的zlbytes属性;ZIPLIST_TAIL_OFFSET获取ziplist的zltail属性;ZIPLIST_LENGTH获取ziplist的zllen属性
ZIPLIST_ENTRY_HEAD得到ziplist头结点的地址;ZIPLIST_ENTRY_TAIL得到ziplist中尾节点的首地址,ZIPLIST_ENTRY_END得到ziplist结尾字节zlend的地址。注意,ziplist中所有的属性值都是以小端的格式存储的。因此取得ziplist中保存的属性值后,还需要对内存做字节翻转才能得到真正的值。intrev32ifbe就是在大端系统下对内存进行字节翻转的函数。
二:entry结构
每个压缩列表节点都由previous_entry_length、encoding和content三部分组成。
previous_entry_length表示前一个entry的字节长度,根据该字段值就可以从后向前遍历ziplist。previous_entry_length字段长度可以是1字节,也可以是5字节。如果前一个entry长度小于254字节,则该字段只占用一个字节;如果前一个entry长度大于等于254字节,则该字段占用5个字节:第一个字节置为0xFE(254),之后的4个字节保存entry的实际长度(小端格式存储)。用0xFE(254)这个值作为分界点,是因为0xFF(255)被用作ziplist的结束标志,一旦扫描到0xFF,就认为ziplist结束了。
content字段保存节点的实际内容,它可以是一个字符串或者整数,值的类型和长度由encoding属性决定。
encoding字段记录了节点的content所保存的数据类型及长度。如果entry中保存的是字符串,则encoding字段的前2个二进制位可以是00、01和10,分别表示不同长度类型的字符串,剩下的二进制位就表示字符串的实际长度;如果entry中的内容为整数,则encoding字段的前2个二进制位都为11,剩下的2个二进制位表示整数的类型。encoding的形式如下:
00pppppp,1字节,表示长度小于等于63(2^6- 1) 字节的字符串;
01pppppp|qqqqqqqq,2 字节,表示长度小于等于16383(2^14 - 1) 字节的字符串;
10______|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt,5字节,表示长度小于等于4294967295(2^32- 1) 字节的字符串
11000000,1字节,表示int16_t类型的整数; 11010000,1字节,表示int32_t类型的整数;
11100000,1字节,表示int64_t类型的整数; 11110000,1字节,表示24位有符号整数;
11111110,1字节,表示8位有符号整数; 1111xxxx,1字节,表示0到12之间的值。使用这一编码的节点没有相应的oontont属性,因为xxxx就可以表示0到12之间的值。因为0000和1110不可用,所以xxxx的取值从0001到1101,也就是1到13之间的值。因此需要从xxxx减去1,才能得到实际的值。
注意,所有的整数都是以小端的格式存储的
三:连锁更新
每个节点的previous_entry_length字段记录了前一个节点的长度。如果前一节点的长度小于254字节,那么previous_entry_length字段占用1个字节;如果前一节点的长度大于等于254字节,那么previous_entry_length字段占用5个字节。
考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN。因所有节点的长度都小于254字节,所以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字节之间,e1的previous_entry_length字段变成5个字节后,e1的长度就大于等于254了。从而e2的previous_entry_length字段,也需要从原来的1字节长扩展为5字节长。因此,需要再次对压缩列表执行空间重分配操作,并将e2节点的previous_entry_length属性从原来的1字节长扩展为5字节长。
以此类推,为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update)。除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。
因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。尽管连锁更新的复杂度较高,但它真正造成性能间题的几率是很低的。
首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能触发。在实际中,这种情况并不多见;其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响,比如对三五个节点进行连锁更新是绝对不会影响性能的;因此,ziplistPush等函数的平均复杂度仅为O(N)。在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。
ziplist变动时,previous_entry_length字段长度可能需要从1字节扩展为5字节,从而会引起连锁更新,也可能需要从5字节收缩为1字节。这就有可能会发生抖动现象,也就是节点的previous_entry_length字段,不断的扩展和收缩的现象。Redis中,为了避免这种现象,允许previous_entry_length字段在需要收缩的情况下,保持5字节不变。
四:代码
Redis中的ziplist实现,并未涉及难以理解的算法。但是因为ziplist本身的编码需求较多,因而代码需要处理各种细节,初看之下比较繁杂,分析之后,其实很容易理解。
1:连锁更新
下面就是处理连锁更新的代码,zl指向一个ziplist,p指向其中第一个不需要更新的节点(第一个已经更新过的节点),其后续的节点可能需要更新:
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; while (p[0] != ZIP_END) { cur = zipEntry(p); rawlen = cur.headersize + cur.len; rawlensize = zipPrevEncodeLength(NULL,rawlen); /* Abort if there is no next entry. */ if (p[rawlen] == ZIP_END) break; 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". */ offset = p-zl; extra = rawlensize-next.prevrawlensize; zl = ziplistResize(zl,curlen+extra); 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. */ 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. */ memmove(np+rawlensize, np+next.prevrawlensize, curlen-noffset-next.prevrawlensize-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. */ zipPrevEncodeLengthForceLarge(p+rawlen,rawlen); } else { zipPrevEncodeLength(p+rawlen,rawlen); } /* Stop here, as the raw length of "next" has not changed. */ break; } } return zl; }
首先获取ziplist当前的长度curlen;然后从p开始轮训,获取p指向节点的总长度rawlen,以及编码该字节长度需要的字节数rawlensize;如果p指向的结点没有后继结点,直接退出返回;否则,next表示p的后继节点,next节点的previous_entry_length字段值为next.prevrawlen,该字段长度是next.prevrawlensize。如果next.prevrawlen与rawlen相等,则表示next节点的前继节点的长度未发生变化,直接退出返回;
如果next.prevrawlensize小于rawlensize,表示next节点的前继节点的长度,原来小于254字节,现在大于等于254字节了。因此next节点的previous_entry_length字段需要扩展长度,扩展的字节数extra为rawlensize-next.prevrawlensize,利用ziplistResize扩展ziplist的内存空间。注意,扩容前要保存p的偏移量,扩容后利用该偏移量,可以重新得到p的位置。
如下图,分别表示扩容前和扩容后的情况:
如果next节点不是尾节点,则需要更新ziplist的zltail属性;如果next是尾结点,因为next之前的内容没有变化,因此无需更新zltail属性;
然后开始移动内存,移动的内容是,从next节点的previous_entry_length字段之后的内存开始,到ziplist末尾字节zlend之前的内容:memmove(np+rawlensize, np+next.prevrawlensize, curlen-noffset-next.prevrawlensize-1);然后更新next中的previous_entry_length字段为rawlen;然后p指向next节点,依次遍历下一个节点;
如果next.prevrawlensize大于rawlensize,表示next节点的前继节点的长度,原来大于等于254字节,现在小于254字节了。为了避免“抖动”,调用zipPrevEncodeLengthForceLarge保持next节点的previous_entry_length字段长度不变,并强制编码为rawlen,然后退出返回;
如果next.prevrawlensize等于rawlensize,表示next节点的前继节点的长度,原来小于254字节,现在还是小于254字节,或者原来大于等于254字节,现在还是大于等于254字节。这种情况下,直接将next节点的previous_entry_length字段编码为rawlen,然后退出返回。