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

一文吃透 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 偏移 倒推
  • 正向/反向遍历都支持
  1. encoding 更丰富(1 字节编码 11 种方式) 编码前缀(高位) 类型 长度信息位数 备注 0xxxxxxx 7bit 无符号整数 — 小整数直接存 10xxxxxx 6bit 字符串长度 — 小串 110xxxxx 13bit 整数 — 中整数 1110xxxx 16bit 有符号整数 — 1111xxxx 特殊:32/64bit 整数等 — … 字符串 + 长度字段 变长 大串用额外长度字段
  2. 整数编码更激进:小整数直接放在 encoding 里,不额外占字节

listpack 优缺点对比 ziplist

维度ziplistlistpack胜出方
内存利用率极高略高(去掉 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,完成
  1. 查找/删除 时:
  • 如果 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.xhash/zset/list 小对象极致省内存连锁更新 O(N) 最坏已废弃
listpack5.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 实现),可以告诉我,我继续带你看!

文章已创建 4631

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部