深入分析redis之listpack,取代ziplist?

2,954 阅读12分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。


前言

续接上篇:深入分析redis之quicklist,不一样的ziplist使用方式?,本文参考源码版本: redis6.2

ziplistquicklist,再到 listpack 结构;可以看到,初衷都是设计一款数据结构能够高效的使用内存。

ziplist 设计出的紧凑型数据块可以有效利用内存,但在更新上,由于每一个 entry 都保留了前一个 entry 的 prevlen 长度,因此在插入或者更新时可能会出现连锁更新,这是一个影响效率的大问题。

因此,接着又设计出 「链表 + ziplist」组成的 quicklist 结构来避免单个 ziplist 过大,可以有效降低连锁更新的影响面。

但 quicklist 本质上不能完全避免连锁更新问题,因此,又设计出与 ziplist 完全不同的内存紧凑型结构 listpack,继续往下看~


一、listpack是什么?

listpack 也叫紧凑列表,它的特点就是用一块连续的内存空间来紧凑地保存数据,同时为了节省内存空间,listpack 列表项使用了多种编码方式,来表示不同长度的数据,这些数据包括整数和字符串。

Redis源码对于listpack的解释为 A lists of strings serialization format,一个字符串列表的序列化格式,也就是将一个字符串列表进行序列化存储。Redis listpack可用于存储字符串或者整型

二、原理分析

1. 结构

listpack结构:

<tot-bytes> <num-elements> <element-1> ... <element-N> <listpack-end-byte>

listpack由4部分组成:total Bytes、Num Elem、Entry以及End

  • Total Bytes为整个listpack的空间大小,占用4个字节,每个listpack最多占用4294967295Bytes。
  • Num Elem为listpack中的元素个数,即Entry的个数,占用2个字节,值得注意的是,这并不意味着listpack最多只能存放65535个Entry,当Entry个数大于等于65535时,Num Elem被设置为65535,此时如果需要获取元素个数,需要遍历整个listpack。
  • Entry为每个具体的元素。
  • End为listpack结束标志,占用1个字节,内容为0xFF。

entry结构:

Entry为listpack中的具体元素,其内容可以为字符串或者整型,每个Entry由3部分组成:

<encoding-type><element-data><element-tot-len>
|                                            |
+--------------------------------------------+
            (This is an element)

从组成上可以看出,和 ziplist 列表项类似,listpack 列表项也包含了元数据信息和数据本身。

不过,为了避免 ziplist 引起的连锁更新问题,listpack 中的每个列表项不再像 ziplist 列表项那样,保存其前一个列表项的长度,它只会包含三个方面内容,分别是当前元素的编码类型(encoding)、元素数据 (data),以及编码类型和元素数据这两部分的长度 (len)。

其中 type 和 tot-len 一定有值;有时 data 可能会缺失,因为对于一些小的元素可以直接将data放在type字段中。

element-tot-len记录了这个Entry的长度(encoding + data),注意并不包括 element-tot-len 自身的长度,占用的字节数小于等于5。

  • element-tot-len 所占用的每个字节的第一个 bit 用于标识;0代表结束,1代表尚未结束,每个字节只有7 bit 有效。
  • 值得一提的是,element-tot-len 主要用于从后向前遍历,当我们需要找到当前元素的上一个元素时,我们可以从后向前依次查找每个字节,找到上一个Entry的 element-tot-len 字段的结束标识,进而可以计算出上一个元素的长度。
  • 例如 element-tot-len 为0000000110001000,代表该元素的长度为00000010001000,即136字节。通过计算即可算出上一个元素的首地址(entry的首地址)。

需要注意的是,在整型存储中,并不实际存储负数,而是将负数转换为正数进行存储。例如,在13位整型存储中,存储范围为[0, 8191],其中[0, 4095]对应非负的[0, 4095](当然,[0, 127]将会采用7位无符号整型存储),而[4096, 8191]则对应[-4096, -1]。

2. 编码方式

encoding-type是理解数据类型的基础,为了节省内存空间,listpack针对不同的数据做了不同的编码:

小的数字

0|xxxxxxx

表示7位无符号整型,后7位为数据。

我们知道,int类型占4字节,字符类型占1字节;因此较小的整型数用字符串表示就可以节省一定的空间;比如,65、1这样的整数,因此,该编码可以用于表示0~127之间的整数。

小的字符串

10|xxxxxx <string-data>

6位字符串长度,后6位为字符串长度,再之后则是字符串内容(0 ~ 63 bytes)

多字节编码

如果第一个字节的最高2bit被设置为1,采用如下两种编码方式

110|xxxxx yyyyyyyy -- 13 bit signed integer,即13位整型,后5位及下个字节为数据内容
1110|xxxx yyyyyyyy -- string with length up to 4095,即12位长度的字符串,后4位及下个字节为字符串长度,再之后的为字符串数据。

如果第一个字节的最高4bit被设置为1,将采用以下几种方式编码

1111|0000 <4 bytes len> <large string>,即32位长度字符串,后4字节为字符串长度,再之后为字符串内容
1111|0001 <16 bits signed integer>,即16位整型,后2字节为数据
1111|0010 <24 bits signed integer>,即24位整型,后3字节为数据
1111|0011 <32 bits signed integer>,即32位整型,后4字节为数据
1111|0100 <64 bits signed integer>,即64位整型,后8字节为数据
1111|0101 to 1111|1110 are currently not used.   当前不用编码
1111|1111 End of listpack,结尾标识

3. listpack 避免连锁更新的实现方式

在 listpack 中,因为每个列表项只记录自己的长度,而不会像 ziplist 中的列表项那样,会记录前一项的长度。所以,当在 listpack 中新增或修改元素时,实际上只会涉及每个列表项自己的操作,而不会影响后续列表项的长度变化,这就避免了连锁更新

如果 listpack 列表项只记录当前项的长度,那么 listpack 支持从左向右正向查询列表,或是从右向左反向查询列表吗?

来,继续往下看~

4. 查询

正向查询

首先,listpack 保存了 LP_HDR_SIZE,通过该参数可以直接将指针移动到第一个 entry 列表项

  • 先计算 encoding + data 的总长度 len1
  • 通过 len1 可计算出 element-tot-len 的长度
  • len1 + element-tot-len 之和即为 entry 总长度 total-len
  • 向后移动 total-len 即为下一个列表项的起始位置

这就是正向查询的过程。

反向查询

为了支持反向查询,特意设计了参数element-tot-len,怎么说呢?因为对于正向查询,encoding保存了数据类型及数据长度,就可以知道整个entry的长度

element-tot-len的特殊编码方式:element-tot-len 每个字节的最高位,是用来表示当前字节是否为 element-tot-len 的最后一个字节,这里存在两种情况,分别是:

  • 最高位为 1,表示 element-tot-len 还没有结束,当前字节的左边字节仍然表示 element-tot-len 的内容;
  • 最高位为 0,表示当前字节已经是 element-tot-len 最后一个字节了。而 element-tot-len 每个字节的低 7 位,则记录了实际的长度信息。

这里需要注意的是,element-tot-len 每个字节的低 7 位采用了大端模式存储,也就是说,entry-len 的低位字节保存在内存高地址上。

正是因为有了 element-tot-len 的特别编码方式,lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 element-tot-len 值,也就可以得到encoding + data的总长度,从而得出entry的总长度;减去entry的总长度,就得到了前一个entry的地址~

源码分析

1、初始化

lpNew 函数创建了一个空的 listpack,一开始分配的大小是 LP_HDR_SIZE 再加 1 个字节。LP_HDR_SIZE 宏定义是在 listpack.c 中,它默认是 6 个字节,其中 4 个字节是记录 listpack 的总字节数,2 个字节是记录 listpack 的元素数量。

此外,listpack 的最后一个字节是用来标识 listpack 的结束,其默认值是宏定义 LP_EOF。和 ziplist 列表项的结束标记一样,LP_EOF 的值也是 255

unsigned char *lpNew(void) {
    unsigned char *lp = lp_malloc(LP_HDR_SIZE+1);
    if (lp == NULL) return NULL;
    lpSetTotalBytes(lp,LP_HDR_SIZE+1);
    lpSetNumElements(lp,0);
    lp[LP_HDR_SIZE] = LP_EOF;
    return lp;
}

来,再来回顾下listpack组成结构:

<tot-bytes> <num-elements> <element-1> ... <element-N> <listpack-end-byte>
|------ LP_HDR_SIZE -----|                             |---- 末尾标志 -----|

2、增删改操作

listpack提供了2种添加元素的方式:一种是在任意位置插入元素,一种是在末尾插入元素。在末尾插入元素的底层实现通过调用任意位置插入元素进行,具体实现为lpInsert函数。

listpack的删除操作被转换为用空元素替换的操作。

listpack的替换操作(即改操作)的底层实现也是通过lpInsert函数实现的。函数定义如下:

unsigned char *lpInsert(unsigned char *lp, unsigned char *ele, uint32_t size, unsigned char *p, int where, unsigned char **newp) 

其中:

  • lp为当前待操作的 listpack;
  • ele 为待插入的新元素或者待替换的新元素,ele 为空时,也就是删除操作;
  • size 为 ele 的长度;
  • p为待插入的位置或者带替换的元素位置;
  • where 有 LP_BEFORE(前插)、LP_AFTER(后插)、LP_REPLACE(替换);
  • *newp 用于返回插入的元素、替换的元素、删除元素的下一个元素。

来,具体看看源码实现:

unsigned char *lpInsert(unsigned char *lp, unsigned char *ele, uint32_t size, unsigned char *p, int where, unsigned char **newp) {
    unsigned char intenc[LP_MAX_INT_ENCODING_LEN];
    unsigned char backlen[LP_MAX_BACKLEN_SIZE];

    uint64_t enclen; /* The length of the encoded element. */
    // 当ele为null时意味着删除, 也就是用null替换原来的ele
    if (ele == NULL) where = LP_REPLACE;
    
    // 如果是在当前元素之后插入的话,那么可以将指针移动到下一个元素,也就是变成了下一个元素的LP_BEFORE操作;因此实际上只剩两种操作,LP_BEFORE and LP_REPLACE
    if (where == LP_AFTER) {
        p = lpSkip(p);
        where = LP_BEFORE;
        ASSERT_INTEGRITY(lp, p);
    }
    
    // 先存下元素的offset,当内存重分配之后可以重新定位p
    unsigned long poff = p-lp
    
    // 得到编码类型,LP_ENCODING_INT or LP_ENCODING_STR
    int enctype;
    if (ele) {
        enctype = lpEncodeGetType(ele,size,intenc,&enclen);
    } else {
        enctype = -1;
        enclen = 0;
    }

    /* We need to also encode the backward-parsable length of the element
     * and append it to the end: this allows to traverse the listpack from
     * the end to the start. */
    // backlen_size也就是element-tot-size,得到backlen_size占用的字节数
    unsigned long backlen_size = ele ? lpEncodeBacklen(backlen,enclen) : 0;
    uint64_t old_listpack_bytes = lpGetTotalBytes(lp);
    uint32_t replaced_len  = 0;
    if (where == LP_REPLACE) {
        replaced_len = lpCurrentEncodedSizeUnsafe(p);
        replaced_len += lpEncodeBacklen(NULL,replaced_len);
        ASSERT_INTEGRITY_LEN(lp, p, replaced_len);
    }

    // 计算新空间大小
    uint64_t new_listpack_bytes = old_listpack_bytes + enclen + backlen_size
                                  - replaced_len;
    if (new_listpack_bytes > UINT32_MAX) return NULL;

    // 接下来将会进行内存重分配,扩容或缩容
    unsigned char *dst = lp + poff; /* May be updated after reallocation. */
    if (new_listpack_bytes > old_listpack_bytes) {
        if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
        dst = lp + poff;
    }

    if (where == LP_BEFORE) {
        memmove(dst+enclen+backlen_size,dst,old_listpack_bytes-poff);
    } else { /* LP_REPLACE. */
        long lendiff = (enclen+backlen_size)-replaced_len;
        memmove(dst+replaced_len+lendiff,
                dst+replaced_len,
                old_listpack_bytes-poff-replaced_len);
    }

    /* Realloc after: we need to free space. */
    // 缩容
    if (new_listpack_bytes < old_listpack_bytes) {
        if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
        dst = lp + poff;
    }

    /* Store the entry. */
    if (newp) {
        *newp = dst;
        /* In case of deletion, set 'newp' to NULL if the next element is
         * the EOF element. */
        if (!ele && dst[0] == LP_EOF) *newp = NULL;
    }
    if (ele) {
        if (enctype == LP_ENCODING_INT) {
            memcpy(dst,intenc,enclen);
        } else {
            lpEncodeString(dst,ele,size);
        }
        dst += enclen;
        memcpy(dst,backlen,backlen_size);
        dst += backlen_size;
    }

    /* Update header. */
    if (where != LP_REPLACE || ele == NULL) {
        uint32_t num_elements = lpGetNumElements(lp);
        if (num_elements != LP_HDR_NUMELE_UNKNOWN) {
            if (ele)
                lpSetNumElements(lp,num_elements+1);
            else
                lpSetNumElements(lp,num_elements-1);
        }
    }
    lpSetTotalBytes(lp,new_listpack_bytes);
 
    ...

    return lp;
}

该函数返回null或者插入的元素,替换的元素,删除元素的下一个元素。删除或者替换的主要过程如下:

  • 计算需要插入的新元素或者替换旧元素的新元素需要的空间;
  • 计算进行插入或者替换后整个 listpack 所需的空间,通过 realloc 申请空间;
  • 调整新的 listpack 中的老的元素的位置,为待操作元素预留空间;
  • 释放旧的 listpack;
  • 在新的 listpack 中进行插入或替换的操作;
  • 更新新的 listpack 结构头部的统计信息。

3、遍历操作

listpack 提供了一组接口用于遍历其所有元素,核心思想是利用每个 entry 的 encoding 或者element-tot-len 字段获取当前 entry 的长度,接口实现较为简单。

unsigned char *lpFirst(unsigned char *lp);
unsigned char *lpLast(unsigned char *lp);
unsigned char *lpNext(unsigned char *lp, unsigned char *p);
unsigned char *lpPrev(unsigned char *lp, unsigned char *p);

此处获取的仅仅是某个 entry 首地址的指针,如果要读取当前元素则需要使用 lpGet 接口,也就是说 lpFirst、lpLast 等接口负责定位 entry 首地址,lpGet 则负责实际读取操作;

4、读取元素

lpGet 用于获取 p 指向的 listpack 中真正存储的元素:

  • 当元素采用字符串编码时,返回字符串的第一个元素位置,count 为元素个数;-
  • 当采用整型编码时,若 intbuf 不为空,则将整型数据转换为字符串存储在 intbuf 中,count为元素个数,并返回 intbuf。若 intbuf 为空,直接将数据存储在 count 中,返回null。
unsigned char *lpGet(unsigned char *p, int64_t *count, unsigned char *intbuf) {
    int64_t val;
    uint64_t uval, negstart, negmax;

    assert(p); /* assertion for valgrind (avoid NPD) */
    if (LP_ENCODING_IS_7BIT_UINT(p[0])) {
        negstart = UINT64_MAX; /* 7 bit ints are always positive. */
        negmax = 0;
        uval = p[0] & 0x7f;
    } else if (LP_ENCODING_IS_6BIT_STR(p[0])) { // 字符串编码类型
        *count = LP_ENCODING_6BIT_STR_LEN(p);
        return p+1;
    } else if (LP_ENCODING_IS_13BIT_INT(p[0])) {
    
        .....

    }

    /* We reach this code path only for integer encodings.
     * Convert the unsigned value to the signed one using two's complement
     * rule. */
    // 只有整型编码能走到这...
    if (uval >= negstart) {
        /* This three steps conversion should avoid undefined behaviors
         * in the unsigned -> signed conversion. */
        uval = negmax-uval;
        val = uval;
        val = -val-1;
    } else {
        val = uval;
    }

    /* Return the string representation of the integer or the value itself
     * depending on intbuf being NULL or not. */
    if (intbuf) {
        *count = snprintf((char*)intbuf,LP_INTBUF_SIZE,"%lld",(long long)val);
        return intbuf;
    } else {
        *count = val;
        return NULL;
    }
}

lpGet的实现较为简单,主要是利用了每个entry的encode字段。

总结

可以看出,ziplist、quicklist 和 listpack 是 redis 是不断迭代优化的产物。

ziplist 的不足主要在于当 ziplist 中元素个数过多,它的查找效率就会降低。而且如果在 ziplist 里新增或修改数据,ziplist 占用的内存空间还需要重新分配;更糟糕的是,ziplist 新增某个元素或修改某个元素时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起连锁更新问题,导致每个元素的空间都要重新分配,这就会导致 ziplist 的访问性能下降。

因此,为解决 ziplist 以上问题,Redis 先是在 3.0 版本中设计实现了 quicklist。quicklist 结构在 ziplist 基础上,使用链表将 ziplist 串联起来,链表的每个元素就是一个 ziplist。这种设计减少了数据插入时内存空间的重新分配,以及内存数据的拷贝。同时,quicklist 限制了每个节点上 ziplist 的大小,一旦一个 ziplist 过大,就会采用新增 quicklist 节点的方法。

不过,又因为 quicklist 使用 quicklistNode 结构指向每个 ziplist,无疑增加了内存开销。为了减少内存开销,并进一步避免 ziplist 连锁更新问题,Redis 在 5.0 版本中,就设计实现了 listpack 结构。listpack 结构沿用了 ziplist 紧凑型的内存布局,把每个元素都紧挨着放置。

综上:

  • 要想彻底解决 ziplist 连锁更新问题,本质上要修改 ziplist 的存储结构,也就是不要让每个元素保存「上一个」元素的长度,因此迭代出了 listpack
  • listpack 每个元素项不再保存上一个元素的长度,而是通过记录entry长度以及element-tot-len中特殊的结束符,来保证既可以从前也可以向后遍历
  • listpack 是为了替代 ziplist 为设计的,但因为 List/Hash/Set/ZSet 都严重依赖 ziplist,所以这个替换之路很漫长,目前只有 Stream 数据类型用到了 listpack



相关参考: