跳转至

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 组合):

  1. 线程池隔离:核心交易与查询/通知用独立线程池,防止慢调用连坐。
  2. 有界队列+CallerRunsPolicy:控制并发、做背压,队列堆积超阈值告警。
  3. Sentinel 慢调用熔断:对下游弱依赖设 RT>500ms 比例超 50% 触发熔断,熔断期间走降级(缓存/默认值),把尾部慢调用快速失败,避免拖垮线程池。
  4. 超时控制: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 大)性能急剧下降。解法:

  1. 游标分页:用 WHERE id > last_id ORDER BY id LIMIT size,避免大 offset 扫描,但要求数据有序且带上一页末尾 ID。
  2. 二次查询法:先各分片取少量粗筛,确定边界再精确查,减少归并数据量。
  3. 禁止深度分页:业务上限制最大翻页深度,超出的引导用条件筛选。
  4. 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 事务消息两阶段流程:

  1. 发送半消息(Half Message):生产者先发一条对消费者不可见的半消息到 broker。
  2. 执行本地事务:半消息发送成功后,执行本地 DB 事务。
  3. 提交/回滚:本地事务成功 → 提交消息(消费者可见);失败 → 回滚消息。
  4. 事务回查:若生产者宕机未提交/回滚,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 看引用链。