☰
Redis源码解析:listpack编码机制与底层内存布局
2026/10/3 6:40:05 网站建设 项目流程

如果你翻过 Redis 的源码目录,大概率会有一个跟我类似的感受:listpack.c这 534 行,看起来像一堆位运算和宏定义的集合,远没有哈希表、跳表那么“有画面”。但它恰恰是 Redis 7.0 之后所有小结构数据(小 list、小 hash、小 zset)的地基。不夸张地说,读懂listpack.c,你就拿到了理解 Redis 内存紧凑布局的半张门票。

这篇笔记的范围很明确:listpack.c的 0-534 行,大约是整个文件的前三分之一,核心内容是文件头部注释、编码宏定义,以及元素解码路径上的几个核心辅助函数。这一部分不涉及插入、删除、扩容这些操作,但它把所有“一个元素在内存里长什么样”的规则交代清楚了。我读这部分的时候最大的感受是:它不像代码,更像一份写在 C 语言里的“存储协议”实现。接下来我就按自己实际阅读的顺序,把这一段的要害逐个拆开。

1. listpack.c 前半段:文件里到底有什么

很多人拿到源码喜欢从lpNew、lpInsert这类对外接口开始看,这是常见的一个误区。listpack.c与普通数据结构不同,它最大的复杂度不在插入删除逻辑,而在一套非常细致的编码规则。0-534 行恰好就是这套规则的落地区域,不先把这里的位模式搞清楚,后面所有函数你都会看得一头雾水。

1.1 一段代码的“任务描述”

先看一眼这段代码在全文件中的定位。listpack.c是 Redis 用来替代 ziplist 的紧凑列表结构,它的目标是:用最少的字节数存储一系列字符串或整数,同时保证正序遍历、尾插入这些操作足够快。

0-534 行范围内,你会遇到几类东西:

  • 头文件与设计注释:说明 listpack 的整体内存布局和设计取舍。
  • 一长串以LP_ENCODING_开头的宏定义:它们定义了元素编码类型的位模式。
  • 一组静态辅助函数,比如lpGetEncodedType、lpGet、lpGetInteger、lpStringToInt64:从字节序列中识别并解出元素内容。

我把这一部分理解成“协议解析层”。它要做的事归结起来就两个:给定内存里的一个字节,判断这个字节属于什么编码;给定一个元素的起始位置,把元素真正的值(字符串或整数)取出来。所有后续的遍历、插入、删除操作,最终都要落到这两个能力上。

1.2 读之前你得先知道的两件事

第一件:listpack 不是链表,它是一整块连续内存。它没有传统的 next/prev 指针,元素之间靠“编码头 + 数据”顺序排列。这意味着任何读取都必须先知道上一个元素在哪、这个元素占几个字节,而这正是解码函数存在的意义。

第二件:listpack 的元素在语义上都是字符串,但它在存储层做了整数压缩。也就是说,你在业务层写入一个整数,底层可能根本不存 ASCII 数字,而是直接存二进制数值,甚至把数值塞进编码字节本身。不理解这个设计,看lpGet的返回值时容易懵——它明明存的是整数,取出来却是一个 char 指针,指向一块“字符化”的缓冲区。

如果你带着这两件事去读 0-534 行,这段代码的“任务描述”就清楚了:它不是一个数据结构,而是一套存储协议的编解码实现。

2. 一张编码表记住 listpack 的内存布局

我给自己画过一张“listpack 内存地图”,放到这篇文章里可以直接抄作业。整个 listpack 从前往后依次是:6 字节头部、若干个元素、末尾的 0xFF 结束标记。

2.1 头部与结束标记

头部 6 字节由两部分组成:

  • 前 4 字节:记录整个 listpack 的总字节数,使用小端序。
  • 后 2 字节:记录元素个数。如果元素个数超过 65535,这里写入一个特殊值LP_HDR_NUMELE_UNK表示“个数未知”,需要遍历确认。

末尾固定有一个字节0xFF。它有两个作用:一是作为结构边界,二是供后向遍历时定位最后一个元素。这个设计我在第 4 节还会展开说。

2.2 元素编码:一表流

每个元素由“编码头 + 数据”组成,编码头的第一个字节决定了后续数据如何解释。源码里用宏定义了十几种编码,归纳下来是下面这张表:

首个字节位模式编码类型额外数据长度可表示范围
0xxxxxxx6bit 字符串0 字节字符串长度 0-63
10xxxxxx7bit 整数0 字节整数 0-127
110xxxxx+ 1字节13bit 整数1 字节整数 -8192 到 8191
1110xxxx+ 1字节12bit 字符串1 字节字符串长度 0-4095
11110000+ 2字节16bit 整数2 字节32 位有符号整数范围
11110001+ 4字节32bit 整数4 字节32 位有符号整数范围
11110010+ 8字节63bit 整数8 字节63 位有符号整数范围
11110011+ 2字节16bit 字符串2 字节字符串长度 0-65535
11110100+ 3字节24bit 字符串3 字节字符串长度 0-16777215
11110101+ 4字节32bit 字符串4 字节字符串长度 0-2^32-1
11110110+ 6字节48bit 字符串6 字节字符串长度 0-2^48-1
11110111+ 8字节64bit 字符串8 字节字符串长度 0-2^64-1

注意看,源码对“到底占几个字节”的判断顺序很讲究:它先看第一个字节的最高位,再逐级向下细分。这本质上是用前缀码区分所有可能性,任何两个编码类型都不会共享同一个前缀,所以解析时不会产生歧义。

2.3 “小整数塞进头部”的存储收益

这张表里最精妙的是前两行。一个 0 到 127 的整数,listpack 只用一个字节就存下了,它的编码字节本身就是数值。举例来说,整数 5 的存储就是单个字节0x85(0x80 | 5)。

对比一下其他方案:如果用固定 8 字节存 long long,再算上元信息,一个整数条目少说占用十几个字节。listpack 用一字节解决 0-127 这个高频区间,设计思路和 ASCII 字符编码有异曲同工之处——把“最常出现的值”映射到最短的表示。

我当时看lpEncodeGetType时有个很直观的体会:源码在决定元素编码方式之前,会先用严格的字符串转整数函数尝试把字符串“看成”整数,如果能转且位数足够,就优先走整数编码。这样做的原因明摆着——很多业务写入的虽然是字符串形式的数字,但底层完全可以用二进制整数存得更省。

3. 解码函数拆解:从字节到值的 4 个动作

0-534 行不只是宏定义,还包含了解码的关键函数。我按调用链从外到里拆一下,你会发现它们的分工非常清晰。

3.1 lpGetEncodedType:首字节识类型

这个函数是解码链路的起点。它接收一个指向元素编码头的指针,返回该元素使用的编码类型。

实现逻辑用大白话说就是:不断检查首字节的前缀位。大致流程可以理解成:

if (p[0] & 0x80) { // 最高位为 1,属于长编码类型,继续按次高位细分 } else { // 最高位为 0,是 6bit 字符串 }

真实源码里用了一组掩码宏逐层剥开位模式,比如LP_ENCODING_IS_7BIT_UINT、LP_ENCODING_IS_13BIT_INT之类。这里有个细节值得注意:判断顺序必须从“长编码”向“短编码”走,否则短编码的特殊前缀会干扰判断。这种前缀码的设计保证了解析时不需要回溯,线性扫描就能确定类型。

3.2 lpGet:字符串与整数的分流取值

拿到编码类型之后,真正取出元素内容的是lpGet。它的签名大致是这样:

unsigned char *lpGet(unsigned char *p, int64_t *count, unsigned char *intbuf);

p是元素起始位置,count用于传出数据长度,intbuf是调用者提供的一块临时缓冲区。为什么要临时缓冲区?因为 listpack 在语义上返回的是“字符串”,但底层如果是整数编码,它没有现成的字符串字节可以返回。于是源码只好把整数解码后,通过类似整数转字符串的方式写进intbuf,再返回intbuf指针。

换句话说,lpGet的返回值永远是一个“字符串形态”的数据。即使底层存的是二进制整数,你拿到的也是它的 ASCII 表示。这个设计对上层很友好,因为 listpack 大量被哈希表、列表、有序集合用作底层存储,上层代码不希望面对“同一种元素有两种数据形态”的复杂性。

3.3 lpGetInteger 与 lpStringToInt64:整数解析的两条路径

lpGetInteger是给确定该元素为整数的场景用的快速路径,它跳过字符串转换,直接返回 int64 数值。调用方必须保证元素确实是整数编码,否则行为未定义。源码里在调用它之前通常会有编码类型校验。

lpStringToInt64则更有意思。它不是用来解码的,而是用来判断“字符串能不能当成整数编码存”。源码不能直接用strtoll之类的库函数,因为strtoll对输入太宽容:它接受前导空白、尾部垃圾字符,甚至能解析十六进制前缀。listpack 需要在“字符串转整数”时不放过任何异常情况,只识别严格的十进制整数格式。这个函数逐字符扫描,负责处理符号、溢出、空串等问题,返回是否转换成功。

我读到这里时意识到,这个函数是 0-534 行里最容易被低估的部分。它是写路径与读路径之间的桥梁:写路径靠它决定“能不能压缩成整数”,读路径靠它保持“整数能被还原成字符串”。两个方向对“合法数字”的定义必须完全一致,否则写入的整数将来取回来就不对劲。

4. 为什么我说这一切是在“修 ziplist 的作业”

只讲编码表还不够,你得理解 listpack 为什么要这样设计。0-534 行里的很多决定,本质上都是在针对 ziplist 的痛点做修正。

4.1 ziplist 的 prevlen 连锁更新问题

ziplist 的每个 entry 包含一个prevlen字段,记录前一个元素的字节长度。这意味着当你删除或插入一个元素、导致某个元素长度变化时,它后面的元素就必须更新自己的prevlen;而更新prevlen又可能让后面的元素长度变化,引发新一轮更新。这就是臭名昭著的连锁更新(cascade update)。

极端情况下,一次删除操作可能引发 O(N) 次内存搬移,整体复杂度能到 O(N^2)。这对追求稳定延迟的 Redis 来说是不能接受的。

4.2 去掉 prevlen 之后的取舍

listpack 最核心的设计决策就是:元素之间不再保存前一个元素的长度信息。没有prevlen,任何元素大小的变化都只影响当前元素,不可能往后续元素传递。连锁更新问题从根上消失了。

代价是什么?代价是后向遍历变难了。ziplist 靠prevlen可以轻松从后往前跳;listpack 没有这个信息,想找前一个元素,就得从头开始重新遍历。源码里的lpPrev因此是 O(N) 的,我在业务开发中会刻意避免依赖它。好在 Redis 对 listpack 的典型使用场景(小 list、小 hash、小 zset)主要依赖正序遍历和尾插入,后向遍历不是高频操作。

4.3 末尾字节的价值

因为没有了prevlen,listpack 必须另想办法支持“从尾部开始处理”。它的解决方案就是末尾的0xFF字节。定位最后一个元素时,从 0xFF 前一个字节开始,向前解码出该元素的长度,从而确定最后一个元素的起始位置。这也是为什么 0-534 行的解码逻辑如此重要——没有精确的元素长度计算能力,后向定位就无从谈起。

所以你看,整个 0-534 行表面在解决“怎么读取一个元素”,实际在为“listpack 能否替代 ziplist”提供底层保障。编码设计上的每一分节省,都是在换取更高的存储密度和更稳定的操作复杂度。

5. 验证这段代码理解的三种方法

读源码最怕的就是“以为懂了”。我读完 0-534 行之后,用了三个方法验证自己对编码表的理解,都比较实用,分享给你。

5.1 从测试文件入手

Redis 源码里有配套的单元测试,比如test-listpack.c,里面会构造各种边界的字符串和整数,再验证读取结果。看测试用例是理解编码行为的捷径,因为测试把源码作者预期中的边界条件直接列出来了。我当时重点看了针对lpGet和lpStringToInt64的测试,很多易错的细节(比如负数编码、超长字符串、0 值)都会在那里出现。

5.2 手工构造内存快照

用一个简单的 C 程序,手动拼出一块 listpack 内存,再调用解码函数验证。比如构造两个元素:整数 5 和一个字符串"abc"。

// 示意图:不直接可编译,用于理解布局 unsigned char lp[512]; // 第 1-4 字节:总长度 // 第 5-6 字节:元素个数 2 // 第 7 字节:0x85(整数 5) // 第 8 字节:0x03(字符串长度 3) // 第 9-11 字节:'a' 'b' 'c' // 第 12 字节:0xFF

手动算一遍总长度是 12 字节,然后用lpGet依次取出两个元素,看输出的值是否和预期一致。这个方法能把“位模式”这种抽象概念变成肉眼可见的字节序列,印象会非常深。

5.3 用 DEBUG OBJECT 观察真实对象

Redis 的DEBUG OBJECT命令会显示 key 的底层编码类型,比如encoding:listpack。我通常这样操作:先用一个小 list 或小 hash 写入若干元素,再执行DEBUG OBJECT key查看serializedlength字段。如果存储的是纯小整数列表,序列化长度会明显小于直接存字符串的方案。配合MEMORY USAGE key观察内存占用,就能直观感受到编码表的收益。

当然DEBUG OBJECT属于危险命令,生产环境千万别开,本地验证就够用了。

6. 读完这 534 行,我的一些方法沉淀

最后想聊几句阅读方法,因为listpack.c0-534 行这段代码的阅读方式,对我后面读 Redis 其他模块帮助很大。

6.1 先从宏和数据布局入手,而不是从函数逻辑入手

函数逻辑是“怎么算”,宏定义和数据布局才是“算什么”。listpack 的编码宏、头部定义、结束标记,这些事读明白了,函数体就变成了机械执行。反过来硬读函数,很容易陷进位运算里出不来。

6.2 警惕那些“看起来多余”的辅助函数

lpStringToInt64在最开始读时很容易被我当成“一个普通的字符串转整数工具”跳过。但实际上它承载了写路径与读路径的一致性约束。源码里很多这种不起眼的辅助函数,往往隐藏着设计上最重要的约束条件。看到这样的函数,先问一句:它存在的边界条件是什么?哪个调用方依赖它的严格性?这样读源码的效率会高很多。

6.3 带着版本演进意识去读

listpack 不是凭空产生的,它是 ziplist 的替代品。读它的编码设计时,脑子里始终要有一根弦:这个设计解决了 ziplist 的哪个问题?去掉prevlen解决了连锁更新,但付出了反向遍历 O(N) 的代价;用前缀码区分类型,让解析不需要回溯,但限制了编码可扩展性。这类“设计权衡”才是源码阅读真正值钱的部分——它不是知识,而是判断力。

最后再分享一个小习惯:我读这类编码密集型代码时,会把那张编码表抄在纸上贴在显示器旁边,边读代码边对照。读到后面你会发现,整个 listpack 的复杂操作(插入、删除、遍历)都是从这张表推演出来的。把表记熟,后面的路就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询