首页 / 博客 / 详情

Redis 紧凑存储与哈希底层原理(重写版)

作者:sa | 2026-10-10 | 浏览 21 Redis

一、Redis 紧凑存储:从 ziplist 演进到 listpack

如果用 Hash 类型保存 10 组短字段,例如用户昵称、年龄这类简单信息,Redis 并不会直接采用常规哈希表存储。普通哈希表每个节点都要附带各类指针,额外开销很高:比如仅 10 字节的字符串,配套指针就要占用 8 字节,内存浪费非常明显。 为解决这个问题,Redis 实现了紧凑存储结构,将多条数据打包存放于一段连续内存,直接消除指针带来的额外开销。

1.1 压缩列表 ziplist:早期内存优化方案

ziplist 是 Redis7.0 版本之前,专门针对数据量小、值长度短场景设计的紧凑结构,同时也是 list、hash、zset 这几种数据类型的底层实现之一。

(1)结构理解:一段连续内存,如同封装好的快递箱

ziplist 本质是一整块连续内存,内部没有空隙,各组成部分作用如下:

表格

组成通俗说明
zlbytes整个压缩列表占用总字节数,方便 Redis 快速调整内存大小
zltail最后一条数据的偏移位置,支持直接定位尾部元素,不用从头遍历
zllen存储元素总数;最多记录 65535 条,超过这个数量就只能遍历统计
entry真正存储的数据,比如name:张三,每一条 entry 都会记录前一个元素的长度
zlend结束标记,固定值,用来标识压缩列表末尾

entry 里记录前一项长度,目的是支持反向遍历,但这个设计也埋下重大缺陷。

(2)致命问题:连锁更新(多米诺骨牌效应)

用来记录前一个元素长度的字段是动态变长的:前一条数据≤253 字节,只用 1 字节保存长度;一旦超过 253 字节,则需要 5 字节。 举个例子:ziplist 内存放 100 条数据,每条长度刚好 253 字节,全部使用 1 字节长度标记。当修改第一条数据,使其长度变成 254 字节,它的长度标识就要从 1 字节扩容为 5 字节。 这一改动会向后挤压内存,第二条 entry 记录的 “前一项长度” 字段同样需要从 1 字节改成 5 字节,接着影响第三条、第四条…… 链式触发大量内存重分配,也就是连锁更新。最坏情况下会阻塞 Redis 单线程,造成请求卡顿。

(3)实操:Redis6.x 查看 ziplist 编码

少量短数据,Redis 默认使用 ziplist 存储:

# 写入用户信息,3个短字段
127.0.0.1:6379> hset user:100 name zhang age 20 city beijing
(integer) 3
# 查看底层编码
127.0.0.1:6379> object encoding user:100
"ziplist"

(4)适用场景(现已淘汰)

Redis7.0 之前,满足下面条件,才会启用 ziplist:

  • hash、zset:元素数量≤512,并且每个元素的值≤64 字节;
  • list:每个元素的值≤64 字节。

1.2 listpack:ziplist 升级版,彻底根除连锁更新

为修复 ziplist 连锁更新的缺陷,Redis5.0 引入 listpack;Redis7.0 之后,listpack 完全替代 ziplist,成为默认紧凑存储结构。

(1)核心优化:移除 “前一个元素长度” 字段

listpack 同样基于连续内存块,但做了关键改造:每个 entry 只记录自身数据长度,不再依赖前一条数据。 修改某一条数据长度时,只会影响当前 entry,从根源杜绝连锁更新。额外优化两点:

  1. 直接保存元素总数,不像 ziplist,超过 65535 条就要遍历计数;
  2. 内存排布更紧凑,遍历效率更高:正向遍历依靠自身长度向后跳转;反向遍历依靠总长度反向计算偏移。

(2)实操:Redis7.0 + 查看 listpack,以及编码切换

① 查看 listpack 编码(Redis7.0 及以上)

127.0.0.1:6379> hset user:100 name zhang age 20 city beijing
(integer) 3
# 7.0+版本,小hash默认编码listpack
127.0.0.1:6379> object encoding user:100
"listpack"

② 超出阈值,listpack 自动转为 hashtable

# 批量写入600个字段,超过默认512条阈值
127.0.0.1:6379> eval "for i=1,600 do redis.call('hset','user:100','key'..i,i) end" 0
(nil)
# 底层切换为普通哈希表
127.0.0.1:6379> object encoding user:100
"hashtable"

(3)ziplist 与 listpack 对比

表格

特性ziplistlistpack
连锁更新存在,多米诺效应,可能阻塞 Redis完全消除
元素数量统计超过 65535 条,需要全量遍历直接读取总数,无需遍历
内存开销更高,冗余保存前项长度内存更节省
支持版本Redis7.0 之前Redis5.0 起,7.0 + 默认使用

(4)listpack 适用场景

触发条件和旧 ziplist 逻辑接近,仅配置项名称替换为 listpack(例如hash-max-listpack-entries)。 只要是少量、短值的 list、hash、zset,Redis 自动选用 listpack,兼顾低内存占用与稳定性能。

二、Redis 哈希表扩容:为什么不会阻塞业务请求

当 Hash 内元素数量超过 listpack 阈值(默认 512),底层存储会切换为哈希表 dict。 哈希表是 Redis 核心基础结构:不仅 Hash 类型依赖它,Redis 全局数据库的键值对底层,同样是 dict 哈希表。

2.1 哈希表基础:双哈希表设计

Redis 的 dict 内置两张子哈希表:

  • ht [0]:旧表,正常业务读写使用;
  • ht [1]:扩容阶段临时新建的表,当作新存储空间;
  • rehashidx:迁移进度标记;值为 - 1 代表没有在做 rehash。

日常所有数据都保存在 ht [0];旧表负载过高触发扩容时,创建 ht [1],然后分批迁移数据,避免一次性迁移造成长时间阻塞。

2.2 扩容触发条件:负载因子判定

负载因子公式:负载因子 = 已占用桶数量 ÷ 哈希表总桶数量 三种场景:

  1. 常规扩容:负载因子>1,并且当前没有执行 BGSAVE/BGREWRITEAOF 持久化;防止扩容叠加持久化,加重内存压力;
  2. 强制扩容:负载因子>5,无论是否正在持久化;避免哈希冲突过多,查询性能暴跌;
  3. 缩容:负载因子<0.1,释放闲置的内存空间。

2.3 渐进式 rehash:高性能的核心设计

Redis 命令执行是单线程,如果一次性把旧表全部数据迁移到新表,会阻塞全部客户端请求。因此设计渐进式 rehash,分批迁移数据,业务读写不受影响。

  1. 新建 ht [1]:新表容量取「旧表已用数量 ×2」向上取最近的 2 的 n 次方。例:旧表 100 个有效数据,新表分配 256 个桶;
  2. 启动迁移:rehashidx 置为 0,从索引 0 位置开始迁移;
  3. 随请求增量迁移:每次处理 HGET/HSET 等客户端命令时,顺带迁移 rehashidx 对应桶的数据,迁移完成索引 + 1;
  4. 后台任务加速:后台定时任务会批量迁移多个桶,防止迁移进度太慢;
  5. 迁移收尾:全部数据迁移完成,ht [1] 转为新的 ht [0],清空旧 ht [0],rehashidx 重置为 - 1。

实操演示,观察哈希扩容

# 批量写入10000条数据,触发哈希表扩容
127.0.0.1:6379> eval "for i=1,10000 do redis.call('hset','big:hash','k'..i,'v'..i) end" 0
(nil)
# 查看编码,已经切换为hashtable
127.0.0.1:6379> object encoding big:hash
"hashtable"
# 扩容过程中,查询依旧正常,无明显卡顿
127.0.0.1:6379> hget big:hash k100
"v100"

2.4 rehash 期间读写规则

整个过程不加锁,不阻塞命令,保证数据不丢失:

  • 读:优先查询旧表 ht [0],找不到再查询新表 ht [1];
  • 写:新增数据直接写入新表 ht [1],防止新数据还要二次迁移;
  • 修改 / 删除:先检索旧表,命中则操作并迁移至新表;未命中,再去新表执行操作。

三、Hash 并发查询:如何保证数据一致性

多个客户端同时读写同一个 Hash,不会读到半更新的脏数据,核心依靠 Redis单线程命令执行模型。

3.1 Redis “单线程” 完整说明

很多人误以为 Redis 所有操作都是单线程,实际划分如下:

  • 命令执行核心:单线程串行执行,HGET、HSET、GET 等所有业务命令排队依次执行;
  • 辅助任务:多线程处理,网络 IO、持久化、异步删除等,不干扰命令执行主线程。

一句话总结:核心业务命令串行保证并发安全,IO、持久化这类繁重杂活交给多线程,充分利用 CPU。

3.2 实操:并发读写,不会出现脏读

开启两个 Redis 客户端终端: 终端 A 持续写入:

127.0.0.1:6379> hincrby counter visits 1
(integer) 1

终端 B 持续读取:

127.0.0.1:6379> hget counter visits
"1"

无论两边并发频率多高,读取结果永远是完整整数,不会读到只更新一半的脏数据。

3.3 一致性保障原理

  1. 命令原子性:单条命令不可中断,执行 HGET 的时候,不会中途插入 HSET 修改数据;
  2. rehash 安全机制:扩容阶段查询同时检索新旧两张哈希表,保证读取完整数据;
  3. 无锁设计:依靠单线程串行,天然规避多线程竞争,无需加锁,性能更高。

3.4 并发查询注意点

  • rehash 阶段查询需要检索两张表,但性能损耗极小,整体依旧是 O (1);
  • 多客户端读取遵守命令排队顺序,后执行的命令一定能读到前面命令修改后的最新数据;
  • 极端场景下哈希链表过长,查询变慢,Redis 会触发强制扩容缓解该问题。

四、全文总结

Redis 底层所有设计,都围绕内存高效 + 高性能两大目标:

  1. 小批量短数据优先使用紧凑存储:早期 ziplist,7.0 之后替换为 listpack,解决 ziplist 连锁更新造成的卡顿问题;可调整 listpack 相关配置优化业务内存占用;
  2. 哈希表扩容采用渐进式 rehash,分批迁移数据,避免一次性迁移阻塞 Redis 主线程;
  3. 并发读写依靠单线程串行执行命令保障数据一致性,无锁实现,兼顾简单与高性能。

吃透这套底层逻辑,不仅可以合理调整 Redis 参数(修改 listpack 阈值),还能定位线上性能问题:比如旧版本 Redis 操作小 Hash 偶发卡顿,大概率就是 ziplist 连锁更新导致。

上一篇
Windows Server 2012 离线部署.NET Framework3.5:SXS 源完整排坑与自动化落地
下一篇
Redis 紧凑存储结构(ziplist /listpack)详细优势