跳转至

Java 后端工程化 · 深度

系统设计与复杂事故复盘题:大促交易链路、ThreadLocal 泄漏事故、不停机分片扩容、消息积压治理、P99 归因分解、分布式事务补偿。

Q1: 请设计一个万级 QPS 的大促交易链路,如何用线程池隔离 + 多级缓存 + 熔断保 99.99% 可用?

深度 | Java后端 | 📖 相关讲解

考察点:能否从入口到存储完整设计高并发交易链路,体现容量评估、分层防御、降级预案、可观测的系统性思维。

参考答案

万级 QPS 大促交易链路的设计要点:

1. 容量评估与架构分层。万级 QPS 不是单机能扛的,需水平扩展。前端 Nginx/LB → 多实例应用层 → 多级缓存 → 分库分表 DB。按单机 1000~2000 QPS 估算,部署 10+ 实例 + 弹性扩容。所有无状态服务可水平扩展,有状态(DB/Redis)做集群分片。

2. 多级缓存挡读流量。读多写少的数据(商品、价格、库存预览)走 L1 本地缓存(Caffeine)+ L2 Redis + DB。80% 以上读请求在缓存层消化,不落 DB。热点 key 大促前预识别并预热、做 key 分片打散。

3. 线程池隔离防连坐。按业务域拆独立线程池:交易核心池、查询池、异步通知池彻底分开,配有界队列 + CallerRunsPolicy 背压。任一慢调用只拖垮自己池,不影响核心下单。

4. Sentinel 熔断降级。对弱依赖(第三方支付、推荐、风控)设慢调用熔断(RT>500ms 比例超 50% 触发),熔断期走降级(默认库存/缓存价/固定话术)。核心链路强依赖做集群容灾(主备/多活)。

5. 异步削峰。下单成功后的通知、积分、统计走 MQ(RocketMQ)异步处理,主链路只做核心扣减,把写流量削峰填谷。

6. 分库分表抗压。按 user_id 分库分表(ShardingSphere),单分片 QPS 可控;写流量分散到多分片。库存扣减用 Redis 预扣 + 异步落 DB。

7. 全链路可观测与预案。Prometheus 监控 QPS/RT/线程池/熔断器状态/GC;Sentinel 规则可动态推送;准备限流降级开关、熔断阈值、容量水位告警。大促前全链路压测验证容量。

保 99.99% 可用的关键不是单一手段,而是多层防御叠加:缓存挡读、隔离防连坐、熔断切弱依赖、异步削峰、分库抗压、容灾兜底,任一层失效有下一层兜底。报告项目正是这套组合实现万级 QPS + 99.99% 可用。

面试官追问 / 红旗
  • 追问:单机扛不住万级 QPS,怎么估算实例数?(按单机 QPS 上限 + 冗余估算)
  • 追问:库存扣减怎么防超卖?(Redis 预扣原子操作 + Lua + DB 兜底乐观锁)
  • 追问:缓存挂了怎么办?(多级缓存 + 限流降级 + DB 兜底)
  • 红旗:只说"加机器/加缓存"无分层防御设计;没有降级预案;不考虑容量评估与压测;说不出 99.99% 靠多层兜底而非单点。

Q2: 讲一次你处理 ThreadLocal 内存泄漏的线上事故全过程。

深度 | Java后端 | 📖 相关讲解

考察点:能否完整复盘一次线上事故(现象→排查→根因→修复→预防),体现真实排查经验而非背书。

参考答案

一次 ThreadLocal 泄漏事故的典型全过程(结合报告项目实战):

现象:交易系统运行一段时间后,某服务实例接口 RT 逐渐升高,监控告警显示 Full GC 频繁,Old 区占用持续上涨且 Full GC 后不回收,最终接近 OOM 触发重启。重启后恢复,但隔段时间复发——典型的内存泄漏特征。

排查过程

  1. jstat -gcutil 确认 FGC 次数飙升、Old 区(O%)持续涨不回收,锁定为内存泄漏而非大对象一次性分配。
  2. jmap -histo:live 看大户对象,发现某个业务对象(如 UserContext/TraceContext)实例数异常多。
  3. jmap -dump 导出 hprof,MAT 分析:Leak Suspects 指向大量该对象被 ThreadLocalMap 持有,支配树显示 Thread → ThreadLocalMap → Entry[] → value 的引用链,value 无法回收。

根因分析ThreadLocal 的 Entry 对 key(ThreadLocal 实例)是弱引用,但对 value 是强引用。在线程池场景,线程长期存活,代码中用完 ThreadLocal 没有调用 remove(),导致 value 被 Entry 的强引用长期持有无法回收,随着每次请求 set 新对象不断累积,Old 区只增不减。

修复:在所有使用 ThreadLocal 的地方,try-finally 在 finally 中调用 ThreadLocal.remove(),确保请求结束后清理。规范上要求团队所有 ThreadLocal 使用必须配 remove。

预防

  • 代码规范:ThreadLocal 必须 try-finally remove,接入静态扫描(如 SonarQube 规则)。
  • 监控:ThreadLocalMap 大小、Old 区增长趋势告警。
  • 改用更安全方案:能用方法参数传递的 context 不用 ThreadLocal;JDK 21 后部分场景可考虑 Scoped Values(替代 ThreadLocal)。

这个事故的价值在于:ThreadLocal 泄漏非常隐蔽(弱引用 key 给人"会被回收"的错觉),但 value 强引用 + 线程池长存活是确凿的泄漏源,必须 remove。

面试官追问 / 红旗
  • 追问:ThreadLocal 的 key 是强引用还是弱引用?(弱引用,但 value 是强引用)
  • 追问:为什么线程池场景才泄漏而短生命周期线程不泄漏?(线程长存活使 Entry 长期驻留)
  • 追问:除了 remove 还有什么预防手段?(静态扫描、监控、Scoped Values)
  • 红旗:说"ThreadLocal 用完会自动回收"(忽略 value 强引用);说不出 jstat/jmap/MAT 的具体使用;没有预防措施只说"重启"。

Q3: 数据库要不停机从 4 分片扩容到 8 分片,给出迁移方案。

深度 | Java后端 | 📖 相关讲解

考察点:能否设计零停机、数据不丢、可回滚的分片扩容方案,体现分片路由、双写、数据校验的工程能力。

参考答案

不停机分片扩容(4→8)的核心难点是:扩容后分片路由规则改变(如 user_id % 4 → user_id % 8),历史数据要按新规则迁移,且迁移期间不能停服、不能丢数据、要可回滚。经典方案是双写 + 数据迁移 + 灰度切流

方案一:倍增扩容(4→8 是 2 倍,最简单)

因为 8 是 4 的 2 倍,新规则 user_id % 8 下,原分片 i 的数据只会落到新分片 i 或 i+4(二选一),迁移路径清晰。

  1. 准备新分片:建好 8 个新分片(可以是新库或同库新表)。
  2. 双写迁移:应用层改为同时写旧 4 分片和新 8 分片(双写),读仍走旧分片。新分片历史数据用迁移工具(如 DataX/Canal/分片间同步)从旧分片按新规则搬运补齐。
  3. 数据校验:迁移完成后,按 user_id 全量比对新旧分片数据一致性,校验通过才进入下一步。
  4. 灰度切流:先把读流量按比例灰度切到新分片(1%→10%→50%→100%),观察无误;再切写流量。ShardingSphere 支持动态分片规则,可配置化切换。
  5. 下线旧分片:全量切到新 8 分片稳定后,停止双写,下线旧 4 分片。

方案二:非倍增扩容(通用)

若不是整数倍(如 4→6),路由变化复杂,常用"双写 + 一致性路由表 + 灰度":维护新旧路由映射,数据全量双写到新旧,按 user_id 灰度切换路由,逐步把读/写从旧迁到新。

关键保障

  • 数据校验:每阶段必须全量/抽样比对一致性,不一致不切流。
  • 可回滚:双写期间任何问题都可切回旧分片,旧数据仍完整。
  • 限速迁移:迁移工具限速,避免压垮 DB。
  • 灰度:按 user_id 或流量比例灰度,控制爆炸半径。

工程上推荐优先做容量规划,扩容时尽量选倍增(如按 2 的幂扩容),降低迁移复杂度。

面试官追问 / 红旗
  • 追问:为什么 4→8 比一般扩容简单?(新规则下数据二选一,迁移路径清晰)
  • 追问:双写期间数据不一致怎么发现?(全量/抽样校验,CRC 或 count + 抽样比对)
  • 追问:迁移期间有增量写怎么办?(Canal 监听 binlog 持续同步增量)
  • 红旗:说"停服迁移"(要求不停机);没有数据校验步骤;没有灰度与回滚预案;不考虑增量同步。

Q4: 消息队列积压了上百万条,如何优雅消费而不击穿下游?

深度 | Java后端 | 📖 相关讲解

考察点:能否在积压场景下兼顾"尽快消化"与"保护下游",体现限速、扩容、降级、兜底的工程权衡。

参考答案

百万级积压的治理核心矛盾是:快速消化队列 vs 保护下游不被打爆。盲目加消费者提速会击穿下游 DB/服务,必须按下游承受能力消费。

分层处置方案

1. 评估下游容量。先搞清楚下游(DB/服务/第三方)能承受多少 QPS,这是消费速率的上限。消费速率不能超过下游容量,否则从一个故障(积压)引发另一个(下游击穿)。

2. 按下游容量限速消费。消费者侧做限流(令牌桶/滑动窗口),把消费速率控制在下游安全水位。宁可慢,不能把下游打挂。

3. 消费者扩容。若单消费者处理慢,增加消费者实例并行消费(需保证分区数 >= 消费者数,否则有消费者空转)。注意 Kafka/RocketMQ 一个分区只能被同一组一个消费者消费,扩容消费者受分区数限制。

4. 快速消费+落库后异步处理(如适用)。若消息处理重,可先把消息快速落到本地表/另一个缓冲队列("卸载"),再由后台任务按容量慢慢处理,先把 MQ 积压消化掉避免消息过期丢失。

5. 临时扩分区/队列。Kafka 可临时增加分区数提升消费并行度(注意扩分区不可逆且影响顺序)。

6. 降级非核心处理。积压期间对消息做降级:跳过非核心字段处理、合并批量处理、关闭部分耗时逻辑(如通知),优先保核心业务消息处理。

7. 死信与告警。消费失败的消息进死信队列(DLQ)人工兜底,不要无限重试卡住消费。

8. 事后根治。积压消化后排查根因(生产突增/消费能力不足/下游故障),常态化做容量规划:监控积压量、生产/消费速率比、消费者健康。

关键原则:积压是症状,击穿下游是更大的事故。先稳住下游(限速 + 降级),再提速消化(扩容 + 卸载),最后根治(容量规划)。

面试官追问 / 红旗
  • 追问:为什么不能直接猛加消费者?(会击穿下游)
  • 追问:Kafka 扩消费者受什么限制?(分区数,一个分区同一组只一个消费者)
  • 追问:消费失败的消息怎么处理?(重试有限次 + 死信队列兜底)
  • 红旗:只说"加消费者/加机器"不考虑下游;没有限速与降级;忽略消费者幂等(积压重试会重复消费);不事后根治根因。

Q5: 你说"核心接口 P99 下降了 50%",这个数字的统计口径、对比基准和归因分解是什么?

深度 | Java后端 | 📖 相关讲解

考察点:量化数据的可追问性——能否说清统计口径、对比基准、归因分解,识别"水分指标"。对应报告红旗"量化数字是否经得起统计口径/对比基准/归因分解追问"。

参考答案

"P99 -50%"这类量化指标必须经得起三重追问:

1. 统计口径(怎么算的 P99)。P99 是把该接口在统计窗口内的所有请求 RT 排序后,取第 99 百分位的值。需明确:统计窗口(如近 7 天/大促当天)、采样方式(全量还是采样)、是否区分成功/失败请求、是否剔除非业务异常。口径要可复现、可对账。例如"P99 = 近 7 天该接口成功请求的 RT 第 99 百分位,基于全量 APM 采样"。

2. 对比基准(和谁比的 -50%)。-50% 必须有明确基线:

  • 时间基线:优化前某段时间(如优化前 1 个月均值)vs 优化后。
  • 版本基线:优化版本 vs 上一个版本。
  • 同环比:环比上月/同比去年同期。

要避免"挑好数据"——基线选择要合理且公开,注明优化前 P99 是多少(如 200ms→100ms),而不是只说"降了一半"。

3. 归因分解(-50% 是哪些优化贡献的)。把 -50% 拆解到具体措施:

优化措施 贡献 机制
慢 SQL 治理(加索引/覆盖索引) 减少回表与扫描 DB RT 下降
分库分表 单分片扫描量降 DB RT 下降
线程池隔离+熔断 尾部慢调用快速失败 P99(尾部)下降
多级缓存 读请求不落 DB 整体 RT 下降

理想情况下每项优化有 A/B 或前后对比数据支撑归因,而非笼统归功于"架构升级"。

坦诚的边界:P99 受 GC、网络抖动、下游波动影响有随机性,单次测量波动大,需足够样本和稳定窗口。若某项优化贡献无法精确量化,应坦诚说明而非夸大。

这个追问对应报告核心洞察:"量化数据是真实性的试金石"——能讲清口径/基准/归因的指标才是扎实的,含糊的"提升很大"是红旗。

面试官追问 / 红旗
  • 追问:P99 和 P50 什么时候差异大?(尾部延迟场景)
  • 追问:你怎么知道 -50% 是哪项优化贡献的?(A/B 或前后对比数据)
  • 追问:P99 统计样本不足会怎样?(波动大不可信)
  • 红旗:说不出 P99 的定义;基线含糊("以前很慢");归因笼统("架构升级"无分解);只给相对值不给绝对值(200ms→100ms)。

Q6: 高并发下的分布式事务如何保证最终一致性?TCC 有哪些坑,补偿怎么设计?

深度 | Java后端 | 📖 相关讲解

考察点:分布式事务方案选型、TCC 三大坑(幂等/空回滚/悬挂)的应对、补偿设计的工程深度。

参考答案

高并发下分布式事务的主流选择是最终一致性(AP),而非强一致(2PC 性能差、锁资源)。常用方案:TCC、Saga、本地消息表、事务消息。高并发交易链路(下单-扣库存-扣款)通常用 TCC(核心强一致资源)+ 本地消息表/Saga(非核心)组合。

TCC 三大坑及应对

TCC = Try(预留资源,如冻结余额)→ Confirm(确认扣减)→ Cancel(解冻取消)。每个业务操作要实现三接口,坑在并发与异常:

含义 应对
幂等 Confirm/Cancel 可能被重试多次 每步带全局事务 ID,记录状态,重复执行直接返回成功
空回滚 Try 未执行却先收到 Cancel(Try 超时/网络丢) Cancel 前查 Try 是否执行,未执行则记录"空回滚"标记后返回
悬挂 Cancel 先于 Try 到达,之后 Try 又执行导致资源悬挂 Try 前查是否已 Cancel/空回滚,已 Cancel 则拒绝 Try

补偿设计原则(适用 TCC 的 Cancel 和 Saga 的补偿):

  1. 幂等可重入:补偿操作必须幂等,因为补偿本身可能失败重试。每次带事务 ID + 操作状态机(待执行/已执行/已补偿)。
  2. 业务可逆:补偿要能真正回滚业务(如扣款→退款、库存扣减→回滚)。不可逆操作(如发货)无法补偿,要用 Saga 提前判断或人工介入。
  3. 状态机驱动:每步记录状态,由状态机驱动流转与补偿,避免遗漏。
  4. 超时与重试:Confirm/Cancel 失败要有定时重试 + 人工兜底,不能永久悬挂。
  5. TCC 资源隔离:Try 阶段预留的资源(冻结金额)要与其他事务隔离,防止超卖。

最终一致性的兜底

  • 对账:所有方案都不是 100%,必须跑定时对账(如每日对账),发现不一致自动或人工补偿。这是最后一道防线。
  • 人工介入:极端异常(补偿失败、数据不一致)转人工,配合可重放的操作日志。

工程结论:高并发下用 AP+最终一致(TCC/Saga/消息表)保可用与吞吐,用对账兜底一致性。资金/库存等强一致核心用 TCC,但要处理好三大坑与补偿幂等。

面试官追问 / 红旗
  • 追问:TCC 的空回滚和悬挂怎么区分?(空回滚是 Try 没来就 Cancel;悬挂是 Cancel 先到 Try 后到)
  • 追问:补偿操作为什么必须幂等?(补偿本身会重试)
  • 追问:发货这种不可逆操作怎么补偿?(无法补偿,Saga 提前判断/人工介入)
  • 红旗:说 TCC 是强一致而无视三大坑;补偿不考虑幂等;没有对账兜底;认为分布式事务能 100% 一致。