Hash 字段操作与大 Hash 拆分
Hash 把一个对象的多个字段压在一个 key 里,单字段读写是 O(1),看似完美,但返回值语义、字段无法单独过期、大 Hash 的性能悬崖这三件事经常让人栽跟头。本文以 Redis 7 为基准,从字段级命令细节讲到 HSCAN 遍历与大 Hash 治理。
一、字段级命令与返回值语义
HSET 一次可以设置多个字段,返回的是新增字段的数量而不是 1/0(HMSET 已废弃,统一用 HSET):
127.0.0.1:6379> HSET user:1001 name tom age 18
(integer) 2
127.0.0.1:6379> HSET user:1001 name tom2
(integer) 0
返回 0 只是说明字段本来就存在并被覆盖,不是失败。其他字段级命令的行为:
- HSETNX:字段不存在才写,返回 1/0,适合"初始化一次就不许改"的字段。
- HINCRBY / HINCRBYFLOAT:对字段做计数,字段不存在按 0 起步;值不是合法数字直接报错,key 中其他字段不受影响。
- HMGET:允许部分命中,缺失字段返回 nil 保持在对应位置,适合字段可选的对象,比多次 HGET 少几次往返。
- HSTRLEN:返回某字段值的长度,不用把值拉回来再算,探测大 value 时很好用。
- HRANDFIELD(Redis 6.2+):随机返回字段,count 为正返回去重结果、为负允许重复、不传返回 1 个,语义与 Set 的 SRANDMEMBER 一致。
另有几组命令补全日常操作:HDEL 删除字段并返回实际删除数,HEXISTS 判存,HKEYS/HVALS 分别取全部字段名与字段值(O(N),大 Hash 用 HSCAN 替代),HLEN 返回字段数是 O(1);HRANDFIELD 带 WITHVALUES 可以连值一起随机返回,做"随机翻牌"类活动很方便。所有字段级命令都只触碰单个字段,Hash 再大也不会拖慢单字段点查——慢的只有 HGETALL 这类全量命令,这正是 Hash 相对"整个对象存成一个 String"的核心优势:局部更新不用整存整取。
二、字段没有独立过期(7.4 之前)
TTL 挂在整个 key 上,Redis 7.4 之前无法让某个字段单独过期。典型诉求是购物车里"30 天没有动作的行自动消失",通用做法有两种:一是把时间戳冗余进 value 或单独建 updateAt 字段,读取时判断、HSCAN 巡检时惰性清理;二是按时间分桶,把不同月份的数据拆到不同 key,整 key 设 TTL。Redis 7.4 起可以用 HEXPIRE 直接为字段设置过期,但它与整 key TTL 是两套体系,字段过期不会影响 key 的存活,混用时要在团队内明确口径,否则排查"字段怎么不见了"会非常痛苦。
分桶的具体做法:以月为粒度建 cart:{uid}:202610,整 key 设 60 天 TTL,跨月数据自然滚动过期;查询时按当月与上月两个 key 各取一次再合并,写入永远落在当月 key。它把"字段过期"问题转化成"key 过期",是 7.4 之前最省心的方案。
三、HSCAN 渐进遍历
HGETALL、HKEYS、HVALS 都是 O(N) 一次性返回全部内容,字段上万时单次响应体积大、主线程被长时间占用。HSCAN 基于游标分批拉取:
127.0.0.1:6379> HSCAN user:1001 0 COUNT 100
1) "34"
2) 1) "name"
2) "tom"
3) "age"
4) "18"
第一条是下次续传的游标,为 0 表示遍历结束。四条语义要记牢:COUNT 只是建议值,实际返回多少由底层编码决定,listpack 编码的小 Hash 可能第一次就把全部字段返回;遍历期间一直存在的字段保证至少返回一次,可能重复,去重要靠调用方;遍历中被删除的字段可能返回也可能不返回;HSCAN 不加锁,全程不阻塞其他命令。Redis 7.4 起 HSCAN 还支持 NOVALUES 选项,只返回字段名不返回值,做字段清点更省流量。清点示例:巡检脚本用 HSCAN key 0 COUNT 500 NOVALUES 只拉字段名,逐个 HSTRLEN 找超长 value,整轮遍历对主线程几乎无感;对比之下,一条 HGETALL 打在两万个字段的 Hash 上,响应可能有好几 MB,还会在慢日志里留下一笔。
四、大 Hash 的判定与拆分
判定大 Hash 看三个信号:HLEN 超过万级;MEMORY USAGE 显示的内存明显偏大(hashtable 编码下每个字段要额外承担 dictEntry 与 SDS 头的指针开销,字段越多越明显);HGETALL 或 HKEYS 耗时进入毫秒级并在 slowlog 出现。
治理两条路。其一是业务上拆 key,例如购物车按字段名哈希取模拆成 user:cart:{uid}:0 到 user:cart:{uid}:9 十个子 key,花括号是 Cluster 的 hash 标签,保证所有子 key 落在同一槽位,多 key 命令才能继续用;其二是结构降维,纯计数场景改用位图或 HyperLogLog(见本系列后续文章),对象超大时拆成多个 Hash 分层存储。迁移过程用 HSCAN 分批读、HSET 分批写,最后对账 HLEN 后再处理旧 key,删除用 UNLINK 异步释放,不要对线上大 key 直接 DEL。
量化工具两件:MEMORY USAGE user:1001 默认抽样统计,大 Hash 要传 SAMPLES 0 才是精确值;配合 HLEN 算出单字段均摊内存,就能预估拆分后的收益。拆分后的取模路由要收敛在一个工具类里,所有读写走同一入口,避免各处自己实现导致规则漂移、数据散落在两套 key 上。
常见坑 / 最佳实践
- HSET 的返回值是新增字段数,不要按 0/1 判断成功。 返回 0 代表字段已存在并覆盖,按"非 1 即失败"写逻辑会误报。
- 不要把 Hash 字段当"会自动过期的会话行"。 7.4 之前字段不会单独过期,必须自带时间戳并定期 HSCAN 清理,否则大 Hash 只增不减。
- 字段数量与字段值长度双重控制。 字段数超过 hash-max-listpack-entries(默认 128)或任何一个值超过 64 字节,整个 Hash 转成 hashtable,内存与遍历成本同步跳升;value 普遍长的场景不要指望调参数解决。
- 线上禁用大 Hash 的 HGETALL/HKEYS/HVALS。 点查用 HMGET,遍历用 HSCAN 分批,删除用 UNLINK,并把这些约定固化到代码评审清单。
- 拆 key 时用 hash 标签保证 Cluster 同槽。 不带
{...}的多个 key 在 Cluster 下执行 MGET、HSCAN 迁移等 multi-key 操作会报 CROSSSLOT。
小结
- HSET 返回新增字段数;HMGET 支持部分命中;HRANDFIELD、HSTRLEN 这类 6.2+ 命令能省不少应用层代码。
- 7.4 之前字段不能单独过期,要么自带时间戳惰性清理,要么按时间分桶整 key 设 TTL。
- 遍历一律 HSCAN 分批进行,理解游标、COUNT 提示与"至少一次"语义,避免 HGETALL 阻塞主线程。
- 大 Hash 用 HLEN、MEMORY USAGE、slowlog 三个信号判定,按 hash 标签拆 key 或降维改造,删除走 UNLINK。
- 点赞
- 收藏
- 关注作者
评论(0)