Skip to content

项目 B:缓存、消息与限流落地 ​

S5-009选修高级约 90 分钟JDK 21项目实战 · 项目 B:高并发业务系统

1. 本节定位 ​

《项目 B:高并发场景与架构设计》画出了五层架构;本节回答一个更难的问题:

text
S4 学的缓存、消息、限流,在秒杀场景里「分别缓存什么、异步什么、限流哪里」?
—— 每个组件都有明确的岗位,放错位置就白做 ✗✗

★ 三个组件的岗位 ✓✓:

组件岗位不岗位化会怎样
缓存 ★★★挡住"读"(库存查了又查)每次都查库 ✗
消息 ★★★摊平"写"(下单洪峰)写库洪峰 ✗✗
限流 ★★★挡住"进"(超出容量的流量)全部进系统 ✗✗✗

2. 学习目标 ​

  • 能说清三个组件在秒杀链路中各自的岗位 ★★★;
  • 知道"缓存库存"与"预扣库存"是两件事 ★★★;
  • 知道消息要发"事件"而不是"指令" ★★★;
  • 知道限流的阈值要按"被保护方容量"定 ★★★;
  • 能说出每个组件失效时的降级方案 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

★ 学了组件 ≠ 会用组件 ✗✗ —— 本节实测里就有两个反面案例:

text
案例 1:把「库存」放进缓存当真相源 ✗✗
    缓存里的库存和数据库的库存不一致时,用户看到"有货"却下单失败 ✗

案例 2:消息发的是「扣减库存」指令 ✗
    消费失败重试 → 重复扣减 ✗✗(应该发"事件",由消费者自己保证幂等)

5. 核心概念 ​

5.1 缓存的岗位:挡"读",不是当"真相源" ★★★ ​

★ 秒杀链路里读什么最多 ✓:

读操作频率该放缓存吗
商品详情(名称/图片/价格)★★★极高(每个进入秒杀页的人)✓ 缓存(热点数据)
是否已抢完(一个标志位)★★★极高✓ 缓存/内存标志(抢完立刻置位)
剩余库存的精确值 ★★高⚠️ 不建议(见下)
用户的下单结果 ★每人一次✗ 不缓存(要最终一致)

★★ 关键判断:库存的"精确值"不能放缓存当真相源 ✗✗:

缓存与数据库是两个世界(《缓存一致性与更新策略》 的一致性问题)✗

缓存说"还剩 3 件"、数据库说"0 件" → 用户看到有货、下单失败 ✗✗

正确做法:缓存里放「是否已抢完」的布尔标志(抢完置位,永不回退)✓✓ —— 它不一致的代价是"多拒绝几个人"(安全方向)✓, 而"精确库存"不一致的代价是"超卖"(危险方向)✗✗

★ 一条通用法则 ✓✓✓:

缓存只能缓存「错了也安全」的数据 ✓

· "已抢完 = true" 错了 → 多拒绝,安全 ✓ · "库存 = 3" 错了 → 超卖,事故 ✗✗

5.2 消息的岗位:摊"写",发事件不发指令 ★★★ ​

★ 秒杀链路里异步什么 ✓:

text
请求(预扣成功)→ 发消息【OrderCreated 事件】→ 消费者:落库 + 通知 + 积分

★★ 发"事件"而不是"指令" ✓✓:

指令(✗)事件(✓)
内容"扣减库存 book=1""OrderCreated{orderId=9527, bookId=1}"
语义告诉你做什么告诉你发生了什么 ✓
重试安全性消费者重复扣 ✗✗消费者按 orderId 幂等 ✓✓
新增消费方要发新消息 ✗订阅同一事件即可 ✓✓✓

★ 事件驱动的好处 ✓✓:下单后要加积分?加个订阅者就行,下单代码一行不改 ✓(呼应 《设计模式在 Spring 中的应用》 开闭原则)

★★ 消息的三条可靠性纪律(《消息可靠性、幂等与顺序》) ✓✓:

#纪律本场景
①发送要确认(至少一次)预扣成功但消息发失败 = 用户白等 ✗✗ —— 要本地消息表兜底
②消费要幂等MQ 必然重复投递 → 唯一索引 on orderId ✓✓
③失败要有 DLQ消费失败进死信 + 告警 ✓

5.3 限流的岗位:挡"进",阈值按容量定 ★★★ ​

★ 本项目实测的教训 ✗✗(《项目 B:高并发场景与架构设计》):

模拟库 5 ms 串行 → 请求到达速率只有 200/s → 令牌桶 300/s 一个都没拦到 ✗✗

限流器必须放在真正的洪峰处 ✓✓✓

★ 阈值怎么定 ✓✓:

层阈值依据
网关层压测出的系统总容量(T2-003)× 70~80% ✓✓
库存预扣库存数本身(预扣完就关闭,天然限流 ✓)
单用户防刷(如 1 秒 5 次)✓

★★ 一条容易忽略的 ✓✓:

预扣库存本身就是最好的限流器 ✓✓✓

库存 100 件 → 第 101 个请求起全部被拒 —— 它限流的精度是"业务语义"的,比任何通用限流器都精准 ✓✓

所以秒杀链路里,通用限流挡住异常流量,库存预扣挡住正常洪峰 ✓

5.4 三个组件的失效降级 ★★★ ​

★ 每个组件都会挂,挂了怎么办必须提前想好 ✓✓:

组件挂了影响降级方案
缓存挂 ★★读穿透到库"已抢完"标志放本地内存(多实例各一份,可接受)✓
MQ 挂 ★★★预扣成功但下单丢失本地消息表(《Seata 分布式事务》)+ 定时补偿 ✓✓
限流器挂 ★★流量全进库存预扣天然限流(第 5.3 节)✓✓ ——所以它是最后防线

★★ 一条总体原则 ✓✓:

降级后的行为必须是"更保守",而不是"更开放" ✓✓

· 缓存挂 → 多拒绝几个 ✓(保守) · MQ 挂 → 停止预扣(宁可卖不掉,不可超卖)✓✓ · 限流挂 → 库存预扣还在 ✓(最后防线)

5.5 落地清单:秒杀链路的 9 个检查点 ★★★ ​

★ 上线前逐条检查 ✓✓:

#检查点对应
①库存预扣是原子的吗?AtomicInteger / Redis DECR ✓
②"已抢完"标志会在抢完时置位吗?预扣到 0 时置位 ✓✓
③消息发的是事件吗?OrderCreated 而非指令 ✓
④消费端幂等吗?唯一索引 on orderId ✓✓
⑤数据库扣减带条件吗?WHERE available_copies > 0 ✓✓✓
⑥发送失败有兜底吗?本地消息表 ✓
⑦限流阈值按容量定了吗?压测数据 ✓
⑧每个组件挂了有降级吗?5.4 的表 ✓
⑨有对账吗?预扣/落库/库存三方校验 ✓✓

6. 递进示例 ​

6.1 最小示例:抢完标志 ​

java
// 预扣到 0 时置位(写一次,之后所有请求读内存/缓存即可)
if (reserved.updateAndGet(cur -> cur > 0 ? cur - 1 : cur) == 0) {
    soldOutFlag.set(true);        // ★ 从此以后连预扣都不用做
}

6.2 业务示例:发事件 ​

java
// 预扣成功后:
messageTable.save(new MessageRecord(
    "order-" + orderId, "OrderCreated",
    JSON.toJSONString(new OrderCreatedEvent(orderId, bookId, cardNo))));   // 事件 ✓

6.3 反例:缓存库存精确值 ​

java
// ✗ 把库存写进缓存当真相源
redis.set("stock:1", String.valueOf(dbStock));
int stock = Integer.parseInt(redis.get("stock:1"));   // 与数据库不一致 → 超卖 ✗✗

7. 完整可运行代码与实测 ​

代码:labs/project-b(SeckillLesson)

运行:

powershell
cd labs
.\mvnw.cmd -pl project-b compile
java "-Dstdout.encoding=UTF-8" -cp project-b/target/classes cn.kb.learn.seckill.SeckillLesson

★ 实测结论回顾 ✓:三种方案均未超卖;异步方案数据库只写 100 次(=成功数)✓✓✓

8. Debug 观察点 ​

  1. 把"已抢完"标志去掉:观察预扣 QPS 是否下降(每次都要做原子操作)✓;
  2. 把事件改成指令:观察消费端是否需要 if-else 判断动作类型 ✗;
  3. 人为让 MQ 发送失败:观察用户拿到"受理"但永远没有结果 ✗✗(本地消息表兜底后消失);
  4. 压测时去掉限流:观察预扣层是否成为唯一防线 ✓。

9. 常见错误 ​

#错误说明
1缓存库存精确值当真相源 ✗✗超卖风险 ✓✓
2消息发指令而非事件 ✗✗重试不安全、扩展难 ✓✓
3消费端不幂等 ✗✗重复下单 ✓✓
4发送失败无兜底 ✗✗用户白等 ✓
5限流阈值拍脑袋 ✗不是误杀就是漏放 ✓
6组件挂了没有降级 ✗✗雪崩 ✓
7不做对账 ✗✗差异无人知晓 ✓✓

10. 动手实验 ​

目标:给秒杀链路做一次"组件岗位审计"。

步骤:

  1. 画出你的链路图,标注每个组件的岗位 ✓✓;
  2. 逐个组件做故障演练(挂掉它),观察降级行为 ★★★;
  3. 把指令改成事件,新增一个"发短信"消费者(不改下单代码)✓✓;
  4. 写对账脚本:预扣数 = 落库数 + 失败数? ★★★;
  5. 压测验证限流阈值(T2-003 方法)✓。

验收方式:能说出"哪个组件挂了会发生什么、你的降级是什么"。

扩展任务:处理热点问题:

  • 一个超级爆款占满所有预扣 ✗(其他商品饿死)✗✗;
  • 方案:按商品分段预扣(分片库存)✓;
  • 说明:分段会破坏"总库存"的严格性(各段独立),怎么权衡 ★★

11. 练习题 ​

  1. 判断:把库存精确值放进缓存可以提升查询性能,应该这么做。(对 / 错)
  2. 填空:消息应发 ______ 而不是 ______,因为前者天然支持 ______。
  3. 简答:为什么说"库存预扣本身就是最好的限流器"?
  4. (场景判断)MQ 挂了,预扣成功的 5000 个用户怎么办?
  5. (设计)给"抢购结果查询"设计缓存:结果一旦确定不会变。
参考答案(要点)
  1. 错 ✗✗。缓存与库不一致时可能超卖 ✗✗ —— 缓存只放「错了也安全」的数据(如"已抢完"标志)✓✓★
  2. 事件;指令;消费者幂等与新增订阅者 ✓
  3. 它的阈值就是业务语义本身(库存 = 100 → 第 101 个起必拒)✓✓ —— 不需要配置、不会误杀、不会漏放 ✓✓ —— 通用限流器挡异常流量,它挡正常洪峰 ✓
  4. 要点:① 预扣要回滚(否则库存被占死)✓✓; ② 本地消息表记录"已预扣未落库",MQ 恢复后补偿 ✓; ③ 降级开关:MQ 不可用时停止预扣(宁可卖不掉,不可超卖)✓✓; ④ 前端提示"系统繁忙" ✓
  5. 要点:结果确定后写缓存,TTL 可以很长(不会变 ✓)✓✓; key = orderId;未确定时不缓存(或短 TTL + 轮询)✓; 注意:缓存的是"结果",不是"进度" ✓

12. 掌握标准 ​

  • [ ] 能说清三个组件的岗位 ★★★;
  • [ ] 知道缓存只放"错了也安全"的数据 ★★★;
  • [ ] 知道消息发事件不发指令 ★★★;
  • [ ] 知道预扣库存是业务语义的限流器 ★★★;
  • [ ] 能说出每个组件的降级方案,且降级是"更保守" ★★★;
  • [ ] 会做 9 个检查点的上线审计 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《项目 B:性能测试与故障分析》:把压测数据变成故障复盘。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引