在 Redis 做业务对象缓存时,几乎所有开发者都会面临这个经典抉择:
- Hash :
HSET user:1001 name "张三" age 28,将对象扁平拆分为 field-value; - String+JSON :
SET user:1001 '{"name":"张三","age":28}',整个对象序列化为 JSON 存入 String; - RedisJSON:模块扩展类型,支持 JSONPath 路径局部更新。
网上大量碎片化文章各执一词:有的鼓吹 Hash"省内存、字段原子更新",有的推崇 JSON"开发简单、适配嵌套"。很多团队踩坑根源是脱离业务访问模式一刀切选型:
- 把高频局部更新的积分计数器用 JSON,出现并发覆盖、数据丢失;
- 将拥有上百字段的复杂嵌套对象强行拍平进 Hash,维护成本爆炸;
- 不清楚 Hash 编码切换规则,小对象运行一段时间后,内存突然暴涨数倍。
一、开篇导读
1.1 Hash 在 Redis 体系中的定位
如果说 String 是 Redis 的"原子",那么 Hash 就是 Redis 里最像"业务对象"的数据类型 。它在同一个 key 下维护多组 field-value,把对象字段组织成一张小表:读可以按字段点穴(HGET),写可以按字段增量改(HSET / HINCRBY),不必像 String 那样整存整取。正是这种"半结构化"形态,让 Hash 在对象缓存、用户资料、商品信息、购物车、会话、配置集合、计数聚合、用户画像等场景中出镜率极高。
Hash 也是 Redis 原生数据类型中唯一支持 field-value 字段粒度操作 的类型,使用频率仅次于 String,但争议程度远超 String------因为它和 String+JSON 在"存对象"这件事上正面撞车。同时,Hash 也是命令语义最容易记混、坑点最集中 的一块:HSET 和 HMSET 差在哪、HINCRBY 返回值是什么、HGETALL 什么时候会出事、listpack 和 hashtable 怎么切换、field 到底能不能单独设过期时间------这些问题每一个都可能让线上出事故。
Hash 还存在大量容易被忽略的约束:编码单向升级、HGETALL 的 O(N) 风险、Redis 7.4 之前无字段级过期、不原生支持嵌套结构。因此,选型不能只看"Hash 好还是 String 好",而要看访问模式、对象形态和版本能力。
1.2 三个把"模糊选型"变成线上故障的场景
空洞地说"看情况"没有意义。先看三个真实场景,它们指向同一个结论:脱离访问模式谈选型,两边都可能翻车。
场景 A:购物车用 String+JSON,大促时 CPU 打满。
一个电商平台的购物车以 cart:{userId} -> {"items":[...],"couponInfos":[...]} 存储,部分大客户购物车超过 300KB。前端频繁修改"商品数量""最近操作时间"这类单个字段,每次都要 GET → 反序列化 → 改一个字段 → 序列化 → SET。低峰期看不出问题,高峰期序列化 CPU 占比飙升,大对象读写放大网络出口,最终把整个实例的事件循环拖住。
场景 B:用户资料用 Hash,对象数量一多内存反超预期。
另一个团队把用户 Profile 全部改成 Hash,字段名 name、country、last_login 在几百万个 Hash 里各存一份。用 MEMORY USAGE 一看,字段名占的内存比业务值还大。实测下来(量级参考,非通用基准),普通 Hash 约 1.18 GB,JSON String 约 1.45 GB------Hash 确实省了一些,但远没有想象中"砍半",因为省下的主要是序列化语法开销,字段名重复的硬伤还在。
场景 C:Hash 要按字段过期,结果整个 key 被一刀切。
一个会话场景需要"每个字段独立的 TTL",团队想当然地以为 Hash 支持字段级过期,结果发现 Redis 7.4 之前 TTL 只能加在整个 key 上;升级到 7.4 用了 HEXPIRE,又踩到"HSET 更新字段会清除该字段 TTL"的坑。
这三个场景指向同一件事:选型的答案不取决于"Hash 好还是 String 好",而取决于你的访问模式、对象形态和版本能力。
1.3 常见认知误区
-
误区一:Hash 一定比 JSON 省内存。
仅当维持 listpack 紧凑编码时成立;一旦升级为 hashtable,大量 dictEntry 指针元数据开销会推高内存,内存开销可能反超 JSON。
-
误区二:Hash 万能,业务对象全部用 Hash 存储。
嵌套数组、复杂层级结构强行拍平,会导致字段爆炸,维护成本急剧上升。
-
误区三:Hash 可以随意使用
HGETALL获取全部对象字段。HGETALL时间复杂度 O(N),字段数量大时会阻塞主线程,引发慢查询、实例抖动。慢查询里躺着一条HGETALL,P99 从 40ms 涨到 800ms 是常有的事。 -
误区四:Hash 天然支持字段级 TTL。
Redis 7.4 版本才新增
HEXPIRE系列命令;低版本只能对整个 key 设置过期时间。7.4+ 还要注意HSET更新字段会清除该字段 TTL;混部环境下还可能"部分节点支持、部分报错"。 -
误区五:把
hash-max-listpack-entries的默认值记成 128。Hash 该项默认值是 512 。128 是
zset-max-listpack-entries(有序集合)的默认值,以及 Redis 6.x 时代hash-max-ziplist-entries的历史默认值。7.0+ 请以CONFIG GET返回的 512 为准。
二、核心命令全集
2.1 基础读写命令
Hash 的骨架就是"按 field 读、按 field 写"。
redis
HSET user:1001 name "张三" age 30 city "上海" # 一个命令写多个字段(4.0+,推荐)
HGET user:1001 name # 取单个字段 → "张三"
HSETNX user:1001 name "李四" # 仅当字段不存在时设置,返回 0 表示已存在未覆盖
HDEL user:1001 city # 删除字段,返回删除个数
HEXISTS user:1001 email # 判断字段是否存在 → 0/1
HLEN user:1001 # 字段个数
HSTRLEN user:1001 name # 该字段值的字节长度(注意是字节,不是字符)
几个必须刻在脑子里的语义:
HSET返回的是"新创建的字段个数" ,不是总字段数。返回 0 说明所有字段都已存在、只做了覆盖。Redis 4.0 起HSET支持一次写多个字段,HMSET已废弃。HSET写同一个 key 不会重置 key 的 TTL 。给 key 设过EXPIRE之后,继续HSET新字段,key 级过期时间仍然保留。注意:这里说的是 key 级 TTL;7.4+ 的字段级 TTL 是另一套规则,HSET覆盖某个字段时会清除该字段 TTL,详见 2.4 与 6.1。HSETNX只判断"字段级"的原子性,不是 key 级。适合"首次初始化才写",一次只能写一个字段。HSTRLEN返回的是字节数。存"张三"返回 6(UTF-8 每个汉字 3 字节),不是 2。
csharp
// StackExchange.Redis 官方 .NET 客户端,IDatabase 是命令入口
using StackExchange.Redis;
// ========## HSET:写入字段 ============
// 多字段重载 HashSetAsync(key, HashEntry[]) 返回 long = 本次"新建"的字段数(不是总字段数);
// 字段原本已存在时只覆盖、不计入新建数。
long createdBatch = await db.HashSetAsync("user:1001", new HashEntry[]
{
new("name", "张三"), new("age", 30), new("city", "上海") // 一次写 3 个字段(等价 HSET f1 v1 f2 v2)
});
// 单字段重载返回 bool:true=字段原本不存在、本次新建;false=已存在、仅覆盖。
// 等价 HSETNX key field value(仅字段不存在才写入),适合"首次初始化才写"。
bool setNxSucceeded = await db.HashSetAsync("user:1001", "name", "李四", When.NotExists);
// ========## HGET / HDEL:读与删 ============
// HGET:返回 RedisValue;字段或 key 不存在时返回 RedisValue.Null,须用 IsNull 判断。
RedisValue name = await db.HashGetAsync("user:1001", "name");
// HDEL:多字段重载返回 long = 实际删除的字段数(不存在的字段不计入)。
long deletedCount = await db.HashDeleteAsync("user:1001", new RedisValue[] { "city", "email" });
// ========## HEXISTS / HLEN / HSTRLEN:元信息查询 ============
// 字段是否存在 → true/false
bool exists = await db.HashExistsAsync("user:1001", "email");
// 字段个数 → O(1)
long len = await db.HashLengthAsync("user:1001");
// 该字段值的"字节"长度,不是字符数(存中文按 UTF-8 字节计)
long byteLength = await db.HashStringLengthAsync("user:1001", "name");
2.2 批量读取命令
HMGET key field1 field2 ... fieldk :一次取多个字段的值,返回按请求顺序排列的数组,不存在的字段返回 nil (而不是跳过)。时间复杂度是 O(k),其中 k 是请求的字段数,不是整个 Hash 的字段总数。这是 Hash 里最"性价比"的读命令------想取 3 个字段就只取 3 个,不必全量。
HGETALL key :一次性返回所有 field 和 value。返回的是"field1, value1, field2, value2..."的两两交错数组(客户端通常解析为 HashEntry[] / 字典)。它是 Hash 里最危险的命令 ------字段越多,返回的包越大,Redis 要把整个 Hash 编码成 RESP 协议塞进输出缓冲区,同时客户端要一次性接收并解析,字段数上万时这条命令能直接卡住主线程 。时间复杂度 O(N),N 是整个 Hash 的字段总数。
redis
HMGET user:1001 name age # → ["张三", "30"],只取这两个字段,O(k)
HGETALL user:1001 # → 两两交错,O(N)
HKEYS user:1001 # → 所有字段名,O(N)
HVALS user:1001 # → 所有字段值,O(N)
HGETALL vs HMGET 的取舍:

⚠️
HMGET的复杂度是 O(k),HGETALL/HKEYS/HVALS是 O(N) ;单个HGET是 O(1)。
csharp
// ========## HMGET:批量取指定字段 ============
// 返回 RedisValue[],顺序与请求字段顺序完全一致;字段不存在时对应位置为 RedisValue.Null,
// 不会"跳过"或缩短数组 → 用下标定位,或用 IsNull 判断。
// 复杂度 O(k),k = 请求字段数;想取 3 个字段就只取 3 个,是最"性价比"的读命令。
RedisValue[] values = await db.HashGetAsync("user:1001",
new RedisValue[] { "name", "age", "score" });
// ========## HGETALL:取全部字段 ============
// 返回 HashEntry[](无分页,一次性把整个 Hash 拉到客户端内存)。
// ⚠️ 仅字段数可控(例如 <100)时使用;大 Hash 必须改用 2.3 的 HashScanAsync 分批遍历。
HashEntry[] all = await db.HashGetAllAsync("user:1001");
var dict = all.ToDictionary(e => e.Name.ToString(), e => e.Value.ToString()); // 转字典便于按字段名取值
2.3 数值、遍历与随机命令
HINCRBY key field increment :把字段值当作 64 位有符号整数做原子自增,返回自增后的值。这是 Hash 做"计数聚合"的核心------同一个 key 下多个计数器各自独立原子递增,互不干扰,比用多个 String key 省得多,也不会像 String 那样并发读改写丢计数。
redis
HINCRBY user:1001 login_count 1
HINCRBYFLOAT user:1001 balance 9.9 # 浮点有精度误差;金额建议以"分"为单位用 long
HSCAN key cursor [MATCH pattern] [COUNT count] [NOVALUES] :增量遍历 Hash 的所有字段。它是 HGETALL 的安全替代品------每次只返回一部分,游标推进,不会一次阻塞。两个必须知道的特性:
- 不保证完整性:如果遍历过程中 Hash 发生了扩容/rehash,同一个字段可能被返回两次,也可能被漏掉。
- COUNT 只是提示:不保证每次返回恰好 COUNT 个。
NOVALUES(Redis 7.4+) :只返回字段名,不返回值,字段名大时省带宽。
redis
HSCAN user:1001 0 COUNT 100
HSCAN user:1001 0 MATCH "addr:*"
HSCAN user:1001 0 COUNT 100 NOVALUES # 7.4+,仅字段名
HRANDFIELD key [count [WITHVALUES]] (6.2+):不带 count 时返回单个随机字段(O(1));带 count 时返回数组,正数不重复,负数允许重复,count 很大时为 O(N)。
redis
HRANDFIELD user:1001
HRANDFIELD user:1001 3 WITHVALUES
HRANDFIELD user:1001 -10
csharp
// ========## HINCRBY / HINCRBYFLOAT:字段级原子自增 ============
// 返回自增后的"新值"。HINCRBY 走 64 位有符号整数,适合计数器(登录次数、积分等)。
long newScore = await db.HashIncrementAsync("user:1001", "score", 100);
// HINCRBYFLOAT 走 double,有精度误差;金额建议用"分"为单位 + long 自增,不要用浮点。
double newBalance = await db.HashIncrementAsync("user:1001", "balance", 9.9);
// ========## HSCAN:增量遍历(HGETALL 的安全替代) ============
// 返回 IAsyncEnumerable<HashEntry>,流式分批拉取,不一次阻塞主线程。
// ⚠️ 不保证完整:遍历期间若发生 rehash,同一字段可能重复出现或被漏掉;
// 数据迁移必须在业务层去重、允许重跑,不要用它做"一次性精确导出"。
await foreach (HashEntry entry in db.HashScanAsync("user:1001", pattern: "*", pageSize: 100))
{
string field = entry.Name.ToString();
string value = entry.Value.ToString();
}
// ========## HRANDFIELD:随机取字段(Redis 6.2+) ============
// 核心库无强类型封装,用 ExecuteAsync 调原生命令;不带 count 返回单个随机字段(O(1))。
RedisResult randOne = await db.ExecuteAsync("HRANDFIELD", "user:1001");
// 带 count 且很大时为 O(N);正数不重复、负数允许重复,抽奖场景别传超大 count。
HSCAN不保证完整性:遍历期间 rehash 可能重复或漏掉。数据迁移场景必须在业务层去重,并允许重跑;不要用它做"一次性精确导出"。
2.4 字段级过期(7.4+)与 8.0 三兄弟
Redis 7.4 起,Hash 支持字段级过期 (HEXPIRE 系列共 9 个命令):
redis
HSET session:u1 token "abc" login_at 1700000000
HEXPIRE session:u1 1800 FIELDS 1 token # 只给 token 设 1800 秒过期
HTTL session:u1 FIELDS 1 token # -1=无过期;-2=字段不存在
HPERSIST session:u1 FIELDS 1 token # 取消字段过期
# 同族:HPEXPIRE / HEXPIREAT / HPEXPIREAT(毫秒 / 绝对秒 / 绝对毫秒)
# HEXPIRETIME / HPEXPIRETIME(反向查询过期时间戳)
Redis 8.0 新增三个 Hash 命令:
redis
HGETDEL user:1001 FIELDS 1 token # 取值的同时删除字段(原子的"取完即焚")
HGETEX session:u1 EX 60 FIELDS 1 token # 取值并同时续期(原子的"读取+续期")
HSETEX session:u1 EX 60 FIELDS 1 token "abc" # 设值并同时设过期(原子的"写入+设TTL")
典型落地:会话场景的"生命周期不一致"问题。 用户基础信息要长期保留、token 只活 30 分钟,以前只能拆成两个 key 或者靠应用层时间戳判断,现在一个 Hash 就能装下。
redis
HSET session:u1 nickname "张三" token "abc"
HEXPIRE session:u1 1800 FIELDS 1 token
HGETEX session:u1 EX 1800 FIELDS 1 token # 8.0+ 滑动续期
csharp
// ========## 准备数据:一个 Hash 里装"常驻字段 + 短命字段" ============
// 目标:nickname 长期保留,token 只活 30 分钟 → 用字段级 TTL 让两者共存于一个 key。
await db.HashSetAsync("session:u1", new HashEntry[]
{
new("nickname", "张三"), new("token", "abc")
});
// ========## HEXPIRE:给指定字段设过期(服务端 7.4+,客户端 2.8.12+) ============
// 只给 token 设 1800 秒过期,其他字段不受影响;返回 ExpireResult[](每字段一个结果)。
ExpireResult[] expireResults = await db.HashFieldExpireAsync(
"session:u1", new RedisValue[] { "token" }, TimeSpan.FromSeconds(1800));
// ========## 查询字段剩余 TTL ============
// 返回每字段剩余毫秒数:>0=剩余毫秒;-1=该字段无过期;-2=字段不存在。
long[] ttlsMs = await db.HashFieldGetTimeToLiveAsync(
"session:u1", new RedisValue[] { "token", "login_at" });
// ttlsMs[0] ≈ 1800000;ttlsMs[1] == -1
// ========## 8.0 三兄弟(客户端 2.9.11+ 原生封装;低版本用 ExecuteAsync 兜底) ============
// HGETDEL:取值并删除字段(原子"取完即焚")。
RedisValue[] taken = await db.HashFieldGetAndDeleteAsync(
"user:1001", new RedisValue[] { "token", "otp" });
// HSETEX:写字段并同时设过期(原子"写入+设 TTL")。
// ⚠️ 关键坑:普通 HSET 覆盖某字段会清除该字段已有 TTL(HINCRBY/HINCRBYFLOAT 不清除);
// 更新后必须重新 HEXPIRE,或用 HSETEX 一步到位。
await db.HashFieldSetAndSetExpiryAsync(
"session:u1", "token", "abc", TimeSpan.FromSeconds(60));
// HGETEX:取值并滑动续期(原子"读取+续期"),适合会话场景。
// 若当前 SDK 版本无稳定强类型方法,用 ExecuteAsync 兜底:
RedisResult tokenResult = await db.ExecuteAsync(
"HGETEX", "session:u1", "EX", 1800, "FIELDS", 1, "token");
string? token = tokenResult.IsNull ? null : tokenResult.ToString();
⚠️ 关键坑点 :
HSET更新字段会清除该字段已有的 TTL ;HINCRBY/HINCRBYFLOAT保留 TTL。key 级 TTL 不受HSET影响。更新后必须重新HEXPIRE,或用 8.0 的HSETEX一步到位。
⚠️ 版本红线 :HEXPIRE系列需要 7.4+ ,HGETDEL / HGETEX / HSETEX需要 8.0+ 。在 7.0 / 7.2 上执行会直接报"unknown command"。混部环境(主从版本不一致)尤其危险 ------上线前必须核对INFO server里的redis_version。
HashSetWithExpiryAsync属于 StackExchange.Redis.Extensions 等扩展库,不是核心IDatabase方法;只引用核心库时不要直接调用。
2.5 命令速查表

2.6 .NET API 对照与三个坑

三个 .NET 坑:
HashSetAsync的返回值 :单字段重载返回bool(true=新建,false=覆盖);多字段重载返回long(新建字段数)。HashGetAllAsync没有分页 :一次性把整个 Hash 拉到内存。大 Hash 改用HashScanAsync。- 字段级过期命令要确认版本 :
HashFieldExpireAsync需要客户端 2.8.12+ 、服务端 7.4+ ;8.0 三兄弟在客户端 2.9.11+ 才有原生封装。另注意HSET更新字段会清除该字段 TTL。
2.7 版本矩阵(速查)

三、编码与内存
3.1 两种编码:listpack 与 hashtable
Hash 在 Redis 内部有两种编码:listpack (紧凑连续内存)和 hashtable(哈希表字典)。小 Hash 走 listpack,字段名和值紧挨着存放,没有每个字段的字典节点指针开销;一旦字段数或单字段长度超过阈值,Redis 就把它升级为 hashtable,以换取 O(1) 的稳定读写。
这一章的核心不是源码细节,而是四个工程结论:listpack 能省多少、什么触发升级、升级为什么不可逆、Compact Hash 如何缓解字段名税。理解了这四点,才能判断一个 Hash 到底是在吃编码红利,还是在背字段名税和 dictEntry 开销。
3.2 小 Hash 的内存优势
假设存一个 4 字段用户对象(uid、name、age、city):
- 用 4 个独立 String :4 个 key,每个 key 都要一套
dictEntry+ key 的 SDS +redisObject+ value 的 SDS。光元数据就要 4 份。 - 用 1 个 Hash :只有一个 key ,一套元数据;4 个字段紧凑排在 listpack 里,每个字段额外开销只有 1~3 字节。
对你的影响:字段越短、字段数越多、对象数量越大,Hash 相比"一字段一 String"省得越多。量级参考:1000 个用户(各 4 字段)的场景,Hash 比多 key String 方案约省 30%~40%;实际值取决于字段名长度和 jemalloc 对齐。
3.3 一旦转成 hashtable,内存优势就没了,而且不可逆
Hash 的 listpack 编码有触发上限 :字段数超过阈值,或任意一个字段名/字段值的长度 超过阈值,Redis 就会立即把整个 Hash 升级为 hashtable。
- 升级后,每个字段都要单独占一个字典条目(dictEntry + 指针),内存开销明显上升。
- 升级是单向不可逆的 。后来把超长字段删掉、把字段数降回来,它也不会退回 listpack,只有把这个 key 整个删掉重新写入才会重新开始。
对你的影响 :哪怕只想存一个"备注"字段(bio,可能超过 64 字节),也可能把一整个本来很紧凑的用户对象 Hash 拖进 hashtable。大文本字段一定不要塞进和"结构化字段"同一个 Hash。
3.4 配置临界点:512 和 64

redis
CONFIG GET hash-max-listpack-entries # 默认 512
CONFIG GET hash-max-listpack-value # 默认 64
必须纠正一个流传极广的错误 :很多中文博客说 hash-max-listpack-entries 默认是 128 ------这是错的。128 是 zset-max-listpack-entries(有序集合)的默认值 ,也是 Redis 6.x 时代 hash-max-ziplist-entries 的历史默认值;Redis 7.0+ 该项默认值是 512。

对你的影响 :这个数字直接决定容量估算和建模决策。做容量规划前,先
CONFIG GET看一下线上实例的真实值。
把阈值调大 能让更多键保持紧凑编码,但代价是单个键包含的元素越多,对整键做 HGETALL 或删除整键时的单次阻塞时间越长。这不是"越大越好"的参数。
3.5 字段名税
必须把内存拆成三层看:
第一层:小对象的编码红利。 Redis 对小 Hash 用 listpack,元素紧凑存储,没有哈希表指针开销。字段少、值短时,Hash 确实省。
第二层:字段名税。 Hash 的每个 field 名都要作为元数据存储。如果有 100 万个结构相同的对象,name、price、status 这些字段名会被重复存 100 万遍。 String+JSON 的字段名只出现在每个 JSON 字符串内部,同样会重复,但 JSON 的字段名和值之间没有额外的 field 元数据开销。所以------对象数量越多、字段名越长,Hash 的"字段名税"越重。
第三层:版本红利。 Redis 8.10 的 Compact Hash 让同 schema 的 Hash 共享字段名模板,把"字段名税"这个硬伤显著缓解。
✅ 一句话结论 :小对象、字段少、走 listpack、或上了 Compact Hash------Hash 省;大量同构对象、字段名长、版本老旧------String+JSON 不一定输。
3.6 Compact Hash(8.10+):把"字段名税"打下来
Redis 8.10(2026 年 7 月 GA)引入 Compact Hash 内部编码 :对于结构相似的大量 Hash,让它们共享同一组字段名模板 ------字段名只存一份,每个具体 Hash 只存值序列和少量元数据。它由 HIMPORT 声明字段集时自动启用,运维侧也可对符合条件的 Hash 开启自动模板转换,已有 Hash 命令的行为和返回完全不变。
对你的影响 :官方公布"字段名相同的多个 Hash,内存占用最高可降低 50%"。量级参考(非通用基准):普通 Hash ≈ 1.18 GB、Compact Hash ≈ 0.72 GB、JSON String ≈ 1.45 GB 。但 50% 是上限,不是承诺 ;实际收益高度依赖字段名长度、值长度、字段集是否稳定,社区也有仅降低约 20% 的场景。上线前必须用真实数据在 8.10 上实测
MEMORY USAGE对比,不要按宣传的 50% 做容量承诺。
⚠️ 边界:Compact Hash 是内部编码,不改变任何命令语义;主要惠及"字段集稳定、记录数量大"的场景(用户画像、会话、特征存储、批量 ETL)。对象 schema 频繁变化时收益会小很多。
3.7 编码转换边界与渐进式 rehash
编码转换规则(单向升级,不可逆):


为什么大 Hash 的写入不会一次性卡死? hashtable 的渐进式 rehash :字典扩容时,Redis 不会一次性重新分布所有键,而是通过 rehashidx 游标把迁移动作分散到后续每次增删改查里,每次只搬一两个桶。
对你的影响 :渐进式 rehash 期间,字典里同时存在新旧两张哈希表,内存会短暂接近翻倍。在 rehash 未完成时做内存评估,可能看到"虚高"的数字。
3.8 定量估算
单个 Hash Key 的构成。 listpack 编码的 Hash 大致由这几块组成:
- key 本体 :
dictEntry(约 32 字节)+ key 的 SDS。 redisObject对象头:16 字节(64 位系统)。- listpack 结构:头部(约 7~11 字节)+ 每个字段的 entry(长度编码 1~3 字节)+ 字段名 + 字段值 + 结束符。
hashtable 编码时,每个字段还要额外承担 dictEntry 哈希节点、SDS 字符串、指针等元数据,每个字段几十字节额外开销。
listpack vs hashtable 的刚性开销对比:

估算公式:
text
# listpack
单 Hash 内存 ≈ 32(dictEntry) + align(key 长度) + 16(redisObject)
+ 11(listpack 头尾)
+ Σ(3 + len(field_i) + 3 + len(value_i))
# hashtable
单 Hash 内存 ≈ 对象头 16B + 字典表头
+ Σ(每个字段 dictEntry + SDS 元数据,约 60~80 字节/字段 + 数据本身)
简化速算:字段数少(<20)、字段短时,一个用户对象 Hash 约 120~200 字节(量级参考)。
⚠️
MEMORY USAGE key不是真实占用 ------不含 jemalloc 分桶对齐和哈希桶开销。全局容量评估以INFO memory的used_memory增量为准。
演算:100 万用户对象(6 个短字段,字段名+值合计约 30 字节,量级参考):

结论 :这组参数下 Hash ≈ String+JSON,两者都比"一字段一 String"省将近 3 倍 。但只在"字段短、没超阈值"时成立 。某个字段超 64 字节触发 hashtable,Hash 单对象可能跳到 400+ 字节,反而比一个 JSON 字符串更费。
字段数膨胀的数量级差异(10 万用户画像,每个 20 个字段,量级参考):
- 走 listpack :单对象约 650 字节 ;10 万 × 650B ≈ 62 MB ,加 jemalloc 对齐约 71 MB。
- 若字段数涨到 1000(超阈值走 hashtable) :单字段开销从约 7 字节跳到约 70 字节,单对象涨到 90 KB 量级 ,10 万用户直接到 GB 级 ------数量级差 100 倍以上。
实操建议 :用
MEMORY USAGE key抽样真实对象对比,但记住它不含对齐和桶开销;最终以INFO memory的used_memory增量为准。控制 Hash 字段数在阈值内,对内存影响是数量级的。
3.9 亲手验证:看你的 Hash 到底是什么编码
redis
FLUSHDB
HSET u1 name "张三" age 30 city "上海"
OBJECT ENCODING u1 # listpack(7.0+;6.x 返回 ziplist)
HSET u1 bio "这是一段故意写得很长的个人简介,用来测试编码切换......"
OBJECT ENCODING u1 # hashtable
HDEL u1 bio
OBJECT ENCODING u1 # 仍然是 hashtable:升级不可逆
MEMORY USAGE u1 # 不含 jemalloc 对齐与桶开销,估值偏低
csharp
// ========## 排查"小 Hash 内存偏高"的三板斧 ============
// ① OBJECT ENCODING:看当前编码。7.0+ 返回 listpack / hashtable;6.x 返回 ziplist。
RedisResult encoding = await db.ExecuteAsync("OBJECT", "ENCODING", "u1");
string? enc = encoding.IsNull ? null : encoding.ToString();
// ② MEMORY USAGE:看该 key 的估算占用(字节)。
// ⚠️ 不含 jemalloc 分桶对齐与哈希桶开销,估值偏低;真实容量以 INFO memory 的 used_memory 增量为准。
RedisResult mem = await db.ExecuteAsync("MEMORY", "USAGE", "u1");
long bytes = mem.IsNull ? -1 : (long)mem;
// ③ CONFIG GET:核对线上真实阈值,不要凭记忆(Hash 默认 entries=512、value=64)。
// ⚠️ CONFIG GET 返回数组,形如 ["hash-max-listpack-entries", "512"],取值用 [1]。
RedisResult cfgEntries = await db.ExecuteAsync(
"CONFIG", "GET", "hash-max-listpack-entries");
RedisResult cfgValue = await db.ExecuteAsync(
"CONFIG", "GET", "hash-max-listpack-value");
排查线上"小 Hash 内存偏高"的第一步 :OBJECT ENCODING 看编码,MEMORY USAGE 看占用,CONFIG GET 核对真实阈值。
3.10 业务影响总结
- 小 Hash:字段数 ≤ 512 且所有 field/value ≤ 64 字节 → listpack,内存非常节省。
- 一旦突破阈值 :任意条件触发即升级 hashtable,内存开销暴涨;删字段、删大 value 也不会回落。
- listpack 内部遍历 O(N) ,但 N 很小,CPU 缓存友好;hashtable 读写平均 O(1),但 dictEntry 指针元数据开销巨大。
- 字段名税是 Hash 内存争论的核心;Redis 8.10+ 的 Compact Hash 可显著缓解,但收益取决于字段集是否稳定,上线前需实测。
- 渐进式 rehash 期间内存短暂接近翻倍,做内存评估时要避开这个窗口。
- 容量评估方法 :
MEMORY USAGE只做相对对比;真实容量以INFO memory的used_memory增量为准。 - 排查第一步 :
OBJECT ENCODING+MEMORY USAGE+CONFIG GET。
四、生产业务场景实战
4.1 场景一:用户资料对象缓存
需求:缓存用户资料(昵称、头像、城市、积分、等级等),特点是"整对象读得多、按字段改得也不少"。
为什么用 Hash :用户资料天然是"字段-值"结构,改昵称只 HSET 昵称一个字段,不必全量读改写。并发场景下两个请求改不同字段互不干扰,而 String+JSON 在并发下会互相覆盖。
redis
HSET user:1001 nickname "张三" avatar "https://..." city "上海" points 1200 level 5
HGET user:1001 points
HINCRBY user:1001 points 100
EXPIRE user:1001 3600
csharp
// 用户资料缓存服务:字段级读写,改单个字段不触发整对象读改写。
public class UserCacheService
{
private readonly IDatabase _db; // StackExchange.Redis 数据库句柄
public UserCacheService(IDatabase db) => _db = db;
// 更新一个或多个字段:等价 HSET。并发下两个请求改"不同字段"互不干扰(单命令原子)。
public Task UpdateFieldsAsync(long uid, params HashEntry[] fields)
=> _db.HashSetAsync($"user:{uid}", fields);
// 取单个字段:字段不存在(RedisValue.Null)时返回 null。
public async Task<string?> GetFieldAsync(long uid, string field)
{
var v = await _db.HashGetAsync($"user:{uid}", field);
return v.IsNull ? null : v.ToString();
}
// 批量取指定字段(HMGET):顺序与请求一致;字段不存在时对应值为空串。
// 只传需要的字段,避免全量 HGETALL;大 Hash 场景尤其要这么写。
public async Task<Dictionary<string, string>> GetFieldsAsync(long uid, params string[] fields)
{
var vals = await _db.HashGetAsync(
$"user:{uid}", fields.Select(f => (RedisValue)f).ToArray());
return fields.Zip(vals, (f, v) => (f, v))
.ToDictionary(x => x.f, x => x.v.ToString());
}
}
边界 :字段数控制在阈值内;大文本字段(完整个人简介)单独用 String key ;禁止对大 Hash 使用 HGETALL;整体过期用 EXPIRE,字段级过期用 7.4+ HEXPIRE(注意 HSET 清 TTL)。
4.2 场景二:购物车
需求:每个用户一个购物车,能加商品、改数量、删商品、查全部商品。
为什么用 Hash :购物车就是"商品ID → 数量"的映射,HINCRBY 改数量、HDEL 删商品、HSCAN 查全部。
redis
HSET cart:user:1001 item:9527 1 item:10086 2
HINCRBY cart:user:1001 item:9527 1
HLEN cart:user:1001
HDEL cart:user:1001 item:10086
EXPIRE cart:user:1001 2592000
csharp
// 购物车服务:购物车本质是"skuId → 数量"的映射,正好用 Hash。
public class CartService
{
private readonly IDatabase _db;
public CartService(IDatabase db) => _db = db;
// 加购/改数量:HINCRBY 原子自增(同 sku 并发加购不会丢)。
public Task<long> AddItemAsync(long uid, string sku, int qty)
=> _db.HashIncrementAsync($"cart:{uid}", sku, qty);
// 查全部商品:HGETALL 一次取回,转字典。
// ⚠️ 仅"商品数可控"时可用;购物车商品很多时请改用 ScanAsync 分批,避免大包阻塞主线程。
public async Task<Dictionary<string, int>> GetAllAsync(long uid)
{
var entries = await _db.HashGetAllAsync($"cart:{uid}");
return entries.ToDictionary(e => e.Name.ToString(), e => (int)e.Value);
}
// 分批遍历(HSCAN):大购物车安全方案;流式 yield,不一次性载入内存。
public async IAsyncEnumerable<(string Sku, int Qty)> ScanAsync(long uid)
{
await foreach (var entry in _db.HashScanAsync($"cart:{uid}", pageSize: 100))
yield return (entry.Name.ToString(), (int)entry.Value);
}
// 删除商品:HDEL 单字段,返回是否真的删除了。
public Task RemoveItemAsync(long uid, string sku)
=> _db.HashDeleteAsync($"cart:{uid}", sku);
}
边界 :Hash 只存 skuId + 数量,商品详情单独缓存 ;商品详情 JSON 塞进 Hash 字段会超 64 字节、触发 hashtable;商品数膨胀时分片;禁止 HGETALL 大批量返回;Hash 无序,按加入时间排序需另用 ZSet。
4.3 场景三:计数聚合与用户画像
需求:一个对象下挂多种计数(登录次数、发帖数、点赞数、积分数)。
为什么用 Hash :同一实体的多种计数聚合在一个 key 下,HINCRBY 各字段独立原子递增;相比"每个计数一个 String key",省 key、省内存,还能一次 HMGET 取回多个计数。
redis
HSET stats:user:1001 login_count 0 posts 0 likes 0 points 0
HINCRBY stats:user:1001 login_count 1
HINCRBY stats:user:1001 posts 1
HMGET stats:user:1001 login_count posts likes points
csharp
// 计数聚合服务:同一实体的多种计数聚合在一个 key,各字段独立原子自增。
public class UserStatsService
{
private readonly IDatabase _db;
public UserStatsService(IDatabase db) => _db = db;
// 自增指定计数:HINCRBY,返回自增后的新值;delta 默认 1,可传负数做扣减。
public Task<long> IncrAsync(long uid, string field, long delta = 1)
=> _db.HashIncrementAsync($"stats:{uid}", field, delta);
// 批量取多个计数(HMGET):一次性取回,减少往返。
// ⚠️ 计数器字段不要混入非整数字符串,否则 HINCRBY 会报 ERR hash value is not an integer。
public async Task<Dictionary<string, long>> GetCountersAsync(long uid, params string[] fields)
{
var vals = await _db.HashGetAsync(
$"stats:{uid}", fields.Select(f => (RedisValue)f).ToArray());
return fields.Zip(vals, (f, v) => (f, v))
.ToDictionary(x => x.f, x => x.v.IsNull ? 0L : (long)x.v);
}
}
澄清 :
HSET key f1 v1 f2 v2作为一条命令本身是原子的。不原子的是"先读多个字段 → 业务判断 → 再写多个字段"的组合逻辑;这种跨命令的业务级原子性才需要 Lua /MULTI/ 乐观锁。
边界 :HINCRBY 是字段级原子;计数器字段不要写入非整数字符串;画像维度一般几十个以内,安全;对象数量极大、字段集固定的画像场景,是 8.10 Compact Hash 的典型受益场景。
4.4 场景四:商品可变属性
需求:商品详情页缓存实时可变属性(价格、库存、状态、销量),变更频率极高;描述、参数、图文详情低频访问。
为什么用 Hash :高频变更字段放一个 Hash,HSET 精确更新单个字段,避免整块 JSON 反序列化改一个值再写回;低频大字段单独 String。
redis
HSET goods:9527 price 199.00 stock 888 status on_sale sales 1203
HSET goods:9527 price 188.00
HINCRBY goods:9527 stock -1
HINCRBY goods:9527 sales 1
csharp
// 商品可变属性服务:高频变更字段(价格/库存/状态/销量)放一个 Hash,精确单字段更新。
public class GoodsAttrService
{
private readonly IDatabase _db;
public GoodsAttrService(IDatabase db) => _db = db;
// 改价格:HSET 单字段。价格用字符串保留两位小数("F2"),避免浮点精度问题;
// 金额更稳的写法是"以分为单位的整数 + long"。
public Task SetPriceAsync(long spuId, decimal price)
=> _db.HashSetAsync($"goods:{spuId}", "price", price.ToString("F2"));
// 扣库存:HINCRBY 负数原子增减。
// ⚠️ 单条 HINCRBY 是原子的,但"查库存→扣减→判断是否超卖"是跨命令组合,不原子;
// 严格防超卖需用 Lua 脚本或 WATCH/MULTI。
public Task<long> DecrStockAsync(long spuId, int qty = 1)
=> _db.HashIncrementAsync($"goods:{spuId}", "stock", -qty);
// 销量自增。
public Task<long> IncrSalesAsync(long spuId, int qty = 1)
=> _db.HashIncrementAsync($"goods:{spuId}", "sales", qty);
// 一次取展示所需字段(HMGET):只取 4 个字段,不用 HGETALL;
// 返回元组;数值字段缺失时按 0 处理(IsNull 判断)。
public async Task<(string Price, long Stock, string Status, long Sales)> GetDisplayAsync(long spuId)
{
var vals = await _db.HashGetAsync(
$"goods:{spuId}", new RedisValue[] { "price", "stock", "status", "sales" });
return (vals[0].ToString(),
vals[1].IsNull ? 0L : (long)vals[1],
vals[2].ToString(),
vals[3].IsNull ? 0L : (long)vals[3]);
}
}
边界 :自定义 SKU/属性字段可能爆炸,规格参数这类"字段数不定、读多写少"的属性更适合整块 String+JSON;金额建议用"分"为单位的整数;库存扣减的"查+扣+判断"不是单命令原子,严格防超卖用 Lua / WATCH/MULTI。
4.5 大 Hash 的拆分策略
策略一:按业务维度垂直拆分:
redis
user:1001:base
user:1001:counters
user:1001:token
策略二:按时间/哈希水平分片:
⚠️ 不要用
string.GetHashCode()做分片 :.NET Core 3.0+ 里它对字符串按进程随机化,应用重启后同一 deviceId 会算出不同分片,导致写入后查不到;且Math.Abs(int.MinValue)会抛异常。改用确定性哈希(FNV-1a,无随机种子)或 SHA256 截断取模。
csharp
// ========## 大 Hash 水平分片:按业务键稳定哈希分到 8 个 shard ============
// 组成 shard key:user:{uid}:devices:{shard},避免单个 Hash 字段数过大。
private static string DeviceShardKey(long uid, int shard)
=> $"user:{uid}:devices:{shard}";
// 写入一台设备:先算稳定分片号,再写入对应 shard 的 Hash(字段=deviceId,值=最后登录时间戳)。
public Task AddDeviceAsync(long uid, string deviceId)
{
// 用确定性哈希,勿用 string.GetHashCode()(见 4.5 正文警告)
int shard = StableHash(deviceId, 8);
return _db.HashSetAsync(
DeviceShardKey(uid, shard),
deviceId,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
}
// FNV-1a:确定性哈希,无随机种子,进程重启后结果不变(跨实例一致,可放心做分片)。
// mod 为分片数,建议取 2 的幂(8/16/...),让分布更均匀。
private static int StableHash(string input, int mod)
{
unchecked
{
uint hash = 2166136261u; // FNV offset basis(固定初始值)
foreach (char c in input)
{
hash ^= c; // XOR 当前字符
hash *= 16777619u; // FNV prime(固定质数)
}
return (int)(hash % (uint)mod); // 恒为非负,无需 Math.Abs
}
}
策略三:冷热字段分离:热字段更小、易被多级缓存命中;冷字段可设更长 TTL 或走持久化存储。
边界 :拆分后要维护一致性;不要为"整齐"强行拆分,字段数远小于 512 且走 listpack 时拆开反而多出 key 元数据开销。删除大 Hash 一律用 UNLINK 而非 DEL ,.NET 用 db.ExecuteAsync("UNLINK", key)。
4.6 Hash 的适用边界
边界一:Hash 不是关系型表的替代品。

一句话:Hash 适合"已知主键、按字段读写"的对象缓存,不适合"按条件查询"的业务数据。
边界二:Hash 不适用于嵌套复杂配置对象。
需求:配置对象多层嵌套、数组,极少局部修改,主要整体读取。选型:优先 String+JSON。Hash 不原生支持嵌套,强行拍平会导致字段爆炸;需要局部更新且环境支持 RedisJSON 时,可评估 RedisJSON。
csharp
// 嵌套复杂配置对象:整体读取为主 → 用 String+JSON 存(Hash 表达不了嵌套)。
// 读取:GET 后反序列化;key 不存在时返回 null。
public async Task<AppConfig?> GetConfigAsync(string key)
{
var json = await _db.StringGetAsync($"config:{key}");
return json.IsNull ? null : JsonSerializer.Deserialize<AppConfig>(json!);
}
// 写入:整体序列化为 JSON 存进 String(整存整取,不做字段级更新)。
public Task SaveConfigAsync(string key, AppConfig cfg)
=> _db.StringSetAsync($"config:{key}", JsonSerializer.Serialize(cfg));
只有当"嵌套结构可拍平为扁平字段、且局部更新频繁"时,才考虑 Hash;否则不要用 Hash 硬扛嵌套。
五、对象存储选型对比
5.1 三种方案逐一拆解
方案 A:Hash(一个 key 存一个对象)
redis
HSET user:1001 name "张三" age 30 city "上海"
HGET user:1001 name
HSET user:1001 age 31
HINCRBY user:1001 age 1
HMGET user:1001 name age city
HGETALL user:1001 # ⚠️ O(N)
- ✅ 字段级读写、网络包最小。
- ✅ 小字段场景内存省,元数据只有一份。
- ✅
HINCRBY字段级原子自增。 - ❌ 不支持嵌套。
- ❌ 字段级过期要 7.4+。
- ❌ 大 Hash 有
HGETALL与迁移开销。
方案 B:String+JSON
redis
SET user:1001 '{"name":"张三","age":30,"addr":{"c":"上海","d":"浦东"}}'
GET user:1001
SET user:1001 '{"name":"张三","age":31}' # 改一个字段也要重写整个 JSON
- ✅ 支持任意嵌套、动态字段。
- ✅ per-key TTL。
- ✅ 整对象一次取出。
- ❌ 局部更新要读改写整个对象。
- ❌ 并发改不同字段会互相覆盖。
- ❌ JSON 字段名重复,体积偏大。
方案 C:一字段一 String
redis
SET user:1001:name "张三"
SET user:1001:age 30
INCR user:1001:age
EXPIRE user:1001:token 1800
- ✅ 每个字段独立 TTL(全版本)。
- ✅ 单 key 操作,字段隔离。
- ❌ key 数量爆炸,元数据开销线性叠加,内存最费。
- ❌
MGET在 Cluster 下可能跨槽失败。 - ❌ key 名重复存前缀。
5.2 四个底层变量
变量一:字段级操作 vs 全量读写。 频繁修改单个字段 → Hash;整存整取 → String+JSON。注意:读性能上 String+JSON 往往不输甚至更快 ,因为 Hash 整体读要用 HGETALL(O(N)),String 只要一次 GET。正确说法是"Hash 的单字段读写更强,整对象读取上 String 未必吃亏"。
变量二:原子性。 Hash 的 HINCRBY 天然原子;HSET f1 v1 f2 v2 单命令本身也是原子的。不原子的是"跨命令的读---判断---写"组合逻辑。String+JSON 的任何字段级更新都是"读改写",并发下会丢更新(lost update)。
变量三:内存三层。 listpack 红利 + 字段名税 + Compact Hash。小对象 Hash 省;大量同构、字段名长、版本老,String+JSON 不一定输。
变量四:TTL 语义。 7.4 之前 Hash 只能整 key 过期;7.4+ 有字段级 TTL(注意 HSET 清 TTL 的坑);一字段一 String 天然每个字段独立 TTL。
性能提醒 :不要为"性能"这个模糊的词做决策。字段级操作场景下 Hash 相对 String+JSON 读改写通常快一个量级(量级参考),但小对象上
HGET与GET都在 0.1ms 量级,差距主要在大对象和全量序列化场景放大。先看访问模式,再看并发,最后才轮到内存和延迟。
5.3 完整对比矩阵

5.4 第四选项:RedisJSON 模块

✅ 结论:对象扁平、无模块依赖时,Hash 的兼容性和零依赖优势通常更稳;只有确实需要 JSONPath 路径查询、嵌套文档局部更新或二级索引时,才值得引入 RedisJSON,并评估云环境支持。
5.5 四问决策树

5.6 混合方案实战(工程最常用)
混合方案是真实项目的常态:热字段 Hash + 冷快照 JSON;集合存 ID + 本体 Hash;嵌套又要路径级查询时引入 RedisJSON。
一个"用户中心"的最终结构:
redis
# 结构化高频字段 → Hash;字段级更新、字段级计数都方便
HSET user:1001:base nickname "张三" avatar "https://..." city "上海" level 5
EXPIRE user:1001:base 86400
# 登录态 → 独立 key,独立 TTL
SET user:1001:token "jwt..." EX 1800
# 7.4+ 等价写法(少一个 key):
# HSET user:1001:base token "jwt..."
# HEXPIRE user:1001:base 1800 FIELDS 1 token
# 大文本 → 单独 String,避免拖垮 base 的 listpack 编码
SET user:1001:bio "这是一段很长的个人简介......" EX 86400
# 嵌套设置 → 整块 String+JSON(Hash 表达不了嵌套)
SET user:1001:settings '{"theme":"dark","lang":"zh","notify":{"email":true,"sms":false}}'
三种可复用模式:
- 模式一:热冷拆分------高频字段 Hash、低频大字段单独 String。
- 模式二:ID/索引 + 对象本体------List/Set/ZSet 只存 ID,本体用 Hash。
- 模式三:引入 RedisJSON------嵌套 + 路径级读写 + 二级索引确实需要时再上。
⚠️ 拆成多个 key 后,Cluster 环境下可能落在不同槽位,无法用
MGET/HGETALL一次原子取全部。需要"一次原子读全对象"时,用{user:1001}这样的 hash tag 绑到同一槽,或接受多一次往返。
5.7 可复制版决策树

补充约束:
- 低版本 Redis(<7.4)不要幻想 Hash 字段级过期,要么拆 key,要么业务层维护过期;
- 云托管 Redis 环境,优先确认是否支持 RedisJSON 模块;
- 同 schema 对象数量百万级、字段名较长:若版本 ≥8.10,可评估 Compact Hash,但上线前必须用真实数据实测收益;
- 字段生命周期差异极大:7.4+ 用 Hash +
HEXPIRE(注意HSET会清除字段 TTL);7.4 以下拆多个 key。
5.8 值序列化格式怎么选
这是"值怎么编码"的问题,与"用不用 Hash"的结构选择是两个正交维度。

⚠️ 不要把整个 JSON 当成 Hash 的某个 field 的值再塞回去 (如
HSET user:1001 addr '{"city":"上海",...}')。既丢字段级操作能力,又踩 64 字节阈值。要么扁平化成addr:city、addr:district,要么该嵌套结构整块走 String。
5.9 一句话落地结论
✅ 结构扁平、字段可枚举、以字段级读写为主、无大字段 → 首选 Hash;结构嵌套/动态、需要整对象替换、字段少且几乎不单独更新 → 用 String+JSON;只有在"字段生命周期差异大且版本低于 7.4"时,才被迫用一字段一 String;嵌套又要路径级更新 → 评估 RedisJSON;字段超千级 → 拆 Hash 或回到 String+JSON。真实项目里最常见的其实是混合方案:结构化的高频字段放 Hash,大文本字段单独放 String,各取所长。
六、高频线上坑点汇总
6.0 三个真实故障,说明这些坑为什么值得看
故障一:用户积分用 JSON,并发扣减丢失、越扣越多。 根因是 GET → 反序列化 → 改 → 序列化 → SET 全量回写非原子,多请求并发时互相覆盖。正确做法:改 Hash HINCRBY 原子增减(坑 15)。
故障二:商品缓存 Hash 定时任务全量 HGETALL,凌晨 CPU 100%。 根因是 HGETALL O(N) 大包阻塞主线程。正确做法:HSCAN 分批 + HMGET 指定字段(坑 1)。
故障三:7.2 上执行 HEXPIRE 报 unknown command。 根因是字段级过期需 7.4+,混部环境还可能"部分节点支持、部分报错"。正确做法:上线前核对 INFO server 的 redis_version,低版本做降级(坑 12)。
三条故障的共同点:问题不在"Hash 好还是 String 好",而在选型是否匹配了访问模式、数据结构与版本能力。
6.1 命令坑
1. HGETALL / HKEYS / HVALS 拉取大 Hash,阻塞主线程
- 现象 :接口 P99 飙高,慢查询日志里躺着
HGETALL。 - 根因 :三者都是 O(N);大 Hash 一次性编码进输出缓冲区,服务端和客户端同时承压。
- 规避 :大 Hash 一律用
HSCAN分批;只取需要的字段用HMGET(O(k));禁止对大 Hash 使用HGETALL。
2. HSTRLEN 返回字节数,不是字符数
- 现象:中文昵称长度校验总是算错。
- 根因 :
HSTRLEN返回字节数;"张三"返回 6 而不是 2。 - 规避 :按字节设计限制,或应用层按字符数校验。若校验用户感知的"文本元素数",用
new StringInfo(s).LengthInTextElements。
3. HINCRBY 对非整数字段值报错
- 现象 :
ERR hash value is not an integer。 - 规避 :计数器字段与普通字段分开存放;浮点用
HINCRBYFLOAT(注意精度)。
4. HRANDFIELD 带 count 时复杂度为 O(N)
- 现象:抽奖接口偶发延迟。
- 规避 :随机一个字段用不带 count 的
HRANDFIELD(O(1));不要无脑传一个很大的 count。
5. HSETNX 误用于更新,静默失败
- 现象:字段更新静默失败返回 0。
- 根因 :
HSETNX只在字段不存在时写入。 - 规避 :初始化用
HSETNX,更新用HSET。
6. HSET 更新字段会清除该字段的 TTL(7.4+)
- 现象:给 token 设了 30 分钟字段级 TTL,更新一次 token 值后 TTL 消失、永不过期。
- 根因 :
HSET覆盖字段时会连带清除该字段已有 TTL ;HINCRBY/HINCRBYFLOAT保留 TTL。key 级 TTL 不受HSET影响。 - 规避 :更新字段后重新
HEXPIRE,或用 8.0 的HSETEX一步到位。
7. 误以为 HSET 会重置 key 的 TTL
- 根因 :
HSET不改变 key 级 TTL ;字段级 TTL 则会被HSET清除。 - 规避 :明确区分 key 级与字段级 TTL;需要滑动续期用
GETEX/HGETEX(8.0+)。
8. HSCAN 遍历过程中修改 Hash,出现重复/丢失
- 根因 :rehash 渐进式扩容过程中 SCAN 游标机制特性;
COUNT只是提示。 - 规避 :业务层去重;迁移场景必须允许重跑,不要用它做一次性精确导出。
6.2 编码坑
9. hash-max-listpack-entries 默认值误记成 128
- 根因 :Hash 默认是 512 ;128 是
zset-max-listpack-entries的默认值(也是 Redis 6.xhash-max-ziplist-entries的历史值)。 - 规避 :以
CONFIG GET hash-max-listpack-entries为准。
10. 单个长字段拖垮整个 Hash(编码单向不可逆升级)
- 现象:小 Hash 加了一个长"备注/简介"字段后内存翻了几倍;删掉该字段内存也不回落。
- 根因 :任意 field/value 超过 64 字节 即从 listpack 升级为 hashtable,且不可逆。
- 规避 :大文本字段单独用 String;用
OBJECT ENCODING定期抽查。
11. 渐进式 rehash 期间内存临时翻倍、CPU 抖动
- 根因:大 Hash 扩容触发渐进式 rehash,新旧表并存。
- 规避:控制单 Hash 字段数;做内存评估时避开 rehash 窗口。
12. 字段级过期命令的版本依赖
- 现象 :7.2 上执行
HEXPIRE报 "unknown command";混部环境功能时好时坏。 - 根因 :
HEXPIRE系列需 7.4+ ,HGETDEL / HGETEX / HSETEX需 8.0+。 - 规避 :上线前核对
INFO server的redis_version;低版本做降级方案。
6.3 架构 / 建模坑
13. 把 Hash 当"万能容器",字段数无上限
- 现象 :单 Hash 字段数破千甚至上百万,
HGETALL越来越慢,DEL阻塞数秒。 - 规避 :垂直拆分 + 水平分片(4.5);或改 String+JSON;定期
redis-cli --bigkeys扫描;删除大 Hash 用UNLINK而非DEL。
14. 把嵌套 JSON 塞进 Hash 的某个 field
- 现象:既享受不到字段级更新,又踩了 64 字节阈值。
- 规避:要么扁平化成多字段,要么该字段改用独立 String。
15. 用 String+JSON 存"高并发改不同字段"的对象,发生覆盖丢失
- 现象:多客户端修改不同字段,更新互相覆盖。
- 根因 :
GET → 反序列化 → 改 → 序列化 → SET全量回写非原子。 - 规避 :频繁局部更新改用 Hash,用
HINCRBY原子增减;JSON 场景必须加版本号乐观锁或分布式锁。
16. Hash 不支持嵌套对象,强行拍平导致字段爆炸
- 根因:Hash 只支持一级 field-value,嵌套靠前缀拼字段名硬凑。
- 规避:嵌套配置整块 String+JSON 存储;确实要按路径查配置,再评估 RedisJSON。
17. 删除大 Hash 用 DEL 同步阻塞
- 根因 :
DEL同步释放整个对象内存,阻塞主线程。 - 规避 :改用
UNLINK异步删除。
6.4 一页速查表

七、小结
Hash 是 Redis 里最像业务对象、也最容易在"细节"上翻车 的类型。它的命令不难,难的是编码临界点和建模决策------什么时候省内存、什么时候反而费、到底该用它还是用 String+JSON。
7.1 三种方案的本质定位
- Hash = "一个对象的字段容器" :擅长字段级原子操作、
HINCRBY原子计数、listpack 紧凑编码省内存;但存在编码单向升级、HGETALL高危、7.4 前无字段 TTL、不支持嵌套等硬约束。 - String+JSON = "一个对象的序列化快照":开发简单、天然支持嵌套数组、整存整取;代价是局部更新存在竞态覆盖风险,每次读写都要序列化/反序列化。
- RedisJSON = "嵌套 + 路径级局部更新":可同时支持嵌套与字段局部更新,但 TTL 仅支持整个文档的 key 级,不支持路径级 TTL;受限于模块兼容性。
7.2 核心结论
- 区分 O(1) / O(k) / O(N) :
HGET是 O(1);HMGET是 O(k) (k 为请求字段数);HGETALL/HKEYS/HVALS是 O(N) (N 为 Hash 字段总数)。大 Hash 上HGETALL必须改用HSCAN;只取需要的字段用HMGET。 - 编码临界点记住 512 和 64 :
hash-max-listpack-entries默认 512 (不是 128),单个字段超 64 字节或字段数超 512 即转 hashtable,且不可逆。 - 7.4+ 才有字段级过期 ,8.0+ 才有
HGETDEL / HGETEX / HSETEX;StackExchange.Redis 中字段级过期 API 自 2.8.12+ 可用,8.0 三兄弟自 2.9.11+ 有原生封装。混部环境必须核对INFO server的redis_version。HSET更新字段会清除该字段 TTL ,更新后必须重新HEXPIRE,或用HSETEX一步到位。 - 选型看业务访问模式 :分水岭是字段级 vs 全量读写、并发原子性、内存三层、TTL 语义。结构扁平、字段可枚举、以字段级读写为主才用 Hash;嵌套/动态结构用 String+JSON;生命周期差异大且版本低于 7.4 才被迫拆 key;嵌套又要路径级更新再评估 RedisJSON。
- 内存不是唯一依据 :小 Hash 有 listpack 红利,大量同构大对象有"字段名税",Redis 8.10 的 Compact Hash 又把天平扳回来(量级参考:普通 Hash 1.18 GB → Compact Hash 0.72 GB)。但 50% 是上限不是承诺,上线前必须实测。
- 真实项目多是混合方案:热字段 Hash + 大字段 String + 嵌套 JSON + 集合存 ID,不要追求"一种方案打天下"。
7.3 高危红线(写进团队规范)
- ❌ 禁止大 Hash 调用
HGETALL------用HSCAN分批或HMGET指定字段。 - ⚠️ 警惕单个大 value 触发 Hash 编码升级 ------大文本不要塞进 Hash 字段,用
OBJECT ENCODING定期巡检。 - ❌ 低版本(<7.4)不要幻想 Hash 字段过期------要么拆 key,要么业务层维护过期。
- ❌ 不要用 String+JSON 存"高并发改不同字段"的对象------必然 lost update。
- ❌ 不要把嵌套 JSON 当 Hash 的 field 值塞进去------既丢字段级操作,又踩 64 字节阈值。
- ❌ 删除大 Hash 用
UNLINK而非DEL。 - ⚠️
HSCAN不保证完整------迁移场景必须允许重跑 + 业务去重。 - ❌ 用 Hash 存字段数无上限的动态属性------触发 hashtable,内存反超;该拆就拆,该换 String+JSON 就换。