《生产事故现场作战白皮书:百条速查命令 + 三大血泪案例 + 架构级防故障指南》
真正把系统拖进深夜会议室的,通常不是“某个组件挂了”,而是大家在前 10 分钟里判断错了方向。本文不讲空泛口号,只讲生产现场怎么判断、命令为什么这样打、哪些修复只是止血、哪些改造才能避免同一类事故反复出现。
一、先回到事故现场:P0 不是从报错开始,而是从误判开始
这篇文章讨论的不是单个中间件,也不是某一类报错,而是高并发 Java 微服务在生产环境中的一整套应急动作。目标读者也不是刚学命令的人,而是已经要值班、要扛线上稳定性指标、要在群里给出判断的人。
原始场景来自一个典型电商订单链路:
用户端 -> API 网关 -> 订单服务 -> 库存服务 -> 支付服务
依赖组件包括:
• MySQL 8.0 主从 • Redis 7 集群 • RocketMQ 4.9 • Nacos 2.2 • Kubernetes 1.28
业务约束也很真实:
• 平时日均订单量约 500 万 • 大促峰值 QPS 12 万 • 核心表 orders已达 3 亿行• 订单链路包含同步写库和异步状态流转
事故发生在大促开始后的 10 分钟内:
Too many connections | ||
真正危险的点在这里:数据库打满、消息积压、应用超时,往往不是三个问题,而是一条故障链上的三个结果。
如果现场直接做这几件事,通常会把事故拖得更久:
• 看到 CPU 高就先扩容全部应用 • 看到 MQ 积压就先加消费者线程 • 看到 502 就先重启 Pod • 看到慢查询就先 Kill 掉一批 SQL
这些动作不一定错,但如果没有先判断“瓶颈是在 CPU、锁、连接池、网络、下游超时还是线程池”,就很可能把一个局部故障扩大成系统性抖动。
生产现场最先要回答的 4 个问题
在任何命令之前,先把判断框架立住:
1. 系统是在变慢,还是已经部分不可用 2. 瓶颈在调用链哪一层扩散 3. 当前是资源耗尽,还是等待放大 4. 应急动作会不会把压力转移到下游
这四个问题的价值,比多记几十条命令更大。因为命令只是取证手段,不是结论本身。
二、第一响应不是“查全部”,而是按故障链收缩范围
多数线上事故都可以先归到下面四类之一:
topvmstat、连接池指标 | ||
jstackprocesslist、调用超时分布 | ||
现场动作也应该分成三层,而不是“发现异常就开始改”:
1. 先止血
目标是让系统别继续失血,而不是立刻找出全部真相。
常见动作:
• 对入口限流,压住写流量 • 关闭非核心异步链路 • 暂停会放大压力的重试任务 • 对缓存回源增加舱壁和兜底
2. 再定位
这一阶段只回答“当前主瓶颈是什么”,不急着一口气解释所有异常。
例如这次事故里,最关键的事实链其实是:
MySQL metadata lock / 慢 SQL -> 连接池等待 -> 订单线程池阻塞 -> 网关超时 -> MQ 消费跟不上
如果这个链条没看清,后面所有扩容都只是在拖时间。
3. 最后修复
修复要分成两类:
• 临时修复:为恢复服务而做的止血动作 • 长期修复:为避免同类事故再次发生的改造
把这两类混在一起,是复盘文章最常见的问题。生产现场能救回来,不代表架构已经健康。
三、百条速查命令:不是背命令,而是知道每条命令在回答什么
下面这 100 条命令按现场排查顺序组织,而不是按工具分类堆砌。每条命令都只解决一个判断问题。
A. 机器与内核:先确认是不是宿主机已经扛不住
uptime | ||
w | ||
top | ||
top -Hp <pid> | ||
htop | ||
vmstat 1 | ||
mpstat -P ALL 1 | ||
pidstat 1 | ||
free -h | ||
cat /proc/meminfo | ||
sar -r 1 5 | ||
sar -u 1 5 | ||
iostat -x 1 | ||
iotop | ||
df -h | ||
df -i | ||
journalctl -xe --since '10 min ago' | ||
cat /proc/loadavg | ||
uname -a |
B. 进程与文件句柄:确认是不是应用自身已经进入资源枯竭
prlimit --pid <pid> | ||
lsof -i :8080 | ||
cat /proc/<pid>/limits | nofile | |
cat /proc/<pid>/status | ||
C. 网络:确认超时是来自链路问题还是应用等待
ss -s | ||
ss -lntp | ||
ip addr | ||
ip route | ||
ping <host> | ||
traceroute <host> | ||
mtr -rwzbc 20 <host> | ||
sar -n DEV 1 5 | ||
sar -n TCP,ETCP 1 5 | ||
tcpdump -i any port 3306 -c 50 |
D. JVM:判断是 GC、死锁、锁竞争还是下游等待
jps -lvm | ||
jstat -gcutil <pid> 1000 10 | ||
jstat -gccapacity <pid> 1000 5 | ||
jcmd <pid> VM.flags | ||
jcmd <pid> GC.heap_info | ||
jcmd <pid> Thread.print | ||
jcmd <pid> GC.class_histogram | ||
jmap -dump:live,format=b,file=heap.hprof <pid> | ||
jcmd <pid> VM.native_memory summary | ||
async-profiler -d 30 -e cpu <pid> | ||
jfr start name=incident settings=profile duration=60s filename=incident.jfr |
E. 应用日志与配置:确认是不是变更、重试或错误分支放大了压力
tail -200 app.log | ||
tail -f app.log | ||
kubectl logs <pod> --previous --tail=200 | ||
kubectl get cm -n <ns> |
F. MySQL:把“数据库慢”拆成连接、锁、执行计划三件事
SHOW FULL PROCESSLIST; | ||
SHOW STATUS LIKE 'Threads_connected'; | ||
SHOW VARIABLES LIKE 'max_connections'; | ||
SHOW ENGINE INNODB STATUS\G | ||
SELECT * FROM sys.innodb_lock_waits; | ||
SELECT * FROM information_schema.innodb_trx\G | ||
SHOW VARIABLES LIKE 'slow_query_log'; | ||
SHOW VARIABLES LIKE 'long_query_time'; | ||
EXPLAIN SELECT ...; | ||
EXPLAIN ANALYZE SELECT ...; | ||
SHOW INDEX FROM <table>; | ||
SHOW OPEN TABLES WHERE In_use > 0; | ||
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%'; | ||
SHOW GLOBAL STATUS LIKE 'Created_tmp%'; | ||
SHOW GLOBAL STATUS LIKE 'Handler_read%'; |
G. Redis:不要只看“可不可用”,还要看是不是被热 Key 和过期风暴打穿
redis-cli INFO server | ||
redis-cli INFO memory | ||
redis-cli INFO stats | ||
redis-cli INFO clients | ||
redis-cli INFO keyspace | ||
redis-cli SLOWLOG GET 20 | ||
redis-cli LATENCY LATEST | ||
redis-cli --latency -h <host> | ||
redis-cli CLUSTER INFO | ||
redis-cli TTL <key> |
H. RocketMQ:消息积压要区分“生产过快”和“消费被堵”
mqadmin clusterList -n <namesrv> | ||
mqadmin topicStatus -n <namesrv> -t <topic> | ||
mqadmin consumerProgress -n <namesrv> -g <group> | ||
mqadmin consumerConnection -n <namesrv> -g <group> | ||
mqadmin queryMsgByKey -n <namesrv> -t <topic> -k <key> |
这 100 条命令怎么用,才不会把自己查乱
现场不建议从第 1 条一直打到第 100 条。更实用的方式是按症状进入:
jstackSHOW FULL PROCESSLIST、ss -antp | |
top -Hpasync-profiler、EXPLAIN | |
consumerProgress | |
这里有两个边界要说清楚:
• jmap -dump、tcpdump、EXPLAIN ANALYZE这类命令很有价值,但都可能对现场造成额外负担,不适合在主库已经打满时随意执行。• 单条命令永远只能给出局部证据。比如 Threads_connected高,并不自动等于“数据库不够用”,也可能只是应用连接归还失败。
四、三大血泪案例:真正的根因,通常藏在“看起来最正常”的地方
下面三个案例保留了原文的核心问题,但把判断过程补完整,因为真正有价值的不是“最后答案”,而是怎么排除错误方向。
案例一:数据库连接池爆满,根因不在数据库,而在连接没有被归还
现场现象
• 订单服务大面积报 502 • 日志连续出现 Connection is not available• 数据库连接数超过 1500 • 应用线程大量阻塞在获取连接
第一眼最容易下的结论
很多团队到这里会直接说“数据库不够用了”,然后开始加 max_connections、扩主库规格,甚至把连接池也一起调大。
这类处理有时能临时缓一下,但它绕开了一个关键问题:连接是被真正占用,还是借出去之后没有归还。
现场排查路径
1. SHOW FULL PROCESSLIST发现大量连接处于Sleep,并且Time很长。2. 订单服务配置为 maximum-pool-size=200,共 5 个 Pod,理论上最多约 1000 连接。3. 数据库侧却看到 1500+ 连接,说明存在池外连接,或者连接生命周期已经失控。 4. jstack继续看,线程并没有都在执行 SQL,而是有相当一部分阻塞在下载接口相关逻辑。5. 最后追到一个文件导出接口,代码使用了 jdbcTemplate.queryForStream,但消费完成后没有完整关闭底层ResultSet和Connection。
真正根因
根因不是“数据库扛不住”,而是连接泄漏导致连接池的借出和归还失衡。数据库只是最先被拖死的那一层。
为什么这种问题在大促更容易爆
• 平时流量低,泄漏速度慢,看起来像偶发抖动 • 大促时请求量陡增,泄漏变成持续累积 • 一旦连接池等待出现,业务线程池会被一起卡死 • 上游超时后触发重试,会进一步放大连接压力
临时止血
• 限制导出接口和其他非核心查询 • 杀掉明显失控的长连接 • 适度清理空闲连接 • 入口限流,避免新流量继续堆积到连接池
长期修复
• 禁止业务代码直接暴露 DataSource.getConnection()• 流式查询必须配套 try-with-resources 或回调式关闭 • 暴露 HikariCP 的 ActiveConnections、IdleConnections、PendingThreads• 连接池等待数持续非 0 即告警,而不是等数据库连接数打满再告警 • 将导出类接口与核心下单链路隔离到独立只读资源池
修复后的验证方式
• 压测时持续观察 PendingThreads• 模拟下载接口中断,确认连接仍能回收 • 检查 Threads_connected与理论池大小是否一致
案例二:缓存雪崩不是“Redis 挂了”,而是你把失效时间排成了队
现场现象
• 整点活动开始后,订单 RT 瞬间抬升 • Redis 仍然可连,但 MySQL CPU 迅速拉满 • 大量热点请求开始直接回源
第一眼最容易下的结论
大家很容易把问题说成“缓存击穿”或者“Redis 被打爆了”。这两个说法都不完全对。
如果 Redis 自身没有明显超时、没有拒绝连接、没有主从切换,那么更应该先想的是:是不是大量热点 Key 在同一时间段集中过期了。
现场排查路径
1. SLOWLOG GET没看到 Redis 自身的慢命令堆积。2. INFO stats里命中率开始掉,但服务端并没有明显资源瓶颈。3. 追活动缓存预热任务,发现一批核心 Key 都在 20:00 统一加载。 4. TTL 统一写成 7200 秒,于是 22:00 前后集中失效。 5. 请求回源后,由于数据库本来就承接同步写压力,读流量一叠加,主库立刻被拉满。
真正根因
根因不是 Redis 故障,而是缓存生命周期设计过于整齐,导致过期事件集中爆发。
这类问题为什么危险
• Redis 监控看起来仍然健康,容易误导现场判断 • 应用看到的是数据库慢,而不是缓存策略错 • 一旦回源没有做并发保护,热点 Key 会形成局部风暴
临时止血
• 对热点接口快速增加入口限流 • 临时回填关键缓存,并把 TTL 打散 • 对热点回源逻辑加互斥锁或单飞控制 • 关闭非核心推荐、画像等附加查询
长期修复
• 物理 TTL 加随机抖动,例如 base + random• 热点数据采用逻辑过期 + 异步刷新,而不是统一物理过期 • 增加本地缓存挡住极短时间内的热点重复回源 • 对空值和异常结果也做短 TTL 缓存,避免穿透 • 缓存重建要和数据库限流绑定,不能允许无限回源
修复后的验证方式
• 统计同一批预热 Key 的 TTL 分布,而不是只看平均值 • 演练 Redis 单分片抖动时,验证回源并发是否被限制 • 观察缓存 miss 突增时,数据库是否仍能稳定在安全水位
案例三:消息积压表面看是 MQ 问题,实质是消费者把下游超时原样放大了
现场现象
• 下单成功后 30 分钟仍未收到通知 • 库存扣减延后 • consumerProgress显示积压达到千万级
第一眼最容易下的结论
常见反应是立刻把消费者线程数调大,或者多扩几个 Pod。这个动作不是不能做,但它默认假设“消费速度慢只是因为算力不够”。
如果真正的问题是下游调用平均 RT 从 50ms 变成 8s,那么单纯加线程只会更快把线程池、连接池、下游接口一起拖死。
现场排查路径
1. mqadmin consumerProgress看到某个核心消费组落后严重。2. mqadmin consumerConnection确认消费者实例都在线,并不是实例丢失。3. jstack发现大量消费线程等待在调用支付网关的 HTTP 客户端上。4. 支付网关配置超时 5 秒,但实际平均 RT 已经高于 8 秒。 5. 消费线程池默认只有 20,线程被长时间占住后,新的消息根本拿不到消费能力。
真正根因
根因不是 MQ,而是消费者把同步下游超时带进了异步链路,并且没有做隔离和快速失败。
临时止血
• 将非核心消费者先停掉,保住核心状态流转 • 调低单次消费批量,避免长耗时消息长时间占用线程 • 对支付网关调用先熔断,失败消息进入重试或死信
长期修复
• 核心状态流转和通知类逻辑拆到不同 Consumer Group• 消费线程池配置和下游超时预算一起设计,而不是各配各的 • 失败消息要有明确去向:重试、死信、人工回放,不能一直阻塞主消费线程 • 基于 Lag 做弹性扩容时,前提是下游依赖还有余量,否则只是更快放大故障
修复后的验证方式
• 注入下游 5 秒以上超时,观察消费者是否快速失败 • 验证死信回放不会重复扣减库存或重复发券 • 将积压恢复时间纳入压测目标,而不是只测正常吞吐
五、命令之外更重要的事:为什么这些故障会沿着调用链扩散
三类问题虽然长得不同,但扩散机制很像,都是同一条链:
这里最容易忽略的是“等待”。
工程上很多事故并不是资源一开始就不够,而是某个等待时间被拉长后,占住了稀缺资源:
• SQL 变慢,占住数据库连接 • 下游接口超时,占住业务线程 • 缓存回源慢,占住请求窗口 • 消费阻塞,占住消息线程池
一旦占住的时间足够长,系统就会从“吞吐下降”进入“排队放大”,最后表现成:
• 连接数打满 • 线程池打满 • 队列积压 • 重试风暴 • 全链路 RT 抬升
所以生产排障不能只问“谁最慢”,还要问:谁在持有共享资源,持有了多久。
六、从被动救火到主动防复发:架构级稳定性指南
如果只收藏命令,不改架构,下一次事故通常只会换个时间再来。真正有效的改造,应该围绕“阻止等待扩散”来设计。
1. 入口先做流量整形,不要让所有请求平等进入核心链路
很多系统在低并发时不需要复杂治理,但当下列条件出现时,入口整形就不是可选项了:
• 活动流量会在分钟级突刺 • 读写共用数据库或共用核心线程池 • 非核心功能与下单、支付等核心路径混跑
建议把入口流量分成三类:
这里需要区分一件事:限流不是为了把 QPS 压低,而是为了给关键资源留出生存空间。
2. 共享依赖必须做舱壁,不要让所有压力落到一个资源池
以下几类资源最容易成为共享瓶颈:
• 数据库连接池 • Redis 连接池 • HTTP 客户端连接池 • 业务线程池 • MQ 消费线程池
更稳妥的做法是:
• 导出、报表、批处理使用独立线程池和独立数据源 • 核心消费和通知消费分组隔离 • 下游不稳定的调用使用独立连接池和超时策略
如果所有业务共享一套线程和连接,任何一个慢依赖都能拖住全局。
3. 缓存不只是“加一层 Redis”,而是要设计失效方式和回源上限
缓存方案成立的前提是:
• 热点键分布相对可预期 • 回源数据库仍有安全余量 • 应用在 miss 时能控制并发
当这些前提不成立时,单层 Redis 远远不够。更适合的组合通常是:
• 本地短 TTL 缓存,挡住瞬时热点 • Redis 做集中式共享缓存 • 热点键逻辑过期,异步刷新 • 回源路径带限流、互斥和兜底
4. 异步链路必须把“失败去哪里”设计清楚
异步不是天然更稳。很多团队把同步压力搬到 MQ 后,以为问题已经解决,实际上只是延后暴露。
设计时至少要回答:
• 下游超时后是重试、丢弃还是进入死信 • 重试是立即重试、指数退避还是人工回放 • 同一条消息被重复投递时,如何保证幂等 • 核心流程和附属流程是否隔离
如果这些问题没有先设计,MQ 积压迟早会变成新的 P0。
5. 监控要覆盖资源、依赖和业务结果,三者缺一不可
一个常见误区是“监控很多,但还是不知道为什么出事”。原因通常是只监控了资源,没有监控等待链。
建议最少覆盖这些指标:
如果只能先做一件事,优先把“等待相关指标”补出来:
• 连接池等待线程数 • 线程池队列长度 • 下游调用 RT 分布 • 锁等待时间 • 消息积压恢复时间
6. 变更治理要把“可回滚”放在“可发布”前面
很多事故不是业务峰值打出来的,而是变更把系统推到了边缘。
生产变更至少要满足:
• 有灰度,不全量直上 • 有回滚,不依赖临场手改配置 • 有基线指标,知道发布前后变化 • 有冻结窗口,避免在高峰期做高风险动作
这里不建议空谈流程,最实用的动作反而很朴素:
• 保留最近 3 个可回退版本 • 每次发布前明确观察指标和观察时长 • 高风险 DDL 与业务发布解耦
七、只保留真正关键的工程实现
原文里有代码示例,这里只保留最能体现长期修复价值的三段。目的不是凑实现,而是说明“怎么把复盘结论变成可执行约束”。
1. 把连接池等待暴露成指标,而不是等数据库先报警
@Configuration
publicclassHikariMetricsConfig {
@Bean
public DataSource dataSource(MeterRegistry registry) {
HikariConfigconfig=newHikariConfig();
// 省略基础连接配置
config.setMetricRegistry(registry);
returnnewHikariDataSource(config);
}
}这段代码本身不复杂,关键在于后面的监控规则。生产环境至少要补两条判断:
• 活跃连接长期超过池上限的 80% • 等待线程数持续大于 0
第一条说明资源紧张,第二条说明业务已经开始排队。真正要命的通常是第二条。
2. 缓存防雪崩不是“加随机数”这么简单,还要限制回源并发
@Service
publicclassCacheService {
privatefinal RedisTemplate<String, Object> redisTemplate;
publicvoidsetWithJitter(String key, Object value, long baseSeconds, long jitterSeconds) {
longttl= baseSeconds + ThreadLocalRandom.current().nextLong(jitterSeconds);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttl));
}
}这只能解决“同一时刻一起过期”的问题,解决不了“过期后一起回源”的问题。生产环境还需要再补:
• 热点键单飞控制 • 逻辑过期异步刷新 • 回源失败的兜底数据 • 空值短 TTL 缓存
3. MQ 消费者要先保证幂等和隔离,再谈扩容
@Component
@RocketMQMessageListener(
topic = "OrderTopic",
consumerGroup = "core-order-consumer",
consumeThreadMin = 15,
consumeThreadMax = 30)
publicclassOrderCoreConsumerimplementsRocketMQListener<OrderMessage> {
@Override
publicvoidonMessage(OrderMessage message) {
Stringkey="order:msg:" + message.getOrderId() + ":" + message.getEventId();
Booleanlocked= redisTemplate.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.MINUTES);
if (Boolean.TRUE.equals(locked)) {
try {
orderService.processCore(message);
} finally {
redisTemplate.delete(key);
}
}
}
}这段代码解决的是“重复消费别把核心业务做两次”。但它依然有适用边界:
• Redis 本身必须稳定,否则幂等层会变成新依赖 • 幂等键过期时间要覆盖业务可重试窗口 • 如果业务要求强一致,还需要结合本地消息表或去重表设计
八、什么时候值得把系统升级到“架构级防故障”
不是所有系统都需要把 Sentinel、HPA、混沌工程、全链路治理一次配齐。对低并发、低复杂度系统来说,过度设计本身就是成本。
更适合升级的典型信号是:
相反,如果系统还处在这些阶段,就没必要一开始就上很重的治理:
• 单体应用,日订单量只有几万 • 故障主要来自功能 bug,而不是容量和等待链 • 团队还没有稳定的监控和发布流程
在这种阶段,先把日志、慢 SQL、连接池和超时配置做好,收益往往比上复杂中间件更直接。
九、复盘之后该落地什么:一份可执行的检查清单
最后留一份简短清单,目的是让复盘能落到下个迭代,而不是停在会议纪要里。
本周内应该完成
• 是否已经给数据库连接池暴露活跃数和等待数 • 是否区分核心流量和非核心流量的限流策略 • 是否检查过热点缓存 TTL 是否集中 • 是否核对过核心消费者的下游超时和线程池大小 • 是否为关键接口准备了人工降级开关
本月内应该完成
• 是否做过一次带业务指标观测的压测 • 是否验证过 Redis miss 激增时数据库仍能承受 • 是否验证过下游超时场景下消费者会进入死信而不是无限阻塞 • 是否保留了可回滚版本和对应变更说明 • 是否做过一次最小规模故障演练,例如杀一个 Pod 或注入 200ms 延迟
不要等出事再补的能力
• 连接池等待告警 • 线程池队列长度告警 • MQ Lag 恢复时间监控 • 发布后基线对比 • 核心链路幂等校验
十、结尾只说一个结论
生产事故里最贵的,不是多买几台机器,也不是多背几条命令,而是在错误方向上浪费掉的前 10 分钟。
这份白皮书真正想沉淀的不是“命令大全”,而是一套现场判断顺序:
• 先确认是资源耗尽,还是等待放大 • 再确认瓶颈在调用链哪一层扩散 • 然后用命令取证,而不是靠经验猜测 • 最后把止血动作和长期修复彻底分开
如果要从本文只带走一件事,那就是把每次事故都复盘成一句可执行的话:
以后再出现这类等待,我们会在它拖垮共享资源之前发现它、限制它、隔离它。