问题排查 —— 从 ssh 失联、进程退出到 CPU/GC/OOM 全链路定位
10 题 · 平均 2.9/5 · low 3 · medium 3.25 · high 2.33 · 第 25 轮
**核心考点定位**:考察「分层排查」的思维纪律——能不能把"连不上"拆成网络层、主机层、服务层、认证层,并为每层找到成本最低的观测手段。
**知识点拆解**:
1. **带外通道优先**:云厂商控制台/VNC/IPMI 是唯一不依赖 sshd 与网络策略的入口。能通过控制台登进去,问题范围立刻从"整机失联"缩小到"sshd 或网络策略";进不去才需要怀疑内核 panic、硬件、宿主机故障。
2. **三层网络判定**:安全组/ACL(云层)→ 主机防火墙 iptables/firewalld(OS 层)→ sshd 监听(进程层)。telnet/nc 的返回码是分界线:timeout 查前两层,refused 查进程层,connected 查认证层。
3. **主机层高频内因**:磁盘 100%(sshd 无法写 utmp/session 文件,登录后立刻被踢)、内存耗尽触发 OOM Killer 干掉 sshd、负载过高导致 fork 失败(`-bash: fork: Cannot allocate memory`)、`/etc/nologin` 存在、`/etc/security/limits` 或 PAM 配置错误、inode 耗尽。
4. **认证层**:密钥权限必须 600、authorized_keys 不能 group-writable、SELinux 上下文错误、`PermitRootLogin`/`AllowUsers` 被改、fail2ban 或云 WAF 把来源 IP 拉黑。
5. **日志是终点**:`journalctl -u sshd`、`/var/log/secure`、`/var/log/auth.log`、`dmesg -T | grep -i oom`,基本能一锤定音。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:是否理解"网络可达"与"服务可用"是两层,能否用返回码区分。
- **广度**:能关联到云网络(安全组 vs 防火墙)、Linux 资源(磁盘/inode/内存/负载)、PAM 认证体系、监控告警。
- **应用**:线上事故中如何在不重启的前提下恢复访问,以及如何做预防(跳板机、监控 sshd、磁盘水位告警)。
**延伸递进面试题**:
1. **磁盘写满为什么会表现为 ssh 登录失败?**
- 解答要点:sshd 登录要写 utmp/wtmp 与用户 session 文件,写失败会导致认证后立即断开;root 保留块耗尽后 root 也可能写不进去;同时 rsyslog 写不了日志会让排查更难。
2. **telnet 端口通但登录秒断,怎么定位?**
- 解答要点:`ssh -vvv` 看卡在哪个阶段;查 `/var/log/secure`、`/etc/nologin`、PAM 配置、用户 shell 是否被改成 /sbin/nologin、磁盘与 inode。
3. **怎么预防这类故障?**
- 解答要点:跳板机/堡垒机 + 多可用区、控制台带外通道演练、磁盘与 inode 水位告警、sshd 存活探针、fail2ban 白名单、密钥轮换与权限基线检查。
**【一句话结论】**
面试官您好,SSH 连不上我会按"带外通道 → 网络 → 主机 → sshd → 认证"五层往下收,而不是直接重启。
**【三点展开】**
第一,先用云控制台或 VNC 登进去,这是唯一不依赖 sshd 的通道。能进去就说明机器活着,问题在 sshd 或网络策略;进不去再怀疑内核、硬件或宿主机。
第二,用 telnet 看返回值区分层次。timeout 是网络或安全组没放通;refused 是机器可达但 sshd 没监听,去看进程和端口;能连上却认证失败或秒断,就查密钥权限、/etc/nologin、PAM 和磁盘。
第三,主机层最常见的其实是资源问题:磁盘写满、inode 耗尽、内存不足被 OOM Killer 杀掉 sshd、负载过高 fork 失败。最后看 sshd 日志、/var/log/secure 和 dmesg,基本能定死原因。
**【一句边界】**
当然,如果是安全组被误改或 fail2ban 拉黑了来源 IP,机器本身完全正常,只在机器内部查是永远查不出来的——所以一定要先确认外部网络路径。
---
**核心考点定位**:考察能否把"进程没了"拆成三层——JVM 自己退出、被操作系统杀、被编排系统杀,并用 exit code + 落地文件把范围收敛。
**知识点拆解**:
1. **进程主动退出**:`System.exit()`、未捕获异常导致 main 线程结束且无存活非守护线程、Spring Boot 优雅停机(SIGTERM 被注册的 shutdown hook 接住,日志会打印 closing/stop 字样)、`-XX:+ExitOnOutOfMemoryError` 触发。
2. **JVM 崩溃退出**:JVM 自身 bug、原生内存越界、栈溢出或 `SIGSEGV` 会生成 `hs_err_pid<pid>.log`(含当前线程栈、JVM 参数、内存映射),并可能生成 core dump。这类"挂"最常见却最容易被误判为被外部杀。
3. **被操作系统杀**:内核 OOM Killer 在物理内存耗尽时按 oom_score 选进程,日志落在 `dmesg` / `/var/log/messages`;`kill -9` 留下 137;`kill`(SIGTERM)留下 143 且一般有优雅关闭日志。
4. **被编排/健康检查杀**:K8s 的 livenessProbe 失败会重启容器(重启次数在 `RESTARTS` 列),超 memory limit 会 OOMKilled;挂了 systemd 的服务若配 `Restart=on-failure` 会表现为"自己又起来了"。
5. **证据优先级**:exit code > hs_err > 应用日志末行 > 容器事件 > 内核日志 > Shell history(history 最不可靠,非交互执行不记录)。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:是否知道"堆 OOM 默认不杀进程"、exit code 的语义、hs_err 的生成条件。
- **广度**:能关联到 JVM 参数(HeapDumpOnOutOfMemoryError、ExitOnOutOfMemoryError、ErrorFile)、Linux OOM Killer、K8s 生命周期与探针、systemd。
- **应用**:如何从"服务反复重启"定位到是内存泄漏、探针配置错误还是资源 limit 太小。
**延伸递进面试题**:
1. **进程退出码 137 和 143 分别代表什么?**
- 解答要点:128+信号编号。137=128+9(SIGKILL,常见于 OOMKilled 或 kill -9),143=128+15(SIGTERM,优雅停机入口)。容器里 137 还要区分是 cgroup OOM 还是被驱逐。
2. **堆 OOM 为什么进程没退出?怎么让它退出?**
- 解答要点:`OutOfMemoryError` 是 Throwable,默认只终止抛出它的线程,进程靠其他非守护线程存活。加 `-XX:+ExitOnOutOfMemoryError` 让 JVM 在 OOM 后立即退出,配合 `-XX:+HeapDumpOnOutOfMemoryError` 留证据,再用容器重启自愈。
3. **怎么区分"被 OOM Killer 杀"和"JVM 自己崩溃"?**
- 解答要点:OOM Killer 在 dmesg/journal 有 `Killed process`,无 hs_err;JVM 崩溃一定有 hs_err_pid 或 core,且 exit code 多为 134/139。两者都无则查容器事件与健康检查。
**【一句话结论】**
面试官您好,进程挂了我会先看有没有留下日志和 exit code,用这个二分法区分"自己走的"还是"被杀的"。
**【三点展开】**
第一,自己退出。看应用日志末行:如果是 Spring 的 closing/stop,说明收到了 SIGTERM 走了优雅停机,那就是发布、缩容或健康检查;如果是未捕获异常或 OOM,要看是不是配了 `-XX:+ExitOnOutOfMemoryError`——注意默认堆 OOM 只杀线程不杀进程。
第二,被系统杀。exit code 是硬证据:143 是 SIGTERM,137 是 SIGKILL,容器里 137 基本就是 OOMKilled 或被驱逐。再看 `kubectl describe pod` 的 Last State、宿主机 `dmesg` 里的 `Killed process`,以及 systemd 的 Restart 策略。
第三,JVM 自己崩。去找 `hs_err_pid*.log`,里面有崩溃线程栈和内存映射;有 core dump 更好。这种情况常被误判成被外部杀掉。
**【一句边界】**
当然,如果是磁盘写满导致日志根本没落盘,上面的日志判断全都失效,所以排查前要先确认磁盘和日志采集链路是正常的。
---
**核心考点定位**:考察是否理解 JVM 的**运行时字节码增强**机制,而不只是会敲命令。
**知识点拆解**:
1. **Attach 机制**:Arthas 先运行 `java -jar arthas-boot.jar`,通过 JDK 的 Attach API(`com.sun.tools.attach.VirtualMachine`)连到目标 JVM 的 attach listener(Linux 下是一个 unix socket,路径在 `/tmp/.java_pid<pid>`),然后把 `arthas-agent.jar` 作为 agent 传进去。
2. **agentmain 而非 premain**:`premain` 只能在 JVM 启动时通过 `-javaagent` 生效;`agentmain` 由 `loadAgent` 触发,JVM 会回调 `Agent-Class` 的 `agentmain(String, Instrumentation)`,从而在运行时拿到 `Instrumentation` 实例。
3. **retransform 已加载类**:`Instrumentation.retransformClasses(clazz)` 允许对已经加载的类重新转换(不能改方法签名、不能加字段),类转换器(`ClassFileTransformer`)用 ASM 在方法体首尾插入 `Spy.before(...)` / `Spy.afterReturning(...)` 调用。
4. **Spy + AdviceListener**:插桩代码只做一件事——把方法标识、入参、时间戳交给 `Spy`,`Spy` 再分发给注册的 `AdviceListener`。真正的耗时计算、条件过滤、输出格式化都在监听器里完成,所以同一套插桩能支撑 `watch`、`trace`、`monitor`、`tt` 多个命令。
5. **性能代价**:增强后每次方法调用多几次静态调用与 ThreadLocal 访问;`tt` 会保存调用现场(入参/返回值/异常)到内存,量大时本身就会 OOM;`trace` 遍历整条调用链,深层递归或高频方法上开销显著。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:attach + agentmain + retransform + ASM 这条链是否打通;知道 `Instrumentation` 的能力边界(不能改签名、不能增删字段)。
- **广度**:能关联到 Java Agent、字节码框架(ASM/Javassist/ByteBuddy)、JVM TI、Spring AOP 与它的区别。
- **应用**:生产上如何用 `watch`/`trace` 的条件与次数限制控制开销,以及什么场景该改用 JFR、async-profiler。
**延伸递进面试题**:
1. **Arthas 和 Spring AOP 都是"增强方法",本质区别是什么?**
- 解答要点:Spring AOP 在 Bean 创建期用动态代理包装对象,只影响被代理的 Bean 且需容器管理;Arthas 直接改类的字节码,对所有实例生效,包括 JDK 类和自己 new 出来的对象,不需要容器也不需要重启。
2. **为什么 Arthas 不能给方法加参数或改返回值类型?**
- 解答要点:`retransformClasses` 受 JVM 约束,只能改方法体,不能改方法签名、字段、继承关系;否则已加载类的元数据与栈帧不兼容。
3. **生产上怎么安全地用?**
- 解答要点:优先用 `monitor`/`watch` 并加 `-n` 次数限制与条件表达式;避免对高频方法 `trace`;观测完 `stop` 或 `reset` 卸载增强;更长期的手段是 JFR 或 async-profiler 采样。
**【一句话结论】**
面试官您好,Arthas 本质是**运行时字节码增强**:靠 Attach API 挂上去,再用 ASM 改已加载类的方法体,在前后插埋点。
**【三点展开】**
第一,它怎么进到进程里。Arthas 自己是普通 Java 进程,用 JDK 的 Attach API 连到目标 JVM 的 attach socket,把 arthas-agent.jar 作为 agent 加载。走的是 `agentmain` 而不是启动参数里的 `-javaagent`,所以不需要重启。
第二,进去之后怎么改。拿到 `Instrumentation` 后调 `retransformClasses` 重新转换已加载的类,用 ASM 在方法入口和出口插入对 `Spy` 的静态调用,把方法标识、入参、时间戳交出去,真正的统计逻辑在 `AdviceListener` 里。这样 `watch`、`trace`、`monitor`、`tt` 能共用同一套增强。
第三,代价在哪。每次调用多几次静态调用和 ThreadLocal 访问;`tt` 会把调用现场存内存,量大直接 OOM;`trace` 在深层链路和高频方法上开销最明显,所以生产上要加次数和条件限制,观测完及时 stop。
**【一句边界】**
当然,如果只是想看整体火焰图和 CPU 分布,JFR 或 async-profiler 的采样开销更低,不必用 Arthas 做逐方法增强。
---
**核心考点定位**:考察对「CPU 时间去哪了」的理解——用户态忙等、内核态系统调用、阻塞让出、调度抢占,这四种状态的组合决定了 CPU 曲线长什么样。
**知识点拆解**:
1. **忙等(busy loop)**:循环体不含任何阻塞调用,线程始终处于 RUNNABLE,占满一个核。单线程死循环在 8 核机器上表现为 CPU 12.5%,多线程死循环才会接近 100%——这是面试常考的陷阱。
2. **阻塞让出**:`Thread.sleep`、`Object.wait`、`LockSupport.park`、阻塞队列 `take`、socket 读、文件读,都会把线程置为 WAITING/BLOCKED 并让出 CPU。此时即使外层是 `while(true)`,CPU 也不涨,但**吞吐/延迟会出问题**,表现为"服务卡住但 CPU 很闲"。
3. **IO 等待的两面性**:真正的磁盘/网络等待表现为 `%wa` 升高、CPU 空闲;但如果 IO 极快(本地 SSD、页缓存命中),循环会不断发起 syscall,表现为 `%sys` 高而非 `%us` 高,这是区分"算得多"和"调得多"的关键。
4. **CPU 飙高的其他来源**:GC 线程(`%us` 高且伴随 GC 日志)、JIT 编译线程(C2 CompilerThread,短时高)、锁自旋(CAS 失败重试,`%us` 高但无热点方法)、上下文切换过多(`cs` 高、`%sy` 高)、正则灾难性回溯、日志同步刷盘。
5. **定位链路**:进程级 `top` → 线程级 `top -Hp` → 栈 `jstack`/`jcmd Thread.print` → 采样 `async-profiler`/JFR。死循环的特征是**同一线程在同一行反复出现**,而不是分散在多个方法。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否区分 RUNNABLE 忙等与 WAITING 阻塞,能否解释 `%us` / `%sy` / `%wa` 三种高法的不同含义。
- **广度**:能关联到线程调度、CAS 自旋、GC 线程、Redis 单线程事件循环、Netty EventLoop。
- **应用**:给出从现象到线程栈的完整定位路径,并说明如何用火焰图验证。
**延伸递进面试题**:
1. **8 核机器上一个线程死循环,CPU 使用率是多少?为什么不是 100%?**
- 解答要点:约 12.5%。`top` 的 CPU 百分比是整机口径,单线程最多占满一个核;若进程内有多线程死循环才会接近 100%。
2. **CPU 100% 一定是死循环吗?**
- 解答要点:不一定。GC(尤其 Full GC 或 CMS 并发失败)、JIT 编译、锁自旋、正则回溯、频繁上下文切换都会拉高。要用线程栈和火焰图区分,GC 场景看 GC 日志和 `jstat -gcutil`。
3. **为什么 Redis 单线程死循环却不吃满 CPU?**
- 解答要点:`aeMain` 里核心是 `epoll_wait`,无事件时阻塞让出 CPU;有事件才处理。忙等只在事件密集时出现,所以 Redis 的 CPU 消耗与 QPS 正相关,闲时接近 0。
**【一句话结论】**
面试官您好,死循环不一定吃 CPU,关键看循环体里有没有阻塞;但反过来说,CPU 飙高也不一定是死循环。
**【三点展开】**
第一,纯忙等会让线程一直 RUNNABLE,占满一个核。注意口径:8 核机器上单线程死循环只显示 12% 左右,只有多线程忙等才会接近 100%,这是很容易答错的地方。
第二,循环体里有阻塞就不涨 CPU。sleep、wait、park、阻塞队列、socket 读都会让线程进入 WAITING 并主动让出 CPU,所以 Redis 的 aeMain、定时任务调度器都是死循环却不吃满——它们的本质是"事件驱动 + 阻塞等待",不是忙等。
第三,反过来 CPU 高还有别的来源:GC 线程、JIT 编译、CAS 自旋、正则回溯、频繁上下文切换。所以定位要落到线程:top 找进程,top -Hp 找线程,线程 id 转十六进制后 jstack 抓栈,同一行反复出现才是死循环;再看火焰图确认热点。
**【一句边界】**
当然,如果循环体卡在磁盘或网络 IO 上,CPU 可能一点都不高,表现是接口全超时、`%wa` 升高——这时候拿 CPU 指标去判断会完全跑偏,要看 IO 和线程栈。
---
**核心考点定位**:考察能不能用"从外到内"的顺序把 MySQL CPU 问题收敛到一条 SQL,并且能区分"算得多"(CPU 真高)与"等得多"(CPU 不高)两种假象。
**知识点拆解**:
1. **先确认是谁在吃 CPU**:OS 层 `top` 看 mysqld 占用,`pidstat -p <pid> 1` 区分 `%usr` 与 `%sys`。`%sys` 高常见于大量系统调用、上下文切换;再看是否有备份、数据同步、监控采集在抢 CPU。
2. **再看负载形态**:`SHOW GLOBAL STATUS LIKE 'Threads_running'`、`Questions`、`Com_select` 算 QPS。**QPS 高且 CPU 高** = 短查询海量并发,通常缺索引或热点行;**QPS 低但 CPU 高** = 少数重量级 SQL(大排序、大聚合、全表扫描);**QPS 低、线程多、CPU 不高** = 锁等待或 IO 等待。
3. **定位具体 SQL**:`SHOW FULL PROCESSLIST` 看正在执行的语句与状态(`Sending data`、`Copying to tmp table`、`Waiting for lock`);`performance_schema.events_statements_summary_by_digest` 按总耗时排序找 TOP SQL;`sys.statement_analysis` 直接给格式化结果;慢日志配合 `pt-query-digest` 做聚合。
4. **确认执行计划**:对 TOP SQL 跑 `EXPLAIN` / `EXPLAIN FORMAT=JSON`,看 `type` 是否 ALL、`rows` 是否爆炸、`Extra` 是否有 `Using filesort` / `Using temporary`;对比历史计划判断是否突变(统计信息、数据量分布变化)。
5. **锁与死锁的判别**:`SHOW ENGINE INNODB STATUS` 看 `LATEST DETECTED DEADLOCK` 与 `TRANSACTIONS`;`performance_schema.data_locks` / `data_lock_waits`(8.0)看阻塞链。注意:死锁会被自动回滚,持续锁等待才是连接堆积的主因。
6. **治理动作**:优先 `KILL` 掉最重的单条 SQL 止血;对批量任务拆批、限流、错峰;补索引并验证;回收高危账号的写权限;加慢查询与 `Threads_running` 告警。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否区分"CPU 高"与"连接堆积"的因果方向,理解锁等待不消耗 CPU。
- **广度**:能关联到索引与执行计划、Buffer Pool、InnoDB 锁、慢日志体系、备份工具的影响。
- **应用**:给出可执行的止血顺序(kill → 限流 → 补索引 → 拆批 → 权限治理)。
**延伸递进面试题**:
1. **CPU 打满但 `SHOW PROCESSLIST` 里 SQL 都很快,可能是什么原因?**
- 解答要点:QPS 太高(短查询海量并发),或存在大量未出现在列表里的已完成语句;用 digest 表按总耗时看聚合,再看是否连接池把并发放大。
2. **锁等待会让 CPU 变高吗?**
- 解答要点:通常不会。等待中的线程是休眠状态,CPU 反而下降,表现为 `Threads_running` 高、响应时间飙升。若同时 CPU 也高,说明另有计算型 SQL 在跑,要分开看。
3. **怎么判断是索引问题还是数据量问题?**
- 解答要点:`EXPLAIN` 看 `type` 与 `rows`,`Handler_read_*` 看实际扫描行数;补索引后 `rows` 显著下降即为索引问题;若索引已最优但扫描行数仍大,是数据量/查询范围问题,需要改查询或分区。
**【一句话结论】**
面试官您好,我会先确认 CPU 到底是不是 mysqld 吃的,再用"QPS 与线程数的组合"判断是算得多还是等得多,最后落到具体 SQL。
**【三点展开】**
第一,先分层。OS 上 top 和 pidstat 确认是 mysqld 而不是备份或同步工具在抢 CPU,再看 `%usr` 和 `%sys` 的构成。然后拿 `Threads_running` 和 QPS 对照:QPS 高、CPU 高,多半是海量短查询,通常缺索引;QPS 低、CPU 高,是少数重量级 SQL 在做大排序或全表扫描;线程多但 CPU 不高,那是在等锁或等 IO,方向完全不同。
第二,定位 SQL。`SHOW FULL PROCESSLIST` 看正在跑什么、卡在什么状态,`performance_schema` 的 digest 表按总耗时排 TOP,慢日志用 pt-query-digest 聚合,然后对 TOP SQL 做 EXPLAIN,看 type 是不是 ALL、rows 有没有爆炸、有没有 filesort 和临时表。
第三,止血和治理。先 kill 掉最重的单条 SQL,必要时对批量任务限流;然后补索引、把大事务拆小、错峰执行,回收高危账号权限,最后补上慢查询和 Threads_running 的告警。
**【一句边界】**
当然,如果是锁等待或死锁导致的连接堆积,CPU 往往不高,这时候盯着 CPU 看会误判,要直接查 InnoDB 的锁等待链。
---
**核心考点定位**:考察统计口径意识与"先收敛、再归因"的排障纪律——能不能用有限信息把一个模糊的"不稳定"变成可定位的维度。
**知识点拆解**:
1. **指标口径**:平均值会掩盖长尾,必须看 P95/P99/P999 与最大值;同时看错误率与 QPS,三者结合才能判断是"变慢"还是"流量变了"。时间窗口要与发布、活动、定时任务对齐。
2. **偶发 vs 持续**:偶发尖刺常见于 GC、连接池瞬时耗尽、下游超时重试、定时任务、网络丢包;持续抬升常见于容量不足、慢查询、索引失效、下游劣化、内存泄漏。
3. **维度切分**:实例(单机 vs 集群)、接口(部分接口说明范围有限)、客户端(版本/地域/网络)、参数(大 key、大分页)、下游依赖。差异维度往往直接指向根因。
4. **链路追踪**:SkyWalking/Zipkin/Jaeger 按 trace 拆 span,看耗时分布在哪一段。注意采样策略与时钟偏移,跨机器 span 的时间要以同一时钟源为准。
5. **长尾成因**:Redis 大 key/热 key 与慢命令(`KEYS`、`HGETALL` 大 hash)、MySQL 偶发锁等待或慢查询、连接池排队(HikariCP `connectionTimeout`)、线程池队列积压、GC 停顿、下游限流、DNS 解析、网络重传、容器 CPU throttling。
6. **重试放大**:下游抖动时上游超时重试,会让流量成倍增长,形成"越抖越重"的正反馈;必须有重试预算、熔断与退避。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:是否理解长尾与平均值的差别,能否用采样与维度切分定位偶发问题。
- **广度**:能关联到 APM/链路追踪、连接池与线程池、Redis 慢命令、MySQL 锁、容器资源限制、网络。
- **应用**:给出可执行的排查顺序与验证方法,并能提出长期治理(超时预算、熔断、缓存、限流)。
**延伸递进面试题**:
1. **P99 很高但平均值正常,说明什么?**
- 解答要点:典型的少数请求严重变慢,常见于 GC 停顿、连接池排队、锁等待、下游长尾、网络重传。平均值被大量快请求稀释,必须按分位看。
2. **部分机器慢、部分机器正常,怎么定位?**
- 解答要点:按实例切分 QPS/延迟,比对版本、配置、CPU throttling、GC 参数、连接池大小、所在可用区与网络质量;再抓单机线程栈与火焰图。
3. **重试为什么会让抖动更严重?怎么治?**
- 解答要点:重试放大流量,下游更慢,超时更多。治法是重试预算(不超过请求量的 10%)、指数退避加抖动、只对幂等请求重试、熔断与降级、超时时间逐层递减。
**【一句话结论】**
面试官您好,我会先定性再切维:用 P99 和错误率判断是抖动还是劣化,再按实例和链路把范围收敛到一个具体环节。
**【三点展开】**
第一,看指标口径。平均值会骗人,一定要看 P99、P999 加错误率和 QPS,跟基线比、跟发布和定时任务的时间点对。持续抬升和偶发尖刺,排查方向完全不同。
第二,切维度。既然是部分接口,就先按实例、接口、客户端版本、参数规模切一刀。经常一切就发现是某台机器慢——CPU 被 cgroup 限流、版本旧、连接池小;如果所有实例都慢,再往下看链路。
第三,看链路。有 SkyWalking 就直接按接口排序看慢 span,判断是自身、Redis 大 key、MySQL 锁等待还是三方超时;没有 APM 就在关键阶段打点,虽然成本高但能兜底。定位到下游抖动后,还要检查上游有没有超时重试把流量放大。
**【一句边界】**
当然,如果 APM 采样率很低,那 1% 的慢请求可能一条都没采到,这时候要按延迟阈值强制采样,或者直接用单机火焰图和线程栈去抓,不能只信链路面板。
---
**核心考点定位**:考察能否把"发布期负载异常"归因到**冷启动 + 流量分配**这两个机制上,并给出工程化对策。
**知识点拆解**:
1. **JIT 冷启动**:新 JVM 先解释执行,热点方法达到阈值才触发 C1/C2 编译。发布初期 CPU 里既有业务计算又有编译线程开销,方法执行还慢,表现为 CPU 高、P99 长尾,跑一段时间后自然消失。
2. **缓存与连接池冷**:本地缓存(Caffeine/Guava)为空,请求全部穿透到 Redis/DB;数据库连接池、HTTP 连接池尚未建立,需要反复握手;线程池也在扩容。这些都会让首批实例的单位请求成本显著更高。
3. **流量分配不均**:滚动发布时旧实例被摘除,新实例数量少却承接同等流量;若 LB/K8s 没有慢启动(slow start)策略,新实例一上线就吃满权重,单机压力被放大。会话保持、长连接不断开也会让流量堆积在少数节点。
4. **健康检查与就绪探针**:就绪探针通过过早,流量在应用真正可用前就打进来;探针配置不合理(超时太短)会导致实例被反复摘除重建,进一步加剧抖动。
5. **其他可能**:新版本引入了更重的初始化(大对象、全量加载配置)、依赖的下游在发布期被瞬时放大、日志级别被误调成 DEBUG、第一批恰好落在资源紧张的宿主机。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:是否理解 JIT 分层编译与冷启动的成本曲线,能否区分"代码问题"与"未预热"。
- **广度**:能关联到缓存穿透、连接池、LB 权重与慢启动、K8s 探针、容量评估。
- **应用**:能给出预热、分批比例、观测窗口与回滚预案的完整方案。
**延伸递进面试题**:
1. **怎么判断是冷启动而不是新版本有 bug?**
- 解答要点:看同版本后续批次是否复现、看指标是否随时间自然回落、看第一批与后续批次的 CPU/延迟曲线形状;如果只有首批高且 10-30 分钟内收敛,基本是冷启动。
2. **JVM 层面怎么预热?**
- 解答要点:发布后先跑预热脚本打核心接口;用分层编译参数控制;考虑 AppCDS 加速类加载;有条件的用 AOT/GraalVM;也可在 LB 层做慢启动逐步放量。
3. **K8s 下怎么避免新 Pod 一上线就被打满?**
- 解答要点:配置 startupProbe 与合理的 readinessProbe(initialDelaySeconds、failureThreshold),开启 LB/Ingress 的 slow start,设置 `minReadySeconds`,并用 PreStop + 优雅停机保证摘流顺序。
**【一句话结论】**
面试官您好,只有第一批高、后面正常,最典型的原因是**新实例冷启动**,不是代码有问题,处置上先暂停后续批次保现场。
**【三点展开】**
第一,先解释现象。新 JVM 刚起来时方法还在解释执行,热点没到阈值没触发 C1/C2 编译;本地缓存是空的,请求全打到 Redis 和数据库;连接池、线程池也没建好。所以同样流量下,首批实例的 CPU 和 P99 明显更高。跑一段、请求量上去之后自然回落,这就是后面批次正常的原因。
第二,排除其他可能。第一批节点少,如果 LB 没有慢启动,流量会按权重压到少数新节点;就绪探针配置不当会让流量在应用真正可用前打进来;会话保持和长连接也会让流量堆积。要按实例看 QPS 和延迟分布,确认是"每台都高"还是"少数几台高"。
第三,处置。当下先暂停后续批次,观察是否在 10-30 分钟内收敛;确认是新版本问题时立即回滚。长期做三件事:发布前预热核心接口和缓存、LB 开慢启动并让批次比例递增、探针加初始延迟和 minReadySeconds,发布后拿同口径的压测数据做对比。
**【一句边界】**
当然,如果后续批次也高,或者指标不随时间回落,那就不是冷启动,要按新版本引入的性能问题去查,比如日志级别、序列化方式、新增的同步调用。
---
**核心考点定位**:考察能否把"日志"从"一行代码"拆成一条完整的成本链——**参数求值 → 格式化 → 序列化 → 加锁入队 → IO 写盘**,并据此选择优化手段。
**知识点拆解**:
1. **参数求值陷阱**:`log.debug("user=" + user)` 和 `log.debug("user={}", JSON.toJSONString(user))` 都会在级别关闭时照样执行拼接与序列化。正确写法是 `if (log.isDebugEnabled())` 或使用惰性求值的 Supplier 形式。这是最常见的"日志偷 CPU"。
2. **格式化与序列化**:占位符替换、时间格式化(`SimpleDateFormat` 还是线程不安全的)、异常栈的 `printStackTrace` 字符串化(深层异常栈的字符串化成本极高)、大对象 JSON 序列化。
3. **同步 appender 的锁竞争**:logback 的 `OutputStreamAppender` 对每个 appender 加锁,高并发下线程在锁上排队,表现为 `%sys` 高、上下文切换多,火焰图能看到 `ReentrantLock.lock` 与 `write`。
4. **IO 与刷盘**:每条日志一次 `write` 系统调用;`immediateFlush=true` 会频繁 flush;磁盘慢或日志文件过大时,写放大明显。容器里日志走 stdout 还要经过 runtime 的日志管道,量大会拖垮节点。
5. **异步日志的两面性**:log4j2 Disruptor / logback AsyncAppender 把写盘移出业务线程,但队列满时有 `Discard`、`Block` 两种策略——`Block` 会把业务线程重新拖回 IO,`Discard` 会丢日志。队列容量与消费者线程数配置不当,问题只是从"慢"变成"丢"或"卡"。
6. **其他放大器**:循环体内打日志(N 次循环 N 条日志)、DEBUG 级别被误开到线上、日志采集 agent(Filebeat/Fluent Bit)CPU 抢占、MDC/ThreadLocal 的 traceId 生成与拷贝。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清日志成本链的每一环,并区分 `%usr`(算)与 `%sys`(写/锁)两种表现。
- **广度**:能关联到日志框架(logback/log4j2)、异步队列、容器 stdout、日志采集、JVM 内存分配。
- **应用**:给出"先证伪再定位再优化"的完整路径,以及异步日志的取舍。
**延伸递进面试题**:
1. **为什么火焰图里日志相关栈很高,但 jstack 抓不到?**
- 解答要点:jstack 是瞬时快照,采样点落在浅栈或阻塞点的概率高;火焰图是周期性采样聚合,能反映时间分布。热点定位要用 async-profiler/JFR,jstack 更适合看死锁、阻塞和线程状态。
2. **异步日志会不会丢日志?怎么权衡?**
- 解答要点:会。队列满时按策略丢弃或阻塞。金融/交易类场景要可靠就选 `Block` 并给足队列与磁盘,日志量大的普通业务可选 `Discard` + 采样,关键是显式选择而不是默认。
3. **线上 CPU 高,怀疑日志,怎么快速验证?**
- 解答要点:动态调高日志级别或加 TurboFilter 关掉可疑 logger,观察 CPU 是否立刻回落;同时看日志文件增长速度(`ls -l` 两次对比)与火焰图中 logger 栈占比。回落后再逐条二分定位到具体日志语句。
**【一句话结论】**
面试官您好,QPS 没涨但 CPU 高,说明单请求变贵了;日志的成本不在"打"这一下,而在参数求值、格式化、加锁和写盘这四段。
**【三点展开】**
第一,先解释为什么日志能吃掉 CPU。最常见的是级别判断失效,debug 关了但字符串拼接和 JSON 序列化照样执行;其次是同步 appender 的锁竞争和每条日志一次 write 系统调用。看火焰图能区分:热点在 StringBuilder 或序列化就是算得多,热点在 write 或 lock 就是写得贵。前者 `%usr` 高,后者 `%sys` 高。
第二,定位手段不能只靠 jstack。jstack 是瞬时快照,热点方法很难恰好抓到,应该用 async-profiler 或 JFR 采样看火焰图,再结合日志文件增长速度、日志条数突增来交叉验证。如果是循环里打日志,日志行数会明显暴涨,一眼就能看出来。
第三,验证和优化。最快的证伪是把可疑 logger 的级别临时调高或加 TurboFilter 关掉,看 CPU 是否立刻回落;回落后再二分到具体那一行。优化上优先去掉无谓的序列化和循环内日志,再考虑异步 appender,但要明确队列满时是丢弃还是阻塞。
**【一句边界】**
当然,如果日志是走 stdout 交给容器 runtime 收集,瓶颈可能不在应用线程,而在节点的日志管道或采集 agent,这时候应用侧怎么优化都没用,要看节点层面的 CPU 和磁盘。
---
**核心考点定位**:考察能否把"频繁 Full GC"拆成**三类根因**——真泄漏、容量不足/分配过快、非堆问题,并用趋势判据而不是单点快照来区分。
**知识点拆解**:
1. **时间线对齐**:Full GC 开始时间点对应的发布、配置变更、流量变化、定时任务、缓存预热。周期性出现(如每天 2:00)优先怀疑定时任务、批量导出、报表、缓存全量刷新。
2. **GC 日志采集**:JDK 8 用 `-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:<file>`,JDK 9+ 用 `-Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags`。关键是能拿到每次 GC 前后的各代容量与耗时。
3. **趋势判据**:用 `jstat -gcutil <pid> 1000` 观察 `O` 与 `FGC`。**每次 FGC 后老年代基线逐步抬高** = 存活对象在累积 = 泄漏;**每次都能回到同一基线但增长极快** = 分配速率高、新生代过小或对象过早晋升;**老年代不高但 FGC 频繁** = `System.gc()` 被调用、元空间不足、Humongous 分配(G1)。
4. **GC cause 分类**:`Allocation Failure`(正常分配失败)、`System.gc()`(有人显式调用,常见于 NIO DirectByteBuffer 回收或第三方库)、`Metadata GC Threshold`(元空间不足)、`Humongous Allocation`(G1 大对象超过 region 一半)、`Promotion Failed`(晋升失败,Survivor 不足或老年代碎片)。
5. **非堆也要查**:元空间(`-XX:MetaspaceSize/MaxMetaspaceSize`,类加载器泄漏、动态代理类暴涨)、直接内存(`-XX:MaxDirectMemorySize`、Netty)、线程栈(线程数暴涨,每线程 1MB 预留)、代码缓存(CodeCache 满会触发编译退化为解释执行,间接影响 GC 节奏)。
6. **定位持有者**:`jmap -histo:live <pid>` 看类实例数与总字节数 TOP;`jcmd <pid> GC.heap_dump` 导出后 MAT 看 Dominator Tree 与 Path to GC Roots,确认是静态集合、缓存、ThreadLocal、监听器还是连接池持有。配合 `jstack` 看哪些线程还在往里写。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否用 post-GC 存活集趋势区分泄漏与容量不足,能否从 GC cause 反推根因。
- **广度**:能关联到分代模型、晋升机制、元空间、直接内存、缓存与线程池的持有关系。
- **应用**:能给出止血(扩容/回滚/重启)与根治(修缓存上限、修泄漏点、调参)两套动作。
**延伸递进面试题**:
1. **Full GC 后老年代能降下来,但还是频繁 Full GC,是什么原因?**
- 解答要点:不是泄漏,是分配速率过高或堆偏小。查大对象分配、批量查询、缓存全量刷新;调大新生代、提高晋升阈值、减少短命大对象。
2. **堆内存不高,为什么 Full GC 还是很频繁?**
- 解答要点:看 GC cause。可能是 `System.gc()` 被显式调用(DirectByteBuffer 回收)、元空间达到阈值、G1 的 Humongous 分配频繁,或 CodeCache 满导致编译退化。这些都不在堆里。
3. **怎么确认是元空间泄漏?**
- 解答要点:`jstat -gc` 看 `MU/MC` 持续上涨、`FGCC` 对应 `Metadata GC Threshold`;`jcmd VM.metaspace` 或 `jmap -clstats` 看类加载器数量;常见于热部署、动态生成代理类、反射滥用、Groovy/脚本引擎。
**【一句话结论】**
面试官您好,频繁 Full GC 我分两步:先用"FGC 后老年代基线是否逐次抬高"区分泄漏和容量不足,再顺着 GC cause 和堆快照找到具体持有者。
**【三点展开】**
第一,先对齐时间线。什么时候开始的、对应哪次发布或配置变更、是不是每天固定时间出现。周期性出现优先怀疑定时任务和缓存全量刷新,这一步经常能直接省掉后面所有分析。
第二,用趋势而不是单点判断。`jstat -gcutil` 连续看,如果每次 FGC 后老年代都回到同一基线,只是涨得快,那是分配速率高或堆太小,调新生代和晋升阈值;如果基线逐次抬高,那才是泄漏,再去导堆。同时看 GC cause:System.gc 是有人显式调用,Metadata GC Threshold 是元空间,Humongous Allocation 是 G1 大对象,方向完全不同。
第三,定位持有者。`jmap -histo:live` 看 TOP 类和实例数,导堆后用 MAT 的 Dominator Tree 和 Path to GC Roots 找到是谁在持有,再用 jstack 看哪个线程在持续写入。常见的是无界本地缓存、静态集合、ThreadLocal 没 remove、监听器没反注册。
**【一句边界】**
当然,如果老年代不高、FGC 却频繁,那根本不是堆的问题,要往元空间、直接内存和 System.gc 上查,盯着堆看会一直找不到原因。
---
**核心考点定位**:考察对 POI 内存模型的理解深度,以及能否把"OOM"从现象推到"对象模型 + 流式方案 + 架构改造"三层。
**知识点拆解**:
1. **usermodel 的 DOM 本质**:`XSSFWorkbook` 把整个 xlsx 解析成内存对象树,`Sheet → Row → Cell` 逐层持有;每个 `XSSFCell` 除了值还持有样式索引、数据类型等字段。20 万行 × 20 列就是 400 万个 cell 对象,加上 `SharedStringsTable`(所有字符串去重后常驻)与 `StylesTable`,堆占用轻松上 GB。
2. **写场景的流式:SXSSFWorkbook**:它包装 `XSSFWorkbook`,只保留最近 `windowSize` 行在内存,超出部分刷到磁盘临时文件,最后统一打包成 zip。内存与 `windowSize × 列数` 成正比,而不是总行数。注意:`windowSize` 默认 100,设太大等于没优化;临时文件必须 `dispose()`;`autoSizeColumn` 在 SXSSF 下不支持或很贵。
3. **读场景的流式:事件模型**:`XSSFReader` + `SheetContentsHandler`(SAX)逐行解析,内存占用与文件大小无关,只与单行大小相关。EasyExcel 的读就是这套模型的封装;写侧 EasyExcel 也基于 SXSSF。
4. **样式泄漏**:`createCellStyle()` 每次调用都会新增样式,xlsx 样式上限约 64000,超了报错,且样式表常驻内存。正确做法是复用少量 CellStyle 对象。这是 POI 导出最隐蔽的内存放大器。
5. **临时文件与磁盘**:SXSSF 刷盘会在 `java.io.tmpdir` 生成临时文件,磁盘满会直接导致导出失败;容器里 tmp 往往是 emptyDir,容量小,必须显式指定目录并做清理。
6. **非堆内存**:xlsx 是 zip,压缩/解压走 `Inflater/Deflater`,占用直接内存或 native 内存。报 `Direct buffer memory` 或容器 OOMKilled 时,堆快照正常,要看 NMT 与 `-XX:MaxDirectMemorySize`。
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 usermodel 与 eventmodel 的差别,SXSSF 的滑窗机制与代价。
- **广度**:能关联到堆/非堆内存、临时文件、样式表、HTTP 流式响应、异步任务。
- **应用**:能给出从"改流式"到"异步 + 分片 + 对象存储"的分层方案,并知道各自的适用边界。
**延伸递进面试题**:
1. **SXSSFWorkbook 的 windowSize 设多大合适?**
- 解答要点:按"内存预算 ÷ (列数 × 单行对象开销)"估算,通常几百行;设太大失去意义,设太小会让刷盘过于频繁影响吞吐。要结合临时文件所在磁盘的 IO 能力。
2. **为什么导出的 Excel 里样式一多就特别慢、甚至报错?**
- 解答要点:每个 cell 新建 CellStyle 会让样式表膨胀,xlsx 样式数量有上限且全量常驻内存。应复用有限几个样式对象,按"数据 + 样式索引"写入。
3. **20 万行必须导出,架构上怎么设计?**
- 解答要点:接口只做任务提交,异步线程分片生成多个文件(SXSSF 或 EasyExcel),合并或打包后存对象存储,通过站内信/邮件通知下载;同时对单次导出量设上限,超限走审批或分批。
**【一句话结论】**
面试官您好,POI 导出 OOM 的根因是 usermodel 把整个工作簿解析成内存对象树,行数一多就是几百万个 cell 对象。
**【三点展开】**
第一,先看清对象模型。`XSSFWorkbook` 下面挂着 Sheet、Row、Cell,每个 Cell 都是独立对象,还带样式索引;共享字符串表和样式表也是全量常驻。20 万行乘 20 列就是 400 万个对象,堆涨到几个 G 很正常。堆转储里能看到 `XSSFCell` 的实例数霸榜,这是最直接的证据。
第二,改造分读写两条路。写用 `SXSSFWorkbook`,它只保留 windowSize 行在内存,其余刷到磁盘临时文件,最后打包成 zip,内存跟总行数脱钩;读用 `XSSFReader` 加 SAX 事件模型,EasyExcel 就是这套封装。要注意 SXSSF 的 windowSize 别设太大、临时文件用完要 dispose、样式对象必须复用,不然内存换个地方继续涨。
第三,架构上再兜一层。真正的大数据量导出不该同步返回,改成异步任务加分片生成,文件放对象存储再通知下载,同时对单次导出量设上限。
**【一句边界】**
当然,如果报的是 Direct buffer memory 而不是 Java heap space,那是 zip 压缩用的直接内存,堆快照看不出来,要用 NMT 查,光改 SXSSF 不一定能解决。
---