listpack 出现之前,redis 是使用 ziplist 的。7.0 后,ziplist 就已经弃用了。如今虽然还能在 7.4 中看到 ziplist 的代码,但是好像确实没有哪里是调用它的了。

为什么要更换到 listpack?以下是作者的原话,也可以见该 issue: https://github.com/redis/redis/issues/8702

image

大概意思是说 ziplist 会有级联更新。具体为什么我不了解,因为我没有读,而且已经弃用了。这个 issue 标题是 [NEW] listpack migration - replace all usage of ziplist with listpack中文翻译就是替换所有的 ziplist 为 listpack,所以说弃用似乎没有问题。

listpack 没有专门的类型,因为每一个元素大小都不一样,所以它就直接用一个unsigned char *表示。所以创建它的函数是:

unsigned char *lpNew(size_t capacity) {
    unsigned char *lp = lp_malloc(capacity > LP_HDR_SIZE+1 ? capacity : 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;
}

估计用 unsigned 的原因是警告用户这个指针不是普通的字符串。

由于 listpack 没有对应的结构体,与其聊它是如何增删改查的,还不如说说它每个元素是如何编码的。

listpack 编码

listpack 有一个 header,分别是一个 32 位整数,表示该压缩列表一共分配了多少空间,一个 16 位整数,表示它存储了多少个元素。以下宏函数,第一个是获得一共分配了多少空间,第二个是获得一共存储了多少个元素。

#define lpGetTotalBytes(p)           (((uint32_t)(p)[0]<<0) | \
                                      ((uint32_t)(p)[1]<<8) | \
                                      ((uint32_t)(p)[2]<<16) | \
                                      ((uint32_t)(p)[3]<<24))

#define lpGetNumElements(p)          (((uint32_t)(p)[4]<<0) | \
                                      ((uint32_t)(p)[5]<<8))

header 后面的,都是其存储的元素或者 EOF(EOF 可能是借用了标准库文件 IO 的那个 EOF 的意思)。lpNew 调用后,结果是这样的。后面留一截空的,表示用户可能预留分配了一定的空间,这个空间可以用 lpNew 的 capacity 指定。

image

元素

每个元素都按如下规则编码:

enc backlen enc backlen

enc 就是真正存储元素信息的部分,backlen 是为了方便反向遍历而设置的值。enc 和 backlen 占多少字节是不确定的。他们的编码也不同。redis 中,对 listpack 的元素的任何操作都必须传递该元素的开始指针 p,也就是 enc 开头的那个字节的地址,这样就可以凭借这个指针确定上一个元素的 backlen 的最后一个字节,也就是 p - 1。

那么 enc 是如何编码的?在源代码中可以见到一堆的宏定义:

#define LP_ENCODING_7BIT_UINT 0
#define LP_ENCODING_7BIT_UINT_MASK 0x80
#define LP_ENCODING_IS_7BIT_UINT(byte) (((byte)&LP_ENCODING_7BIT_UINT_MASK)==LP_ENCODING_7BIT_UINT)
#define LP_ENCODING_7BIT_UINT_ENTRY_SIZE 2

#define LP_ENCODING_6BIT_STR 0x80
#define LP_ENCODING_6BIT_STR_MASK 0xC0
...

想了想还是画个表比较好。

类型 二进制表示
6bit 表示字符串长度的 str 10xxxxxx
7bit 表示正整数的 uint 0xxxxxxx
13bit 表示带符号整数的 int 110xxxxx xxxxxxxx
12bit 表示字符串长度的 str 1110xxxx xxxxxxxx
16bit 表示带符号整数的 int 11110001 xxxxxxxx xxxxxxxx
24bit 表示带符号整数的 int 11110010 xxxxxxxx xxxxxxxx xxxxxxxx
32bit 表示带符号整数的 int 11110011 xxxxxxxx ......
64bit 表示带符号整数的 int 11110100 xxxxxxxx ......
32bit 表示字符串长度的 str 11110000 字符串
EOF 11111111

仔细看就会发现,每一个编码的前缀都不一样。这也就是 redis 分辨某一个元素的类型的依据。其中表示 int 类型的部分,会把它变成整数后再存储。原因是补码表示有一定问题。比如 13 bit 带符号整数会这么转换:

    } else if (v >= -4096 && v <= 4095) {
        /* 13 bit integer. */
        if (v < 0) v = ((int64_t)1<<13)+v;
        if (intenc != NULL) {
            intenc[0] = (v>>8)|LP_ENCODING_13BIT_INT;
            intenc[1] = v&0xff;
        }
        if (enclen != NULL) *enclen = 2;
    }
...

接下来就是 backlen 的编码。backlen 是无符号整数,所以不需要考虑负数的问题。它的想法就是把 backlen 分成 7 位一组。

范围 结果
[0,2^7-1) 0xxxxxxx
[2^7,2^14-1) 0xxxxxxx 1xxxxxxx
[2^14,2^21-1) 0xxxxxxx 1xxxxxxx 1xxxxxxx
[2^21,2^28-1) 0xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx
[2^28,2^32-1) 0xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx

最后一行不是 2^35,原因是 listpack 最多也就只能到 2^32 大小。

下面是一个例子(对,string 不以 \0 结尾)

image

listpack 增删改操作

其实若真理解了 listpack 的编码,这一部分不是很有必要。因为,增无非是计算一下新增元素占多少空间,backlen 占多少空间,调用 memmove 移动一下插入位置以及其后面的元素。删无非是把后面的移动到前面。改也类似。所以 listpack 就把这几个功能做成了一个函数 lpInsert。增删改值得提的就是:

  1. 把插入到后面转换为一次 next,再插入到前面。
    if (where == LP_AFTER) {
        p = lpSkip(p);
        where = LP_BEFORE;
        ASSERT_INTEGRITY(lp, p); // do-while 的写法
    }
  1. realloc 之后一定要更新 offset
        if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
        dst = lp + poff;
  1. 如果一个字符串表示为一个整数,那么 listpack 会把它存储为整数
static inline int lpEncodeGetType(unsigned char *ele, uint32_t size, unsigned char *intenc, uint64_t *enclen) {
    int64_t v;
    if (lpStringToInt64((const char*)ele, size, &v)) {
        lpEncodeIntegerGetType(v, intenc, enclen);
        return LP_ENCODING_INT;
    } else {
        if (size < 64) *enclen = 1+size;
        else if (size < 4096) *enclen = 2+size;
        else *enclen = 5+(uint64_t)size;
        return LP_ENCODING_STRING;
    }
}
  1. 批量插入,批量删除,范围删除也无非是把多次 memmove 操作变成一次移动罢了。

  2. listpack 中共用户使用的结构体。它在批量操作、随机获取元素中会用。

/* Each entry in the listpack is either a string or an integer. */
typedef struct {
    /* When string is used, it is provided with the length (slen). */
    unsigned char *sval;
    uint32_t slen;
    /* When integer is used, 'sval' is NULL, and lval holds the value. */
    long long lval;
} listpackEntry;
  1. lpGetWithSize 的参数含义。这个函数也是从 listpack 中获得值的方法。除此之外里面还有一个方便检查错误的技巧。
static inline unsigned char *lpGetWithSize(unsigned char *p, int64_t *count,
                                           unsigned char *intbuf, uint64_t *entry_size);
    // 如果 p 是字符串,那么
    //   1. 返回 p 这个字符串的指针
    //   2. *count 设置为该字符串长度
    //   3. *entry_size 设置为 p 这个节点的总空间占用
    // 如果 p 是整数,
    //   如果 intbuf 是 NULL,那么
    //     1. *count 设置为该整数
    //     2. 返回 NULL
    //   否则
    //     1. 将该整数用字符串表示到 intbuf
    //     2. 返回 intbuf
...
    } else {
        uval = 12345678900000000ULL + p[0];
        negstart = UINT64_MAX;
        negmax = 0;
    }
...
  1. 获取 enc 的大小

redis 给了两个版本,一个是 Unsafe,它不仅仅会访问 p 的第一个字节,后续的一部分也会被访问到,用于获得整个编码结果的长度。另一个版本则只访问 p 的第一个字节,只能确定类型,以及整数类型的编码长度。之所以一个字节就够了,是因为前面提到的编码,只要确定第一个字节就可以确定该元素存储的值的类型了。

static inline uint32_t lpCurrentEncodedSizeUnsafe(unsigned char *p);

static inline uint32_t lpCurrentEncodedSizeBytes(unsigned char *p);

listpack 查找元素

由于 listpack 会把可以转换为整数的字符串直接存储为整数,所以查找的过程并不是简单的字符串匹配。

它首先还是遍历,遍历的时候进行比较。比较函数稍微有点意思。

/* Comparator function to find item */
static inline int lpFindCmp(const unsigned char *lp, unsigned char *p,
                            void *user, unsigned char *s, long long slen) {
    struct lpFindArg *arg = user;
    if (s) {
        if (slen == arg->slen && memcmp(arg->s, s, slen) == 0) {
            return 0;
        }
    } else {
        if (arg->vencoding == 0) {
            if (arg->slen >= 32 || arg->slen == 0 || !lpStringToInt64((const char*)arg->s, arg->slen, &arg->vll)) {
                arg->vencoding = UCHAR_MAX;
            } else {
                arg->vencoding = 1;
            }
        }
        if (arg->vencoding != UCHAR_MAX && slen == arg->vll) {
            return 0;
        }
    }
...

s 如果是 NULL,把 slen 当作整数看待,否则把它当作字符串 s 的长度看待。因为 listpack 可能会把字符串转换为 int,所以这里的 arg->vll 相当于一个结果缓存,而 arg->vencoding 保证改转换只发生一次。调用它的函数是 lpFindCb,它被 lpFind 调用。

unsigned char *lpFind(unsigned char *lp, unsigned char *p, unsigned char *s,
                      uint32_t slen, unsigned int skip)
{
    struct lpFindArg arg = {
        .s = s,
        .slen = slen
    };
    return lpFindCb(lp, p, &arg, lpFindCmp, skip);
}

lpFind 的参数单纯表示字符串,所以 lpFindCmp 有字符串转 int。

listpack 随机获取值

随机获取值的流程是,先获取一个随机下标,再掉 lpSeek。所以这个效率比较低。

void lpRandomPair(unsigned char *lp, unsigned long total_count,
                  listpackEntry *key, listpackEntry *val, int tuple_len)
{
    unsigned char *p;

    assert(tuple_len >= 2);

    /* Avoid div by zero on corrupt listpack */
    assert(total_count);

    int r = (rand() % total_count) * tuple_len;
    assert((p = lpSeek(lp, r)));
    key->sval = lpGetValue(p, &(key->slen), &(key->lval));

    if (!val)
        return;
    assert((p = lpNext(lp, p)));
    val->sval = lpGetValue(p, &(val->slen), &(val->lval));
}