如果让我在 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 + JSON | Hash |
|---|---|---|
| 修改单个字段 | 需整体读写,易覆盖 | 字段级原子更新 |
| 批量读取对象 | 一次 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 用好了,它是最趁手的对象容器;用不好,它就是最容易埋雷的数据结构。希望这篇拆解能帮你把它放在合适的位置上。