news 2026/10/13 6:16:31

Redis Hash底层原理与实战:listpack、渐进式rehash及避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis Hash底层原理与实战:listpack、渐进式rehash及避坑指南

如果让我在 Redis 的所有数据类型里挑一个“最值回票价”的,我大概率会把票投给 Hash。String 是面子,十个键里八个是它;Hash 才是里子,真正做对象缓存、购物车、计数器聚合、分布式锁的可重入逻辑时,你根本绕不开。尤其是我帮客户排查过几次线上 Redis 抖动,最后根因都指向 Hash——海量字段的 Big Key、底层转成 hashtable 后触发 rehash、HGETALL 一次慢查询拖垮网络。所以我想认真写一篇 Redis Hash 的拆解:它解决什么问题、底层到底怎么存、常用命令怎么用、业务里怎么落,以及我踩过的那些坑。这篇是 Redis 数据结构系列的第八篇,不管你是刚入门 Redis 的读者,还是准备问到“哈希表底层原理”的面试候选人,应该都能找到点实在东西。

1. Hash到底解决了什么问题?三种常见姿势对比

1.1 没有Hash的日子,对象缓存的三条弯路

先看一个最常见的需求:缓存用户信息,包含 uid、name、age、city 等字段。在没有 Hash 之前,我见过团队用过三种别扭写法。

第一种是整串 JSON。把对象序列化成一个字符串塞进 String,读取时反序列化。这个方案最简单,但有一个很致命的痛点:只要改其中一个字段,就要把整个 JSON 读出来、改完再覆盖回去。一旦两个服务并发做这个操作,后写的一方会直接覆盖先写的那次变更,出现典型的“丢失更新”。而且 Redis 里存的是一整坨字符串,业务方想按字段做权限控制、做部分更新,都没办法。

第二种是每个字段一个 key。比如user:1001:name、user:1001:age。这样确实能单独读写字段了,但内存开销会翻倍:每个 key 都有独立的 key 字符串、RedisObject、SDS 结构,一个用户十几个字段就是十几个 key,线上几百万用户的时候,光 key 的元数据内存就能让人崩溃。更麻烦的是,如果你想一次性取回整个用户对象,得拼 key 再跑多次 GET,网络 RTT 全花在往返上了。

第三种是把多个字段拼成一个长字符串,中间用分隔符切。比如"name=王二|age=18|city=北京"。看起来省 key,实际解析逻辑极其脆弱,一旦某个字段值里带了分隔符,或者字段顺序调整,线上就出脏数据。这个方案我在老项目里见过,后来维护成本高到直接重写。

Hash 正好把这三个问题都解了。一个 key 对应一个对象,内部是 field-value 的映射结构。字段级读写天然支持,内存上只用维护一个 key 的元数据加内部字段表,批量取回用一次 HGETALL 就能搞定。

1.2 什么场景乖乖选Hash,什么场景要绕开

不是所有对象都适合用 Hash。这个取舍很多人面试时容易答偏,实际业务里更需要有明确判断。

适合用 Hash 的场景,我总结了三个特征:一是对象属性相对稳定,增删字段不频繁;二是确实需要频繁做字段级读写、更新;三是单字段 value 较小,通常在几百字节以内。典型如用户资料、商品详情、会话状态、购物车、多维计数器。这些场景里 Hash 能同时拿到“结构清晰”和“操作灵活”两个红利。

不适合的场景也很明确。如果你的对象属性经常整批变化,比如一个配置项十分钟换一批字段,Hash 的老字段清理会非常麻烦,不如直接用 String 存 JSON 覆盖。如果字段数量可能膨胀到不可控,比如往一个 Hash 里不断塞 deviceId 这种高基数数据,field 数量轻松破百万,那就是给自己埋了一个 Big Key 的雷。如果你需要按字段做范围查询、排序,Hash 也帮不上忙,该用 ZSET 用 ZSET。

经验:我评估一个对象到底用 String 还是 Hash,核心就一句话——改字段的频率高不高。频繁改局部字段,选 Hash;整体读、整体写,选 String。

2. 底层结构拆解:hashtable、listpack 与渐进式 rehash

2.1 哈希表怎么组织内部数据

Hash 在最一般的情况下,底层就是一张标准的哈希表,Redis 内部称之为 dict。dict 里有两个 dictht 哈希表,平时只用一个,rehash 的时候两个并用。dictht 里有一个指针数组 table,每个槽位放一个 dictEntry 链表头;dictEntry 里保存 key、value 和 next 指针,用来解决哈希冲突。

Redis 计算键位置的时候,先用 SipHash 算法算出 field 的哈希值,再跟数组长度做按位与,得到槽位下标。之所以不用明显的简单哈希,而选择 SipHash,是为了抵抗哈希碰撞攻击。你可以想象一个场景:如果攻击者能构造大量哈希值相同的 field,全部堆到同一个链表上,查询复杂度直接退化成 O(N),整个 Redis 主线程就会被拖死。SipHash 通过随机种子打散了这一招,这是 Redis 在网络安全上很多不那么显眼的细节之一。

哈希冲突采用链地址法解决,也就是冲突的 entry 串在同一个链表里。当链越来越长时,负载因子—— used/size 的比值——就会变大,Redis 就要决定扩容了。负载因子本身是衡量哈希表“挤不挤”的关键指标:挤到一定程度,链表太长,读写性能下降;太松,又浪费内存。Redis 的策略我下一节细说。

2.2 小对象优化:ziplist 到 listpack 的进化

大部分初学者会默认 Hash 底层就是一张哈希表,这个回答其实只对了一半。Redis 对 Hash 做了一个非常关键的内存优化:如果 field 少、value 小,就不会建哈希表,而是用更紧凑的连续内存结构来存。

在 Redis 7.0 之前,这个紧凑结构是 ziplist。你可以把 ziplist 理解成一块连续的内存,里面一个 entry 接一个 entry,每个 entry 保存前一节点的长度、当前节点编码、数据内容。因为连续内存,没有指针开销,字符串短的时候甚至不需要单独的 RedisObject,所以内存占用比 hashtable 小得多。缺点是插入、删除中间元素需要搬移后面的数据,而且存在一个著名的“连锁更新”问题:某个 entry 长度变化,可能导致它的 previous_entry_length 字段变大,又挤压下一个 entry,像多米诺骨牌一样连锁反应。

Redis 7.0 之后,ziplist 被 listpack 取代。listpack 同样是一块连续内存,但设计上做了一处关键改动:每个 entry 不保存前一个 entry 的长度,而是保存自身长度,也就是 backlen 字段。这样某一个 entry 变长不会再反向影响前一个节点,彻底消除了连锁更新问题。对 Hash 来说,这个变化让紧凑编码的稳定性强了很多。

触发从 listpack 转为 hashtable 的条件,由配置控制。默认情况下是这样的:

hash-max-listpack-entries 128 hash-max-listpack-value 64

意思是当一个 Hash 的 field 数量超过 128 个,或者某个 field 或 value 的长度超过 64 字节,它就会从 listpack 转成标准的 hashtable。这个转换是一次性的,不可逆。实际业务里,如果你看到某个 Hash 的 encoding 是 listpack,说明它还很“轻”;如果变成 hashtable,你就要多想想 field 数量是不是开始膨胀了。

2.3 渐进式 rehash:扩容不阻塞的秘密

哈希表总要扩容的。Redis 的 dict 在两种情况下会触发扩容:一是负载因子大于等于 1,同时没有子进程在做持久化或 AOF 重写,说明系统比较空闲;二是负载因子已经大于等于 5,不管有没有子进程,都必须马上扩容,否则哈希冲突链太长,性能会崩。

扩容的目标大小也有讲究:一般是当前 used 数量的两倍,再往上取到最近的 2 的幂次方。比如当前有 100 个 entry,目标就是 128 这个桶数组长度。这块写成 2 的幂,是为了能用位运算替代取模,提高计算效率。

真正精彩的是迁移过程。Redis 没有选择一次性把所有 entry 搬到新表,那样会阻塞主线程很长时间。它用了渐进式 rehash:维护一个 rehashidx 指针,从旧表的第一个槽位开始,每执行一次增删改查操作时,顺手迁移一小批 entry,rehashidx 不断往后推进,直到所有槽位迁移完。

这个过程我用一个搬家类比你就明白了:你不是在某一天把所有家当全搬完,而是每天上下班路上顺手搬一箱。虽然搬家周期拉长了,但营业不停,用户无感。迁移期间,新增的字段一律进新表,查找时先查新表,查不到再去旧表找。整个迁移过程对调用方是透明的。

注意:渐进式 rehash 平时很平稳,但如果你有一个超大 Hash,比如几十万 field,一次操作里它需要迁移多个槽位,单次命令的耗时就会明显上升,极端时候依然能造成阻塞。这才是那些大 key 事故的底层原因。

3. 常用命令实操:从字段级操作到批量优化

3.1 字段级命令速查表

Hash 的命令本身就很有设计感,我把最常用的整理成了一张速查表,后面跟着命令示例和复杂度,方便直接参考。

命令作用时间复杂度示例
HSET设置单个或多个字段O(1)(多个字段 O(N))HSET user:1 name "王二" age 18
HGET读取单个字段O(1)HGET user:1 name
HSETNX字段不存在时才设置O(1)HSETNX user:1 email a@b.com
HDEL删除一个或多个字段O(N)(N 是被删字段数)HDEL user:1 age
HEXISTS判断字段是否存在O(1)HEXISTS user:1 name
HLEN统计字段数量O(1)HLEN user:1
HGETALL获取全部字段与值O(N)HGETALL user:1
HKEYS / HVALS只拿字段名或只拿值O(N)HKEYS user:1

这里面最需要警惕的是 HGETALL。它在字段少的时候问题不大,field 数量一旦上了万级,一次 HGETALL 的光是遍历加网络传输就会造成慢查询。线上排查现场,我经常看到同事对着一个十万字段的 Hash 敲 HGETALL,然后整个服务卡了两秒。这个习惯一定要戒掉。

3.2 HINCRBY:计数器函数的高频玩法

Hash 里有一个 String 不常被注意、但对计数器特别友好的命令:HINCRBY,对某个字段做原子自增。它可以完美把多个计数项塞进同一 key:

HSET stats:2024-11-21 pv 0 HINCRBY stats:2024-11-21 pv 1 HINCRBY stats:2024-11-21 uv 1

这个模式非常适合做按天、按小时、按维度的多桶计数。比如一个活动要统计 PV、UV、分享数、点赞数,以前可能会拆出四个 key,现在直接一个 Hash 搞定。更重要的是,HINCRBY 是原子的,天然规避并发覆盖问题。

如果业务要的是浮点计数,比如金额累计、评分平均,Redis 也给了一个对应命令:HINCRBYFLOAT。这两个命令在背后都是先取旧值、计算、写回,但因为整个操作在 Redis 主线程里串行执行,不需要你自己加锁。

3.3 批量读写与管道,把 RTT 降下来

Hash 天生适合批量操作。比如前端要展示一个用户基础信息卡片,需要同时读 name、avatar、level 三个字段,用 HMGET 一次就能拿回来:

HMGET user:1001 name avatar level

一次命令代替三次 GET,网络往返直接减少三分之二。同理,设置多个字段可以用 HMSET,虽然 Redis 4.0 之后 HSET 也支持多字段了,但老代码里 HMSET 很常见,两者功能等价。

如果要做更复杂的批量操作,比如循环读一百个用户的 Hash 部分字段,千万别写一个 for 循环一个一个读,那样会产生一百次网络 RTT,性能相差一个数量级。正确姿势是在客户端用 Pipeline 批量提交:

List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (String uid : uidList) { connection.hashCommands().hMGet(("user:" + uid).getBytes(StandardCharsets.UTF_8), "name".getBytes(StandardCharsets.UTF_8), "avatar".getBytes(StandardCharsets.UTF_8)); } return null; });

Pipeline 的原理就是把多个命令攒在一起,一次性发给 Redis,再一次性读回结果。实测下来,批量一百个用户的时候,耗时能从几十毫秒降到几毫秒。这个差异在接口性能优化里非常可观。

3.4 过期设计的坑:Hash 不支持字段级 TTL

使用 Hash 时需要特别清楚一个边界:Redis 的过期时间是针对 key 的,不是针对 field 的。你没法对user:1里的name单独设置 30 秒过期,再让avatar继续存活。

这个限制在 Session 存储场景里特别容易踩坑。如果你把用户登录态的所有字段塞进一个 Hash,又希望每个字段按不同生命周期失效,那是实现不了的。常规方案有三种:一是整体设置 EXPIRE,让整个对象同生共死,适合大多数会话场景;二是把时间戳作为 value 的一部分,在应用层判断,适合需要精细控制且字段量不大的场景;三是改成双 key 结构,比如一个 Hash 存数据、一个 String 存过期标记。我个人在项目里最常用的是整体 EXPIRE,简单、可控、符合直觉。

4. 从缓存到分布式锁:Hash 的四大典型落地场景

4.1 用户信息缓存:String 和 Hash 到底怎么选

我在第一节说过判断标准,这里展开成一个具体案例。假设要缓存用户资料,字段有 uid、昵称、头像、签名、等级。大多数业务里,用户改昵称、换头像很频繁,而且用户访问自己的主页时,接口往往只需要读其中几个字段,比如列表页只需要昵称和头像。这种场景用 Hash 就很合适:

HSET user:2003 name "老王" avatar "https://cdn.example.com/a.jpg" level 6 HGET user:2003 avatar

这样头像单独更新、昵称单独更新,不会互相覆盖,列表页按需 HGET 也不会把整个对象拉出来。

反过来,如果你的应用是“一拉一整套”的读多写少,比如文章详情页,全文包含标题、正文、作者、标签、统计数据,用户每次访问就是整块整块读,那不如直接用 String 存 JSON。String 的方案在序列化上有天然优势,JSON 结构可以自由演进,新增字段不需要提前定义。而 Hash 字段名是固定的,增加字段意味着要改代码。

维度String + JSONHash
修改单个字段需整体读写,易覆盖字段级原子更新
批量读取对象一次 GET 拿全量HGETALL 全量或 HMGET 选字段
对象结构变化灵活,JSON 适应性好字段需预先规划
内存效率整串存储,结构紧凑字段元数据有额外开销
典型场景文章详情、整页缓存用户资料、会话状态

4.2 购物车:Hash 的教科书级应用

说购物车是 Hash 的教科书级应用,一点不夸张。以电商为例,每个用户的购物车可以设计成一个以 userId 为 key 的 Hash,field 是商品 skuId,value 是加入数量:

HSET cart:88 sku_1001 2 HSET cart:88 sku_1002 1

加购一件商品就是 HSET,如果已存在则直接覆盖数量;修改数量同样 HSET;删掉某件商品就是 HDEL;查询整个购物车列表可以用 HGETALL;统计购物车里一共有几类商品用 HLEN。比如凑单页需要一次性展示用户所有购物车条目,一次 HGETALL 就能拿到全部数据。

购物车还有两个高频操作也离不开 Hash。一是批量查价格、库存状态:购物车在服务端要先查出所有 skuId 再调商品服务,这时候可以用 HKEYS 拿到全部 skuId;二是“全选加入”或“批量删除”,可以借助 HMGET 快速批量获取商品信息。相比用 List 或 Set 实现购物车,Hash 天然把“商品 ID”和“数量”绑定在一起,简直是为这个场景定制的。

4.3 多维计数器:一个 Hash 当一张统计表

很多团队做活动运营时,需要同时统计多个维度的数据,给所有参加者发送通知、排行榜、汇总报表。Hash 在这里能充当一张内存里的统计表。

举个例子,一个抽奖活动要按渠道统计参与人数、中奖人数、奖品消耗数。你可以把日期和活动 ID 作为 key,每个维度作为 field:

HSET activity:20241121 wechat_participants 0 HSET activity:20241121 app_participants 0 HSET activity:20241121 rewards_used 0 HINCRBY activity:20241121 wechat_participants 1 HINCRBY activity:20241121 rewards_used 1

这样每天一个 key,后台运营想看全局统计时直接 HGETALL,想看某个渠道单独数据时 HGET。比拆成几十个独立的 String key 清晰太多,也方便按天淘汰过期活动数据。

另一个常见设计是“频率控制”。比如限制某个用户每分钟最多调用某接口 20 次,可以用时间窗口 + 用户维度做 Hash:key 是rate:userId:202411211430,field 是接口名,value 是调用次数。每来一次请求就 HINCRBY,超过阈值直接拒绝。到下一分钟旧 key 自然过期,全新计数从零开始。

4.4 分布式锁的可重入与续期,Hash 藏了多少秘密

Hash 还有一个容易被忽略的重要应用:分布式锁的可重入。用 Redisson 实现的 Redis 分布式锁,底层其实不是简单的 SETNX,而是用 Hash 记录“哪个线程持有了锁、重入了多少次”。

我们知道一个锁要支持可重入,就得记录持锁线程的身份,还要一个计数器。这正好是 Hash 的强项:key 是锁名,field 是线程标识,value 是重入次数。

加锁时的 Lua 脚本核心逻辑可以简化成:

if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 end if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('hincrby', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 end return 0

解锁释放时,同样用 Lua 做原子判断:

if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then return nil end local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1) if (counter > 0) then redis.call('expire', KEYS[1], ARGV[2]) return 0 else redis.call('del', KEYS[1]) return 1 end

每次重入一次就把计数器加一,每释放一次减一,减到零才真正删除锁。这个设计里 Hash 的字段级操作和原子自增帮了大忙。如果是纯 SETNX,你只能记一个标志位,根本无法安全地实现重入和释放逻辑。很多面试官喜欢沿着这条线往下问,答到 Hash 这层基本就过关了。

5. 避坑实录:Big Key、序列化与 rehash 阻塞

5.1 Big Key 是隐形的线上炸弹

Hash 最典型的线上事故就是 Big Key。什么是 Big Key?对 Hash 来说,就是单个 key 的 field 数量过多或某个 value 过大。我见过最夸张的一次,团队把某设备上报的所有历史记录都塞进一个 Hash 里,field 数量到了几百万,HGETALL 直接让 Redis 主线程卡了几秒,监控曲线瞬间拉平。

Big Key 会带来连锁反应。内存上,一个大 Hash 会让内存碎片率升高;读取上,HGETALL 这种命令要一次性把所有数据从内存序列化并写入 socket,占用大量网络带宽;更重要的是,Hash 底层一旦是 hashtable,每次扩容触发 rehash,迁移期间所有对该 key 的操作都可能变慢。

排查 Big Key 最简单的方法是 redis-cli 自带的扫描命令:

redis-cli --bigkeys

它会扫描整个实例,按类型输出最大的 key。但要注意,--bigkeys只能看到最大的一小撮,不能代替日常巡检。更精细的内存排查可以用:

MEMORY USAGE user:2003

这个命令能精确返回某个 key 占用的内存字节数,但它本身也会遍历整个 Hash,在大 key 上执行时需要谨慎,别在高峰期对超大的 key 用。

如果真的发现了一个大 Hash,不要直接用 DEL,DEL 会阻塞主线程。正确操作是用 UNLINK 异步删除,或者用 HSCAN 配合 HDEL 在低峰期分段清理:

HSCAN big:hash 0 COUNT 100 HDEL big:hash field1 field2 ...

5.2 序列化方案不一致,字段结构演进的坑

Hash 本身提供的字段级操作虽然方便,但如果你把序列化后的 JSON 塞进 value,就相当于把 Hash 用成了带 key 的 String。比如:

HSET user:1 profile "{\"name\":\"王二\",\"age\":18}"

表面上你还是在用 Hash,可一旦要改 name,还是得把整个 JSON 读出来、改完再写回去,Hash 字段级原子更新的优势一点都没吃到。所以规则是:字段本身是结构化、不需要层层嵌套的,才适合直接用 Hash;如果是复杂嵌套对象,宁可 String 存 JSON。

另一个容易踩的坑是客户端序列化配置不一致。比如 Java 客户端用了 JDK 序列化,Python 客户端用了 json 序列化,同一个 Hash 里读出来的是乱码,甚至在拼接 key 时因为序列化前缀不同,两个服务根本操作不到同一个 key。解决办法很简单:团队统一一套 key 和 value 的序列化方案,项目中一般建议用 StringRedisSerializer 或自定义的 JSON 序列化,避免默认 JdkSerializationRedisSerializer 带来的二进制乱码。

5.3 从缓存治理角度看待 Hash 的容量监控

“缓存治理”这个词这两年提得很多,落到 Hash 上,核心就两件事:限制单个 key 的规模,关注转换时机。

限制规模是最直接的。你可以在应用层写一个约定:单个 Hash 的 field 数量不超过 1000,value 长度不超过 1KB。一旦超过,要么拆分 key,要么换数据结构。比如用户行为标签,可以把 user 维度拆成tag:user:1001,并限制标签数量,超了就走单独的 List。这个约定要写进团队的编码规范,不能只靠口头传达。

关注转换时机是指,可以用 OBJECT ENCODING 命令看一个 Hash 当前是 listpack 还是 hashtable:

OBJECT ENCODING user:2003 # 输出可能是 listpack 或 hashtable

正常情况下,一个新用户字段少,应该是 listpack;如果发现大量用户转变成了 hashtable,说明某个字段可能塞了大对象,或者 field 数量触到了阈值。这时可以用 HLEN 快速检查一个 key 的字段数量,判断是业务增长导致的,还是代码 bug 导致的无脑写入。

5.4 面试高频追问:看到 Hash 先想三个问题

面试官几乎必问 Redis Hash,这里给你一个两分钟回答框架。先说底层编码:小 Hash 用 listpack,大 Hash 用 hashtable,由hash-max-listpack-entries和hash-max-listpack-value控制。再说扩容机制:负载因子超阈值触发扩容,采用渐进式 rehash,期间新旧表并存,增删改查时逐步迁移。最后说应用场景:对象缓存、购物车、计数器聚合、可重入分布式锁。

如果面试官追问 rehash 时的阻塞风险,你要能把大 key 的事故关联起来:渐进式 rehash 通常不阻塞,但如果是超大 Hash,单次操作迁移的槽位数量可能爆炸,依然会造成卡顿。如果追问内存节省,你要能说出连续内存、没有指针、没有独立 RedisObject 这些关键点。这套组合拳下来,Redis Hash 这部分基本稳了。

我个人还在实际项目里保持着一个很土但有效的习惯:凡是新增一个 Hash 结构,我一定会在设计文档里顺手写死两条上限——最大 field 数量和最大 value 长度,再配一个巡检脚本定期扫HLEN和MEMORY USAGE。别嫌麻烦,线上好几次差点翻车,最后都是靠这两条“土规矩”救回来的。Hash 用好了,它是最趁手的对象容器;用不好,它就是最容易埋雷的数据结构。希望这篇拆解能帮你把它放在合适的位置上。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 6:15:25

ECharts双轴组合图实战:柱状图与折线图混合配置及避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 6:14:06

Windows下CGNS静态库配置:HDF5/zlib/szip依赖与VS链接实战

简介&#xff1a;在 Windows 64 位环境下可直接使用的 CGNS 静态库集成包&#xff0c;面向从事流体力学、CFD 模拟或科学计算后处理的 C/C 开发者。CGNS 支持复杂三维、多时间步网格数据交换&#xff0c;本包已预先整合 HDF5、ZLIB、SZIP 依赖&#xff0c;免去 CMake 编译配置与…

作者头像 李华