跳转至

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-finallyremove()
内存泄漏 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 看引用链。