评论

收藏

[NoSQL] Redis底层详解(五) 压缩列表

数据库 数据库 发布于:2021-07-08 13:15 | 阅读数:528 | 评论:0

   DSC0000.png
一、压缩列表概述
  压缩列表是一种编码过的“链表”旨在实现高效的内存管理。它可以存储整数和字符串,整数以小端序存储,字符串则以字节数组存储。压缩列表的内存存储结构如下图所示:
DSC0001.png

  其中zlbytes、zltail、zllen 是 压缩列表头( ziplist header ),entry1 到 entryN 是列表的结点部分,zlen 是结尾标记。
二、压缩列表头(ziplist header)
  zlbytes :4个字节,指定了压缩列表总共需要多少个字节(包含它本身的这四个字节)。
       zltail : 4个字节,指定了列表首地址到尾结点 (entryN) 的偏移量,这样弹出尾结点就不需要遍历所有结点了。
       zllen:2个字节,指定了压缩列表中结点的数量。当有大于等于 2^16-1 个结点时,这个值为 2^16-1,并且需要遍历整个结点列表才能算出总共有多少个结点。
DSC0002.png

  zl 是压缩列表的首地址(头指针),默认是 unsigned char 类型的。整个压缩列表的占用字节总数(即 zlbytes 字段)就是以小端序存储在这个首地址上的,由于 zlbytes 字段占用 4 个字节,所以在获取值的时候需要先转换成 uint32_t,也就是上面的宏定义中 ZIPLIST_BYTES 的含义(先进行指针强转再用 * 解指针)。同理,ZIPLIST_TAIL_OFFSET 和 ZIPLIST_LENGTH 这两个宏分别是获取 zltail (4字节)和 zlen (2字节)字段的值。ZIPLIST_HEADER_SIZE 为上面三者占用字节总数。
        ZIPLIST_END_SIZE 为 zlend 占用的字节数,即 1字节。
        ZIPLIST_ENTRY_HEAD 和 ZIPLIST_ENTRY_TAIL 分别代表压缩列表的 首结点 和 尾结点 的首地址。intrev32ifbe 是前一篇文章提到的字节序转换。
        ZIPLIST_ENTRY_END 表示压缩列表结尾标记 zlend 的地址。zlend 用一个字节标志的压缩列表的结尾,值为 0xff (即十进制中的 255),所有的结点的第一个字节不会是 0xff。
三、压缩列表结点(entry)
  1、zlentry结构体
DSC0003.png

  这是压缩列表结点的结构体,但是实际存储的时候并不是这样的,只是在进行计算的时候需要把结点从内存编码中转换出来才方便写逻辑。于是,有了这么一个结构体。
       a、每个结点需要记录前一个结点占用的字节数 prerawlen,以及存储 prerawlen 需要用到的字节数 prevrawlensize;
       b、每个结点可以是字符串或者数组,当它是字符串时,len为字符串长度;当它是整数时,len为存储整数需要的字节数;lensize 为存储 len 需要的字节数;
       c、headersize = prevrawlensize + lensize;
       d、encoding 表示结点的存储方式;
       e、p 为该结点在内存中的首地址;
  2、entry 内存存储
       实际上,一个结点的信息存储如下:
DSC0004.png

  prevlen 代表前一个结点的长度(集成了 prerawlen 和 prevrawlensize);
        encoding 代表当前结点的内存编码方式;
        entry-data 则是实际的结点内存数据。
  3、prevlen
        prevlen 表示前一个结点的长度,在内存中的存储实现如下:
DSC0005.png

  a、如果长度小于 254 (定义在 ZIP_BIGLEN 中),那么它用一个1个字节(8位无符号整数)来存储长度;
DSC0006.png

  b、如果长度大于等于254,则需要分配5个字节,第一个字节设置成 254,后面四个字节代表实际长度,后四个字节的存储方式采用小端序;
DSC0007.png

  有 encode 就有 decode,decode 就是将内存中的值反序列化到临时变量中方便逻辑使用,它是 encode 的逆运算。宏 ZIP_DECODE_PREVLEN 就是做这个 decode 的,具体实现如下:
DSC0008.png

  ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) 会通过指针 ptr 指向位置的第一个字节判断是 1 字节 的长度 还是 5 字节的长度。然后再去内存中取得对应的长度值存到 prevlen 中。
  4、encoding
  encoding 字段可能占用1个字节,也可能占用多个字节,完全取决于第一个字节的编码。当 encoding 字段的第一个字节的前两个比特位为 00、01、10 时,代表这个结点是个字符串。为11时,代表这个结点是个整数。
DSC0009.png

  首先讨论字符串的情况,相关宏定义如下:
DSC00010.png

  ZIP_STR_MASK 的二进制表示为 11000000,所以 ZIP_IS_STR 的位运算的含义,就是根据 enc 这个字节的前两位来判断这个结点是否是字符串。
        a、ZIP_STR_06B 总共占用 1 个字节。表示长度小于等于 2^6-1 的字符串。存储方式如下图所示,|xxxxxx|存储实际长度;
DSC00011.png

  b、ZIP_STR_14B 总共占用 2 个字节。表示长度小于等于 2^14-1 的字符串,存储方式如下图所示,剩下的 14 个比特位按照大端序存储;
DSC00012.png

  c、ZIP_STR_32B 总共占用 5 个字节。表示长度小于等于 2^32-1 的字符串,第一个字节的低 6 位设置成0,后 32 个比特位按照大端序存储。
DSC00013.png

  再来讨论整数的情况,相关宏定义如下:
DSC00014.png

  a、ZIP_INT_16B 第1个字节为 |11000000|,总共占用 3 个字节。后 2 字节表示 16位 整数;
        b、ZIP_INT_32B 第1个字节为 |11010000|,总共占用 5 个字节。后 4 字节表示 32位 整数;
        c、ZIP_INT_64B 第1个字节为 |11100000|,总共占用 9 个字节。后 8 字节表示 64位 整数;
        d、ZIP_INT_24B 第1个字节为 |11110000|,总共占用 4 个字节。后 3 字节表示 24位 整数;
        e、ZIP_INT_8B   第1个字节为 |11111110|,  总共占用 2 个字节。后 1 字节表示 8 位 整数;
DSC00015.png

  f、|1111xxxx| 用来表示 0 到 12 的 4 位整数,|xxxx| 的取值为 |0001| 到 |1101| (其中 |0000| 、|1110|、 |1111| 因为已经有编码占用,所以不能用)。举个例子,  |0001|  代表的是 0, |0002| 代表 1, 以此类推。
        所有整数存储采用小端序。
  5、entry-data
  实际的结点数据以小端序存储在 entry-data 中。
四、连锁更新
  考虑这样一种情况, 有多个连续的、长度介于 250 字节到 253 字节之间的结点 e1 到 eN 。由于长度范围在 [250, 253],所以每个结点的 prevlen 字段只需要一个字节。其中 e1 的 prevlen 字段等于 0。如图所示:
DSC00016.png

  这时,我们在列表头插入一个新结点 new ,这个新结点的长度大于等于 254 。如图所示:
DSC00017.png

  e1 的 prevlen 字段就表示 new 结点的长度(大于等于254),需要从 1字节 变为 5字节。换言之,e1 结点的长度增加了4。 e1 结点原本的长度范围在 [250,253],现在变成了 [254, 257],那么 e2 结点的 prevlen 字段也从原来的 1 字节变成了 5 字节。就这样以此类推, 一直到 eN,所有结点的 prevlen 字段都从 1 字节 增长为 5 字节。这就是连锁更新。
        连锁更新最坏情况下会触发 n 次空间重分配 (realloc) 和内存移动 (memmove),每次空间重分配的复杂度是O(n),所以连锁更新的最坏时间复杂度是O(n^2)。但是由于要有恰好多个长度 [250, 253] 的结点才会触发连锁更新,连续三五个在这个范围内的结点是不会影响性能的。
五、列表实例
0, 12, 13, 127, 128, 32767, 32768, 8388607, 8388608, 2147483647, 2147483648, "Hello World"
  上图所示的列表,在 redis 中以 ziplist 的存储方式存储后,得到下图的内存布局:
DSC00018.png

  每一行显示 16 个字节,这个 ziplist 总共占用 74 个字节。
         第一个字段是 zlbytes ,小端序存储 4a,十进制的值恰好为 74 ,即总共占用的字节数;
         第二个字段是 zltail,小端序存储 3c,十进制的值为 60,即压缩列表首地址到 "Hello World" 这个结点的首地址的偏移量;
         第三个字段是 zllen ,小端序存储 0c,十进制的值为 12,即代表总共 12 个结点;
         接下来就是每个结点了,结点的第1个字节代表它前驱结点的长度,结点元素 0 和 12 采用的是 |1111xxxx| 编码;13 和 127 采用 |11111110| 的 8 位整数编码;128 和 32767 采用的是 |11000000| 的 16 位整数编码; "Hello World" 这个字符串被看作是一个结点整体,它的两个额外字节 0a 和 0b 分别代表 前面结点 2147483648 的长度 和 本身编码 |00xxxxxx| (b 是 十进制中的 11,代表 "Hello World" 字符串的长度)。

  
关注下面的标签,发现更多相似文章