做者:张仕华git
Redis使用ziplist是为了节省内存.以zset为例,当zset元素个数少而且每一个元素也比较小的时候,若是直接使用skiplist(能够理解为多层的双向链表),每一个节点的先后指针这些元数据占用空间的比例可能达到50%以上.而ziplist是分配在堆上的一块连续内存,经过必定的编码格式,使数据保存更加紧凑.以下是一个编码为ziplist的zset.github
127.0.0.1:6666> zadd zs 100 'a' (integer) 1 127.0.0.1:6666> zadd zs 200 'b' (integer) 1 127.0.0.1:6666> object encoding zs "ziplist"
ziplist的格式以下图所示:
ziplist各字段解释以下:redis
每一个entry的字段解释以下:curl
注意ziplist中有一个zltail字段是最后一个entry的偏移量,经过该字段定位到最后一个entry后,读取prev_entry_len能够继续向前定位上一个entry的起始地址.也就是说ziplist适合于从后往前遍历.函数
首先看下代码中是如何修复该bug的,而后经过把代码反向修改回来,能够构造示例复现该bug.经过复现过程详细描述该bug的产生过程优化
@@ -778,7 +778,12 @@ unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned cha /* When the insert position is not equal to the tail, we need to * make sure that the next entry can hold this entry's length in * its prevlen field. */ + int forcelarge = 0; nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0; + if (nextdiff == -4 && reqlen < 4) { + nextdiff = 0; + forcelarge = 1; + } /* Store offset because a realloc may change the address of zl. */ offset = p-zl; @@ -791,7 +796,10 @@ unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned cha memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); /* Encode this entry's raw length in the next entry. */ - zipStorePrevEntryLength(p+reqlen,reqlen); + if (forcelarge) + zipStorePrevEntryLength(p+reqlen,reqlen); + else + zipStorePrevEntryLengthLarge(p+reqlen,reqlen); /* Update offset for tail */ ZIPLIST_TAIL_OFFSET(zl) =
能够看到代码中增长了一个判断this
if (nextdiff == -4 && reqlen < 4)
咱们看看nextdiff是如何计算的编码
int zipPrevLenByteDiff(unsigned char *p, unsigned int len) { unsigned int prevlensize; //宏,展开以后根据p[0]处的值计算出prevlensize,若是p[0]<254,prevlensize为1,不然为5 ZIP_DECODE_PREVLENSIZE(p, prevlensize); //zipStorePrevEntryLength函数若是第一个参数为NULL,则根据len字段计算须要的字节数,同理,len<254为1个字节,不然为5个字节 return zipStorePrevEntryLength(NULL, len) - prevlensize; }
如上函数计算nextdiff,能够看出,根据插入位置p当前保存prev_entry_len字段的字节数和即将插入的entry须要的字节数相减得出nextdiff.值有三种类型url
bug修复过程首先判断nextdiff等于-4,即p位置的prev_entry_len为5个字节,而当前要插入的entry的长度只须要1个字节去保存.而后判断reqlen < 4.看到此处可能读者会有疑惑,既然prev_entry_len长度已经为5个字节了,那么新插入的值prev_entry_len+encoding+content字段确定会大于5字节,为何会出现小于4的状况呢?
这种状况确实比较费解,经过下文的构造示例咱们可以看出,在连锁更新的时候,为了防止大量的从新分配空间的动做,若是一个entry的长度只须要1个字节就可以保存,可是连锁更新时若是原先已经为prev_entry_len分配了5个字节,则不会进行缩容操做.
把bug修复代码反向修改回来,编译以后执行以下命令能够致使Redis crash(注意前边是命令编号,下文经过该编号解释Redis中ziplist内存的变化状况):spa
0.redis-cli del list 1.redis-cli rpush list one 2.redis-cli rpush list two 3.redis-cli rpush list AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 4.redis-cli rpush list AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 5.redis-cli rpush list three 6.redis-cli rpush list a 7.redis-cli lrem list 1 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 8.redis-cli linsert list after AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 10 9.redis-cli lrange list 0 -1
前6条命令会往一个list中分别插入'one','two',252个'A',250个'A','three','a'六个元素.此时内存占用状况以下:
每一个小矩形框表示占用内存字节数,大矩形框表示一个个entry,每一个entry有三项,分别为prev_entry_len,encoding和content字段
接着执行第7条命令,内存占用状况如图,表示以下:
删除了第3个entry,此时第4个entry的前一个entry长度由255字节变为5字节(第2个entry此时为第4个entry的前一个entry),因此prev_entry_len字段由占用5个字节变为占用1个字节.参见图中黄框部分.
注意此时会发生连锁更新,由于蓝框部分的prev_entry_len由257字节变为253,也能够更新为1个字节.但Redis中在连锁更新的状况下为了不频繁的realloc操做,这种状况下不进行缩容.
接着执行第8条命令,插入绿框中的数据(见图第3列所示),此时蓝筐中的prev_entry_len是5个字节,绿框中的数据只占用2字节,当将prev_entry_len更新为1字节后,prev_entry_len多余的4字节能够完整的容纳绿框中的数据.
即虽然插入了数据,但realloc以后反而缩小了占用的内存,从而致使ziplist中的数据损坏.
修复这个bug的代码也就很容易理解了,即图中第3列蓝框的prev_entry_len仍然保留为5个字节.
能够进一步构造另外一种状况,即第6步构造为rpush list 10,则此时不会形成redis crash,而是会丢失10这个元素.读者能够画出内存占用图自行分析
经过上边的分析,是否是觉着很难理解?Redis做者也意识到因为连锁更新的存在致使ziplist并非简单易懂.因而提出了一个优化后的替代结构listpack.
listpack主要作了以下两点改进:
总体结构以下:
<tot-bytes> <num-elements> <element-1> ... <element-N> <listpack-end-byte>
每一个entry的结构以下:
<encoding-type><element-data><element-tot-len>
咱们知道ziplist设计为适合从尾部到头部逐个遍历,那么listpack如何实现该功能呢?
首先经过tot-bytes偏移到结尾,而后从右到左读取element-tot-len(注意该字段设计为从右往左读取),这样既实现了尾部到头部的遍历,又没有连锁更新的状况.是否是很巧妙.