有两点原因:
不用指针实现双端链表,具备双端链表的特性,可在任意一端进行压入,弹出操作
#define ZIP_END 255 #define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl))) // 获取ziplist的bytes指针 #define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t)))) // 获取ziplist的tail指针 #define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2))) // 获取ziplist的len指针 #define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t)) // ziplist头大小 #define ZIPLIST_END_SIZE (sizeof(uint8_t)) // ziplist结束标志位大小 #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) // 获取结束标志位指针 unsigned char *ziplistNew(void) { // 创建一个压缩表 unsigned int bytes = ZIPLIST_HEADER_SIZE+1; // zip头加结束标识位数 unsigned char *zl = zmalloc(bytes); ZIPLIST_BYTES(zl) = intrev32ifbe(bytes); // 大小端转换 ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE); ZIPLIST_LENGTH(zl) = 0; // len赋值为0 zl[bytes-1] = ZIP_END; // 结束标志位赋值 return zl; }
头信息
zlbytes :无符号32位整数,4字节,总字节数
zltail :无符号32位整数,4字节,尾节点的偏移量,列表中的最后一个节点到起始地址之间的字节数,如果知道压缩列表的起始地址加上zltail就是最后一个节点的起始地址,就能快速定位到列表的最后一个节点
zllen:无符号16位整数,2字节,entry节点的个数
结束标识 zlend:(16进制):0xff
节点 entry:占用字节数不确定,entry
一般来说连续内存空间每一个entry大小固定,便于根据脚标快速定位节点,但是存入压缩列表的数据占用大小会不一样,如果存了一个小值和大值,如果用同样的空间存储,就造成了资源浪费,为了节省内存,那么问题来了,那么entry长度怎么确定?怎么寻址遍历?
typedef struct zlentry { // 压缩列表节点 unsigned int prevrawlensize, prevrawlen; // prevrawlen是前一个节点的长度,prevrawlensize是指prevrawlen的大小,有1字节和5字节两种 unsigned int lensize, len; // len为当前节点长度 lensize为编码len所需的字节大小 unsigned int headersize; // 当前节点的header大小 unsigned char encoding; // 节点的编码方式 unsigned char *p; // 指向节点的指针 } zlentry; void zipEntry(unsigned char *p, zlentry *e) { // 根据节点指针返回一个enrty ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen); // 获取prevlen的值和长度 ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len); // 获取当前节点的编码方式、长度等 e->headersize = e->prevrawlensize + e->lensize; // 头大小 e->p = p; }
privious_entry_length,encoding
privious_entry_length:前一个节点的长度,占1个或5个字节
encoding:编码类型,第一部分记录content的数据类型(字符串还是整数)以及长度,数据类型的编码不同,所以在encoding里有个标识来标记采用哪个编码,第二部分记录content的长度,entry长度不固定指的是content的长度不固定,因此encoding要记录长度,而encoding的长度本身长度也是不固定的,占用1个、2个或5个字节(跟编码关系有关)
privious_entry_length,encoding长度都可以根据编码方式推算,真正变化的是content,而content长度记录在encoding里 ,因此entry的长度就知道了
entry总长度 = privious_entry_length字节数+encoding字节数+content字节数
为什么entry这么设计?
如果知道了当前的起始地址,因为entry是连续的,entry后一定是另一个entry,想知道下一个entry的地址,只要将当前的起始地址加上当前entry总长度。如果还想遍历下一个entry,只要继续同样的操作。
怎么倒序遍历?
当前节点的地址减去前一个节点的长度privious_entry_length,得到前一个节点的起始地址,只要重复此操作就可以实现倒序遍历。
因此不通过指针,通过记录长度也能实现正序,倒序遍历
记录长度的好处:占用内存小,1或者5个字节。
encoding怎么标记字符串还是整数?怎么记录长度的?
encoding和privious_entry_length两部分采用的都是小端字节序进行存储的
小端字节序:从低位到高位保存
例如一个16进制数(两位16进制数从00到FF,表示0-255的10进制数,也就是8 位(1个字节)的数值形式。):0x1234,12是一个字节,34是一个字节,采用小端字节序存储值位:0x3412
字符串:如果encoding是以"01"、"01"、或者"10"开头,则证明content是字符串
编码 | 编码长度 | 字符串大小 |
---|---|---|
|00pppppp| 后6位记录长度 | 1 bytes | <= 63 bytes |
|01pppppp|qqqqqqqq| 14位记录长度 | 2 bytes | <= 16383 bytes |
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| 32位记录长度 | 5 bytes | <= 4294967295 bytes |
encoding记录一部分编码,一部分长度,content长度可长可短,如果content只占了5个字节,长度就是5,只用一个字节就能记录,如果content保存的字符串很大,要几万几百万字节,就需要更多字节记录长度。
例如:保存字符串: "ab"和"bc",
"ab"大小为2字节,采用第一种编码encoding为 00000010,为第一个节点,privious_entry_length为00000000,content是"ab"转化成字节97和98,01100001(64+32+1),01100010
"ab"转化为16进制保存
privious_entry_length:0x00 encoding:0x02 content:0x61 0x62
"bc":
privious_entry_length("ab"的长度4):0x04 encoding:0x02 content:0x62 0x63
也就是说ab字符串的entry为4个字节
我们也能推出整个ziplist的结构
整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节
字符串长度变动大,而整数最大8个字节,encoding记录长度只要一个字节
编码 | 编码长度 | 整数类型 |
---|---|---|
11000000 | 1 | int16_t(2 bytes) |
11010000 | 1 | int32_t(4 bytes) |
11100000 | 1 | int64_t(8 bytes) |
11110000 | 1 | 24位有符整数(3 bytes) |
11111110 | 1 | 8位有符整数(1 bytes) |
1111xxxx | 1 | 直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值 |
如果有很小的如1、2,用1个字节来表示它,就有点浪费,因此有专门记录小的数字的编码 1111xxxx
用4bit位直接记录数值而不是长度,0000和1110和1111(结束标识)都被占用,范围从0001到1101。表示实际值0到12
例如:一个ziplist包含两个整数值:2 和 5
previous_entry_length:00000000 encoding: 11110011 不需要content,16进制表示entry: 0x00 0xf3
两个entry:0x00 0xf3 0x02 0xf6
整个ziplist:
使用限制:只能从前向后或者从后往前,如果长度很长,要找的数在中间,效率就很慢
如果有n个250~253字节之间的entry的ziplist,往头部插入254字节大小的entry,后一个entry记录前一个节点长度的privious_entry_length就会从原来的1个字节变为5个字节,因此这个entry变为了254个字节,同样的,又会导致后面的entry都变为entry
为什么要在元素较少的时候使用 ziplist ?
因为 redis 中的集合容器中,很多情况都用到了链表的实现,元素和元素之间通过储存的关联指针有序的串联起来,但是这样的指针往往是 随机I/O,也就是指针地址是不连续的(分布不均匀)。而我们的 ziplist 它本身是一块连续的内存块,所以它的读写是 顺序I/O,从底层的磁盘读写来说,顺序I/O 的效率肯定是高于 随机I/O 。你可能会问了,那为什么不都用 顺序I/O 的 ziplist 代替 随机I/O 呢,因为 ziplist 是连续内存,当你元素数量多了,意味着当你创建和扩展的时候需要操作更多的内存,所以 ziplist 针对元素少的时候才能提升效率。