zoukankan      html  css  js  c++  java
  • Redis 设计与实现 5:压缩列表 ziplist

    压缩列表是 ZSET、HASH和 LIST 类型的其中一种编码的底层实现,是由一系列特殊编码的连续内存块组成的顺序型数据结构,其目的是节省内存。

    ziplist 的结构

    外层结构

    下图展示了压缩列表的组成:
    ziplist 的结构

    各个字段的含义如下:

    1. zlbytes:是一个无符号 4 字节整数,保存着 ziplist 使用的内存数量。
      通过 zlbytes,程序可以直接对 ziplist 的内存大小进行调整,无须为了计算 ziplist 的内存大小而遍历整个列表。
    2. zltail:压缩列表 最后一个 entry 距离起始地址的偏移量,占 4 个字节。
      这个偏移量使得对表尾的 pop 操作可以在无须遍历整个列表的情况下进行。
    3. zllen:压缩列表的节点 entry 数目,占 2 个字节。
      当压缩列表的元素数目超过 2^16 - 2 的时候,zllen 会设置为2^16-1,当程序查询到值为2^16-1,就需要遍历整个压缩列表才能获取到元素数目。所以 zllen 并不能替代 zltail
    4. entryX:压缩列表存储数据的节点,可以为字节数组或者整数。
    5. zlend:压缩列表的结尾,占一个字节,恒为 0xFF

    实现的代码 ziplist.c 中,ziplist 定义成了宏属性。

    // 相当于 zlbytes,ziplist 使用的内存字节数
    #define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))
    // 相当于 zltail,最后一个 entry 距离 ziplist 起始位置的偏移量
    #define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))
    // 相当于 zllen,entry 的数量
    #define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))
    // zlbytes + zltail + zllen 的长度,也就是 4 + 4 + 2 = 10
    #define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))
    // zlend 的长度,1 字节
    #define ZIPLIST_END_SIZE        (sizeof(uint8_t))
    // 指向第一个 entry 起始位置的指针
    #define ZIPLIST_ENTRY_HEAD(zl)  ((zl)+ZIPLIST_HEADER_SIZE)
    // 指向最后一个 entry 起始位置的指针
    #define ZIPLIST_ENTRY_TAIL(zl)  ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))
    // 相当于 zlend,指向 ziplist 最后一个字节
    #define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)
    

    以下是重建新的空 ziplist 的代码实现,在 ziplist.c 中:

    unsigned char *ziplistNew(void) {
        // ziplist 头加上结尾标志字节数,就是 ziplist 使用内存的字节数了
        unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
        unsigned char *zl = zmalloc(bytes);
        ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
        // 因为没有 entry 列表,所以尾部偏移量是 ZIPLIST_HEADER_SIZE
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
        // entry 节点数量是 0
        ZIPLIST_LENGTH(zl) = 0;
        // 设置尾标识。
        // #define ZIP_END 255 
        zl[bytes-1] = ZIP_END;
        return zl;
    }
    

    entry 节点的结构

    布局

    节点的结构一般是:<prevlen> <encoding> <entry-data>

    • prevlen:前一个 entry 的大小,用于反向遍历。
    • encoding:编码,由于 ziplist 就是用来节省空间的,所以 ziplist 有多种编码,用来表示不同长度的字符串或整数。
    • data:用于存储 entry 真实的数据;

    prevlen

    节点的 prevlen 属性以字节为单位,记录了压缩列表中前一个节点的长度。编码长度可以是 1 字节或者 5 字节。

    • 当前面节点长度小于 254 的时候,长度为 1 个字节。
    • 当前面节点长度大于 254 的时候,1 个字节不够存了。前面第一个字节就设置为 254,后面 4 个字节才是真正的前面节点的长度。

    下图展示了 1 字节 和 5 字节 prevlen 的示意图(来源)
    不同长度的 prevlen 示意图

    prevlen 属性主要的作用是反向遍历。通过 ziplistzltail,我们可以得到最后一个节点的位置,接着可以获取到前一个节点的长度 len,指针向前移动 len,就是指向倒数第二个节点的位置了。以此类推,可以一直往前遍历。


    encoding

    encoding 记录了节点的 data 属性所保存数据的类型和长度。类型主要有两种:字符串和整数。


    类型 1. 字符串

    如果 encoding0001 或者 10 开头,就表示数据类型是字符串

    #define ZIP_STR_06B (0 << 6)
    #define ZIP_STR_14B (1 << 6)
    #define ZIP_STR_32B (2 << 6)
    

    字符串有三种编码:

    • 长度 < 2^6 时,以 00 开头,后 6 位表示 data 的长度,。
    • 2^6 <= 长度 < 2^14 时,以 01 开头,后续 6 位 + 下一个字节的 8 位 = 14 位表示 data 的长度。
    • 2^14 <= 长度 < 2^32 字节时,以 10 开头,后续 6 位不用,从下一字节起连续 32 位表示 data 的长度。

    下图为字符串三种长度结构的示意图(来源):
    ziplist 字符串编码示意图


    类型 2. 整数

    如果 encoding11 开头,就表示数据类型是整数

    #define ZIP_INT_16B (0xc0 | 0<<4)
    #define ZIP_INT_32B (0xc0 | 1<<4)
    #define ZIP_INT_64B (0xc0 | 2<<4)
    #define ZIP_INT_24B (0xc0 | 3<<4)
    #define ZIP_INT_8B 0xfe
    
    #define ZIP_INT_IMM_MIN 0xf1    /* 11110001 */
    #define ZIP_INT_IMM_MAX 0xfd    /* 11111101 */
    

    整数一共有 6 种编码,说起来麻烦,看图吧(来源)。
    ziplist 整数编码示意图
    看了上图的最后一个类型,可能有小伙伴就有疑问:为啥没有 11111111
    答:因为 11111111 表示 zlend (十进制的 255,十六进制的 oxff)


    data

    data 表示真实存的数据,可以是字符串或者整数,从编码可以得知类型和长度。知道长度,就知道 data 的起始位置了。

    比较特殊的是,整数 1 ~ 13 (0001 ~ 1101),因为比较短,刚好可以塞在 encoding 字段里面,所以就没有 data

    连锁更新

    通过上面的分析,我们知道:

    • 前个节点的长度小于 254 的时候,用 1 个字节保存 prevlen
    • 前个字节的长度大于等于 254 的时候,用 5 个字节保存 prevlen

    现在我们来考虑一种情况:假设一个压缩列表中,有多个长度 250 ~ 253 的节点,假设是 entry1 ~ entryN。
    因为都是小于 254,所以都是用 1 个字节保存 prevlen
    如果此时,在压缩列表最前面,插入一个 254 长度的节点,此时它的长度需要 5 个字节
    也就是说 entry1.prevlen 会从 1 个字节变为 5 个字节,因为 prevlen 变长,entry1 的长度超过 254 了。
    这下就糟糕了,entry2.prevlen 也会因为 entry1 而变长,entry2 长度也会超过 254 了。
    然后接着 entry3 也会连锁更新。。。直到节点不超过 254, 噩梦终止。。。

    这种由于一个节点的增删,后续节点变长而导致的连续重新分配内存的现象,就是连锁更新。最坏情况下,会导致整个压缩列表的所有节点都重新分配内存。

    每次分配空间的最坏时间复杂度是 (O(n)),所以连锁更新的最坏时间复杂度高达 (O(n^2)) !

    虽然说,连锁更新的时间复杂度高,但是它造成大的性能影响的概率很低,原因如下:

    1. 压缩列表中需要需要有连续多个长度刚好为 250 ~ 253 的节点,才有可能发生连锁更新。实际上,这种情况并不多见。
    2. 即使有连续多个长度刚好为 250 ~ 253 的节点,连续的个数也不多,不会对性能造成很大影响

    因此,压缩列表插入操作,平均复杂度还是 (O(n)).


    总结:

    • 压缩列表是一种为节约内存而开发的顺序型数据结构,是 ZSET、HASH 和 LIST 的底层实现之一。
    • 压缩列表有 3 种字符串类型编码、6 种整数类型编码
    • 压缩列表的增删,可能会引发连锁更新操作,但这种操作出现的几率并不高。

    本文的分析没有特殊说明都是基于 Redis 6.0 版本源码
    redis 6.0 源码:https://github.com/redis/redis/tree/6.0

  • 相关阅读:
    hdu 1042 N!
    hdu 1002 A + B Problem II
    c++大数模板
    hdu 1004 Let the Balloon Rise
    hdu 4027 Can you answer these queries?
    poj 2823 Sliding Window
    hdu 3074 Multiply game
    hdu 1394 Minimum Inversion Number
    hdu 5199 Gunner
    九度oj 1521 二叉树的镜像
  • 原文地址:https://www.cnblogs.com/chenchuxin/p/14199444.html
Copyright © 2011-2022 走看看