实践场景 & 问题排查 —— 从单点现象到系统排查到架构设计
10 题 · 平均 4/5 · low 3.33 · medium 4.25 · high 4.33 · 第 1 轮
**核心考点定位**:考察"性能问题排查方法论"——从现象到根因的分层下钻,以及 Java 应用冷启动特有的耗时来源。
**知识点拆解**:
1. **排查五步法**:定位现象(时间轴/范围)→ 关联变更(发布/流量/依赖)→ 分层下钻(应用/JVM/接口/中间件/机器)→ 抓证据(arthas/JFR/线程 dump)→ 处置止血
2. **Java 冷启动三大耗时源**:JIT 未预热(解释执行 vs 编译执行)、连接池冷启动(TCP/TLS 握手 + 认证)、本地缓存空(全穿透到远端)
3. **Load 的本质**:`load = 运行态进程数 + D 状态进程数`。CPU 打满看 C1/C2 编译和热点方法;D 状态多则看磁盘 IO / 网络阻塞
4. **处置止血优先级**:降级 > 限流 > 回滚 > 扩容(前两个秒级生效,回滚分钟级,扩容小时级)
5. **预防手段**:预热接口(启动后异步压热点)、灰度发布(1%→10%→50%)、滚动重启(避免同时启动导致惊群)
**考察方向**:
- **深度**:JIT 编译阶段(C1 解释 → C1 简单编译 → C2 优化)、连接池预热算法、FullGC 触发条件
- **广度**:Linux `vmstat 1`(d 状态)、`pidstat`(单进程 CPU)、`ss -s`(连接状态)、Prometheus JMX 指标
- **应用**:灰度发布系统、预热脚本、服务发现的健康检查窗口
**延伸递进面试题**:
1. **JIT 预热到底要多久?怎么验证?**
- 解答要点:C1 约 200-1000 次调用触发,C2 约 10000 次;用 `java -XX:+PrintCompilation` 或 JFR 观察编译事件;生产上可主动调预热接口触发
2. **load 飙高但 CPU 不高,可能是什么原因?**
- 解答要点:D 状态进程堆积(磁盘 IO 慢、NFS 卡、网络阻塞);`cat /proc/loadavg` 对比 `vmstat 1` 的 `r/b` 列;可能是 GC STW(CPU 时间没算到用户态)
3. **如何设计预热机制让应用启动即战?**
- 解答要点:启动后异步任务调 top-N 热点接口(限流避免冲击);连接池 keepalive 保活;预热脚本在 readiness 探针通过后触发
**【一句话结论】**
面试官您好,"启动后几分钟飙高"是典型的冷启动问题,我先从时间轴确认是渐变还是突增,然后分三层排查:JVM 冷启动(JIT/连接池/缓存)、系统资源(CPU/D 状态/磁盘)、业务流量(灰度比例/依赖方)。
**【三点展开】**
第一,Java 冷启动三件事:JIT 没预热,热点方法还在解释执行;连接池刚建,TCP/TLS 握手开销大;本地缓存空,全部穿透到 DB/Redis。用 `arthas dashboard` 看类加载数是否还在涨,`jcmd <pid> VM.event` 看 C1/C2 编译事件,基本能锁定是不是 JIT 没追上。
第二,Load 飙高要区分是 CPU load 还是 D 状态。`vmstat 1` 看 d 列,d 大说明卡在 IO,可能是日志刷盘、NFS 卡住、或者 FullGC STW 把 CPU 时间挤到用户态之外。这时候看 arthas 的线程状态,WAITING/BLOCKED/DUMPED 多不多。
第三,处置有优先级:先降级/限流(秒级),再回滚(分钟级),最后扩容(小时级)。如果是新功能问题,回滚后要保留现场抓线程 dump 和 heap dump 分析根因,别急着清理环境。
**【一句边界】**
当然,如果是灰度只发了一台机器、且没有预热接口,用户流量直接被冷实例承接,这种场景下排查完还是要推预热机制,光靠排查没用。
**核心考点定位**:考察对 JVM 类加载机制的深度理解——不只是"找不到类",而是"什么时候、在哪一层找不到"。
**知识点拆解**:
1. **CNFE vs NCDFE**:ClassNotFoundException 是运行时抛出的 checked exception,代表首次加载失败;NoClassDefFoundError 是 Error,代表曾经加载成功但依赖的类缺失或静态初始化抛 ExceptionInInitializerError
2. **类加载器委派模型**:Bootstrap → Extension → Application → 自定义 ClassLoader。任何一层找不到都会向上问父加载器,父找不到再找子类。跨 ClassLoader 的类"互相看不见"是运行期 CNFE 的核心场景
3. **运行期触发的三种路径**:
- 反射调用:`Class.forName("xxx")` 找不到
- 动态代理:CGLIB/JDK Proxy 生成代理类时父类找不到
- 延迟加载:代码路径首次执行才触发类加载
4. **常见根因**:Maven `provided` 作用域依赖漏打、shade/uber-jar 过滤规则错误、多 ClassLoader 隔离(Tomcat/OSGi)、SPI 配置错、JDK 版本升级(`javax.*` → `jakarta.*`)
5. **排查工具链**:`jcmd VM.classloader_dump`、`arthas sc -d`、`dependency:tree`、`-verbose:class`、`java -Xbootclasspath` 检查扩展
**考察方向**:
- **深度**:类加载器双亲委派的实现(`loadClass` 三阶段:findLoadedClass → findBootstrapClass → findClass)、双亲委派破坏场景(SPI、热部署)
- **广度**:与 `UnsatisfiedLinkError`(native 库缺失)、`ExceptionInInitializerError`(静态块失败)的区分
- **应用**:Maven shade 过滤规则、Spring Boot fat jar 的多 ClassLoader、Tomcat webapp 隔离
**延伸递进面试题**:
1. **CNFE 和 NCDFE 到底差在哪?给个具体例子。**
- 解答要点:CNFE 是 ClassLoader 找不到 .class 文件;NCDFE 是 JVM 已解析到但依赖的类缺失或静态初始化失败。例:静态块里调用的类被 shade 过滤掉,首次运行会抛 NCDFE
2. **Spring Boot fat jar 的类加载器和 Tomcat 有本质区别吗?**
- 解答要点:Spring Boot 用 LaunchedURLClassLoader 加载 BOOT-INF/lib/*.jar,破坏双亲委派(lib 下依赖先于 app 加载);Tomcat 用 WebAppClassLoader,隔离不同 webapp 的同名类
3. **线上运行期突然 CNFE,怎么快速定位是哪个 ClassLoader 出的问题?**
- 解答要点:`jcmd <pid> VM.classloader_dump <id>` 看每个 CL 的 URL;`arthas sc -d <class>` 直接查类的 ClassLoader 和 jar 路径;异常堆栈里找 `ClassLoader` 类型
**【一句话结论】**
面试官您好,"运行期"这个关键词很重要——不是启动就报错,说明类路径没变,问题大概率来自"延迟加载触发"或"多 ClassLoader 隔离",我会先确认异常类型(CNFE 还是 NCDFE),再按 ClassLoader 层级排查。
**【三点展开】**
第一,先区分异常类型:ClassNotFoundException 是首次加载找不到 .class;NoClassDefFoundError 是曾经加载过但依赖缺失或静态初始化失败。后者更隐蔽,往往静态块里调用的类被 shade 过滤掉才会踩到。
第二,看调用栈定位触发路径:是反射 `Class.forName` 触发,还是 CGLIB 动态代理生成代理类失败,还是延迟执行的代码路径首次走。反射路径查传入的类名字符串对不对;动态代理查父类加载器和父类可见性。
第三,用 `arthas sc -d <className>` 看类的 ClassLoader 和 jar 路径,用 `jcmd <pid> VM.classloader_dump` 看每个 CL 的 URL 列表,确认类在哪个 ClassLoader 范围内。常见根因:Maven `provided` 依赖漏打、shade 过滤规则错误、Spring Boot 与 Tomcat 的 ClassLoader 差异、SPI 配置拼写错误。
**【一句边界】**
当然,如果是 JDK 9+ 模块系统,跨模块访问的类还会被 `IllegalAccessError` 拦截,那是另一个坑——运行期突然 CNFE 也可能只是模块名拼错触发的假象。
**核心考点定位**:考察对 Java IO 生态中"内存模型选择"的理解——不是"POI 会 OOM",而是"什么时候会、怎么避免"。
**知识点拆解**:
1. **POI 的四种实现**:
- **HSSF**(.xls):DOM 全量加载,1 万行就危险
- **XSSF**(.xlsx):DOM 全量加载,但 xlsx 是 zip 压缩,内存放大 5-10 倍
- **SXSSF**(.xlsx 写):滑动窗口,默认 100 行,超过自动刷临时文件
- **SAX API**(.xlsx 读):事件驱动 `XSSFSheetXMLHandler`,逐行回调,内存恒定
2. **内存放大系数**:xlsx 是 zip 格式,解压后 DOM 加载通常放大 5-10 倍;100MB xlsx → 500MB-1GB heap
3. **典型 OOM 场景**:读百万行 xlsx 全量加载、并发多个 workbook 未 close、SXSSF 忘调 `dispose()` 临时文件堆积
4. **替代方案对比**:
- EasyExcel(阿里):基于 SAX,1 万行内存恒定 < 10MB,API 更简洁
- Poi-TL:模板填充,不解析全表
- JXL:只支持 .xls,已停止维护
5. **工程实践**:限制并发读 xlsx 的线程数、用对象池复用 Workbook、监控 `-XX:+HeapDumpOnOutOfMemoryError`
**考察方向**:
- **深度**:DOM vs SAX 的内存模型本质差异、SXSSF 滑动窗口机制(`rowaccessWindowSize`)
- **广度**:与 Apache Commons CSV、OpenCSV 的对比;JDK 9+ `Files.lines` 处理大文本
- **应用**:大文件上传下载(分片 + 流式)、数据导入场景(EasyExcel + 分片校验)
**延伸递进面试题**:
1. **XSSF 和 HSSF 的内存差异具体有多大?给个数字。**
- 解答要点:10 万行 xlsx 用 XSSF 加载约 500MB heap,HSSF 的 .xls 只支持 65536 行且 DOM 占用相似;SXSSF 保持 100 行窗口只占几十 MB
2. **EasyExcel 为什么能保持内存恒定?**
- 解答要点:基于 SAX 事件驱动,每处理一行释放上一行对象;用 `@ExcelProperty` 注解做字段映射,避免反射全表
3. **线上跑了一个读 500MB xlsx 的任务,OOM 了,怎么事后复盘?**
- 解答要点:用 `-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp` 抓 dump;MAT 分析 dominator tree,看 `Workbook`/`Sheet`/`Row` 的 retained heap;结论是改 SXSSF/SAX
**【一句话结论】**
面试官您好,POI 是 Apache 的 Excel 解析库,OOM 的根因是它默认用 DOM 模型全量加载到内存——xlsx 是 zip 压缩,解压后 DOM 通常放大 5-10 倍,100MB 文件能吃掉 1GB heap。
**【三点展开】**
第一,POI 有四个实现要分清楚:HSSF(.xls DOM)、XSSF(.xlsx DOM)、SXSSF(.xlsx 流式写,滑动窗口)、SAX API(.xlsx 流式读,事件驱动)。读大文件必须用 SAX,写大文件用 SXSSF,DOM 只适合小文件。
第二,内存放大是核心坑:xlsx 是 zip 格式,解压后每个 cell 在内存里是完整对象,100MB xlsx 解压后可能 1GB heap。典型 OOM 堆栈是 `OutOfMemoryError: Java heap space` + 大量 `Row`/`Cell` 占 Old 区。SXSSF 默认保留 100 行,超过自动刷到临时文件,用完必须调 `dispose()` 释放临时文件。
第三,生产上替代方案首选 EasyExcel——阿里基于 SAX 封装,1 万行内存恒定 < 10MB,API 用 `@ExcelProperty` 注解映射,比手写 SAX 回调简洁得多。配合堆监控 + `HeapDumpOnOutOfMemoryError` 抓 dump 分析 retained heap,能快速定位是不是 DOM 加载惹的祸。
**【一句边界】**
当然,如果是几十行的小文件做模板填充,XSSF 直接用完全没问题,不必为了流式而流式——SXSSF/EasyExcel 的 API 更啰嗦,小场景反而降低可读性。
**核心考点定位**:考察 Redis 超时排查的完整方法论——从现象(超时)到根因(命令阻塞/网络/资源)的分层下钻。
**知识点拆解**:
1. **超时来源分层**:
- 客户端侧:连接池打满、`commandTimeout` 过短、`maxWaitMillis` 超时
- 网络侧:抖动/丢包/机房跨域、TCP retransmit
- Redis 侧:单线程阻塞(大命令/Lua/KEYS)、持久化 fork、主从同步
- 集群侧:hash slot 迁移(cluster down)、热 key 单分片
2. **第一手证据**:`SLOWLOG GET 128` + `SLOWLOG LEN`;`INFO stats` 看 `rejected_connections`、`blocked_clients`、`instantaneous_ops_per_sec`
3. **命令阻塞典型**:`KEYS *`、`SMEMBERS`/`HGETALL` 大集合、`SORT`、Lua 循环、`BLPOP` 阻塞式命令
4. **持久化阻塞机制**:RDB `bgsave` 用 fork + COW,主进程 fork 瞬间可能卡顿(数百 ms 到数秒);内存越大 fork 越慢
5. **降级三件套**:
- **熔断**:Resilience4j CircuitBreaker,连续失败后短路
- **本地兜底**:Caffeine 本地缓存 + 随机 TTL 防击穿
- **互斥重建**:`setnx` + 后台线程重建热点 key
**考察方向**:
- **深度**:Redis 单线程模型、IO 多路复用(epoll)、fork COW 页表机制
- **广度**:与 Lettuce/Jedis 客户端配置对比;Redis Cluster 的 hash slot 与节点拓扑
- **应用**:生产监控(阿里云/腾讯云 Redis 监控 + 自建 exporter)、慢查询治理平台
**延伸递进面试题**:
1. **Redis 是单线程,为什么快?慢在哪?**
- 解答要点:快在 IO 多路复用 + 内存操作 + 无锁;慢在大命令(KEYS/SMEMBERS)、Lua 阻塞、fork、主从同步
2. **fork 为什么会阻塞主进程?怎么避免?**
- 解答要点:fork 要拷贝页表,内存越大拷贝时间越长;可调整 `save` 频率、用 AOF 代替 RDB、扩容降低单节点内存
3. **热 key 和热点分片怎么排查和治理?**
- 解答要点:`MONITOR`(生产慎用)或 `redis-cli --hotkeys`(LUA 统计);治理用本地缓存兜底、读写分离、客户端分片(一主多读)
**【一句话结论】**
面试官您好,Redis 超时我按"客户端 → 网络 → Redis 内部"三层排查,第一手证据是 SLOWLOG,然后按根因分类处置:阻塞命令治理、连接池扩容、降级兜底。
**【三点展开】**
第一,先看客户端侧:连接池打满(Lettuce/Jedis 的 `maxWaitMillis` 超时)、`commandTimeout` 配置过短。用 `INFO stats` 看 `rejected_connections` 和 `blocked_clients`,用 `CLIENT LIST` 看空闲连接和异常 client。客户端侧问题改配置即可,秒级生效。
第二,看网络层:跨机房延迟、TCP 丢包。用 `ping`、`mtr`、`tcpdump` 抓包,看 retransmit 比例。网络波动通常持续几秒,改机房路由或就近部署 Redis 实例能解决。
第三,看 Redis 内部:`SLOWLOG GET 128` 定位耗时命令,典型是大集合 `SMEMBERS`/`HGETALL`、`KEYS *`、Lua 循环。持久化方面看 `INFO persistence` 的 `rdb_last_bgsave_status` 和 fork 时间;主从同步看 `INFO replication` 的 `master_sync_in_progress`。治理手段:大 key 拆分、Lua 分解、读写分离、Resilience4j 熔断 + Caffeine 本地缓存兜底。
**【一句边界】**
当然,如果是集群模式下的 hash slot 迁移导致的短暂超时,那是 Redis Cluster 的正常行为,用 `redis-cli cluster slots` 看拓扑,客户端会自动重定向,一般不用介入。
**核心考点定位**:考察"幂等性"在分布式系统中的完整设计——从消息层到请求层到数据层的组合拳。
**知识点拆解**:
1. **MQ 重复的根本原因**:
- 生产端:发送后未收到 ack 就崩溃 → 重试发送(at-least-once)
- Broker:持久化前崩溃 → 重发同一消息
- Consumer:处理完业务但未提交 offset → Rebalance/崩溃后重新消费
2. **幂等性三种实现**:
- **业务 ID 幂等**:数据库唯一索引(`unique key on biz_id`),`INSERT ... ON DUPLICATE KEY UPDATE`
- **Token 幂等**:Redis `SET key value NX EX 300`,请求前发 token,请求时消耗
- **入参幂等**:hash(入参) 作为 key,短时间相同入参视为重复(注意误判)
3. **状态机 + 乐观锁**:`UPDATE orders SET status='已支付' WHERE id=? AND status='待支付'`,依赖唯一索引防并发,affected rows = 0 表示重复
4. **事务消息方案**:
- RocketMQ 事务消息:半消息 + 本地事务 + 回查
- 本地消息表:业务表 + 消息表同事务,定时扫表发 MQ,失败重试
- Outbox Pattern:业务表 + Outbox 表同事务,CDC(Debezium)监听 binlog 发 MQ
5. **降级策略**:Redis 不可用时降级到本地内存 Map + 数据库唯一索引兜底
**考察方向**:
- **深度**:Kafka 的 at-least-once 语义实现(commit offset 时机)、RocketMQ 事务消息的三阶段
- **广度**:与 Stripe/支付宝的"订单幂等"实践对比;Sagas 模式的补偿事务
- **应用**:支付系统、订单系统、消息驱动架构的幂等设计
**延伸递进面试题**:
1. **RocketMQ 事务消息的三阶段具体是什么?**
- 解答要点:①发送半消息 ②执行本地事务 ③提交/回滚半消息;Broker 定时回查本地事务状态
2. **本地消息表怎么保证消息不丢不重?**
- 解答要点:业务表 + 消息表同事务(insert 业务 + insert 消息表);定时扫表发 MQ;发送成功后 delete 消息;消息表加唯一索引防重复发送
3. **Token 幂等和状态机怎么选?**
- 解答要点:Token 适合请求级别去重(防双击);状态机适合业务级别(订单只能单向流转);生产上两者组合用
**【一句话结论】**
面试官您好,重复消费和重复下单本质是幂等性问题,MQ 侧靠"业务 ID 去重 + at-least-once 容忍",请求侧靠"Token + 状态机 + 乐观锁"组合拳。
**【三点展开】**
第一,先理解为什么 MQ 会重复:Kafka/RocketMQ 都是 at-least-once 语义,生产端未收到 ack 就崩溃会重试、Consumer 处理完但未提交 offset 会 Rebalance 重新消费。所以不追求"不重复",而是"重复了也能正确处理"。消息侧用唯一消息 ID 去重,`INSERT ... ON DUPLICATE KEY UPDATE` 或 Redis `SET NX EX 600` 记已消费 ID。
第二,请求侧重复下单有三个层次:① 入口层用 Token(Redis `SETNX + TTL 5 分钟`)防双击;② 业务层用唯一索引(`unique key on out_order_no`)防重复写入;③ 状态机用乐观锁 `UPDATE orders SET status='已支付' WHERE id=? AND status='待支付'`,affected rows = 0 表示重复。三者组合拳,任一层兜住都不会重复。
第三,跨服务场景用事务消息:RocketMQ 事务消息(半消息 + 本地事务 + 回查)或本地消息表(业务表 + 消息表同事务,定时扫表发 MQ)。这样本地事务和消息发送的原子性才能保证,不会出现"下单成功但通知没发"的中间态。
**【一句边界】**
当然,幂等设计有个坑:Token TTL 太短会导致重复(Token 过期后客户端重试),太长会导致 Token 泄露被恶意利用。生产上一般是 5-10 分钟,配合限流 + 风控做二次防护。
**核心考点定位**:考察"单机调度"到"分布式调度"的架构演进,以及分布式锁的工程化细节。
**知识点拆解**:
1. **@Scheduled 的单机局限**:
- 默认单线程(`ScheduledExecutorService` 单线程),多个任务串行
- `fixedRate` 是"从上次开始计",`fixedDelay` 是"从上次结束计",语义不同
- 无执行日志、无失败重试、无告警、无路由
2. **分布式锁三大陷阱**:
- **TTL 陷阱**:业务执行超过 TTL → 锁失效 → 并发;解法:Redisson 看门狗(默认 30s 续期)
- **误删陷阱**:A 节点锁超时释放,B 节点获取锁,A 执行完误删 B 的锁;解法:value 用 UUID,删除前 CAS 校验
- **主从切换陷阱**:Redis 主节点写锁,未同步到从节点就宕机,从节点选主后锁丢失;解法:RedLock(有争议,Martin Kleppmann 反对)
3. **xxl-job 核心机制**:
- 分片广播:`shardIndex/shardTotal` 分片执行,天然避免并发
- 阻塞策略:覆盖(`DispatcherTimeOut` 触发覆盖)、丢弃(后触发丢弃)、排队(FIFO 串行)
- 路由策略:轮询、一致性哈希(同 shard 路由到同节点)、故障转移、执行器繁忙度
- 执行器心跳:每 10s 心跳,调度中心检测执行器健康
4. **方案对比**:
- **Redis 分布式锁**:轻量、简单、TTL 陷阱;适合单机任务临时上锁
- **xxl-job**:功能全、生态好、有 UI、有告警;适合生产级定时任务
- **Elastic-Job**:分片能力强、弹性扩缩;学习曲线陡
- **Quartz JDBC store**:简单、无需外部依赖;扩展性差、锁竞争
5. **兜底策略**:即使锁失效,业务幂等(唯一索引 + 状态机)要能兜住
**考察方向**:
- **深度**:Redis 分布式锁的原子性实现(Lua 脚本)、RedLock 的争议、Redisson 看门狗原理
- **广度**:与 Quartz、Elastic-Job、SchedulerX 的对比;Cron 表达式 vs Quartz Cron
- **应用**:大数据 ETL 调度(Airflow/DolphinScheduler)、任务血缘、失败补偿
**延伸递进面试题**:
1. **Redis 分布式锁的 TTL 怎么设置合理?**
- 解答要点:一般 30s + 看门狗续期;业务执行时间远超 TTL 时要评估是否真的需要锁(可能任务本身设计有问题)
2. **xxl-job 分片广播和 Quartz JDBC store 有什么区别?**
- 解答要点:xxl-job 是"分片执行"(每个节点执行自己那份),Quartz 是"单点执行"(多节点竞争同一份任务,靠锁选出一个);前者扩展性好,后者简单
3. **如果任务执行时间超过锁 TTL,怎么保证不并发?**
- 解答要点:Redisson 看门狗自动续期(默认 30s);业务执行前检查锁是否还在(CAS 校验 value);业务结束释放锁时再次 CAS 校验
**【一句话结论】**
面试官您好,单机 @Scheduled 在集群下必然并发,首选上分布式调度平台(xxl-job);如果一定要在 @Scheduled 上救场,就用"分布式锁 + 业务幂等"组合拳。
**【三点展开】**
第一,先判断该不该用 @Scheduled:如果是生产级定时任务(有重试、告警、分片需求),直接上 xxl-job,它有分片广播(多节点分片执行)、阻塞策略(覆盖/丢弃/排队)、路由策略(轮询/一致性哈希)、失败重试、执行日志,单机 @Scheduled 缺的功能它都有。
第二,如果一定要 @Scheduled + 分布式锁:用 Redisson `RLock` 而不是裸 Redis `SETNX`,Redisson 有看门狗机制(30s 自动续期)解决 TTL 陷阱,value 用 UUID + CAS 校验防误删。锁的粒度要按业务场景选:全局唯一锁(任务级)、hash 分片锁(数据级)。锁竞争高时降级到数据库 CAS(`UPDATE task_lock SET locked_by=? WHERE status='free' AND expire_time>?`)。
第三,锁失效的兜底是业务幂等:任务处理的数据写入用唯一索引(`unique key on biz_id`),状态机用乐观锁 SQL(`UPDATE ... WHERE status=?`)。即使锁失效并发执行了,业务层也能兜住不重复处理。锁是"防并发",幂等是"防重复",两者组合才完整。
**【一句边界】**
当然,如果是分钟级以上的低频任务,甚至可以用"数据库唯一索引 + 时间窗口"做最简锁(`INSERT INTO task_log(task_name, exec_time) VALUES(?, ?) ON DUPLICATE KEY UPDATE ...`),不用引入 Redis 依赖——KISS 原则,能用数据库解决的别引入新组件。
**核心考点定位**:考察"零停机数据迁移"的完整方法论——从架构设计到工程落地到回滚预案。
**知识点拆解**:
1. **迁移目标明确**:先回答"为什么迁"(容量/性能/技术栈升级/拆分),不同目标方案不同
- 容量:垂直扩容 + 分库分表
- 性能:读写分离 + 缓存 + 分库分表
- 技术栈:MySQL→PolarDB、Oracle→MySQL
- 拆分:单体库→分库分表
2. **迁移阶段(六步法)**:
- **兼容性验证**:测试环境搭子库,跑全量 SQL 兼容性测试
- **全量同步**:DataX/mysqldump 迁移历史数据(保留近期 N 天增量)
- **增量同步**:CDC(Canal/Debezium)基于 binlog 追增量
- **双写灰度**:应用层双写 + 读写分离,逐步切流
- **单写切换**:只写新库,老库保留 + 反向同步
- **回滚期**:老库保留 1-2 个月,反向同步防回滚
3. **数据一致性保障**:
- **写入侧**:binlog ROW 格式 + GTID,保证不丢不重
- **校验侧**:抽样对比(按 id 分段)+ 业务指标对齐
- **切换侧**:DNS/负载均衡灰度(1%→10%→50%→100%)
4. **回滚预案**:
- 切回时间点(记录老库最后 binlog 位点)
- 反向同步(新库→老库 binlog 同步)
- 数据校验脚本(切回前校验新库数据完整性)
- 切回步骤(DNS/负载均衡权重调回 100% 老库)
5. **工程细节**:
- 迁移工具:DataX(全量)、Canal(增量)、DTS(阿里云)、CloudCanal
- 并发控制:限流避免打爆老库(DataX 的 `channel` 参数)
- 大表处理:分片同步(按主键分段)+ 断点续传
- 锁与事务:避免 DDL、长事务阻塞同步
**考察方向**:
- **深度**:MySQL binlog 格式(ROW/STATEMENT/MIXED)、GTID 机制、Canal 的 parse 算法
- **广度**:与 ElasticSearch 数据同步、ClickHouse 实时同步对比;Kafka 作为 CDC 中间层
- **应用**:分库分表迁移(ShardingSphere-JDBC)、跨云迁移、异构数据库迁移(Oracle→MySQL)
**延伸递进面试题**:
1. **Canal 是怎么做到基于 binlog 的增量同步的?**
- 解答要点:伪装成 MySQL slave,订阅 binlog 事件(Write_rows/Update_rows/Delete_rows);ROW 格式 binlog 记录行级变更,parse 后转换为 DDL/DML 事件推送给下游
2. **迁移过程中如何保证数据一致性?**
- 解答要点:三阶段校验:全量同步后抽样对比、增量同步后延迟监控、切换后业务指标对齐;GTID 保证不丢不重,唯一索引防重复写入
3. **如果迁移过程中新库挂了,怎么回滚?**
- 解答要点:老库保留 + 反向同步(新库→老库 binlog);DNS/负载均衡权重切回老库;切回前校验新库数据完整性(记录切回时间点)
**【一句话结论】**
面试官您好,平滑迁移的核心是"双写 + 灰度 + 反向同步"三件套,本质是把"一次性切换"拆成"渐进式切流",每一步都可验证、可回滚。
**【三点展开】**
第一,先验证再迁移:搭测试环境子库,跑全量 SQL 兼容性测试(特别是 MySQL 5.7→8.0 这种大版本升级,语法/配置/驱动都可能不兼容)。我们踩过坑:MySQL 5.5 升到 5.8,测试环境整个瘫了,因为默认配置不兼容旧语法,重新调配置才恢复。
第二,迁移分六步走:兼容性验证 → 全量同步(DataX,限流防打爆老库)→ 增量同步(Canal/Debezium 基于 binlog)→ 双写灰度(应用层双写 + 读写分离,DNS 权重 1%→10%→50%→100%)→ 单写切换(只写新库)→ 回滚期(老库保留 1-2 个月)。每步都有校验:全量后抽样对比、增量后延迟监控、切换后业务指标对齐。
第三,回滚预案是兜底:老库保留 + 新库→老库反向同步(binlog 同步),切回前校验新库数据完整性,DNS/负载均衡权重秒级切回老库。迁移期间跟业务侧约定不改表(增表/改表会破坏同步),这是踩坑后血泪教训。
**【一句边界】**
当然,如果是小表(< 100 万行)一次性迁移,直接停机 + mysqldump 就行,不必走这套流程——KISS 原则,能用简单方案解决的别引入 Canal/DTS 这种复杂组件。
**核心考点定位**:考察"分布式链路排查"的完整方法论——从现象(缓慢)到根因(哪一层阻塞)的分层下钻。
**知识点拆解**:
1. **链路分层(6 层模型)**:
- 用户端:客户端网络、DNS 解析、CDN 命中
- 网络层:丢包、延迟、TCP retransmit、跨地域
- 入口层:LB/Nginx 转发、连接数、会话保持
- 应用层:JVM(GC/线程池)、业务逻辑、序列化
- 缓存层:Redis 超时、缓存穿透、热点 key
- 数据层:慢查询、锁竞争、连接池耗尽、主从延迟
2. **规模判断**:
- 大规模:服务端问题(配置错误、依赖故障、流量突增)
- 个别:用户端问题(网络、设备、地域)
3. **关键排查手段**:
- **链路追踪**:Skywalking/Zipkin/Jaeger,看每个 span 的耗时
- **网络诊断**:`ping`/`mtr`/`traceroute`/`iperf3`/`netstat -s`
- **JVM 诊断**:`jstat -gc`、`jmap -histo`、Tomcat 工作线程数
- **数据库诊断**:SLOWLOG、`SHOW PROCESSLIST`、`SHOW ENGINE INNODB STATUS`
4. **真实案例复盘**:某接口大量查询打满数据库连接池 → 所有业务瘫痪;处置:临时下线非核心接口 + 加缓存 + 限流
5. **降级三件套**:
- **熔断**:Resilience4j CircuitBreaker,连续失败后短路
- **限流**:Sentinel/Resilience4j RateLimiter,保护核心链路
- **临时下线**:非核心接口直接返回空/降级文案
**考察方向**:
- **深度**:链路追踪的 span 采样率、JVM GC 对 RT 的影响、TCP retransmit 的代价
- **广度**:与 Chaos Engineering(故障注入)的对比;全链路压测(影子表)
- **应用**:生产监控(Prometheus + Grafana + Skywalking 三件套)、告警分级
**延伸递进面试题**:
1. **链路追踪的采样率怎么设置合理?**
- 解答要点:生产环境 1%-10%(高流量场景),核心链路 100%;采样用 head-based 或 tail-based,tail-based 按 trace 质量采样更精确
2. **CPU 不高但 RT 高,可能是哪些原因?**
- 解答要点:FullGC STW(用户态 CPU 不高)、线程池打满(等待队列堆积)、锁竞争(BLOCKED 状态多)、IO 等待(D 状态进程)
3. **连接池耗尽怎么排查和预防?**
- 解答要点:`SHOW PROCESSLIST` 看连接来源、监控 `active connections` 指标、连接池参数(`maxActive`/`maxWait`)、慢查询治理(连接池耗尽通常是慢查询引起)
**【一句话结论】**
面试官您好,"CPU 不高但 RT 高"是典型的"非 CPU 密集型瓶颈",我按"用户端 → 网络 → 入口 → 应用 → 缓存 → 数据"六层排查,第一手证据是 Skywalking 链路追踪的 span 耗时分布。
**【三点展开】**
第一,先分规模:大规模反馈是服务端问题(配置错误、依赖故障、流量突增),个别反馈是用户端问题(网络、设备、地域)。用 Skywalking 查具体用户请求的 span 耗时,定位是哪个节点慢——是 DNS 解析慢、LB 转发慢、业务逻辑慢、还是数据库慢。
第二,按六层下钻:DNS 层用 `dig` 查解析时间和 TTL;网络层用 `ping`/`mtr` 查丢包和延迟,`netstat -s` 看 retransmit;入口层看 LB 连接数和会话保持;应用层用 `jstat -gc` 看 GC 频率,Tomcat `maxThreads` 是否耗尽;缓存层看 Redis SLOWLOG 和连接池;数据层看 SLOWLOG 和锁竞争。
第三,处置有优先级:先止血(熔断/限流/临时下线非核心接口),再定位(链路追踪 + 监控),最后根治(慢查询优化、连接池扩容、缓存预热)。我踩过一个坑:某接口大量查询打满数据库连接池,导致整个业务瘫痪——处置是临时下线该接口 + 加缓存 + 限流,事后复盘加连接池监控和慢查询告警。
**【一句边界】**
当然,如果是跨地域网络问题(用户在国内、服务在海外),那是 CDN 和边缘节点的问题,光靠服务端排查解决不了——得靠 CDN 部署和 Anycast 路由优化。
**核心考点定位**:考察**系统性排查能力**——不是知识点的记忆,而是从"收到告警"到"定位根因"到"制定方案"的完整工程思维。
**知识点拆解**:
1. **第一步:确认现象**(10 分钟内)
- 用 `kafka-consumer-groups.sh --bootstrap-server ... --describe --group <group>` 查看每个分区的 Current-Offset / Log-End-Offset / Lag
- 观察 Lag 分布:均匀 vs 集中
- 观察堆积增长速度:线性 vs 指数
2. **第二步:分场景定位**
- **场景 A:堆积均匀增长**
- 消费者整体处理慢(下游 DB 慢、外部 API 慢)
- Consumer 端线程池打满、GC 频繁、CPU 打满
- **场景 B:堆积集中在某分区**
- 该分区消息体异常大
- 该分区消费逻辑死锁或异常
- 消费者组 Rebalance 期间,某分区未被接管
- **场景 C:堆积突然从 0 到 100 万**
- Producer 侧突发大量消息(下游积压、批量任务、重试风暴)
- Consumer 组突然崩溃或全部下线
- Rebalance 触发导致整个组暂停
3. **第三步:常见根因分类**
- **Consumer 侧**:
- 下游依赖(DB 慢查询、外部 API 超时、Redis 阻塞)
- JVM 问题(FullGC、OOM、CPU 100%)
- 业务逻辑(死循环、幂等去重表锁竞争、大事务)
- 配置问题(`max.poll.records` 太大、`max.poll.interval.ms` 超时)
- 网络问题(与 Broker 连接不稳定、DNS 解析失败)
- **Producer 侧**:
- 突发大量消息(大促、批量任务、下游积压补偿)
- 重试风暴(Consumer 消费失败反复重试)
- **Kafka 集群侧**:
- Broker 磁盘满、副本同步慢
- Controller 频繁切换、Leader 频繁迁移
- ZooKeeper / KRaft 异常
4. **第四步:解决方案分级**
- **P0(数据不能丢)**:扩容消费者(注意分区数上限)+ 限流生产者 + 下游扩容/优化 + 跳过已处理消息(消费幂等保证)
- **P1(可以延迟)**:降级消费逻辑、临时跳过非核心消息
- **P2(可以丢弃)**:跳过堆积消息(`--to-latest` 或修改 offset)
5. **关键陷阱**:
- **消费者数 > 分区数**:多出的消费者空转,扩容无效
- **Rebalance 期间**:整个组暂停消费,堆积是正常的(几秒到几十秒)
- **幂等设计**:堆积恢复时可能重复消费,消费端必须幂等
- **分区扩容**:Kafka 分区只能增不能减,扩容会改变消息路由
> 从「确认现象」→「分场景定位」→「根因分类」→「分级方案」,构成完整排查链条。
**考察方向**:
- **深度**:Lag 分布分析、`kafka-consumer-groups.sh` 参数、Kafka Manager / Prometheus 监控指标
- **广度**:与 RocketMQ 堆积排查对比、与数据库慢查询排查类比、与分布式系统故障隔离的联系
- **应用**:如何设计堆积告警、如何做自动化预案、如何做日常演练
**延伸递进的顺序排查流程(生产实战)**:
1. `kafka-consumer-groups.sh --describe --group <group>` 看 Lag 分布
2. 看消费者进程是否存活、Rebalance 频率
3. 看消费者 Pod 状态(OOM、CPU、GC 日志)
4. 看下游依赖(DB 慢查询、外部 API 响应时间)
5. 看 Kafka Broker 状态(磁盘、副本同步、Controller 稳定性)
6. 看 Producer 侧(是否突发大量消息)
**延伸递进面试题**:
1. **堆积 100 万消息,业务方要求 10 分钟内追平,怎么做?**
- 扩容消费者(注意分区数上限)、优化消费逻辑、跳过非核心消息、降级下游依赖
2. **堆积集中在某一个分区,怎么排查?**
- 检查该分区消息 Key 是否特殊、消息体是否异常、消费逻辑是否有死锁、消费者是否被分配到该分区
3. **Rebalance 频繁导致堆积,怎么解决?**
- 用 CooperativeStickyAssignor、调大 `session.timeout.ms` 和 `max.poll.interval.ms`、减小 `max.poll.records`
面试官您好,收到堆积告警后,我会按"确认现象 → 分场景定位 → 找根因 → 分级方案"这四步走。
第一步是**确认现象**。我用 `kafka-consumer-groups.sh --describe --group <group>` 看每个分区的 Lag 分布。堆积 100 万到底是均匀分布还是集中在某个分区,这决定了后续排查方向。同时看堆积增长速度,是线性还是指数增长,指数增长往往是正反馈问题。
第二步是**分场景定位**:
**如果是堆积均匀增长**,说明消费者整体处理慢,重点查 Consumer 侧:下游 DB 慢查询、外部 API 超时、线程池打满、JVM FullGC、业务逻辑死锁、幂等去重表锁竞争。同时看 Rebalance 频率,如果 Rebalance 频繁,整个组会周期性暂停消费。
**如果是堆积集中在某个分区**,重点查该分区:消息 Key 是否特殊、消息体是否异常大、消费逻辑是否有死锁、消费者是否被正确分配到该分区。
**如果是堆积突然从 0 到 100 万**,重点查:Producer 侧是否突发大量消息(下游积压、批量任务、重试风暴)、Consumer 组是否突然崩溃、Rebalance 是否触发。
第三步是**根因分类**:
**Consumer 侧**:下游依赖问题、JVM 问题(FullGC、OOM)、业务逻辑问题、配置问题(`max.poll.records` 太大)、网络问题。
**Producer 侧**:突发大量消息、重试风暴。
**Kafka 集群侧**:Broker 磁盘满、副本同步慢、Controller 频繁切换。
第四步是**分级方案**:
**数据不能丢(P0)**:扩容消费者(注意**分区数上限**,消费者数不能超分区数)、限流生产者、下游扩容或优化、消费端保证幂等。
**可以延迟(P1)**:降级消费逻辑、临时跳过非核心消息。
**可以丢弃(P2)**:跳过堆积消息(`--to-latest` 或修改 offset)。
这里有几个坑要注意:第一,消费者数超过分区数是无效的,扩容前先确认分区数;第二,Rebalance 期间整个组会暂停消费,几秒到几十秒的堆积是正常的;第三,堆积恢复时可能重复消费,消费端必须做幂等;第四,扩容分区会改变消息路由,需要评估对顺序性的影响。
日常还要做好堆积告警、自动化预案、定期演练,让问题在早期被发现。
---
**核心考点定位**:考察"分布式实时系统"的设计能力——状态存储、广播机制、一致性模型三者的权衡。
**知识点拆解**:
1. **系统架构分层**:
- **接入层**:WebSocket 网关(Netty),用户长连接挂在这里
- **业务层**:房间状态服务(麦位、在线人数、聊天消息)
- **存储层**:Redis(状态 + 计数器 + 麦位集合)
- **广播层**:Redis Pub/Sub 或 Kafka(跨服务器状态同步)
2. **状态存储设计**:
- 在线人数:`INCR/DECR` 原子操作,key 为 `room:{roomId}:online`
- 麦位集合:`SET` 存当前麦位用户列表,key 为 `room:{roomId}:mics`
- 麦位排队:`LIST` 存排队用户,key 为 `room:{roomId}:mics:queue`
- 用户状态:`HASH` 存用户详细信息,key 为 `room:{roomId}:user:{userId}`
3. **广播机制**:
- **Redis Pub/Sub**:轻量、无持久化,适合状态广播(延迟 < 10ms)
- **Kafka**:可靠、有持久化,适合聊天消息(延迟 < 100ms)
- **WebSocket 网关集群**:多 WS 网关订阅 Pub/Sub,收到消息后推送给本地连接
4. **麦位管理的复杂性**:
- **并发申请**:多用户同时申请同一麦位,用 `SETNX` 或 Lua 脚本保证原子性
- **排队机制**:申请失败进队列,空位时按顺序分配
- **自动释放**:用户下线/心跳超时触发释放,释放后通知下一个排队用户
- **麦位转让**:用户主动让麦,释放后分配给下一个人
5. **在线人数统计**:
- **心跳保活**:客户端 30s 心跳,服务器 60s 超时清理(`EXPIRE` 自动过期)
- **分布式会话**:用户连接挂服务器 A,状态存 Redis,任意服务器可读
- **计数器维护**:`INCR`/`DECR` 原子操作,避免并发错乱
6. **实时同步协议**:
- **WebSocket**:全双工、低延迟(< 100ms)、生产环境首选
- **SSE**:单向推送、HTTP 兼容、延迟略高
- **长轮询**:兜底方案、延迟高(10s)、连接开销大
7. **一致性模型**:
- **强一致**:写 Redis + 同步广播(所有服务器同时收到),延迟 100ms 内
- **最终一致**:写 Redis + 异步广播(通过 MQ),可能短暂不一致,秒级收敛
**考察方向**:
- **深度**:Redis Pub/Sub 的实现原理、Kafka 的分区与消费者组、WebSocket 的心跳机制
- **广度**:与 IM 系统(融云/环信/腾讯云 IM)对比;与弹幕系统(WebSocket + 分片)对比
- **应用**:实时弹幕、在线游戏房间、协同编辑(CRDT)、直播互动
**延伸递进面试题**:
1. **万人在线的 WebSocket 连接怎么横向扩展?**
- 解答要点:WS 网关无状态化(状态放 Redis),多副本部署;用 Redis Pub/Sub 广播状态变更;客户端通过域名访问,LB 做 WebSocket 路由
2. **麦位申请高并发怎么保证原子性?**
- 解答要点:Redis `SETNX` 占位(key 为 `mic:{micId}`),失败则排队;Lua 脚本保证"检查 + 占位"的原子性;空位时 `BLPOP` 阻塞等待下一个申请
3. **强一致和最终一致怎么选型?**
- 解答要点:麦位状态用强一致(同步广播),聊天消息用最终一致(MQ 异步);核心是"用户能感知到的实时"其实是"准实时"(秒级),不必追求毫秒级强一致
**【一句话结论】**
面试官您好,万人在线的核心是"状态存 Redis + 广播走 Pub/Sub + 长连接走 WebSocket",麦位管理用 `SETNX + Lua` 保证原子性,在线人数用心跳保活 + 超时清理。
**【三点展开】**
第一,架构分层:WebSocket 网关(Netty)负责用户长连接,无状态化便于横向扩展;房间状态服务负责业务逻辑,状态全部存 Redis(在线人数 `INCR/DECR`、麦位 `SET`、排队 `LIST`);Redis Pub/Sub 负责跨服务器广播状态变更——用户连在 WS 网关 A,状态变更发生在服务器 B,B 发消息到 Pub/Sub,A 订阅后推送给本地连接。
第二,麦位管理有并发陷阱:多用户同时申请同一麦位,用 `SETNX mic:{micId} userId` 原子占位,失败则进排队队列(`LIST`);空位时 `BLPOP` 阻塞等待下一个申请;用户下线/心跳超时触发释放,释放后通知排队用户。Lua 脚本保证"检查麦位空 + 占位"的原子性,避免竞态。
第三,在线人数用心跳保活:客户端 30s 心跳,服务器 60s 超时清理(`EXPIRE` 自动过期,避免死连接);新用户进房直接读 Redis 拿当前状态,之后通过 WebSocket 订阅房间状态变更,秒级感知。不能用客户端轮询——万人 QPS 会打爆服务器。强一致(麦位状态同步广播,延迟 < 100ms)和最终一致(聊天消息走 MQ,秒级收敛)按场景选型。
**【一句边界】**
当然,如果是百万级并发(YY 语音、KOOK),自建成本高,直接用 IM 云(融云/环信/腾讯云 IM)+ WebSocket 自建弹幕系统更划算——核心是"麦位状态自建,聊天消息用云"。