一文吃透 Redis 核心存储结构:ziplist、listpack 与哈希表扩容 / 并发查询
一、Redis 紧凑存储结构:从 ziplist 到 listpack
如果用 hash 存 10 个短字段(比如用户昵称、年龄),Redis 不会直接用普通哈希表存储 —— 因为普通哈希表每个元素会附带指针等额外开销(比如 10 字节的字符串,指针要占 8 字节),内存浪费严重。为此 Redis 设计了 “紧凑存储结构”,把数据打包到连续内存块中,彻底去掉指针开销。
1.1 压缩列表 ziplist:早期的内存优化方案
ziplist 是 Redis 7.0 前为 “小数据量、短值” 场景设计的紧凑存储结构,也是 list、hash、zset 的底层实现之一。
(1)核心结构:像 “打包好的快递箱”
ziplist 本质是一段连续的内存块,就像无空隙的快递箱,结构如下:
| 组成部分 | 通俗解释 |
|---|---|
| zlbytes | 整个 “快递箱” 的总大小(方便 Redis 快速调整内存) |
| zltail | 最后一个数据的位置(不用从头翻,直接定位尾元素) |
| zllen | 数据个数(最多存 65535 个,超了要挨个数) |
| entry | 实际数据(如 “name: 张三”),每个 entry 记录 “前一个数据的长度” |
| zlend | 封箱标记(固定值,代表结束) |
每个 entry 记录 “前一个数据的长度” 是为了支持反向遍历,但这也埋下了致命隐患。
(2)致命缺陷:“连锁更新”(多米诺骨牌效应)
“前一个数据的长度” 字段的长度是动态的:前一个数据≤253 字节时用 1 字节记录,超过则用 5 字节。 举个例子:ziplist 里有 100 个数据,每个数据长度都是 253 字节(刚好适配 1 字节长度记录)。若把第一个数据改成 254 字节,其长度记录会从 1 字节变 5 字节 —— 这会导致第二个数据的 “前一个数据长度” 字段也需从 1 字节改 5 字节,进而影响第三个、第四个…… 像多米诺骨牌一样触发 “连锁更新”,最坏情况下阻塞单线程的 Redis。
(3)【实操示例】查看 ziplist 编码(Redis 6.x)
少量小数据默认使用 ziplist 存储:
# 存入少量小数据
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)使用场景(已淘汰)
Redis 7.0 之前,满足以下条件才会用 ziplist:
- hash/zset:元素数≤512 个,且每个元素值≤64 字节;
- list:每个元素值≤64 字节。
1.2 listpack:ziplist 的升级版(彻底解决连锁更新)
为修复 ziplist 的 “连锁更新” 问题,Redis 5.0 引入 listpack,7.0 后完全替代 ziplist 成为默认紧凑存储结构。
(1)核心改进:去掉 “前一个数据长度”
listpack 同样是连续内存块,但做了关键简化:每个 entry 只记录 “自身长度”,不再依赖前一个数据。修改某个数据的长度时,仅影响自身,从根源杜绝连锁更新。 此外还有两个优化:
- 数据个数直接记录(无需像 ziplist 那样,超 65535 个就遍历统计);
- 内存更紧凑,遍历效率更高(正向遍历按 “自身长度” 跳转,反向遍历通过总长度计算)。
(2)【实操示例】查看 listpack 编码及切换逻辑
① 查看 listpack 编码(Redis 7.0+):
# 存入少量小数据
127.0.0.1:6379> hset user:100 name zhang age 20 city beijing
(integer) 3
# 7.0+ 默认编码为 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 vs listpack 核心对比
| 特性 | ziplist | listpack |
|---|---|---|
| 连锁更新 | 存在(多米诺效应) | 彻底解决 |
| 数据个数统计 | 超 65535 个需遍历 | 直接读取,无需遍历 |
| 内存开销 | 略高(冗余的 “前一个长度”) | 更省内存 |
| 适用版本 | Redis 7.0 前 | Redis 5.0+(7.0+ 标配) |
(4)listpack 的使用场景
触发条件与 ziplist 兼容,仅配置参数名把 “ziplist” 改为 “listpack”(如 hash-max-listpack-entries)。只要是小数据量、短值的 list、hash、zset,Redis 都会自动用 listpack 存储,既省内存又不卡性能。
二、Redis 哈希表扩容:为什么不会阻塞你的请求?
当 hash 元素数量超过 listpack 阈值(如 512 个),Redis 会切换到底层的 “哈希表(dict)” 存储。哈希表是 Redis 的核心结构 —— 不仅是 hash 类型的底层,Redis 自身的键值对数据库底层也是哈希表。
2.1 哈希表的基础:双表设计
Redis 哈希表(dict)内置两个子表:
- ht[0]:当前正在使用的 “旧家”;
- ht[1]:扩容时的临时 “新家”;
- rehashidx:扩容进度标记(-1 表示未扩容)。
平时数据存在 “旧家”,“旧家” 挤不下时准备 “新家”,再分批搬家,避免一次性迁移阻塞请求。
2.2 扩容触发条件:看 “负载因子”
Redis 用 “负载因子” 判断是否扩容,公式:负载因子 = 已使用的桶数 / 哈希表总桶数
触发条件分三种:
- 正常扩容:负载因子 > 1,且 Redis 未执行持久化(BGSAVE/BGREWRITEAOF)—— 避免扩容加剧内存压力;
- 强制扩容:负载因子 > 5(无论是否持久化)—— 避免数据过挤导致查询变慢;
- 缩容:负载因子 < 0.1—— 释放闲置内存。
2.3 渐进式 rehash:核心高性能设计
Redis 是单线程处理命令,若一次性把 “旧家” 数据搬到 “新家”,会阻塞所有请求。为此设计了 “渐进式 rehash”—— 分批迁移,不影响正常使用:
- 准备 “新家”:扩容时 “新家” 大小为 “旧家已用数据 ×2” 的最小 2 的幂(如旧家用了 100 个位置,新家设为 256);
- 开始搬家:标记 rehashidx=0,从第 0 个位置开始迁移;
- 分批迁移:每次处理客户端请求(如 HGET/HSET)时,顺带迁移 rehashidx 位置的数据,迁移后 rehashidx++;
- 加速搬家:后台定时任务批量迁移多个位置,避免搬家过慢;
- 完成搬家:所有数据迁移后,将 “新家” 设为 “旧家”,清空原 “旧家”,标记 rehashidx=-1。
(4)【实操示例】观察哈希表扩容过程
# 创建大 hash 触发扩容
127.0.0.1:6379> eval "for i=1,10000 do redis.call('hset','big:hash','k'..i,'v'..i) end" 0
(nil)
# 查看编码(已为哈希表)
127.0.0.1:6379> object encoding big:hash
"hashtable"
# 扩容期间查询无卡顿
127.0.0.1:6379> hget big:hash k100
"v100"2.4 扩容期间的读写规则
全程无锁、不阻塞,且保证数据不丢失:
- 读数据:先查 “旧家”,没找到再查 “新家”;
- 写数据:直接写入 “新家”(避免新数据二次迁移);
- 删 / 改数据:先查 “旧家”,找到则操作并迁移到 “新家”;未找到则查 “新家” 操作。
三、Redis 哈希表并发查询:为什么能保证数据一致?
多个客户端同时查询 Redis 的 hash 数据时,不会读到 “一半更新、一半未更新” 的脏数据,核心原因是 Redis 的 “单线程命令执行模型”。
3.1 Redis 的 “单线程” 小秘密
很多人误以为 Redis 是纯单线程,实际是:
- 核心命令执行:单线程串行(所有 HGET/HSET/GET 等命令按顺序执行);
- 辅助操作:多线程(网络 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 并发查询的一致性保障
- 命令原子性:单条命令不可分割(如 HGET 执行时,不会插入 HSET 修改数据);
- rehash 安全:扩容期间查询会同时检查 “旧家” 和 “新家”,确保读到全量数据;
- 无锁高性能:单线程模型无需加锁,天然保证线程安全。
3.4 并发查询的小注意点
- 扩容期间查询会多查一个表,但性能几乎无影响(正常 / 扩容时查询复杂度均为 O (1));
- 多客户端查询结果永远是 “最新状态”—— 命令按到达顺序执行,后执行的命令能读到前一个命令的修改结果;
- 极端情况下(哈希表某个位置数据链过长)查询会变慢,但 Redis 会触发 “强制扩容” 自动避免。
四、总结:Redis 设计的核心思想
Redis 所有底层设计都围绕 “内存高效 + 性能优先” 两大核心:
- 紧凑存储结构:小数据量用 ziplist/listpack 省内存,listpack 解决了 ziplist 的连锁更新问题,是 7.0+ 版本标配;
- 哈希表扩容:用渐进式 rehash 替代一次性迁移,避免单线程阻塞,兼顾扩容效率和请求响应;
- 并发查询:靠单线程串行执行命令保证一致性,无锁设计既简单又高效。
理解这些底层逻辑,不仅能帮你优化 Redis 配置(如调整 listpack 阈值),还能解释日常性能问题(如旧版本 Redis 操作小 hash 偶尔卡顿,可能是 ziplist 连锁更新导致)。
版权所属:SO JSON在线解析
原文地址:https://www.sojson.com/blog/583.html
转载时必须以链接形式注明原始出处及本声明。
如果本文对你有帮助,那么请你赞助我,让我更有激情的写下去,帮助更多的人。
