Redis 的 Hash 命令,写起来就那么几个:HSET、HGET、HMGET、HGETALL,三分钟能背完。但我在实际项目里见过太多栽在这几个命令上的情况:有人拿 Hash 当无限长的表,塞了二十多万个 field,一次 HGETALL 把主线程卡住两秒多;有人明明只改一个字段,非要用 String 存整段 JSON 做读-改-写,并发下丢数据还查不出原因;还有人不知道小 Hash 和大 Hash 完全是两套底层结构,踩了内存翻倍的坑,回头怪版本升级。这篇就按我自己的理解路径,从命令语义、返回值细节、底层编码一路捋到大 key 治理,把我踩过和见过的坑都摊开讲。适合刚上手 Redis 不久、想把这套东西真正用稳的同学,也适合写了几年业务、但一直靠“能跑就行”混过来的朋友——把 Hash 这一个结构啃透,你对 Redis 单线程模型、内存编码、原子性的理解会顺带上一个台阶。
1. 为什么 Hash 值得单独拉出来讲
1.1 一个购物车场景的真实改造过程
我印象最深的一次改造,是某电商项目的购物车。最早的设计很朴素:一个用户一个 String key,value 是整段 JSON,结构大致是{"sku_9527": 2, "sku_1001": 1, ...}。上线初期没问题,后来就陆续出状况。第一个问题是并发丢更新,用户在两台设备上同时加购,两边都读出旧 JSON、各自改完再写回,后写的把先写的覆盖掉,用户投诉“我明明加了两个”。第二个问题是网络开销,一个大促期间的重度用户,购物车里两百多个 SKU,JSON 序列化出来十几 KB,而加一个商品只需要把某个 sku 的数量加一,却要传输十几 KB 全量数据。第三个问题是反序列化开销,渲染购物车列表时后端每次都要把整段 JSON parse 成对象,再取需要的字段。
改成 Hash 之后,这三件事一起解决了。key 还是cart:{userId},field 用 skuId,value 用数量。加购就是HINCRBY cart:1001 sku_9527 1,减购就是HINCRBY cart:1001 sku_9527 -1,这个操作在 Redis 单线程里是原子的,两台设备同时加购不会互相覆盖,也不需要任何分布式锁。改数量的网络开销从十几 KB 降到几十字节。渲染列表的时候,如果只要 SKU 和数量,HGETALL直接拿到一个平坦数组,不需要 parse,业务侧拼一下就能用。
但改造完之后又冒出新问题:那个两百多 SKU 的用户,HGETALL一次返回四百多个元素,虽然不至于卡死,但配合上商品详情查询,接口响应时间明显变长。后来的做法是把“数量”和“展示信息”彻底拆开——Hash 里只存 skuId 到数量的映射,商品标题、图片、价格这些走另一套缓存,购物车渲染时批量MGET或者批量查商品缓存。这个拆分动作很关键,它其实是在回答一个问题:Hash 到底该存什么。我的答案是,存那些需要被单独读写、需要原子操作、且数量可控的字段,而不是把整个业务对象一股脑塞进去。
1.2 Hash 的模型定位:一个 key 底下的结构化字段表
从数据模型上看,Hash 就是一层映射套一层映射:外层是 Redis 的 key 空间,内层是一张 field 到 value 的小表。value 只能是字符串,但你可以把它当整数用(配合 HINCRBY),也可以存序列化后的内容。单个 Hash 理论上能放 2^32 - 1 个 field,这个数字大到没有实际意义,因为真正的限制从来不是协议上限,而是内存和单线程阻塞。
它跟 String 的核心差异在“更新粒度”。String 存 JSON 时,任何一次字段修改都是全量替换,你没法告诉 Redis“我只改 nickname 这一项”,除非你自己在客户端做合并。Hash 天生支持字段级操作,HSET user:1001 nickname "阿吉"只动这一个字段,其他字段纹丝不动,而且这个动作是原子的。这个差异在并发场景下会被放大:两个请求同时改同一个对象的不同字段,String 方案下必然有一个覆盖另一个,Hash 方案下两个都能成功。
它跟 ZSet 的差异在“有没有序”。Hash 的 field 是无序的,底层是哈希表,你没法按 field 的字典序或者按 value 的大小做范围查询。所以排行榜、时间线这类需求,Hash 干不了,该用 ZSet。反过来,ZSet 也没法做“按 field 名精确取值”这种 O(1) 操作。这两个结构经常被混着用:比如文章的多维计数用 Hash 存,热门文章榜用 ZSet 存,各司其职。
再看一个容易被忽略的对比维度——字段级过期。在 Redis 7.4 之前,Hash 是不支持给单个 field 设置过期时间的,只有整个 key 能EXPIRE。这个限制在用户画像、会话数据这类场景里非常致命。比如你想让“最近一次登录 IP”这个字段七天后自动失效,Hash 做不到。主流的绕法有三种:一是整体设过期,让整个 Hash 一起失效重建;二是额外维护一个 ZSet 记录每个 field 的写入时间戳,读的时候判断是否过期,再懒删除;三是干脆把这个需要过期的字段拆出去,单独用一个 String key 存,因为 String 支持SET ... EX 604800。我一般选第三种,简单直接,代价是多一个 key。
1.3 该用和坚决别用的分界线
我把判断标准压缩成几条,基本能覆盖八成场景。
该用 Hash 的情况:对象的字段需要独立更新,比如用户资料、商品属性、配置项;一组相关的计数器需要一次性读回,比如一篇文章的点赞、收藏、评论、转发四个数值,用 Hash 存,HGETALL一次拿全,比四次GET省三个 RTT;需要原子增减且不想引入额外同步机制;字段数量可控,能预估上限。
坚决别用的情况:需要按 value 排序或范围查询,这是 ZSet 的地盘;字段数量无上限增长,比如把用户每一次浏览行为都塞进一个 Hash,这种数据应该用 List、Stream,或者干脆不落 Redis;需要字段级 TTL;存在深层嵌套结构,比如“订单里有多个商品,每个商品有多个规格”,这种嵌套硬塞进 Hash 只会把 value 变成又一段 JSON,白白损失 Hash 的优势,正确做法是拆成多个 key,用命名规范串起来。
还有一个隐形的坑:很多人把 Hash 当成“小型关系表”,一个 key 底下放几万个 field,然后在业务里做分页。这种用法短期能跑,长期必然出问题,后面第 6 节会详细讲大 key 的治理。
2. 命令全景:哪些是主力,哪些是边角料
2.1 四个必须闭眼能写的核心命令
HSET key field value [field value ...]是写入的主力。这里有个细节很多人没注意:它的返回值是新增的 field 数量,而不是“成功写入的字段数”。也就是说,你HSET一个已存在的 field 去改它的值,返回 0,但值确实被改了。这个语义在做批量写入统计时很有用,但也容易误导——有人看到返回 0 以为写失败了,实际上是更新。另外,从 Redis 4.0 开始,HSET支持一次传多组 field-value,这让它彻底取代了老命令HMSET。HMSET现在还能用,但它返回的是简单字符串OK,拿不到新增数量,新代码没必要再用它。
HGET key field取单个字段,field 不存在时返回 nil。这个 nil 在不同客户端里表现不一样,有的返回 None,有的返回 null,有的返回空字节串,写业务判断的时候一定要确认你用的客户端是哪种行为,别拿if not value去判断,因为空字符串和不存在在某些客户端里区分不开。
HMGET key field [field ...]批量取,返回一个数组,顺序跟你传的 field 顺序严格对应。某个 field 不存在,对应位置就是 nil,不会导致整个调用失败,也不会缩短数组长度。这个特性很好用:你可以把一批 field 名传进去,拿到结果后按下标对位,不需要额外做 map 查找。但要注意,如果 key 整体不存在,HMGET返回的是全 nil 数组,长度等于你传的 field 个数,不是空数组。
HGETALL key取全部,返回一个平坦数组,格式是[field1, value1, field2, value2, ...],长度是 field 数量的两倍。这是 Hash 里最危险的命令,原因很简单:它的时间复杂度是 O(N),N 是 field 数量,而 Redis 是单线程的,一次HGETALL一个十万 field 的 Hash,这段时间里所有其他请求全部排队。我在压测环境里试过,十多万 field 的 Hash 做HGETALL,单次耗时能到几百毫秒量级,线上出现一次就是一次故障。所以我的规矩是:HGETALL只对确定是小 Hash 的 key 用,不确定就用HSCAN分批。
2.2 删除、存在性判断与长度统计
HDEL key field [field ...]删除字段,返回实际删掉的个数。这里有个非常关键的行为:当 Hash 的最后一个 field 被删除后,整个 key 会自动从 Redis 中消失。这个行为很多人不知道,写代码时容易出 bug——比如你先HDEL清空了一个 Hash,然后又去EXPIRE它,会返回 0,因为 key 已经不存在了。反过来的场景更常见:有人用EXISTS判断业务对象是否“初始化过”,结果对象的所有字段被清空后 key 自己没了,判断逻辑就失效。如果需要“空对象”这个状态存在,得额外塞一个占位字段,比如_init: 1。
HEXISTS key field返回 0 或 1,用来做存在性判断。这个命令本身 O(1),很轻,但别滥用——如果需要判断多个字段,HMGET一次拿回来判断 nil 更划算。
HLEN key返回 field 数量,O(1),底层直接读哈希表的元素计数。这个命令在监控和治理里很有用,比如写个巡检脚本定期扫一遍,把HLEN超过阈值的 key 记录下来。
HSTRLEN key field返回某个 field 对应 value 的字节长度。注意是字节数不是字符数,存中文的时候一个汉字通常占 3 个字节,别拿它当字符长度用。这个命令在判断“字段值是否超长”时有实际价值,因为 Hash 的编码转换阈值是按字节算的。
HKEYS和HVALS分别返回所有 field 名和所有 value,时间复杂度都是 O(N),属于典型的“小 Hash 才敢用”的命令。它们跟HGETALL一样有大 key 风险,而且实际业务里用得不多,因为HGETALL一次就拿到键值对了,拆成两次反而多一次往返。
2.3 数字字段的原子增减
HINCRBY key field increment是 Hash 里我最喜欢的命令,没有之一。它把“读-改-写”三个动作合成一个原子操作,彻底消除了并发丢更新的可能。field 不存在时按 0 处理,所以第一次HINCRBY cart:1001 sku_9527 1会直接把值设成 1,不需要先初始化。increment 可以是负数,做减库存、取消点赞都靠它。
它的边界条件必须记清楚。第一,value 必须是能解析成 64 位有符号整数的字符串,存了个"2.5"或者"abc"就会报ERR hash value is not an integer。第二,结果超过 long long 范围(大约正负 9.22×10^18)会报ERR increment or decrement would overflow,不会静默回绕。第三,前导空格、+号这类格式 Redis 的解析比较严格,别指望它能容错。
HINCRBYFLOAT key field increment是浮点版本,内部用 long double 计算。浮点就有精度问题,虽然 Redis 做了处理,返回结果时会去掉尾部的零,但0.1 + 0.2这类经典问题在业务侧依然要小心。我的建议是:能用整数就别用浮点。金额一律用最小货币单位(分)存整数,百分比、评分这类如果精度要求高,也放大成整数存,展示的时候再除。
还有一个HSETNX key field value,语义是“field 不存在时才写入”,返回 1 表示写了,0 表示没写。它的原子性只针对单个 field,常被用来做简单的抢占式初始化,比如给新用户写默认配置。但要注意,它不能替代分布式锁——多个 field 的组合初始化,HSETNX保证不了整体原子性。
2.4 遍历与随机取样:容易被低估的两个命令
HSCAN key cursor [MATCH pattern] [COUNT count]是遍历大 Hash 的正确姿势。它基于游标迭代,每次返回一小批数据和下一个游标,游标回到 0 表示遍历结束。这套机制的设计目标是在 rehash 期间也能保证“遍历开始时就存在的元素至少被返回一次”,代价是可能返回重复元素,业务侧必须自己去重。
这里有几个实操细节,不理解就会写出 bug。第一,COUNT是提示而不是精确值,你传COUNT 100,可能返回 30 条,也可能返回 0 条然后游标还没到 0,这是完全正常的,所以必须写循环一直扫到游标为 0,不能扫一次就以为完事。第二,MATCH是在取回数据之后过滤的,也就是说即使只有一个 field 匹配,Redis 依然可能扫过成百上千个元素,大 Hash 上用MATCH做模糊查询的效率很低。第三,HSCAN不保证一致性,遍历过程中如果有字段被删除,可能扫不到;有新增,可能扫到也可能扫不到。做数据迁移时要接受这个特性,或者选在低峰期停写迁移。
HRANDFIELD key [count [WITHVALUES]]是随机取字段,这个命令在抽奖、随机推荐、随机抽样做数据校验这些场景特别顺手。count 为正数时返回不重复的 field,最多返回全部;count 为负数时允许重复,返回的数量是 count 的绝对值,这在“随机抽 N 次,允许重复中奖”的场景里正好用得上。加上WITHVALUES会同时返回值,返回数组长度翻倍。
把常用命令整理成一张表,方便对照查。
| 命令 | 时间复杂度 | 返回值含义 | 典型风险 |
|---|---|---|---|
| HSET | O(1) 每字段 | 新增 field 数量 | 覆盖不报错,易误判 |
| HGET | O(1) | value 或 nil | nil 与空串需区分 |
| HMGET | O(N) N 为字段数 | 按序数组,缺失为 nil | key 不存在返回全 nil |
| HGETALL | O(N) | 平坦数组,长度 2N | 大 key 阻塞主线程 |
| HDEL | O(N) | 实际删除数 | 删空后 key 自动消失 |
| HEXISTS | O(1) | 0 或 1 | 循环调用浪费 RTT |
| HLEN | O(1) | field 数量 | 无 |
| HSTRLEN | O(1) | value 字节长度 | 中文字符按字节算 |
| HINCRBY | O(1) | 增减后的值 | 非整数、溢出报错 |
| HINCRBYFLOAT | O(1) | 增减后的值 | 浮点精度 |
| HSETNX | O(1) | 1 写入 / 0 未写 | 仅单字段原子 |
| HSCAN | O(1) 每次 | 新游标 + 数据 | 可能重复、COUNT 不精确 |
| HRANDFIELD | O(N) 取全部时 | field 或 field-value | count 过大开销高 |
3. 底层编码:小 Hash 和大 Hash 是两种生物
3.1 listpack 与 hashtable 的分水岭
Redis 对 Hash 做了编码优化,小 Hash 用紧凑的连续内存结构存,大 Hash 用真正的哈希表。7.0 之前紧凑结构叫 ziplist,7.0 开始换成了 listpack,配置项名也从hash-max-ziplist-entries/hash-max-ziplist-value改成了hash-max-listpack-entries/hash-max-listpack-value。默认值分别是 128 和 64,意思是:field 数量不超过 128 个,且每个 field 名和 value 的长度都不超过 64 字节,就用 listpack;任意一个条件被突破,立刻转成 hashtable。
listpack 省内存的原因很直白:它把所有 field 和 value 紧凑地排在一块连续内存里,前面带长度信息,不需要为每个元素维护指针,也不需要维护哈希桶数组。代价是查找要遍历,但因为元素数量被限制在 128 以内,遍历成本可控。hashtable 则是标准的哈希表结构,查找 O(1),但每个元素都要额外的指针和桶数组开销,内存占用明显更高。
这两个阈值是可以调的,但我一般不建议乱调。往大了调,单个 Hash 的 listpack 会变长,查找变慢,而且HDEL这类操作在 listpack 上要移动内存,大 listpack 的删除成本不低。往小了调,更多 Hash 会提前退化成 hashtable,内存涨上去。默认值 128 和 64 是经过大量场景验证的平衡点,除非你有明确的内存测试数据支撑,否则别动。
3.2 编码转换是不可逆的,这一点必须记牢
最坑的地方在这里:Hash 从 listpack 转成 hashtable 是单向的。你往里写了一个 200 字节的长 value,编码立刻变成 hashtable;之后你把这个字段删掉,剩下的字段全部符合 listpack 条件,它也不会自己转回去。不同版本的内部行为可能有些微差异,但不要把希望寄托在自动回收上,实践中就是“转了就是转了”。
这意味着一次误写入的代价可能是永久的。我见过一个项目,商品属性 Hash 里本来只存 id、名称、价格这类短字段,后来有人加了个“商品详情描述”字段,平均两三百字节,结果这几十万个 Hash 全部从 listpack 变成 hashtable,内存用量直接翻了两倍多,监控报警才发现。排查方式很简单,OBJECT ENCODING key看一眼编码,MEMORY USAGE key看实际占用,再CONFIG GET hash-max-listpack-*确认阈值。
redis-cli OBJECT ENCODING user:profile:1001 # 期望输出 listpack,如果输出 hashtable 就要查原因 redis-cli MEMORY USAGE user:profile:1001 # 返回该 key 占用的字节数 redis-cli CONFIG GET hash-max-listpack-entries redis-cli CONFIG GET hash-max-listpack-value我在测试机上量过一个量级:128 个 field、field 名 12 字节、value 20 字节左右的 Hash,listpack 编码下MEMORY USAGE在几 KB 这个量级;同样的数据转成 hashtable,占用大概翻两到三倍。这只是量级参考,具体数字跟 key 名长度、内存分配器、是否开启 jemalloc 碎片整理都有关系,别拿它当精确基准。真正要养成的是习惯:涉及 Hash 的写入路径,上线前跑一遍OBJECT ENCODING,确认没有意外触发转换。
3.3 渐进式 rehash 与它带来的微妙延迟
hashtable 编码的 Hash 在扩容时会做渐进式 rehash,这是 Redis 单线程模型下的必要设计。内部维护两张表 ht[0] 和 ht[1],以及一个rehashidx游标。需要扩容时(通常负载因子达到 1,如果正在做持久化则放宽到 5),给 ht[1] 分配空间,然后每次对该 key 进行增删改查操作时,顺带把 ht[0] 上的一个桶迁移到 ht[1],同时后台定时任务也会帮忙搬一部分。搬迁期间,查找会先查 ht[0] 再查 ht[1],新增只写 ht[1]。
这个机制保证了扩容不会一次性阻塞,但代价是扩容期间的单次操作会略微变慢,因为要多查一张表。这解释了一个现象:某些大 Hash 的HGET平时都是零点几毫秒,偶尔会跳到一两毫秒,如果没有其他明显原因,很可能就是碰上 rehash 了。这不算故障,也不用优化,知道有这回事就行。
对业务侧的启示是:尽量避免在流量高峰期做大批量的 Hash 写入。如果有全量刷新缓存的任务,要么放在低峰,要么分批限速,给 rehash 留出喘息空间。
4. 实操:三个场景的完整落地写法
4.1 场景一:购物车的正确姿势
数据结构定成cart:{userId},field 是 skuId,value 是数量。核心操作只有三个:加购、减购、读取。
# 加购,原子自增,不需要先查再写 HINCRBY cart:1001 sku_9527 1 # 减购,减到 0 以下的业务校验要自己做 HINCRBY cart:1001 sku_9527 -1 # 删除某商品 HDEL cart:1001 sku_9527 # 读全量,只在小购物车场景用 HGETALL cart:1001减购这里有个细节:HINCRBY允许减到负数,它不做业务校验。数量不能为负,这个约束得自己保证。有两种做法,一是先HGET读出来在代码里判断,但这破坏了原子性,高并发下依然可能减成负数;二是用 Lua 脚本把“判断 + 增减”打包成一个原子操作。
import redis r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) DECREMENT_SCRIPT = """ local cur = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0') local delta = tonumber(ARGV[2]) if cur + delta < 0 then return -1 end return redis.call('HINCRBY', KEYS[1], ARGV[1], delta) """ decr = r.register_script(DECREMENT_SCRIPT) # 返回 -1 表示库存不足,其他值表示扣减后的数量 result = decr(keys=["cart:1001"], args=["sku_9527", -1])register_script内部用的是 EVALSHA,脚本会被缓存,不用每次都传全文。这是生产环境的标准做法,直接用EVAL每次都传脚本内容,在大脚本上会明显浪费带宽。
购物车的 Hash 还要考虑整体过期。通常设成 30 天,每次写操作后刷新一下EXPIRE。注意别每次读写都刷,那样等于把 Hash 变成了永不过期的 key,写操作后刷一次就够。
HINCRBY cart:1001 sku_9527 1 EXPIRE cart:1001 2592000如果要支持购物车列表分页,Hash 本身做不到,因为 field 无序。方案有两种:一是把 skuId 同时写进一个 ZSet,score 用加购时间戳,分页时先查 ZSet 拿到当前页的 skuId 列表,再HMGET批量取数量;二是干脆在前端分页,后端一次返回全部,适合 SKU 数量不大的场景。我倾向第一种,虽然多维护一个结构,但扩展性好,还能顺便支持“按加购时间排序”。
4.2 场景二:用户资料缓存与分桶策略
用户资料是最典型的 Hash 场景:user:profile:{userId},field 是 nickname、avatar、level、city 这些。更新的时候只HSET变化的字段,不用全量覆盖。
HSET user:profile:1001 nickname "阿吉" level 12 city "杭州" HGET user:profile:1001 nickname HMGET user:profile:1001 nickname level city这里最大的坑是 field 数量失控。用户资料字段随着业务迭代会不断增加,今天加个“会员等级”,明天加个“最近登录设备”,后天加个“偏好标签”,很容易突破 128 的 listpack 上限,甚至涨到几百上千。一旦突破 128,编码转 hashtable,内存翻倍;如果再涨到几千,HGETALL就开始危险了。
我的处理方式是分桶。把字段按业务域拆成几个 Hash:基础资料一个、账户资产一个、偏好设置一个。key 命名上做区分,比如user:base:1001、user:asset:1001、user:pref:1001。这样每个 Hash 的 field 数量可控,读取时按需取,不会因为读个昵称把资产信息也拉回来。
如果单个域内的字段还是太多,再进一步做哈希分桶:
def profile_key(user_id: int, buckets: int = 16) -> str: return f"user:profile:{user_id % buckets}"注意分桶的维度选择。按 userId 取模分桶,同一用户的字段会散到不同 key 里,读全量时要查 16 个 key,用 pipeline 批量发能接受。但如果你是按 field 名分桶,同一个用户的某个字段固定落在某个桶,读单个字段时能直接定位,读全量时反而要扫全部。这两种策略适用场景不同,前者适合“字段多但每次只读少数几个”,后者适合“字段多且经常要读全量”。
还有一个必须处理的问题:缓存与数据库的一致性。标准做法是“先更新数据库,再删除缓存”,而不是更新缓存。删除而不是更新,是因为并发下“更新缓存”可能出现旧值覆盖新值——A 请求读到旧数据准备写缓存,B 请求更新了数据库并删了缓存,A 才把旧值写进去,缓存就脏了。删除缓存配合下次读时回填,虽然有短暂的缓存穿透,但一致性更好保障。延迟双删可以作为兜底,但我一般不用,因为延迟时间很难拍准,反而增加复杂度。
4.3 场景三:多维计数器的组合读
内容平台的文章互动数据,点赞、收藏、评论、转发、举报,用 Hash 存是最合适的。
HINCRBY stats:article:8801 like 1 HINCRBY stats:article:8801 collect 1 HINCRBY stats:article:8801 comment 1 # 列表页一次取回所有计数,避免多次往返 HMGET stats:article:8801 like collect comment share这里用HMGET而不是HGETALL,理由是字段可能随业务增加,HMGET显式列出需要的字段,多出来的字段不会污染返回值,前端解析也更稳定。HGETALL适合“全部字段都要”的场景,但一旦有人在里面加了内部字段,前端就可能拿到不该暴露的数据。
批量场景下要用 pipeline 把多次HMGET打包:
def batch_stats(r, article_ids): pipe = r.pipeline(transaction=False) for aid in article_ids: pipe.hmget(f"stats:article:{aid}", "like", "collect", "comment", "share") results = pipe.execute() return {aid: dict(zip(("like", "collect", "comment", "share"), vals)) for aid, vals in zip(article_ids, results)}transaction=False表示不用 MULTI/EXEC 包裹,纯 pipeline 批量发送,性能更好。计数场景不需要事务语义,用不上 MULTI。
计数的持久化要注意:Redis 里的计数是“增量”,数据库里存的是“基准值”。常见做法是定时任务把 Redis 的增量刷回数据库,或者干脆以 Redis 为准、数据库异步落盘。前者要处理刷失败重试和幂等,后者要接受 Redis 故障时的数据丢失。这个取舍没有标准答案,取决于业务对数据准确性的要求。
4.4 一个容易被忽略的点:写入统一封装
不管哪个场景,我都强烈建议把所有 Hash 写入收敛到一个封装层。原因很实际:Hash 的坑集中在写入路径上——字段名拼错、value 超长触发编码转换、忘了设过期、批量写入没走 pipeline。如果这些逻辑散落在几十个业务文件里,排查起来非常痛苦。
封装层至少要做几件事:统一 key 前缀、统一过期时间、写入前校验 value 长度、多字段写入自动走 pipeline、记录异常日志。这套东西写一次,后面所有业务都受益。我在几个项目里都推行过,最直观的收益是“内存莫名上涨”这类问题的排查时间从半天缩短到十分钟,因为一眼就能看出是哪个业务写入了超长字段。
5. 常见问题与排查思路
5.1 问题速查表
| 现象 | 可能原因 | 排查手段 | 处置方式 |
|---|---|---|---|
| HGETALL 偶发慢查询 | key 的 field 数量过大 | 慢日志 + HLEN + MEMORY USAGE | 改 HSCAN 分批,或拆分 key |
| 内存持续上涨 | 编码从 listpack 转 hashtable | OBJECT ENCODING 抽查 | 清理超长 value,重建 key |
| HINCRBY 报类型错误 | 字段存了非整数字符串 | HGET 看实际值 | 修正写入逻辑,做类型校验 |
| HINCRBY 报溢出 | 累计值超 long long | HGET 确认当前值 | 改用分段计数或换结构 |
| 数据好像“丢”了 | 多处 HSET 互相覆盖 | 代码审查 + 慢查询 | 收敛写入入口,统一封装 |
| 集群下报 CROSSSLOT | 多 key 操作落在不同槽 | 检查 key 是否带 hash tag | 用 {} 指定相同槽位 |
| 删了字段 key 还在/不在 | 最后一个字段被删导致 key 消失 | EXISTS 检查 | 需要空态就加占位字段 |
| 读取返回 nil 但业务说写过 | 空串与 nil 混淆 | 用 HSTRLEN 判断 | 明确客户端的 nil 语义 |
5.2 HGETALL 引发慢查询的定位流程
线上报警“接口超时”,第一反应看慢日志。SLOWLOG GET 10如果看到HGETALL排在前面,基本就锁定了。接下来确认是哪个 key,慢日志里带 key 名,直接HLEN看字段数量,超过一万就属于必须治理的范围,MEMORY USAGE再看实际占用。
临时止血有两种。一是业务侧改成HSCAN分批取,这个改动量稍大但治本;二是加个短路判断,HLEN超过阈值就直接跳过缓存走数据库,牺牲性能保稳定,给改造争取时间。我一般选第二种先上线止血,因为改动小、风险低,然后再慢慢做第一种。
需要提醒的是,MONITOR命令虽然能看到所有请求,但它对性能影响很大,在生产环境慎用,而且如果 QPS 高,输出刷得你根本看不过来。定位单个 key 的问题,慢日志加MEMORY USAGE通常就够了。
5.3 内存翻倍问题的复盘路径
这类问题的排查有个固定套路。先OBJECT ENCODING抽查一批 Hash,如果大量出现 hashtable,去看CONFIG GET hash-max-listpack-value,确认阈值。然后把嫌疑 key 的字段逐个HGET出来,看 value 长度,大概率能找到那个超长字段。
找到之后没有捷径,只能重建。做法是HSCAN扫出所有字段,把超长字段拆到独立的 String key,剩下的用HSET写到新 key,最后RENAME覆盖。注意不要用DEL直接删大 key,会阻塞,用UNLINK或者HSCAN+HDEL分批删。
预防比治理重要。封装层里加一条校验:value 字节长度超过 64 就拒绝写入并打日志。这条规则救过我两次。
5.4 集群模式下的槽位陷阱
Redis Cluster 下,一个 Hash 的所有 field 天然在同一个槽,所以单 key 的 Hash 操作不会有跨槽问题。出问题的是多 key 操作,比如你用 Lua 脚本同时操作cart:1001和cart:1002,或者用MGET批量取多个 Hash 之外的 key。
解决办法是 hash tag:在 key 里用{}包住决定槽位的部分,比如cart:{1001}:items和cart:{1001}:total,两个 key 都会按1001算槽,落在同一节点。但要注意别滥用,如果所有 key 都用同一个 tag,等于把整个集群的数据塞到一个节点上,分片就失效了。
分桶策略在集群下要重新审视。按 userId 取模分 16 桶,16 个 key 会散在不同节点,读全量时需要客户端做扇出合并,复杂度上升。如果桶数量不多,可以用 hash tag 强制它们同槽,但这又回到了单节点压力问题。这里的取舍没有通用答案,得看你的数据量和访问模式。
6. 大 key 治理与几条硬规矩
6.1 怎么发现大 key
常规工具是redis-cli --bigkeys,它会采样扫描并输出各类型最大的 key。这个命令的优点是简单,缺点是基于采样,可能漏掉,而且在大实例上跑会影响性能,建议在从节点或者低峰期执行。更精确的方式是SCAN遍历所有 key,逐个MEMORY USAGE,但全量扫一遍代价很高,适合离线做。
# 采样扫描,注意在低峰期执行 redis-cli --bigkeys -i 0.1 # 精确看单个 key 的内存占用 redis-cli MEMORY USAGE user:profile:1001 SAMPLES 0SAMPLES 0表示全量采样,更精确但更慢。默认SAMPLES 5会抽样估算,速度快但可能有偏差。
针对 Hash 的巡检,我习惯写个小脚本:用SCAN遍历,对每个 key 先TYPE判断是不是 hash,是的话HLEN,超过阈值记录到列表里。这个脚本跑起来对线上影响很小,可以定期执行。
6.2 大 key 怎么安全地删
DEL一个十万 field 的 Hash 会阻塞主线程,因为释放内存是同步的。正确做法有两种。一是用UNLINK,它把 key 从 key 空间摘除后,把实际的内存释放交给后台线程,主线程不阻塞。这是首选,一行命令搞定。
二是HSCAN+HDEL分批删,每批 100 个字段,批之间 sleep 几毫秒。这种方式适合老版本没有UNLINK的情况,或者你想精确控制删除节奏。缺点是慢,而且如果在删除过程中有新的写入,可能删不干净,需要多轮。
def safe_delete_hash(r, key, batch=100, pause=0.01): cursor = 0 while True: cursor, fields = r.hscan(key, cursor=cursor, count=batch) if fields: r.hdel(key, *fields.keys()) if cursor == 0: break time.sleep(pause) r.delete(key)注意hscan返回的 fields 在 redis-py 里是字典,直接取 keys 传给hdel就行。
6.3 我自己定的几条规矩
第一条,单个 Hash 的 field 数量控制在千级以内。超过就分桶或者换结构。这不是 Redis 的硬限制,是运维的软约束——千级以内的 Hash,HGETALL的耗时在可接受范围,HSCAN遍历也快,出问题好处理。
第二条,value 长度尽量压在 64 字节以内。这不仅是为了保住 listpack 编码,也是为了控制单 key 内存。超过 64 字节的内容,问自己一句:这东西真的需要放在 Hash 里吗?大多数时候答案是“不需要”,拆成独立 key 更合适。
第三条,不给 Hash 存大 JSON、不存二进制。Hash 的 value 是字符串,塞一段序列化数据进去,等于放弃了字段级操作的所有优势,还不如直接用 String。
第四条,所有 Hash 写入走统一封装。这条前面说过,但值得重复,因为它能挡住八成的低级错误。
第五条,上线前对核心 Hash 跑一遍OBJECT ENCODING和MEMORY USAGE。这个动作花不了两分钟,但能提前发现编码转换和内存异常。我在几个项目里把它加进了发布检查清单,后来再没出现过“上线后内存翻倍”的事故。
第六条,给每个 Hash 明确过期策略。要么整体设 TTL,要么有明确的清理任务。没有过期策略的 Hash 是内存泄漏的温床,尤其是按用户维度建的 key,用户量增长就是内存线性增长。
这六条不是什么高深技巧,都是被坑出来的条件反射。真正用起来你会发现,Hash 本身很简单,难的是管住自己和其他人的手——别为了图省事往一个 key 里塞不该塞的东西,别在没搞清楚数据规模的情况下随手写个HGETALL。把这几个习惯养成了,Hash 就是 Redis 里最趁手的结构之一;养不成,它就是最容易埋雷的地方。
最后分享一个我常用的小技巧:调试阶段,在本地开一个空实例,把线上某几个 Hash 的HLEN、OBJECT ENCODING、MEMORY USAGE三个值抄下来,写成一个对照表。等你改完代码或者调整了字段设计,再跑一遍,前后一对比,编码有没有退化和内存有没有膨胀一目了然。比事后翻监控快得多。