Skip to content

项目 B:高并发场景与架构设计 ​

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

1. 本节定位 ​

项目 A 的矛盾是并发安全(不能超借);项目 B 的矛盾升级为高并发吞吐:

text
场景:100 件库存,5000 个请求(200 并发)抢购
矛盾:瞬时流量远超存储层的处理能力 ✗✗

★ 本节的实测(同一场景、三种方案) ✓✓✓:

text
                 成功  拒绝    墙钟       P50      P99    「数据库」写
① 无保护直扣      100    0   8905 ms     3 ms  1270 ms   5000 次 ✗✗
② 限流 300 QPS    100  4581    753 ms     0 ms   509 ms    419 次 ✓✓
③ 异步削峰        100  4900     72 ms     0 ms     0 ms    100 次 ✓✓✓

★ 墙钟从 8.9 秒降到 0.07 秒(约 124 倍)—— 而库存始终不多不少 ✓✓

2. 学习目标 ​

  • 能识别高并发场景的核心矛盾(瞬时流量 vs 存储能力) ★★★;
  • 能画出秒杀的分层防护架构 ★★★;
  • 理解"限流的本质是部分失败换整体存活" ★★★;
  • 理解"异步削峰"的本质是把写库洪峰摊平 ★★★;
  • 知道四条必须配套的正确性保障 ★★★。

3. 前置知识检查 ​

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

★ 先看无保护时发生了什么(实测第 1 节)✗✗:

text
  无保护直扣:成功 100 / 失败 4900
  响应 P50=3 ms  P99=1270 ms   ← 失败的 4900 人等了 1 秒多才知道失败 ✗✗
  墙钟 8905 ms(约 9 秒才处理完)
  「数据库」写 5000 次,忙碌 98%   ← 它满负荷了,毫无余力 ✗✗

★ 问题不在"慢",而在三点 ✗:

  1. 4900 个注定失败的请求,每个都打到了数据库 ✗✗(浪费 98% 的写能力)
  2. 失败的响应要 1 秒多(用户体验极差)✗
  3. 数据库满负荷——这时候任何一点额外流量都会压垮它(雪崩前夜)✗✗

5. 核心概念 ​

5.1 高并发的本质 ★★★ ​

★★ 一句话(这一页要背下来) ✓✓✓:

高并发的本质不是「让每层都变快」,

而是「让流量在到达昂贵资源之前被层层消化」 ✓✓✓

★ "昂贵资源"在本场景就是数据库写 ✓:1 次写 = 1 ms(串行锁下每秒最多约 1000 次), 而流量是 5000 个请求同时来 ✗ —— 差 5 倍 ✓

5.2 秒杀的分层防护架构 ★★★ ​

text
流量 → 【CDN/静态化】→【网关限流】→【内存预扣库存】→【MQ 削峰】→【数据库原子扣减】
层挡住什么手段
CDN/静态化 ★★静态请求(图片/页面)根本不回源 ✓✓
网关限流 ★★★超出容量的流量令牌桶/滑动窗口(《Spring Cloud 核心组件》)
内存预扣库存 ★★★★大部分无效请求原子操作,挡在数据库前 ✓✓✓
MQ 削峰 ★★★写库的瞬时洪峰异步 + 消费者按能力消费(《消息队列基础与选型》)
数据库原子扣减 ★★★★最后的正确性防线条件 UPDATE(项目 A 已验证)

★ 核心思想 ✓✓:每一层都"消化"掉一部分流量,让到达下一层的量 = 下一层能承受的量 ✓

5.3 实测:三种方案的对比 ★★★ ​

指标① 无保护② 限流③ 异步削峰
成功100100100
拒绝(限流)045814900
墙钟8905 ms ✗✗753 ms ✓72 ms ✓✓✓
P503 ms0 ms0 ms
P991270 ms509 ms0 ms
数据库写5000 次 ✗✗419 次100 次(只写成功的)✓✓✓

★ 三条解读 ✓✓:

#解读
①限流把 4581 个请求挡在了数据库前面 —— 数据库写从 5000 降到 419 ✓✓
②异步把数据库写降到 100 次 —— 只写成功的(预扣挡住了全部无效请求)✓✓✓
③墙钟约 124 倍的差距,全部来自"无效请求不再消耗数据库" ✓

5.4 限流的本质:部分失败换整体存活 ★★★ ​

实测对比 ✓✓:

不限流限流
注定失败的 4900 人等 3 秒才失败 ✗✗0 ms 立刻失败 ✓
成功的 100 人P50 463 ms ✗P50 ≈ 0 ms ✓
数据库满负荷 98% ✗✗有余力 ✓

★★ 一条反直觉的认识 ✓✓✓:

拒绝是好事 ✓✓

快速失败让用户立刻看到"已抢完/稍后再试", 而不是转圈 30 秒后失败 ✗✗

这就是"用部分用户失败,换系统整体存活" ✓✓✓

★★★ 本项目还踩到一个真实的实验设计坑 ✓✓:

第一版把模拟写库设为 5 ms —— 数据库串行锁把请求到达速率压到了约 200/s, 比令牌桶的 300/s 还低 → 限流器一个请求都没拦到(拒绝数 = 0) ✗✗

教训:限流器必须放在真正的洪峰处,否则它什么也保护不了 ✓✓✓

把模拟写库改成 1 ms 后,到达速率远超 300/s,限流立刻生效 ✓

5.5 异步削峰:把写库洪峰摊平 ★★★ ​

实测 ✓✓✓:

text
  异步削峰:P50=0 ms / P99=0 ms / 数据库写 100 次 / 墙钟 101 ms

★ 为什么能做到"0 ms" ✓✓:

请求只做两件事: ① 内存预扣库存(AtomicInteger,纳秒级)✓ ② 把落库任务丢进队列,立刻返回 ✓✓

落库由消费者线程按自己的节奏完成 —— 洪峰被摊平 ✓✓✓

★ 代价 ✓:

用户拿到的是"受理",不是"成功" ✓ ——最终一致(《消息可靠性、幂等与顺序》)

这正是所有秒杀"先返回排队中、后通知结果"的原因 ✓✓

5.6 四条必须配套的正确性保障 ★★★ ​

★ 缺一个就会出事故 ✗✗:

#保障对应
①预扣库存必须原子AtomicInteger / Redis DECR(《缓存穿透、击穿与雪崩》)✓✓
②消息消费必须幂等唯一索引(《消息可靠性、幂等与顺序》)✓✓
③数据库扣减必须带条件available_copies > 0(防 MQ 重复消费超卖)✓✓✓
④对账兜底预扣数 vs 落库数 vs 库存数,每天校验 ✓✓

★ 第 ③ 条最关键 ✓✓✓:

预扣(内存)和落库(数据库)是两个世界 ✗ —— MQ 至少一次投递必然重复 ✗ —— 数据库的条件 UPDATE 是防止"重复消费导致超卖"的最后防线 ✓✓✓

6. 递进示例 ​

6.1 最小示例:内存预扣 ​

java
AtomicInteger reserved = new AtomicInteger(STOCK);
// 每个请求:
int remain = reserved.updateAndGet(cur -> cur > 0 ? cur - 1 : -1);
if (remain < 0) { return Result.soldOut(); }   // ★ 纳秒级拒绝 ✓✓
queue.submit(() -> db.tryDeduct());            // 落库异步化
return Result.accepted();

6.2 业务示例:真实秒杀的层次 ​

见 5.2 ✓

6.3 反例:把限流放在数据库之后 ​

java
boolean ok = db.tryDeduct();          // ✗ 先打数据库
if (!limiter.tryAcquire()) { ... }    // ✗ 限流器形同虚设

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

代码:labs/project-b/src/main/java/cn/kb/learn/seckill/SeckillLesson.java

运行:

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

实测输出(关键部分) ✓:

text
===== 1. 无保护 =====
  无保护直扣        成功 100   拒绝 0     失败 4900
    响应 P50=3 ms  P99=1270 ms  墙钟=8905 ms
    「数据库」写 5000 次,忙碌 98% ✗✗
    剩余库存:0 ✓ 未超卖

===== 2. 限流 300 QPS =====
  限流 300 QPS      成功 100   拒绝 4581  失败 319
    响应 P50=0 ms  P99=509 ms  墙钟=753 ms
    「数据库」写 419 次

===== 3. 异步削峰 =====
  异步削峰          成功 100   拒绝 4900  失败 0
    响应 P50=0 ms  P99=0 ms  墙钟=72 ms
    「数据库」写 100 次 ✓✓✓
    剩余库存:0 ✓ 未超卖

8. Debug 观察点 ​

  1. 把令牌桶速率改成 50 QPS:观察成功数是否下降(限流太狠会误杀真实用户)⚠️;
  2. 去掉内存预扣,只用 MQ:观察数据库写次数暴涨(MQ 里全是无效请求)✗✗;
  3. 把数据库 sleep 改成 5 ms:复现"限流器失效"(到达速率被压低)✓✓;
  4. 去掉条件 UPDATE 的 WHERE:观察是否会超卖 ✗✗(MQ 重复消费时)。

9. 常见错误 ​

#错误说明
1以为"加机器"就能解决秒杀 ✗✗瓶颈在串行资源(库存行)✓
2限流器不在洪峰处 ✗✗本项目实测:形同虚设 ✓✓
3只有 MQ 没有内存预扣 ✗✗数据库写 = 全部请求 ✓
4预扣不原子 ✗✗超卖 ✓✓
5消费端不幂等 ✗✗MQ 重复消费 → 重复下单 ✓✓
6数据库扣减不带条件 ✗✗✗最后防线失守 → 超卖 ✓✓✓
7不做对账 ✗✗预扣与落库对不上没人知道 ✓

10. 动手实验 ​

目标:亲手把秒杀链路调到"高吞吐且不超卖"。

步骤:

  1. 跑 SeckillLesson,记录三组数据 ✓✓;
  2. 把令牌桶改成 50 QPS,观察成功数变化(思考阈值怎么定)★★;
  3. 去掉内存预扣,对比数据库写次数 ★★★;
  4. 把消费端改成"无条件扣减",构造 MQ 重复消费场景,验证是否超卖 ★★★;
  5. 设计对账逻辑:预扣数、落库数、库存数三者怎么校验 ✓✓。

验收方式:能画出你的秒杀分层图,并说清每层挡住什么。

扩展任务:处理"下单超时未支付":

  • 预扣了库存但用户不支付 → 库存被白白占用 ✗✗;
  • 方案:延迟消息(《RabbitMQ 核心概念与工作模式》 的 TTL+DLQ)回滚库存 ✓;
  • 说明:为什么"定时扫表"在秒杀量级下不行(提示:扫描本身也是压力)★★

11. 练习题 ​

  1. 判断:秒杀系统的关键是把数据库写得越快越好。(对 / 错)
  2. 填空:秒杀分层从外到内是 、、、、______。
  3. 简答:为什么"拒绝是好事"?
  4. (判断)
java
int remain = reserved.decrementAndGet();
if (remain < 0) { return 拒绝; }

这样写有什么问题? 5. (设计)你的秒杀系统上线后,监控发现"预扣 10 万次但落库只有 100 次",怎么排查?

参考答案(要点)
  1. 错 ✗✗。关键是让无效请求不消耗数据库 ✓ —— 写得再快也扛不住 5000 并发(本机实测:5000 次串行写 = 14 秒)✓★
  2. CDN/静态化;网关限流;内存预扣库存;MQ 削峰;数据库原子扣减 ✓✓
  3. ① 快速失败对用户体验更好(0 ms vs 3 秒)✓; ② 挡住的请求不消耗数据库(数据库有余力服务真实用户)✓✓; ③ 系统整体存活(不至于雪崩)✓✓★
  4. decrementAndGet 会把库存扣成负数 ✗✗ —— 虽然后续判断能拒绝,但预扣计数已经不准(-1、-2……累积)✗ —— 要用 updateAndGet(cur -> cur > 0 ? cur - 1 : cur) 这种失败不递减的写法 ✓✓★
  5. 要点:① 看消费者是否在消费(队列积压)✓; ② 看消费失败率(库存不足被拒?幂等冲突?)✓; ③ 看预扣与落库的差值是否等于"预扣后从未落库"的数量 ✗✓; ④ 判断:10 万预扣 vs 100 落库 = 差 9.99 万 —— 大概率是"预扣后没有发消息"或"消息全丢了" ✗✗ —— 这就是对账兜底的意义 ✓✓★

12. 掌握标准 ​

  • [ ] 能识别高并发的核心矛盾 ★★★;
  • [ ] 能画出秒杀的五层防护架构 ★★★;
  • [ ] 理解"拒绝是好事" ★★★;
  • [ ] 理解异步削峰把写库洪峰摊平 ★★★;
  • [ ] 能说出四条正确性保障 ★★★;
  • [ ] 知道限流器必须放在真正的洪峰处 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《项目 B:缓存、消息与限流落地》:把 S4 学的三个组件在秒杀场景里用对。

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

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