← 学习门户 每日面试题 学习看板

2026-10-22

问题排查 —— 从 ssh 失联、进程退出到 CPU/GC/OOM 全链路定位

10 题 · 平均 2.9/5 · low 3 · medium 3.25 · high 2.33 · 第 25 轮

#1 服务器突然 ssh 连不上了,可能是什么问题? low practice

题目

服务器突然 ssh 连不上了,可能是什么问题?

我的回答

根据不同服务部署情况有不同的方案;首先如果是云服务,那么登录对应服务商的服务后台,查看服务状态是否正常可用,看是否能通过云服务平台登录到机器,而不是通过ssh;在本地检查网络链接,通过telnet 检查对应的ssh端口是否还可以访问,网络是否可达,默认是22,但是有些时候会修改,具体看服务方面开启的配置;如果网络端口访问不通,而云服务平台的机器是正常的,那么可能中间的安全策略导致服务无法访问,或者本地网络到服务的安全网络是不通的可以进行检查排查,是否未登录VPN等;如果发现机器活着无法登录,那么排查密钥是否正确,是否有人调整了服务器配置,或者你的密钥出现了问题;可以通过云服务器的管理平台登录进去检查具体信息;

点评

评分:⭐⭐⭐☆☆(3/5)
优点:分层清晰,带外通道抓得准,注意到端口可能不是 22。
不足:漏了机器活着但登不上的内因(sshd 挂、磁盘满、负载高 fork 失败、fail2ban);未区分 telnet 的 timeout/refused/秒断三种语义;未落到 sshd 日志。
补充:安全组与主机防火墙是两层,放通一层不代表通。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察「分层排查」的思维纪律——能不能把"连不上"拆成网络层、主机层、服务层、认证层,并为每层找到成本最低的观测手段。

**知识点拆解**:
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 白名单、密钥轮换与权限基线检查。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,SSH 连不上我会按"带外通道 → 网络 → 主机 → sshd → 认证"五层往下收,而不是直接重启。

**【三点展开】**
第一,先用云控制台或 VNC 登进去,这是唯一不依赖 sshd 的通道。能进去就说明机器活着,问题在 sshd 或网络策略;进不去再怀疑内核、硬件或宿主机。

第二,用 telnet 看返回值区分层次。timeout 是网络或安全组没放通;refused 是机器可达但 sshd 没监听,去看进程和端口;能连上却认证失败或秒断,就查密钥权限、/etc/nologin、PAM 和磁盘。

第三,主机层最常见的其实是资源问题:磁盘写满、inode 耗尽、内存不足被 OOM Killer 杀掉 sshd、负载过高 fork 失败。最后看 sshd 日志、/var/log/secure 和 dmesg,基本能定死原因。

**【一句边界】**
当然,如果是安全组被误改或 fail2ban 拉黑了来源 IP,机器本身完全正常,只在机器内部查是永远查不出来的——所以一定要先确认外部网络路径。

---

#2 Java 进程突然挂了,可能是什么原因? low practice

题目

Java 进程突然挂了,可能是什么原因?

我的回答

检查java日志,看日志最后是怎么退出的是否有相关记录,如果有异常捕获,oom退出等可以判定是java进程自己退出,如果是显示的日志是优雅退出,证明有外部关闭调用;如果没有什么日志信息,那么可能是外部强关服务,导致没有记录,检查服务器上命令记录,排查是否存在异常情况;如果是容器性质服务器,查看服务器日志,看是否有服务器崩溃或者服务器容器重启之类的日志,或者健康告警

点评

评分:⭐⭐⭐☆☆(3/5)
优点:用「有没有日志」做二分,方向正确,分到应用日志、外部强关、容器三层。
不足:缺 exit code(143/137/134)、hs_err_pid.log、dmesg OOM Killer、kubectl OOMKilled;把「OOM 退出」说得太绝对。
补充:默认堆 OOM 只杀线程,需 `-XX:+ExitOnOutOfMemoryError` 才退出进程。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察能否把"进程没了"拆成三层——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。两者都无则查容器事件与健康检查。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,进程挂了我会先看有没有留下日志和 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 更好。这种情况常被误判成被外部杀掉。

**【一句边界】**
当然,如果是磁盘写满导致日志根本没落盘,上面的日志判断全都失效,所以排查前要先确认磁盘和日志采集链路是正常的。

---

#3 Arthas 统计方法耗时的原理是什么? low practice

题目

Arthas 统计方法耗时的原理是什么?

我的回答

arthas统计方法耗时其本身是无侵入的工具,执行时使用agent模式插入到java程序中,作为类切面的方式在执行方法前后进行统计执行时间,arthas的使用由于是织入到java程序中,对于性能上会有一定影响的,高并发的时候或者生产环境尽可能还是少用arthas

点评

评分:⭐⭐⭐☆☆(3/5)
优点:agent、前后埋点、性能代价三点都命中。
不足:未说明 Attach API + agentmain + retransformClasses 的链路,未提 ASM 与 Spy/AdviceListener 的分工,性能代价过于笼统。
补充:premain 是启动时插桩,agentmain 是运行时插桩,Arthas 走后者。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察是否理解 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 采样。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,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 做逐方法增强。

---

#4 死循环会导致 CPU 使用率升高吗?为什么? medium practice

题目

死循环会导致 CPU 使用率升高吗?为什么?

我的回答

死循环正常是会占用一个cpu一直在跑,然后让cpu利用率标高,但是如果内部有阻塞,或者等待,将当前时间片放出去之后,则会出现cpu释放,再等下一轮再获取时间片;比如其实定时任务,redis的时间事件处理都是死循环,但是他们中间会阻塞,会等待,是不会完全让cpu一直跑的,是会放开时间片给其他事情

点评

评分:⭐⭐⭐☆☆(3/5)
优点:抓住「循环体有无阻塞」这个本质,Redis aeMain 与定时任务的例子很准。
不足:主动让出与时间片抢占混淆;未区分 IO 等待与快速 syscall;缺反向排查(GC/JIT/CAS 自旋)。
补充:8 核机器单线程忙等约 12.5%,是多线程才接近 100%。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察对「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。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,死循环不一定吃 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 和线程栈。

---

#5 数据库 CPU 被打满排查过程 medium practice

题目

数据库 CPU 被打满排查过程

我的回答

如果出现mysql的cpu暴涨,先去检查数据库的连接池情况,是否链接都占满了,如果是链接都占满了,检查当前在执行的sql都有什么,确认具体是因为什么原因导致的占满;如果是有慢SQL,那么是有慢SQL持续拿着链接,导致其他sql无法正常执行,如果不是慢SQL,那是否当前有大量更新同数据,导致行锁等待,锁冲突,导致无法正常执行;另外检查是否出现死锁,导致无法正常执行;检查服务器是否有大批数据写入或者数据同步之类的操作,查看当前活跃线程,看具体执行内容,以及执行的设备,看是有手动执行内容,还是有什么计划任务导致大批数据获取,一步步定位到问题点,如果是有人手动或者计划任务,确认下是否可停止,不要影响线上服务;然后做出规范,收回外部权限,并且对计划任务进行拆解,把大事务拆解成小事务,分段执行,并且把执行时间放在业务低谷期

点评

评分:⭐⭐⭐☆☆(3/5)
优点:链路完整,治理动作实在(权限回收、大事务拆小、错峰)。
不足:方向性偏差——锁等待与死锁通常让 CPU 降低;缺具体观测命令;未先确认是否 mysqld 在吃 CPU;漏执行计划突变、索引缺失、Buffer Pool 命中率。
补充:QPS 高+CPU 高=海量短查询;QPS 低+CPU 高=重量级 SQL;线程多但 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` 显著下降即为索引问题;若索引已最优但扫描行数仍大,是数据量/查询范围问题,需要改查询或分区。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,我会先确认 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 的锁等待链。

---

#6 部分服务接口出现响应时间不稳定如何排查和解决 medium practice

题目

部分服务接口出现响应时间不稳定如何排查和解决

我的回答

服务器部分接口出现响应不稳定的时候,看监控的p99的走势,是持续升高,还是相对稳定,只是偶发性质的有波动;然后看公司是否有链路追踪之类的监控体系,如果有skywalking,那么可以直接查看skywalking,对一段时间内的某个接口的请求情况进行检查排序,看出现超时或者访问缓慢的接口具体是什么阶段出现了问题,是什么位置出现问题,直接在skywalking上能够看到整个链路;是出现redis长尾波动,还是mysql出现查询阻塞,或者三方出现问题;由于是部分接口的不稳定,所以不是批量的事件,不是gc之类引起的全面问题,所以需要抓取个例的情况;如果没有skywalking之类的链路追踪工具,就要人工手动打日志去检查每个阶段的时间进行查看了,但成本相对较高,并且不一定能抓到

点评

评分:⭐⭐⭐⭐☆(4/5)
优点:P99 定性 → 链路归因 → 长尾三分类 → 无 APM 兜底,层次完整;「部分=局部、GC=全局」的排除法很好。
不足:缺按实例切维;未量化(P99+错误率+QPS);漏超时重试放大;未考虑 APM 低采样漏掉慢请求。
补充:固化四步——定性、切维、归因、验证。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察统计口径意识与"先收敛、再归因"的排障纪律——能不能用有限信息把一个模糊的"不稳定"变成可定位的维度。

**知识点拆解**:
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%)、指数退避加抖动、只对幂等请求重试、熔断与降级、超时时间逐层递减。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,我会先定性再切维:用 P99 和错误率判断是抖动还是劣化,再按实例和链路把范围收敛到一个具体环节。

**【三点展开】**
第一,看指标口径。平均值会骗人,一定要看 P99、P999 加错误率和 QPS,跟基线比、跟发布和定时任务的时间点对。持续抬升和偶发尖刺,排查方向完全不同。

第二,切维度。既然是部分接口,就先按实例、接口、客户端版本、参数规模切一刀。经常一切就发现是某台机器慢——CPU 被 cgroup 限流、版本旧、连接池小;如果所有实例都慢,再往下看链路。

第三,看链路。有 SkyWalking 就直接按接口排序看慢 span,判断是自身、Redis 大 key、MySQL 锁等待还是三方超时;没有 APM 就在关键阶段打点,虽然成本高但能兜底。定位到下游抖动后,还要检查上游有没有超时重试把流量放大。

**【一句边界】**
当然,如果 APM 采样率很低,那 1% 的慢请求可能一条都没采到,这时候要按延迟阈值强制采样,或者直接用单机火焰图和线程栈去抓,不能只信链路面板。

---

#7 服务发布分 10 批,第一批发完后负载很高后面恢复正常,如何处理? medium practice

题目

服务发布分 10 批,第一批发完后负载很高后面恢复正常,如何处理?

我的回答

服务发布一般来说应该是先灰度,服务部署分配少量流量,服务没问题了,再考虑部署其他服务,而且第一批部署了之后,如果出现负载很高,需要先暂停后续发布,观测负载情况,对负载升高检查,检查本次发版内容,是否有特殊的改动,发布后负载高了,需要检查是否引入了什么内容,检查监控看服务的接口调用量是否有激增,是否出现某些服务调用异常,是否存在某些加载异常,但是后续平稳了,意味着是部署开始时有问题,时间拉长或者说,服务部署多了均衡后服务稳定了,需要观察负载对比同期是否有什么变化,是否发生增高,如果有,检查新增或者改动的功能是否有什么问题,进行分析,是什么情况

点评

评分:⭐⭐⭐☆☆(3/5)
优点:处置动作正确,先暂停、保现场、对比同期。
不足:未解释核心现象——冷启动(JIT 解释执行、缓存与连接池为空);未分析流量分配(首批节点少 + LB 无慢启动);缺具体修复手段。
补充:只有首批高且 10-30 分钟自然回落=冷启动;后续批次也高=新版本性能问题。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察能否把"发布期负载异常"归因到**冷启动 + 流量分配**这两个机制上,并给出工程化对策。

**知识点拆解**:
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 + 优雅停机保证摘流顺序。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,只有第一批高、后面正常,最典型的原因是**新实例冷启动**,不是代码有问题,处置上先暂停后续批次保现场。

**【三点展开】**
第一,先解释现象。新 JVM 刚起来时方法还在解释执行,热点没到阈值没触发 C1/C2 编译;本地缓存是空的,请求全打到 Redis 和数据库;连接池、线程池也没建好。所以同样流量下,首批实例的 CPU 和 P99 明显更高。跑一段、请求量上去之后自然回落,这就是后面批次正常的原因。

第二,排除其他可能。第一批节点少,如果 LB 没有慢启动,流量会按权重压到少数新节点;就绪探针配置不当会让流量在应用真正可用前打进来;会话保持和长连接也会让流量堆积。要按实例看 QPS 和延迟分布,确认是"每台都高"还是"少数几台高"。

第三,处置。当下先暂停后续批次,观察是否在 10-30 分钟内收敛;确认是新版本问题时立即回滚。长期做三件事:发布前预热核心接口和缓存、LB 开慢启动并让批次比例递增、探针加初始延迟和 minReadySeconds,发布后拿同口径的压测数据做对比。

**【一句边界】**
当然,如果后续批次也高,或者指标不随时间回落,那就不是冷启动,要按新版本引入的性能问题去查,比如日志级别、序列化方式、新增的同步调用。

---

#8 日志打印导致 CPU 飙高问题排查 high practice

题目

日志打印导致 CPU 飙高问题排查

我的回答

qps没有什么变动,但是cpu标高,需要检查下具体是什么线程导致的cpu标高,top -hp 确认具体线程,然后使用jstack导出线程栈查看具体栈帧信息,找到对应方法;检查方法内是否存在什么问题,检查最新更新的代码,是否有什么特殊调整;

点评

评分:⭐⭐☆☆☆(2/5)
优点:起手方向对——QPS 不涨但 CPU 高说明单请求变贵。
不足:完全没回答「日志为什么耗 CPU」;jstack 是瞬时快照,不适用于热点定位;缺 A/B 证伪;漏异步日志队列降级。
补充:`%usr` 高看拼接/序列化,`%sys` 高看 write/锁;热点用 async-profiler/JFR。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察能否把"日志"从"一行代码"拆成一条完整的成本链——**参数求值 → 格式化 → 序列化 → 加锁入队 → 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 栈占比。回落后再逐条二分定位到具体日志语句。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,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 和磁盘。

---

#9 频繁 FullGC 问题排查 high practice

题目

频繁 FullGC 问题排查

我的回答

出现频繁fullgc首先看开始出现的时间,先分析当时发生了什么,另外如果是周期性的当时没能处理好,是否在每个周期个固定的时间发生?导出fullgc日志,检查回收前后堆空间的变化,如果变化很小,回收了也没有回收多少内容,并且是持续fgc那么高概率是内存泄露了;如果fgc后能回收较多的内容,证明回收能回收掉并不是内存泄露;导出内存快照,检查其中对象分布,看具体是什么类型的对象占用大量内存,并且导出当前的jstack线程栈,看是什么线程在持续运行,对应的方法是否有内存泄露的内容;

点评

评分:⭐⭐⭐☆☆(3/5)
优点:时间线、GC 日志对比、堆快照 + 线程栈三条主线都对。
不足:判据需改为「FGC 后老年代基线是否逐次抬升」;未看 GC cause;工具链不具体;漏元空间与直接内存。
补充:老年代不高但 FGC 频繁,优先查 System.gc、元空间、Humongous。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察能否把"频繁 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/脚本引擎。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,频繁 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 上查,盯着堆看会一直找不到原因。

---

#10 POI 导致内存溢出排查 high practice

题目

POI 导致内存溢出排查

我的回答

POI导出大批量数据导致OOM,核心原因是单次将整个文件都加载到内存中然后做解析导致的,这块在排查时可以根据oom的错误日志,查看具体oom产生原因,oom的异常信息,对应线程栈信息,应该能看到对应证据调用的节点信息等;针对这种问题,可以调整导出逻辑修改为流式导出,不要一次性进行加载,而是分配进行导出读取到内存中

点评

评分:⭐⭐☆☆☆(2/5)
优点:抓住 usermodel 全量 DOM 这一核心,流式方向正确。
不足:未点出 XSSFWorkbook→Row→Cell 对象模型与 SharedStringsTable;未区分读写两套流式方案(SXSSF vs XSSFReader+SAX);缺堆转储证据形态与 SXSSF 隐藏坑;未提直接内存。
补充:SXSSF 的 windowSize、临时文件 dispose、样式复用是三个必踩点;报 Direct buffer memory 时堆快照看不出来。

完整讲解

第一部分:知识重点剖析

**核心考点定位**:考察对 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),合并或打包后存对象存储,通过站内信/邮件通知下载;同时对单次导出量设上限,超限走审批或分批。

第三部分:面试者参考回答(三步法口语化,300-400 字)

**【一句话结论】**
面试官您好,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 不一定能解决。

---

📚 查看全部面试记录