Redis源码学习 --> 基本数据结构 --> Listpack
1. 什么是Listpack?
Listpack是Redis自主研发的一种双向表数据结构,是List的底层数据结构之一。设计的核心思想是节省存储内存。
2. Listpack和Ziplist
在Redis 6之前的版本List使用Ziplist数据结构,Redis 7之后Ziplist被Listpack替代并废弃。
在大量的生产实践中,Ziplist的连锁更新问题对运行效率影响很大,问题的根源在于:Ziplist中,每个节点包含prevlen字段,用于记录前一个节点的长度。当某个节点的长度因插入/修改操作而变化时:
- 若新长度超过 254 字节(需从 1 字节扩展为 5 字节),后续所有节点的
prevlen都需同步更新。 - 这种连锁反应在最坏情况下会导致
O(n²)时间复杂度(如:插入一个超长元素触发全部节点更新)
Listpack解决连锁更新的核心优化方案:
每个节点通过
backlen字段,仅记录自身长度
3. Listpack结构分析
3.1 核心功能
Listpack需要实现以下两点核心功能:
- 保存任意个元素,每个元素是任意长度的二进制数据。
- 支持双向遍历。
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数组代替指针链式结构
整体的结构图如下:

每一个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字节保存二进制数据的大小,中间存放数据。

这个方案是可行的,但是有很大的优化空间,因为生产实践中小元素的占比远多于大元素,dataBytes统一用4字节有点浪费。可以使用类似哈弗曼编码的思想,采用变长编码的方式压缩。整体的编码方案如下:
- 整体分为两部分将前面的
dataBytes和数据一起编码,后面的dataBytes单独编码。 - 将数据分成
数字和字符串,如果二进制数据是数字字符串,则转换为数字节省大量空间。
优化后的数据结构如下:

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

数据一共分成了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的编码方案如下:

反向遍历时,通过查找第一个最高位是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的性能分析
从时间复杂度考量:
- 尾部增删改查
O(1),头部查询O(1)增删改O(n) - 随机增删改查
O(n),需要遍历整个表。
从空间复杂度考量:
- 通过紧凑的编码,虽然仍是
O(n),但是与通用链表相比没有指针字段,常数项很小。
优势:
- 占用空间少。
- 尾部操作速度快。
- 内存碎片少,所有数据使用一块内存。遍历时Cache的命中率较高可以增加访问速度。
劣势:
- 随机访问慢。
- 增删改还需要
扩容缩容以及memmove()末尾的所有数据。
为什么Listpack的劣势很致命,仍要这样设计?
可以看出,Listpack在元素少的时候,随机访问可以看成O(1)。那么换一种思路,只要一直保证元素较少,那就可以一直是O(1)。
那么如何保证元素较少?Redis设计了一个上层数据结构Quicklist,当元素较多的时候,分割成多个Listpack。详细解决方案参考数据结构Quicklist。

浙公网安备 33010602011771号