简介:下午三点订单接口批量超时,连接池被打满而数据库CPU却很闲,这是锁等待而非算力瓶颈的典型信号。本文完整复盘一次由死锁引发的线上连锁故障:从会话分析、锁等待链定位到根因还原,并给出统一加锁顺序、事务瘦身、索引治理、死锁重试与监控告警等一整套止血与根治方案,可直接沉淀为团队的排查手册。
下午三点,订单相关接口突然大面积超时。打开监控,数据是这样的:
这组数据里藏着一个关键矛盾点:如果是查询性能问题(缺索引、数据量暴涨、坏执行计划),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 查询也被拖住了。可能是查询要访问的记录正被加锁,也可能是会话在等连接或被表级锁阻塞。这也解释了为什么“以读为主”的业务会被“写锁”拖垮——锁的排队效应不分读写。
主流数据库都会在系统视图(元数据视图或性能视图)中暴露锁等待关系,核心是拿到两个信息:等待方是谁、阻塞方是谁。
示意查询(具体视图名以所用数据库文档为准):
SELECT w.waiting_trx_id, -- 等待中的事务w.waiting_query, -- 等待方正在执行的SQLb.blocking_trx_id, -- 持锁阻塞方事务b.blocking_query -- 阻塞方正在执行的SQLFROM lock_waits wJOIN 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)。
这里必须澄清两个极易混淆的概念:
那本次事故为什么没被死锁检测“自动治愈”?复盘发现三个放大器:
一句话总结:死锁是火花,“锁等待排队 + 连接池占满 + 上层无节制重试”才是把火花烧成大火的燃料。
还原出的事务逻辑大致如下(示意):
-- 路径A:下单成功后,先回写订单状态,再扣库存BEGIN;UPDATE orders SET status = 2 WHERE id = 123;UPDATE inventory SET stock = stock - 1 WHERE sku_id = 456;COMMIT;-- 路径B:库存侧反向同步,先改库存,再更新订单BEGIN;UPDATE inventory SET stock = stock - 1 WHERE sku_id = 456;UPDATE orders SET status = 2 WHERE id = 123;COMMIT;
路径 A 先锁订单行再锁库存行,路径 B 先锁库存行再锁订单行。并发一上来,A 拿着订单锁等库存锁,B 拿着库存锁等订单锁,环路成立。这是最经典的 AB-BA 死锁模型。
两个放大锁冲突面的工程细节同样值得警惕:
事故中第一反应是重启数据库,确实好了 10 分钟——但根因还在,锁很快重新堆起来。更合理的止血顺序是:
// 伪代码:死锁重试模板for (int i = 0; i < MAX_RETRY; i++) {try {return doInTransaction(() -> orderService.complete(orderId));} catch (DeadlockException e) { // 死锁类错误sleep(backoff(i)); // 指数退避,避免立即再次冲突}}throw new OrderBusyException(); // 重试耗尽,快速失败
把这次事故抽象出来,可以得到一个可复用的排查框架:
死锁本身并不可怕,它是并发系统中几乎必然会遇到的经典问题。可怕的是对锁行为缺乏可观测性,以及没有一套“发现—定位—止血—根治”的既定动作。把这套框架沉淀下来,下一次告警响起时,你需要的就不是 4 个小时,而是 4 分钟。