一文吃透 Redis 核心存储结构:ziplist、listpack 与哈希表扩容 / 并发查询

JSON 2026-02-11 16:29:08 971

一、Redis 紧凑存储结构:从 ziplist 到 listpack

如果用 hash 存 10 个短字段(比如用户昵称、年龄),Redis 不会直接用普通哈希表存储 —— 因为普通哈希表每个元素会附带指针等额外开销(比如 10 字节的字符串,指针要占 8 字节),内存浪费严重。为此 Redis 设计了 “紧凑存储结构”,把数据打包到连续内存块中,彻底去掉指针开销。

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

ziplist 是 Redis 7.0 前为 “小数据量、短值” 场景设计的紧凑存储结构,也是 listhashzset 的底层实现之一。

(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 核心对比

特性ziplistlistpack
连锁更新存在(多米诺效应)彻底解决
数据个数统计超 65535 个需遍历直接读取,无需遍历
内存开销略高(冗余的 “前一个长度”)更省内存
适用版本Redis 7.0 前Redis 5.0+(7.0+ 标配)

(4)listpack 的使用场景

触发条件与 ziplist 兼容,仅配置参数名把 “ziplist” 改为 “listpack”(如 hash-max-listpack-entries)。只要是小数据量、短值的 listhashzset,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. 正常扩容:负载因子 > 1,且 Redis 未执行持久化(BGSAVE/BGREWRITEAOF)—— 避免扩容加剧内存压力;
  2. 强制扩容:负载因子 > 5(无论是否持久化)—— 避免数据过挤导致查询变慢;
  3. 缩容:负载因子 < 0.1—— 释放闲置内存。

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

Redis 是单线程处理命令,若一次性把 “旧家” 数据搬到 “新家”,会阻塞所有请求。为此设计了 “渐进式 rehash”—— 分批迁移,不影响正常使用:

  1. 准备 “新家”:扩容时 “新家” 大小为 “旧家已用数据 ×2” 的最小 2 的幂(如旧家用了 100 个位置,新家设为 256);
  2. 开始搬家:标记 rehashidx=0,从第 0 个位置开始迁移;
  3. 分批迁移:每次处理客户端请求(如 HGET/HSET)时,顺带迁移 rehashidx 位置的数据,迁移后 rehashidx++;
  4. 加速搬家:后台定时任务批量迁移多个位置,避免搬家过慢;
  5. 完成搬家:所有数据迁移后,将 “新家” 设为 “旧家”,清空原 “旧家”,标记 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 并发查询的一致性保障

  1. 命令原子性:单条命令不可分割(如 HGET 执行时,不会插入 HSET 修改数据);
  2. rehash 安全:扩容期间查询会同时检查 “旧家” 和 “新家”,确保读到全量数据;
  3. 无锁高性能:单线程模型无需加锁,天然保证线程安全。

3.4 并发查询的小注意点

  1. 扩容期间查询会多查一个表,但性能几乎无影响(正常 / 扩容时查询复杂度均为 O (1));
  2. 多客户端查询结果永远是 “最新状态”—— 命令按到达顺序执行,后执行的命令能读到前一个命令的修改结果;
  3. 极端情况下(哈希表某个位置数据链过长)查询会变慢,但 Redis 会触发 “强制扩容” 自动避免。

四、总结:Redis 设计的核心思想

Redis 所有底层设计都围绕 “内存高效 + 性能优先” 两大核心:

  1. 紧凑存储结构:小数据量用 ziplist/listpack 省内存,listpack 解决了 ziplist 的连锁更新问题,是 7.0+ 版本标配;
  2. 哈希表扩容:用渐进式 rehash 替代一次性迁移,避免单线程阻塞,兼顾扩容效率和请求响应;
  3. 并发查询:靠单线程串行执行命令保证一致性,无锁设计既简单又高效。

理解这些底层逻辑,不仅能帮你优化 Redis 配置(如调整 listpack 阈值),还能解释日常性能问题(如旧版本 Redis 操作小 hash 偶尔卡顿,可能是 ziplist 连锁更新导致)。  

版权所属:SO JSON在线解析

原文地址:https://www.sojson.com/blog/583.html

转载时必须以链接形式注明原始出处及本声明。

本文主题:

如果本文对你有帮助,那么请你赞助我,让我更有激情的写下去,帮助更多的人。

关于作者
一个低调而闷骚的男人。
相关文章
MySQL存储引擎
MySQL存储引擎
IE、Firefox对同域名访问并发限制,及解决优化方案
SpringBoot 集成Spring-data-redisredis对象序列化存储
MySQL无法存储Emoji情,IPhone情问题 。
whois查询是什么?它有哪些作用?
如何查询自己的邮政编码?
qq号吉凶号码查询-qq号吉凶真的有影响吗?
Spring JPA查询,JPA 根据方法名字查询详细介绍
Elasticsearch教程 ,Elasticsearch count 查询,Elasticsearch 查询是否存在
最新文章
文件上传漏洞与防御 1548
前端构建工具选型指南:Webpack、Vite、Rollup、esbuild 深度对比 483
物联网时代2026年时序数据库选型指南 507
SaaS行业面临AI挑战:从“无限复用”到“灵活适应” 683
神经网络:从构造到模型训练全链路解析 545
一文吃透 Redis 核心存储结构:ziplist、listpack 与哈希表扩容 / 并发查询 964
Linux sudo提权完整指南:从基础用法到生产级安全配置 281
XSS 和 CSRF 的本质区别及开发防御全解析 390
JVM垃圾回收(GC)全维度解析:从原理到调优实战 420
Linux动静态库与ELF加载全解析:从实操制作到底层原理 539
最热文章
免费天气API,天气JSON API,不限次数获取十五天的天气预报 771514
最新MyEclipse8.5注册码,有效期到2020年 (已经更新) 708851
苹果电脑Mac怎么恢复出厂系统?苹果系统怎么重装系统? 679438
Jackson 时间格式化,时间注解 @JsonFormat 用法、时差问题说明 562378
我为什么要选择RabbitMQ ,RabbitMQ简介,各种MQ选型对比 512346
Elasticsearch教程(四) elasticsearch head 插件安装和使用 484468
Jackson 美化输出JSON,优雅的输出JSON数据,格式化输出JSON数据... ... 301586
Java 信任所有SSL证书,HTTPS请求抛错,忽略证书请求完美解决 247158
Elasticsearch教程(一),全程直播(小白级别) 232831
谈谈斐讯路由器劫持,你用斐讯路由器,你需要知道的事情 228099
支付扫码

所有赞助/开支都讲公开明细,用于网站维护:赞助名单查看

查看我的收藏

正在加载... ...