Redis源码学习 --> 基本数据结构 --> Listpack

1. 什么是Listpack?

Listpack​​是Redis自主研发的一种双向表数据结构,是List的底层数据结构之一。设计的核心思想是节省存储内存。

2. Listpack和Ziplist

在Redis 6之前的版本List使用Ziplist数据结构,Redis 7之后ZiplistListpack替代并废弃。

在大量的生产实践中,Ziplist连锁更新问题对运行效率影响很大,问题的根源在于:Ziplist中,每个节点包含prevlen字段,用于记录前一个节点的长度。当某个节点的长度因插入/修改操作而变化时:

  1. 若新长度超过 254 字节(需从 1 字节扩展为 5 字节),后续所有节点的prevlen都需同步更新。
  2. 这种连锁反应在最坏情况下会导致O(n²)时间复杂度(如:插入一个超长元素触发全部节点更新)

Listpack解决连锁更新的核心优化方案:

每个节点通过backlen字段,仅记录自身长度

3. Listpack结构分析

3.1 核心功能

Listpack需要实现以下两点核心功能:

  1. 保存任意个元素,每个元素是任意长度的二进制数据。
  2. 支持双向遍历。

3.2 用双向链表实现核心需求

如果使用通常的双向链表,Node的数据结构如下:

struct Node
{
    uint8_t *data;
    uint32_t len;
    struct Node *prev;
    struct Node *next;
};

现代计算机通常都是64bit机器字长,上述结构体至少需要28字节。
如果只是为了保存一个字节数据使用28字节过于浪费,于是设计了Listpack这个数据结构。

3.3 Listpack的优化方案

3.3.1 用uint8_t数组代替指针链式结构

整体的结构图如下:

Listpack-1-datastruct

每一个Node数据是一个element,在数组中紧密排列。6字节头部存放element总字节数和总个数,尾部是1字节0xFF结尾。

如果一个元素都没有的空表,总共占7字节,内容是:{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}
NumElements大小是2B,最多可以表示65535个元素。当元素超过65535个时强制设为65535,实际长度必须通过遍历整个listpack得到,时间复杂度退化成O(n)

因为是数组结构,增加和删除元素时必须重新分配内存调整大小,以及移动后面的所有元素,是一个O(n)的操作。

3.3.2 element编码压缩

每个element如果不进行编码,要实现保存二进制数据和双向遍历的功能,很容易想到这样一个element结构。
在两端用4字节保存二进制数据的大小,中间存放数据。

Listpack-1-element-1

这个方案是可行的,但是有很大的优化空间,因为生产实践中小元素的占比远多于大元素,dataBytes统一用4字节有点浪费。可以使用类似哈弗曼编码的思想,采用变长编码的方式压缩。整体的编码方案如下:

  1. 整体分为两部分将前面的dataBytes数据一起编码,后面的dataBytes单独编码。
  2. 将数据分成数字字符串,如果二进制数据是数字字符串,则转换为数字节省大量空间。

优化后的数据结构如下:
Listpack-1-element-2

  1. 字段的基本单位为bit,多个字段连续存储不浪费一个bit。因为多个字段合并了,backLen保存的是前面所有字段的总长度(不包含backlen自身)。
  2. 进一步细分多个等级,将小元素采用短编码。string data不参与编码,原封不动拷贝。

最终 encode+(number datastring len + string data)的编码方案如下:

Listpack-1-listpack-data-encode

数据一共分成了9个等级。
最短的7 bit无符号数,encoding字段只占用1个bit固定为0,后面的number data占用7 bit,可表示[0, 127]的数字。
最长的32 bit字符串,encoding字段占用8个bit,string len占用32 bit,string data最大可以是4GB大小。

前向遍历时,通过判断encoding字段内容,可解析出实际长度。

static inline uint32_t lpCurrentEncodedSizeUnsafe(unsigned char *p) {
    if (LP_ENCODING_IS_7BIT_UINT(p[0])) return 1;
    if (LP_ENCODING_IS_6BIT_STR(p[0])) return 1+LP_ENCODING_6BIT_STR_LEN(p);
    if (LP_ENCODING_IS_13BIT_INT(p[0])) return 2;
    if (LP_ENCODING_IS_16BIT_INT(p[0])) return 3;
    if (LP_ENCODING_IS_24BIT_INT(p[0])) return 4;
    if (LP_ENCODING_IS_32BIT_INT(p[0])) return 5;
    if (LP_ENCODING_IS_64BIT_INT(p[0])) return 9;
    if (LP_ENCODING_IS_12BIT_STR(p[0])) return 2+LP_ENCODING_12BIT_STR_LEN(p);
    if (LP_ENCODING_IS_32BIT_STR(p[0])) return 5+LP_ENCODING_32BIT_STR_LEN(p);
    if (p[0] == LP_EOF) return 1;
    return 0;
}

最终backlen的编码方案如下:

Listpack-1-listpack-backlen-encode

反向遍历时,通过查找第一个最高位是0的字节为终止字节,然后开始解析。

/* Decode the backlen and returns it. If the encoding looks invalid (more than
 * 5 bytes are used), UINT64_MAX is returned to report the problem. */
static inline uint64_t lpDecodeBacklen(unsigned char *p) {
    uint64_t val = 0;
    uint64_t shift = 0;
    do {
        val |= (uint64_t)(p[0] & 127) << shift; // 拼接7bit数据
        if (!(p[0] & 128)) break; // 如果最高位为0表明结束
        shift += 7; 
        p--;
        if (shift > 28) return UINT64_MAX; // 防御性编程: 防止指针越界导致死循环
    } while(1);
    return val;
}

4 Listpack的性能分析

从时间复杂度考量:

  1. 尾部增删改查O(1),头部查询O(1) 增删改O(n)
  2. 随机增删改查O(n),需要遍历整个表。

从空间复杂度考量:

  1. 通过紧凑的编码,虽然仍是O(n),但是与通用链表相比没有指针字段,常数项很小。

优势:

  1. 占用空间少。
  2. 尾部操作速度快。
  3. 内存碎片少,所有数据使用一块内存。遍历时Cache的命中率较高可以增加访问速度。

劣势:

  1. 随机访问慢。
  2. 增删改还需要扩容缩容以及memmove()末尾的所有数据。

为什么Listpack的劣势很致命,仍要这样设计?

可以看出,Listpack在元素少的时候,随机访问可以看成O(1)。那么换一种思路,只要一直保证元素较少,那就可以一直是O(1)。
那么如何保证元素较少?Redis设计了一个上层数据结构Quicklist,当元素较多的时候,分割成多个Listpack。详细解决方案参考数据结构Quicklist

posted @ 2025-09-14 21:43  -蓝蜗牛-  阅读(180)  评论(0)    收藏  举报