Redis 数据类型全解 - Hash 还是 String + JSON?一文终结存储选型之争(三)

在 Redis 做业务对象缓存时,几乎所有开发者都会面临这个经典抉择:

  1. Hash :HSET user:1001 name "张三" age 28,将对象扁平拆分为 field-value;
  2. String+JSON :SET user:1001 '{"name":"张三","age":28}',整个对象序列化为 JSON 存入 String;
  3. 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 常见认知误区

  1. 误区一:Hash 一定比 JSON 省内存。

    仅当维持 listpack 紧凑编码时成立;一旦升级为 hashtable,大量 dictEntry 指针元数据开销会推高内存,内存开销可能反超 JSON。

  2. 误区二:Hash 万能,业务对象全部用 Hash 存储。

    嵌套数组、复杂层级结构强行拍平,会导致字段爆炸,维护成本急剧上升。

  3. 误区三:Hash 可以随意使用 HGETALL 获取全部对象字段。

    HGETALL 时间复杂度 O(N),字段数量大时会阻塞主线程,引发慢查询、实例抖动。慢查询里躺着一条 HGETALL,P99 从 40ms 涨到 800ms 是常有的事。

  4. 误区四:Hash 天然支持字段级 TTL。

    Redis 7.4 版本才新增 HEXPIRE 系列命令;低版本只能对整个 key 设置过期时间。7.4+ 还要注意 HSET 更新字段会清除该字段 TTL;混部环境下还可能"部分节点支持、部分报错"。

  5. 误区五:把 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 坑:

  1. HashSetAsync 的返回值 :单字段重载返回 bool(true=新建,false=覆盖);多字段重载返回 long(新建字段数)。
  2. HashGetAllAsync 没有分页 :一次性把整个 Hash 拉到内存。大 Hash 改用 HashScanAsync。
  3. 字段级过期命令要确认版本 :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 大致由这几块组成:

  1. key 本体 :dictEntry(约 32 字节)+ key 的 SDS。
  2. redisObject 对象头:16 字节(64 位系统)。
  3. 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 业务影响总结

  1. 小 Hash:字段数 ≤ 512 且所有 field/value ≤ 64 字节 → listpack,内存非常节省。
  2. 一旦突破阈值 :任意条件触发即升级 hashtable,内存开销暴涨;删字段、删大 value 也不会回落。
  3. listpack 内部遍历 O(N) ,但 N 很小,CPU 缓存友好;hashtable 读写平均 O(1),但 dictEntry 指针元数据开销巨大。
  4. 字段名税是 Hash 内存争论的核心;Redis 8.10+ 的 Compact Hash 可显著缓解,但收益取决于字段集是否稳定,上线前需实测。
  5. 渐进式 rehash 期间内存短暂接近翻倍,做内存评估时要避开这个窗口。
  6. 容量评估方法 :MEMORY USAGE 只做相对对比;真实容量以 INFO memory 的 used_memory 增量为准。
  7. 排查第一步 :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 可复制版决策树

补充约束:

  1. 低版本 Redis(<7.4)不要幻想 Hash 字段级过期,要么拆 key,要么业务层维护过期;
  2. 云托管 Redis 环境,优先确认是否支持 RedisJSON 模块;
  3. 同 schema 对象数量百万级、字段名较长:若版本 ≥8.10,可评估 Compact Hash,但上线前必须用真实数据实测收益;
  4. 字段生命周期差异极大: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.x hash-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 核心结论

  1. 区分 O(1) / O(k) / O(N) :HGET 是 O(1);HMGET 是 O(k) (k 为请求字段数);HGETALL / HKEYS / HVALS 是 O(N) (N 为 Hash 字段总数)。大 Hash 上 HGETALL 必须改用 HSCAN;只取需要的字段用 HMGET。
  2. 编码临界点记住 512 和 64 :hash-max-listpack-entries 默认 512 (不是 128),单个字段超 64 字节或字段数超 512 即转 hashtable,且不可逆。
  3. 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 一步到位。
  4. 选型看业务访问模式 :分水岭是字段级 vs 全量读写、并发原子性、内存三层、TTL 语义。结构扁平、字段可枚举、以字段级读写为主才用 Hash;嵌套/动态结构用 String+JSON;生命周期差异大且版本低于 7.4 才被迫拆 key;嵌套又要路径级更新再评估 RedisJSON。
  5. 内存不是唯一依据 :小 Hash 有 listpack 红利,大量同构大对象有"字段名税",Redis 8.10 的 Compact Hash 又把天平扳回来(量级参考:普通 Hash 1.18 GB → Compact Hash 0.72 GB)。但 50% 是上限不是承诺,上线前必须实测。
  6. 真实项目多是混合方案:热字段 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 就换。
相关推荐
夕除5 小时前
Redis 过期策略
redis·笔记·面试
莫晓燚12 小时前
双机部署若依 (RuoYi‑Vue) 传统环境部署---实战
linux·运维·数据库·redis·mysql·nginx·ruoyi
夕除14 小时前
redis--resp
redis
莫晓燚14 小时前
搭建Redis 主从复制---实战
数据库·redis·缓存
IT规划师16 小时前
Redis 数据类型全解 - List:用对是队列用错是灾难(二)
redis·消息队列·.net
重生之我是Java开发战士19 小时前
【Redis】Java & JavaSpring 客户端
java·redis·bootstrap
是程序喵呀19 小时前
Redis实现持久化
数据库·redis·缓存
余槐i20 小时前
PostgreSQL 加索引后查询反而更慢:优化器弃用索引的 3 种情形
spring boot·redis·mysql·postgresql·事务