连接池打满但CPU很闲:一次数据库死锁事故复盘

作者:暴富20212026.11.06 20:58浏览量:0

简介:下午三点订单接口批量超时,连接池被打满而数据库CPU却很闲,这是锁等待而非算力瓶颈的典型信号。本文完整复盘一次由死锁引发的线上连锁故障:从会话分析、锁等待链定位到根因还原,并给出统一加锁顺序、事务瘦身、索引治理、死锁重试与监控告警等一整套止血与根治方案,可直接沉淀为团队的排查手册。

一、事故现场:一组“矛盾”的监控数据

下午三点,订单相关接口突然大面积超时。打开监控,数据是这样的:

  • 数据库活跃连接数从常态的 50 左右飙升至 500+,顶着连接池上限;
  • 应用层大量报错:无法从连接池获取连接;
  • 接口响应时间从 100ms 级恶化到 30 秒以上直至超时;
  • 数据库 CPU 使用率只有 20% 左右,处于明显低位;
  • 慢查询日志数量激增。

这组数据里藏着一个关键矛盾点:如果是查询性能问题(缺索引、数据量暴涨、坏执行计划),CPU 或 IO 应该先被打满;现在是 CPU 空闲但连接全满,说明这些连接不是在“算”,而是在“等”。等什么?大概率是等锁。慢查询激增也顺理成章,不是 SQL 本身慢,而是执行前或执行中卡在锁等待上,等待时间全被记进了执行时长。

这一步的定性判断非常关键:它直接决定排查方向是“优化 SQL 性能”,还是“治理锁与事务”。方向错了,后面全是无用功。

还要补一句连接池被打满的连锁效应:连接池是应用侧的保护性资源,一旦连接被长时间持有不释放,新请求只能排队,上游线程随之堆积,与该数据库共享连接池的其他业务也会被殃及,故障从“一个接口慢”放大为“整个应用不可用”。

二、排查第一步:看清几百个连接到底在干什么

几乎所有关系型数据库都提供了查看当前会话的指令或系统视图,核心字段通常包括:连接 ID、来源主机、登录账号、当前命令类型、已持续时长、会话状态、正在执行的 SQL。按“持续时间 + 状态”排序,优先关注长时间处于非空闲状态的会话。

输出(简化示意):

连接ID 来源 当前SQL 已持续 状态
1001 10.0.0.1 UPDATE orders SET status=2 WHERE id=123 180s 等待锁
1002 10.0.0.2 UPDATE orders SET status=2 WHERE id=456 178s 等待锁
1003 10.0.0.3 SELECT * FROM orders WHERE user_id=789 175s 发送数据
…… …… 还有几百条类似记录 …… ……

结论很清晰:大量连接处于“等待锁”状态,且等待时间以分钟计。这说明系统里存在一个或多个“长期持锁不放”的事务,所有需要这把锁的请求全部在排队。

一个值得注意的细节:连普通的 SELECT 查询也被拖住了。可能是查询要访问的记录正被加锁,也可能是会话在等连接或被表级锁阻塞。这也解释了为什么“以读为主”的业务会被“写锁”拖垮——锁的排队效应不分读写。

三、排查第二步:画出“谁在等谁”的锁等待链

主流数据库都会在系统视图(元数据视图或性能视图)中暴露锁等待关系,核心是拿到两个信息:等待方是谁、阻塞方是谁。

示意查询(具体视图名以所用数据库文档为准):

  1. SELECT w.waiting_trx_id, -- 等待中的事务
  2. w.waiting_query, -- 等待方正在执行的SQL
  3. b.blocking_trx_id, -- 持锁阻塞方事务
  4. b.blocking_query -- 阻塞方正在执行的SQL
  5. FROM lock_waits w
  6. JOIN transactions b ON b.trx_id = w.blocking_trx_id;

输出(示意):

waiting_trx waiting_query blocking_trx blocking_query
10001 UPDATE orders … 10002 UPDATE inventory …
10002 UPDATE inventory … 10001 UPDATE orders …

一眼看穿:10001 在等 10002,10002 又在等 10001,互相等待,形成闭环。这就是教科书级的死锁(deadlock)。

四、疑点:数据库不是会自动解开死锁吗?

这里必须澄清两个极易混淆的概念:

  • 死锁(deadlock):两个及以上事务互相持有对方需要的锁,形成环。支持行级锁的事务型存储引擎普遍内置死锁检测机制,发现环路后会立刻选择一个“回滚代价最小”的事务作为牺牲者回滚,让另一个继续执行。
  • 锁等待(lock wait):A 持锁,B 排队等 A,但没有环。这种等待不会自动解除,只能等 A 提交或回滚,或者等到锁等待超时被系统掐断。

那本次事故为什么没被死锁检测“自动治愈”?复盘发现三个放大器:

  1. 牺牲者回滚后立刻重试原逻辑,没有退避、也没有改变加锁顺序,又迅速和下一个事务绞在一起,死锁从偶发变成高频事件;
  2. 业务代码没有捕获死锁错误,重试发生在更上层的调用方,每次重试都重新申请连接、重新排队,把连接池当成了重试队列;
  3. 锁等待超时配置过长,前排事务一卡几分钟,后面几百个连接全部挂着干等,连接池被占满后,新请求在应用侧继续堆积。

一句话总结:死锁是火花,“锁等待排队 + 连接池占满 + 上层无节制重试”才是把火花烧成大火的燃料。

五、根因还原:两条更新路径的加锁顺序不一致

还原出的事务逻辑大致如下(示意):

  1. -- 路径A:下单成功后,先回写订单状态,再扣库存
  2. BEGIN;
  3. UPDATE orders SET status = 2 WHERE id = 123;
  4. UPDATE inventory SET stock = stock - 1 WHERE sku_id = 456;
  5. COMMIT;
  6. -- 路径B:库存侧反向同步,先改库存,再更新订单
  7. BEGIN;
  8. UPDATE inventory SET stock = stock - 1 WHERE sku_id = 456;
  9. UPDATE orders SET status = 2 WHERE id = 123;
  10. COMMIT;

路径 A 先锁订单行再锁库存行,路径 B 先锁库存行再锁订单行。并发一上来,A 拿着订单锁等库存锁,B 拿着库存锁等订单锁,环路成立。这是最经典的 AB-BA 死锁模型。

两个放大锁冲突面的工程细节同样值得警惕:

  • WHERE 条件必须走索引。在行级锁机制下,UPDATE/DELETE 若因缺索引而走全表扫描,可能对扫描经过的大量记录(某些实现下还包括间隙)加锁,把“改一行”放大成“锁一片”,死锁概率指数级上升;
  • 事务内混入远程调用。持锁期间再调用下游接口,锁的持有时间从毫秒级被拉长到秒级,排队者成倍堆积,锁等待雪球越滚越大。

六、应急止血:别急着重启

事故中第一反应是重启数据库,确实好了 10 分钟——但根因还在,锁很快重新堆起来。更合理的止血顺序是:

  1. 保留现场:先抓取会话列表、锁等待关系、死锁日志和慢查询样本,再动手处理。重启后内存中的会话与锁信息全部丢失,现场没了,后续定位只能靠猜;
  2. 定点击破:从锁等待视图中找到“最上游”的阻塞事务(它不等人、只被人等),评估业务影响后手动终止该会话,让整条排队链一次性解开;
  3. 上游降压:对引发风暴的入口做限流或降级,避免止血期间排队继续膨胀;
  4. 观察验证:确认活跃连接数回落、锁等待清零后,再逐步解除降级。

七、长效治理:一份可落地的防死锁清单

  1. 统一加锁顺序(治本):全局约定所有事务按相同顺序访问表与行,例如“先订单后库存”,或统一按主键升序加锁。写入开发规范与代码评审清单,这是消除 AB-BA 死锁最有效的手段;
  2. 缩小锁的范围与时长:保证 UPDATE/DELETE 的 WHERE 条件命中索引;大事务拆小,事务内不做远程调用、不做复杂计算,能后置的逻辑(发消息、记日志)放到提交之后;
  3. 建立死锁重试机制:应用层捕获死锁类错误,做有限次数的指数退避重试,前提是事务幂等、业务语义可重入:
  1. // 伪代码:死锁重试模板
  2. for (int i = 0; i < MAX_RETRY; i++) {
  3. try {
  4. return doInTransaction(() -> orderService.complete(orderId));
  5. } catch (DeadlockException e) { // 死锁类错误
  6. sleep(backoff(i)); // 指数退避,避免立即再次冲突
  7. }
  8. }
  9. throw new OrderBusyException(); // 重试耗尽,快速失败
  1. 让失败来得更快:把锁等待超时从“几分钟”调到业务可接受的秒级——宁可快速失败再重试,也不要让几百个连接挂在原地空等;连接池设置获取超时与打满告警,避免队列无限堆积;
  2. 热点治理:对超高并发的热点行(如秒杀库存),考虑在数据库层之外做预扣减、请求合并或队列串行化,把行锁竞争挡在数据库之前;
  3. 监控与告警前置:将活跃连接数、连接池使用率、锁等待数量、长事务持续时间、死锁发生次数纳入核心告警。托管化的云数据库在这方面通常有明显优势,例如百度智能云等厂商提供的云数据库与监控告警类产品,一般会内置连接数、锁等待、慢查询等关键指标的可视化与阈值告警,能把“发现问题”的环节从小时级压缩到分钟级。

八、沉淀成方法论:一次排查的通用框架

把这次事故抽象出来,可以得到一个可复用的排查框架:

  1. 看矛盾信号:CPU/IO 低 + 连接满 + 响应慢,优先怀疑锁等待,而不是算力瓶颈;
  2. 看会话状态:确认连接在“等”而不是在“算”,找出等待时长最长的状态与 SQL;
  3. 看等待关系:用锁等待视图还原“谁等谁”,找到阻塞源头或环路;
  4. 看事务逻辑:还原双方 SQL,检查加锁顺序、索引命中、事务大小;
  5. 先止血后根治:定点终止阻塞源 + 上游限流,事后再做加锁顺序、事务瘦身、重试机制等长效改造;
  6. 防回归:把死锁错误率、锁等待指标纳入日常监控,把加锁顺序规范写进评审清单。

死锁本身并不可怕,它是并发系统中几乎必然会遇到的经典问题。可怕的是对锁行为缺乏可观测性,以及没有一套“发现—定位—止血—根治”的既定动作。把这套框架沉淀下来,下一次告警响起时,你需要的就不是 4 个小时,而是 4 分钟。