Redis 的 ziplist 和 listpack 有什么区别

在 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 的总长度(backlenbacklen 是为了支持从后向前遍历而设计的,它记录的是当前 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 变长编码)

从内存角度看,ziplistprevlen 在大多数情况下只占 1 字节,而 listpackbacklen 对于小 entry 也通常占 1 字节,所以两者内存开销接近。但在极端情况下,listpackbacklen 可能占用更多字节,不过这是为了换取性能稳定性而付出的合理代价。

五、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 解决了单个节点内部的紧凑存储和更新效率问题。

七、总结

ziplistlistpack 都是 Redis 为了节省内存而设计的紧凑结构,核心区别在于:ziplist 存储前一个 entry 的长度,导致连锁更新;listpack 存储自身 entry 的长度,彻底避免了这个问题listpackziplist 的进化版,在 Redis 7.0 中已经全面替代 ziplist。理解它们的区别,不仅能帮你应对面试,更能让你体会 Redis 在内存效率和性能稳定性之间的权衡艺术。

未经允许不得转载:任鹏个人博客 » Redis 的 ziplist 和 listpack 有什么区别

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏