redis源码阅读笔记(6)——ziplist

本文深入探讨了Redis中压缩列表(ziplist)的设计原理及其实现细节,包括其内存管理和性能特点。压缩列表是一种特殊的双端链表结构,用于节省内存空间,在处理小量数据时尤为高效。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.高层视角解读

压缩列表(ziplist)是为了尽可能地节约内存而设计的特殊编码双端链表,是列表键和哈希键的底层实现之一。
当一个列表键或者哈希键只包含少量项, 并且每个项要么就是小整数值, 要么就是长度比较短的字符串, 那么 Redis 就会使用压缩列表来做底层实现。

请先看这篇文章
[url]http://www.redisbook.com/en/latest/toc.html[/url] 里的第7章——压缩列表

2.底层代码
请看ziplist.h , ziplist.c, 2000多行代码。

3.(题外话)代码调试
和前面5篇文章提到的源代码相比,ziplist是到目前为止最复杂的一个数据结构了。首先这个数据结构没有一个.h文件来定义它的结构,所以一开始我看的云里雾里的。
如果看代码看不清楚的话,我们不妨写一个测试程序,再加上断点一步一步调试。
本人采用了cygwin(安装gcc,gdb)加上eclipse cdt来调试。
另外由于原版的redis代码我不知道怎么在eclipse里编译通过,所以重新改了一下,把和ziplist相关的代码抽取了出来,可以编译通过。可下载附件。
eclipse里面有个memory view,相当好,可以看某个内存地址里的值。我们可以通过观察内存地址里的值,从而理解ziplist的数据结构。

4.测试代码以及跟踪结果
测试代码在附件压缩包里的ziplist.c

int main(void)
{
unsigned char *zl = ziplistNew();
char * str1 = "abcde";
char * str2 = "ABC";
char * str3 = "10";
zl = ziplistPush(zl, (unsigned char*)str1, strlen(str1), ZIPLIST_TAIL);
zl = ziplistPush(zl, (unsigned char*)str2, strlen(str2), ZIPLIST_TAIL);
zl = ziplistPush(zl, (unsigned char*)str3, strlen(str3), ZIPLIST_HEAD);
return 0;
}


ziplist代码理解的难点就是,h文件中是没有这个数据结构的定义的(不像别的sds,adlist啊是看得到定义的)。另外zlentry只是一个中间临时存储变量,最后写到ziplist里就完全成了直接写连续的一块内存。

ZIPLIST_ENTRY_HEAD和ZIPLIST_ENTRY_TAIL分别指向第一个节点和最后一个节点,这个和java里的LinkedList还有redis里的adlist如出一辙,方便正向遍历和反向遍历。

ziplistNew以后如图所示,这个时候还一个元素都没有。
[img]http://dl2.iteye.com/upload/attachment/0098/9893/ed3f8398-8b8a-3d57-949e-0cbbe00e9ae4.png[/img]

加入了第1个节点"abcde"以后
[img]http://dl2.iteye.com/upload/attachment/0098/9895/f0c32593-4caa-3e07-bd19-ce98320243d5.png[/img]

加入了第2个节点"ABC"以后(加在最后)
加入了第3个节点"10"以后(加在最前面)
[img]http://dl2.iteye.com/upload/attachment/0098/9897/aa32617b-8d97-3d01-8a07-29e03c0120b2.png[/img]

5. 新增元素带来的扩容以及元素移动
每次加入节点时需要扩容,调用的是realloc函数,可以理解成这个函数会保留原来数组的内容,只是把数组容量扩大。当然,如果找不到足够的连续空间供扩容的话,就会另辟一块新的内存地址,并将原来数组的内容复制过去。
元素的加入有2种情况。
第一种是元素加到列表的末尾,这样前面的元素无需移动,将新元素放入新扩容的地址就行了。
第二种情况就是加到列表的开头或中间,这样后面的元素都要往后移动。我们可以想象是往ArrayList中间插入了一个元素,然后后面的元素一个一个往后挪动。这样有没有性能问题?

答案是没有性能问题的。注意最后一张图里的蓝线,代表着内存的移动,调用了一次memmove函数,用memmove的话只是内存拷贝,速度还是很快的。之所以不用memcpy是因为memcpy复制的两块内存不能有交叉。而memmove允许两块内存有重合的地方。

6.内存拷贝性能测试
测试代码可以见附件压缩包里的memmovetest.c

#include <stdio.h>
#include <string.h>
#include <time.h>

#define ROUND 100000000

int main(void)
{
int N = 100;
char src[N];
char dest[N];
memset(src, 'A', N);

char * pSrc = src;
char * pDest = dest;

int i, j;

//1. loop
double timeStart = (double)clock();
for (j = 0; j < ROUND; j++)
{
for (i = 0; i < N; i++)
{
*pDest++ = *pSrc++;
}

pSrc = src;
pDest = dest;
}
double timeFinish = (double)clock();
printf("operate time(loop): %.2fms\n", (timeFinish-timeStart));

memset(dest, 0, N);


//2. memcpy
timeStart = (double)clock();
for (j = 0; j < ROUND; j++)
{
memcpy(dest, src, N);
}
timeFinish = (double)clock();
printf("operate time(memcpy): %.2fms\n", (timeFinish-timeStart));

memset(dest, 0, N);

//3. memmove
timeStart = (double)clock();
for (j = 0; j < ROUND; j++)
{
memmove(dest, src, N);
}
timeFinish = (double)clock();
printf("operate time(memmove): %.2fms\n", (timeFinish-timeStart));

return 0;
}


3种方法测试下来,普通的循环复制最慢,需要20多秒,而另外两种memcpy和memmove都只要2秒左右就完成了复制。
这是我在一台Win7 64位系统上测试的结果

operate time(loop): 23836.00ms
operate time(memcpy): 1701.00ms
operate time(memmove): 1279.00ms

这是我在另一台Win7 32位系统上测试的结果

operate time(loop): 26707.00ms
operate time(memcpy): 2762.00ms
operate time(memmove): 2886.00ms

其中memcpy和memmove的差别不明显,我测得的结果是32位系统上memcpy更快,而64位系统上memmove更快。原因不明?但都是一个数量级的。

至于原因么,因为memcpy和memmove的复制都是以word pointer为单位的,而自己写的那个是以byte pointer为单位的。另外,[url=https://zh.wikipedia.org/wiki/%E5%8D%95%E6%8C%87%E4%BB%A4%E6%B5%81%E5%A4%9A%E6%95%B0%E6%8D%AE%E6%B5%81]SIMD[/url]指令的运用也使得memcpy和memmove的执行得到了加速。

7.为何不用双向链表
通过图我们可以发现,双向链表(adlist)每个节点需要一个previous指针和一个next指针,这样2个指针就需要8个字节,而压缩列表(ziplist)一般情况下只需要2个字节,记录前一个元素的长度就可以了。

8.编码方式
如果可以转为数字,则转为数字。
会首先调用util.c里面的string2ll函数,尝试将string转为long long型,并以数字型存储。如"123"可以转成123,如果不能转,则以字符串形式存储。
比如前面的测试代码前面两个节点"abcde", "ABC"以字符串形式存储,而最后加入的第3个节点"10"可以转为整数10,这样只需要1个字节就能存10这个整数了,如果用字符串形式存储的话要2个字节(2个char)。(将内存榨干到极限啊!)(最后真正存入的是241+10=251)
解释:
当整数是介于 0 和 12 之间的值时,采用如下编码
1111xxxx 1 字节
11110000(2)=240(10)

9.#define的应用
通过阅读ziplist.c,可以看到有许多预处理器指令#define

9.1 类函数宏(function-like macro),
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))

解释一下这个语法,c语言里面#define是允许写成函数的样子,且带有参数的,这种写法称之为类函数宏(function-like macro),宏的参数则用括号括起来。
以上语法类似于定义了一个函数ZIPLIST_BYTES,参数是zl,然后后面则是函数的实现。

9.2 一行写不下可以在一行的结尾加"\"表示下一行是连着的。
#define ZIPLIST_INCR_LENGTH(zl,incr) { \
if (ZIPLIST_LENGTH(zl) < UINT16_MAX) \
ZIPLIST_LENGTH(zl) = intrev16ifbe(intrev16ifbe(ZIPLIST_LENGTH(zl))+incr); \
}


9.3 为何使用类函数宏
用意就是用空间换时间,因为函数调用是有额外开销的,还是为了提速啊。另外宏有一个取巧的地方,就是不用检查参数类型,不管你是啥类型,都接受。
比如

#define MAX (X, Y) ((X) > (Y) ? (X): (Y))

甭管你X,Y是int还是double型,都接受。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值