真正把系统拖进深夜会议室的,通常不是“某个组件挂了”,而是大家在前 10 分钟里判断错了方向。本文不讲空泛口号,只讲生产现场怎么判断、命令为什么这样打、哪些修复只是止血、哪些改造才能避免同一类事故反复出现。
一、先回到事故现场:P0 不是从报错开始,而是从误判开始
这篇文章讨论的不是单个中间件,也不是某一类报错,而是高并发 Java 微服务在生产环境中的一整套应急动作。目标读者也不是刚学命令的人,而是已经要值班、要扛线上稳定性指标、要在群里给出判断的人。
原始场景来自一个典型电商订单链路:
用户端 -> API 网关 -> 订单服务 -> 库存服务 -> 支付服务
依赖组件包括:
- MySQL 8.0 主从
- Redis 7 集群
- RocketMQ 4.9
- Nacos 2.2
- Kubernetes 1.28
业务约束也很真实:
- 平时日均订单量约 500 万
- 大促峰值 QPS 12 万
- 核心表
orders 已达 3 亿行
- 订单链路包含同步写库和异步状态流转
事故发生在大促开始后的 10 分钟内:
| 时间 |
现场现象 |
当时最可能的误判 |
| 20:00 |
下单页开始转圈 |
以为只是网关流量高 |
| 20:02 |
订单服务 P99 从 120ms 升到 12s,网关 502 比例达到 45% |
以为只是应用实例不够 |
| 20:05 |
MySQL 主库 CPU 100%,连接数打满,日志出现 Too many connections |
容易直接把根因归到数据库 |
| 20:08 |
RocketMQ 消费 Lag 超过 800 万 |
容易把消息积压当成独立故障 |
| 20:10 |
用户无法下单,业务损失按分钟累计 |
所有人都在催恢复,最容易仓促改配置 |
真正危险的点在这里:数据库打满、消息积压、应用超时,往往不是三个问题,而是一条故障链上的三个结果。
如果现场直接做这几件事,通常会把事故拖得更久:
- 看到 CPU 高就先扩容全部应用
- 看到 MQ 积压就先加消费者线程
- 看到 502 就先重启 Pod
- 看到慢查询就先 Kill 掉一批 SQL
这些动作不一定错,但如果没有先判断“瓶颈是在 CPU、锁、连接池、网络、下游超时还是线程池”,就很可能把一个局部故障扩大成系统性抖动。
生产现场最先要回答的 4 个问题
在任何命令之前,先把判断框架立住:
- 系统是在变慢,还是已经部分不可用
- 瓶颈在调用链哪一层扩散
- 当前是资源耗尽,还是等待放大
- 应急动作会不会把压力转移到下游
这四个问题的价值,比多记几十条命令更大。因为命令只是取证手段,不是结论本身。
二、第一响应不是“查全部”,而是按故障链收缩范围
多数线上事故都可以先归到下面四类之一:
| 故障类型 |
典型信号 |
先看什么 |
| 资源耗尽 |
CPU、内存、连接数、线程数打满 |
top、vmstat、连接池指标 |
| 等待放大 |
锁等待、线程阻塞、下游超时 |
jstack、processlist、调用超时分布 |
| 流量失控 |
突刺、缓存同时失效、重试风暴 |
网关 QPS、Redis TTL 分布、失败重试量 |
| 变更引入 |
发布后抖动、某版本独有报错 |
发布记录、灰度比例、变更 diff |
现场动作也应该分成三层,而不是“发现异常就开始改”:
1. 先止血
目标是让系统别继续失血,而不是立刻找出全部真相。
常见动作:
- 对入口限流,压住写流量
- 关闭非核心异步链路
- 暂停会放大压力的重试任务
- 对缓存回源增加舱壁和兜底
2. 再定位
这一阶段只回答“当前主瓶颈是什么”,不急着一口气解释所有异常。
例如这次事故里,最关键的事实链其实是:
MySQL metadata lock / 慢 SQL -> 连接池等待 -> 订单线程池阻塞 -> 网关超时 -> MQ 消费跟不上
如果这个链条没看清,后面所有扩容都只是在拖时间。
3. 最后修复
修复要分成两类:
- 临时修复:为恢复服务而做的止血动作
- 长期修复:为避免同类事故再次发生的改造
把这两类混在一起,是复盘文章最常见的问题。生产现场能救回来,不代表架构已经健康。
三、百条速查命令:不是背命令,而是知道每条命令在回答什么
你在 云栈社区 可以找到更多这种贴近实战的技术文档。下面这 100 条命令按现场排查顺序组织,而不是按工具分类堆砌。每条命令都只解决一个判断问题。
A. 机器与内核:先确认是不是宿主机已经扛不住
| # |
命令 |
它回答什么 |
| 1 |
uptime |
负载是否在短时间内陡增 |
| 2 |
w |
机器上是否有异常交互操作 |
| 3 |
top |
CPU、内存、负载的总体形态 |
| 4 |
top -Hp <pid> |
具体是哪些业务线程在吃 CPU |
| 5 |
htop |
适合快速查看多核分布,需要环境已安装 |
| 6 |
vmstat 1 |
是 CPU 忙、IO 等待还是上下文切换异常 |
| 7 |
mpstat -P ALL 1 |
是否单核打满或出现核间不均衡 |
| 8 |
pidstat 1 |
哪些进程在异常抢占 CPU 或 IO |
| 9 |
free -h |
可用内存是否已经见底 |
| 10 |
cat /proc/meminfo |
是否存在页缓存挤压、脏页堆积 |
| 11 |
sar -r 1 5 |
内存回收和缓存变化趋势 |
| 12 |
sar -u 1 5 |
CPU 用户态、系统态、iowait 分布 |
| 13 |
iostat -x 1 |
磁盘是否成为根瓶颈 |
| 14 |
iotop |
是谁在持续打磁盘 |
| 15 |
df -h |
磁盘空间是否耗尽 |
| 16 |
df -i |
inode 是否耗尽 |
| 17 |
dmesg -T \| tail -100 |
OOM、内核级软硬件错误 |
| 18 |
journalctl -xe --since '10 min ago' |
系统级错误的最近上下文 |
| 19 |
cat /proc/loadavg |
当前负载是否还在继续爬升 |
| 20 |
uname -a |
事故现场确认内核版本和环境差异 |
B. 进程与文件句柄:确认是不是应用自身已经进入资源枯竭
| # |
命令 |
它回答什么 |
| 21 |
ps -ef \| grep java |
Java 进程是否还在 |
| 22 |
ps -Lp <pid> \| wc -l |
线程数是否过多 |
| 23 |
prlimit --pid <pid> |
进程级资源上限是多少 |
| 24 |
lsof -p <pid> \| wc -l |
打开的文件句柄总数 |
| 25 |
lsof -p <pid> \| grep TCP |
打开的 TCP 连接情况 |
| 26 |
lsof -i :8080 |
谁占用了服务端口 |
| 27 |
cat /proc/<pid>/limits |
nofile 等限制值是否过低 |
| 28 |
cat /proc/<pid>/status |
线程数、内存、上下文切换摘要 |
| 29 |
pmap -x <pid> \| tail -20 |
内存映射段是否异常增长 |
| 30 |
watch -n 1 "ls /proc/<pid>/fd \| wc -l" |
文件描述符增长趋势 |
C. 网络:确认超时是来自链路问题还是应用等待
| # |
命令 |
它回答什么 |
| 31 |
ss -s |
当前 TCP 状态总体分布 |
| 32 |
ss -lntp |
监听端口是否正常 |
| 33 |
ss -antp \| grep :3306 |
到 MySQL 的连接情况 |
| 34 |
ss -antp \| grep TIME-WAIT |
TIME-WAIT 是否过多 |
| 35 |
ss -antp \| grep SYN-SENT |
是否存在大量建连失败 |
| 36 |
netstat -anp \| grep <pid> |
特定进程的网络连接(备选方案) |
| 37 |
ip addr |
网卡和 IP 是否正常 |
| 38 |
ip route |
路由是否异常 |
| 39 |
ping <host> |
连通性是否直接失败 |
| 40 |
traceroute <host> |
路由路径是否异常变长 |
| 41 |
mtr -rwzbc 20 <host> |
是否存在持续抖动和丢包 |
| 42 |
nstat -az \| grep TcpRetransSegs |
是否存在 TCP 重传 |
| 43 |
sar -n DEV 1 5 |
网卡吞吐是否打满 |
| 44 |
sar -n TCP,ETCP 1 5 |
TCP 建连、重置、重传异常情况 |
| 45 |
tcpdump -i any port 3306 -c 50 |
必须抓包时确认报文层异常 |
D. JVM:判断是 GC、死锁、锁竞争还是下游等待
当我们在分析Java应用时,JVM 层面的排查至关重要。
| # |
命令 |
它回答什么 |
| 46 |
jps -lvm |
JVM 进程、启动参数和主类 |
| 47 |
jstat -gcutil <pid> 1000 10 |
GC 是否频繁打断业务 |
| 48 |
jstat -gccapacity <pid> 1000 5 |
堆区容量配置是否异常 |
| 49 |
jcmd <pid> VM.flags |
线上 JVM 参数到底是什么 |
| 50 |
jcmd <pid> GC.heap_info |
当前堆使用情况 |
| 51 |
jcmd <pid> Thread.print |
线程阻塞、锁等待和调用栈 |
| 52 |
jstack <pid> \| grep -n 'BLOCKED' |
是否有阻塞线程 |
| 53 |
jstack <pid> \| grep -n 'waiting for monitor entry' |
是否有等待锁的线程 |
| 54 |
jcmd <pid> GC.class_histogram |
哪类对象在疯狂占内存 |
| 55 |
jmap -histo:live <pid> \| head -50 |
存活对象 Top 50(会触发 Full GC) |
| 56 |
jmap -dump:live,format=b,file=heap.hprof <pid> |
需要离线分析堆时使用,注意会暂停 |
| 57 |
jcmd <pid> VM.native_memory summary |
是否本地内存膨胀而非 Java 堆 |
| 58 |
jcmd <pid> PerfCounter.print \| head -50 |
JVM 内部性能计数器概览 |
| 59 |
async-profiler -d 30 -e cpu <pid> |
CPU 热点到底在业务代码还是框架 |
| 60 |
jfr start name=incident settings=profile duration=60s filename=incident.jfr |
需要短时录制现场时使用 |
E. 应用日志与配置:确认是不是变更、重试或错误分支放大了压力
| # |
命令 |
它回答什么 |
| 61 |
tail -200 app.log |
最近错误是什么类型 |
| 62 |
tail -f app.log |
错误是否仍在持续发生 |
| 63 |
grep -i 'Exception' app.log \| tail -50 |
最近的异常堆栈 |
| 64 |
grep -i 'timeout' app.log \| tail -50 |
哪些调用在报超时 |
| 65 |
grep -i 'Too many connections' app.log \| tail -20 |
是否发生过连接耗尽 |
| 66 |
grep -i 'RejectedExecution' app.log \| tail -20 |
线程池是否满了 |
| 67 |
grep -i 'Sentinel' app.log \| tail -20 |
流控降级是否触发 |
| 68 |
grep -i 'oom' app.log \| tail -20 |
是否有相关内存溢出记录 |
| 69 |
kubectl logs <pod> --previous --tail=200 |
容器重启前到底发生了什么 |
| 70 |
kubectl get cm -n <ns> |
最近是否改过配置中心内容 |
F. MySQL:把“数据库慢”拆成连接、锁、执行计划三件事
| # |
命令 |
它回答什么 |
| 71 |
SHOW FULL PROCESSLIST; |
当前是谁在占连接、在等什么 |
| 72 |
SHOW STATUS LIKE 'Threads_connected'; |
已连接数是否逼近上限 |
| 73 |
SHOW VARIABLES LIKE 'max_connections'; |
上限本身是否过低 |
| 74 |
SHOW ENGINE INNODB STATUS\G |
死锁、锁等待、事务摘要 |
| 75 |
SELECT * FROM sys.innodb_lock_waits; |
谁锁住了谁 |
| 76 |
SELECT * FROM information_schema.innodb_trx\G |
长事务是否拖住了全局 |
| 77 |
SHOW VARIABLES LIKE 'slow_query_log'; |
慢日志是否开启 |
| 78 |
SHOW VARIABLES LIKE 'long_query_time'; |
慢 SQL 门槛是多少 |
| 79 |
EXPLAIN SELECT ...; |
有没有走错索引 |
| 80 |
EXPLAIN ANALYZE SELECT ...; |
实际执行是否与预期不符 |
| 81 |
SHOW INDEX FROM <table>; |
索引设计是否支持当前查询 |
| 82 |
SHOW OPEN TABLES WHERE In_use > 0; |
是否存在表级占用 |
| 83 |
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%'; |
行锁等待是否异常 |
| 84 |
SHOW GLOBAL STATUS LIKE 'Created_tmp%'; |
临时表是否过多 |
| 85 |
SHOW GLOBAL STATUS LIKE 'Handler_read%'; |
是否存在大量全表扫描迹象 |
G. Redis:不要只看“可不可用”,还要看是不是被热 Key 和过期风暴打穿
| # |
命令 |
它回答什么 |
| 86 |
redis-cli INFO server |
基本角色和运行状态 |
| 87 |
redis-cli INFO memory |
内存是否逼近上限 |
| 88 |
redis-cli INFO stats |
命中率、拒绝连接、淘汰情况 |
| 89 |
redis-cli INFO clients |
客户端连接是否异常 |
| 90 |
redis-cli INFO keyspace |
各 DB 键数量和过期分布 |
| 91 |
redis-cli SLOWLOG GET 20 |
哪些命令拖慢了 Redis |
| 92 |
redis-cli LATENCY LATEST |
最近延迟事件是什么 |
| 93 |
redis-cli --latency -h <host> |
网络还是服务端延迟 |
| 94 |
redis-cli CLUSTER INFO |
集群状态是否健康 |
| 95 |
redis-cli TTL <key> |
热点 Key 是否正好集中失效 |
H. RocketMQ:消息积压要区分“生产过快”和“消费被堵”
| # |
命令 |
它回答什么 |
| 96 |
mqadmin clusterList -n <namesrv> |
Broker 和集群是否正常 |
| 97 |
mqadmin topicStatus -n <namesrv> -t <topic> |
Topic 各队列写入情况 |
| 98 |
mqadmin consumerProgress -n <namesrv> -g <group> |
当前积压量到底有多大 |
| 99 |
mqadmin consumerConnection -n <namesrv> -g <group> |
消费者实例是否在线 |
| 100 |
mqadmin queryMsgByKey -n <namesrv> -t <topic> -k <key> |
关键消息到底有没有投递和消费 |
这 100 条命令怎么用,才不会把自己查乱
现场不建议从第 1 条一直打到第 100 条。更实用的方式是按症状进入:
| 现场症状 |
建议起手命令 |
| RT 突然升高,但 CPU 不高 |
jstack、SHOW FULL PROCESSLIST、ss -antp |
| CPU 100% |
top -Hp、async-profiler、EXPLAIN |
| 大量 502/504 |
网关日志、应用线程栈、下游连接状态 |
| 消息积压 |
consumerProgress、消费者线程栈、下游超时分布 |
| 大促整点雪崩 |
Redis TTL、回源 SQL、入口 QPS 与重试数 |
这里有两个边界要说清楚:
jmap -dump、tcpdump、EXPLAIN ANALYZE 这类命令很有价值,但都可能对现场造成额外负担,不适合在主库已经打满时随意执行。
- 单条命令永远只能给出局部证据。比如
Threads_connected 高,并不自动等于“数据库不够用”,也可能只是应用连接归还失败。
四、三大血泪案例:真正的根因,通常藏在“看起来最正常”的地方
下面三个案例保留了原文的核心问题,但把判断过程补完整,因为真正有价值的不是“最后答案”,而是怎么排除错误方向。
案例一:数据库连接池爆满,根因不在数据库,而在连接没有被归还
现场现象
- 订单服务大面积报 502
- 日志连续出现
Connection is not available
- 数据库连接数超过 1500
- 应用线程大量阻塞在获取连接
第一眼最容易下的结论
很多团队到这里会直接说“数据库不够用了”,然后开始加 max_connections、扩主库规格,甚至把连接池也一起调大。
这类处理有时能临时缓一下,但它绕开了一个关键问题:连接是被真正占用,还是借出去之后没有归还。
现场排查路径
SHOW FULL PROCESSLIST 发现大量连接处于 Sleep,并且 Time 很长。
- 订单服务配置为
maximum-pool-size=200,共 5 个 Pod,理论上最多约 1000 连接。
- 数据库侧却看到 1500+ 连接,说明存在池外连接,或者连接生命周期已经失控。
jstack 继续看,线程并没有都在执行 SQL,而是有相当一部分阻塞在下载接口相关逻辑。
- 最后追到一个文件导出接口,代码使用了
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 在同一时间段集中过期了。
现场排查路径
SLOWLOG GET 没看到 Redis 自身的慢命令堆积。
INFO stats 里命中率开始掉,但服务端并没有明显资源瓶颈。
- 追活动缓存预热任务,发现一批核心 Key 都在 20:00 统一加载。
- TTL 统一写成 7200 秒,于是 22:00 前后集中失效。
- 请求回源后,由于数据库本来就承接同步写压力,读流量一叠加,主库立刻被拉满。
真正根因
根因不是 Redis 故障,而是缓存生命周期设计过于整齐,导致过期事件集中爆发。
这类问题为什么危险
- Redis 监控看起来仍然健康,容易误导现场判断
- 应用看到的是数据库慢,而不是缓存策略错
- 一旦回源没有做并发保护,热点 Key 会形成局部风暴
临时止血
- 对热点接口快速增加入口限流
- 临时回填关键缓存,并把 TTL 打散
- 对热点回源逻辑加互斥锁或单飞控制
- 关闭非核心推荐、画像等附加查询
长期修复
- 物理 TTL 加随机抖动,例如
base + random
- 热点数据采用逻辑过期 + 异步刷新,而不是统一物理过期
- 增加本地缓存挡住极短时间内的热点重复回源
- 对空值和异常结果也做短 TTL 缓存,避免穿透
- 缓存重建要和数据库限流绑定,不能允许无限回源
修复后的验证方式
- 统计同一批预热 Key 的 TTL 分布,而不是只看平均值
- 演练 Redis 单分片抖动时,验证回源并发是否被限制
- 观察缓存 miss 突增时,数据库是否仍能稳定在安全水位
案例三:消息积压表面看是 MQ 问题,实质是消费者把下游超时原样放大了
现场现象
- 下单成功后 30 分钟仍未收到通知
- 库存扣减延后
consumerProgress 显示积压达到千万级
第一眼最容易下的结论
常见反应是立刻把消费者线程数调大,或者多扩几个 Pod。这个动作不是不能做,但它默认假设“消费速度慢只是因为算力不够”。
如果真正的问题是下游调用平均 RT 从 50ms 变成 8s,那么单纯加线程只会更快把线程池、连接池、下游接口一起拖死。
现场排查路径
mqadmin consumerProgress 看到某个核心消费组落后严重。
mqadmin consumerConnection 确认消费者实例都在线,并不是实例丢失。
jstack 发现大量消费线程等待在调用支付网关的 HTTP 客户端上。
- 支付网关配置超时 5 秒,但实际平均 RT 已经高于 8 秒。
- 消费线程池默认只有 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. 监控要覆盖资源、依赖和业务结果,三者缺一不可
一个常见误区是“监控很多,但还是不知道为什么出事”。原因通常是只监控了资源,没有监控等待链。
建议最少覆盖这些指标:
| 维度 |
必看指标 |
| 资源 |
CPU、内存、磁盘 IO、线程数、连接数 |
| 依赖 |
数据库慢 SQL、锁等待、Redis 命中率、MQ Lag、下游 RT |
| 业务 |
下单成功率、支付回调延迟、库存扣减延迟、对账差异 |
如果只能先做一件事,优先把“等待相关指标”补出来:
- 连接池等待线程数
- 线程池队列长度
- 下游调用 RT 分布
- 锁等待时间
- 消息积压恢复时间
6. 变更治理要把“可回滚”放在“可发布”前面
很多事故不是业务峰值打出来的,而是变更把系统推到了边缘。
生产变更至少要满足:
- 有灰度,不全量直上
- 有回滚,不依赖临场手改配置
- 有基线指标,知道发布前后变化
- 有冻结窗口,避免在高峰期做高风险动作
这里不建议空谈流程,最实用的动作反而很朴素:
- 保留最近 3 个可回退版本
- 每次发布前明确观察指标和观察时长
- 高风险 DDL 与业务发布解耦
七、只保留真正关键的工程实现
原文里有代码示例,这里只保留最能体现长期修复价值的三段。目的不是凑实现,而是说明“怎么把复盘结论变成可执行约束”。
1. 把连接池等待暴露成指标,而不是等数据库先报警
@Configuration
public class HikariMetricsConfig {
@Bean
public DataSource dataSource(MeterRegistry registry) {
HikariConfig config = new HikariConfig();
// 省略基础连接配置
config.setMetricRegistry(registry);
return new HikariDataSource(config);
}
}
这段代码本身不复杂,关键在于后面的监控规则。生产环境至少要补两条判断:
- 活跃连接长期超过池上限的 80%
- 等待线程数持续大于 0
第一条说明资源紧张,第二条说明业务已经开始排队。真正要命的通常是第二条。
2. 缓存防雪崩不是“加随机数”这么简单,还要限制回源并发
@Service
public class CacheService {
private final RedisTemplate<String, Object> redisTemplate;
public void setWithJitter(String key, Object value, long baseSeconds, long jitterSeconds) {
long ttl = 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)
public class OrderCoreConsumer implements RocketMQListener<OrderMessage> {
@Override
public void onMessage(OrderMessage message) {
String key = "order:msg:" + message.getOrderId() + ":" + message.getEventId();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.MINUTES);
if (Boolean.TRUE.equals(locked)) {
try {
orderService.processCore(message);
} finally {
redisTemplate.delete(key);
}
}
}
}
这段代码解决的是“重复消费别把核心业务做两次”。但它依然有适用边界:
- Redis 本身必须稳定,否则幂等层会变成新依赖
- 幂等键过期时间要覆盖业务可重试窗口
- 如果业务要求强一致,还需要结合本地消息表或去重表设计
八、什么时候值得把系统升级到“架构级防故障”
不是所有系统都需要把 Sentinel、HPA、混沌工程、全链路治理一次配齐。对低并发、低复杂度系统来说,过度设计本身就是成本。
更适合升级的典型信号是:
| 业务条件 |
推荐动作 |
原因 |
| 核心接口 P99 持续波动,且人工扩容无法稳定 |
先补等待链监控,再做入口限流和资源隔离 |
先看清瓶颈再改架构 |
| 数据库连接数频繁逼近上限 |
拆分连接池、治理慢 SQL、限制回源 |
盲目加连接只会延迟爆炸 |
| 每次活动都靠人盯盘手工降级 |
建立预案开关、灰度和自动告警 |
人工处置速度跟不上突刺 |
| MQ 积压恢复时间不可预测 |
重构消费者隔离、失败流转和扩缩容策略 |
积压本质是消费模型设计问题 |
| 发布后抖动频繁,但定位依赖经验 |
建立基线指标、灰度回滚和变更审计 |
没有基线就无法判断变更影响 |
相反,如果系统还处在这些阶段,就没必要一开始就上很重的治理:
- 单体应用,日订单量只有几万
- 故障主要来自功能 bug,而不是容量和等待链
- 团队还没有稳定的监控和发布流程
在这种阶段,先把日志、慢 SQL、连接池和超时配置做好,收益往往比上复杂中间件更直接。
九、复盘之后该落地什么:一份可执行的检查清单
最后留一份简短清单,目的是让复盘能落到下个迭代,而不是停在会议纪要里。
本周内应该完成
- 是否已经给数据库连接池暴露活跃数和等待数
- 是否区分核心流量和非核心流量的限流策略
- 是否检查过热点缓存 TTL 是否集中
- 是否核对过核心消费者的下游超时和线程池大小
- 是否为关键接口准备了人工降级开关
本月内应该完成
- 是否做过一次带业务指标观测的压测
- 是否验证过 Redis miss 激增时数据库仍能承受
- 是否验证过下游超时场景下消费者会进入死信而不是无限阻塞
- 是否保留了可回滚版本和对应变更说明
- 是否做过一次最小规模故障演练,例如杀一个 Pod 或注入 200ms 延迟
不要等出事再补的能力
- 连接池等待告警
- 线程池队列长度告警
- MQ Lag 恢复时间监控
- 发布后基线对比
- 核心链路幂等校验
十、结尾只说一个结论
生产事故里最贵的,不是多买几台机器,也不是多背几条命令,而是在错误方向上浪费掉的前 10 分钟。
这份白皮书真正想沉淀的不是“命令大全”,而是一套现场判断顺序:
- 先确认是资源耗尽,还是等待放大
- 再确认瓶颈在调用链哪一层扩散
- 然后用命令取证,而不是靠经验猜测
- 最后把止血动作和长期修复彻底分开
如果要从本文只带走一件事,那就是把每次事故都复盘成一句可执行的话:
以后再出现这类等待,我们会在它拖垮共享资源之前发现它、限制它、隔离它。