在 Redis 的底层数据结构中,ziplist(压缩列表)和 listpack(紧凑列表)是两个经常被拿来比较的角色。如果你在准备 Redis 面试,或者阅读 Redis 源码,一定会遇到这两个名字。它们都为了节省内存而生,都用于存储小规模数据,但设计哲学和实现细节有本质区别。本文将从结构、连锁更新问题、性能以及版本演进几个角度,把它们的区别讲清楚。
一、为什么需要这两种结构?
Redis 是基于内存的数据库,内存是稀缺资源。为了在存储小对象时避免为每个元素都分配一个完整的 redisObject 或链表节点,Redis 设计了紧凑的字节数组结构。ziplist 是早期版本(Redis 3.2 之前)广泛使用的紧凑结构,用于 List、Hash、ZSet 等类型的小对象存储。而 listpack 是在 Redis 5.0 引入、Redis 7.0 全面替代 ziplist 的新一代紧凑结构。
两者的目标一致:用一段连续内存存储多个元素,减少指针开销和内存碎片。但 ziplist 有一个致命缺陷——连锁更新,listpack 正是为了解决这个问题而诞生的。
二、ziplist 的结构与连锁更新问题
一个 ziplist 在内存中的布局如下:
<zlbytes> <zltail> <zllen> <entry1> <entry2> ... <entryN> <zlend>
zlbytes:整个 ziplist 占用的字节数zltail:最后一个 entry 的偏移量,方便反向遍历zllen:entry 数量(超过 65535 时需遍历才能知道真实数量)zlend:结束标记 0xFF
每个 entry 的结构是:
<prevlen> <encoding> <data>
关键在 prevlen:它记录前一个 entry 的总长度,用于从后向前遍历。prevlen 本身占 1 字节或 5 字节:
- 如果前一个 entry 长度小于 254 字节,
prevlen用 1 字节存储 - 否则用 5 字节(第一个字节为 0xFE,后 4 字节存实际长度)
连锁更新就发生在这里:假设有一个 ziplist,每个 entry 长度都在 250~253 字节之间,prevlen 都是 1 字节。现在在头部插入一个长度为 254 字节的新 entry,那么第二个 entry 的 prevlen 需要从 1 字节扩展为 5 字节,导致第二个 entry 总长度增加 4 字节。如果第二个 entry 原本长度恰好是 253,增加后变成 257,又会导致第三个 entry 的 prevlen 扩展……以此类推,可能引发后续所有 entry 的连锁扩容。
最坏情况下,一次插入操作的时间复杂度是 O(N²)。虽然实际发生概率较低,但这是 ziplist 的固有缺陷。
三、listpack 的结构与改进
listpack 的设计目标就是消除连锁更新。它的内存布局:
<total-bytes> <num-elements> <entry1> <entry2> ... <entryN> <0xFF>
每个 entry 的结构变为:
<encoding> <data> <backlen>
注意关键变化:listpack 的 entry 不再存储前一个 entry 的长度,而是存储自身 entry 的总长度(backlen)。backlen 是为了支持从后向前遍历而设计的,它记录的是当前 entry 的长度(不包括 backlen 本身),并且采用变长编码,最多 5 字节。
这样设计的好处是:每个 entry 的长度只取决于自身,不依赖前一个 entry。当某个 entry 被修改时,不会影响其他 entry 的字段大小,从根本上杜绝了连锁更新。
那么反向遍历怎么实现?从 listpack 尾部开始,先读取最后一个字节(0xFF 之前),根据 backlen 的编码规则解析出当前 entry 的长度,从而定位到前一个 entry 的起始位置,再读取它的 backlen,依次向前。虽然反向遍历比 ziplist 稍微复杂一点,但换来了插入/删除时的稳定性。
四、核心区别对比
| 特性 | ziplist | listpack |
|---|---|---|
| 引入版本 | Redis 早期 | Redis 5.0 |
| 每个 entry 存储 | prevlen + encoding + data | encoding + data + backlen |
| 反向遍历依据 | 前一个 entry 的长度 | 当前 entry 自身的长度 |
| 连锁更新 | 存在,最坏 O(N²) | 不存在 |
| 内存开销 | 略小(prevlen 通常 1 字节) | 略大(backlen 通常 1~5 字节) |
| 适用场景 | Redis 7.0 之前的小对象 | Redis 7.0 之后全面替代 ziplist |
| 实现复杂度 | 较低 | 稍高(backlen 变长编码) |
从内存角度看,ziplist 的 prevlen 在大多数情况下只占 1 字节,而 listpack 的 backlen 对于小 entry 也通常占 1 字节,所以两者内存开销接近。但在极端情况下,listpack 的 backlen 可能占用更多字节,不过这是为了换取性能稳定性而付出的合理代价。
五、Redis 7.0 的全面替换
Redis 7.0 做了一个重要决定:用 listpack 完全替代 ziplist。具体来说:
- Hash 类型的底层编码从
ziplist改为listpack - ZSet 类型的底层编码从
ziplist改为listpack - List 类型在 Redis 3.2 之后已经改用
quicklist,而quicklist的节点在 Redis 7.0 中也从ziplist换成了listpack
这意味着在 Redis 7.0 中,ziplist 已经彻底退出了历史舞台。你仍然可以在配置文件中看到 list-max-ziplist-size 这样的参数名(为了兼容性保留),但实际底层已经是 listpack。
六、面试常见追问
1. 为什么 listpack 不直接存储前一个 entry 的长度?
因为那正是连锁更新的根源。存储自身长度可以让每个 entry 独立,修改一个 entry 不会影响其他 entry 的字段大小。
2. listpack 的 backlen 为什么是变长编码?
为了节省内存。小 entry 的 backlen 只需 1 字节,大 entry 才需要更多字节。如果固定 4 字节或 8 字节,内存浪费会很明显。
3. ziplist 的连锁更新在实际中严重吗?
不严重,因为触发条件比较苛刻:需要连续多个 entry 长度都在 250~253 字节附近。但 Redis 作为基础组件,不能容忍这种最坏情况,所以设计了 listpack 来彻底解决。
4. quicklist 和 listpack 是什么关系?
quicklist 是 List 类型的底层结构,它是一个双向链表,每个链表节点是一个 listpack(Redis 7.0 之前是 ziplist)。quicklist 解决了大 List 的存储问题,listpack 解决了单个节点内部的紧凑存储和更新效率问题。
七、总结
ziplist 和 listpack 都是 Redis 为了节省内存而设计的紧凑结构,核心区别在于:ziplist 存储前一个 entry 的长度,导致连锁更新;listpack 存储自身 entry 的长度,彻底避免了这个问题。listpack 是 ziplist 的进化版,在 Redis 7.0 中已经全面替代 ziplist。理解它们的区别,不仅能帮你应对面试,更能让你体会 Redis 在内存效率和性能稳定性之间的权衡艺术。
未经允许不得转载:任鹏个人博客 » Redis 的 ziplist 和 listpack 有什么区别

