一文吃透 Redis 压缩列表、listpack 及哈希表扩容与并发查询

Redis 为了在内存性能之间做极致平衡,在不同数据类型的小数据量场景下,使用了非常紧凑的内存结构:ziplist(压缩列表)listpack(紧凑列表),以及作为核心的dict(哈希表)在扩容时的渐进式 rehash 机制。

下面从原理、结构、优缺点、演进、源码关键点、实际影响几个维度全面拆解。

1. 压缩列表(ziplist) —— Redis 早期“省内存神器”

适用对象(Redis 6及更早版本默认):

  • hash / zset / list(元素少且值短时)
  • 阈值示例(可配置):
    • hash:hash-max-ziplist-entries 512hash-max-ziplist-value 64
    • zset:zset-max-ziplist-entries 128zset-max-ziplist-value 64
    • list:list-max-ziplist-size

内存布局(连续内存块)

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <0xFF>
  4字节     4字节     2字节     变长           1字节(结束标志)

每个 entry 结构:

<prevlen> <encoding> <entry-data>
  • prevlen:前一个 entry 的字节长度(1 或 5 字节)
    • ≤254 → 1 字节
    • 254 → 5 字节(0xFE + 4字节长度)

  • encoding:当前 entry 的类型和长度(1 字节起)
    • 字符串:6bit 长度 + 内容,或特殊编码
    • 整数:特殊编码(立即数、int8/16/24/32/64)
  • entry-data:实际内容

最大优势极致省内存,没有指针,全部紧挨着存放。

致命缺陷连锁更新(cascade update)

当某个 entry 长度变化(例如从小字符串变长):

  • 如果 prevlen 由 1 字节 → 5 字节,后续所有 entry 的 prevlen 都要右移
  • 右移又可能导致下一个 prevlen 溢出 → 连锁反应
  • 最坏情况:O(N) 时间,N 是元素个数

结论:ziplist 适合极小值长度稳定的场景,一旦频繁更新大值,会出现性能毛刺。

2. listpack —— Redis 7.0 之后彻底取代 ziplist

Redis 7.0 开始,ziplist 完全被 listpack 取代(RDB 加载时也会自动转换)。

设计目标:保留 ziplist 省内存的优势,彻底消灭连锁更新

listpack 内存布局

<total-bytes> <num-elements> <entry> <entry> ... <entry> <0xFF>
   4字节         2字节           变长                     1字节

每个 entry

<encoding-type> <optional length> <data>   ← 没有 prevlen!

关键变化

  1. 去掉 prevlen(最重要!)

    • 不再记录前一个 entry 长度
    • 想知道前一个 entry?从头部 total-bytes - 当前 entry 偏移 倒推
    • 正向/反向遍历都支持
  2. encoding 更丰富(1 字节编码 11 种方式)

    编码前缀(高位) 类型 长度信息位数 备注
    0xxxxxxx 7bit 无符号整数 小整数直接存
    10xxxxxx 6bit 字符串长度 小串
    110xxxxx 13bit 整数 中整数
    1110xxxx 16bit 有符号整数
    1111xxxx 特殊:32/64bit 整数等
    字符串 + 长度字段 变长 大串用额外长度字段
  3. 整数编码更激进:小整数直接放在 encoding 里,不额外占字节

listpack 优缺点对比 ziplist

维度 ziplist listpack 胜出方
内存利用率 极高 略高(去掉 prevlen 后更省) listpack
连锁更新风险 极高(O(N) 最坏) 无(每个 entry 独立) listpack
编码复杂度 较复杂 更简单统一 listpack
反向遍历效率 依赖 prevlen 通过 total-bytes 倒推 相当
Redis 版本 ≤6.x 主用 7.0+ 完全取代

结论:listpack 是 ziplist 的“进化版”,内存差不多甚至更省彻底解决性能毛刺,已成为 Redis 7+ 所有小集合的首选结构。

3. Redis 哈希表(dict)扩容与渐进式 rehash

Redis dict 结构(核心是哈希表)

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];           // 0: 当前表,1: 扩容/缩容目标表
    long rehashidx;         // -1 表示不在 rehash,否则是正在迁移的 bucket 索引
    ...
} dict;

dictht(单个哈希表)

typedef struct dictht {
    dictEntry **table;      // 桶数组
    unsigned long size;     // 桶数量(2的幂)
    unsigned long sizemask; // size-1,用于 & 取模
    unsigned long used;     // 已用键值对数量
} dictht;

扩容/缩容触发条件(大致)

  • 扩容:used >= size 且 load factor > 1(或 >5 但没在 bgsave)
  • 缩容:load factor < 0.1(且 used > 一定值)

渐进式 rehash 核心流程

  1. 触发扩容 → 创建新 ht[1](桶数通常 ×2)
  2. 设置 rehashidx = 0(开始迁移标志)
  3. 每次增删改查操作时(_dictRehashStep):
    • 把 ht[0] 的 rehashidx 位置 的整个 bucket 迁移到 ht[1]
    • rehashidx ++
    • 如果迁移完所有 bucket → rehashidx = -1,完成
  4. 查找/删除 时:
    • 如果 rehashidx != -1,必须同时查 ht[0] 和 ht[1]
    • 更新操作优先写到 ht[1](写新不写旧)

并发查询视角(单线程 Redis 下的“并发”)

  • Redis 是单线程主循环,所有客户端请求串行执行
  • 但在渐进式 rehash 期间
    • 查找:可能查两张表(ht[0] + ht[1])
    • 插入:只写 ht[1]
    • 删除:先查 ht[1],找不到再查 ht[0]
    • SCAN 指令:采用高位进位法遍历,保证扩容期间不漏 key、不重复

最坏情况:rehash 期间,单个请求可能多查一次表,但整体分摊到所有操作,不会出现长阻塞

实际影响

  • 大 key(百万级 field)扩容时,不会瞬间卡死,而是平滑分摊
  • 高 QPS 场景下,rehash 会被频繁的操作加速完成

快速总结对比表

结构 出现版本 主要用途 最大优点 最大缺点 当前状态(Redis 7+)
ziplist 早期~6.x hash/zset/list 小对象 极致省内存 连锁更新 O(N) 最坏 已废弃
listpack 5.0 引入,7.0+ 默认 同上 省内存 + 无连锁更新 略复杂编码 当前标准
dict(ht) 所有版本 hash 主要实现 O(1) 平均查询 扩容时内存翻倍 渐进式 rehash 保护

面试/实战常见问题

  1. 为什么 Redis 7 要把 ziplist 换成 listpack?
    → 消灭连锁更新,解决更新大值时的性能毛刺。

  2. listpack 怎么实现反向遍历?
    → total-bytes - 当前偏移 → 倒推上一个 entry 起始位置。

  3. 渐进式 rehash 期间,key 到底在哪张表?
    → 查两张表,写只写新表,迁移完后统一切 ht[0] = ht[1]。

  4. 大 hash 扩容时会不会卡?
    → 单线程分摊,基本不会明显卡,但 QPS 会短暂下降(多查一次)。

  5. listpack 比 ziplist 更省内存吗?
    → 大多数场景更省(去掉 prevlen),极端情况下接近。

希望这篇把 Redis 这三个最核心的“省内存 + 高性能”机制讲透了。如果你想深入某个部分的源码片段(例如 listpack 的插入/删除、dict 的 rehashStep 实现),可以告诉我,我继续带你看!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐