线上问题排查手册
Production Troubleshooting Guide — 从方法论到真实案例,掌握结构化排查能力
1. 排查方法论
1.1 结构化排查五步法
线上出问题不可怕,可怕的是没有章法地乱排查。记住这五个步骤:
| 步骤 | 做什么 | 常用手段 |
|---|---|---|
| 现象确认 | 搞清楚"什么坏了" | 用户反馈、监控告警、错误日志 |
| 指标采集 | 量化问题的严重程度 | CPU/内存/磁盘/网络指标 |
| 根因定位 | 找到为什么会坏 | 日志分析、profiling、代码回溯 |
| 修复验证 | 修复并确认恢复 | 发布修复、观察指标 |
| 复盘总结 | 防止再次发生 | 复盘文档、加监控、改流程 |
1.2 日志等级
开发阶段用
请求进出记录
可恢复的异常
需要人工关注
进程即将退出
1.3 必备工具箱
1.4 生产环境黄金法则
- 在出问题的时候疯狂排查,忘了先恢复服务 — 每多一分钟宕机,就多一分损失
- 没有保留现场就重启了 — 重启前先 dump 内存、保存日志
- 排查完就完了,不复盘 — 同样的问题会再来第二次
- 一个人闷头查,不通知团队 — 大故障要拉群,多人协作
2. CPU 飙高 — 从告警到定位的完整思路
再想一层:你的公司(进程)有很多员工(线程)。如果有一个员工发疯了,疯狂地在原地打转(死循环),那他一个人就能把公司的电费(CPU)用光,其他员工想干活也没有资源了。
2.1 先理解: 为什么CPU会飙高?
在开始排查之前,你需要先理解几个概念。不然命令背了也不知道为什么这么做。
| 层次 | 是什么 | 生活类比 |
|---|---|---|
| 进程 | 一个运行的程序,比如你的 java -jar app.jar |
一栋办公楼 |
| 线程 | 进程里面的执行单元,程序靠线程跑代码 | 办公楼里的一个个员工 |
| 代码 | 线程正在执行的具体方法/逻辑 | 员工正在做的具体工作 |
为什么要这样拆? 因为CPU飙高,不是"整个程序"在烧CPU,而是某一个或几个线程在疯狂执行某段代码。你只有先找到是哪个进程,再找到是哪个线程,最后看这个线程在执行什么代码,才能定位到根因。
正常情况下,线程执行完一个请求后会等待(让出CPU给别人用)。但如果线程停不下来,就会把CPU吃满。什么情况下停不下来?
- 死循环 —
while(true)里的条件永远为 true,出不去 - 正则回溯 — 正则引擎在尝试天文数字的匹配路径,几百年都跑不完
- 频繁GC — 内存快满了,垃圾回收器疯狂工作但怎么也回收不够
- 加密/序列化 — 大量数据加解密、序列化,纯CPU密集计算
- 无限递归 — 方法自己调自己,栈帧越堆越深(最终会StackOverflow,但在那之前CPU会飙)
2.2 症状
- 服务响应变慢,接口超时
top显示某个进程 CPU 接近 100% (甚至超过100%表示多核)- 监控告警: CPU使用率持续 > 90%
- 用户反馈页面卡、加载不出来
2.3 排查思路: 从告警到代码行,五步搞定
记住这个排查链: 告警 → 哪个进程 → 哪个线程 → 线程在执行什么代码 → 为什么这段代码会死循环
输入
top,看 %CPU 列,找到数值最大的那个进程,记下它的 PID。比如 java 进程 PID=8921,CPU 98%。
加
-H 参数意思是"显示线程", -p 指定进程。这样你就能看到这个进程里所有线程的CPU排行。找到CPU最高的那个线程的 TID(线程ID),比如 9032。🤔 为什么要看线程?因为进程本身不执行代码,是线程在执行。进程CPU高 = 里面某个线程在疯狂跑。
printf '%x\n' 9032 得到 2348。为什么要转?因为 jstack 输出的线程ID是用十六进制表示的(nid=0x2348),而 top 显示的是十进制。你得把它转成同一种格式才能对应上。
jstack 会打印进程里所有线程的堆栈信息(线程在执行哪行代码)。用 grep 过滤出你要找的那个线程,
-A 20 表示显示匹配行后20行,这样能看到完整的调用栈。
堆栈里会有类似
at com.app.service.OrderService.process(OrderService.java:67) 的行,直接告诉你是哪个类的哪个方法、第几行。💡 多打几次 jstack(隔5秒打一次,打3次),如果每次都是同样的栈帧,基本就是死循环了。
2.4 如何看懂 jstack 线程堆栈
jstack 输出长这样,很多人一看就懵了。别怕,逐行翻译给你:
// jstack 输出示例
"http-nio-8080-exec-5" #45 ← 线程名称(Tomcat线程池的第5号线程)
daemon ← 守护线程(非用户线程)
prio=5 ← 线程优先级(1-10, 5是默认)
os_prio=0 ← 操作系统层面的优先级
tid=0x00007f8c34123450 ← JVM内部的线程地址(不用管)
nid=0x2348 ← 操作系统线程ID(十六进制!)← 就是你在找的!
runnable ← 线程状态:RUNNABLE(正在运行,吃CPU的那个)
# 下面是调用栈,从下往上读(最下面是最外层调用,最上面是当前正在执行的位置)
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Curly.match(Pattern.java:4148) ← 当前卡在这里!
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4660)
at java.util.regex.Pattern$Curly.match0(Pattern.java:4259)
at java.util.regex.Pattern$Curly.match(Pattern.java:4148) ← 注意:又出现了!说明在递归调用!
at com.app.validator.EmailValidator.check(EmailValidator.java:23)
at com.app.controller.UserController.register(UserController.java:45)
| 堆栈特征 | 说明 | 疑似原因 |
|---|---|---|
| 同一个方法名反复出现,像叠塔一样 | 方法在递归调用自己 | 死循环 / 递归 |
java.util.regex.Pattern 占满调用栈 |
正则引擎在疯狂匹配 | 正则回溯 |
GC 相关线程 CPU 高 |
垃圾回收器在拼命工作 | 频繁GC |
javax.crypto / MessageDigest |
加解密运算 | 加密计算 |
业务代码的 while / for 位置 |
循环条件不满足退出 | 业务死循环 |
2.5 五种常见原因详解
光看表格记不住,下面把每种原因的原理、代码、jstack特征和修复方案都讲清楚。
死循环 — 最常见的CPU杀手
循环的退出条件永远不会满足,导致线程一直在循环体内转圈,永远不会让出CPU。
// 典型 bug: BigDecimal 比较错误
public void calculateDiscount(BigDecimal price) {
BigDecimal result = price;
while (result.compareTo(BigDecimal.ZERO) > 0) {
result.subtract(BigDecimal.TEN); // BUG!
// subtract() 返回新对象,不改原值!
// result 永远不变,循环永远不退出
}
}
// 正确写法:
result = result.subtract(BigDecimal.TEN); // 必须接收返回值!
// jstack 特征: 业务方法反复出现
"pool-3-thread-1" #45 nid=0x2324 runnable
java.lang.Thread.State: RUNNABLE
at com.app.service.OrderService.calculateDiscount(OrderService.java:87)
// 多次 jstack,每次都卡在这行 ← 死循环确认!
- 修复循环条件: 确保
result = result.subtract(...)接收返回值 - 加循环上限保护:
int maxRetry = 1000; while(... && maxRetry-- > 0) - 加超时: 循环体内检查
System.currentTimeMillis(),超时则 break
正则回溯 — 看不见的CPU炸弹
正则引擎在匹配失败时会回退重试(backtracking)。某些正则写法 + 特定输入,会导致尝试路径数量指数级爆炸。比如正则 (a+)+ 匹配 "aaaaaaaaaaaaaa!",尝试路径有 2^n 条,n=14 就有 16384 条,每条都要执行。
⚠️ 这种问题最阴险:平时完全正常,只有特定输入才触发。攻击者可以用这个做 ReDoS(正则拒绝服务)攻击。
// 危险正则: 嵌套量词 (a+)+
Pattern badRegex = Pattern.compile("^([a-zA-Z0-9]+[a-zA-Z0-9._-]*)+$");
// 输入 "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" → CPU瞬间100%
Matcher m = badRegex.matcher(input);
m.matches(); // 这一行卡死,CPU疯狂计算
// jstack 特征: Pattern 相关方法占满调用栈
" http-nio-8080-exec-5" nid=0x2348 runnable
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Curly.match(Pattern.java:4148)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4660)
at java.util.regex.Pattern$Curly.match0(Pattern.java:4259)
at java.util.regex.Pattern$Curly.match(Pattern.java:4148)
// 满屏都是 Pattern$xxx.match ← 正则回溯确认!
- 避免嵌套量词: 不要写
(a+)+、(a*)*这类模式 - 限制输入长度:
if (input.length() > 100) return false; - 用更严格的正则: 用非贪婪
?或原子组 - 用专业校验库: 邮箱用
@Email注解,不要自己写正则
频繁GC — 垃圾回收器的疯狂加班
JVM 的垃圾回收器(GC)负责回收不再使用的对象。正常情况下偶尔触发一次,很快结束。但如果内存快满了(内存泄漏或加载了大对象),GC 就会疯狂工作:刚回收一点点又被占满,刚回收一点点又被占满...GC线程自己就吃掉了大量CPU。
类比: 就像你家垃圾桶满了,保洁阿姨刚倒完,马上又满了,她只能不停地跑来跑去倒垃圾,累得不行(吃CPU),但还是倒不完。
# 用 jstat 查看 GC 情况, 每1秒打印一次, 共10次
$ jstat -gcutil 8921 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT
0.00 45.32 89.12 98.76 94.50 91.23 234 3.456 28 12.890
0.00 45.32 92.56 98.76 94.50 91.23 235 3.512 29 13.234
0.00 45.32 95.89 98.76 94.50 91.23 236 3.567 30 13.578
# O列(Old Gen) = 98.76% ← 老年代快满了!
# FGC(Full GC次数) = 28→29→30, 每秒+1 ← 每秒一次Full GC!
# FGCT(Full GC总耗时) = 12.890→13.234 → 每次 Full GC 耗时 0.3秒
# jstack 特征: "GC task thread" CPU 高
"GC task thread#0 (ParallelGC)" nid=0x1a2b runnable
关键指标: FGC 每秒+1 说明 Full GC 频率极高(正常应该几分钟甚至几小时才一次)。O=98.76% 说明老年代几乎满了,GC回收不了东西,就疯狂重试。
- 先看内存泄漏:
jmap -histo:live <PID> | head -20查看什么对象最多 - dump 堆分析:
jcmd <PID> GC.heap_dump /tmp/heap.hprof,用 MAT 分析 - 临时方案: 重启服务(治标不治本,内存泄漏还会再来)
- 具体修复看下面的 OOM 章节
加密/序列化 — 合理但过量的CPU消耗
加密(AES/RSA/MD5)、压缩(GZIP)、序列化(JSON/XML)都是CPU密集型操作。单次没问题,但如果批量处理大量数据,比如一次性加密10万条记录,或者序列化一个巨大的对象树,CPU就会飙高。这种CPU飙高是"正常的",说明程序确实在干活,但可能需要优化处理方式。
// 堆栈显示加密/压缩相关类
"batch-task-thread-3" nid=0x3b2c runnable
java.lang.Thread.State: RUNNABLE
at javax.crypto.CipherCore.encryptFinal(CipherCore.java:1024)
at com.sun.crypto.provider.AESCipher.engineDoFinal(...)
at com.app.service.DataExportService.encryptBatch(DataExportService.java:120)
- 分批处理: 10万条拆成100批,每批1000条,中间 sleep 一下给其他请求让路
- 异步处理: 用
@Async在独立线程池执行,不阻塞主线程 - 限流: 用 Semaphore 限制并发加密任务数量
- 换更快的实现: 比如用 native 库代替纯 Java 实现
无限递归 — 方法调自己,越陷越深
方法直接或间接调用自己,且没有正确的终止条件。每次调用都会在栈上创建一个新的栈帧(Stack Frame),栈帧越堆越深。在抛出 StackOverflowError 之前,CPU会先飙高(疯狂创建栈帧和执行方法体)。
类比: 俄罗斯套娃,你一层一层打开,永远找不到最小的那个,手一直在动(CPU在跑),直到桌子放不下了(StackOverflow)。
// 典型 bug: 忘记写终止条件,或终止条件错误
public TreeNode findRoot(TreeNode node) {
// BUG: 忘记判断 node == null 或 node.parent == null
return findRoot(node.getParent()); // 无限递归!
}
// 正确写法:
public TreeNode findRoot(TreeNode node) {
if (node == null || node.getParent() == null) {
return node; // 终止条件!
}
return findRoot(node.getParent());
}
// jstack 特征: 同一个方法名反复出现,像复印一样
" http-nio-8080-exec-3" nid=0x4c5d runnable
java.lang.Thread.State: RUNNABLE
at com.app.model.TreeNode.findRoot(TreeNode.java:28)
at com.app.model.TreeNode.findRoot(TreeNode.java:28)
at com.app.model.TreeNode.findRoot(TreeNode.java:28)
at com.app.model.TreeNode.findRoot(TreeNode.java:28)
at com.app.model.TreeNode.findRoot(TreeNode.java:28)
// ... 重复几百行,都是同一个方法 ← 无限递归确认!
- 必须有终止条件: 每个递归方法的第一行就应该判断是否该返回
- 设置递归深度上限:
if (depth > 100) throw new RuntimeException("递归过深"); - 考虑改成迭代: 递归能做的循环也能做,循环不会有栈溢出风险
2.6 速查表: 收到CPU告警后的60秒
| 时间 | 做什么 | 命令 | 看什么 |
|---|---|---|---|
| 0-10秒 | 确认哪个进程 | top |
%CPU 最高的进程PID |
| 10-20秒 | 确认哪个线程 | top -Hp <PID> |
%CPU 最高的线程TID |
| 20-30秒 | 转十六进制 | printf '%x\n' <TID> |
得到十六进制TID |
| 30-40秒 | 看线程在干什么 | jstack <PID> | grep -A 20 <hex> |
调用栈最后一行(当前执行位置) |
| 40-60秒 | 判断原因 | 看堆栈特征 | 业务代码→死循环 / Pattern→正则 / GC线程→频繁GC |
2.7 完整案例: Java CPU 100% 排查 — 正则回溯
Java 服务 CPU 100% — 正则回溯引发
周五下午3点,用户反馈页面加载超时。监控显示 Java 服务 CPU 从 15% 飙到 98%。
# Step 1: 确认是哪个进程
$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM
8921 java 20 0 3896544 512320 24320 S 98.3 6.4
...
# Step 2: 用 top -Hp 找到高CPU线程
$ top -Hp 8921
PID USER %CPU TID
9032 java 95.6 ...
# Step 3: 线程TID转十六进制,用jstack定位
$ printf '%x\n' 9032
2348
$ jstack 8921 | grep -A 20 'nid=0x2348'
"http-nio-8080-exec-5" #45 daemon prio=5 os_prio=0
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Curly.match(Pattern.java:4148)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4660)
...
# 也可以用 Arthas 一键定位
$ arthas-boot.jar → 选择 8921
[arthas@8921]$ thread -n 3
# Count CPU% Name
45 95.6% http-nio-8080-exec-5
邮箱验证正则 /^([a-zA-Z0-9]+[a-zA-Z0-9._-]*)@([a-zA-Z0-9.-]+)$/ 在遇到恶意输入 "aaaaaaaaaaaaaaa!" 时发生了灾难性回溯。正则引擎尝试了指数级的匹配路径。
// Before: 容易回溯(Java Pattern 编译后有同样问题)
Pattern emailReg = Pattern.compile(
"^([a-zA-Z0-9]+[a-zA-Z0-9._-]*)@([a-zA-Z0-9.-]+)$");
// After: 使用非贪婪/原子组,并限制长度
// 先做长度检查,再做正则匹配
if (email == null || email.length() > 254) {
return false;
}
Pattern emailReg = Pattern.compile(
"^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,62}[a-zA-Z0-9])?@[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,248}[a-zA-Z0-9])?$");
// 或者更简单: 直接用字符串校验
// Spring Validation 注解 + Hibernate Validator Email 校验
@Email
@Size(max = 254)
private String email;
- 所有正则都要考虑回溯风险,尤其是嵌套量词
(a+)+ - 线上必须加CPU监控告警
- 用户输入的验证要限制长度
2.8 懒人方案: Arthas 一键定位
如果你觉得 top → top -Hp → printf → jstack 四步太麻烦,可以用阿里巴巴的 Arthas 工具,一条命令搞定:
# 1. 下载并启动 Arthas
$ curl -O https://arthas.aliyun.com/arthas-boot.jar
$ java -jar arthas-boot.jar
# 2. 选择你要排查的 Java 进程
[1]: 8921 com.app.Application
[2]: 9032 org.apache.catalina.startup.Bootstrap
$ 1 # 选1
# 3. 一键查看最忙的3个线程
[arthas@8921]$ thread -n 3
Threads Total: 87, NEW: 0, RUNNABLE: 5, BLOCKED: 0, WAITING: 42, TIMED_WAITING: 40
Memory used / total / max
heap 512M / 1024M / 1024M
# CID NAME CPU% STATE TIME INTERRUPTED
45 http-nio-8080-exec-5 95.6 RUNNABLE 2m30s false
46 http-nio-8080-exec-3 12.3 RUNNABLE 0m45s false
47 http-nio-8080-exec-7 8.1 RUNNABLE 0m22s false
# 4. 直接看这个线程在干什么
[arthas@8921]$ thread 45
"http-nio-8080-exec-5" Id=45 RUNNABLE (cpu 95.6%)
at java.util.regex.Pattern$Curly.match(Pattern.java:4148)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4660)
at com.app.validator.EmailValidator.check(EmailValidator.java:23)
at com.app.controller.UserController.register(UserController.java:45)
# 就这么简单! thread -n 3 + thread <ID>, 两步搞定!
Arthas 对比传统命令:
| 传统方式 | Arthas方式 |
|---|---|
| top → top -Hp → printf → jstack → grep | thread -n 3 → thread <id> |
| 5条命令 | 2条命令 |
| 需要手动转十六进制 | 自动显示 |
| 只能看一次快照 | 可以持续监控(dashboard) |
- 生产环境紧急排查: 用传统命令(jstack),Arthas attach 进程时会有短暂停顿
- 日常开发/测试环境: 用 Arthas,方便快捷,还能反编译代码、查看方法参数
- 容器环境: Arthas 支持
kubectl exec进入容器使用
3. 内存溢出 (OOM)
3.1 症状
- 进程突然消失,系统日志有
Out of memory: Kill process - 服务频繁重启,响应越来越慢(频繁GC导致)
free -h显示可用内存极少,swap使用量高
3.2 排查步骤
3.3 常见原因
| 原因 | 特征 | 典型场景 |
|---|---|---|
| 内存泄漏 | 内存持续增长,不释放 | 全局Map不断put不remove |
| 大对象加载 | 瞬间内存飙升 | 一次性SELECT *百万行数据 |
| 缓存无限增长 | 缓存量与时间正相关 | 自建LRU缓存没有淘汰策略 |
| 连接池泄漏 | 连接不归还,内存累积 | 忘记connection.close() |
3.4 完整案例: Java 服务内存从512MB涨到2.8GB
Java 服务内存泄漏 — ThreadLocal 未清理 + 静态 Map 无限增长
Spring Boot 服务运行3天后,堆内存从 512MB 缓慢增长到 2.8GB。频繁 Full GC(每小时 20+ 次), STW 停顿越来越长。最终 OOM 被杀并自动重启,然后再次增长... 如此循环。
# Step 1: 确认内存状况
$ free -h
total used free
Mem: 7.8Gi 6.2Gi 400Mi
Swap: 2.0Gi 1.8Gi 200Mi
# Step 2: 找到内存大户
$ ps aux --sort=-%mem | head -5
java 8921 6.4 35.2 3896544 2744320 ... -jar app.jar
# Step 3: jmap 查看对象直方图
$ jmap -histo:live 8921 | head -20
num #instances #bytes class name
1: 8426310 606694320 java.util.HashMap$Node
2: 8426300 269641600 java.lang.String
3: 8426300 201943120 com.app.dto.UserSession
4: 126543 40490400 java.util.HashMap
...
# Step 4: 导出 heap dump,用 MAT (Memory Analyzer Tool) 分析
$ jcmd 8921 GC.heap_dump /tmp/heap.hprof
# 用 Eclipse MAT 打开,执行 Leak Suspects Report
# 发现: com.app.service.SessionCache 的 ConcurrentHashMap 占了 1.8GB
# 且 ThreadLocal 中残留大量 UserSession 对象
在 Eclipse MAT 的 Dominator Tree 中发现:
SessionCache.sessions(ConcurrentHashMap) 占了 1.8GB,840万+ 个 UserSession- 每个 UserSession 内部还持有 ThreadLocal 引用,GC 无法回收
// 问题代码: ThreadLocal + 静态 Map 双重泄漏
public class SessionCache {
// 静态 Map,只 put 不 remove!
private static final ConcurrentHashMap<String, UserSession> sessions
= new ConcurrentHashMap<>();
// ThreadLocal 也没清理
private static final ThreadLocal<UserSession> currentSession
= new ThreadLocal<>();
public void createSession(String userId) {
UserSession session = new UserSession(userId);
sessions.put(userId, session); // 永远只增不减
currentSession.set(session); // 线程池复用,ThreadLocal 永不释放
}
// 缺少 destroySession 方法!
}
@Service
public class SessionCache {
// 用 Caffeine 替代 ConcurrentHashMap,自动过期淘汰
private final Cache<String, UserSession> sessions = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(30, TimeUnit.MINUTES)
.build();
private static final ThreadLocal<UserSession> currentSession
= new ThreadLocal<>();
public void createSession(String userId) {
// 先清理旧的
destroySession(userId);
UserSession session = new UserSession(userId);
sessions.put(userId, session);
currentSession.set(session);
}
public void destroySession(String userId) {
UserSession session = sessions.getIfPresent(userId);
if (session != null) {
sessions.invalidate(userId);
}
// 关键: ThreadLocal 用完必须 remove
currentSession.remove();
}
}
// 在 Filter/Interceptor 中确保清理
@Component
public class SessionCleanupFilter implements OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
try {
chain.doFilter(req, res);
} finally {
// 无论是否异常,都清理 ThreadLocal
SessionCache.cleanupCurrentThread();
}
}
}
- 静态集合/Map 必须有淘汰策略,推荐 Caffeine 或 Guava Cache
- ThreadLocal 在线程池环境下必须 remove(),否则线程复用导致泄漏
- 定期用 jmap -histo:live 或 MAT 分析堆对象,提前发现泄漏
- JVM 加
-Xmx512m -XX:+HeapDumpOnOutOfMemoryError限制上限并自动 dump
4. 磁盘满
4.1 症状
- 日志报错:
No space left on device - 文件上传失败,数据库写入失败
- 服务行为异常(inode耗尽时)
4.2 排查步骤
4.3 完整案例: 日志文件写满磁盘导致MySQL崩溃
日志轮转没配置,一个文件长到120GB
凌晨2点告警: MySQL连接失败。应用日志疯狂报 Error: connect ECONNREFUSED。登录服务器发现 MySQL 进程不存在了。
# Step 1: MySQL 状态
$ systemctl status mysql
Active: failed (Result: exit-code)
# Step 2: 查看 MySQL 错误日志
$ tail -50 /var/log/mysql/error.log
[ERROR] InnoDB: Write to file ./ib_logfile0 failed at offset ...
[ERROR] InnoDB: Operating system error number 28 ...
[ERROR] InnoDB: Error number 28 means 'No space left on device'
# Step 3: 磁盘使用情况
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 50G 49G 512M 99% /
# Step 4: 找大文件
$ du -sh /var/log/*
120G /var/log/app/app.log <-- 就是它!
4.0K /var/log/app/app.log.1
应用的日志配置没有设置日志轮转(log rotation),app.log 从服务启动以来一直追加写入,3个月长到了 120GB,磁盘 99% 满后 MySQL 无法写入 binlog/redo log 就崩溃了。
# 立即: 截断日志释放空间(不是删除!)
$ > /var/log/app/app.log # 或 truncate -s 0
# 重启 MySQL
$ systemctl start mysql
# 长期: 配置 logrotate
$ cat /etc/logrotate.d/app
/var/log/app/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
copytruncate
maxsize 500M
}
- 所有服务上线前必须配置日志轮转
- 加磁盘使用率监控告警(阈值 80%)
- 数据库和日志尽量分开存储在不同磁盘/分区
5. 网络问题
5.1 症状
- 接口超时、连接被拒绝
Connection timed out、Connection refused- DNS解析失败、跨服务调用失败
5.2 排查步骤
5.3 常见原因
| 原因 | 特征 | 排查方式 |
|---|---|---|
| DNS解析失败 | nslookup失败,偶尔超时 | nslookup、dig |
| 防火墙规则 | ping通但端口不通 | telnet、iptables -L |
| 连接数耗尽 | 新连接建立失败 | ss -s查看TIME_WAIT数量 |
| 带宽打满 | 传输极慢,丢包 | iftop、nethogs |
5.4 完整案例: TIME_WAIT 太多导致新连接失败
高并发短连接场景下 TIME_WAIT 堆积
压测时发现,并发到一定量后新请求全部报 connect: cannot assign requested address。服务端无压力,但客户端就是建不了新连接。
# 查看连接状态统计
$ ss -s
TCP: 28431 (estab 231, closed 28000, orphaned 0, timewait 27800)
# 27800个TIME_WAIT! 几乎占满了所有可用端口
# 详细看连接状态
$ netstat -antp | awk '{print $6}' | sort | uniq -c | sort -rn
27800 TIME_WAIT
231 ESTABLISHED
12 LISTEN
# 查看本地可用端口范围
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 61000
# 最多约28000个端口,已被TIME_WAIT全部占满
应用作为客户端频繁发起短连接调用第三方API,每次调用完关闭连接后进入 TIME_WAIT 状态(默认60秒)。高并发时产生速度 > 回收速度,端口耗尽。
# 方案1: 开启 TIME_WAIT 复用 (内核参数)
$ sysctl -w net.ipv4.tcp_tw_reuse=1
$ sysctl -w net.ipv4.tcp_timestamps=1 # 前提条件
# 方案2: 缩短 TIME_WAIT 超时时间
$ sysctl -w net.ipv4.tcp_fin_timeout=15
# 方案3 (最佳): 使用连接池/keepalive
// Java 使用 Apache HttpClient 连接池
PoolingHttpClientConnectionHandler connManager =
new PoolingHttpClientConnectionHandler();
connManager.setMaxTotal(200); // 总连接数上限
connManager.setDefaultMaxPerRoute(50); // 单路由上限
connManager.setConnectionTimeToLive(60, TimeUnit.SECONDS);
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connManager)
.setKeepAliveStrategy((response, ctx) ->
TimeValue.ofMinutes(5)) // keep-alive 5分钟
.build();
// 所有 HTTP 调用复用这个 client,连接自动池化
- 短连接场景必须考虑连接池或 keepalive
- 压测时要关注
ss -s的 TIME_WAIT 数量 - 线上服务器建议默认开启
tcp_tw_reuse
6. 数据库问题 — 慢查询
6.1 症状
- API响应慢,数据库CPU高
- 慢查询日志中有大量记录
6.2 排查步骤
long_query_time6.3 完整案例: 慢查询优化
订单列表查询从12秒优化到20毫秒
订单列表页面打开需要12秒,用户疯狂投诉。数据库CPU持续80%。
# 查看慢查询
mysql> SELECT * FROM orders
WHERE user_id = 12345
AND status = 'PAID'
AND created_at > '2025-01-01'
ORDER BY created_at DESC
LIMIT 20;
# EXPLAIN 分析
mysql> EXPLAIN SELECT * FROM orders WHERE user_id=12345 AND status='PAID' AND created_at>'2025-01-01' ORDER BY created_at DESC LIMIT 20\G
type: ALL <-- 全表扫描!
rows: 5800000 <-- 扫描了580万行
Extra: Using where; Using filesort <-- 文件排序
# 加复合索引
mysql> ALTER TABLE orders
ADD INDEX idx_user_status_created (user_id, status, created_at);
# 再次 EXPLAIN
mysql> EXPLAIN SELECT * FROM orders WHERE user_id=12345 AND status='PAID' AND created_at>'2025-01-01' ORDER BY created_at DESC LIMIT 20\G
type: ref
key: idx_user_status_created
rows: 156
Extra: Using index condition <-- 走索引了!
# 效果: 12秒 → 20毫秒
EXPLAIN是你的好朋友,关注type(ALL最差,const最好)和rows- 复合索引要遵循最左前缀原则
SELECT *尽量改成只查需要的列
6b. 数据库问题 — 死锁
完整案例: 两个UPDATE互相等待
转账场景下的死锁
日志中报: Deadlock found when trying to get lock; try restarting transaction
# 查看最近的死锁信息
mysql> SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 1234567, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 101, OS thread handle 140234567890
UPDATE account SET balance = balance - 100 WHERE id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY
*** (2) TRANSACTION:
TRANSACTION 1234568, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE account SET balance = balance - 50 WHERE id = 1
// 转账逻辑: A转B 和 B转A 同时发生
// Spring + MyBatis 场景
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountMapper.updateBalance(fromId, amount.negate()); // 先扣款
// 如果此时另一个事务反方向转账,就死锁了
accountMapper.updateBalance(toId, amount); // 再加款
}
// 事务A: 锁了id=1, 等待id=2
// 事务B: 锁了id=2, 等待id=1
// 形成循环等待 → 死锁!
// Fix: 按固定顺序加锁(总是先锁ID小的)
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 保证先锁ID小的,后锁ID大的
Long first = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
if (first.equals(fromId)) {
accountMapper.updateBalance(fromId, amount.negate());
accountMapper.updateBalance(toId, amount);
} else {
accountMapper.updateBalance(toId, amount);
accountMapper.updateBalance(fromId, amount.negate());
}
// 现在所有事务都按同一顺序获取锁,不会死锁
}
// 应用层做好死锁重试: 捕获 DeadlockLoserDataAccessException
@Retryable(value = DeadlockLoserDataAccessException.class,
maxAttempts = 3, backoff = @Backoff(delay = 100))
public void safeTransfer(Long fromId, Long toId, BigDecimal amount) {
transfer(fromId, toId, amount);
}
- 死锁的本质是循环等待,打破循环就能避免
- 最常见解法: 按固定顺序获取锁
- 应用层要做好死锁重试机制(捕获异常后重试)
6c. 数据库问题 — 连接数耗尽
Too many connections
应用报 Error: Too many connections,所有数据库操作失败。
# 查看当前连接数
mysql> SHOW PROCESSLIST;
| Id | User | Host | db | Command | Time | State |
| 1 | app | 10.0.1.5:42311 | prod | Sleep | 3600 | |
| 2 | app | 10.0.1.5:42312 | prod | Sleep | 2800 | |
| ... | ... | ... | ... | Sleep | ... | |
| 151 | app | 10.0.1.5:42461 | prod | Sleep | 1200 | |
| 152 | app | 10.0.1.5:42462 | prod | Query | 0 | Showing proc |
# 大量 Sleep 连接,长时间不释放!
mysql> SHOW VARIABLES LIKE 'max_connections';
151
# 临时: 增大连接数
mysql> SET GLOBAL max_connections = 500;
# 永久: my.cnf 配置
# max_connections = 500
# wait_timeout = 600 # 10分钟空闲断开
# interactive_timeout = 600
# 应用层: 使用连接池,设置合理的 pool size
# Spring Boot application.yml — HikariCP
spring:
datasource:
hikari:
maximum-pool-size: 20 # 不要超过 max_connections / 实例数
minimum-idle: 5
idle-timeout: 600000 # 空闲连接超时 10分钟
max-lifetime: 1800000 # 连接最大生命周期 30分钟
connection-timeout: 30000 # 获取连接超时 30秒
7. 前端问题
7.1 白屏问题排查
7.2 API错误速查
| 错误码 | 含义 | 排查方向 |
|---|---|---|
| CORS Error | 跨域被拦截 | 后端 Response Header Access-Control-Allow-Origin |
| 401 | 未认证/Token过期 | 检查Token是否有效,刷新机制 |
| 500 | 服务端内部错误 | 看后端日志,排查具体异常 |
| Timeout | 请求超时 | 网络问题或后端处理慢 |
7.3 完整案例: 上线后部分用户白屏
CDN缓存旧版本JS导致白屏
发版后,部分用户反馈页面白屏。但开发人员和另一些用户访问正常。清除浏览器缓存后恢复正常。
# 1. 让用户打开 DevTools → Network
# 发现: app.js 返回的是旧版本(没有新接口的调用)
# 但 index.html 是新版本(引用了新组件)
# 2. 检查 index.html 中 app.js 的引用方式
<script src="https://cdn.example.com/static/js/app.js"></script>
# 问题: 没有 hash! 文件名永远是 app.js
# 3. 检查CDN缓存配置
# Cache-Control: max-age=86400 (缓存24小时)
# CDN节点缓存了旧版 app.js
构建产物没有加 content hash。新版的 index.html 中调用了新版JS中的函数,但用户从CDN拿到的是旧版 app.js,导致调用 undefined 函数 → JS报错 → 白屏。
// webpack / vite 配置 content hash
// output: '[name].[contenthash:8].js'
// 产出: app.a3f2b8c1.js
// HTML中引用:
// <script src="https://cdn.example.com/static/js/app.a3f2b8c1.js"></script>
// 每次构建文件名不同 → CDN缓存自动失效
// 立即修复: CDN刷新API或手动刷新缓存
- 所有静态资源必须加 content hash
- HTML文件不缓存或缓存时间极短(5分钟)
- 发版后验证 CDN 缓存是否刷新
7.4 浏览器内存泄漏
打开 DevTools → Memory 面板:
- 拍一个 Heap Snapshot
- 操作页面(来回切换路由等)
- 再拍一个 Heap Snapshot
- 选择 Comparison 视图,对比两次快照
- 关注 Detached DOM nodes — 已从DOM树移除但仍被JS引用的元素
// 常见泄漏: 忘记移除事件监听器
// 每次 mounted 都 addEventListener 但 unmounted 不 removeEventListener
// 或者 setInterval 没有 clearInterval
8. Docker / K8s 问题
8.1 容器不断重启
| Exit Code | 含义 | 排查方向 |
|---|---|---|
Exit 0 | 正常退出 | 检查是否被误停 |
Exit 1 | 应用错误 | docker logs <container>看日志 |
Exit 137 | 被SIGKILL(OOMKilled) | 增加内存限制或排查内存泄漏 |
Exit 139 | Segmentation Fault | 检查 native 模块 |
Exit 143 | 被SIGTERM(正常停止) | 检查健康检查配置 |
8.2 完整案例: K8s Pod CrashLoopBackOff
Pod 启动3秒后崩溃,反复重启
K8s中某个 Pod 状态为 CrashLoopBackOff,已重启 17 次。
# Step 1: 查看 Pod 状态
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
my-app-7d9f8b6c4-x2k9l 0/1 CrashLoopBackOff 17 42m
# Step 2: 查看 Pod 事件
$ kubectl describe pod my-app-7d9f8b6c4-x2k9l
Warning BackOff 3m kubelet Back-off restarting failed container
Last State: Terminated
Reason: Error
Exit Code: 1
# Step 3: 查看容器日志(上一次的,因为当前可能还没启动完)
$ kubectl logs my-app-7d9f8b6c4-x2k9l --previous
ERROR o.s.boot.SpringApplication - Application run failed
com.zaxxer.hikari.pool.HikariPool$PoolInitializationException:
Failed to initialize pool: HikariPool-1
Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException:
Communications link failure
at com.mysql.cj.jdbc.exceptions.SQLError.createCommunicationsException(...)
...
***************************
APPLICATION FAILED TO START
***************************
Pod 启动时尝试连接 MySQL,但 MySQL Service 还没准备好(DNS解析不到 mysql-server)。应用没有启动重试逻辑,连接失败直接退出。
# 方案1: 应用层加重试逻辑
// Spring Boot 配置 HikariCP 连接重试
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public DataSource dataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
// 或用 Spring Retry 注解,自动重试
@Retryable(
value = {SQLException.class, CommunicationsException.class},
maxAttempts = 10,
backoff = @Backoff(delay = 3000)
)
public void connectWithRetry() {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
}
}
# 方案2: K8s Startup Probe
startupProbe:
httpGet:
path: /actuator/health
port: 8080
failureThreshold: 30
periodSeconds: 3
# 给应用 30 * 3s = 90秒 启动时间
9. 应急预案模板
9.1 页面不可用 — 5分钟恢复SOP
9.2 数据丢失 — 备份恢复流程
- 立即停止写入 — 防止数据被进一步覆盖
- 评估损失范围 — 哪些数据?什么时间段的?
- 找到最近的备份 — 全量备份 + 增量binlog
- 在恢复环境演练 — 不要直接在生产恢复!
- 执行恢复 — 先全量,再回放binlog到指定时间点
- 验证数据完整性 — 抽查关键数据是否正确
# MySQL 数据恢复示例
$ mysql -u root -p db_name < full_backup_20250609.sql
$ mysqlbinlog --start-datetime="2025-06-09 10:00:00" \
--stop-datetime="2025-06-09 14:30:00" \
/var/lib/mysql/mysql-bin.000123 | mysql -u root -p
9.3 安全事件 — 被入侵后怎么做
- 隔离受影响机器 — 断网,防止攻击者进一步操作
- 保留现场 — 不要重启! 保存内存dump、磁盘快照、日志
- 分析入侵路径 — 查看登录日志、crontab、可疑进程
- 修改所有密码/密钥 — 数据库密码、API Key、SSH密钥
- 通知相关方 — 法务、安全团队、受影响用户
- 修复漏洞 — 打补丁、升级依赖、加固配置
# 快速排查入侵痕迹
$ last -20 # 查看最近登录
$ cat /var/log/auth.log | grep "Accepted" # SSH登录记录
$ crontab -l # 检查定时任务
$ ps auxf # 查看所有进程
$ netstat -antp # 查看网络连接
$ find /tmp -type f -executable # 查找可疑文件
10. 20个真实生产案例
以下案例均来自真实的生产环境故障,每个包含完整的排查过程和经验教训。
Spring Boot 死循环导致CPU 100%
支付服务CPU持续100%,所有支付请求超时。
top -Hp <pid> 发现线程TID 0x3a。jstack发现一个 while(true) 中的条件判断永远为 true。
分页查询的 offset 在溢出后变成负数,导致 hasNext 永远返回 true。
修复分页逻辑,加入 offset 上限校验。
循环必须有退出条件和超时保护。
Java 应用频繁 Full GC 导致响应超时
每5分钟接口超时一次,持续10秒左右。
jstat -gcutil 显示 Full GC 频率异常。jmap 导出堆发现大量 byte[] 对象。
图片处理服务将大图直接加载到内存,没有压缩。
限制图片最大尺寸,压缩后再处理。
处理用户上传的大文件必须限制大小。
Node.js 内存泄漏: 闭包持有大对象
服务内存每天增长200MB,一周后被OOM Kill。
Heap snapshot 对比发现大量 RequestContext 对象未释放。
日志中间件的闭包捕获了整个 request body(含文件内容),日志队列积压导致对象无法GC。
日志只记录元数据,不记录完整body。设置队列上限。
闭包是最常见的隐性内存泄漏来源。
Redis 连接池耗尽导致全站不可用
所有依赖Redis的接口超时,全站几乎瘫痪。
redis-cli info clients 显示连接数达到 maxclients 上限。
新上线的定时任务每秒创建大量短连接,没有复用连接池。
改为使用共享连接池,设置 maxIdle 和连接复用。
任何中间件访问都必须走连接池。
Nginx 日志把磁盘写满导致502
所有请求返回502 Bad Gateway。
Nginx error.log: write() failed (28: No space left on device)
access.log 每天产生10GB,没有配置logrotate,磁盘100%。
清空日志,配置 logrotate,加磁盘监控。
日志轮转是上线checklist必选项。
DNS 解析失败导致间歇性超时
调用第三方API偶尔超时,频率约每小时2-3次。
在超时时间点用 dig 发现DNS解析耗时5秒+。
CoreDNS负载高时响应慢,应用默认DNS超时5秒。
配置本地DNS缓存(nscd),缩短DNS超时,加DNS监控。
间歇性超时要考虑DNS和网络的边缘情况。
MySQL 慢查询导致主从延迟
读从库的数据延迟10分钟+,用户看到旧数据。
SHOW SLAVE STATUS Seconds_Behind_Master = 600+。慢查询日志发现一个 UPDATE 扫描了300万行。
缺少索引的 UPDATE 在主库执行慢,从库回放更慢。
加索引,优化SQL,拆分大批量操作为小批次。
主从延迟通常是大事务或慢查询导致。
K8s OOMKilled: 容器内存限制太小
Pod 状态 OOMKilled,describe 显示 Last State: Terminated Reason: OOMKilled Exit Code: 137。
查看 resources.limits.memory = 256Mi,应用启动后需要400MB+。
JVM默认堆大小超出容器限制,触发OOM Killer。
设置 -XX:MaxRAMPercentage=75.0 让JVM感知容器限制。调大 limits 到 512Mi。
JVM在容器中必须配置感知cgroup的内存限制。
CDN 缓存导致用户看到旧版本页面
发版后大量用户反馈看到旧UI。
检查CDN缓存命中率,HTML文件缓存了2小时。
HTML的 Cache-Control 设成了 max-age=7200。HTML中引用的JS虽然有hash,但HTML本身被缓存了。
HTML设为 no-cache,发版后刷新CDN缓存。
HTML和API不缓存,静态资源(带hash)长缓存。
数据库连接泄漏: 忘记在finally中关闭
运行几小时后报 Too many connections。
SHOW PROCESSLIST 大量 Sleep 连接来自应用服务器。
异常路径没有关闭连接。conn.query() 抛异常后跳过了 conn.release()。
使用 try/finally 或连接池自动回收。
资源释放必须在 finally 块中,或使用 with/connection pool。
线程池打满导致服务假死
服务没挂但所有请求超时,健康检查也超时。
jstack 显示所有线程都处于 WAITING 状态,等待一个慢接口返回。
下游API变慢,线程池所有线程被占用等待,新请求无法处理。
给外部调用加超时,线程池设拒绝策略和监控。
线程池必须有监控和合理的拒绝策略。
大事务导致数据库锁表
所有写操作阻塞,应用大面积超时。
SHOW ENGINE INNODB STATUS 显示一个事务持有了大量行锁,运行了30分钟。
批处理任务在一个事务中更新10万条记录。
拆分为每次1000条的小事务。
事务要短小,绝不能在事务中做耗时操作。
Elasticsearch 堆内存溢出
ES集群状态变红,搜索请求失败。
ES日志: OutOfMemoryError: Java heap space。
一个深度聚合查询(deep aggregation)消耗了大量堆内存。
限制聚合深度,增加 indices.breaker.total.limit,调整JVM堆大小。
ES查询也需要限制复杂度,不能无限聚合。
Nginx 504 Gateway Timeout: 上游响应太慢
文件上传接口返回504。
Nginx error.log: upstream timed out (110: Connection timed out)。
大文件上传处理需要120秒,但Nginx的 proxy_read_timeout 默认60秒。
对上传接口单独设置 proxy_read_timeout 300s;。
了解你的反向代理超时配置,不要用默认值。
WebSocket 连接泄漏导致内存溢出
实时推送服务内存缓慢增长,2天后OOM。
Heap dump发现大量 WebSocket connection 对象没有关闭。
客户端断开后,服务端没有监听 close 事件,连接对象一直保留在内存中。
加上 close/ping-pong 超时检测,清理僵尸连接。
长连接必须有心跳检测和超时清理机制。
K8s ImagePullBackOff: 私有仓库认证失败
Pod一直处于 ImagePullBackOff 状态。
kubectl describe pod: Failed to pull image: authentication required。
imagePullSecrets 过期了,没有更新。
更新 Secret,确认 imagePullSecrets 配置正确。
定期检查Secret过期时间,加入巡检项。
消息队列积压导致订单处理延迟
用户下单后5分钟才收到确认邮件(正常10秒)。
RabbitMQ 管理界面显示队列积压 50000+ 消息,消费者只有2个。
促销活动流量暴增10倍,消费者处理能力不足。
紧急扩容消费者到20个,长期方案:加自动扩缩容。
消息队列要有积压监控和自动扩容。
CORS 配置错误导致前端无法调用API
前端console报 Access-Control-Allow-Origin 错误。
后端CORS配置只允许 http://localhost:3000,生产域名没加。
CORS白名单硬编码为开发环境地址,没有用环境变量。
CORS配置改为从环境变量读取,支持多环境。
所有环境相关配置必须用环境变量,不要硬编码。
SSL 证书过期导致服务不可用
浏览器提示"您的连接不是私密连接",App端请求全部失败。
检查证书有效期发现已过期3小时。
没有证书过期监控,也没有自动续期。
紧急更新证书。长期:使用 Let's Encrypt + certbot 自动续期,加监控。
SSL证书过期是低级但致命的错误,必须自动续期+监控。
日志级别错误导致敏感信息泄露
安全审计发现生产日志中包含用户密码明文。
搜索日志文件,发现登录接口的DEBUG日志打印了完整request body。
生产环境日志级别设成了DEBUG,且没有脱敏处理。
生产设为INFO级别,对敏感字段脱敏处理,清理历史日志。
日志脱敏是安全基线,上线前必须检查日志内容。
线上问题排查手册 v2.0 — 从方法论到实战案例
记住黄金法则: 先恢复 → 再排查 → 后复盘