1. 项目背景

1.1 业务场景

电商系统的用户模块和交易模块有两个典型的数据结构:用户画像和购物车。

用户画像是用户的基本信息集合——昵称、等级、城市、积分、VIP 等级、最近登录时间、头像 URL。这些字段加起来大约 500 字节。最初后端同学图省事,把整个用户对象序列化成 JSON 字符串存进 Redis 的 String。看起来简单,但问题很快暴露出来:用户每次修改昵称或城市,都必须把整个 JSON 读出来、反序列化、修改字段、再整体序列化写回去。如果一个 JSON 有 30 个字段,你只改了 1 个,却要传输和解析全部 30 个。

购物车更严重。用户每添加一个商品,都要更新商品数量和勾选状态。如果购物车里有 50 件商品(大促期间很常见),每次"数量+1"都要读 50 个 SKU 的对象、修改一个、再写回整个对象——这本质上是"用读改写来模拟局部更新",既浪费带宽又容易产生并发覆盖。

1.2 痛点

问题 String 方案的表现 Hash 方案的优势
局部更新 读出整个 JSON → 改一个字段 → 写回整个 JSON HSET user:profile:1001 nickname "小胖" 只更新一个字段
原子数量增减 需要读改写三步,不原子 HINCRBY cart:1001 sku:2001 1 单命令原子完成
并发覆盖 两个请求同时读、各自改、先后写回,后者覆盖前者 Hash 的每个字段操作独立,不存在对象级覆盖
字段级过期 整个 JSON 只能设一个全局 TTL Redis 8.x 支持字段级过期(HEXPIRE),不同字段可独立过期
内存开销 完整 JSON + 序列化开销 小 Hash 内部编码为 listpack,更紧凑
可读性 GET user:profile:1001 返回一串 JSON HGETALL user:profile:1001 返回字段-值对,可读性更好

1.3 本章目标

掌握 Redis Hash 的核心操作和三种实战模式:

  • 用户画像模式:Hash 作为对象存储,字段独立读写。
  • 购物车模式:Hash 作为动态属性集,支持原子增减和局部删除。
  • 字段级过期模式(Redis 8.x):理解字段独立 TTL 的业务场景。

同时理解三个重要边界:

  • Hash vs JSON String 的取舍——不是所有对象都应该用 Hash。
  • Hash 的字段数量红线——巨大 Hash 的 HGETALL 和 HSCAN 差异。
  • 购物车的 value 设计——存数量还是存小 JSON,取决于业务复杂度。

2. 项目设计

2.1 第一幕:Hash 就是"小号 Redis 里的 Redis"

小胖:"用户画像存 String 不是挺方便吗?我用 Jackson 一圈 objectMapper.writeValueAsString(user),存进去;读取时 objectMapper.readValue(json, User.class),完美。Hash 还能比这个方便?"

小白:"那你更新昵称的时候,要读整个 User 对象出来,改一个字段,再序列化整个对象写回去。这中间如果有另一个请求也在更新头像 URL——你俩的写操作就可能互相覆盖。而且每次改一个字段都要传输完整的 User JSON,网络成本也不低。"

小胖:"那你说 Hash 怎么存?"

大师拿出一张对比表:

String 方式(存整个 JSON):
  Key:  user:profile:1001
  Value: {"name":"小胖","level":3,"city":"杭州","score":120,"avatar":"https://..."}

  → 改昵称: GET user:profile:1001 → 解析 → 改 name → 序列化 → SET user:profile:1001
  → 问题: 非原子、大量冗余传输、并发可能互相覆盖

Hash 方式(字段即属性):
  Key:  user:profile:1001
  Fields:
    name     → "小胖"
    level    → "3"
    city     → "杭州"
    score    → "120"
    avatar   → "https://..."

  → 改昵称: HSET user:profile:1001 name "小胖"
  → 单字段更新,无需读取、无需整体覆盖

大师:"你可以把 Hash 理解成'套在 Redis 里的另一个小键值对数据库'。Hash 的每个 field 就像 Redis 自己的一个 key,只不过它们共享同一个外层 key 的生命周期——TTL 是给整个 Hash key 的,不是给单个 field 的(除非你用了 8.x 的字段级过期)。"

技术映射:Hash 本质是 key → {field1: val1, field2: val2, ...} 的二级映射。Redis 内部用小规模编码(listpack)或哈希表存储。

小胖:"那所有对象都用 Hash 不就完了?为什么还有人选 JSON String?"

大师:"因为 Hash 也有代价。第一,如果你的对象有嵌套结构(比如 user.address.city),Hash 的处理会很笨重——你要么把 address 的值存成另一个 JSON,要么把 address 拆成一堆带前缀的 field。第二,如果你的客户端已经有了成熟的序列化机制,JSON String 可以一行代码搞定序列化/反序列化。第三,如果你需要对整个对象做版本管理、CAS 更新或整体传输,JSON String 反而更直观。"

2.2 第二幕:购物车——存数量还是存小 JSON?

小胖:"购物车简单啊。每个 SKU 一个 field,value 就是数量:"

HSET cart:1001 sku:2001 2
HSET cart:1001 sku:2002 1
HINCRBY cart:1001 sku:2001 1   → 变成 3

小白:"但如果购物车还需要保存商品勾选状态、加入时间、价格快照呢?你只存个数量根本不够。"

大师:"这就引出了 Hash 建模的核心权衡——value 是存简单值还是存小 JSON:"

方案 A:value = 简单数字
HSET cart:1001 sku:2001 2
HSET cart:1001 sku:2002 1
→ 优点:HINCRBY 原子增减、内存紧凑
→ 缺点:只能存数量,无法存勾选状态、价格、时间

方案 B:value = 小 JSON
HSET cart:1001 sku:2001 '{"count":2,"checked":true,"price":3999,"addedAt":1690000000}'
HSET cart:1001 sku:2002 '{"count":1,"checked":false,"price":5999,"addedAt":1690000100}'
→ 优点:每个 SKU 可以存完整上下文
→ 缺点:改数量需要读改写(非原子)、HGETALL 后需要解析 JSON、内存略高

方案 C:flat field 前缀
HSET cart:1001 sku:2001:count 2
HSET cart:1001 sku:2001:checked 1
HSET cart:1001 sku:2001:price 3999
→ 优点:每个属性可独立操作,count 可用 HINCRBY
→ 缺点:field 数量膨胀 N 倍,HGETALL 会返回大量 field

大师:"选哪种,取决于业务的'操作频率'和'数据复杂度'。如果用户频繁改数量但很少改勾选状态,方案 A 或 C 更合适;如果购物车不是高频操作(大部分人浏览不买),方案 B 的 JSON 更省心。我的建议是——先用方案 B 快速上线,如果后续 HINCRBY 的原子性成为瓶颈(比如秒杀场景),再针对性地优化或迁移到 Redis Stream。"

技术映射:Hash 的建模没有标准答案,它是一组 trade-off——原子性 vs 复杂度、字段数量 vs 可维护性、内存成本 vs 开发成本。

2.3 第三幕:字段级过期——Hash 的最后一公里

小胖:"Hash 的一个大问题是——我只能给整个 key 设 TTL,不能给单独的 field 设过期。比如说我用户画像里的 risk_score 字段是风控系统实时计算的,只想缓存 5 分钟,但我整个用户画像应该存活 1 小时。如果为了一个字段的过期把整个 Hash 的 TTL 缩到 5 分钟,其他字段也要频繁重建。"

小白:"这个痛点我深有体会。之前做优惠券系统,用户可能持有多种优惠券,每种券的过期时间都不同。如果全部放到一个 Hash 里,根本没法独立设置过期;如果拆成多个独立的 String key,管理起来又很散乱。"

大师:"这在 Redis 8.x 之前确实无解。Redis 8.x 引入了字段级过期能力,让我们看看怎么用它解决这个问题:"

# 用户画像(整体不设过期,或设 1 小时)
HSET user:profile:1001 name "小胖" level 3 score 120

# 风控分数字段独立 5 分钟过期(Redis 8.x)
HEXPIRE user:profile:1001 300 FIELDS 1 risk_score

大师:"这个能力的适用场景不止风控。购物车里的'限时优惠标记'、商品详情页的'库存紧张标签'、用户会话中的'两步验证状态'——这些字段的生命周期和所属对象不同,现在可以各自管理了。"

技术映射:字段级过期让 Hash 从"同生共死"进化为"各自独立生命周期"。但要注意——这是个新特性,使用时必须确认 Redis 版本(8.x+)、客户端支持和集群兼容性。

2.4 第四幕:大 Hash 的暗面

小胖:"既然 Hash 这么好,我就可以放心地一个用户存一个 Hash,field 想加多少加多少了吧?"

小白:"那如果某个用户的 Hash 有 10 万个 field(比如浏览历史),你用 HGETALL 会发生什么?Redis 会阻塞多久?"

大师:"这是 Hash 最常见的事故。HGETALL 的复杂度是 O(N),N 是 field 数量。一个 10 万 field 的 HGETALL 不仅会让 Redis 主线程阻塞几十到几百毫秒,还会返回一个巨大的响应包,可能撑爆客户端内存。"

实验:比较 HGETALL 和 HSCAN 的差异

Hash 有 100 个 field(模拟):
  HGETALL → 一次返回所有,网络包 ≈ 5KB  → OK
  HSCAN 0 COUNT 10 → 分批返回,每次 ≈ 500B → OK

Hash 有 10000 个 field:
  HGETALL → 网络包 ≈ 500KB → 主线程阻塞明显
  HSCAN 0 COUNT 100 → 分 100 次迭代 → 安全但慢

Hash 有 100000 个 field:
  HGETALL → 网络包 ≈ 5MB → ❌ 可能阻塞数百毫秒甚至秒级
  HSCAN → 分 1000 次迭代 → 可接受但需控制总体时间

大师:"经验法则:对象型 Hash(用户画像、配置项)的 field 数量应控制在 100 以内;集合型 Hash(购物车、标签集)的 field 数量应控制在 1000 以内。超过这个范围就说明建模方式需要重新考虑——是不是该用 Set、ZSet 或者拆分成多个 key 了。"

技术映射:Hash 的优势是字段局部操作,但一旦 field 过多,它就失去了这个优势——因为你根本没办法轻量地迭代整个 Hash。HSCAN 是逃生窗口,但它返回的结果不是快照,迭代过程中可能有遗漏或重复。


3. 项目实战

3.1 环境准备

docker exec -it redis-lab redis-cli -a redis123

3.2 实战一:用户画像缓存

步骤 1:写入用户资料

# HSET:写入多个字段
127.0.0.1:6379> HSET user:profile:1001 name "小胖" level 3 city "杭州" score 120 vip 1
(integer) 5    # 返回 5 表示新增了 5 个字段

# HGET:读取单个字段
127.0.0.1:6379> HGET user:profile:1001 name
"小胖"

# HMGET:一次读取多个字段
127.0.0.1:6379> HMGET user:profile:1001 name level city
1) "小胖"
2) "3"
3) "杭州"

# 读取不存在的字段
127.0.0.1:6379> HGET user:profile:1001 email
(nil)

# 读取不存在的 key
127.0.0.1:6379> HGET user:profile:9999 name
(nil)

步骤 2:修改和增加字段

# 增加新字段
127.0.0.1:6379> HSET user:profile:1001 avatar "https://cdn.example.com/avatar/1001.jpg"
(integer) 1    # 返回 1 表示新增

# 修改已有字段
127.0.0.1:6379> HSET user:profile:1001 city "上海"
(integer) 0    # 返回 0 表示更新(不是新增)

# 仅当字段存在时修改(HSETNX 的 Hash 版,但 Redis 没有直接的命令,用 Lua)
# 如果需要"字段不存在才设置",可以用 Lua:
127.0.0.1:6379> EVAL "if redis.call('HEXISTS',KEYS[1],ARGV[1])==0 then redis.call('HSET',KEYS[1],ARGV[1],ARGV[2]) return 1 else return 0 end" 1 user:profile:1001 email "xiaopang@example.com"
(integer) 1    # 字段之前不存在,写入成功

步骤 3:积分增减(HINCRBY)

# 原子增加积分
127.0.0.1:6379> HINCRBY user:profile:1001 score 10
(integer) 130
127.0.0.1:6379> HINCRBY user:profile:1001 score -5
(integer) 125

# 如果字段不存在,从 0 开始递增
127.0.0.1:6379> HINCRBY user:profile:1001 coins 50
(integer) 50

# 浮点数递增
127.0.0.1:6379> HSET user:profile:1001 balance 100
(integer) 1
127.0.0.1:6379> HINCRBYFLOAT user:profile:1001 balance -9.9
"90.1"

步骤 4:查看 Hash 信息

# 查看所有字段和值
127.0.0.1:6379> HGETALL user:profile:1001
 1) "name"
 2) "小胖"
 3) "level"
 4) "3"
 5) "city"
 6) "上海"
 7) "score"
 8) "125"
 9) "vip"
10) "1"
11) "avatar"
12) "https://cdn.example.com/avatar/1001.jpg"
13) "email"
14) "xiaopang@example.com"
15) "coins"
16) "50"
17) "balance"
18) "90.1"

# 只列出字段名
127.0.0.1:6379> HKEYS user:profile:1001
1) "name"
2) "level"
3) "city"
...

# 只列出字段值
127.0.0.1:6379> HVALS user:profile:1001
1) "小胖"
2) "3"
...

# 字段数量
127.0.0.1:6379> HLEN user:profile:1001
(integer) 9

# 检查字段是否存在
127.0.0.1:6379> HEXISTS user:profile:1001 email
(integer) 1
127.0.0.1:6379> HEXISTS user:profile:1001 phone
(integer) 0

步骤 5:设置过期和删除

# 整个 Hash 设置 1 小时过期
127.0.0.1:6379> EXPIRE user:profile:1001 3600
(integer) 1

# 查看 TTL
127.0.0.1:6379> TTL user:profile:1001
(integer) 3597

# 删除单个字段
127.0.0.1:6379> HDEL user:profile:1001 email
(integer) 1
127.0.0.1:6379> HEXISTS user:profile:1001 email
(integer) 0

# 删除整个 Hash
127.0.0.1:6379> DEL user:profile:1001
(integer) 1

3.3 实战二:购物车完整实现

步骤 1:添加商品到购物车

# 简单购物车:value 存数量
127.0.0.1:6379> HSET cart:1001 sku:2001 2
(integer) 1
127.0.0.1:6379> HSET cart:1001 sku:2002 1
(integer) 1
127.0.0.1:6379> HSET cart:1001 sku:2003 1
(integer) 1

# 查看购物车
127.0.0.1:6379> HGETALL cart:1001
1) "sku:2001"
2) "2"
3) "sku:2002"
4) "1"
5) "sku:2003"
6) "1"
127.0.0.1:6379> HLEN cart:1001
(integer) 3

步骤 2:修改商品数量

# 原子增加数量
127.0.0.1:6379> HINCRBY cart:1001 sku:2001 1
(integer) 3

# 原子减少数量
127.0.0.1:6379> HINCRBY cart:1001 sku:2001 -1
(integer) 2

# 数量减到 0 时,业务上需要删除该商品
127.0.0.1:6379> HINCRBY cart:1001 sku:2002 -1
(integer) 0

# 业务代码中:如果返回 0,调用 HDEL 移除该 SKU
127.0.0.1:6379> HDEL cart:1001 sku:2002
(integer) 1

步骤 3:复杂购物车(value 存小 JSON)

# 每个 SKU 的值是 JSON,包含数量、勾选状态、加入时间、价格快照
127.0.0.1:6379> HSET cart:1001 sku:2001 '{"count":2,"checked":true,"price":3999,"addedAt":1690000000}'
(integer) 1
127.0.0.1:6379> HSET cart:1001 sku:2002 '{"count":1,"checked":false,"price":5999,"addedAt":1690000100}'
(integer) 1

# 读取单个 SKU 的完整信息
127.0.0.1:6379> HGET cart:1001 sku:2001
"{\"count\":2,\"checked\":true,\"price\":3999,\"addedAt\":1690000000}"

步骤 4:Java 购物车服务(完整代码)

public class CartService {
    private static final String CART_PREFIX = "cart:";
    private static final int CART_TTL_HOURS = 72;  // 购物车 3 天未操作自动清除

    public void addItem(Long userId, String skuId, int count) {
        String key = CART_PREFIX + userId;
        // 原子增加数量,如果返回的值就是添加后的总数
        Long newCount = redis.hincrBy(key, skuId, count);
        // 设置整个购物车 TTL(每次操作重置)
        redis.expire(key, Duration.ofHours(CART_TTL_HOURS));
    }

    public void updateCount(Long userId, String skuId, int count) {
        String key = CART_PREFIX + userId;
        if (count <= 0) {
            // 数量 ≤0 则移除该 SKU
            redis.hdel(key, skuId);
        } else {
            // 直接设置为目标数量(注意:不是增量!)
            redis.hset(key, skuId, String.valueOf(count));
        }
        redis.expire(key, Duration.ofHours(CART_TTL_HOURS));
    }

    public void removeItem(Long userId, String skuId) {
        redis.hdel(CART_PREFIX + userId, skuId);
    }

    public Map<String, Integer> getCart(Long userId) {
        Map<String, String> raw = redis.hgetAll(CART_PREFIX + userId);
        Map<String, Integer> cart = new HashMap<>();
        for (Map.Entry<String, String> entry : raw.entrySet()) {
            cart.put(entry.getKey(), Integer.parseInt(entry.getValue()));
        }
        return cart;
    }

    public int getCartCount(Long userId) {
        Long count = redis.hlen(CART_PREFIX + userId);
        return count == null ? 0 : count.intValue();
    }

    public void clearCart(Long userId) {
        redis.del(CART_PREFIX + userId);
    }
}

常见坑:

  • HINCRBY 返回的是递增后的值,如果你想返回"之前的值",需要用 Lua。
  • 设置购物车 TTL 时,每次操作都应刷新 TTL——类似 Session 的滑动过期策略。
  • 如果前端需要"购物车总价",不要每次在应用层计算——可以维护一个 cart:total 字段或让前端自己算。

3.4 实战三:HSCAN 安全遍历

当购物车可能有很多 SKU 时,千万不要用 HGETALL:

# 写入测试数据:20 个商品
127.0.0.1:6379> HSET bigcart:1001 sku:1 "item1" sku:2 "item2" sku:3 "item3" sku:4 "item4" sku:5 "item5"
127.0.0.1:6379> HSET bigcart:1001 sku:6 "item6" sku:7 "item7" sku:8 "item8" sku:9 "item9" sku:10 "item10"
127.0.0.1:6379> HSET bigcart:1001 sku:11 "item11" sku:12 "item12" sku:13 "item13" sku:14 "item14" sku:15 "item15"
127.0.0.1:6379> HSET bigcart:1001 sku:16 "item16" sku:17 "item17" sku:18 "item18" sku:19 "item19" sku:20 "item20"

# 安全方式:HSCAN 分批遍历
127.0.0.1:6379> HSCAN bigcart:1001 0 COUNT 5
1) "10"             # 下次迭代的游标
2)  1) "sku:14"
    2) "item14"
    3) "sku:9"
    4) "item9"
    5) "sku:13"
    6) "item13"
    ...

# 继续迭代直到游标返回 0
127.0.0.1:6379> HSCAN bigcart:1001 10 COUNT 5

Java 中使用 HSCAN 安全遍历大 Hash:

public Map<String, String> safeGetAll(String key, int batchSize) {
    Map<String, String> result = new LinkedHashMap<>();
    String cursor = "0";

    do {
        ScanResult<Map.Entry<String, String>> scanResult =
            redis.hscan(key, cursor, ScanOptions.scanOptions().count(batchSize).build());

        for (Map.Entry<String, String> entry : scanResult.getEntries()) {
            result.put(entry.getKey(), entry.getValue());

            // 安全上限:最多扫 5000 条
            if (result.size() > 5000) {
                log.warn("Hash key={} 字段数超过安全阈值,已截断", key);
                return result;
            }
        }
        cursor = scanResult.getCursor();
    } while (!"0".equals(cursor));

    return result;
}

注意事项:

  • HSCAN 不是快照——迭代期间增删字段,可能遗漏或重复。
  • COUNT 是"建议值",实际每次返回的数量不确定。
  • 必须设置迭代上限,防止字段数量失控导致无限循环。

3.5 实战四:Hash 编码观察

# 查看小 Hash 的内部编码
127.0.0.1:6379> HSET encoding:test f1 v1 f2 v2 f3 v3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING encoding:test
"listpack"       # Redis 7.0+ 小 Hash 用 listpack 编码

# 查看准确内存占用
127.0.0.1:6379> MEMORY USAGE user:profile:1001
(integer) 392    # 字节

# 对比:同样数据存为 JSON String
127.0.0.1:6379> SET user:profile:string:1001 '{"name":"小胖","level":"3","city":"杭州","score":"125","vip":"1"}'
OK
127.0.0.1:6379> MEMORY USAGE user:profile:string:1001
(integer) 113

你会发现小对象的 Hash 和 String 的内存差异不大,但当字段多起来后,Hash 的结构化优势开始体现——尤其是你需要频繁局部更新的时候。

3.6 实战五:批量操作 Hash 的 Pipeline 技巧

如果需要同时操作多个用户的 Hash(比如批量给用户加分):

# 不使用 Pipeline:N 次网络往返
127.0.0.1:6379> HINCRBY user:profile:1001 score 10
127.0.0.1:6379> HINCRBY user:profile:1002 score 10
127.0.0.1:6379> HINCRBY user:profile:1003 score 10
# → 3 次 RTT

# 使用 Pipeline(在 redis-cli 中演示)
# 先创建一个文件 batch.txt,内容为:
# HINCRBY user:profile:1001 score 10
# HINCRBY user:profile:1002 score 10
# HINCRBY user:profile:1003 score 10

# 然后执行:
# docker exec -i redis-lab redis-cli -a redis123 --pipe < batch.txt

Java 使用 Pipeline:

public void batchAddScore(List<Long> userIds, int delta) {
    redis.executePipelined((RedisCallback<Object>) connection -> {
        for (Long userId : userIds) {
            byte[] key = ("user:profile:" + userId).getBytes();
            byte[] field = "score".getBytes();
            byte[] value = String.valueOf(delta).getBytes();
            connection.hashCommands().hIncrBy(key, field, Long.parseLong(new String(value)));
        }
        return null;
    });
}

4. 项目总结

4.1 核心命令速查

命令 用途 复杂度 注意事项
HSET key field value 写入/更新单个字段 O(1) 可一次写多个 field-value 对
HGET key field 读取单个字段 O(1) 不存在返回 nil
HMGET key f1 f2 ... 批量读取多个字段 O(N) N 为请求字段数,不是 Hash 总字段数
HGETALL key 读取所有字段和值 O(N) N 为 Hash 总字段数,字段多时危险
HINCRBY key field N 原子自增整数 O(1) field 不存在时从 0 开始
HINCRBYFLOAT key field N 原子自增浮点数 O(1) 结果以字符串存储
HDEL key field 删除字段 O(1) 可一次删多个 field
HEXISTS key field 检查字段是否存在 O(1) 返回 1 或 0
HLEN key 获取字段数量 O(1) Hash 内维护了计数器
HKEYS key 获取所有字段名 O(N) 字段多时慎用
HVALS key 获取所有字段值 O(N) 字段多时慎用
HSCAN key cursor 分批迭代字段 O(1)/次 不保证一致性,需设上限
HEXPIRE key s FIELDS N f1 字段级过期(8.x) O(N) 需确认版本支持

4.2 Hash vs String 对比

维度 Hash String (JSON)
局部读取 HGET key field O(1) 整体反序列化 O(N)
局部写入 HSET key field val O(1) 整体序列化 + 写回 O(N)
原子自增 HINCRBY 天然支持 需要读改写或 Lua
字段过期 8.x 支持字段级过期 只能整个 key 过期
嵌套对象 扁平化处理(field 命名模拟层级) JSON 原生支持嵌套
序列化/反序列化 不需要(value 是字符串) 每次需要
批量读取 HMGET 按需读取 必须整体传输后解析
客户端支持 各语言都有,但 API 不如 String 方便 所有语言的 JSON 库第一公民
内存效率 小 Hash 用 listpack 编码,较紧凑 embstr/raw 编码

4.3 适用与不适用场景

适用场景:

  1. 用户资料/画像——字段固定,需要频繁局部更新(昵称、头像、积分)。
  2. 购物车——动态 SKU 集合,数量需要原子增减。
  3. 配置对象——如商品规格、活动配置,字段独立读写。
  4. 会话 Session——用户登录信息,多个字段,整体设置过期时间。
  5. 简单统计指标——如"今日订单数""今日 GMV"等多个统计指标用一个 Hash 管理。

不适用场景:

  1. 深层嵌套对象——比如 user.orders[0].items[2].price,Hash 扁平建模太绕,适合 RedisJSON(第 39 章)。
  2. 字段数量超过 1000——大 Hash 的 HGETALL/HKEYS/HVALS 会阻塞,需要重新考虑数据切分。
  3. 需要跨 key 的集合运算——比如两个用户的购物车合并,Hash 不能直接做交集/并集。
  4. 需要整体频繁传输的对象——如果 99% 的访问都是读整个对象,JSON String 可能更省事。

4.4 常见踩坑经验

案例 表象 根因 解法
大 Hash 拖慢 Redis 某接口 P99 延迟从 5ms 涨到 200ms 运维用 HGETALL 查看一个 5000 字段的购物车 key 禁用生产环境的 HGETALL 对大 Hash 的调用;引入 HSCAN 和分页读取
Hash 字段无限增长 用户画像 key 的 field 数量从 10 个涨到 200 个 开发团队每次加功能就在 Hash 里加 field,没有上限管控和归档机制 制定字段数量上限规则(如 50 个);过期的历史数据迁移到冷存储
购物车并发覆盖 用户反映"加购的商品数量不对" 前端使用"读-改-写"方式更新购物车,两个请求同时读取后互相覆盖 改用 HINCRBY 做数量增减;或使用带版本号的 CAS 更新(配合 WATCH)
编码退化 小 Hash 从 listpack 变成 hashtable 后内存翻倍 当单个 field 过长或 field 数量超过阈值,Redis 自动转换编码 了解编码转换阈值;控制单个 value 大小;接受编码转换的客观存在
字段级过期预期不一致 开发认为 HEXPIRE 可用,但测试发现线上 Redis 是 7.0 新能力未确认版本就写进代码 使用 INFO server 确认版本;对不支持的版本做降级处理(拆 key 或业务删除)

4.5 思考题

  1. 设计题:你需要设计一个"用户权益中心",包括基础权益(昵称、头像、等级)和临时权益(限时 VIP、7 天优惠券标记、24 小时风控标签)。基础权益需要长期保存但允许更新,临时权益各有不同的过期时间。请设计 Redis 的数据结构方案(使用哪些类型、哪些 key),并说明为什么不用单一 Hash 存放所有字段。

  2. 优化题:一个电商平台的购物车使用了 Hash 存储,value 是小 JSON。大促期间,运营需要统计"有多少用户把 SKU=2001 加入了购物车"。你如何在 Redis 中实现这个统计?如果购物车 key 有 100 万个,你的方案需要扫描多少次?

(答案提示将在下一章末尾给出。)

上一章思考题答案提示(第3章):

第1题(IP 限流设计):key 格式为 ratelimit:{IP}:{hour},如 ratelimit:192.168.1.1:2026071814。使用 INCR(因为需要知道具体次数,不是简单的"有没有")。Lua 代码:local c=redis.call('INCR',KEYS[1]); if c==1 then redis.call('EXPIRE',KEYS[1],3600); end; return c。如果返回 > 100 则拒绝。过期时间设为 3600 秒(一小时后 key 自动消失)。

第2题(开关不生效的 3 种根因):① 本地缓存未失效——应用层使用了 Caffeine/Guava 本地缓存,5 秒窗口内读的都是旧值(解决:缩短缓存 TTL 或改用 Redis Pub/Sub 推送变更通知)。② Redis 集群中 key 迁移导致短暂不可用——MOVED 重定向未正确处理(解决:客户端升级为集群模式连接)。③ 多实例 Redis 的数据不一致——连接到了不同的 Redis 节点,该节点的 key 未同步(解决:确保开关 key 落在同一个分片,或使用 hash tag)。

延伸阅读与资源

Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

下一章预告:第5章——列表 List 实战:任务队列与最新动态流。

posted on 2026-10-29 11:08  一天不进步,就是退步  阅读(15)  评论(0)    收藏  举报