前言
基于带中文注释的redis3.0源码阅读,github地址:https://github.com/huangz1990/redis-3.0-annotated。redis的基础数据结构ziplist的源码位于ziplist.c。
一、数据结构
ziplist实际上没有具体的数据结构,而是一块连续的内存。redis提供对这块内存操作的api,从而达到数据增删改查的功能。
为了达到这个目的,我们需要对这块内存做出一些规定,不能让数据随便放在里面,否则就找不到了。一个空ziplist在内存中按下表所示组织
- zlbytes表示这个ziplist占用了多少字节,zlbytes大小为4字节,这里zlbytes是11(二进制1011)
- zltail表示ziplist的尾部的偏移量,zltail大小为4字节,这里zltail值是10(二进制1010)
- zllen表示ziplist元素个数,大小占2字节
- zlend表示ziplist尾部,大小1字节,值是固定的1111 1111
zlbytes | zltail | zllen | zlend | |
---|---|---|---|---|
值 | 1011 | 1010 | 0 | 1111 1111 |
大小 | 4 bytes | 4 bytes | 2 bytes | 1 bytes |
在zllen后面存放的是ziplist的元素entry。
zlbytes | zltail | zllen | entry | entry | entry | zlend |
---|---|---|---|---|---|---|
4 bytes | 4 bytes | 2 bytes | ? | ? | ? | 1 bytes |
其中每个entry内按顺序分为三部分
- 第一部分编码了前一个entry相关信息,可以通过它找到前一个节点,相当于链表的prev指针
- 第二部分编码当前节点数据的编码和长度信息,可以通过它找到下一个节点,相当于next指针,也可以用它找到节点存储的数据
- 第三部分是数据本身
redis将前一个entry的信息按自定义编码规则存放到entry的第一部分内存,将当前数据信息放到第二部分内存。现在看起来还比较抽象,下面通过实际案例来讲解。
二、编码规则
每个entry中涉及编码的就是前一个entry的信息和当前数据信息
1.前一个entry信息编码
每个entry内的第一个部分的内存主要存放前一个entry占多大字节,这里分两种情况
1、前一个entry所占字节小于254,这种情况第一部分的内存大小只占1个字节,该字节中的数据即为前一个entry所占字节大小
2、前一个entry所占字节大于等于254,1个字节存不下了,这种情况第一部分的内存大小占5个字节,第一个字节固定为254(二进制11111110),后面4个字节为前一个entry所占字节大小
可以看到,为了尽量节省内存,redis分情况来编码前一个entry的信息,使用ziplist时应该尽量存储较小的元素,否则这个5个字节的编码信息都赶上一个指针了。
因此获取前一个entry长度信息的算法如下
1、获取当前entry内第一个字节,看看是否小于254
2、小于254则直接返回第一个字节的数据
3、否则返回后面4个字节的数据
代码如下
// ZIP_BIGLEN = 254,该函数获取entry内第一个字节,判断是否小于254
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do { \
// ptr[0]是entry内第一个字节的数据,ptr指向entry
// prvlnsiz表示编码前一个entry长度所需字节大小
if ((ptr)[0] < ZIP_BIGLEN) { \
(prevlensize) = 1; \
} else { \
(prevlensize) = 5; \
} \
} while(0);
#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do { \
// 该函数计算前一个entry的长度,保存到prevlen中
// prevlensize保存编码前一个entry信息所需字节大小
/* 先计算被编码长度值的字节数 */ \
ZIP_DECODE_PREVLENSIZE(ptr, prevlensize); \
\
/* 再根据编码字节数来取出长度值 */ \
if ((prevlensize) == 1) { \
//直接保存第一个字节数据
(prevlen) = (ptr)[0]; \
} else if ((prevlensize) == 5) { \
assert(sizeof((prevlensize)) == 4); \
//保存后面4个字节的数据
memcpy(&(prevlen), ((char*)(ptr)) + 1, 4); \
memrev32ifbe(&prevlen); \
} \
} while(0);
2.当前数据信息
根据entry内第一个字节的数据,如果小于254,那么下一个字节就是我们说的第二部分内存,否则4个字节后的内存才是。
第二部分的内存按以下编码规则读取:
先看前两位:
1、前两位为00,那么entry存储的数据是长度小于等于63字节的字符串,且第二部分内存仅占1字节,如 |00pppppp|,后6位的pppppp就是data的字节长度
2、前两位为01,那么entry存储的数据是长度小于等于16383字节的字符串,第二部分内存占2字节,如 |01pppppp|qqqqqqqq|,后14位是data的字节长度
3、前两位为10,那么entry存储的数据是长度大于等于16384字节的字符串,第二部分内存占5字节,如 |10______|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt|,使用后面4个字节存储data的字节长度
4、前两位为11,那么entry存储的数据是整数,且第二部分内存仅占1字节
4.1、该字节数据为|11000000|表示data为int16_t 类型的整数,长度为 2 字节
4.2、该字节数据为|11010000|表示data为int32_t 类型的整数,长度为 4 字节
4.3、该字节数据为|11100000|表示data为int64_t 类型的整数,长度为 8 字节
4.4、该字节数据为|11110000|表示data为24 位(3 字节)长的整数
4.5、该字节数据为|11111110|表示data为8 位(1 字节)长的整数
4.6、除了上面几种情况,还有|1111xxxx|,因为11110001-11111101的连续数据还没被用到,因此redis用这几种情况表示data为0-12的整数(这样就没有后面的data内存了),以此进一步减少空间。
其实这个字节内还有很多情况没用到,如果多写几个if else是不是可以存比12更大一点的数据[doge]。或者改下编码方式11000000-11000101表示上面5种情况,11000110-11111111用来存data,不清楚redis使用这种编码方式的原因。。。
3.编码案例
到这里我们了解了ziplist在内存中的分布情况以及它的编码规则。下面来看个案例:我们在ziplist中插入字符串’hello world\0’和整数0.
- 第一个entry的prevlenencode是0表示该entry是ziplist第一个entry。
- dataencode为00001100,前两位00表示data的长度所需字节小于等于63,后6位001100表示data的长度为12.
- 我们知道,一个字符占1个字节,所以在data的12个字节中,存放的是’hello world\0’的每一个字符的ascii编码,注意字符串要以’\0’结尾
- 第二个entry的prevlenencode为14,占1个字节,表示前一个entry的整个长度占14字节,这样我们拿到entry2的指针就可以通过14这个偏移量找到前一个entry
- 第二个entry的dataencode为11110001,前两位11表示data是一个整数,而且属于上面的4.6情况,data的数据直接保存在dataencode中,data的值为0.
zllen | 1_prevlenencode | 1_dataencode | data | 2_prevlnencode | 2_dataencode | data | zltail |
---|---|---|---|---|---|---|---|
0 | 00001100 | ‘hello world\0’ | 00001110 | 11110001 | |||
1 byte | 1 byte | 12 bytes | 1 byte | 1 byte | 0 byte |
三、增删改查
了解了ziplist的内存结构和编码规则,我们应该对如何增删改查有一定的思路了
- 查找的过程:从ziplist头开始,解码出每个entry的data进行对比,通过prevlenencode和dataencode解码出正确的偏移量移动指针找到下一个entry,直到找到为止。
- 添加元素的过程:1.查找要插入的位置;2.计算插入元素的prevlenencode+dataencode+data所需字节数;3.扩大ziplist内存区域大小,把当前位置的内存向后移动为插入entry腾出空间;4.把插入entry的信息写入内存;5.因为插入entry可能导致后面节点的prevlenencode发生变化,所以要调整后面节点的prevlenencode,可能会有扩容和缩容(prevlenencode可能从1字节变5字节或5字节变1字节)
- 删除元素的过程:1.查找要删除的元素;2.找到删除元素的下一个元素,把内存向前移动;3.调整后面entry的prevlenencode
- 修改元素和删除差不多:也是找到元素,然后修改,最后调整后面entry的prevlenencode
插入
下面提供插入元素的代码加深理解。其他操作的就不贴了,理解了插入后别的应该都能理解。
/*
* 根据指针 p 所指定的位置,将长度为 slen 的字符串 s 插入到 zl 中。
* 函数的返回值为完成插入操作之后的 ziplist
* T = O(N^2)
*/
static unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
// curlen记录当前 ziplist 的长度,reqlen是插入节点所需字节长度,prevlen是前一个节点长度
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen, prevlen = 0;
size_t offset;
int nextdiff = 0;
unsigned char encoding = 0;
long long value = 123456789; /* initialized to avoid warning. Using a value
that is easy to see if for some reason
we use it uninitialized. */
// zlentry是表示entry的结构体
// ziplist不会存储这样的结构体,而是在使用时解码出来
zlentry entry, tail;
// 找prevlen
if (p[0] != ZIP_END) {
// 如果 p[0] 不指向列表末端,说明列表非空,并且 p 正指向列表的其中一个节点
// 那么取出 p 所指向节点的信息,并将它保存到 entry 结构中
// 然后用 prevlen 变量记录前置节点的长度
// (当插入新节点之后 p 所指向的节点就成了新节点的前置节点)
// T = O(1)
entry = zipEntry(p); // 解码zlentry,按照我们上面的编码规则来解码
prevlen = entry.prevrawlen;
} else {
// 如果 p 指向表尾末端,那么程序需要检查列表是否为:
// 1)如果 ptail 也指向 ZIP_END ,那么列表为空;
// 2)如果列表不为空,那么 ptail 将指向列表的最后一个节点。
unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
if (ptail[0] != ZIP_END) {
// 表尾节点为新节点的前置节点
// 取出表尾节点的长度
// T = O(1)
prevlen = zipRawEntryLength(ptail);
}
}
///
// 尝试看能否将输入字符串转换为整数(个人理解原因是整数比字符串省空间),如果成功的话:
// 1)value 将保存转换后的整数值
// 2)encoding 则保存适用于 value 的编码方式
// 无论使用什么编码, reqlen 都保存节点值的长度
// T = O(N)
if (zipTryEncoding(s,slen,&value,&encoding)) {
reqlen = zipIntSize(encoding); // data所需大小
} else {
reqlen = slen; // data所需大小
}
// 计算prevlenencode所需的大小
// T = O(1)
reqlen += zipPrevEncodeLength(NULL,prevlen);
// 计算dataencode所需的大小
// T = O(1)
reqlen += zipEncodeLength(NULL,encoding,slen);
// 到这里计算完了reqlen,即插入节点整个的大小
// 只要新节点不是被添加到列表末端,
// 那么程序就需要检查看 p 所指向的节点(的 prevlenencode)能否编码新节点的长度。
// nextdiff 保存了新旧编码之间的字节大小差,如果这个值大于 0
// 那么说明需要对 p 所指向的节点以及后续节点进行扩展
// T = O(1)
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
/* Store offset because a realloc may change the address of zl. */
// 因为重分配空间可能会改变 zl 的地址
// 所以在分配之前,需要记录 zl 到 p 的偏移量,然后在分配之后依靠偏移量还原 p
offset = p-zl;
// curlen 是 ziplist 原来的长度
// reqlen 是整个新节点的长度
// nextdiff 是新节点的后继节点扩展 header 的长度(要么 0 字节,要么 4 个字节)
// T = O(N)
zl = ziplistResize(zl,curlen+reqlen+nextdiff); // 重新分配空间
p = zl+offset;
if (p[0] != ZIP_END) {
// 新元素之后还有节点,因为新元素的加入,需要对这些原有节点进行调整
// 移动现有元素,为新元素的插入空间腾出位置
// T = O(N)
memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); // 移动内存
// 将新节点的长度编码至后置节点
// p+reqlen 定位到后置节点
// reqlen 是新节点的长度
// T = O(1)
zipPrevEncodeLength(p+reqlen,reqlen);
// 更新到达表尾的偏移量,将新节点的长度也算上
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
// 如果新节点的后面有多于一个节点
// 那么程序需要将 nextdiff 记录的字节数也计算到表尾偏移量中
// 这样才能让表尾偏移量正确对齐表尾节点
// T = O(1)
tail = zipEntry(p+reqlen);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
ZIPLIST_TAIL_OFFSET(zl) =
intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
} else {
/* This element will be the new tail. */
// 新元素是新的表尾节点
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
}
// 当 nextdiff != 0 时,新节点的后继节点的(header 部分)长度已经被改变,
// 所以需要级联地更新后续的节点
if (nextdiff != 0) {
offset = p-zl;
// T = O(N^2)
zl = __ziplistCascadeUpdate(zl,p+reqlen); // 调整后续节点
p = zl+offset;
}
// 一切搞定,将前置节点的长度写入新节点的 header
p += zipPrevEncodeLength(p,prevlen);
// 将节点值的长度写入新节点的 header
p += zipEncodeLength(p,encoding,slen);
// 写入节点值
if (ZIP_IS_STR(encoding)) {
// T = O(N)
memcpy(p,s,slen);
} else {
// T = O(1)
zipSaveInteger(p,value,encoding);
}
// 更新列表的节点数量计数器
// T = O(1)
ZIPLIST_INCR_LENGTH(zl,1);
return zl;
}