前言
那天正在忙别的事,突然被拉进一个事故群,消息只有一句:服务所有接口都在疯狂超时。
看到这种”全线超时”的描述,第一反应是——这是不是之前那次老问题又犯了?之前排查过一次类似的雪崩,根因是一个”写在异步函数里实际却是同步阻塞”的调用把事件循环冻住,进而拖垮健康检查、被容器编排平台判定不健康摘流重启(那次的完整排查过程另开一篇写过,这里不重复)。带着这个假设开始排查,结果这次从头到尾都不是同一个坑,但顺着往下挖,牵出的问题比想象中更多,链条也更长。
这篇先说止损,再说复盘——毕竟出事的时候,大家最想看到的永远是”现在怎么样了”,分析可以慢慢来。
一、先排除”老问题重演”的可能
按上次的经验,第一步照例是看服务自身的基础指标:CPU 使用率、内存压力,一切正常,没有任何异常波动。
因为大家反馈的是”所有接口都超时”,更值得关心的其实是核心业务接口本身的耗时,但既然怀疑是老问题,第一件事还是去看了一眼健康检查接口的 TP95——如果是事件循环被冻住那一套,健康检查大概率也会被拖慢,这是个很好用的判断信号。结果这次健康检查 TP95 完全正常。
服务基础指标正常 + 健康检查正常,这两条一确认,基本可以排除是同一个老毛病复发。既然应用进程本身看着挺健康,那问题大概率不在应用层,得往中间件方向查。
二、转向中间件:数据库 CPU 已经冲顶
第一时间去看了数据库的情况——这一看,发现是真的不对劲,CPU 已经冲到了顶:

赶紧去 AWS 控制台想看更详细的执行细节,结果发现这套库没开 Performance Insights,没法直接看历史的 Top SQL、等待事件分布这些细粒度数据。好在控制台里还是给了一部分基础的 SQL 耗时统计,扫了一眼那个数字——耗时基本都是几千秒起步,吓了一跳,一度怀疑是不是单位看错了或者面板有问题。后来确认没看错,这个量级说明有语句已经卡在数据库里跑了接近一个小时甚至更久。
没有 Performance Insights,细粒度的历史分析做不了,那就只能换个笨办法:用数据库管理员账号,直接连上去,靠 pg_stat_activity 这类系统视图手动排查现场。
三、硬查 pg_stat_activity:揪出两个卡死的家伙
SELECT pid, state, wait_event_type, wait_event, now() - query_start AS duration,
left(query, 150) AS query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC
LIMIT 30;
按耗时排序扫一眼,两个卡死的家伙立刻冒出来:
第一个:一条复杂的分析型查询(跨表聚合统计,类似”按事件表统计每局游戏的对话轮次分布”这种),状态是 active,已经连续运行了两天多,而且不是挂着不动——wait_event_type 显示它就是在 CPU 上实打实地跑,配套还带了两个并行 worker 进程。也就是说,这不是一条”忘了关、静静躺在那”的空闲事务,而是一条持续吃 CPU 跑了两天半的大查询,还带并行度。
第二个:一批参数相同、只是各自 PID 不同的 UPDATE 语句,全都指向同一张表,状态同样是 active,等待事件五花八门(行锁、事务锁、缓冲区内部锁都有),耗时从几分钟到接近 5 个小时不等,一眼就能看出是同一个定时任务反复触发、一层层叠上去的排队——最老的那个已经堵了将近 30 个后来者。
两个家伙的画像都很清楚了:一个在持续放血,一个在锁死排队,而且都是”继续跑下去毫无意义”的类型。
四、先止损:杀掉它们,让数据库先喘口气
事故现场第一原则先止损后查因,确认这几类会话已经卡死、继续跑下去没有任何意义,直接清掉给系统腾资源。这一步不是只杀了前面提到的那两个”代表”,而是按类别做了批量清理:
-- 先杀掉那条分析查询链条里耗时最久的会话,其余并行 worker 会跟着一起终止
select pg_cancel_backend(<pid>);
- 那条分析查询本体加上它带的两个并行 worker,一共 3 个会话一起终止;
- 锁排队链条里堆积的 32 个
UPDATE会话,逐个确认耗时和状态后全部清掉,不是只杀链头那一两个了事; - 因为缺索引在跑全表扫描、卡在排行榜计数查询上的会话,当时同时在跑的有 738 个,也一并批量取消。
执行完之后盯着数据库指标看:CPU 应声回落,活跃会话数从大几千骤降到几十,连接数也跟着掉下来。大概过了几分钟,线上所有接口的超时报错基本消失,容器也不再频繁被健康检查判定超时、拉起又摘掉——雪崩链条被从根上打断了。

先说清楚一句:这一步只是止损,两个”病灶”被摘除了,但没有回答任何一个”为什么”——为什么这条分析查询能挂两天半没人管,为什么一个看着有保护的定时任务能排出近 30 个并发,这些都是止损之后才有空回头去查的。
五、复盘第一问:为什么两天半后才出事
顺着日志和事故时间线往回倒,第一个问题是:那条分析查询明明两天半前就开始跑了,为什么中间这么长时间都没出事,偏偏是这个时间点才崩?
答案跟这套数据库用的机器规格有关系——这台实例配置相当高(db.m7i.8xlarge),32 vCPU、128GB 内存,用的还是高性能存储。这种规格的机器,哪怕背着一条持续跑满并行度的重查询,短时间内也未必能把它彻底压垮,更多是在”悄悄多耗一些资源、后台清理任务被拖慢一点”这种不太显眼的层面上消耗,业务侧几乎感知不到。

换句话说,数据库其实是硬扛了 50 多个小时——这条分析查询在后台持续放血,垃圾回收被拖慢,表一天天在膨胀,但机器底子好,扛得住。直到当天业务流量迎来一次日常波峰,本来就已经被啃掉一大截余量的数据库,这次真的扛不住了,问题才集中爆发。这也是这类”慢性消耗型”问题最麻烦的地方:它不会一开始就报警,反而会在余量耗尽的那一刻突然爆发,让人第一反应是”怎么毫无征兆”——其实征兆一直都在,只是被机器的富余性能盖住了。
六、复盘第二问:定时任务的行锁雪崩是怎么滚起来的
止损时杀掉的第二个家伙,背后是一个很朴素的定时任务:找出”创建中”状态卡太久没更新的记录,超时就标记成失败。代码里特意加了 Redis 分布式锁,写明是为了防止集群里多个实例同时跑同一批数据(为避免暴露具体实现,字段名和变量名做了替换,逻辑保持一致):
LOCK_KEY = f"{env}:item:monitor_creating:lock"
LOCK_TIMEOUT = SCHEDULE_MIN_INTERVAL # 300 秒,与调度最小间隔取的同一个值
lock = redis_client.lock(LOCK_KEY, timeout=LOCK_TIMEOUT, blocking=False)
if not lock.acquire():
return # 锁被占用,本轮跳过
try:
# ... 执行批量 UPDATE ...
finally:
lock.release()
看起来该有的保护都写了,为什么现场还是排出了近 30 个并发的同款 UPDATE、最长卡了将近 5 个小时?这里有两条路径都能让锁提前失效,叠在一起才是真正的问题:
- TTL 本身就设短了:任务调度间隔是 7.5 分钟一次,锁的 TTL 只给了 300 秒(5 分钟)。当数据库整体已经很吃紧、这条 UPDATE 单次执行时间被拖到几个小时时,锁会在任务还没跑完的时候就自动过期——Redis 不知道、也不关心上一轮任务是不是还在执行。
- 容器被摘流重启时,锁被”体面地”主动释放了:数据库过载期间,大量容器因为响应变慢被健康检查判定超时、遭遇摘流重启。一个正在持锁执行这条 UPDATE 的容器被摘掉时,
finally: lock.release()这段收尾逻辑同样会被触发,把锁主动释放掉——哪怕它负责的那个数据库事务还没提交、还攥着一堆行锁没放。锁一放,新起来的容器立刻能抢到锁,发起同一批条件的新一轮 UPDATE,去和上一个还没提交的事务抢同一批行的锁。
两条路都会导致”锁看似空出来了,但底层事务其实还没完”,于是新一轮任务一波接一波地涌进来排队,越堆越多。这类跑批场景下,”锁被释放”不该单纯等同于”可以放心开始下一轮”——分布式锁的语义需要覆盖到它保护的那个事务是否真的已经终结,而不只是覆盖调度层面的”要不要发起下一次调用”。
七、插曲:以为是索引问题,结果被 EXPLAIN 打脸
既然这条 UPDATE 本身要跑到几个小时,第一反应当然是怀疑索引没建对。查了下这张表,规模确实吓人:接近 800GB、4600 多万行;再一查目标状态的记录数,全表里只有一百多行命中。这个数字一度让我更确信是索引问题——猜测现有索引没覆盖时间字段,导致这一百多行的候选集还要挨个回表判断超时条件,分摊到近 800GB 的大表上随机 IO 会很重,几乎要直接建议加一个复合索引。
好在动手前多跑一步 EXPLAIN 验证,结果显示走的就是索引扫描,代价小到可以忽略——现成的一个复合索引已经把这一百多行筛得干干净净。这条语句的执行计划本身没有任何问题,加不加新索引跟这次事故无关,之前的推理方向是错的。
这个插曲值得记一下:“表很大” + “命中的行很少”这两个事实凑在一起,很容易让人直觉性地怀疑索引,但直觉代替不了 EXPLAIN。 幸好只是多跑一条只读验证,没有真的在近 800GB 的大表上做一次没必要、还会额外抢 IO 的索引变更——系统已经吃紧的时候,这种”好心办坏事”的操作代价可能比不做还大。
八、复盘第三问:真正的负载大头在哪
单看那批锁排队的 UPDATE,其实解释不了 CPU 为什么会冲顶——它们卡的原因,更像是”整个实例已经很紧张”的结果,而不是”这几条语句拖垮了整个实例”的原因。
比对了几个负载数字才看清楚:活跃会话数瞬间冲到 2000+,负载峰值一度冲到 2332——而这台 32 核实例正常情况下的安全基线也就在 32 左右,相当于超载了 70 多倍;同一时间数据库总连接数逼近 2700。真正的负载大头,是另一张表上的一条很不起眼的计数查询:
SELECT count(*) FROM t_item_result WHERE game_id = $1 AND public IS true;
这条语句对应一个排行榜类接口,底层这张表的现成索引前导列不是 game_id,按 game_id 过滤命中不了。平时流量小还能忍,赶上当天流量波峰,几百个并发同时命中,每一条都是一次全表扫描,CPU 直接被烧穿。当时统计运行超过 60 秒的查询接近 500 条,里面只有约三分之一跟前面那张 version 表相关,剩下三分之二全是被这个环境拖累的无辜查询——这个比例也印证了负载大头不在 UPDATE 那条线上。另外还有一张评论相关的表,关联查询用的字段也没有对应索引支撑,同样添了一把火,只是量级上不如这条计数查询突出。
九、雪上加霜:自愈的最后一条退路也被堵死
数据库遇到这种膨胀和堆积,理论上还有 autovacuum 这道最后防线可以慢慢清理、让情况自己缓过来。但现场查下去发现,连这条退路都被堵死了:负责清理某个大字段存储区的 autovacuum 进程被卡了 3 小时 07 分都没跑完,另外两张核心表的 autovacuum 也分别被限速机制拖了 12 分 49 秒和 6 分 01 秒没能正常推进。
三个 autovacuum 进程各自卡在不同的表上,说明这不是某一张表局部的问题,而是整个实例的资源已经紧张到连后台清理任务都挤不进去执行时间片——自愈在这个节点上已经彻底没有可能了,只能等人工介入,也就是前面第四节做的那一步。
十、串起完整链条
把前面几问拼起来,大致是这样一个逐步升级的过程:
- 两天半前,一条持续占用 CPU、带并行度的分析查询开始跑,一直没有退出。它不仅自己消耗资源,还因为迟迟不结束的事务,卡住了全局的 MVCC 快照水位线,让
autovacuum没法正常清理死元组,核心表持续膨胀。 - 数据库配置够高,硬扛了 50 多个小时没出明显问题,直到一次日常流量波峰到来,一个缺索引的热点计数查询被并发放大成大量全表扫描,CPU 被瞬间烧穿。
- CPU 过载的环境里,一个原本有分布式锁保护的定时任务,因为 TTL 设短、加上容器被摘流重启时锁被提前释放,同款 UPDATE 一轮接一轮地排队,堆到近 30 个会话。
- 整个链路里没有任何一处配置了语句级超时,大量请求只能硬等,一直等到承载请求的容器自己被健康检查判定超时、遭遇摘流重启,协程才会被动取消。容器一批批被替换——前后两波,加起来十几个容器因为探活超时被判定不健康而摘流重启——又反过来给第 3 步递刀子:新容器起来立刻抢到刚释放的锁,发起新一轮冲突的 UPDATE,循环放大。
- 自愈机制(
autovacuum)此时也早已被拖到动弹不得,只能靠人工介入止损。
五条叠在一起,数据库和依赖它的所有服务全面变慢,业务侧的直观感受就是”所有接口都在超时”;而止损杀掉那两个源头之后,链条应声断掉,各项指标很快回归正常。
十一、真正要补的坑
现场处置只是止血,没解决任何一个根因。梳理下来要补的事不少,按优先级大致是这些:
1. 数据库连接补上语句级超时
查了一下,数据库连接这一层目前完全没有配置语句级超时(PostgreSQL 里对应的参数是 statement_timeout)——一条 SQL 能在数据库里卡多久,完全取决于外层谁先超时。这东西不设,等于给所有慢查询开了无限期挂账的口子。
设置方式很简单,PostgreSQL 支持好几个层级,按需要选:
-- 方式一:会话级,连接建立后手动执行一次,只对当前连接生效
SET statement_timeout = '15s';
-- 方式二:针对某个数据库账号,这个账号后续所有连接都自动生效
ALTER ROLE app_user SET statement_timeout = '15s';
-- 方式三:整个数据库实例级别的默认值(postgresql.conf 或者云厂商的参数组里配)
statement_timeout = 15000 -- 单位是毫秒
用连接池 / ORM 的话,更推荐在建连接的地方直接传进去,比如 Python 的 asyncpg:
create_async_engine(
DATABASE_URL,
connect_args={"server_settings": {"statement_timeout": "15000"}}, # 毫秒
)
具体设多少秒不能拍脑袋,得按接口类型分级:面向用户的核心读接口,通常给到个位数到十几秒;后台批处理、离线任务这类可以适当放宽到几十秒;但不管哪一档,都必须是一个明确的数字,而不是”不设”。这次事故里最长的一批查询卡了接近 5 个小时,如果哪怕只按最宽松的档位设了个 60 秒的超时,也不至于卡到需要人工介入才能收场。
2. 定时任务改分批提交,分布式锁不能只靠固定 TTL 硬扛
大表的数据量还在持续增长,一次性大批量 UPDATE 的方式本身就不太经得起数据量增长的考验,应该拆成小批次、分批提交。分布式锁这边,TTL 不能覆盖”任务最坏情况下要跑多久”这件事本身就是隐患,锁的释放时机也要重新设计——服务被动重启不应该等同于”可以安全释放锁去唤醒下一轮任务”,要么加续期机制,要么把锁的生命周期和它保护的事务状态绑得更紧。
3. 补上那两处真正缺失的索引
给热点计数查询命中的表建上覆盖实际查询条件的索引(用不锁表的方式建),给评论关联查询用到的字段也补上对应索引。这次的教训也提醒自己:排查的时候不能凭直觉断定”哪张表该加索引”,先用 EXPLAIN 或者历史负载分析工具找到真正的负载来源,再动手。
4. 给热点计数接口加缓存或者定期预聚合,而不是每次都实时 count(*),减少对底层大表的直接冲击。
5. 上一个只读副本,专门给离线分析用:哪怕只是拉一个不接线上业务流量的只读实例,专门给离线分析、数据核对这类查询用,也能避免”一条分析脚本忘关连接,直接拖垮生产主库”这种情况。主库到处被拿去做临时分析,风险敞口太大了。
6. 把关键监控和告警补齐:数据库层面的核心负载指标、慢 SQL 分布,这次也顺带确认了一件事——类似 Performance Insights 这种能看历史 Top SQL 和等待事件的工具,虽然要花一点成本,但事故时能不能几分钟定位到根因,差别巨大,值得为核心库常态化打开。
总结
这次排查最大的感受是:生产事故很少是单一原因,大多是几个各自看起来都不致命的小问题,在某个时间点凑到了一起。 一条忘关的分析脚本、一个缺索引的热点接口、一把 TTL 算短了又在错误时机被释放的分布式锁、外加全链路都没有语句级超时兜底——拆开看都是很容易被原谅的疏忽,叠在一起就是一次实打实的雪崩。而且因为机器本身性能够好,问题足足潜伏了 50 多个小时才被一次流量波峰引爆,这种”迟到的爆发”比”立刻报警”更值得警惕。
另外一点更具体的收获是:排查过程中”看起来很合理”的推断,动手之前值得用工具再验证一遍。 我一度笃定是索引问题,要不是多跑了一步 EXPLAIN,可能就在一张近 800GB 的大表上做了一次完全没必要、还会额外消耗 IO 的索引变更。查阻塞链能告诉你谁在等谁,但查负载大头永远得回到”谁真正在吃 CPU”这个问题上,两者不能互相替代;而遇到事故,先止损让系统喘口气,永远比一边扛着压力一边慢慢分析更重要。
本博客所有文章除特别声明外,均采用 @Oreoft 许可协议。转载请注明出处!
┌┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┐
├ 记得关注公众号:没有气的汽水 ┤
└┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┘