主题
界面
主题
《项目 B:高并发场景与架构设计》画出了五层架构;本节回答一个更难的问题:
S4 学的缓存、消息、限流,在秒杀场景里「分别缓存什么、异步什么、限流哪里」?
—— 每个组件都有明确的岗位,放错位置就白做 ✗✗★ 三个组件的岗位 ✓✓:
| 组件 | 岗位 | 不岗位化会怎样 |
|---|---|---|
| 缓存 ★★★ | 挡住"读"(库存查了又查) | 每次都查库 ✗ |
| 消息 ★★★ | 摊平"写"(下单洪峰) | 写库洪峰 ✗✗ |
| 限流 ★★★ | 挡住"进"(超出容量的流量) | 全部进系统 ✗✗✗ |
★ 学了组件 ≠ 会用组件 ✗✗ —— 本节实测里就有两个反面案例:
案例 1:把「库存」放进缓存当真相源 ✗✗
缓存里的库存和数据库的库存不一致时,用户看到"有货"却下单失败 ✗
案例 2:消息发的是「扣减库存」指令 ✗
消费失败重试 → 重复扣减 ✗✗(应该发"事件",由消费者自己保证幂等)★ 秒杀链路里读什么最多 ✓:
| 读操作 | 频率 | 该放缓存吗 |
|---|---|---|
| 商品详情(名称/图片/价格)★★★ | 极高(每个进入秒杀页的人) | ✓ 缓存(热点数据) |
| 是否已抢完(一个标志位)★★★ | 极高 | ✓ 缓存/内存标志(抢完立刻置位) |
| 剩余库存的精确值 ★★ | 高 | ⚠️ 不建议(见下) |
| 用户的下单结果 ★ | 每人一次 | ✗ 不缓存(要最终一致) |
★★ 关键判断:库存的"精确值"不能放缓存当真相源 ✗✗:
缓存与数据库是两个世界(《缓存一致性与更新策略》 的一致性问题)✗
缓存说"还剩 3 件"、数据库说"0 件" → 用户看到有货、下单失败 ✗✗
正确做法:缓存里放「是否已抢完」的布尔标志(抢完置位,永不回退)✓✓ —— 它不一致的代价是"多拒绝几个人"(安全方向)✓, 而"精确库存"不一致的代价是"超卖"(危险方向)✗✗
★ 一条通用法则 ✓✓✓:
缓存只能缓存「错了也安全」的数据 ✓
· "已抢完 = true" 错了 → 多拒绝,安全 ✓ · "库存 = 3" 错了 → 超卖,事故 ✗✗
★ 秒杀链路里异步什么 ✓:
请求(预扣成功)→ 发消息【OrderCreated 事件】→ 消费者:落库 + 通知 + 积分★★ 发"事件"而不是"指令" ✓✓:
| 指令(✗) | 事件(✓) | |
|---|---|---|
| 内容 | "扣减库存 book=1" | "OrderCreated{orderId=9527, bookId=1}" |
| 语义 | 告诉你做什么 | 告诉你发生了什么 ✓ |
| 重试安全性 | 消费者重复扣 ✗✗ | 消费者按 orderId 幂等 ✓✓ |
| 新增消费方 | 要发新消息 ✗ | 订阅同一事件即可 ✓✓✓ |
★ 事件驱动的好处 ✓✓:下单后要加积分?加个订阅者就行,下单代码一行不改 ✓(呼应 《设计模式在 Spring 中的应用》 开闭原则)
★★ 消息的三条可靠性纪律(《消息可靠性、幂等与顺序》) ✓✓:
| # | 纪律 | 本场景 |
|---|---|---|
| ① | 发送要确认(至少一次) | 预扣成功但消息发失败 = 用户白等 ✗✗ —— 要本地消息表兜底 |
| ② | 消费要幂等 | MQ 必然重复投递 → 唯一索引 on orderId ✓✓ |
| ③ | 失败要有 DLQ | 消费失败进死信 + 告警 ✓ |
★ 本项目实测的教训 ✗✗(《项目 B:高并发场景与架构设计》):
模拟库 5 ms 串行 → 请求到达速率只有 200/s → 令牌桶 300/s 一个都没拦到 ✗✗
限流器必须放在真正的洪峰处 ✓✓✓
★ 阈值怎么定 ✓✓:
| 层 | 阈值依据 |
|---|---|
| 网关层 | 压测出的系统总容量(T2-003)× 70~80% ✓✓ |
| 库存预扣 | 库存数本身(预扣完就关闭,天然限流 ✓) |
| 单用户 | 防刷(如 1 秒 5 次)✓ |
★★ 一条容易忽略的 ✓✓:
预扣库存本身就是最好的限流器 ✓✓✓
库存 100 件 → 第 101 个请求起全部被拒 —— 它限流的精度是"业务语义"的,比任何通用限流器都精准 ✓✓
所以秒杀链路里,通用限流挡住异常流量,库存预扣挡住正常洪峰 ✓
★ 每个组件都会挂,挂了怎么办必须提前想好 ✓✓:
| 组件挂了 | 影响 | 降级方案 |
|---|---|---|
| 缓存挂 ★★ | 读穿透到库 | "已抢完"标志放本地内存(多实例各一份,可接受)✓ |
| MQ 挂 ★★★ | 预扣成功但下单丢失 | 本地消息表(《Seata 分布式事务》)+ 定时补偿 ✓✓ |
| 限流器挂 ★★ | 流量全进 | 库存预扣天然限流(第 5.3 节)✓✓ ——所以它是最后防线 |
★★ 一条总体原则 ✓✓:
降级后的行为必须是"更保守",而不是"更开放" ✓✓
· 缓存挂 → 多拒绝几个 ✓(保守) · MQ 挂 → 停止预扣(宁可卖不掉,不可超卖)✓✓ · 限流挂 → 库存预扣还在 ✓(最后防线)
★ 上线前逐条检查 ✓✓:
| # | 检查点 | 对应 |
|---|---|---|
| ① | 库存预扣是原子的吗? | AtomicInteger / Redis DECR ✓ |
| ② | "已抢完"标志会在抢完时置位吗? | 预扣到 0 时置位 ✓✓ |
| ③ | 消息发的是事件吗? | OrderCreated 而非指令 ✓ |
| ④ | 消费端幂等吗? | 唯一索引 on orderId ✓✓ |
| ⑤ | 数据库扣减带条件吗? | WHERE available_copies > 0 ✓✓✓ |
| ⑥ | 发送失败有兜底吗? | 本地消息表 ✓ |
| ⑦ | 限流阈值按容量定了吗? | 压测数据 ✓ |
| ⑧ | 每个组件挂了有降级吗? | 5.4 的表 ✓ |
| ⑨ | 有对账吗? | 预扣/落库/库存三方校验 ✓✓ |
// 预扣到 0 时置位(写一次,之后所有请求读内存/缓存即可)
if (reserved.updateAndGet(cur -> cur > 0 ? cur - 1 : cur) == 0) {
soldOutFlag.set(true); // ★ 从此以后连预扣都不用做
}// 预扣成功后:
messageTable.save(new MessageRecord(
"order-" + orderId, "OrderCreated",
JSON.toJSONString(new OrderCreatedEvent(orderId, bookId, cardNo)))); // 事件 ✓// ✗ 把库存写进缓存当真相源
redis.set("stock:1", String.valueOf(dbStock));
int stock = Integer.parseInt(redis.get("stock:1")); // 与数据库不一致 → 超卖 ✗✗代码:labs/project-b(SeckillLesson)
运行:
cd labs
.\mvnw.cmd -pl project-b compile
java "-Dstdout.encoding=UTF-8" -cp project-b/target/classes cn.kb.learn.seckill.SeckillLesson★ 实测结论回顾 ✓:三种方案均未超卖;异步方案数据库只写 100 次(=成功数)✓✓✓
| # | 错误 | 说明 |
|---|---|---|
| 1 | 缓存库存精确值当真相源 ✗✗ | 超卖风险 ✓✓ |
| 2 | 消息发指令而非事件 ✗✗ | 重试不安全、扩展难 ✓✓ |
| 3 | 消费端不幂等 ✗✗ | 重复下单 ✓✓ |
| 4 | 发送失败无兜底 ✗✗ | 用户白等 ✓ |
| 5 | 限流阈值拍脑袋 ✗ | 不是误杀就是漏放 ✓ |
| 6 | 组件挂了没有降级 ✗✗ | 雪崩 ✓ |
| 7 | 不做对账 ✗✗ | 差异无人知晓 ✓✓ |
目标:给秒杀链路做一次"组件岗位审计"。
步骤:
验收方式:能说出"哪个组件挂了会发生什么、你的降级是什么"。
扩展任务:处理热点问题:
《项目 B:性能测试与故障分析》:把压测数据变成故障复盘。