线上问题排查手册

Production Troubleshooting Guide — 从方法论到真实案例,掌握结构化排查能力

1. 排查方法论

1.1 结构化排查五步法

线上出问题不可怕,可怕的是没有章法地乱排查。记住这五个步骤:

现象确认
指标采集
根因定位
修复验证
复盘总结
用户反馈/告警
top/日志/监控
缩小范围
修复/回滚
复盘文档
📚 每一步做什么
步骤做什么常用手段
现象确认搞清楚"什么坏了"用户反馈、监控告警、错误日志
指标采集量化问题的严重程度CPU/内存/磁盘/网络指标
根因定位找到为什么会坏日志分析、profiling、代码回溯
修复验证修复并确认恢复发布修复、观察指标
复盘总结防止再次发生复盘文档、加监控、改流程

1.2 日志等级

DEBUG
调试信息
开发阶段用
INFO
关键流程
请求进出记录
WARN
潜在问题
可恢复的异常
ERROR
业务异常
需要人工关注
FATAL
系统崩溃
进程即将退出

1.3 必备工具箱

top / htop
查看CPU、内存占用排行
iostat
磁盘I/O统计,排查IO瓶颈
vmstat
虚拟内存统计,swap情况
netstat / ss
网络连接统计,端口监听状态
tcpdump
抓包分析,网络层问题排查
journalctl
systemd服务日志查询
strace
追踪系统调用,定位底层问题
lsof
查看进程打开的文件/端口

1.4 生产环境黄金法则

先恢复 → 再排查 → 后复盘
Keep calm. Rollback first. Investigate later.
⚠️ 新人最容易犯的错
  • 在出问题的时候疯狂排查,忘了先恢复服务 — 每多一分钟宕机,就多一分损失
  • 没有保留现场就重启了 — 重启前先 dump 内存、保存日志
  • 排查完就完了,不复盘 — 同样的问题会再来第二次
  • 一个人闷头查,不通知团队 — 大故障要拉群,多人协作

2. CPU 飙高 — 从告警到定位的完整思路

🤖 生活类比
CPU飙高 = Excel卡死了 — 你写了一个巨大的VLOOKUP公式,Excel在疯狂计算,什么都点不动。只不过服务器上的"Excel"是你的Java应用程序。

再想一层:你的公司(进程)有很多员工(线程)。如果有一个员工发疯了,疯狂地在原地打转(死循环),那他一个人就能把公司的电费(CPU)用光,其他员工想干活也没有资源了。

2.1 先理解: 为什么CPU会飙高?

在开始排查之前,你需要先理解几个概念。不然命令背了也不知道为什么这么做。

📚 核心概念: 进程 → 线程 → 代码
层次是什么生活类比
进程 一个运行的程序,比如你的 java -jar app.jar 一栋办公楼
线程 进程里面的执行单元,程序靠线程跑代码 办公楼里的一个个员工
代码 线程正在执行的具体方法/逻辑 员工正在做的具体工作

为什么要这样拆? 因为CPU飙高,不是"整个程序"在烧CPU,而是某一个或几个线程在疯狂执行某段代码。你只有先找到是哪个进程,再找到是哪个线程,最后看这个线程在执行什么代码,才能定位到根因。

🤔 想一想: CPU为什么会飙到100%?

正常情况下,线程执行完一个请求后会等待(让出CPU给别人用)。但如果线程停不下来,就会把CPU吃满。什么情况下停不下来?

  • 死循环while(true) 里的条件永远为 true,出不去
  • 正则回溯 — 正则引擎在尝试天文数字的匹配路径,几百年都跑不完
  • 频繁GC — 内存快满了,垃圾回收器疯狂工作但怎么也回收不够
  • 加密/序列化 — 大量数据加解密、序列化,纯CPU密集计算
  • 无限递归 — 方法自己调自己,栈帧越堆越深(最终会StackOverflow,但在那之前CPU会飙)

2.2 症状

2.3 排查思路: 从告警到代码行,五步搞定

记住这个排查链: 告警 → 哪个进程 → 哪个线程 → 线程在执行什么代码 → 为什么这段代码会死循环

收到CPU告警
top
找到高CPU的进程PID
▼ 进程里面有很多线程,是哪个在捣乱?
top -Hp PID
找到CPU最高的线程TID
printf '%x\n' TID
▼ 线程ID转成十六进制,因为jstack用的是十六进制
jstack PID
grep 十六进制TID
看到线程在执行什么代码
1
top — 找到CPU最高的进程
输入 top,看 %CPU 列,找到数值最大的那个进程,记下它的 PID。比如 java 进程 PID=8921,CPU 98%。
2
top -Hp <PID> — 找到CPU最高的线程
-H 参数意思是"显示线程", -p 指定进程。这样你就能看到这个进程里所有线程的CPU排行。找到CPU最高的那个线程的 TID(线程ID),比如 9032。
🤔 为什么要看线程?因为进程本身不执行代码,是线程在执行。进程CPU高 = 里面某个线程在疯狂跑。
3
printf '%x\n' <TID> — 线程ID转十六进制
printf '%x\n' 9032 得到 2348。为什么要转?因为 jstack 输出的线程ID是用十六进制表示的(nid=0x2348),而 top 显示的是十进制。你得把它转成同一种格式才能对应上。
4
jstack <PID> | grep -A 20 <hex_tid> — 看线程在干什么
jstack 会打印进程里所有线程的堆栈信息(线程在执行哪行代码)。用 grep 过滤出你要找的那个线程, -A 20 表示显示匹配行后20行,这样能看到完整的调用栈。
5
分析堆栈 — 定位到具体代码
堆栈里会有类似 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特征和修复方案都讲清楚。

1

死循环 — 最常见的CPU杀手

🔥 什么是死循环?

循环的退出条件永远不会满足,导致线程一直在循环体内转圈,永远不会让出CPU。

🔎 典型代码 & jstack 特征
// 典型 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
2

正则回溯 — 看不见的CPU炸弹

🔥 什么是正则回溯?

正则引擎在匹配失败时会回退重试(backtracking)。某些正则写法 + 特定输入,会导致尝试路径数量指数级爆炸。比如正则 (a+)+ 匹配 "aaaaaaaaaaaaaa!",尝试路径有 2^n 条,n=14 就有 16384 条,每条都要执行。

⚠️ 这种问题最阴险:平时完全正常,只有特定输入才触发。攻击者可以用这个做 ReDoS(正则拒绝服务)攻击

🔎 典型代码 & jstack 特征
// 危险正则: 嵌套量词 (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 注解,不要自己写正则
3

频繁GC — 垃圾回收器的疯狂加班

🔥 什么是频繁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 章节
4

加密/序列化 — 合理但过量的CPU消耗

🔥 为什么加密会吃CPU?

加密(AES/RSA/MD5)、压缩(GZIP)、序列化(JSON/XML)都是CPU密集型操作。单次没问题,但如果批量处理大量数据,比如一次性加密10万条记录,或者序列化一个巨大的对象树,CPU就会飙高。这种CPU飙高是"正常的",说明程序确实在干活,但可能需要优化处理方式。

🔎 jstack 特征
// 堆栈显示加密/压缩相关类
"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 实现
5

无限递归 — 方法调自己,越陷越深

🔥 什么是无限递归?

方法直接或间接调用自己,且没有正确的终止条件。每次调用都会在栈上创建一个新的栈帧(Stack Frame),栈帧越堆越深。在抛出 StackOverflowError 之前,CPU会先飙高(疯狂创建栈帧和执行方法体)。

类比: 俄罗斯套娃,你一层一层打开,永远找不到最小的那个,手一直在动(CPU在跑),直到桌子放不下了(StackOverflow)。

🔎 典型代码 & jstack 特征
// 典型 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秒

⚡️ 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% 排查 — 正则回溯

C

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 工具,一条命令搞定:

⚙️ 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 → grepthread -n 3thread <id>
5条命令2条命令
需要手动转十六进制自动显示
只能看一次快照可以持续监控(dashboard)
⚠️ 什么时候用 Arthas,什么时候用传统命令?
  • 生产环境紧急排查: 用传统命令(jstack),Arthas attach 进程时会有短暂停顿
  • 日常开发/测试环境: 用 Arthas,方便快捷,还能反编译代码、查看方法参数
  • 容器环境: Arthas 支持 kubectl exec 进入容器使用

3. 内存溢出 (OOM)

🤖 生活类比
OOM = 你打开了一个200MB的Excel文件,电脑内存不够了 — 系统要么疯狂换页(变卡),要么直接杀掉进程(OOM Killer)。

3.1 症状

3.2 排查步骤

free -h
确认内存使用
ps aux --sort=-%mem
找内存大户
是 Java?
jmap heap dump
分析大对象/泄漏
常用命令:
free -h
ps aux --sort=-%mem
jmap -histo:live
jcmd GC.heap_dump
Arthas dashboard

3.3 常见原因

原因特征典型场景
内存泄漏 内存持续增长,不释放 全局Map不断put不remove
大对象加载 瞬间内存飙升 一次性SELECT *百万行数据
缓存无限增长 缓存量与时间正相关 自建LRU缓存没有淘汰策略
连接池泄漏 连接不归还,内存累积 忘记connection.close()

3.4 完整案例: Java 服务内存从512MB涨到2.8GB

C

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:liveMAT 分析堆对象,提前发现泄漏
  • JVM 加 -Xmx512m -XX:+HeapDumpOnOutOfMemoryError 限制上限并自动 dump

4. 磁盘满

4.1 症状

4.2 排查步骤

1
df -h 查看各分区使用率
2
du -sh /* 找到占用最大的目录
3
逐层深入: du -sh /var/log/*
4
find / -size +500M -type f 找大文件
5
清理或扩容

4.3 完整案例: 日志文件写满磁盘导致MySQL崩溃

C

日志轮转没配置,一个文件长到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 症状

5.2 排查步骤

ping 目标IP
通?
YES
curl/telnet 端口
检查DNS/防火墙
NO
traceroute
检查路由/网关
tcpdump抓包
ss -s 连接统计
netstat -antp
定位网络层问题

5.3 常见原因

原因特征排查方式
DNS解析失败 nslookup失败,偶尔超时 nslookupdig
防火墙规则 ping通但端口不通 telnetiptables -L
连接数耗尽 新连接建立失败 ss -s查看TIME_WAIT数量
带宽打满 传输极慢,丢包 iftopnethogs

5.4 完整案例: TIME_WAIT 太多导致新连接失败

C

高并发短连接场景下 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 症状

6.2 排查步骤

1
开启 slow_query_log,设置 long_query_time
2
mysqldumpslowpt-query-digest 分析慢查询
3
对问题SQL执行 EXPLAIN
4
优化: 加索引、改写SQL、分页查询

6.3 完整案例: 慢查询优化

C

订单列表查询从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互相等待

C

转账场景下的死锁

🔴 现象

日志中报: Deadlock found when trying to get lock; try restarting transaction

死锁示意图
事务A
UPDATE account SET balance=balance-100 WHERE id=1
持有: id=1的锁
等待: id=2的锁 ⌛
DEADLOCK
事务B
UPDATE account SET balance=balance-50 WHERE id=2
持有: id=2的锁
等待: id=1的锁 ⌛
🔍 排查过程
# 查看最近的死锁信息
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. 数据库问题 — 连接数耗尽

C

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 白屏问题排查

1
打开 DevTools Console — 看有没有JS报错
2
检查 Network Tab — 资源加载是否404/超时
3
检查路由 — 是否匹配到了空组件
4
检查CDN缓存 — 新版HTML引用旧版JS

7.2 API错误速查

错误码含义排查方向
CORS Error 跨域被拦截 后端 Response Header Access-Control-Allow-Origin
401 未认证/Token过期 检查Token是否有效,刷新机制
500 服务端内部错误 看后端日志,排查具体异常
Timeout 请求超时 网络问题或后端处理慢

7.3 完整案例: 上线后部分用户白屏

C

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 面板:

  1. 拍一个 Heap Snapshot
  2. 操作页面(来回切换路由等)
  3. 再拍一个 Heap Snapshot
  4. 选择 Comparison 视图,对比两次快照
  5. 关注 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 139Segmentation Fault检查 native 模块
Exit 143被SIGTERM(正常停止)检查健康检查配置

8.2 完整案例: K8s Pod CrashLoopBackOff

C

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

🚨 页面挂了 — SOP
1
确认问题(30秒) — 打开页面,确认确实挂了。看监控大盘。
2
拉群通报(1分钟) — 建立应急群,拉入负责人,说明: "XX页面不可用,正在排查"
3
快速判断(1分钟) — 最近有没有发版?有的话直接回滚
4
恢复服务(2分钟) — 回滚/重启/扩容/切换流量,用最快的手段恢复
5
确认恢复(30秒) — 验证功能正常,群里通报"已恢复"

9.2 数据丢失 — 备份恢复流程

💾 数据丢了 — 恢复SOP
  1. 立即停止写入 — 防止数据被进一步覆盖
  2. 评估损失范围 — 哪些数据?什么时间段的?
  3. 找到最近的备份 — 全量备份 + 增量binlog
  4. 在恢复环境演练 — 不要直接在生产恢复!
  5. 执行恢复 — 先全量,再回放binlog到指定时间点
  6. 验证数据完整性 — 抽查关键数据是否正确
# 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 安全事件 — 被入侵后怎么做

🔒 安全事件 — 应急SOP
  1. 隔离受影响机器 — 断网,防止攻击者进一步操作
  2. 保留现场 — 不要重启! 保存内存dump、磁盘快照、日志
  3. 分析入侵路径 — 查看登录日志、crontab、可疑进程
  4. 修改所有密码/密钥 — 数据库密码、API Key、SSH密钥
  5. 通知相关方 — 法务、安全团队、受影响用户
  6. 修复漏洞 — 打补丁、升级依赖、加固配置
# 快速排查入侵痕迹
$ last -20              # 查看最近登录
$ cat /var/log/auth.log | grep "Accepted"  # SSH登录记录
$ crontab -l            # 检查定时任务
$ ps auxf               # 查看所有进程
$ netstat -antp         # 查看网络连接
$ find /tmp -type f -executable # 查找可疑文件

10. 20个真实生产案例

以下案例均来自真实的生产环境故障,每个包含完整的排查过程和经验教训。

1

Spring Boot 死循环导致CPU 100%

🔴 现象

支付服务CPU持续100%,所有支付请求超时。

🔍 排查

top -Hp <pid> 发现线程TID 0x3a。jstack发现一个 while(true) 中的条件判断永远为 true。

🔥 根因

分页查询的 offset 在溢出后变成负数,导致 hasNext 永远返回 true。

✅ 修复

修复分页逻辑,加入 offset 上限校验。

📚 教训

循环必须有退出条件和超时保护。

2

Java 应用频繁 Full GC 导致响应超时

🔴 现象

每5分钟接口超时一次,持续10秒左右。

🔍 排查

jstat -gcutil 显示 Full GC 频率异常。jmap 导出堆发现大量 byte[] 对象。

🔥 根因

图片处理服务将大图直接加载到内存,没有压缩。

✅ 修复

限制图片最大尺寸,压缩后再处理。

📚 教训

处理用户上传的大文件必须限制大小。

3

Node.js 内存泄漏: 闭包持有大对象

🔴 现象

服务内存每天增长200MB,一周后被OOM Kill。

🔍 排查

Heap snapshot 对比发现大量 RequestContext 对象未释放。

🔥 根因

日志中间件的闭包捕获了整个 request body(含文件内容),日志队列积压导致对象无法GC。

✅ 修复

日志只记录元数据,不记录完整body。设置队列上限。

📚 教训

闭包是最常见的隐性内存泄漏来源。

4

Redis 连接池耗尽导致全站不可用

🔴 现象

所有依赖Redis的接口超时,全站几乎瘫痪。

🔍 排查

redis-cli info clients 显示连接数达到 maxclients 上限。

🔥 根因

新上线的定时任务每秒创建大量短连接,没有复用连接池。

✅ 修复

改为使用共享连接池,设置 maxIdle 和连接复用。

📚 教训

任何中间件访问都必须走连接池。

5

Nginx 日志把磁盘写满导致502

🔴 现象

所有请求返回502 Bad Gateway。

🔍 排查

Nginx error.log: write() failed (28: No space left on device)

🔥 根因

access.log 每天产生10GB,没有配置logrotate,磁盘100%。

✅ 修复

清空日志,配置 logrotate,加磁盘监控。

📚 教训

日志轮转是上线checklist必选项。

6

DNS 解析失败导致间歇性超时

🔴 现象

调用第三方API偶尔超时,频率约每小时2-3次。

🔍 排查

在超时时间点用 dig 发现DNS解析耗时5秒+。

🔥 根因

CoreDNS负载高时响应慢,应用默认DNS超时5秒。

✅ 修复

配置本地DNS缓存(nscd),缩短DNS超时,加DNS监控。

📚 教训

间歇性超时要考虑DNS和网络的边缘情况。

7

MySQL 慢查询导致主从延迟

🔴 现象

读从库的数据延迟10分钟+,用户看到旧数据。

🔍 排查

SHOW SLAVE STATUS Seconds_Behind_Master = 600+。慢查询日志发现一个 UPDATE 扫描了300万行。

🔥 根因

缺少索引的 UPDATE 在主库执行慢,从库回放更慢。

✅ 修复

加索引,优化SQL,拆分大批量操作为小批次。

📚 教训

主从延迟通常是大事务或慢查询导致。

8

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的内存限制。

9

CDN 缓存导致用户看到旧版本页面

🔴 现象

发版后大量用户反馈看到旧UI。

🔍 排查

检查CDN缓存命中率,HTML文件缓存了2小时。

🔥 根因

HTML的 Cache-Control 设成了 max-age=7200。HTML中引用的JS虽然有hash,但HTML本身被缓存了。

✅ 修复

HTML设为 no-cache,发版后刷新CDN缓存。

📚 教训

HTML和API不缓存,静态资源(带hash)长缓存。

10

数据库连接泄漏: 忘记在finally中关闭

🔴 现象

运行几小时后报 Too many connections

🔍 排查

SHOW PROCESSLIST 大量 Sleep 连接来自应用服务器。

🔥 根因

异常路径没有关闭连接。conn.query() 抛异常后跳过了 conn.release()

✅ 修复

使用 try/finally 或连接池自动回收。

📚 教训

资源释放必须在 finally 块中,或使用 with/connection pool。

11

线程池打满导致服务假死

🔴 现象

服务没挂但所有请求超时,健康检查也超时。

🔍 排查

jstack 显示所有线程都处于 WAITING 状态,等待一个慢接口返回。

🔥 根因

下游API变慢,线程池所有线程被占用等待,新请求无法处理。

✅ 修复

给外部调用加超时,线程池设拒绝策略和监控。

📚 教训

线程池必须有监控和合理的拒绝策略。

12

大事务导致数据库锁表

🔴 现象

所有写操作阻塞,应用大面积超时。

🔍 排查

SHOW ENGINE INNODB STATUS 显示一个事务持有了大量行锁,运行了30分钟。

🔥 根因

批处理任务在一个事务中更新10万条记录。

✅ 修复

拆分为每次1000条的小事务。

📚 教训

事务要短小,绝不能在事务中做耗时操作。

13

Elasticsearch 堆内存溢出

🔴 现象

ES集群状态变红,搜索请求失败。

🔍 排查

ES日志: OutOfMemoryError: Java heap space

🔥 根因

一个深度聚合查询(deep aggregation)消耗了大量堆内存。

✅ 修复

限制聚合深度,增加 indices.breaker.total.limit,调整JVM堆大小。

📚 教训

ES查询也需要限制复杂度,不能无限聚合。

14

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;

📚 教训

了解你的反向代理超时配置,不要用默认值。

15

WebSocket 连接泄漏导致内存溢出

🔴 现象

实时推送服务内存缓慢增长,2天后OOM。

🔍 排查

Heap dump发现大量 WebSocket connection 对象没有关闭。

🔥 根因

客户端断开后,服务端没有监听 close 事件,连接对象一直保留在内存中。

✅ 修复

加上 close/ping-pong 超时检测,清理僵尸连接。

📚 教训

长连接必须有心跳检测和超时清理机制。

16

K8s ImagePullBackOff: 私有仓库认证失败

🔴 现象

Pod一直处于 ImagePullBackOff 状态。

🔍 排查

kubectl describe pod: Failed to pull image: authentication required

🔥 根因

imagePullSecrets 过期了,没有更新。

✅ 修复

更新 Secret,确认 imagePullSecrets 配置正确。

📚 教训

定期检查Secret过期时间,加入巡检项。

17

消息队列积压导致订单处理延迟

🔴 现象

用户下单后5分钟才收到确认邮件(正常10秒)。

🔍 排查

RabbitMQ 管理界面显示队列积压 50000+ 消息,消费者只有2个。

🔥 根因

促销活动流量暴增10倍,消费者处理能力不足。

✅ 修复

紧急扩容消费者到20个,长期方案:加自动扩缩容。

📚 教训

消息队列要有积压监控和自动扩容。

18

CORS 配置错误导致前端无法调用API

🔴 现象

前端console报 Access-Control-Allow-Origin 错误。

🔍 排查

后端CORS配置只允许 http://localhost:3000,生产域名没加。

🔥 根因

CORS白名单硬编码为开发环境地址,没有用环境变量。

✅ 修复

CORS配置改为从环境变量读取,支持多环境。

📚 教训

所有环境相关配置必须用环境变量,不要硬编码。

19

SSL 证书过期导致服务不可用

🔴 现象

浏览器提示"您的连接不是私密连接",App端请求全部失败。

🔍 排查

检查证书有效期发现已过期3小时。

🔥 根因

没有证书过期监控,也没有自动续期。

✅ 修复

紧急更新证书。长期:使用 Let's Encrypt + certbot 自动续期,加监控。

📚 教训

SSL证书过期是低级但致命的错误,必须自动续期+监控。

20

日志级别错误导致敏感信息泄露

🔴 现象

安全审计发现生产日志中包含用户密码明文。

🔍 排查

搜索日志文件,发现登录接口的DEBUG日志打印了完整request body。

🔥 根因

生产环境日志级别设成了DEBUG,且没有脱敏处理。

✅ 修复

生产设为INFO级别,对敏感字段脱敏处理,清理历史日志。

📚 教训

日志脱敏是安全基线,上线前必须检查日志内容。

线上问题排查手册 v2.0 — 从方法论到实战案例

记住黄金法则: 先恢复 → 再排查 → 后复盘