Redis基本数据类型ZipList


为什么要有ziplist

有两点原因:

  • 普通的双向链表,会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,是不是有点得不偿失?而且Redis是基于内存的,而且是常驻内存的,为了节省内存,又能达到链表的功能,ziplist出现了。
  • 链表在内存中,一般是不连续的,遍历相对比较慢,而ziplist可以很好的解决这个问题。

ZipList

不用指针实现双端链表,具备双端链表的特性,可在任意一端进行压入,弹出操作

#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

Redis基本数据类型ZipListRedis基本数据类型ZipList

一般来说连续内存空间每一个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个字节

    • 如果前一个节点的长度小于254,则采用1个字节来保存这个长度值
    • 如果前一个节点的长度大于254,则采用5个字节来保存这个长度值,第一个字节固定为0xfe,后四个字节才是真实长度,描述长度也会占用内存,所以一般不推荐对单个数值占用过多内存
  • 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编码

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的结构

Redis基本数据类型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:Redis基本数据类型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 针对元素少的时候才能提升效率。

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/289969.html

(0)
上一篇 2022年9月17日
下一篇 2022年9月17日

相关推荐

发表回复

登录后才能评论