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 适用与不适用场景
适用场景:
- 用户资料/画像——字段固定,需要频繁局部更新(昵称、头像、积分)。
- 购物车——动态 SKU 集合,数量需要原子增减。
- 配置对象——如商品规格、活动配置,字段独立读写。
- 会话 Session——用户登录信息,多个字段,整体设置过期时间。
- 简单统计指标——如"今日订单数""今日 GMV"等多个统计指标用一个 Hash 管理。
不适用场景:
- 深层嵌套对象——比如
user.orders[0].items[2].price,Hash 扁平建模太绕,适合 RedisJSON(第 39 章)。 - 字段数量超过 1000——大 Hash 的
HGETALL/HKEYS/HVALS会阻塞,需要重新考虑数据切分。 - 需要跨 key 的集合运算——比如两个用户的购物车合并,Hash 不能直接做交集/并集。
- 需要整体频繁传输的对象——如果 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 思考题
-
设计题:你需要设计一个"用户权益中心",包括基础权益(昵称、头像、等级)和临时权益(限时 VIP、7 天优惠券标记、24 小时风控标签)。基础权益需要长期保存但允许更新,临时权益各有不同的过期时间。请设计 Redis 的数据结构方案(使用哪些类型、哪些 key),并说明为什么不用单一 Hash 存放所有字段。
-
优化题:一个电商平台的购物车使用了 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 实战:任务队列与最新动态流。

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号