Files
cs-note/hhs/Redis/02-核心数据类型/02-1-ziplist与listpack.md
T
2026-05-25 22:24:47 +08:00

11 KiB
Raw Blame History

tags, create time, author
tags create time author
Redis
底层数据结构
ziplist
listpack
2026-05-25 10:30 hhs

ziplist 与 listpack

概述

ziplist 是 Redis 为节省内存设计的一种紧凑型线性结构——把多个元素连续排列在一块内存里,省掉链表的指针开销。它是 Hash、List(quicklist 节点)、ZSet 在小数据量时的默认编码。

但 ziplist 有一个臭名昭著的缺陷:级联更新(cascade update)。Redis 7.2 引入 listpack 彻底解决了这个问题。

一、ziplist 的内存布局

[!question] 为什么要连续内存? 传统链表每个节点独立分配内存,额外的 next/prev 指针各占 8 字节(64 位系统)。存 100 个短字符串,指针开销可能比数据本身还大。ziplist 的思路是:把所有数据塞进一块连续内存,用紧凑的 header 描述每个元素的位置。

一个 ziplist 在内存中长这样:

flowchart LR
    ZB["zlbytes, 4B, 整体字节数"] --> ZT["zltail, 4B, 尾节点偏移"]
    ZT --> ZL["zllen, 2B, 节点数量"]
    ZL --> E1["entry 1"]
    E1 --> E2["entry 2"]
    E2 --> EN["entry N"]
    EN --> ZE["zlend, 0xFF, 结束标记"]

    classDef header fill:#e1f5fe,stroke:#2196f3
    classDef entry fill:#fff3e0,stroke:#ff9800
    classDef tail fill:#fce4ec,stroke:#e91e63
    class ZB,ZT,ZL header
    class E1,E2,EN entry
    class ZE tail
字段 大小 作用
zlbytes 4 字节 整个 ziplist 占用的总字节数(含自身)
zltail 4 字节 最后一个 entry 的偏移量(支持从尾部快速遍历)
zllen 2 字节 entry 节点数量(超过 65535 时需遍历计数)
entry 变长 实际数据,逐个紧密排列
zlend 1 字节 固定值 0xFF,标记结束

二、entry 的内部结构

[!question] 既然没有指针,怎么知道每个 entry 的边界? 答案是:每个 entry 用 prevlen + encoding 告诉 Redis "上一个元素多长"和"我自己是什么类型、多长"。解析时从头往后依次读,就知道每个 entry 在哪里。

每个 entry 由三部分组成:

flowchart LR
    PL["prevlen, 1 或 5B"] --> EN["encoding, 1, 2 或 5B"]
    EN --> DA["data, 实际内容"]

    classDef field fill:#e8f5e9,stroke:#4caf50
    class PL,EN,DA field
字段 说明
prevlen 前一个 entry 的长度。≤ 253 字节用 1 字节存;> 253 字节用 5 字节存(第 1 字节标记 0xFE,后 4 字节存长度)
encoding 标识 data 的类型和长度——整数还是字符串?多长?
data 实际存储的内容

encoding 的 bit 编码

[!question] encoding 字段是怎么用 1~5 个字节同时表达"类型"和"长度"的? 核心思路:用高位 bit 组合区分类型,剩余 bit 存长度或数值。这让 Redis 解析时只需读第一个字节就能判断后续格式。

字符串类型——高 2 位为 00/01/10:

编码格式 长度 bit 数 最大长度
00xxxxxx 6 bit 63 字节
01xxxxxx xxxxxxxx 14 bit 16383 字节
10xxxxxx + 4 字节 32 bit 2³²-1 字节

整数类型——高 4 位为 11xx:

编码 含义
11000000 int16,后跟 2 字节有符号整数
11010000 int32,后跟 4 字节有符号整数
11010001 int64,后跟 8 字节有符号整数
11110001 int24,后跟 3 字节有符号整数
1111xxxx (0001~1101) 立即数 0~12,data 部分为空

[!tip] 为什么存整数不直接用字符串? 把 "12345" 当字符串存需要 5 字节 + encoding;转成 int16 只要 2 字节 + 1 字节 encoding。小整数的紧凑编码是 ziplist 省内存的另一大功臣。

[!warning] prevlen 是级联更新的根源 注意到没有?prevlen 只有 1 字节(≤ 253)或 5 字节(> 253)两种取值。当一个 entry 从 ≤ 253 变成 > 253 时,它的下一个 entry 的 prevlen 必须从 1 字节扩展到 5 字节——而这可能导致更后面的 entry 也跟着扩展,形成连锁反应。

三、级联更新(Cascade Update)

这是 ziplist 最大的性能隐患。我们用一个例子说明:

触发条件

  1. 插入一个新 entry(或现有 entry 增长),导致下一个 entry 的 prevlen 从 1 字节扩展到 5 字节
  2. 这 4 字节的膨胀可能让该 entry 总长超过 253,从而导致再下一个 entry 的 prevlen 也要扩展
  3. 如此链式传播,最坏情况下所有后续 entry 都要重新分配

最坏情况复杂度

[!danger] O(N²) 的代价 假设 ziplist 有 N 个 entry,每个都刚好在 253 字节边界。一次插入触发 N 次 memmove,每次 O(N)——总计 O(N²)。

实际中很少触发最坏情况(需要连续的 253 边界 entry),但只要 ziplist 够大,风险就不可忽视。这就是为什么 Redis 给 ziplist 设了阈值——超过就切到更安全的结构。

代码验证

rdb.Del(ctx, "test:ziplist")

// 1. 小 Hash → ziplist/listpack 编码,内存紧凑
for i := 0; i < 100; i++ {
    rdb.HSet(ctx, "test:ziplist", fmt.Sprintf("f%d", i), "short")
}
enc, _ := rdb.ObjectEncoding(ctx, "test:ziplist").Result()
mem1, _ := rdb.MemoryUsage(ctx, "test:ziplist").Result()
fmt.Printf("编码: %s, 内存: %d bytes\n", enc, mem1)
// Redis 6: encoding=ziplist,  Redis 7+: encoding=listpack

// 2. 加入一个超长 value → 触发编码切换到 hashtable
rdb.HSet(ctx, "test:ziplist", "big_field", strings.Repeat("x", 65))
enc2, _ := rdb.ObjectEncoding(ctx, "test:ziplist").Result()
mem2, _ := rdb.MemoryUsage(ctx, "test:ziplist").Result()
fmt.Printf("编码: %s, 内存: %d bytes\n", enc2, mem2)
// enc2=hashtable, 内存明显增大——65 > hash-max-ziplist-value(64)

[!question] 如何观察级联更新的延迟影响? 级联更新是内部实现,客户端无法直接观察。但你可以间接感知:当 ziplist 编码的 Hash/ZSet 接近阈值时,HSET / ZADD 的 p99 延迟可能出现毛刺——因为某次写入恰好触发了多级 prevlen 扩展。这也是 Redis 设置阈值提前切换的原因之一。

在 Redis 7+ 使用 listpack 后,这类毛刺基本消失。

四、listpack:ziplist 的继任者

[!question] 如果没有 prevlen,怎么知道前一个 entry 的边界? listpack 的答案是:不需要知道前一个 entry 的长度。每个 entry 末尾存一个 element-total-len(简称 backlen),记录自己的总长度。正向遍历时,读完当前 entry 的 encoding + data,再读 backlen 跳到下一个 entry;修改任意 entry 只影响它自己的 backlen,不波及邻居。

listpack 的整体布局

flowchart LR
    LT["total-bytes, 4B"] --> LN["num-elements, 2B"]
    LN --> E1["entry 1"]
    E1 --> E2["entry 2"]
    E2 --> EN["entry N"]
    EN --> EOL["EOF, 0xFF"]

    classDef header fill:#e1f5fe,stroke:#2196f3
    classDef entry fill:#e8f5e9,stroke:#4caf50
    classDef tail fill:#fce4ec,stroke:#e91e63
    class LT,LN header
    class E1,E2,EN entry
    class EOL tail

和 ziplist 的整体结构几乎一致(total-bytes / num-elements / EOF),差别全在 entry 内部。

entry 内部:backlen 的变长编码

flowchart LR
    EN["encoding"] --> DA["data"]
    DA --> BL["backlen, 1~5B, 自己的总长度"]

    classDef field fill:#e8f5e9,stroke:#4caf50
    class EN,DA,BL field

backlen 采用类似 protobuf varint 的变长编码——高位 bit 为 1 表示后续字节仍属于 backlen,为 0 则结束。大多数 entry 总长 ≤ 127 字节,backlen 只需 1 字节,和 ziplist 的 1 字节 prevlen 一样紧凑。

[!tip] backlen 为什么能消灭级联更新? ziplist 中 A 增长 → B 的 prevlen 膨胀 → B 总长变化 → C 的 prevlen 也得改……链式传播。

listpack 中 A 增长 → A 的 backlen 可能变大,但 B 的 backlen 只记录 B 自己的长度,和 A 无关。B 不需要做任何修改,C 也不需要。传播链在 A 处就断了。

Redis 7.0+,Hash、ZSet、Stream、List(quicklist 节点)的紧凑编码全部切换为 listpack。

核心区别

flowchart TB
    subgraph ziplist_entry["ziplist entry"]
        direction LR
        ZP["prevlen, 记录前一个 entry 长度"]
        ZE["encoding"]
        ZD["data"]
    end
    subgraph listpack_entry["listpack entry"]
        direction LR
        LP["encoding"]
        LD["data"]
        LE["element-total-len, 记录自己总长"]
    end

    classDef old fill:#ffebee,stroke:#f44336
    classDef new fill:#e8f5e9,stroke:#4caf50
    class ZP,ZE,ZD old
    class LP,LD,LE new
对比 ziplist listpack
entry 间依赖 prevlen 引用前一个 entry 无,各 entry 独立
级联更新 会触发 不会触发
空间开销 prevlen 1~5 字节 element-total-len 1~5 字节(相当)
倒序遍历 依赖 prevlen 逐个跳 从尾部向前,读 element-total-len 跳
引入版本 Redis 早期 Redis 3.2 (实验), 7.2 (正式替代)

[!tip] 你现在不需要关心版本差异 理解"listpack = 无级联更新的 ziplist"就够了。Redis 的配置参数名(如 hash-max-ziplist-entries)在 7.2+ 仍然生效,只是底层实现换成了 listpack。

五、为什么 Redis 还保留阈值切换?

[!insight] 紧凑结构不是万能的 ziplist/listpack 的查找是 O(N)——要从头遍历到目标 entry。当元素少时,这块连续内存对 CPU cache 非常友好,遍历也很快。但元素一多,O(N) 的代价就压不住了,必须切到 hashtable(O(1))或 skiplist(O(logN))。

各类型的切换阈值——阈值内用紧凑编码省内存,超过阈值切到通用结构保性能:

数据类型 阈值内编码 配置项 默认值 超过阈值切到
Hash listpack ¹ hash-max-ziplist-entries 512 个 field dict(hashtable)
Hash listpack ¹ hash-max-ziplist-value 64 字节 dict(hashtable)
ZSet listpack ¹ zset-max-ziplist-entries 128 个 member skiplist + dict
ZSet listpack ¹ zset-max-ziplist-value 64 字节 skiplist + dict
List quicklist 节点内的 listpack list-max-ziplist-size ² -2(每节点 8KB) quicklist 新节点

¹ Redis 6 为 ziplist,7.0+ 为 listpack。配置参数名不变。 ² Redis 7.2+ 配置项更名为 list-max-listpack-size。

[!question] 切换后各自长什么样?

  • Hash:从一块连续内存(listpack)变成 dict——两张 hashtable 交替 rehash,查找从 O(N) → O(1)
  • ZSet:从一块连续内存(listpack)变成 skiplist + dict 的双结构——skiplist 负责有序遍历和范围查询 O(logN),dict 负责按 member 查 score O(1)
  • List:不是"丢弃 listpack",而是 quicklist 继续以 listpack 为节点内容,只是单个节点的容量上限受此阈值控制;超限时 quicklist 不会转换编码,而是把新元素放进一个新的 listpack 节点

关联笔记