Redis Hash 命令实战:编码转换、慢查询与避坑指南
某个接口的 P99 从 40ms 涨到 800ms告警一响第一反应是数据库慢查询排查了半天发现 SQL 都很干净最后SLOWLOG GET里躺着的是一条HGETALL——某次活动上线后,一个 Redis Hash 的 field 从几十个涨到了十几万个,一次全量拉取直接把单线程堵住了。这个场景我遇到过不止一次,而每次问题解决之后,当事人对 Hash 的态度的变化都很一致:从不就是存个对象吗变成这玩意儿得挑着用。Redis 的 Hash 命令集是五大数据结构里最像业务对象的一个,它把一个 key 内部的 field-value 对组织成一张小表,读可以按字段点穴,写可以按字段增量改,不用像 String 那样整存整取。正是这种半结构化的形态,让它在对象缓存、购物车、计数聚合、字段级去重这些场景里出镜率极高。但它同时也是命令语义最容易记混、坑点最集中的一块:HSET和HMSET到底差在哪、HINCRBY的返回值是什么、HGETALL什么时候会出事、listpack 和 hashtable 两种编码怎么切换、field 能不能单独设过期时间。这篇内容面向已经会用HGET/HSET但还没把这些细节捋顺的开发者,也适合想搞清楚底层为什么这么设计的进阶读者。我打算从 Hash 的建模本质讲起,把编码转换的门槛、高频命令的精确语义、遍历类命令的危险边界、真实业务里的落地写法,以及我踩过的几个坑,一条条摊开讲。1. 先想清楚 Hash 这个抽屉柜在解决什么形状的问题1.1 从整存整取到按字段点穴String 存对象的方式很粗暴:把整个对象序列化成 JSON,一把塞进去。读的时候全量取回,反序列化,取你要的那个字段,剩下的扔掉;改的时候更麻烦,得先取回来、反序列化、改字段、再序列化、再写回去。整个过程是整存整取的,字段越多、越只关心其中一两个,浪费就越大。Hash 把这一层拆开了:key 是对象的名字,field 是属性名,value 是属性值。HGET user:1001 nickname只取昵称,不管其他字段有多大;HSET user:1001 level 8只改等级,不会碰头像 URL 那几 KB 的字符串。这带来的两个直接好处,一是网络传输量按需付费,二是并发修改不同字段时天然不会互相覆盖——两个人同时改昵称和签名,用 JSON 方案后写的会覆盖先写的,用 Hash 各改各的字段,互不干扰。这个差异在字段多、更新频繁的场景里会被放大得很厉害。我见过一个商品详情缓存的案例,一个商品有 60 多个属性字段,其中 90% 的请求只关心价格和库存,原来用 JSON 存,每次都要拉 8KB 下来;换成 Hash 之后HMGET只取两个字段,平均响应体积降到几百字节,带宽和反序列化开销一起下来了。1.2 三种建模方式的取舍:JSON、分键、Hash同一个业务对象,在 Redis 里至少有三种常见存法,选哪个不是审美问题,是取决于读写模式。建模方式典型命令适合的读写模式主要代价String JSONSET/GET整对象读、整对象写,字段少改一个字段要全量读写,并发写易覆盖一字段一 StringSET user:1001:nick字段极多、需要字段级过期key 数量爆炸,元数据开销大,无法原子读多字段HashHSET/HMGET多字段、按字段读写、字段集合相对固定无法给单个 field 设过期(7.4 之前),大 key 需治理一字段一 String 的方案经常被新手拿来解决字段级过期的需求,代价是 key 数量线性膨胀。Redis 每个 key 自身要维护过期字典、字典表项等结构,存 50 个字段就是 50 个 key,内存里一多半被元数据吃掉了,而且想一次拿三个字段得三次往返或者一次MGET,虽然MGET能合并,但跨 key 的原子性就没有了。Hash 的甜区很明确:字段数量在几十到几千这个量级,读写都是按字段来,字段集合基本稳定。低于这个量级用 JSON 更省事,高于这个量级就要考虑拆分了——具体怎么拆后面第 5 节讲。1.3 一个常被忽略的事实:Hash 的 field 数没有软上限Redis 官方给的理论上限是每个 Hash 最多 2^32-1 个 field,这个数字大到没有实际意义。真正约束你的是内存、单次命令的返回体积、以及主线程的阻塞时间。我见过有人把一个百万级用户的活跃度数据全塞进一个 Hash,field 是用户 ID,value 是分数,单 key 内存干到 3GB 以上,HGETALL一跑就触发主从复制积压、客户端超时,最后不得不停机迁移。所以建模的时候要养成一个习惯:先问自己这个 Hash 的 field 数量级是百、千、还是百万。百和千可以用编码优化和批量读来兜住,百万就必须在设计阶段拆桶,比如按hash:202401:01到hash:202401:31按日期分片,或者按用户 ID 取模分片。这属于设计决策,一旦数据写进去再改,迁移成本是灾难级的。2. listpack 与 hashtable:那道决定内存和性能的转换门槛2.1 小 Hash 用 listpack 存,是为了省那几十字节的元数据Redis 的 Hash 有两种内部编码:listpack(7.0 之前是ziplist)和hashtable。小 Hash 用 listpack,本质是把field1, value1, field2, value2...按顺序紧密排列在一段连续内存里,没有哈希桶、没有链表指针、没有单独的字典表项,查找靠顺序遍历。听起来很傻——顺序遍历不就是 O(N) 吗?但 N 小的时候,连续内存的顺序扫描比哈希计算加指针跳转更快,因为 CPU 缓存命中率高,一次加载能把整个 listpack 带进缓存行。这就是典型的小数据量下常数因子打败复杂度。等 field 多起来,顺序遍历的代价超过哈希方案,就需要转成hashtable。这个切换点由两个配置项控制,不同版本名字不一样,这一点特别容易踩:Redis 6.x 及更早:hash-max-ziplist-entries(默认 512)、hash-max-ziplist-value(默认 64)Redis 7.0 起:hash-max-listpack-entries(默认 128)、hash-max-listpack-value(默认 64)7.0 把底层结构从 ziplist 换成 listpack 的同时,顺带把 entries 的默认值从 512 调到了 128。这个变化意味着同样一份代码,在 6.x 上跑是 listpack 编码、在 7.0 上跑可能早就变成 hashtable 了,内存占用差出一截。所以别背默认值,上线前用CONFIG GET hash-max-listpack-entries看一眼实际值,写完数据用OBJECT ENCODING验一下。2.2 两个阈值,任意一个超了就整体转换hash-max-listpack-entries管 field 数量,hash-max-listpack-value管单个 field 或 value 的字节长度。注意是任一超过,不是同时超过:字段数没超但某个 value 有 200 字节,整个 Hash 也会转成 hashtable;长度都很短但字段数到了 200,同样会转。# 造一个 3 个字段的小 hash redis-cli HSET cfg:app theme dark lang zh timeout 30 redis-cli OBJECT ENCODING cfg:app # listpack # 塞一个超长 value redis-cli HSET cfg:app bigdesc $(python3 -c print(x*100)) redis-cli OBJECT ENCODING cfg:app # hashtable转换是单向的、不可逆的。一旦某个瞬间触发了 hashtable,即使你后来把超长的字段删掉、把字段数降到 10,编码也不会回到 listpack,除非这个 key 被完全删除后重新创建。这个特性带来的实际影响是:一个业务上临时塞了个大字段后来又删了的 Hash,会永久停留在高内存占用的编码上。我在一次内存排查里就遇到过这种事,一个配置 Hash 曾经被写进一段 300 字节的错误日志做调试,删掉之后编码没回退,单 key 内存比同类 key 高出 40%。2.3 用 OBJECT ENCODING 和 MEMORY USAGE 做一次实地验证纸上谈兵不如自己跑一遍。下面这段可以直接复制到本地环境,观察编码和内存随字段数增长的变化:#!/bin/bash KEYbench:hash:$(date %s) for i in $(seq 1 300); do redis-cli HSET $KEY f$i v$i /dev/null if [ $((i % 50)) -eq 0 ]; then echo -n fields$i encoding redis-cli OBJECT ENCODING $KEY echo -n memory redis-cli MEMORY USAGE $KEY fi done redis-cli DEL $KEY在你自己的环境里跑,通常会看到前 128 个字段(或 512,取决于版本)一直保持 listpack,越过阈值的那一刻编码翻转,同时MEMORY USAGE有一个明显跳变。这个跳变的幅度跟 field 的平均长度有关,短字段跳得相对明显,因为这些 key 原本几乎没有元数据开销。理解这一点之后,一个很实用的调优手段就出来了:如果你的业务 Hash 明确知道字段数在 200 左右、每个 value 都很短,而版本默认阈值是 128,那把这个 Hash 强行留在 hashtable 其实是浪费。可以考虑调高hash-max-listpack-entries,但要注意这是全局配置,调高意味着所有 hash 的查找走顺序遍历的上限也提高了,单次HGET的最坏耗时会上涨。我的经验是不要超过 1024,而且要在自己的数据分布上实测HGET的 P99。3. 写入与读取:HSET 系列命令的精确语义3.1 HSET 的返回值到底是什么意思这是最容易被记混的一个点。HSET key field value的返回值不是当前 field 总数,而是本次命令中新创建的 field 数量。redis-cli DEL u:1 redis-cli HSET u:1 name tom age 20 # 返回 2,因为新建了 2 个字段 redis-cli HSET u:1 name jerry # 返回 0,因为 name 已存在,只是覆盖 redis-cli HSET u:1 name bob city sz # 返回 1,只有 city 是新的很多人拿这个返回值当写入成功与否来判断,这没错,但如果你想拿它做新增用户的幂等控制就会出错——覆盖已有值返回 0,看起来像失败,其实写成功了。要做仅在不存在时写入的语义,用HSETNX。顺带说一句HMSET。这个命令在 Redis 4.0 之后功能被HSET完全吸收(HSET支持一次传多组 field-value),官方已经把它标为废弃。老代码里大量存在HMSET,能跑,但新代码没理由再用。迁移的时候注意一个细节:HMSET永远返回OK,HSET返回整数,如果你的代码里有if result OK这种判断,替换的时候会静默失效。3.2 HINCRBY 做计数器,原子性和边界都要看清楚HINCRBY key field increment把一个 Hash 的某个字段当整数加减,返回运算后的新值。这个命令是原子的,并发场景下不会丢更新,这是它比读出来加一再写回去唯一但决定性的优势。redis-cli HSET stat:20240101 pv 100 uv 50 redis-cli HINCRBY stat:20240101 pv 1 # 101 redis-cli HINCRBY stat:20240101 pv -10 # 91 redis-cli HINCRBY stat:20240101 newf 1 # 1,字段不存在按 0 起算几个必须知道的边界:第一,field 不存在时按 0 处理,这是便利也是陷阱。如果业务上字段不存在和值为 0含义不同,那你不能靠HINCRBY来区分,得先用HEXISTS判断。我见过一个库存扣减的实现,靠HINCRBY stock -1把库存扣成负数才发现没做前置校验,业务上应该在下单时用 Lua 脚本判断余量。第二,值不是整数会直接报错ERR hash value is not an integer,而且这个错误会中断当前命令。如果你的 Hash 里混着数字字段和字符串字段(很常见,比如同时存count和name),一定要保证HINCRBY只打在数字字段上。第三,整数范围是 64 位有符号,超出会报increment or decrement would overflow。这个边界在小数场景下用HINCRBYFLOAT能绕开,但浮点数有精度问题。3.3 HINCRBYFLOAT 的精度坑,以及我为什么更推荐放大整数HINCRBYFLOAT用长双精度浮点做加减,返回值是字符串形式的数字。问题在于浮点累加误差:连续执行 10 次HINCRBYFLOAT k f 0.1,最后拿到的可能是0.9999999999999999而不是1。redis-cli DEL acc for i in $(seq 1 10); do redis-cli HINCRBYFLOAT acc v 0.1 /dev/null; done redis-cli HGET acc v # 0.9999999999999999这不是 Redis 的缺陷,是 IEEE 754 的固有行为。但业务代码里如果拿这个值做金额判断,误差累积起来是要出事故的。我的做法是统一放大成整数:金额以分为单位存整数,用HINCRBY加减,展示的时候再除 100。这个套路在支付、计费、积分场景里几乎是标准做法,精度零损失,性能也更好。真的必须存小数的时候(比如评分、权重),用HINCRBYFLOAT但接受它是近似值,做判断时加一个容差,别用比较。3.4 HSETNX、HEXISTS、HDEL 与字段级去重HSETNX key field value只在 field 不存在时写入,返回 1 表示写成功,0 表示 field 已存在。这个命令是天然的字段级去重工具,比先HEXISTS再HSET少一次往返,而且原子。它的经典用法是做每人每天只发一次的标记:key 是活动维度,field 是用户 ID,HSETNX返回 1 就发奖,返回 0 就跳过。这个方案比用 String 的SET NX强在,一个 key 可以承载成千上万个用户标记,而不是每个用户一个 key。当然,当用户量大到几十万的时候,这个 Hash 本身就变成大 key 了,得按天或者按用户 ID 段来分片。HDEL key field [field ...]删字段,返回实际删除的个数。注意区分:DEL key删的是整个 key,HDEL删的是字段。我见过把HDEL当DEL用的代码,删完发现 Hash 还在、只是空了,而 Redis 中空 Hash这个概念不存在——最后一个字段被删掉时,key 会自动消失。这个行为容易让人误以为HDEL把 key 也删了,其实是副作用。HSTRLEN key field返回字段值的长度,HLEN key返回字段总数,两个都是 O(1)(HSTRLEN在 listpack 编码下是 O(N),因为要遍历,但在小 Hash 上可以忽略)。4. 遍历类命令才是 Hash 的高危区4.1 HGETALL 在大 field 数下到底发生了什么HGETALL一次性返回所有 field 和 value。它的复杂度是 O(N),N 是 field 数量。问题不在于 O(N) 本身,而在于这个 N 会随着业务增长而失控,而且这个命令是在 Redis 单线程里执行的,响应包的组装、序列化、网络发送全都占着主线程。当 N 是几万、value 都是几百字节的时候,一次HGETALL的返回体可能是几十 MB,阻塞时间轻松上到几百毫秒。在这个窗口里,所有其他客户端的命令都在排队。你会看到的现象是:一个接口超时,连带整片接口的 P99 都抖。这就是开头那个场景的完整链路。判断风险的经验阈值:field 数超过 1000,或者预估返回体超过 100KB,就不该用HGETALL。这属于经验值,具体到你的环境还要看机器规格和流量,但可以先按这个卡。同样的风险也适用于HKEYS和HVALS,它们各自只返回一半,但同样是 O(N)、同样一次性返回。4.2 HSCAN 的游标语义,以及它保证什么、不保证什么HSCAN是增量遍历的正解,但它不是分页,理解这一点非常关键。redis-cli HSCAN myhash 0 COUNT 100 # 返回: 下一个游标, [field1, value1, field2, value2, ...]三个必须记住的语义:不保证一次返回 COUNT 个元素。COUNT 只是提示 Redis 每次扫多少个哈希桶,实际返回可能远少于这个数,也可能多。想拿固定条数得分页,HSCAN做不到,得用其他方案。可能返回重复元素。遍历过程中如果 Hash 被修改(扩容、缩容),元素可能被扫描到两次。做数据聚合的时候要能容忍重复,或者自己去重。遍历期间一直存在的元素,一定会被返回。这是它唯一的强保证,也是它能替代HGETALL做安全遍历的根本原因。遍历期间被删掉的元素可能返回也可能不返回,这个无所谓。正确用法是循环到游标回到 0:cursor0 while true; do out$(redis-cli HSCAN myhash $cursor COUNT 200) cursor$(echo $out | head -1) echo $out | tail -n 2 [ $cursor 0 ] break done实际业务里我一般会再加一个总耗时保护,遍历超过 5 秒就中断并记录,避免在超大 key 上无限循环。Redis 7.4 给HSCAN加了个NOVALUES选项,只返回 field 不返回值。做只统计字段名这类操作时能省一半带宽,如果你的版本支持可以试试。4.3 HRANDFIELD:抽奖、灰度、随机取样的顺手工具HRANDFIELD key [count [WITHVALUES]]从 Hash 里随机取字段,count 为正数返回不重复的字段,为负数允许重复,带WITHVALUES时值为成对返回。做抽奖、A/B 分流、随机推荐,这个命令比HGETALL之后在应用层随机安全得多。一个实际用例:配置灰度,Hash 里 field 是用户 ID,value 是分组,要随机抽 100 个用户体验新功能,HRANDFIELD pool 100一行搞定,不用把整个池子拉到内存里。也要注意 count 大的时候它同样是 O(N) 的,HRANDFIELD key 100000一样会阻塞,别拿它当批量导出用。5. 落到业务里:Hash 用得对和用不对的分界线5.1 对象缓存的取舍:什么时候 Hash 真的比 JSON 省先说结论:不是所有对象都该用 Hash。如果你的读写模式是整对象读、整对象写,用 Hash 反而更差——HGETALL的返回体比 JSON 大(多了 field 名字的重复),而且没有 JSON 那种压缩后的紧凑性。Hash 的优势只在只读写部分字段的时候才体现。判断标准我通常用两条:单次请求平均需要读的字段数 / 总字段数 30%,用 Hash。字段的更新频率差异很大(比如库存每秒变、描述一天变一次),用 Hash,避免改库存时把整个对象重写一遍。反过来,字段少(少于 5 个)、读写都是全量、或者需要在应用层做复杂嵌套结构的,老老实实用 JSON。嵌套结构这点很重要:Hash 只有一层 field-value,存不了嵌套对象,你只能把嵌套部分再序列化成字符串塞进 value,那就变成了Hash 里套 JSON,既没省掉序列化,又多了层结构,得不偿失。5.2 field 级过期这个需求,在 7.4 之前怎么绕这是 Hash 最常被吐槽的限制:过期时间只能设置在 key 上,不能设置在 field 上。一个购物车 Hash,你希望每个商品条目 7 天过期,做不到。Redis 7.4 引入了一组字段级 TTL 命令:HEXPIRE、HPEXPIRE、HTTL、HPERSIST等,确实填上了这个坑。但要注意两点:一是老版本用不了,二是字段级 TTL 的维护成本和可观测性都比 key 级 TTL 复杂得多,生产环境大规模用之前要仔细评估。在老版本上,常见的绕法有三种,各有代价:方案做法代价拆分 key每个条目一个 key,各自设 TTLkey 数量爆炸,聚合查询难值里带时间戳field 是条目 ID,value 是 JSON 含过期时间,读时判断应用层要做惰性清理,过期数据占内存定期整 key 重建按天分 key,过期由 key TTL 兜底跨天数据不连续,迁移逻辑要处理我一般选第三种。购物车按用户分 key,key 上设 30 天 TTL,同时条目自身带时间戳做逻辑过期,读的时候过滤掉过期的。这样内存回收靠 key TTL 兜底,业务语义靠时间戳精确控制,两边都不吃亏。5.3 超大 Hash 的分片,以及集群环境下的注意事项当 field 数量确定会突破几万,设计阶段就要分片。分片维度选业务上天然可切的:按日期、按用户 ID 区间、按业务类型。分片 key 的命名要能反查出所有分片,比如cart:{uid}:202401,这样清理的时候能按前缀扫出来。在 Redis Cluster 环境下,一个 Hash 是一个 key,只会落在某一个 slot 上,不会有跨槽问题,这一点比MGET多 key 要省心。但也正因为如此,单个超大 Hash 的热点无法靠分片自动打散,必须业务层手动切。如果你的分片 key 之间需要原子操作(比如同时改两个分片),那就要用 hash tag 把它们强制落到同一个 slot,但这样就失去了分散热点的意义,得权衡。还有一个集群下的常见误区:以为 Hash 内部的 field 会分散到不同节点。不会的。Hash 永远整存整取在单个节点上,所以单 Hash 的容量上限就是单节点的内存上限,这一点在做容量规划时必须算进去。6. 我踩过的几个坑,以及对应的排查路径6.1 WRONGTYPE:类型冲突的前因后果报错长这样:WRONGTYPE Operation against a key holding the wrong kind of value。意思是你拿 Hash 命令去打一个非 Hash 类型的 key。最常见的来源是 key 命名冲突。比如某个业务用SET user:1001 ...存了个字符串,另一个模块以为user:1001是 Hash,一HSET就炸。这类问题的排查顺序是:TYPE user:1001确认当前类型。找出是谁写的这个 key,全局搜一下 key 的拼接逻辑。统一命名规范,比如 Hash 统一加前缀h:,String 加s:,从命名上杜绝冲突。还有个隐蔽的来源是数据迁移。从旧环境导入的 RDB 里,某个 key 是 ziplist 编码的空 Hash,导入后被别的逻辑当成计数器用了INCR,报错信息看起来莫名其妙。遇到这种情况先OBJECT ENCODING看编码,再看TYPE,两步定位。6.2 一次慢查询的完整排查链路回到开头那个场景,我把当时的排查过程还原一下,这个链路有复用价值。第一步,确认是 Redis 侧的问题。看客户端监控,发现超时集中在某几个接口,而它们都访问同一个 key。SLOWLOG GET 10拿到最近的慢命令,看到HGETALL耗时 600ms。第二步,量化这个 key 的规模。HLEN big:hash返回 12 万,MEMORY USAGE big:hash显示 800MB。到这一步原因已经明确了。第三步,定位是谁在调HGETALL。在代码里搜命令名,发现是一个导出全部配置的接口,这个接口平时没人用,但那天运营点了一次批量导出,直接打满。第四步,止血。立刻在代理层把这个接口限流,同时用UNLINK异步删除一个不再需要的副本 key 释放内存(注意是UNLINK不是DEL,DEL800MB 的 key 会阻塞主线程好几秒)。第五步,根治。把那个接口改成HSCAN分批拉取,并且加了业务层的条数上限。同时给所有 Hash 相关的代码加了一条 lint 规则:禁止无 count 限制的HGETALL。这套确认现象 → 量化规模 → 定位调用方 → 止血 → 根治的顺序,在 Redis 相关问题里通用。关键在第二步,很多人卡在这里瞎猜,其实HLENMEMORY USAGE两条命令就能定性。顺便说一下扫描大 key 的工具。redis-cli --bigkeys能扫出各类型最大的 key,但它是基于SCAN采样的,只能给个大概;--memkeys统计更准但更慢。生产环境扫的时候一定要限制-i间隔,别把主线程拖垮:redis-cli --bigkeys -i 0.1 redis-cli --memkeys -i 0.16.3 编码转换带来的内存突增,以及一次误判有一回监控报警说某个 Redis 实例内存 3 小时内涨了 30%,但 QPS 和 key 数量都没明显变化。排查思路是先看 key 数量,平稳;再看大 key 排行,变化不大;最后按前缀统计各业务的内存占比,发现是配置中心那个模块。深入看下去,是某个配置 Hash 在活动期间被写入了一个超长的 JSON 字符串作为字段值,长度超过了hash-max-listpack-value的 64 字节阈值,整个 Hash 从 listpack 转成了 hashtable。虽然那个长字段后来被删了,但编码不会回退,这个 key 的内存从几 KB 涨到几十 KB,而它有一万多个实例,累加起来就是几百 MB。这类问题的排查要点是:编码是单向的,删字段救不回来。要么把整个 key 删掉重建,要么接受这个内存占用。我们最后的处理是写了个脚本,把所有OBJECT ENCODING为 hashtable 但HLEN小于阈值的 key 扫出来,逐个重建。扫描的时候用SCAN配合OBJECT ENCODING,注意SCAN期间不要做大批量写入,否则游标语义会更不稳定。从那以后我养成了一个习惯:在写 Hash 的代码路径里加一道校验,如果 value 长度超过配置的阈值就拒绝写入并打日志。这道校验挡掉过好几次不小心把异常堆栈写进配置字段的事故。6.4 批量写入用 pipeline,但别一次塞太多批量初始化一个 Hash,比如导入 5000 个字段,一条条HSET意味着 5000 次往返,在跨机房网络下轻松几十秒。正确做法是用 pipeline 合并:import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) data {ff{i}: fv{i} for i in range(1, 5001)} pipe r.pipeline(transactionFalse) BATCH 200 keys list(data.keys()) for i in range(0, len(keys), BATCH): chunk keys[i:i BATCH] for k in chunk: pipe.hset(import:big, k, data[k]) pipe.execute()三个细节值得说。第一,transactionFalse关掉事务,只用 pipeline 的批量效果,吞吐更高;要事务再加。第二,分批大小控制在 200 到 500 之间,太大会让单个命令的响应包过大,反而增加主线程压力;第三,导入完成后用OBJECT ENCODING和HLEN验一下结果,别导完了才发现编码转换导致内存超预期。还有一点,导入完成后如果这个 Hash 只是被读,可以顺手CONFIG SET一下阈值让它留在 listpack,但更稳妥的做法是让导入的 value 长度本身就在阈值内,从源头避免转换。7. 命令速查与选型决策表7.1 按场景反查该用哪条命令前面散着讲了各种命令,这里收拢成一张按场景查的表,日常写代码的时候对照着看能省不少翻文档的时间。你要做的事首选命令说明写单个字段HSET k f v返回新字段数,不是总数写多个字段HSET k f1 v1 f2 v2一次往返,原子字段不存在才写HSETNX k f v返回 1/0,做去重标记读单个字段HGET k f字段不存在返回 nil读多个字段HMGET k f1 f2 f3按顺序返回,缺失位为 nil读全部HGETALL k大 key 禁用,改 HSCAN判断字段存在HEXISTS k fO(1)字段数量HLEN kO(1)字段值长度HSTRLEN k flistpack 编码下 O(N)整数加减HINCRBY k f n原子,返回新值浮点加减HINCRBYFLOAT k f n有精度误差,慎用删除字段HDEL k f [f...]删空后 key 自动消失增量遍历HSCAN k cursor COUNT n唯一安全的全量遍历方式随机取样HRANDFIELD k n [WITHVALUES]负数允许重复7.2 三条永远别这么写的硬规则第一条,永远不要在生产代码里写无限制的HGETALL。如果非要全量读,用HSCAN分批,并且设上限和超时。第二条,永远不要用HDEL去清空一个 HFIELD 数不确定的 Hash。要清就用DEL或UNLINK,大 key 优先UNLINK(异步释放,不阻塞主线程)。HDEL一个一个删的代价是 O(N),而且如果 field 数上万,一次HDEL传一万个参数会让命令包过大。第三条,永远不要在 Hash 的 value 里塞不定长的、可能失控的东西。日志、异常堆栈、用户上传的文本、第三方返回的原始报文,都不该进 Hash 的字段值。这些东西一旦写进去,轻则触发编码转换,重则把整个 key 撑成大 key,而清理的时候你都不知道该删哪个字段。我自己现在写 Hash 相关代码有个固定动作:凡是 value 来源于外部输入或者不可控长度的地方,一定在写入前做长度截断,并且打一条截断日志。这个习惯帮我挡掉了好几次潜在的内存事故,成本只是几行代码。Redis 用起来确实简单,但简单的东西失控起来往往更难收场,Hash 尤其如此。