Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 16场景题与项目追问

前面 15 章都是「知识点」,这一章是「怎么把它们串起来答」。 场景题没有标准答案,但有标准推导过程——面试官要听的是你怎么一步步排除掉错误选项。

这一章仍然只讲实测过的:库存 10、100 个并发下单, 「先查再改」跑出 100 单成功、库存只扣了 6;换成一条带条件的 SQL 之后, 稳定 10 单、库存 0。这组数字就是「防超卖」这道题的全部底气。

学习目标
能把「防超卖」「分布式 ID」「幂等」三条讲成可落地的方案
前置要求
读过 11/12(索引与事务)、13(Redis)、08(并发)
本章产出
2 组实测 + 4 套方案对比 + 1 个项目讲述模板
建议学时
3 小时(超卖实验务必自己跑一遍)

16.1场景题在考什么

「设计一个秒杀系统」这种题,面试官心里有三条线,答满第一条只是及格:

#层次不合格合格
1能给出方案只会说「加锁」能说出「数据库行锁 / Redis 预扣 / 消息队列削峰」三种手段的取舍
2知道边界在哪答「用了 Redis 就不会超卖」能说出「Redis 扣减成功但 DB 失败怎么办」
3有数据支撑「性能会好很多」「实测 100 并发下条件更新稳定 10 单,先查再改跑出 97 单」
实验环境:MySQL 8.0.44(InnoDB),库 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 件。

注意这个细节:A 的剩余库存是 4,不是负数。 很多人的直觉是「超卖 = 库存变负」,但更常见的形态是「订单数远超库存扣减量」—— 因为每次 update 都是「用我读到的旧值减 1」,后写的覆盖先写的。 面试时能说出这一点,比说「会超卖」具体得多。

B 为什么一条 SQL 就够

update stock set num = num - 1 where id = 1 and num > 0;

两个关键点:

返回的影响行数 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保证是正数
时间戳412^41 毫秒约 69 年
机器102^101024 台(5 位机房 + 5 位机器)
序列122^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—— 而且不会报错,只会在数据库主键冲突时才发现。

面试常挖的第三问:「雪花 ID 有序吗?」
趋势递增,不是严格递增。因为时间戳在高位,所以整体按时序增长; 但同一毫秒内不同机器的 ID 顺序不保证,而且不同机器的时钟可能有几毫秒偏差。 这对 MySQL 的 B+ 树足够友好(避免随机主键导致的页分裂,11 章讲过), 但不能拿它当「严格递增的排序键」用。

16.4幂等:三种方案,按成本排

「用户点了两次提交,怎么只下一单」——这题的关键是先说清「幂等键」是什么, 再说用什么存。

#方案幂等键实现适合
1唯一索引业务单号表上加 unique key,重复插入报 1062最简单可靠,一定先考虑
2Redis SETNXtoken / 请求 IDSET key 1 NX PX 60000高并发、要快速失败
3状态机订单状态update ... where status = '待支付'状态流转类业务

第 1 条是兜底方案:不管前面用什么,数据库唯一索引都要加—— 因为它是唯一不会被并发绕过的一层。第 2 条在 13.9 已经实测过: SET lock uuidA NX PX 3000 第二次返回 None,而且必须带 value 用 Lua 释放。

一个常被追问的细节:「Redis 的幂等键什么时候删?」
不要删,让它靠 TTL 自然过期。因为删早了,用户的第二次请求还能进来; 删晚了顶多占一点内存。TTL 应该略大于「用户可能重复提交的最长时间窗」,通常 1~5 分钟。

16.5怎么讲项目:STAR 加三个数字

「介绍一下你的项目」这道题,最怕讲成流水账。用 STAR 但每一段都塞一个数字:

段要讲什么反例 → 正例
S 背景业务规模 + 你负责的边界「做了个电商系统」→「日订单 3 万,我负责下单和库存模块」
T 任务一个具体的难点,不是「全部功能」「负责后端开发」→「大促时库存扣减有超卖,要根治」
A 行动你试过什么、为什么换掉「用了 Redis」→「先试了先查再改,压测 100 并发跑出 97 单超卖;换成条件更新后稳定 10 单」
R 结果可量化的结果「效果不错」→「超卖归零,下单接口 p99 从 320ms 降到 180ms」
三个必须准备的数字(面试官一定会追问):
① QPS / 数据量:「这个接口峰值多少 QPS」「表里多少行」——答不上来会被认为没上线过;
② 优化前后的对比:「从多少降到多少」——只有「优化了」没有数字等于没做;
③ 你个人的贡献占比:「这个模块几个人做的,你做了哪部分」——团队项目最容易被问穿。
一个实用的准备方法:把项目里每一个「技术选型」都写成一句话的对比—— 「为什么用 Redis 不用本地缓存」→「因为要多个实例共享库存,本地缓存会各自为政」。 面试官不关心你用了什么,只关心你排除掉了什么、为什么。 这一章前面所有的实测数据,作用就是让你在每个「为什么」后面都能接一个数字。

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 过期,不要主动删。
最后一句总结(也是整个教程的总结):这 16 个主题里, 真正让答案「有分量」的从来不是名词,是数字和自己踩过的坑。 「volatile 保证可见性」是背的;「不加 volatile 的循环 5 次全卡死、自旋计数是 0」是自己的。 面试官分辨这两者的能力,比你想象得强得多。

动手清单与自查表

#动手项可观察的判据
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 位不是无限的
反例实验(做了会记一辈子):把 A 方案里的 time.sleep(0.002) 删掉再跑, 你会发现超卖变少了甚至不超卖。因为窗口被压到几微秒,线程来不及交错。 这说明超卖是「概率问题」,不是「必然问题」——所以线上偶发一次, 复现不了,等你想查的时候早就过去了。这类 bug 只能靠设计规避,不能靠复现定位。
卡住了怎么办去哪查
不确定会不会超卖看扣减是不是「在数据库里算」(num = num - 1),不是就有风险
不确定锁有没有生效12 章的 performance_schema.data_locks 看行锁;或直接压测看剩余库存
ID 冲突了先查两台机器的 workerId 配置,再查有没有时钟回拨日志
不确定幂等有没有生效同一个幂等键发两次,看第二次是返回旧结果还是又下了一单