1. 事件背景:一次本不该发生的“低峰期事故”
4月9日凌晨1点40分,我的手机在床头柜上连续震动了几次。这个时间点来消息,基本没好事——果然,值班群里的告警截图显示,线上订单服务的P99延迟从平时的80ms直接飙到了3.2秒,错误率从0.02%爬到了11.7%。更让人头疼的是,我们当时刚好在0点30分开始做一次常规发版,按计划只发布了8台机器中的2台,也就是25%的灰度批次。
一开始所有人的第一反应都一样:是不是新代码捅了娄子?毕竟改动里有一条和订单状态流转相关的逻辑,虽然自测、联调、预发环境都跑过一遍,但线上环境的数据分布和流量特征总归不一样。我当时的第一反应也是回滚,但再看了几分钟监控以后,我反而冷静下来了——因为出问题的请求分布压根没有集中在刚发版的这两台机器上,6台老版本机器的错误率也在同步猛涨。这个细节很关键,如果真是新代码引入的Bug,不可能出现“新旧版本一起挂”的情况。
于是决策从“立刻回滚”变成了“先定位再决定”。事后回看整个排障过程,我有几个特别想分享给同行的体会:
- 凌晨的故障处理,最忌讳的就是凭第一直觉做动作,回滚本身也有成本,如果根因不在发版代码里,回滚完问题依旧存在,反而会浪费最宝贵的排查窗口期。
- 告警信息不能只看“有没有”,要看“分布在哪”。同样的错误率上涨,集中在新版本机器和均匀分布在所有机器上,指向的完全是两类问题。
- 监控指标的粒度决定了你的定位速度。如果只盯着“接口平均耗时”,你什么都看不出来,必须把线程数、GC、连接池、数据库慢查询这些底层指标并列着看,才可能拼出完整画面。
这起事故从发生到彻底确认根因,前后花了大约50分钟。复盘的时候我们拉了完整的工单记录、监控截图和操作日志,发现整个排查链路里其实有好几个地方是可以提前规避的——不管是监控盲区、参数配置的隐患,还是发布节奏的设计,单独拿出来看都不是致命问题,但叠在一起就变成了一场40分钟的线上故障。
这篇文章的复盘不会只停留在“发生了什么”,我会把当时完整的时间线、每一步的判断依据、以及最终确认的根因链路全部展开,也会把我们在事后做的改进整理成可以直接抄作业的清单。如果你负责的服务也依赖JVM、连接池这类基础组件,哪怕技术栈不完全一样,里面涉及的排查思路和配置经验应该都能给你一些参考。
先简单交代一下当时的系统形态。这套订单服务是标准的Spring Boot + Dubbo架构,部署在8台4核8G的容器上,JVM堆内存设置的是4G,使用CMS垃圾回收器。服务依赖一个MySQL主库(承接订单表读写)和一个Redis集群(做基础缓存)。数据库连接池用的是Druid,配置了maxActive=20。服务承接的流量不算高,日常峰值QPS大概在300左右,所以给到连接池和JVM的资源其实是比较宽裕的。
正因为量级不算大,所以出事之后所有人都很意外——按常理说,这种量级下连“性能瓶颈”的边都摸不到。但恰恰是这种“觉得够用就行”的心态,埋下了后面几个坑。
2. 第一轮排查的时间线:从异常定位到监控盲区被逐步揭开
故障处理讲究一个“时间线越清晰,复盘越有价值”。我现在把当天从告警到初步定位的过程按时间顺序整理出来,每一个节点标上我当时的判断依据,这样比直接抛出结论更有参考意义。
2.1 告警触发后的前5分钟:大家都在盯表象
凌晨1点40分,告警群弹出第一条消息:订单服务的“接口成功率”低于99%阈值,具体数字为98.3%。紧接着1点42分,“P99耗时”告警触发,从基线的80ms涨到1秒以上。1点45分,我登录跳板机连上监控面板,第一眼看到的是“订单创建接口QPS”还在正常范围,但“异常数”在快速增长。
这时候团队里已经有人在群里问:要不要先回滚刚才发的新版本?我刚才说了,我的判断是先看错误分布的来源。所以操作的顺序是:
- 先看网关层有没有5xx状态码的集中爆发,判断是不是入口流量有问题。
- 再打开服务的监控面板,按“版本号”维度看两台新版本机器和六台老版本机器的错误率曲线是否一致。
- 如果一致,说明和代码变更无关,问题出在更底层。
这三步花了大概4分钟。结果非常明确——8台机器的错误率曲线几乎是重合的,没有任何一台单独恶化。这说明问题不在刚发布的代码里,于是“回滚”这个选项暂时被排除,排查方向转向了基础设施和中间件。
2.2 第5到第15分钟:数据库和Redis先被排除
凌晨的故障排查,优先级最高的永远是有没有直接依赖的“外部存储”。因为服务内部出问题通常会导致单机或单集群异常,而存储中间件出问题会导致大面积雪崩。所以我们第二轮的排查目标直接锁定了MySQL和Redis。
当时用到的命令和工具如下:
# 查看数据库当前活跃连接数 mysql> SHOW STATUS LIKE 'Threads_connected'; mysql> SHOW STATUS LIKE 'Threads_running'; # 查看是否有长时间未提交的事务 SELECT * FROM information_schema.innodb_trx\G # 慢查询日志检查(在线开启临时开关) SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;MySQL这边的指标很干净:Threads_connected稳定在80到100之间,远低于max_connections(我们设置的是500);Threads_running基本在3到5之间徘徊;慢查询日志里只有几条毫秒级的普通查询,没有任何超过1秒的SQL。这说明数据库层面没有锁等待、没有连接打满、没有慢SQL堆积。
Redis这边用了redis-cli --latency测了一下实时延迟,均值稳定在0.3ms以内,内存和连接数也都在正常水位。两个最核心的依赖都被排除了,排查方向开始收敛到应用本身。
2.3 第15到第30分钟:从JVM监控中找到“毛刺”
数据库和Redis没问题,那问题大概率出在Java应用进程自身。我们当时的车是自建监控系统,JVM的指标采集粒度是30秒一次,Grafana面板里能看到Heap、Old Gen、GC次数和耗时。打开面板的瞬间,我看到了当天最核心的一条线索——Old Gen的占用曲线出现了一个非常明显的上升台阶。
具体表现是这样的:
- 凌晨0点30分之前,Old Gen一直稳定在1.5G到1.8G之间,很平稳。
- 0点30分开始发版之后,Old Gen以肉眼可见的速度往上爬。
- 到1点30分左右,Old Gen已经逼近3.5G,接近4G堆内存的可用上限。
- 1点42分,CMS GC开始频繁触发,每次Full GC耗时从平时的200ms左右暴涨到2到3秒。
这里有个背景信息必须补充:我们这套服务用的是4G堆内存,Old Gen理论上最多占2.8G左右,但实际运行时Old Gen超过2.5G就已经非常危险,因为CMS的并发回收需要预留足够的空间,否则会出现“Concurrent Mode Failure”并退化到Serial Old收集器,那种STW停顿在低延迟服务里是毁灭性的。
1点42分前后,监控面板上的“Full GC次数”从每10分钟0到1次突然变成了每10分钟8到10次,单次GC耗时普遍超过1秒。服务的P99耗时被GC停顿直接顶到了3秒以上,错误率飙升的逻辑闭环到这里就通了——应用的业务线程在GC STW期间被集体冻结,所有请求全部超时,部分请求被服务框架的异步超时机制标记为失败。
但问题是——底层逻辑通了之后,新的问题又冒出来了:为什么Old Gen会在凌晨这个低流量时段快速上涨?流量没涨,代码也没变,内存怎么可能平白无故被撑爆?
3. 根因定位的完整链路:从GC参数到连接池参数的连锁反应
到这一步,前面所有的排查都还只算“表象”,真正的高潮在于搞清楚Old Gen为什么会在凌晨这个绝对低峰期被填满。这一段是整个复盘里最有含金量的部分,我尽量把每一步的判断逻辑拆细一点。
3.1 GC日志里的“异常对象”:char[] 和 byte[] 占据了半壁江山
为了搞清楚内存是被什么东西吃掉的,我登录到那台问题最严重的机器上,拉取了最近一小时的GC日志。
# 查看GC日志文件,重点看并发回收前后的堆占用变化 grep "CMS" gc.log | tail -100 # 如果GC日志没开,可以用jstat手动拉取实时数据 jstat -gcutil 18211 1000 10GC日志里出现了一个非常典型的现象:每次CMS GC的日志里,Heap after GC的Old Gen部分有接近1.5G的内存无法被释放,而且这部分内存的组成非常单一,通过jmap -histo:live命令查看后,排名前两位的是:
[C也就是char[],占用了约800MB。[B也就是byte[],占用了约500MB。
char[]和byte[]是Java应用里最常见的两类数组——前者对应String的内部实现,后者对应IO缓冲、网络帧、序列化结果等。如果你的服务在低峰期出现这两类对象的堆积,最常见的可能性有两个:要么是代码里有大字符串/大数组被缓存了而且无法回收;要么是连接池或IO层的缓冲对象长时间驻留。
结合当时的场景,我们第一时间排除了“大对象缓存”——因为流量没涨,如果有大对象被缓存,不可能选择在凌晨这个时间点集中暴露。于是排查方向转入了连接层。
3.2 被忽略的连接池参数:maxActive=20 背后的隐性压力
这里必须引入一个很多人容易忽略的细节:Druid连接池的“物理连接”和“业务线程”之间的关系。
我们当时的Druid配置大概是这样的:
spring: datasource: druid: maxActive: 20 minIdle: 5 initialSize: 5 maxWait: 60000 testWhileIdle: true timeBetweenEvictionRunsMillis: 60000表面上看起来,maxActive=20对于QPS不到500的服务绰绰有余——毕竟正常的单次数据库操作只占用连接几十毫秒,20个连接理论上能支撑每秒几百次查询。但这里的问题不在于“够不够用”,而在于“当连接不够用的时候,连接池会怎么表现”。
在故障发生前的几个小时里,由于业务方做了一轮数据补录,白天的时候数据库出现过几次慢查询,单次耗时达到2到3秒。这些慢查询不致命,但它们在Druid连接池里占着连接不放,导致连接池的“活跃连接数”一直处于中高位水平。等到凌晨流量回落后,连接池里的连接虽然被逐步释放了,但物理连接的TCP缓冲区里积累了大量未完全清空的半关闭连接——Druid默认会通过testWhileIdle定期检测空闲连接的可用性,但对于已经被数据库主动断开的“死连接”,检测的频次和灵敏度并不足以完全避免资源黑洞。
更关键的是,发布过程中新版本服务启动时需要建立一批新的数据库连接,这会让连接池进入“扩容”状态。而连接池扩容时,如果底层TCP建连因为某种原因变慢(比如网络抖动、或者数据库端的back_log队列堆积),Druid会持续尝试创建新连接,甚至进入“获取连接超时”的循环。每一轮尝试都会创建一批byte[]缓冲对象作为网络读写缓冲区,这些对象在连接创建失败后并不能立刻被GC回收——因为它们被连接池内部的ConnectionHolder对象强引用着,要等池子完成清理才能释放。
这个过程在白天慢查询+夜间发版的组合下,就像把一个本来只有20根管道的排水系统,突然塞进了大量半堵塞的管道。连接获取变慢,业务线程等待变长,线程数堆积,线程栈上挂着的业务对象(包括String、byte[]、事务上下文)全部变成GC Roots的强引用目标,Old Gen占用自然就一路上涨。
3.3 雪崩的启动按钮:发布动作恰好踩中了GC的“回收临界点”
到这里,根因的逻辑链已经有了,但还有一个关键问题没解释:为什么这台机器偏偏在0点30分发布之后才开始出现Old Gen快速上涨?如果是连接池的问题,为什么凌晨之前没爆?
这个问题的答案在复盘时被我们总结为“三重巧合叠加”。发布动作本身不是根因,但它恰好成了压垮骆驼的最后一根稻草:
- 第一重巧合:新版本服务启动时,Spring容器初始化要加载大量Bean定义,其中包含了Dubbo服务的注册、配置中心的拉取、以及连接池初始化。这些对象在启动完成后大部分会变成不可达垃圾,但因为在0点30分这个时间点,老版本机器的Old Gen已经因为白天的慢查询积累到了一个较高水位(约2.0G),新版本服务的启动动作又额外增加了约200MB的短期对象,把堆推向了2.5G这个临界点。
- 第二重巧合:CMS的并发回收有一个“提前触发阈值”——当Old Gen使用率达到
-XX:CMSInitiatingOccupancyFraction设定的百分比时,后台线程就开始并发标记。我们的设置是68%。当Old Gen达到2.7G左右(4G堆的68%附近)时,CMS开始频繁进入并发回收阶段,而并发回收期间,业务线程依然在正常处理请求,连接池的动态扩容又在持续产生新的不可回收对象,导致“一边回收、一边增长”的拉锯战。 - 第三重巧合:凌晨的流量虽然低,但恰好有一批定时批处理任务在0点30分同时触发,这批任务会批量查询近一周的订单数据,每条查询结果集都偏大(涉及订单明细和商品快照),且都在同一个事务里执行。这批任务运行期间,数据库连接被长时间占用,连接池可用连接数骤降,业务请求开始排队,最终把线程池打满,触发Tomcat的拒绝策略,返回大量错误。
可以想象一下:白天积累的半死连接、深夜启动的容器、高峰期错峰执行的批处理任务、加上CMS回收阈值设置不当——这四个条件任何一个单独出现都不可怕,但当它们在同一时间窗口内叠到一起时,就变成了一场系统性雪崩。每个环节单独看都有“合理的解释”,但合在一起就超出了任何单一监控指标的预警能力。
3.4 用一次“内存Dump”实锤根因
逻辑分析归逻辑分析,最终确认根因我们还是用了最传统也最扎实的手段——jmap导出堆内存快照,然后离线分析。
# 在问题机器上导出堆快照(注意:这一步会触发一次Full GC,低峰期操作风险可控) jmap -dump:live,format=b,file=/tmp/heap_0409.hprof 18211 # 把快照拉回本地,用MAT或者JProfiler分析 scp root@X.X.X.X:/tmp/heap_0409.hprof /Users/me/heapdump/结果和预想完全一致。MAT的Histogram界面里,byte[]的前三大引用路径分别指向:
- Druid的
DruidConnectionHolder及其内部的ConnectionBuffer。 - Netty(Dubbo底层通信框架)的
ChannelOutboundBuffer。 - Tomcat的
NioEndpoint里的SocketWrapper。
这些路径把“连接池残留连接 + 网络层缓冲 + 发布时的建连风暴”三个嫌疑对象全部实锤了。至于char[],主要来源是Druid连接池在初始化时创建的全量SQL预编译缓存(PSCache)——我们把poolPreparedStatements开到了true,默认的缓存数量是每个连接缓存20条SQL。在慢查询变多时,这些预编译对象的体积会变大,而连接池迟迟不清理空闲连接,这部分内存就变成了“沉默的钉子户”。
到这一步,我已经可以很确定地说:事故的直接原因不是某个Bug或者某条慢SQL,而是一组“配置参数在极端组合下的行为失衡”。这些参数单看都合理,但缺少一个全局视角的统筹。
4. 那些“加了等于没加”的监控措施和参数陷阱
复盘过程中,我们花了很长时间去讨论一个让人脸红的问题:为什么这些隐患卧底了半年,监控系统完全没发现?不是没有监控,而是监控的设计存在几个典型的“盲区叠加”。如果你也负责线上系统的稳定性,这part应该会有不少共鸣。
4.1 “平均耗时正常”不等于“可用性正常”
我们当时的核心监控指标是“接口平均耗时”和“TP99耗时”。这两项指标在白天流量正常时表现得非常好——平均耗时在50ms左右,TP99在120ms以内。但问题在于,平均耗时会被大量低延迟请求稀释。即使有10%的请求因为连接获取变慢而耗时3秒,平均耗时也只会上涨到350ms左右,除非你把监控阈值卡死到50ms以下,否则很容易被掩盖。
那天的情况更是极端——凌晨本身就处于流量低谷期,错误的请求数量虽然在报警阈值以上,但绝对数量相比白天要少得多。如果只看“总请求成功率”,零点到一点这个时段甚至还没达到我们设置的99.5%的告警阈值。真正触发告警的其实是P99耗时这个指标,因为那一批批处理请求把尾部延迟顶得实在太狠了。
这里给一个非常具体的建议:监控必须同时盯“均值、中位数、P99、P99.9、最大值”这五个指标,而且告警规则要分开配置。P99.9和最大值的告警阈值可以比均值放宽10倍以上,但一旦触发就必须立刻人工介入。只配置“平均值”的监控系统,等于给故障穿了一件隐身衣。
4.2 GC监控和连接池监控:两个边缘指标的手动补课
我们用自建的监控系统采集了JVM的堆内存使用率和GC次数,但监控面板太粗糙——只展示了堆内存的“总量”和“已用百分比”,没有按“Eden/Survivor/Old Gen”分段展示,更没有对单次GC耗时设置独立告警。CMS GC单次耗时超过1秒这件事,在当时的监控面板上是看不到的,因为它的展示维度是“每分钟GC总耗时”,而低峰期的GC总耗时本身就很低。
连接池的监控就更粗糙了——我们只关注Druid的activeCount是否接近maxActive,恰恰没有关注关键的“等待获取连接的线程数”。在慢查询导致的连接池拥堵场景下,activeCount可能一直保持在15/20左右,看起来离上限还有距离,但等待获取连接的线程数已经悄悄飙升到了50+。这是一个非常典型的“指标接近上限才告警,指标还没到上限就出事”的例子。
经过这次事故,我们把Druid的Web监控页面(/druid/index.html)的指标完整接入了自建监控,重点盯三个指标:
waitThreadCount:等待获取连接的线程数,阈值设为5,超过就告警。activePeak:连接池活跃连接数的峰值,用来辅助判断峰值时段的连接压力。discardCount:连接被丢弃的次数,这个数值如果持续增长,说明连接池内部在不断地“清理死连接”,往往伴随着底层网络的不稳定。
4.3 “发布窗口”里的三次老生常谈
每次复盘都会聊发布流程,但聊归聊,真正能落实的其实不多。这次的故障里,发布动作本身不算直接原因,但它确实起到了“扳机”的作用——因为它改动了连接池初始化的时间点,让连接池的“半死连接清理”和“慢查询残留”刚好撞在一起。
我们的发布方式是按批次滚动,每批2台,间隔10分钟。这个设计原本是为了“小步快跑,有问题就止损”,但问题在于:发布窗口内,新批次启动时,前一批次机器正在处理的请求会重新路由到剩下未发布的机器上。如果你的发布批次没有等比扩容,剩余机器的连接池和线程池需要在短时间内承接双倍流量。尤其在这个案例里,剩余机器自身的连接池本来就已经处于“亚健康”状态(半死连接、PSCache膨胀),多承接一点流量就会把延迟拉长,延迟拉长导致连接获取时间更长,于是形成一个微型正反馈。
这个问题的解法其实不复杂,但需要很强的执行纪律:发布前检查剩余机器的连接池活跃数、P99耗时是否低于基线;发布批次之间增加一次“观察哨”窗口,至少观察5分钟再决定是否继续。这些动作我们把它们固化成了一份checklist,贴在了发布工具的首页,每次上线必须逐项确认。
5. 事后整改清单:哪些参数和策略值得直接抄作业
复盘会最怕的就是“会上痛心疾首,会后涛声依旧”。这次我们定了一套可以量化验证的整改方案,并且后续两周内逐步落地,这里我把改动内容整理成表,方便直接参考。
5.1 连接池参数的重新校准
| 参数 | 原值 | 调整后 | 调整理由 |
|---|---|---|---|
| maxActive | 20 | 50 | 给低峰期批处理任务预留足够连接,避免因为20个连接被占满就直接雪崩 |
| minIdle | 5 | 10 | 提前保持一定数量的空闲连接,减少动态建连带来的内存和CPU开销 |
| initialSize | 5 | 10 | 和minIdle对齐,启动时就完成连接建立,避免启动阶段触发集中建连 |
| maxWait | 60000 | 3000 | 原值太长,应用会傻等60秒才放弃获取连接,导致线程被拖死;3秒足够覆盖绝大多数正常耗时,超时后直接失败返回,配合重试策略更健康 |
| testWhileIdle | true | true | 保留,但把验证一次连接的SQL从SELECT 1改成SELECT id FROM order WHERE id = -1,避免极端情况下验证语句本身被数据库解析优化器卡住 |
| poolPreparedStatements | true | false | 关闭PSCache,这块内存收益不大但风险不小,尤其在高版本MySQL下预编译对象体积比想象中大 |
| keepAlive | false | true(时长60秒) | 开启连接保活,减少数据库主动断开连接导致的“半死连接”积累 |
这里面有一个我特别想强调的点:maxWait=3000这个调整,刚开始团队里有争议,觉得3秒太短,可能导致正常流量下的握手失败率上升。但实际上,连接获取超过3秒本身就是一个极其危险的信号——说明连接池已经被严重堵塞了,与其让大家排队傻等,不如快速失败并触发重试。我们还配合了spring的重试机制(对非幂等写操作做了Safe Idempotent处理),这样就算个别请求失败,也能在业务层自动补一次,不会给用户可感知的影响。
5.2 JVM参数的调整:从CMS切换到G1,并显式设置Region大小
这次的根因里有很大一块是CMS在堆接近临界点时“一边回收一边增长”的拖沓表现。虽然这个事故的直接诱因不是GC算法本身,但CMS在面对“突发性大对象分配+连接池缓冲膨胀”时的表现确实不如G1来的从容。
我们当时的JVM参数不全,我把调整后的关键参数列出来:
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/jvm/ -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -Xloggc:/data/logs/gc.logG1和CMS相比,最大的优势在于它对“碎片化”的容忍度更高。CMS在回收后容易产生大量内存碎片,导致后续大对象分配直接触发Full GC或Serial Old回收;而G1会把堆划分为多个Region,在回收时会主动进行拷贝和压缩,碎片化问题显著改善。对于连接池这种需要持续分配大小不等的byte[]缓冲的场景,G1的整体表现会更平稳。
G1HeapRegionSize=4m这个参数是我后来特意加上的。G1默认会根据堆大小自动计算Region大小(4G堆通常对应2M或4M),但我们有一些连接池的缓冲对象和Dubbo的协议帧通常会超过2M,如果Region太小,大对象会直接分配到Humongous区域,触发连续的标记和回收开销,反而拖慢GC效率。手动固定4M后,这块的GC耗时下降非常明显。
5.3 批处理任务和发布窗口的“错峰策略”
这个属于流程层面的整改,但技术上也可以做一些保证。
批处理任务原本由XXL-Job的默认调度线程池执行,每天0点30分准时跑。我们后来调整成了“按订单ID分片”的方式,将全量批处理拆成了5个分片,分别延迟5分钟执行。另外加了一个“智能熔断”规则:如果某个分片执行期间检测到数据库Threads_running超过20,就主动让出CPU并延长下一次重试的间隔。
发布窗口这边,我们把“观察哨”机制做成了工具链的硬性步骤:每批发布完,CICD流水线会强制暂停,等待监控平台的“健康分”评分达到80分以上才继续下一批。健康分的计算维度包括P99耗时、错误率、GC耗时、连接池等待线程数四个指标,任何一个不合格就自动阻断发布通道。这个机制上线之后,发布导致的事故数量下降了60%以上。
5.4 监控盲区的补丁:三个“新增看板”和两个“新增告警”
监控的整改不再只是加几个Dashboard的问题,而是重新设计了一套针对“亚健康状态”的告警规则。
新增的看板有三个:
- 连接池健康看板:展示maxActive、activeCount、waitThreadCount、discardCount、keepAlive连接数五个指标,时间粒度细化到10秒。
- JVM分段看板:把Eden、Survivor、Old Gen、MetaSpace分成四条独立曲线,同时展示每次GC的耗时(柱状图)和GC后存活对象大小。
- 发布影响看板:在发布窗口内,自动把“发布批次”和“错误率/耗时/GC/JVM”四张图叠放在一起,方便定位发布动作和系统指标之间的因果关系。
新增的告警规则有两条:
waitThreadCount连续3个采样点(30秒)超过5,触发WARNING;连续3分钟超过5,触发CRITICAL。GC耗时单次超过800ms即触发WARNING,无论该次GC是否引起了业务指标波动。
这些规则上线后的第一个月就提前抓到了两次类似的隐患——都是连接池等待线程数先涨,其他指标还没反应的阶段。这就是“把故障扼杀在苗头里”的典型效果,比等P99飙到3秒再救火要从容得多。
6. 复盘这件事本身,也是需要“复盘”的
最后这一段不聊技术细节了,想聊聊“复盘”这个动作本身。我做技术复盘这十多年,最深的感受是:复盘的价值不在于总结出一个“责任人”,而在于梳理出一套“可复用的防御机制”。如果一场复盘会开完,得到的结论只是“运维不到位”“代码写得不够健壮”这类标签,那这场复盘基本是无效的。
我们这次复盘会开了两个多小时,最终沉淀下来的不是“谁背锅”,而是三张清单:
- 故障时间线:精确到分钟的人机交互记录,所有操作和判断依据都写了备注。
- 行动项清单:每一项都带负责人、截止日期和验收标准,后续每周技术周会上滚动跟踪。
- 系统脆弱点清单:明确标注了系统里哪些环节在什么条件下可能出问题,哪些监控指标是“滞后指标”,哪些是“先行指标”。
我个人还有一个习惯,复盘会结束前会问在场每一个人一个问题:“如果在故障发生的瞬间你重来一次,你会怎么做?”这个问题不追求一个标准答案,但能逼着所有人跳出“事后诸葛亮”的心态,把注意力放在“当时的信息条件下,最优的决策路径是什么”。很多时候,我们能做的其实不是优化“决策”,而是优化“获取信息的方式”——比如这次如果waitThreadCount有监控,我们根本不需要花30分钟看GC日志。
另外,这种复盘blog写出来的意义,在我看来是给其他团队提供一个“提前避坑”的机会。互联网公司的技术栈大同小异,很多低概率、高影响的事故模式都是可以跨团队触类旁通的。如果你读到这里,发现自己服务的连接池参数还停留在“默认值没改过”的状态,或者你的监控面板里还没有“GC单次耗时”和“连接池等待线程数”这两个指标,那我建议你尽快把这些补上——大概率不会立刻用上,但一旦用上,省下的就是一顿凌晨三点的手忙脚乱。
我自己的一个习惯是:每隔一段时间,把线上机器当作“已经出了事故”来演练一遍排障路径,看自己能不能在15分钟内定位到最可能的原因。这种主动的“假想复盘”,比任何告警规则都更能锻炼团队的应急手感。