1. 那个凌晨两点,我盯着屏幕上的红色报错
凌晨两点十七分,手机在枕头边震了一下。我本能地不想动,但连续震了三次之后,还是摸过来看了一眼——运维群里有人甩了一张截图,核心服务的响应时间曲线像被人从悬崖上推了下去,直接拉成了一条垂直线。紧接着第二条消息:“用户端大面积超时,客服电话已经被打爆了。”
我坐起来,打开电脑,连上环境。屏幕上的日志滚动速度快得根本看不清,满屏都是超时和连接池耗尽的报错。那一刻脑子是清醒的,但手是凉的。这不是我第一次遇到线上故障,但每次在凌晨被叫醒,那种感觉都一样——你知道有一堆用户在等着,而你连问题出在哪都还没定位到。
这篇文章不是要讲什么高深的技术原理,而是想把那次“凌晨两点的惊魂时刻”完整复盘一遍。从故障现象、排查路径、根因定位,到最终的修复方案和后续的防御措施,我会把每一个关键决策背后的逻辑都拆开讲清楚。如果你也在做线上服务,或者正在负责某个系统的稳定性,这些踩过的坑和总结出来的经验,应该能帮你少走一些弯路。
2. 故障现场:现象拆解与第一反应
2.1 从监控面板上读出的三个异常信号
遇到线上故障,第一步永远是看监控,而不是盲目登机器翻日志。当时我打开监控面板,看到三个非常明显的异常信号:
- 接口响应时间(P99)从正常的200ms飙升到超过30秒,而且不是某一个接口,是几乎所有对外接口都在恶化。这说明问题大概率不在某个具体的业务逻辑上,而是在更底层的公共依赖上。
- 数据库连接池活跃连接数直接打满,等待队列里堆积了大量请求。连接池打满通常意味着要么慢查询把连接占住了,要么连接泄漏导致连接没被释放。
- 服务实例的CPU使用率反而不高,维持在30%左右。这个信号很关键——如果是计算密集型的问题,CPU应该飙升;CPU不高但响应极慢,说明大量线程在等待I/O或者锁。
这三个信号组合在一起,基本可以排除“代码死循环”或者“流量突增”这类原因。流量突增的话,QPS曲线应该有明显尖峰,但当时QPS和平时差不多,甚至因为响应变慢还略有下降。所以问题出在系统内部——某个环节变慢了,导致请求堆积,进而拖垮了整个链路。
这里有一个经验:故障排查时,先看“什么没变”比看“什么变了”有时候更有价值。QPS没变、CPU没变,那变化的一定是某个依赖的响应时间或者某个资源的可用性。
2.2 快速止血:先恢复,再找根因
定位到问题大概方向之后,我面临一个选择:是继续深挖根因,还是先做止血操作?我的原则一直是先恢复服务,再复盘根因。原因很简单——每多拖一分钟,就有更多用户受到影响,而且请求堆积会引发连锁反应,比如重试风暴、缓存击穿等,让问题变得更复杂。
当时的止血操作分三步:
- 重启了部分服务实例。重启能快速释放被占满的连接池和线程池,让这部分实例先恢复服务能力。但注意,重启只是临时手段,如果根因没解决,重启后过一段时间又会复现。
- 对非核心接口做了限流。把一些不太重要的查询接口临时限流,把资源让给核心链路。这个决策需要和业务方沟通,但在凌晨这个时间点,先保核心是共识。
- 手动清理了数据库中的长时间空闲事务。当时发现有几个事务已经持有了超过十分钟,明显是异常状态,直接kill掉之后,连接池的压力立刻缓解了一部分。
这三步做完,大概过了五分钟,监控上的响应时间曲线开始回落,虽然还没回到正常水平,但至少用户端不再大面积超时了。这时候才有余力去认真排查根因。
3. 根因追踪:一条慢查询如何拖垮整个系统
3.1 从连接池打满反推问题源头
服务恢复之后,我开始正式排查根因。思路很明确:连接池被打满,说明有连接被长时间占用。那么问题就是——谁占用了连接,为什么这么久不释放?
我先查了数据库的当前会话列表,发现有几条SQL的执行时间异常长,超过了300秒。正常情况下,我们的业务查询应该在几百毫秒内完成,超过300秒的查询基本可以判定为异常。把这些SQL拿出来一看,是一条关联了四张表的复杂查询,而且没有走索引,做了全表扫描。
但这里有一个疑问:这条SQL平时也在跑,为什么偏偏这个时间点出问题?继续查下去,发现当天晚上有一批定时任务在凌晨两点触发,这批任务会往那几张表里批量写入数据。数据量突增之后,原本勉强能走的执行计划变成了全表扫描,单次查询从几百毫秒变成了几分钟。而这条查询又在一个高频调用的接口里,每次请求都会触发,于是连接被迅速占满。
3.2 执行计划突变:数据量增长引发的连锁反应
这里涉及一个很多开发者容易忽略的问题:执行计划不是一成不变的。数据库优化器会根据表的统计信息来选择执行计划,当数据量或者数据分布发生变化时,优化器可能选择一个和之前完全不同的计划。
我对比了故障前后的执行计划,发现变化非常明显:
| 对比项 | 故障前 | 故障后 |
|---|---|---|
| 驱动表 | 小表驱动 | 大表驱动 |
| 索引使用 | 走了复合索引 | 全表扫描 |
| 扫描行数 | 约2000行 | 约180万行 |
| 预估耗时 | 50ms | 无法预估(超时) |
故障前,优化器选择了一个小表作为驱动表,通过索引快速定位数据;故障后,由于统计信息更新不及时,优化器错误地估计了数据分布,选择了一个大表做全表扫描。这个变化在平时可能不会造成太大影响,但在凌晨批量任务写入数据的时间窗口内,就被放大了。
实操心得:定期更新统计信息,尤其是在有批量写入的场景下。很多数据库有自动统计信息收集机制,但默认的触发阈值可能不适合你的数据增长模式。对于写入频繁的表,建议手动在业务低峰期触发统计信息更新。
3.3 连接池配置的隐藏问题
除了SQL本身的问题,连接池的配置也值得反思。当时我们用的是比较常见的连接池配置,最大连接数设的是50,等待超时设的是5秒。这个配置在正常情况下够用,但在故障场景下暴露了两个问题:
- 最大连接数偏小。50个连接在平时够用,但当慢查询出现时,连接被长时间占用,很快就打满了。如果适当调大,可以争取更多的缓冲时间。
- 没有配置连接泄漏检测。连接池本身有泄漏检测机制,但我们没有开启。如果开启了,当连接被持有超过一定时间后,连接池会主动回收并打印堆栈信息,能更快定位到问题代码。
后来我们调整了配置:最大连接数调到100,开启了泄漏检测(阈值设为60秒),并且把等待超时从5秒改成了3秒。等待超时改短的原因是——与其让请求在连接池里排队等5秒然后超时,不如快速失败,让上游有机会做降级处理。
4. 修复方案:从应急到长期防御
4.1 SQL优化:让查询重新走回索引
根因定位到SQL之后,优化方向就很明确了。那条查询的业务逻辑是:根据用户ID查询订单列表,同时关联商品信息和物流信息。原始SQL大致是这样的:
SELECT o.order_id, o.create_time, p.product_name, l.logistics_status FROM orders o JOIN products p ON o.product_id = p.id JOIN logistics l ON o.order_id = l.order_id WHERE o.user_id = ? ORDER BY o.create_time DESC LIMIT 20;问题出在orders表上。user_id字段有索引,但优化器没有选择它,而是选择了全表扫描。原因是orders表的数据量已经增长到了千万级别,而统计信息没有及时更新,优化器低估了user_id的区分度。
优化方案分两步:
- 强制使用索引。在SQL中加入
FORCE INDEX提示,让优化器必须走user_id索引。这是一个快速修复手段,但长期来看不够优雅。 - 调整索引结构。把原来的单列索引
idx_user_id改成了复合索引idx_user_create_time (user_id, create_time DESC)。这样查询可以直接从索引中拿到排序好的数据,避免了额外的排序操作。
优化之后,同样的查询从超时变成了平均30ms左右。这个提升非常明显,连接池的压力也随之消失了。
4.2 批量任务的隔离与限流
SQL优化解决了单次查询慢的问题,但批量任务写入导致的数据量突增仍然是一个隐患。如果下次批量任务写入的数据量更大,或者写入频率更高,类似的问题可能再次发生。
我们的应对策略是把批量任务和在线查询做资源隔离。具体做法:
- 批量任务走独立的数据库连接池。在线查询用一个连接池,批量任务用另一个,两者互不影响。即使批量任务把连接池占满,也不会影响在线查询。
- 批量任务限流。控制批量任务的写入速率,比如每秒钟最多写入多少条,避免瞬间对数据库造成太大压力。
- 批量任务错峰执行。把批量任务的执行时间从凌晨两点调整到凌晨四点,避开业务低峰期中的其他定时任务。
这些措施的核心思路是:不要让不同性质的负载相互影响。在线查询要求低延迟,批量任务要求高吞吐,两者的资源需求不同,混在一起就容易出问题。
4.3 监控告警的补全
故障发生的时候,我们的监控其实已经发现了异常,但告警的触发时间偏晚。从响应时间开始恶化到告警发出,中间隔了将近五分钟。这五分钟里,问题已经从局部扩散到了全局。
事后我们补全了几个关键告警:
| 告警项 | 阈值 | 触发动作 |
|---|---|---|
| 数据库连接池活跃连接数 | 超过80%持续30秒 | 企业微信告警 |
| 慢查询数量(超过1秒) | 每分钟超过10条 | 企业微信告警 |
| 接口P99响应时间 | 超过1秒持续1分钟 | 电话告警 |
| 批量任务执行时长 | 超过预期时长2倍 | 企业微信告警 |
其中“慢查询数量”这个告警在这次故障中会非常有效——故障发生前几分钟,慢查询数量就已经开始上升了,如果能在那时候就收到告警,就能更早介入。
5. 复盘与反思:那些平时不注意、出事才后悔的细节
5.1 为什么没有更早发现执行计划的问题
这次故障之后,我一直在想一个问题:执行计划突变不是一瞬间发生的,它是一个渐进的过程。数据量是慢慢增长的,统计信息是慢慢过时的,为什么我们没有更早发现?
原因在于,我们缺少对执行计划的定期审查机制。平时大家只关注SQL能不能跑出结果,很少有人会去看执行计划。但执行计划恰恰是SQL性能的核心——同样的SQL,不同的执行计划,性能可能差几百倍。
后来我们建立了一个简单的机制:每周对核心SQL做一次执行计划审查,重点关注那些扫描行数超过一定阈值的查询。这个工作不需要太多时间,但能提前发现很多潜在问题。
5.2 连接池配置的常见误区
关于连接池配置,我见过很多团队的做法是“设一个差不多的值,然后就不管了”。但连接池配置其实需要根据业务特点来调整:
- 最大连接数不是越大越好。连接数太大,数据库本身的线程调度开销会增加,反而可能降低整体吞吐。一般来说,最大连接数可以设为数据库CPU核数的2到4倍,再根据实际压测结果调整。
- 等待超时要设得合理。设得太长,请求会堆积;设得太短,正常波动也会导致失败。建议根据接口的SLA来定,比如接口要求200ms内返回,那等待超时就不应该超过200ms。
- 一定要开启泄漏检测。这个功能几乎没有任何性能开销,但能在关键时刻帮你快速定位问题。
5.3 凌晨故障的沟通与协作
技术问题之外,这次故障还暴露了沟通上的问题。凌晨两点,很多人都在睡觉,拉群、打电话、同步信息都需要时间。当时我们花了大概十分钟才把相关的人凑齐,这十分钟在故障处理中是极其宝贵的。
后来我们优化了应急响应流程:
- 明确值班制度。每天有一个 primary 值班和一个 backup 值班,确保任何时候都有人能第一时间响应。
- 预设应急群。提前建好应急沟通群,把相关人都拉进去,故障发生时直接在里面同步信息,不需要临时拉人。
- 制定故障等级标准。什么级别的问题需要通知到什么人,提前定义清楚,避免临时决策浪费时间。
这些流程上的改进,和技术上的修复同样重要。毕竟故障处理是一个团队协作的事情,光靠一个人技术再强,也扛不住沟通不畅带来的延迟。
6. 后续:从一次故障中学到的长期习惯
那次凌晨两点的故障,前后持续了大概四十分钟。从被叫醒到服务恢复,用了不到二十分钟;剩下的时间都在做根因定位和后续修复。四十分钟在故障里不算长,但那种压力感让我记了很久。
后来我养成了几个习惯,都是那次故障之后慢慢形成的:
第一,每次上线前看一眼慢查询日志。不需要很复杂,就是扫一眼有没有新增的慢查询。如果有,上线前就处理掉,不要等到出事再查。
第二,定期做故障演练。我们会模拟一些常见的故障场景,比如数据库连接池打满、某个依赖服务超时等,看看监控能不能及时发现,值班人员能不能快速响应。演练的时候发现问题,总比真实故障时发现问题要好。
第三,给每个核心接口设定明确的SLA。比如首页接口要求P99在500ms以内,订单查询要求P99在300ms以内。有了明确的指标,才能判断系统是否健康,也才能在恶化时及时发现。
第四,保持对“正常”的敏感。很多时候故障不是突然发生的,而是从一些微小的异常开始的。比如响应时间从200ms变成250ms,可能不会触发告警,但如果你注意到了,就能在它变成2000ms之前介入。
这些习惯看起来都很简单,但坚持下来并不容易。不过每次想到那个凌晨两点的屏幕,我就觉得这些坚持是值得的。毕竟,谁也不想在凌晨被叫醒之后,面对的是一个完全陌生的问题。