我至今记得那次线上排查:一个服务的内存曲线在凌晨三点突然抬了个头,之后一路缓慢上扬,重启就好,第二天又开始。查了半天,最后落在一个只写了二十来行代码的购物车模块上——问题不在业务逻辑,而在 Redis Hash 的编码在某个临界点悄悄翻了身,从一个紧凑的连续结构变成了真正的散列表,多出来的指针开销直接把内存顶上去了一截。这件事让我重新认真啃了一遍 Hash 的底层实现,也让我意识到,很多人口中"会用 Redis"和真正"敢在核心链路上用 Redis Hash"之间,差的是对细节的掌控。
这篇内容就是我那段时间的完整梳理,从为什么选 Hash、底层编码怎么切换、命令有哪些暗坑,到购物车、对象缓存、计数器这些真实场景怎么落地,再到线上遇到的大 key 和内存暴涨怎么处理。不管你是刚接触 Redis 的新手,还是已经用了两三年但一直停留在"能跑就行"阶段的开发者,都能从里面找到可以直接抄走的东西。
1. 为什么我把对象缓存从 String 迁到了 Hash
1.1 一个真实场景:三个字段引发的三次网络往返
先说一个很典型的例子。某电商项目里有个用户概要信息,包含昵称、头像地址、等级三个字段。最初的做法是每个字段一个 String key:user:nick:1001、user:avatar:1001、user:level:1001。这个方案在写入端很舒服,改哪个字段就 SET 哪个,互不干扰。
问题出在读取端。一次详情页渲染要拿这三个值,就是三次独立的网络往返。Redis 单次命令的 RTT 在局域网里可能只有 0.2 毫秒,看着不多,但页面上不止一个用户信息,还要取商品、取库存、取推荐位,累计起来一次请求打了十几个 Redis 调用,P99 延迟就被这些碎调用硬生生抬了上去。
更麻烦的是运维视角。三个 key 意味着三份 key 的元数据开销,key 名越长、数量越多,内存里那些你看不见的部分占比就越大。十万用户就是三十万个 key,用SCAN扫一遍要扫很久,做数据迁移和备份的时候特别难受。
换成 Hash 之后,做法很直接:一个user:profile:1001存三个 field,一次HMGET拿全部或者拿其中几个。网络往返从三次变成一次,key 数量从三十万降到十万。这就是 Hash 最朴素也最扎实的价值——把同一实体的多个属性聚合成一个逻辑单元。
1.2 Hash 到底解决了什么问题
把 Hash 的价值拆开看,其实是三件事。
第一件是聚合。同一个业务实体的字段放在一个 key 下面,天然有归属关系。删除用户的时候一个DEL搞定,不用先查出所有相关 key 再逐个删。这一点在数据清理和一致性维护上省了大量代码。
第二件是部分读写。这是 Hash 相比"String 存 JSON"最锋利的地方。JSON 序列化之后要改一个字段,你得先 GET 出来、反序列化、改值、再序列化、再 SET 回去,整个过程有并发丢失更新的风险,而且传输量大。Hash 直接HSET user:profile:1001 level 5,只动那一个字段,其他字段纹丝不动。字段多、更新频繁的场景下,这个差距非常明显。
第三件是内存效率。在字段数量不多的时候,Hash 底层用的是紧凑的连续内存结构,每个 field-value 就是紧挨着的一段字节,没有额外的指针和对象头。同样存 20 个字段的小对象,用 Hash 往往比用 20 个 String 省下一大截内存。这个优势在实例里存了几百万个小对象时会被放大成实实在在的成本差异。
当然 Hash 不是银弹。它不支持嵌套,你要存一个带数组的结构就只能自己序列化某个 field 的值;它也不支持字段级过期,TTL 只能设在 key 上。这些限制后面会详细说,先把边界搞清楚,用起来才不别扭。
2. Hash 的底层结构与编码转换
2.1 listpack:小 Hash 的紧凑存储
Hash 类型在 Redis 内部有两种编码:一种叫 listpack(早期版本叫 ziplist),一种叫 hashtable。用哪种不是你能直接指定的,Redis 会根据数据规模自动判断。
listpack 是一块连续的字节数组,从头到尾依次排列着每个元素的编码信息。它内部为每个 field 和 value 都保存了一份紧凑的记录,记录里包含了内容长度、内容本身。因为是连续内存,遍历的时候 CPU 缓存命中率高,而且没有任何指针开销——在元素少的时候,这是最省内存的布局。
我打个比方:listpack 就像一张手写的清单,一行一行往下写,找东西只能从头往下捋。字段少的时候飞快,字段多了就变成线性扫描,复杂度是 O(N)。
这里有个关键细节值得单独拎出来:listpack 在插入和修改时可能需要重新分配整块内存。因为它是连续的,中间插入一个更长的元素就得把后面的内容整体往后挪。所以小 Hash 虽然省内存,但字段特别长或者频繁在中间位置增删的时候,CPU 开销会上升。这也是为什么 Redis 给 listpack 设了阈值——超过就转 hashtable。
补充一句版本差异:Redis 7.0 之前用的是 ziplist,7.0 开始用 listpack 替代。两者思路类似,但 listpack 解决 ziplist 在极端情况下连锁更新(cascade update)的性能问题,结构上更稳。如果你手上的实例版本比较老,看到的编码名会是ziplist,配置项也叫hash-max-ziplist-entries,别对着新文档找不着。
2.2 hashtable:大 Hash 的散列表
字段数量或者单个字段长度超过阈值,Redis 就会把整个 Hash 转成 hashtable 编码。底层就是一个标准的散列表,每个 field-value 存成一个节点,节点之间用指针串起来。
这时候查询是 O(1) 的平均复杂度,插入删除也不会引发大块内存搬迁。代价是每个节点都要额外的指针和元数据,内存占用比 listpack 高出不少——业内经验值大概是 1.5 到 2 倍,具体取决于 field 和 value 的长度分布。
这里必须强调一点:编码转换是单向的。从 listpack 转成 hashtable 之后,就算你后来把字段删到只剩两个,它也不会自动转回去。这个特性在实操中影响很大,后面第 5 章会专门讲一个因此踩坑的案例。
2.3 两个阈值参数与转换时机
控制转换的两个参数在 redis.conf 里:
# Redis 7.0 及以上 hash-max-listpack-entries 128 hash-max-listpack-value 64含义很直白:field 数量超过 128 个,或者任意一个 field 或 value 的长度超过 64 字节,就转 hashtable。两个条件是"或"的关系,触发任意一个就转换。
老版本的配置名是hash-max-ziplist-entries和hash-max-ziplist-value,默认值同样是 128 和 64。
这两个默认值是怎么来的?128 这个数字大致对应了"线性扫描还能接受"的上限——listpack 里查找一个字段平均要扫 64 个元素,每个元素比较一次,在现在的 CPU 上基本是纳秒级,完全感觉不到。64 字节则对应了"典型短字符串"的长度,超出之后紧凑布局带来的收益就被重新分配的成本吃掉了。
这两个参数不是不能改,但改之前要清楚代价。调大 entries 阈值,小 Hash 更省内存,但单个 key 的操作延迟会上去;调小阈值,操作更快但内存占用上升。我一般的建议是:如果你的 Hash 字段数稳定在 100 以内,别动;如果业务上明确每个 Hash 就是 5 到 10 个短字段,也别动;只有在你有大量小 Hash 且内存吃紧的时候,才考虑适度上调 entries。
看当前编码用什么命令?OBJECT ENCODING key:
127.0.0.1:6379> HSET user:profile:1001 nick "A同学" avatar "/a.png" level 3 (integer) 3 127.0.0.1:6379> OBJECT ENCODING user:profile:1001 "listpack" 127.0.0.1:6379> HSET user:profile:1002 desc "这是一段超过六十四个字节的非常长的描述文本..." (integer) 1 127.0.0.1:6379> OBJECT ENCODING user:profile:1002 "hashtable"第二组示例里,因为 value 长度超了 64 字节,只写了一个字段就已经是 hashtable 了。这个现象很多人第一次见会觉得奇怪,但完全符合规则。
2.4 渐进式 rehash 与那些"卡顿"时刻
hashtable 编码下还有一个绕不开的话题:rehash。
散列表扩容或者缩容的时候,需要把所有节点重新分配到新的桶数组里。如果一次做完,几百万个字段的 Hash 会在那一条命令里卡住,其他所有请求全被阻塞。Redis 的方案是渐进式 rehash:维护两张表ht[0]和ht[1],每次对 Hash 做读写操作时,顺手把ht[0]里的一批桶迁移到ht[1],用一个rehashidx记录迁移进度。迁移期间查询会同时查两张表,写入只写新表。
这套机制保证了单次操作不会被拖死,但副作用是:在大 Hash 扩容的那段时间里,内存会有明显的双份占用,因为两张表同时存在。这也是为什么有时候你会看到内存曲线在一个时间窗口内冲高然后回落,很可能就是某个大 Hash 正在 rehash。
如果实例里有activerehashing开启(默认开启),Redis 还会在空闲时间每 100 毫秒抽 1 毫秒出来推进 rehash,加快收敛。这个配置基本不用动,知道有这么回事就行。
3. 常用命令梳理与易错点
3.1 写命令:HSET、HSETNX、HINCRBY 的边界
写操作里最常用的是HSET。注意 Redis 4.0 之后HSET支持一次写多个 field-value,HMSET已经被标记为废弃,新代码统一用HSET。
127.0.0.1:6379> HSET cart:1001 sku:2001 2 sku:2002 1 (integer) 2返回值是新增的字段数,不是写入的字段总数。这个值和HLEN配合能判断某个 field 是不是第一次写入,做幂等逻辑的时候有用。
HSETNX只在 field 不存在时才写,返回 1 表示写入成功,0 表示已存在。做"首次创建"语义的时候很好用,比如给用户打标签、初始化默认配置,天然避免覆盖。
127.0.0.1:6379> HSETNX user:profile:1001 level 1 (integer) 0HINCRBY加一个整数增量,返回计算后的新值。这里有个必须知道的边界:它操作的是有符号 64 位整数,范围是-2^63到2^63-1。超出范围会直接报错ERR increment or decrement would overflow,不会静默截断。所以用它做计数器的时候,如果业务上可能超过 64 位范围(基本不会,但设计时要想清楚),得自己评估。
127.0.0.1:6379> HINCRBY cart:1001 sku:2001 3 (integer) 5 127.0.0.1:6379> HINCRBY cart:1001 sku:2001 -10 (integer) -5另外,HINCRBY对不存在的 field 会当作 0 处理,对不存在的 key 会自动创建。这一点既是便利也是隐患:如果你代码里有 bug 传了错误的 key 名,它会悄悄创建一个新 Hash,而不是报错让问题暴露。我见过一次线上因为拼接 key 时漏了用户 ID,结果所有用户的数据写进同一个 key,直到缓存穿帮才发现。
3.2 读命令:HMGET 与 HGETALL 的取舍
读单个字段用HGET,读多个字段用HMGET。HMGET的返回是一个数组,缺失的 field 对应位置返回 nil,顺序和请求的 field 顺序严格一致。这个特性很实用,指定字段名就能拿到确定位置的返回值。
127.0.0.1:6379> HMGET cart:1001 sku:2001 sku:2002 sku:9999 1) "5" 2) "1" 3) (nil)HGETALL返回全部 field 和 value,复杂度 O(N),N 是字段总数。小 Hash 用它无所谓,但字段数上千之后,一次HGETALL会在网络上产生几十 KB 的响应体,还可能在服务端造成明显的单命令阻塞。我的做法是:字段数不确定的 Hash,一律用HSCAN分批取,不用HGETALL。
HSCAN的用法是游标式遍历:
127.0.0.1:6379> HSCAN cart:1001 0 COUNT 100 1) "0" 2) 1) "sku:2001" 2) "5" 3) "sku:2002" 4) "1"返回的第一个元素是下一次的游标,第二个元素是 field-value 交替排列的列表。游标返回0表示遍历结束。
关于HSCAN有三条必须记住的保证和限制:
- 保证:从遍历开始到结束一直存在的元素,一定会被返回至少一次。
- 不保证:遍历期间新增或被删除的元素,可能返回也可能不返回。
- 可能重复:同一个 field 在一次完整遍历中可能被返回多次,所以下游必须做去重。
COUNT参数只是个提示值,Redis 未必严格按它返回,实际返回数量可能多也可能少。设太小会导致往返次数多,设太大又失去分批的意义,我的经验是 100 到 500 之间比较合适。
Redis 6.2 之后还加了HRANDFIELD,可以随机取字段,配合COUNT和WITHVALUES能取多个带值的结果。做抽奖、随机推荐这类场景挺好用。不过如果COUNT大于字段总数,它会把所有字段都返回,不会重复取,这一点和某些语言里的随机采样不太一样。
127.0.0.1:6379> HRANDFIELD cart:1001 1 WITHVALUES 1) 1) "sku:2002" 2) "1"3.3 删除与判断:HDEL、HEXISTS、HLEN
HDEL支持一次删多个 field,返回值是实际删除的数量。如果删完之后 key 里一个字段都不剩,Redis 会自动把这个 key 删掉——这是个很重要但经常被忽略的行为:Hash 空了之后 key 就不存在了,EXISTS会返回 0。所以如果你给 Hash 设过 TTL,删空再重建的时候,TTL 会一起消失,需要重新设置。这个坑我在限流场景里踩过,导致计数器永远不过期,内存慢慢堆积。
HEXISTS判断字段是否存在,返回 1 或 0。HLEN返回字段数量。这两个都是 O(1) 操作,可以放心用。不过HLEN在 listpack 编码下其实是遍历计数,虽然 128 个以内的遍历成本可以忽略,但如果你的代码里在一个循环里反复调HLEN,还是建议提出来缓存一下。
HKEYS和HVALS分别返回所有字段名和所有值,复杂度同样是 O(N),同样建议在大 Hash 上避开,改用HSCAN自己拼。
HSTRLEN返回某个 field 的值的字符串长度,做长度校验的时候比先HGET再算长度省一次数据传输。
3.4 那些被误用的命令
有几个命令在实际项目里被误用的频率特别高,值得单独点出来。
HGETALL用于"取一个字段"。有人图省事直接拿全部再在代码里取某个字段,小规模看不出问题,等数据涨起来就是定时炸弹。该用HGET就用HGET。
HKEYS用于"判断字段是否存在"。有人先拿所有字段再遍历判断,正确做法是直接HEXISTS。前者 O(N) 加一次全量传输,后者 O(1)。
HDEL后面不判断返回值。删不存在的字段返回 0,如果业务逻辑依赖"删除成功"这个语义,不检查返回值会出现静默失败。比如库存回滚场景,删失败了但代码继续往下走,数据就错乱了。
在事务或 Lua 里对超大 Hash 做全量操作。Lua 脚本是原子执行的,中间不会插入其他命令,如果你在脚本里HGETALL一个十万字段的 Hash,整个实例会阻塞到脚本执行完。这在线上是灾难级的,我见过一次因此引发的几十秒超时雪崩。
4. 典型落地场景与实现细节
4.1 对象缓存:Hash 与 String+JSON 的正面比较
这是 Hash 最经典的用途,也是选择成本最高的地方。两种方案各有明确的适用边界,我整理成表格对比:
| 维度 | Hash | String + JSON |
|---|---|---|
| 单字段读取 | HGET,直接定位,无需反序列化 | 需 GET 全量后反序列化 |
| 单字段更新 | HSET,只动一个字段 | 需读全量、改、写回,有并发风险 |
| 嵌套结构 | 不支持,需自行序列化某个 field | 天然支持任意层级 |
| 网络传输 | 可精确读取所需字段 | 总是传输完整 JSON |
| 内存占用(小对象) | 紧凑编码下更省 | 多一份 JSON 结构开销 |
| 整体替换 | 需先DEL再写,或逐字段覆盖 | 一次SET覆盖,天然原子 |
| 字段级过期 | 不支持 | 不支持 |
从表里能看出,判断标准其实很清楚:
- 字段多、读取时只关心其中少数字段、更新集中在个别字段 → 用 Hash
- 结构嵌套深、每次都要整体使用、整体替换频繁 → 用 String + JSON
我自己的经验线是:如果这个对象在一次业务操作里经常只用到不到一半的字段,或者存在高频的单字段更新,就选 Hash;如果是整体读取、整体写入、结构还可能变,就选 String+JSON。
有个折中方案在某些场景下很好用:外层用 Hash 存主要字段,把不适合拆开的部分(比如一个数组)序列化后放在一个 field 里。这样既保留了大部分字段的部分读写能力,又不用为了嵌套结构整个退回到 JSON。代价是代码复杂度上升,要维护两套访问逻辑,团队小的时候不太划算。
4.2 购物车:Hash 最贴切的场景
购物车是我认为 Hash 结构匹配度最高的业务。key 是用户,field 是商品,value 是数量,三个维度严丝合缝。
# 加购 HSET cart:1001 sku:2001 2 # 改数量 HSET cart:1001 sku:2001 3 # 数量增减 HINCRBY cart:1001 sku:2001 1 # 移除 HDEL cart:1001 sku:2001 # 查看全部 HGETALL cart:1001 # 商品数 HLEN cart:1001注意这里有几种不同的处理策略,选哪种取决于业务需求:
加购是否累加。如果用户点两次加购,数量是变成 2 还是还是 2?前者用HINCRBY,后者用HSET覆盖。多数电商是前者,但需要配合上限校验,不然用户狂点能把数量加到几十万。
上限校验的原子性。HINCRBY之后检查是否超限,超了再HDEL或者改回去,这个过程不是原子的。如果并发高,可能出现短暂超限。严格场景要用 Lua 脚本包起来:
-- KEYS[1] = cart key, ARGV[1] = sku, ARGV[2] = 增量, ARGV[3] = 上限 local cur = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0') local inc = tonumber(ARGV[2]) local max = tonumber(ARGV[3]) if cur + inc > max then return -1 end return redis.call('HINCRBY', KEYS[1], ARGV[1], inc)这个脚本在库存校验、限量领取这类场景里是标准写法,值得收进工具箱。
购物车的过期策略。绝大多数购物车需要过期,不然长期不活跃的用户数据会一直占内存。整 key 设 TTL 最简单:
EXPIRE cart:1001 2592000但要注意,每次用户操作购物车都应该刷新 TTL,否则活跃用户的购物车也会在 30 天后突然消失。这个刷新逻辑要写在每次写操作之后,或者用HSET时统一包一层。
商品信息的存放。很多人会在购物车 Hash 里顺手存商品名、价格、图片。这是个隐患:商品信息会变,缓存里的是旧值,展示出来价格不对就是事故。正确做法是购物车只存商品 ID 和数量,展示时再批量查商品服务或者商品缓存。这个道理简单,但实际项目里踩坑的不少。
4.3 计数器与限流:HINCRBY 的正确打开方式
HINCRBY是天然的多维计数器。典型用法是把时间维度作为 field 名:
# 按天统计某个接口的调用量 HINCRBY stat:api:order:20240501 /api/order/create 1 # 按小时统计 HINCRBY stat:api:order:20240501 14 1这样做的好处是一个 key 下聚合了多个维度的计数,查询某个时间范围的数据只要取对应 key 的全部字段,一次HGETALL就够(字段数一般不超过几十,安全)。
限流场景下 Hash 也有用武之地。比如按用户维度记录多个接口的调用次数:
# key: limit:user:1001,field 是接口名,value 是窗口内次数 HINCRBY limit:user:1001 /api/order/create 1 EXPIRE limit:user:1001 60这里有个细节要格外注意:EXPIRE每次都要设置,但不要每次都重置。因为HINCRBY之后再EXPIRE,会把窗口期往后推,变成滑动窗口而不是固定窗口。如果你要的是固定窗口(比如每分钟重置),应该在 key 不存在时才设置 TTL:
-- 固定窗口限流 -- KEYS[1]=key ARGV[1]=field ARGV[2]=上限 ARGV[3]=窗口秒数 if redis.call('EXISTS', KEYS[1]) == 0 then redis.call('HSET', KEYS[1], ARGV[1], 1) redis.call('EXPIRE', KEYS[1], ARGV[3]) return 1 end local cur = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0') if cur >= tonumber(ARGV[2]) then return -1 end return redis.call('HINCRBY', KEYS[1], ARGV[1], 1)这个脚本有个隐含问题:EXISTS和后面的写入之间不是原子的吗?在这个 Lua 里是原子的,因为整个脚本串行执行。但在脚本之外并发进来的其他 key 的写入不受影响,这点要注意。
还有一个更隐蔽的坑:如果 Hash 里所有字段都被删光了,key 会消失,TTL 也跟着没。有些清理逻辑会HDEL掉归零的字段,结果 key 被删掉,下次写入重新开始,限流窗口被意外重置。要避免这个问题,就别删字段,让它自然过期,或者删之前判断HLEN是否大于 1。
4.4 配置热更新与可重入计数
Hash 还有个特别实用的场景:配置中心。
HSET config:order-service timeout 3000 retry 3 grayPercent 10服务启动时把配置读到本地缓存,同时订阅一个更新通知(可以用发布订阅,也可以定时轮询比对版本号)。运维改配置就是一个HSET,服务端秒级生效,不用重启。
相比每个配置一个 String key,Hash 的优势是:一次HMGET拿多个配置,改一个不影响其他,key 数量少,用HSCAN遍历所有配置项很自然。
还有一个用法是可重入锁的计数。分布式锁里记录"同一线程重入了几次",用 Hash 的 field 存线程标识,value 存重入次数,比用 String 拼接更清晰:
# 加锁 HSET lock:order:1001 thread-A 1 # 重入 HINCRBY lock:order:1001 thread-A 1 # 释放一次 HINCRBY lock:order:1001 thread-A -1 # 归零则彻底释放 HDEL lock:order:1001 thread-A注意这里有个坑:如果重入计数减到 0 之后不HDEL,field 会一直留着,key 也永远不过期。要么减到 0 立刻删,要么给 key 设置一个兜底的 TTL,防止异常情况下锁永远不放。
5. 大 key、内存与线上踩坑记录
5.1 大 key 的判定与治理
Hash 最容易出事的形态就是大 key。业内比较通用的判定线是:单个 Hash 的字段数超过 5000,或者序列化后的总大小超过 10MB,就该被列为重点关注对象。
大 key 的危害是连锁的:
- 单次
HGETALL或HKEYS产生大量网络传输,客户端解析慢 - 删除时如果直接用
DEL,会阻塞主线程(释放内存是同步的) - 主从复制时会产生一个大命令包,可能撑爆复制缓冲区,触发全量同步
- 集群模式下整个 Hash 只能落在一个分片上,容易造成数据倾斜
治理的第一步是发现。可以用redis-cli --bigkeys扫一遍,它对每种类型都会找出最大的那个:
redis-cli -h 127.0.0.1 -p 6379 --bigkeys但它只找每种类型最大的一个,不够全面。更细的做法是用SCAN遍历所有 key,配合MEMORY USAGE key和HLEN统计:
redis-cli --scan --pattern 'cart:*' | while read k; do size=$(redis-cli MEMORY USAGE "$k") echo "$k $size" done | sort -k2 -nr | head -20生产环境慎用,MEMORY USAGE在大 key 上是 O(N) 操作,虽然样本数不大但有开销,建议在从节点或者低峰期跑。
治理的第二步是拆分。一个存了十万字段的 Hash,要么按业务逻辑拆成多个 key(比如按用户分片),要么改成每个字段一个 String。后者牺牲了聚合性但解决了单点问题,前者需要业务上有天然的拆分维度。
治理的第三步是安全删除。绝对不要对大 key 直接DEL。
# 不推荐:可能阻塞 DEL cart:1001 # 推荐:UNLINK 异步释放内存 UNLINK cart:1001 # 或者分批删 # 用 HSCAN 每次拿 100 个 field 再 HDEL,循环到清空UNLINK是 Redis 4.0 引入的,把内存释放放到后台线程做,主线程只负责摘除引用,基本不阻塞。如果是老版本没有UNLINK,就老老实实分批HDEL。
5.2 编码转换引发的内存暴涨
回到开头那个凌晨三点的问题。排查过程大致是这样的。
先看内存分布,发现某个业务前缀的 key 占了大头。抽样几个 key 看OBJECT ENCODING,全是hashtable。再看HLEN,只有 20 到 30 个字段,按理说远没到 128 的阈值。那为什么转了?
再仔细看单个字段的长度——其中有个字段是一条用户备注,长度经常超过 64 字节。只要触发一次超长写入,整个 Hash 就永久变成 hashtable,之后哪怕把这些长字段删掉也不会转回去。
内存差距有多大?我做过一个粗略对比,同一个 25 字段的对象,listpack 编码下MEMORY USAGE大概是 1.2KB,转成 hashtable 之后是 2.4KB 左右,翻了一倍。这个实例上这类对象有两百多万个,多出来的就是几个 GB。
解决思路有几条,我们最后采用的是组合方案:
第一,把长字段挪出去。用户备注这种经常超过 64 字节的内容,单独用 String 存,Hash 里只保留短字段。这一条就解决了大部分转换。
第二,调整阈值。把hash-max-listpack-value从 64 调到 128,覆盖了绝大多数实际字段长度。这里要权衡:阈值调大之后,单个 field 的查找要在线性结构里比较更长的字符串,但考虑到字段数还是 20 到 30,实际影响微乎其微,我们用压测验证过延迟没有可观测的变化。
第三,对存量数据做迁移。写了个离线任务,用HSCAN遍历所有受影响的 key,把长字段抽出去、短字段重新HSET到一个新 key,然后UNLINK旧的。整个过程分批次跑,控制了速率,没对线上造成明显影响。
注意:编码转换是不可逆的,这是一个典型的"一旦触发不可回退"的设计。任何可能写入长字段的 Hash,上线前就应该想清楚是否会突破阈值。
5.3 常见问题速查表
下面这张表是我这两年记录下来的高频问题,基本覆盖了 Hash 使用中 80% 的疑问:
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 内存远超数据量估算 | 编码从 listpack 转成 hashtable | OBJECT ENCODING key看编码 | 抽离长字段、调整阈值、迁移重建 |
HGETALL响应慢甚至超时 | Hash 字段数过多 | HLEN key看字段数 | 改用HSCAN分批 |
| 删除 key 时主线程卡顿 | 大 key 同步释放内存 | 慢查询日志、LATENCY命令 | 改用UNLINK或分批HDEL |
| TTL 莫名消失 | 字段删空导致 key 被自动删除 | 检查是否有HDEL到空 | 保留占位字段或删前判断HLEN |
| 主从数据不一致 | 大 key 复制超时触发全量同步 | 看复制偏移量、缓冲区指标 | 拆分大 key、调大复制缓冲区 |
HINCRBY报 overflow | 超出 64 位有符号整数范围 | 检查计数值 | 改用 String 存大数或定期归零 |
HSCAN返回重复字段 | 遍历期间数据发生增删 | 属于预期行为 | 下游做去重处理 |
| 集群下某个分片内存偏高 | 大 Hash 无法分片 | CLUSTER KEYSLOT看槽位分布 | 按业务维度拆 key |
再补充几个我自己的实操心得,这些是文档里不会写的。
心得一:给 Hash 的 key 命名留出可拆分的维度。比如cart:{userId}而不是cart加 field,这样天然分散在集群的不同槽位上。如果一开始就设计成一个大 key 加十万个 field,后期拆分成本极高。
心得二:上线前用真实数据量做一次编码检查。写完代码跑一遍典型流程,然后OBJECT ENCODING看一眼。如果预期是 listpack 结果变成了 hashtable,说明你的字段设计有问题,这时候改成本最低。
心得三:监控里加一个编码分布的指标。定期抽样统计listpack和hashtable的比例,比例突变就是信号。我们后来加了这个监控,第二次出现类似趋势时提前一周就发现了。
心得四:不要在 Hash 里存会变化的"引用数据"。商品名、用户名、店铺名这类会变的字段,存进去就是给自己埋雷。Hash 适合存"归属明确、变化可控"的数据。
心得五:客户端库的批量能力要用起来。像 Java 客户端的 Pipeline、Python 客户端的pipeline(),把多个HGET合成一批发出去,能显著降低 RTT 累积。不过要注意单批的命令数,几百条一批比较合适,太多会撑大客户端的发送缓冲区。
我个人在实际项目里的体会是,Redis Hash 这东西上手门槛极低,五个命令就能跑通业务,但真正决定它稳不稳的,是那几个不起眼的配置项和编码规则。把hash-max-listpack-entries和hash-max-listpack-value这两个数记在心里,写代码的时候脑子里过一遍"我这个字段会不会超过 64 字节",能省掉后面一大半的排查时间。至于HGETALL,我的建议很简单:写的时候多问一句"这个 Hash 的字段数有没有上限",如果没有明确答案,就用HSCAN。慢一点没关系,卡住线上才是真麻烦。