主题 16场景题与项目追问
前面 15 章都是「知识点」,这一章是「怎么把它们串起来答」。 场景题没有标准答案,但有标准推导过程——面试官要听的是你怎么一步步排除掉错误选项。
这一章仍然只讲实测过的:库存 10、100 个并发下单, 「先查再改」跑出 100 单成功、库存只扣了 6;换成一条带条件的 SQL 之后, 稳定 10 单、库存 0。这组数字就是「防超卖」这道题的全部底气。
16.1场景题在考什么
「设计一个秒杀系统」这种题,面试官心里有三条线,答满第一条只是及格:
| # | 层次 | 不合格 | 合格 |
|---|---|---|---|
| 1 | 能给出方案 | 只会说「加锁」 | 能说出「数据库行锁 / Redis 预扣 / 消息队列削峰」三种手段的取舍 |
| 2 | 知道边界在哪 | 答「用了 Redis 就不会超卖」 | 能说出「Redis 扣减成功但 DB 失败怎么办」 |
| 3 | 有数据支撑 | 「性能会好很多」 | 「实测 100 并发下条件更新稳定 10 单,先查再改跑出 97 单」 |
seckill_lab,
表 stock(id,num,version) 初始库存 10、orders(id,uid)。
100 个线程用 Event 门闸对齐后同时下单。脚本 seckill_lab.py。另外雪花算法用 JDK 17 单独跑
Snowflake.java。
16.2防超卖:三种写法,同一份并发
库存 10,100 个人同时抢。三种写法各跑一遍:
=== 100 个并发,库存 10 ===
A 先查再改 下单成功 100 单,订单表 100 条,剩余库存 4,耗时 1317 ms
B 条件更新 下单成功 10 单,订单表 10 条,剩余库存 0,耗时 2047 ms
C 乐观锁 version 下单成功 10 单,订单表 10 条,剩余库存 0,耗时 7130 ms
再跑一次(并发结果会随调度变化,但结论不变):
A 先查再改 下单成功 97 单,订单表 97 条,剩余库存 0,耗时 1513 ms
B 条件更新 下单成功 10 单,订单表 10 条,剩余库存 0,耗时 7133 ms
C 乐观锁 version 下单成功 10 单,订单表 10 条,剩余库存 0,耗时 4722 ms
A 的错法为什么这么典型
select num from stock where id=1; -- 读到 10
if (num > 0) { -- 10 > 0,成立
// ... 业务处理 2ms ...
update stock set num = 9 where id=1;
}
100 个线程都读到了 10,都判断成立,都下单,最后都写回 9。 结果:订单 100 条,库存只从 10 变成 4——卖了 100 件,账上只扣了 6 件。
B 为什么一条 SQL 就够
update stock set num = num - 1 where id = 1 and num > 0;
两个关键点:
num = num - 1而不是num = 9——在数据库里算,不在应用里算, 不存在「用旧值覆盖新值」;and num > 0——把判断和更新合成一个原子操作。 InnoDB 执行时会加行锁(12 章实测过X,REC_NOT_GAP), 并发的 update 排队执行,每个都能读到前一个的结果。
返回的影响行数 n == 1 就代表抢到,n == 0 就代表没库存。不需要再查一次。
C 为什么反而慢
乐观锁的写法是「读版本号 → 带版本号更新 → 影响行数为 0 就重试」:
select num, version from stock where id=1; -- num=10, version=0
update stock set num=num-1, version=version+1
where id=1 and version=0; -- 只有一个线程能成功
它不会超卖(结果和 B 一样是 10 单),但耗时 4722~7130ms,比 B 慢一倍以上。 因为 100 个线程里 90 个都要重试最多 50 次,每次重试都是一次完整往返。 冲突越激烈,乐观锁越吃亏——这正是它叫「乐观」的代价:它假设冲突很少。
| 方案 | 实测结果 | 原理 | 适合 | 坑 |
|---|---|---|---|---|
| A 先查再改 | 100 单(超卖 90) | 无任何保护 | 绝不能用于扣减 | 旧值覆盖新值 |
| B 条件更新 | 10 单,库存 0 | 行锁 + 判断合一 | 绝大多数场景 | 热点行会成为瓶颈 |
| C 乐观锁 | 10 单,但慢 2 倍 | 版本号 CAS | 冲突少的更新 | 高冲突下重试风暴 |
① Redis 预扣:
DECR 是原子的,先在 Redis 把库存扣到 0,
再异步落库。13 章的 SET NX PX 和 Lua 就是干这个的。但它引入了新问题:
Redis 扣成功、DB 写失败怎么办——要么补偿,要么用消息队列保证最终一致;② 消息队列削峰:下单请求先入队,后端按自己的能力消费。 它解决的是「瞬时洪峰」,不是「超卖」——超卖还是得靠 B 那条 SQL 兜底。
16.3分布式 ID:雪花算法的 340 万 QPS 和两个坑
单机自增主键在分库分表之后就不唯一了,所以需要分布式 ID。雪花算法是最常见的答案, 它的结构是 64 位 = 1 位符号 + 41 位时间 + 10 位机器 + 12 位序列:
| 段 | 位数 | 能表示多少 | 换算 |
|---|---|---|---|
| 符号位 | 1 | 恒为 0 | 保证是正数 |
| 时间戳 | 41 | 2^41 毫秒 | 约 69 年 |
| 机器 | 10 | 2^10 | 1024 台(5 位机房 + 5 位机器) |
| 序列 | 12 | 2^12 | 每毫秒 4096 个 |
实测生成 100 万个:
生成 1000000 个 ID,用时 294 ms,去重后 1000000 个,QPS 340 万
拆解一个 ID:时间戳 1793863431321,数据中心 1,机器 1,序列 0
原值 393691765443530752(18 位十进制,long 能装下)
340 万 QPS,去重后还是 100 万——但这已经接近理论上限了:
4096 × 1000 = 409.6 万/秒。超过这个数,序列号就会在同一毫秒内绕回 0,
代码里必须 while (ts <= lastTs) ts = now() 等到下一毫秒,否则就撞 ID。
坑一:时钟回拨
服务器对时(NTP)时,时钟可能往回跳几十毫秒。这时 now < lastTs,
如果不管,就会生成和之前重复的 ID。三种处理方式:
| 策略 | 做法 | 代价 |
|---|---|---|
| 直接抛异常 | 回拨就拒绝服务 | 短暂不可用,但最安全 |
| 等待追赶 | 小幅回拨(如 < 5ms)就自旋等到 lastTs | 最多卡几毫秒 |
| 备用 workerId | 回拨大就换一个机器号继续发 | 要预留号段(如 1~512 正常、513~1023 备用) |
我的实现用的是第二种(back > 5ms 就抛异常),代码里那三行就是全部:
if (ts < lastTs) {
long back = lastTs - ts;
if (back > 5) throw new IllegalStateException("时钟回拨 " + back + "ms");
ts = lastTs; // 小幅回拨:等在原地,等它追上来
}
坑二:机器号怎么分配
10 位机器号是写死的,两台机器配成同一个号,就会生成重复 ID—— 而且不会报错,只会在数据库主键冲突时才发现。
- 小规模:配置文件写死,部署时人工保证不重复;
- 中等规模:用 Zookeeper / etcd 的顺序节点自动分配;
- 云上:用Pod 序号或 IP 后两段取模,但要注意扩容后可能撞号。
趋势递增,不是严格递增。因为时间戳在高位,所以整体按时序增长; 但同一毫秒内不同机器的 ID 顺序不保证,而且不同机器的时钟可能有几毫秒偏差。 这对 MySQL 的 B+ 树足够友好(避免随机主键导致的页分裂,11 章讲过), 但不能拿它当「严格递增的排序键」用。
16.4幂等:三种方案,按成本排
「用户点了两次提交,怎么只下一单」——这题的关键是先说清「幂等键」是什么, 再说用什么存。
| # | 方案 | 幂等键 | 实现 | 适合 |
|---|---|---|---|---|
| 1 | 唯一索引 | 业务单号 | 表上加 unique key,重复插入报 1062 | 最简单可靠,一定先考虑 |
| 2 | Redis SETNX | token / 请求 ID | SET key 1 NX PX 60000 | 高并发、要快速失败 |
| 3 | 状态机 | 订单状态 | update ... where status = '待支付' | 状态流转类业务 |
第 1 条是兜底方案:不管前面用什么,数据库唯一索引都要加——
因为它是唯一不会被并发绕过的一层。第 2 条在 13.9 已经实测过:
SET lock uuidA NX PX 3000 第二次返回 None,而且必须带 value 用 Lua 释放。
不要删,让它靠 TTL 自然过期。因为删早了,用户的第二次请求还能进来; 删晚了顶多占一点内存。TTL 应该略大于「用户可能重复提交的最长时间窗」,通常 1~5 分钟。
16.5怎么讲项目:STAR 加三个数字
「介绍一下你的项目」这道题,最怕讲成流水账。用 STAR 但每一段都塞一个数字:
| 段 | 要讲什么 | 反例 → 正例 |
|---|---|---|
| S 背景 | 业务规模 + 你负责的边界 | 「做了个电商系统」→「日订单 3 万,我负责下单和库存模块」 |
| T 任务 | 一个具体的难点,不是「全部功能」 | 「负责后端开发」→「大促时库存扣减有超卖,要根治」 |
| A 行动 | 你试过什么、为什么换掉 | 「用了 Redis」→「先试了先查再改,压测 100 并发跑出 97 单超卖;换成条件更新后稳定 10 单」 |
| R 结果 | 可量化的结果 | 「效果不错」→「超卖归零,下单接口 p99 从 320ms 降到 180ms」 |
① QPS / 数据量:「这个接口峰值多少 QPS」「表里多少行」——答不上来会被认为没上线过;
② 优化前后的对比:「从多少降到多少」——只有「优化了」没有数字等于没做;
③ 你个人的贡献占比:「这个模块几个人做的,你做了哪部分」——团队项目最容易被问穿。
16.6速记卡与自查
| # | 卡 | 内容(带数字) |
|---|---|---|
| 1 | 防超卖 | 库存 10、100 并发:先查再改跑出 100 单(库存只扣了 6);
条件更新 where num > 0 稳定 10 单。
超卖不一定是负库存,常见形态是「订单数 ≫ 扣减量」。 |
| 2 | 乐观锁的代价 | 同样 10 单,但耗时 4722~7130ms,比条件更新慢一倍以上。 冲突越激烈,重试越亏。 |
| 3 | 雪花算法 | 64 位 = 1+41+10+12;每毫秒 4096 个,理论上限 409.6 万/秒, 实测 100 万个 294ms(340 万 QPS)。两个坑:时钟回拨、机器号撞车。 |
| 4 | 幂等的兜底 | 不管前面用 Redis 还是 token,数据库唯一索引必须加—— 它是唯一不会被并发绕过的一层。幂等键靠 TTL 过期,不要主动删。 |
动手清单与自查表
| # | 动手项 | 可观察的判据 |
|---|---|---|
| 1 | 跑 seckill_lab.py |
A 行显示近百单,B/C 行稳定 10 单 |
| 2 | 把 B 的 and num > 0 去掉再跑 |
库存变成 -90——这才是「负库存」的形态 |
| 3 | 把 C 的重试次数从 50 改成 1 | 成功单数骤降——乐观锁的正确性依赖重试 |
| 4 | 跑 Snowflake 1000000 |
去重后仍是 100 万;QPS 在 300~400 万之间 |
| 5 | 把 EPOCH 改到 41 年后的未来 |
时间戳段变成负数,ID 变成负数——41 位不是无限的 |
time.sleep(0.002) 删掉再跑,
你会发现超卖变少了甚至不超卖。因为窗口被压到几微秒,线程来不及交错。
这说明超卖是「概率问题」,不是「必然问题」——所以线上偶发一次,
复现不了,等你想查的时候早就过去了。这类 bug 只能靠设计规避,不能靠复现定位。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不确定会不会超卖 | 看扣减是不是「在数据库里算」(num = num - 1),不是就有风险 |
| 不确定锁有没有生效 | 12 章的 performance_schema.data_locks 看行锁;或直接压测看剩余库存 |
| ID 冲突了 | 先查两台机器的 workerId 配置,再查有没有时钟回拨日志 |
| 不确定幂等有没有生效 | 同一个幂等键发两次,看第二次是返回旧结果还是又下了一单 |