Java 后端工程化 · 进阶¶
考察从"知道概念"到"线上定位与治理"的能力:P99 治理、GC 选型、分库分表难题、Redis 锁原理与争议、分布式事务方案、Full GC 排查。
Q1: 核心接口 P99 劣化,如何用线程池 + Sentinel 定位和治理?¶
进阶 | Java后端 | 📖 相关讲解
考察点:能否从监控指标定位 P99 劣化根因,并给出线程池与熔断降级的组合治理手段,体现"线上定位→治理→验证"的闭环。
参考答案
P99 劣化的定位与治理是一个闭环流程:
第一步:定位(监控归因)。先看 APM(SkyWalking/Pinpoint)确认是整体变慢还是尾部变慢。P99 劣化但 P50 正常,通常是尾部延迟——某个慢调用(下游 DB/第三方)、GC 停顿、或线程池排队。重点看:接口 RT 分布、线程池队列堆积、下游依赖 RT、GC 日志、慢 SQL。
第二步:根因分类。常见根因:
| 根因 | 表现 | 证据 |
|---|---|---|
| 下游慢调用 | 尾部延迟 | APM 看 DB/第三方 RT 高 |
| 线程池排队 | P50 正常 P99 高 | 队列堆积指标 |
| Full GC 停顿 | 周期性尖刺 | GC 日志 STW |
| 慢 SQL | 整体或部分慢 | 慢查询日志 |
第三步:治理(线程池+Sentinel 组合):
- 线程池隔离:核心交易与查询/通知用独立线程池,防止慢调用连坐。
- 有界队列+
CallerRunsPolicy:控制并发、做背压,队列堆积超阈值告警。 - Sentinel 慢调用熔断:对下游弱依赖设 RT>500ms 比例超 50% 触发熔断,熔断期间走降级(缓存/默认值),把尾部慢调用快速失败,避免拖垮线程池。
- 超时控制:Feign/HTTP/DB 连接统一设超时,杜绝无限等待。
第四步:验证。治理后压测对比 P99,确认下降且无新问题。报告项目中"核心接口 P99 下降 50%"正是这条治理链路(慢 SQL 治理 + 分库分表 + 线程池隔离 + 熔断)的成果。
面试官追问 / 红旗
- 追问:P50 正常但 P99 高,通常是什么原因?(尾部延迟:慢调用/GC/排队)
- 追问:线程池队列堆积到多少该告警?(接近容量阈值即预警,因为离拒绝很近)
- 追问:P99 怎么统计?(把请求 RT 排序取第 99 百分位,需足够样本量)
- 红旗:只说"加机器"或"加缓存"而无定位过程;不知道从 APM/线程池/GC/慢 SQL 归因;说不出 P50 与 P99 区分的诊断价值。
Q2: G1 和 ZGC 的算法有什么区别?什么场景应该选 ZGC?¶
进阶 | Java后端 | 📖 相关讲解
考察点:理解 G1 分区回收与 ZGC 染色指针/读屏障的算法差异,能基于堆大小与延迟需求做选型。
参考答案
G1 算法:把堆划分为多个大小相等的 Region(Eden/Survivor/Old/Humongous),通过混合回收 (Mixed GC) 优先回收垃圾最多的 Region(Garbage First)。它仍是分代+标记复制,通过 -XX:MaxGCPauseMillis 设目标停顿(默认 200ms)。G1 的停顿随 Region 数量(堆大小)有一定增长,适合 4~32G 堆、追求吞吐与停顿平衡的场景,是 JDK 9 起默认 GC。
ZGC 算法:核心是染色指针(Colored Pointers)+ 读屏障(Load Barrier)+ 内存多重映射。它在 64 位指针的高位嵌入 GC 元数据,通过读屏障在对象被访问时并发整理内存。关键优势是并发整理——标记、转移、重定位几乎全部与应用线程并发执行,停顿时间不随堆大小增长(亚毫秒~10ms 级),可管理 TB 级堆。
| 维度 | G1 | ZGC |
|---|---|---|
| 算法 | 分区+标记复制 | 染色指针+读屏障 |
| 停顿 | 可控(数十~百毫秒),随堆增长 | <10ms,不随堆增长 |
| 堆规模 | 4~32G 通用 | 超大堆(几十~几百 G~TB) |
| 成熟度 | JDK9 默认 | JDK15 生产可用,JDK21 成熟 |
| 吞吐 | 高 | 略低(读屏障开销) |
何时选 ZGC:堆非常大(> 32G 甚至几百 G)、对 P99 延迟极其敏感(如交易下单要求毫秒级停顿)、能接受小幅吞吐下降的场景。报告通过者中有人以 G1/ZGC 调优突出,正是低延迟接口切 ZGC 的实践。一般业务 4~32G 堆用 G1 即可,无需过早引入 ZGC。
面试官追问 / 红旗
- 追问:ZGC 怎么做到停顿不随堆增长?(染色指针+读屏障并发整理)
- 追问:G1 的 Region 和 Mixed GC 是什么?
- 追问:ZGC 有没有代价?(读屏障开销、吞吐略降、对老代码如 synchronized 钉住线程敏感)
- 红旗:说不出 G1 与 ZGC 算法本质差异;把 ZGC 说成"完全没停顿";不知道 ZGC JDK15 才生产可用,说得很久以前就能用。
Q3: 分库分表后,跨分片的 JOIN 和分页怎么解决?¶
进阶 | Java后端 | 📖 相关讲解
考察点:理解分库分表带来的查询难题,能给出工程化的解法(绑定表、应用层组装、游标分页、ES 宽表)。
参考答案
分库分表(如 ShardingSphere 按 user_id 拆分)后,数据分布在不同库/表,跨分片 JOIN 与分页是两个典型难题:
跨分片 JOIN 的解法:
| 方案 | 做法 |
|---|---|
| 绑定表 (Binding Table) | ShardingSphere 配置主从表用同一分片键,保证关联记录同分片,可正常 JOIN |
| 广播表 | 小表(字典)广播到所有分片,JOIN 时本地完成 |
| 应用层组装 | 分别查各表,应用内存 JOIN(数据量大时慎用) |
| 冗余字段 | 把关联字段冗余到主表,避免 JOIN |
| ES 宽表 | 把多表数据聚合写入 ES,复杂查询走 ES |
跨分片分页的解法:
跨分片 LIMIT offset, size 需要每个分片都取 offset + size 条再归并排序,深度分页(offset 大)性能急剧下降。解法:
- 游标分页:用
WHERE id > last_id ORDER BY id LIMIT size,避免大 offset 扫描,但要求数据有序且带上一页末尾 ID。 - 二次查询法:先各分片取少量粗筛,确定边界再精确查,减少归并数据量。
- 禁止深度分页:业务上限制最大翻页深度,超出的引导用条件筛选。
- ES 宽表:列表查询整体落到 ES,用
search_after游标分页。
工程上,报告项目按 user_id 分片,90% 查询带 user_id 单分片内完成,跨分片统计走 ES/数仓,列表分页用游标分页,从设计上规避了跨分片难题。
面试官追问 / 红旗
- 追问:深度分页
LIMIT 100000, 20为什么慢?(要扫前 100020 行) - 追问:游标分页有什么限制?(数据有序、带末尾 ID、不支持随机跳页)
- 追问:分片键怎么选才不踩跨分片坑?(贴合主查询路径,如 user_id)
- 红旗:说不出跨分片分页需要"各分片取 offset+size 再归并";只说"加索引"解决不了分片问题;不知道绑定表/广播表。
Q4: Redisson 看门狗的原理是什么?Redlock 为什么有争议?¶
进阶 | Java后端 | 📖 相关讲解
考察点:理解看门狗定时续期机制及其依赖,掌握 Redlock 的算法与 Martin Kleppmann 的批评。
参考答案
看门狗(Watchdog)原理:Redisson 的 RLock 加锁时若不显式指定 leaseTime,默认锁有效期 30 秒(lockWatchdogTimeout),同时启动一个后台定时任务(看门狗),每隔 10 秒(lockWatchdogTimeout/3)检查持锁线程是否仍存活,存活则把锁过期时间重置回 30 秒。本质是定时续期,避免"业务执行时间 > 锁有效期"导致锁提前释放被他人抢占。
看门狗的关键依赖:它依赖持有锁的客户端进程存活且能正常调度定时任务。如果进程假死(长时间 GC、死循环、被 kill),看门狗不再续期,锁会自然过期——这是一种保护,但若假死期间业务仍在"执行"则会出问题。所以关键资源加锁必须配最大持有时间与业务超时兜底。
Redlock 争议:Redlock 是 antirez 提出的多 Redis 实例算法——向 N(通常 5)个独立 master 同时申请锁,超过半数成功且耗时小于锁有效期才算加锁成功,目的是解决单实例主从切换丢锁。Martin Kleppmann 在著名文章中批评:
- Redlock 依赖各节点时钟同步,但在 GC pause、进程暂停、时钟漂移下仍可能出错。
- 对于需要严格正确性的场景(如金融扣款),应使用基于共识算法(Paxos/Raft,如 Zookeeper/etcd)的锁,而非依赖时钟的 Redis 锁。
- Redis 锁只适合"容忍偶尔出错、性能优先"的场景。
工程结论:扣款/库存等强一致场景不用 Redis 锁,用 DB 行锁/TCC/共识锁;防重复提交、限流、秒杀资格预筛等可容忍场景用 Redisson。
面试官追问 / 红旗
- 追问:看门狗每多久续期一次?(默认 10 秒,= lockWatchdogTimeout/3)
- 追问:客户端进程假死时看门狗会怎样?(不再续期,锁自然过期)
- 追问:Redlock 为什么需要过半数?(避免单点故障丢锁)
- 红旗:说看门狗"永远不会让锁过期"(忽略进程假死场景);不知道 Redlock 的时钟依赖批评;认为 Redis 锁可用于资金扣款。
Q5: RocketMQ 事务消息的两阶段流程是什么?和本地消息表方案怎么选?¶
进阶 | Java后端 | 📖 相关讲解
考察点:准确描述事务消息的半消息/本地事务/回查流程,对比本地消息表的优劣与选型。
参考答案
RocketMQ 事务消息两阶段流程:
- 发送半消息(Half Message):生产者先发一条对消费者不可见的半消息到 broker。
- 执行本地事务:半消息发送成功后,执行本地 DB 事务。
- 提交/回滚:本地事务成功 → 提交消息(消费者可见);失败 → 回滚消息。
- 事务回查:若生产者宕机未提交/回滚,broker 定期回查生产者"这个事务到底成了没",生产者根据本地事务状态答复提交/回滚。
生产者需实现 TransactionListener,提供执行本地事务与回查两个回调,且回查要幂等、能根据业务主键查到最终状态。
对比本地消息表:
| 维度 | 事务消息 | 本地消息表 |
|---|---|---|
| 实现 | 半消息+回查,需实现 Listener | 业务表+消息表同库写,定时扫描发送 |
| 一致性依赖 | broker 回查驱动 | 本地事务 + 定时轮询 |
| 组件依赖 | 必须 RocketMQ | 任何 MQ + 业务库 |
| 轻量度 | 较轻(无消息表) | 中(有消息表+定时任务) |
| 通用性 | 仅 RocketMQ | 通用 |
选型:已用 RocketMQ 且需要业务级事务消息,选事务消息更轻量;多 MQ 共存或不想侵入 RocketMQ 特性,本地消息表最通用稳妥。报告项目交易链路(下单-扣库存-扣款)需保证"本地订单与发消息原子",事务消息是自然选择。两者都需消费者幂等。
面试官追问 / 红旗
- 追问:半消息为什么对消费者不可见?(broker 标记,提交后才可见)
- 追问:回查接口设计要注意什么?(幂等、能查到最终状态、超时处理)
- 追问:本地消息表怎么保证一致性?(业务与消息同库本地事务,同生共死)
- 红旗:说不清半消息/回查流程;认为事务消息是强一致(实为最终一致);不知道消费者要幂等。
Q6: 缓存与数据库一致性,延迟双删和 Canal 监听 binlog 各有什么优劣?¶
进阶 | Java后端 | 📖 相关讲解
考察点:理解双写一致性的各种策略及其并发问题,能权衡延迟双删与 binlog 兜底的优劣。
参考答案
缓存与 DB 双写一致性是经典难题,常见策略:
| 策略 | 做法 | 问题 |
|---|---|---|
| 先更 DB 再删缓存 | 写 DB → DEL cache | 删除失败则不一致(可重试/MQ) |
| 先删缓存再更 DB | DEL cache → 写 DB | 并发下:A 删缓存→B 读 DB 旧值写回→A 更新 DB,长期不一致 |
| 延迟双删 | 删缓存→写 DB→延迟 N 秒再删一次 | 修补"先删后写"并发窗口,但延迟时间难精确 |
| Canal binlog | DB 变更→Canal 解析→异步删缓存 | 最终一致、解耦,但引入组件与延迟 |
延迟双删的优劣:
- 优:实现简单,无需额外组件,能覆盖大部分"先删后写"的并发不一致窗口。
- 劣:延迟时间 N 难精确(取决于读请求的执行耗时),过短仍有窗口,过长浪费;第二次删除仍可能失败。
Canal binlog 的优劣:
- 优:DB 变更触发,完全解耦业务代码(业务只管写 DB),一致性最稳,是最终一致的工程级方案。
- 劣:引入 Canal 组件与运维成本;binlog 解析有秒级延迟;需保证删缓存操作幂等。
工程推荐:写时用"先删缓存 + 更新 DB + 短延迟双删"主路径,再用 Canal binlog 异步删缓存做最终一致性兜底,二者结合。同时所有读请求走"查缓存→未命中查 DB→回写缓存"。金融等强一致场景则不强依赖缓存,或用旁路缓存 + 对账。
面试官追问 / 红旗
- 追问:为什么"先删缓存再更 DB"会有不一致?(并发下旧值被写回缓存)
- 追问:延迟双删的延迟时间怎么定?(略大于一次读请求耗时)
- 追问:删除缓存失败怎么办?(重试 + MQ 兜底 + binlog 兜底)
- 红旗:说"更新缓存"而非"删缓存"(更新有并发覆盖风险);认为能做强一致;不知道 Canal 是监听 binlog。
Q7: 线上频繁 Full GC,给出完整的排查路径。¶
进阶 | Java后端 | 📖 相关讲解
考察点:能否给出从现象到根因的工具化排查路径(jstat/jmap/MAT),识别大对象、内存泄漏、ThreadLocal 泄漏等典型根因。
参考答案
频繁 Full GC 的完整排查路径:
第一步:确认现象与 GC 类型。用 jstat -gcutil <pid> 1s 持续观察各代占用、YGC/FGC 次数与耗时、GCT。确认是 Full GC 频繁(FGC 次数飙升)还是 Young GC 频繁。重点看 Old 区(O)占用是否持续上涨且 Full GC 后不回收——这是内存泄漏的典型信号。
第二步:定位对象类型。用 jmap -histo:live <pid> 触发 GC 并按对象大小排序,找出占用最大的类("大户对象")。也可用 Arthas 的 dashboard/heapdump 在线诊断。
第三步:导出堆 dump 深度分析。jmap -dump:format=b,file=heap.hprof <pid> 导出,用 MAT 打开,看 "Leak Suspects"(泄漏嫌疑)、"Dominator Tree"(支配树,看谁持有最多内存)、"GC Roots"(找对象到 GC Root 的引用链)。
第四步:根据根因分类修复:
| 根因 | 特征 | 修复 |
|---|---|---|
| 大对象进老年代 | 大数组/集合,Old 持续涨 | 分页、流式、避免一次性大集合 |
| ThreadLocal 泄漏 | 线程池场景 Old 涨,MAT 见 ThreadLocalMap 大 | try-finally 中 remove() |
| 内存泄漏 | Full GC 后 Old 不降,MAT 有泄漏嫌疑 | 找到无法回收的引用链修复 |
| 元空间泄漏 | Metaspace 涨,动态生成类(CGLIB/Groovy) | 排查 ClassLoader 卸载 |
| 配置不当 | 堆太小/年轻代太小 | 调 -Xms/-Xmx、年轻代比例 |
第五步:验证与预防。修复后观察 FGC 次数回落,并加 GC 监控告警(FGC 频率、Old 占比)。报告项目曾遇到"大对象进老年代 / ThreadLocal 泄漏"事故,正是这条路径定位修复。
面试官追问 / 红旗
- 追问:
jmap -histo:live的:live有什么作用?(触发一次 GC 后统计存活对象) - 追问:ThreadLocal 为什么在线程池场景会泄漏?(value 强引用 + 线程长存活)
- 追问:MAT 怎么定位泄漏?(Leak Suspects + 支配树 + GC Root 引用链)
- 红旗:一上来就"重启"或"加内存"而无排查过程;分不清 Young GC 与 Full GC;不知道用 MAT 看引用链。