Redis源码解析:listpack编码机制与底层内存布局
如果你翻过 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-6310xxxxxx7bit 整数0 字节整数 0-127110xxxxx 1字节13bit 整数1 字节整数 -8192 到 81911110xxxx 1字节12bit 字符串1 字节字符串长度 0-409511110000 2字节16bit 整数2 字节32 位有符号整数范围11110001 4字节32bit 整数4 字节32 位有符号整数范围11110010 8字节63bit 整数8 字节63 位有符号整数范围11110011 2字节16bit 字符串2 字节字符串长度 0-6553511110100 3字节24bit 字符串3 字节字符串长度 0-1677721511110101 4字节32bit 字符串4 字节字符串长度 0-2^32-111110110 6字节48bit 字符串6 字节字符串长度 0-2^48-111110111 8字节64bit 字符串8 字节字符串长度 0-2^64-1注意看源码对“到底占几个字节”的判断顺序很讲究它先看第一个字节的最高位再逐级向下细分。这本质上是用前缀码区分所有可能性任何两个编码类型都不会共享同一个前缀所以解析时不会产生歧义。2.3 “小整数塞进头部”的存储收益这张表里最精妙的是前两行。一个 0 到 127 的整数listpack 只用一个字节就存下了它的编码字节本身就是数值。举例来说整数 5 的存储就是单个字节0x850x80 | 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 末尾字节的价值因为没有了prevlenlistpack 必须另想办法支持“从尾部开始处理”。它的解决方案就是末尾的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 的复杂操作插入、删除、遍历都是从这张表推演出来的。把表记熟后面的路就顺了。