“蝴蝶效应”这个说法,在数据库事故里从来不是比喻。一个不起眼的缺失索引,几周内可能毫无存在感,却在某个业务高峰的瞬间,像多米诺骨牌一样引发连锁反应,最终把整个系统推向死锁的深渊。前段时间我就亲手处理了这么一起事故,从一条慢查询告警开始,到最后在 binlog 里找到完整的死锁链路——整个排查过程像剥洋葱一样层层深入,每一次定位都指向更深层的问题。
这不是什么复杂的高并发场景,就是一套普通的订单系统,MySQL 8.0 版本,数据量不过几百万行,日常 QPS 也就在千级。但就是这样一套看起来很“日常”的系统,却在一次常规促销活动中突然爆出一连串的数据库死锁告警,业务侧同时上报大量订单状态更新失败。事后复盘时我越发意识到,这类事故的典型程度远超想象,很多团队至今仍在用“缘分”来面对死锁问题。
1. 事故回顾:从一条慢查询到业务雪崩
1.1 最初的异常信号
当天上午 10 点 23 分,监控系统开始弹出第一波告警。起初只是几条慢查询日志,集中在 orders 表上一个非常普通的查询语句上:
sql复制SELECT * FROM orders
WHERE user_id = 1024 AND status = 1
ORDER BY create_time DESC LIMIT 10;
这条 SQL 本身没有任何问题,就是典型的订单列表查询。但执行计划一出来,问题就明显了:type=ALL,全表扫描,扫描行数 280 万。正常情况下,user_id 上是有索引的,但由于历史原因,这个索引是单列索引 idx_user_id,在 status 和 create_time 上并没有任何索引支撑,这导致 MySQL 在过滤 status 和排序 create_time 时,只能基于 idx_user_id 找到所有该用户的订单,再在内存中做二次过滤和 filesort。
如果只是慢查询,问题还不至于致命。但事情很快失控了——监控面板上,同一张表上的 UPDATE 语句开始出现大量行锁等待,等待时间从最初的几十毫秒飙升到十几秒。紧接着,死锁告警开始刷屏,几乎每隔几秒就有一条新的死锁记录。
1.2 业务侧的真实表现
业务侧反馈的现象也很有代表性:用户下单支付成功后,回调通知里的订单状态更新偶尔会失败,导致用户已经支付但订单一直停留在“待支付”状态,用户反复点击支付按钮又触发了重复支付请求,进一步放大了订单表的读写压力。整个系统陷入一个典型的恶性循环——数据库越堵,业务重试越多;业务重试越多,数据库越堵。
更要命的是,负责订单状态更新的核心方法,采用的是“先查后改”模式:
sql复制-- 事务A:用户支付回调
BEGIN;
SELECT * FROM orders WHERE order_id = 20241101 FOR UPDATE;
UPDATE orders SET status = 2 WHERE order_id = 20241101;
COMMIT;
这套流程在平时毫无问题,但在大量慢查询抢占资源、锁等待加剧的情况下,多个事务之间的锁竞争变得异常激烈,死锁出现的频率陡然上升。而且由于业务代码里缺少完善的死锁重试机制,很多事务在遇到死锁后直接抛出异常,订单状态就卡在了半路。
事后我回顾整条链路,发现最让我印象深刻的不是死锁本身,而是那条几乎不会被人注意的慢查询——它在系统里潜伏了整整一周,直到业务高峰到来时才露出獠牙。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 死锁定位:如何在成百上千个会话中间找到真凶
2.1 第一现场:show engine innodb status 的最后一小段
说到排查死锁,很多人的第一反应是打开 MySQL 的错误日志或者直接执行 SHOW ENGINE INNODB STATUS。这个方法没错,但在一个会话数动辄数百的数据库实例上,找到关键信息并不容易。我当时的第一步操作,就是先拉取最近的死锁记录。
在执行这个命令之前,我习惯先确认一个前提:死锁记录只保留最近一次的死锁信息。如果事故已经持续了一段时间,你看到的可能不是最早的那次死锁,而是最后一次。所以正确的做法是,先看时间点,确认这条死锁记录是否出现在业务异常的高峰期。
sql复制SHOW ENGINE INNODB STATUS\G
输出结果中,重点看 LATEST DETECTED DEADLOCK 这一段。我当时截取到的关键信息是这样的:
code复制*** (1) TRANSACTION:
TRANSACTION 5271833, ACTIVE 12 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 824243, OS thread handle 139770000000000, query id 9876543
UPDATE orders SET status = 2 WHERE order_id = 20241101
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 128 page no 204 n bits 272 index PRIMARY of table `db_shop`.`orders`
trx id 5271833 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 5271835, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 824245, OS thread handle 139770000000001, query id 9876544
UPDATE orders SET status = 3 WHERE order_id = 20241101
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 128 page no 204 n bits 272 index PRIMARY of table `db_shop`.`orders`
trx id 5271835 lock_mode X locks rec but not gap
两个事务都在更新同一条订单记录,都持有了某些锁,又都在等待对方释放锁——典型的死锁闭环。这里有一个很快的判定方法:如果死锁涉及的都是 UPDATE ... WHERE order_id = xxx 这种基于主键的等值更新,那问题多半不是缺索引,而是业务逻辑层面的锁顺序问题。但这次的情况不太一样,现场除了主键锁等待,还出现了大量辅助索引上的锁等待。
2.2 用 performance_schema 找到锁等待源头
SHOW ENGINE INNODB STATUS 只能看到最近一次死锁,但这次事故中,死锁发生的频率太高,单靠一条记录根本看不全。为了弄清楚锁等待的分布情况,我打开了 performance_schema 这把利器。
首先看当前有哪些事务正在等待锁:
sql复制SELECT
thread_id,
EVENT_NAME,
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks
WHERE LOCK_STATUS = 'WAITING'
ORDER BY OBJECT_NAME, INDEX_NAME;
这条查询能直接告诉你,当前所有正在等待的锁对象是谁、在哪个索引上被阻塞了。我当时执行后发现,大量等待集中在 orders 表的 idx_user_id 索引上,以及主键索引上。更关键的是,有些等待的记录,LOCK_DATA 显示的不是一个具体的主键值,而是一个范围区间:
code复制LOCK_DATA: supremum pseudo-record
LOCK_MODE: X,GAP
supremum pseudo-record 加上 GAP 锁,意味着某个事务在 idx_user_id 索引上,对“大于某个值的所有记录”加了一个间隙锁。这个细节非常关键,它直接把我引向了索引范围查询的问题。
2.3 正确定位:揪出那个代价高昂的罪魁祸首
通过 performance_schema 锁等待数据,再配合慢查询日志,我锁定了两个核心嫌疑语句:
sql复制-- 语句1:订单列表分页查询
SELECT * FROM orders
WHERE user_id = 1024 AND status = 1
ORDER BY create_time DESC LIMIT 10;
-- 语句2:统计用户某时间段的订单金额
SELECT user_id, SUM(amount)
FROM orders
WHERE user_id = 1024
AND create_time >= '2024-10-01 00:00:00'
AND create_time < '2024-11-01 00:00:00'
GROUP BY user_id;
两条语句都走了 idx_user_id 索引,但都没能完全覆盖查询条件。第一条语句需要回表后在内存中过滤 status 和排序,第二条语句需要在索引扫描后对 create_time 做范围判断。由于数据量大,它们扫描的索引行数都非常可观。
这就是问题所在。索引不是没有,只是有“但不完整”。在执行计划中看着走了索引,实际却依然在大量回表和排序。
我用一个简单的实验证明了这一点。假设 user_id = 1024 的用户有 2000 条订单记录,那么语句1走 idx_user_id 索引时会找到这 2000 条记录,然后逐一回表,读取出完整的行数据后,再过滤出 status = 1 的记录,最后进行 filesort 排序取前 10 条。整个过程涉及 2000 次回表,而用户真正需要的只是 10 条记录。
我用 EXPLAIN ANALYZE 验证:
sql复制EXPLAIN ANALYZE
SELECT * FROM orders
WHERE user_id = 1024 AND status = 1
ORDER BY create_time DESC LIMIT 10;
结果中明确出现了 filesort (cost: 12.34 rows: 2047) 的字样——2000 多次回表,加上一次内存排序。代价之高,在数据量低峰期可能无感,但一到高峰就被成百上千倍的并发放大了。
3. 缺索引如何一步步引爆死锁:完整链路复原
说到底,这场事故的起点是一个再普通不过的索引缺失问题。但死锁之所以发生,绝不只是“查询慢”这么简单,而是一整条因果链在特定条件下被依次激活。
3.1 链路第一环:范围扫描放大了锁的范围
在 InnoDB 默认的 REPEATABLE READ 隔离级别下,普通查询是快照读,不加锁。但 SELECT ... FOR UPDATE 和 UPDATE、DELETE 语句走的是当前读,需要加锁。锁的粒度取决于扫描过程中访问到的索引记录范围。
正常情况下,UPDATE orders SET status = 2 WHERE order_id = 20241101 这条语句,通过主键等值查找,只需要锁住一行记录。但如果执行计划不是走主键,而是走了辅助索引,那么 InnoDB 在扫描辅助索引的过程中,会把所有扫描到的索引记录连同它们对应的主键记录全部加锁。
在这次事故中,由于 idx_user_id 索引无法精确定位到目标行,MySQL 需要扫描大量的辅助索引记录。每扫描一条辅助索引记录,就要对相应主键记录加锁。扫描范围越大,锁范围越大,锁与锁之间的交集可能性也越高。
我用一个类比来解释这个过程:你到图书馆找一本特定主题的书,如果图书馆有一套精确的分类索引,你直接翻到对应书架拿书就行,全程只碰那一本书。但如果没有精确索引,你得在“社科类”这个大类里一个书架一个书架地扫过去,每经过一本书你都要伸手碰一下——你可以不用借走,但管理员为了安全,已经标记了你碰过的所有书。
3.2 链路第二环:慢查询拖长了持锁时间
范围大了只是第一步,更要命的是持锁时间被无限拉长。
回到语句1那条慢查询,它走了 idx_user_id 索引,扫描了大量记录,然后回表、过滤、排序。整个过程如果单独看,可能也就几百毫秒。但在高并发场景下,几百毫秒意味着什么?意味着这期间可能有几十个事务在等待这批锁资源。
更致命的是,很多业务事务并不是简单的一行更新。实际的支付回调事务里,除了更新订单状态,还会插入一条支付流水、更新用户账户余额、更新优惠券使用状态。这意味着一个事务会持有多个表、多行记录的锁。当多个事务各自持有一部分锁、又都在等待对方持有的另一部分锁时,死锁就发生了。
我用一个简单的锁等待时间线来复盘当时的场景:
| 时间点 | 事务A | 事务B | 事务C |
|---|---|---|---|
| T1 | 更新订单20241101,获得行锁 | 扫描订单表,获取大量共享锁 | 更新订单20241102,获得行锁 |
| T2 | 更新支付流水表,等待 | 更新订单20241101,等待A释放锁 | 更新订单20241101,等待A释放锁 |
| T3 | 等待B释放订单表扫描锁 | 被C阻塞 | 被A阻塞 |
| T4 | 与B形成循环等待 | 与A形成循环等待 | 持续等待 |
这张表虽然简化了不少细节,但核心冲突已经很明显:不同事务在访问订单表时,因为扫描范围过大,锁覆盖了大量重叠的行;而慢查询又导致锁释放得异常缓慢,相当于每个事务把锁握在手里不放的时间成倍延长。死锁在此时已经不仅仅是概率问题,而是必然结果。
3.3 链路第三环:线程池耗尽,系统雪崩
死锁本身是数据库的保护机制——检测到循环等待后,InnoDB 会选择回滚一个代价较小的事务来打破僵局。但问题的严重性在于:死锁的频率远超事务重试的频率。
业务代码里的重试机制是有的,但只做了两层重试。最外层是消息队列的补偿机制,中间层是业务方法内的异常捕获重试。当死锁频繁发生时,第一层重试很可能再次撞上死锁,第二层消息队列补偿又存在数秒的延迟。在这个时间窗口内,用户端看到的是“支付成功但订单未更新”,于是不断点击刷新、重复提交支付结果查询。每个请求进来都会触发订单状态查询,又都因为索引缺失而变成慢查询,进一步加剧锁竞争。
最终的结果是:数据库的活跃线程数持续飙升,线程池被打满,新的请求根本排不上执行。整个订单服务的数据库访问延迟从最初的几十毫秒上涨到十几秒,服务端大量请求超时,业务几乎停滞。
我在事故复盘时画过一张时序图,完整地展示了整个链条:
缺索引导致低效查询 → 查询扫描行数暴增 → 索引范围锁变大 → 锁竞争加剧 → 持锁时间变长 → 锁等待超时/死锁 → 业务失败重试 → 重试放大流量 → 线程耗尽 → 服务雪崩
每个环节单独看都不是致命问题,但连起来就是一场典型的“蝴蝶效应”事故。
4. 解决方案与落地验证:从“加索引”到“改代码”
4.1 第一步:设计合理的复合索引
定位到根因后,解决方案其实非常明确。问题出在 idx_user_id 单列索引不够用,需要设计一个能够覆盖核心查询条件的复合索引。
第一个需要优化的查询是订单列表:
sql复制SELECT * FROM orders
WHERE user_id = 1024 AND status = 1
ORDER BY create_time DESC LIMIT 10;
这条查询的过滤条件是 user_id 和 status,排序条件是 create_time。按照最左前缀原则,复合索引的第一个字段必须是 user_id,因为它是等值匹配。接下来要考虑的是:status 和 create_time 的先后顺序怎么排。
我当时做了两个选择对比:
- (
user_id,status,create_time):等值过滤后直接索引排序,不需要 filesort,而且用不上create_time后续排序的额外排序操作,因为索引已经按create_time排好序了。 - (
user_id,create_time,status):过滤条件只有user_id能走索引,create_time用于范围排序,但status的过滤只能回表后做。
最终按照行业里的通用优化原则,选定方案一:等值条件列在前,排序列放在最后。这样索引的数据结构直接满足查询需求,既完成了过滤,又完成了排序,还能避免回表和 filesort。
sql复制ALTER TABLE orders
ADD INDEX idx_user_status_time (user_id, status, create_time);
第二个需要优化的是订单金额统计查询:
sql复制SELECT user_id, SUM(amount)
FROM orders
WHERE user_id = 1024
AND create_time >= '2024-10-01 00:00:00'
AND create_time < '2024-11-01 00:00:00'
GROUP BY user_id;
这个查询的过滤条件是 user_id(等值)和 create_time(范围),需要统计的是 amount 字段。这时候一个 (user_id, create_time, amount) 的复合索引,就能让查询在索引内部完成全部工作,连回表都不需要。这个索引叫覆盖索引,因为索引字段包含了查询所需的所有列。
sql复制ALTER TABLE orders
ADD INDEX idx_user_time_amount (user_id, create_time, amount);
索引加上后,我用 EXPLAIN 重新验证:
sql复制EXPLAIN
SELECT * FROM orders
WHERE user_id = 1024 AND status = 1
ORDER BY create_time DESC LIMIT 10;
执行计划显示 type=ref,key=idx_user_status_time,rows=8,Extra 字段不再出现 filesort。扫描行数从 2047 直接降到个位数。第二条统计查询的 Extra 字段则变成了 Using index,意味着完全在索引层面完成了查询。
4.2 第二步:业务代码层面的防御性修改
加索引解决了“锁范围大”和“查询慢”的问题,但这次事故还暴露了另一个隐患:业务代码对死锁的应对能力不足。
具体来说,订单状态更新方法在捕获到死锁异常后,直接向上抛出,没有立即重试。对于这种“支付回调”类的核心写操作,合理的做法是加入有限次数的重试机制。考虑到死锁通常发生在秒级以内,重试三次、每次间隔 200ms 是一个比较稳妥的设置。
我当时在代码里加了这样的逻辑:
java复制@Retryable(
value = {CannotAcquireLockException.class, DeadlockLoserDataAccessException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2)
)
public void updateOrderStatus(String orderId, Integer status) {
// 原有更新逻辑
}
这里选 @Retryable 而不是手写 try-catch 循环,是因为 Spring 的重试注解对异常类型的判定更精准——它只对真正的死锁异常或锁获取失败异常触发重试,不会因为网络抖动等其他异常盲目重试。
更重要的是,我在重试之外做了一个业务顺序调整。原来的“先查后改”事务里,是先查询订单再更新状态,看起来没什么问题,但在并发场景下,两个事务同时查到了同一行数据,然后依次尝试加锁更新,就形成了锁等待。我把事务拆小,核心的 UPDATE 语句直接走主键更新,不再前置 SELECT ... FOR UPDATE,因为订单状态更新本身就是幂等的,直接执行更新即可:
sql复制UPDATE orders
SET status = 2
WHERE order_id = 20241101 AND status = 1;
这样不仅减少了锁的持有时间,还避免了“先查后改”模式下多一步查询带来的额外锁开销。
4.3 验证效果:从“频繁死锁”到“零告警”
索引和代码都改完之后,我做了三轮验证。
第一轮是单机压测。用 10 个并发线程同时模拟支付回调、订单查询、统计报表三类操作,连续运行 30 分钟,死锁告警数量从修改前的平均每分钟 5 - 8 次降为 0。最重要的是,performance_schema.data_locks 中不再出现大范围的 GAP 锁等待。
第二轮是线上灰度。先在订单量最大的一个城市节点开启新索引,观察 15 分钟,确认慢查询数量明显下降——全表扫描的样例记录从每秒 200 多条降到不足 10 条,锁等待超时告警清零。
第三轮是完整上线后的一周观察。这段时间内,订单表的 rows examined 平均值从 230 万降到了 450,查询响应时间 P99 从 3.2 秒降到 120 毫秒。整个系统安静得让人有些不习惯,但监控面板上那一串归零的告警曲线,就是这次修复最直观的证据。
5. 从一次事故到一套机制:索引审查与监控的长期建设
事故处理完之后,我一直在想一个问题:如果在事发之前,我们就能主动识别出这类“低效索引”的风险,是不是根本不会走到死锁这一步?
答案是肯定的。但主动识别需要建立一套机制,不能靠运气,也不能靠 DBA 的紧盯。我从这次事故里总结出几个可以落地的预防措施。
5.1 定期审查执行计划中的“假索引使用”
很多 SQL 乍一看走了索引,实际上只是走了索引的一小部分,后续的过滤和排序全部在回表和内存中完成。这种“假索引使用”是慢查询的头号来源。
我在团队里定的规矩是:每个月跑一次全量 SQL 审计,重点查两类执行计划。
第一类是 type 为 index 或 ALL 的查询。前者意味着全索引扫描,后者意味着全表扫描,都是危险信号。第二类是 Extra 字段出现 Using filesort 或 Using temporary 的查询,这通常意味着索引设计没能覆盖排序或分组需求。
排查 SQL 和对应的执行计划,可以借助 sys 库中的诊断视图快速定位:
sql复制-- 找出有潜在问题的高开销 SQL
SELECT
db,
query,
exec_count,
rows_examined_avg,
rows_sent_avg,
last_seen
FROM sys.statements_with_full_table_scans
ORDER BY rows_examined_avg DESC
LIMIT 20;
这个视图会直接列出全表扫描类的高频 SQL,不用自己翻慢查询日志。然后对每条 SQL 做执行计划审核,确认索引是否真的合理。
5.2 用 performance_schema 建立锁等待监控基线
死锁发生之前,锁等待一定是先异常升高的。如果我们能监控锁等待的趋势,就能提前预警,而不是等死锁刷屏了才后知后觉。
我建立了一个简单的锁等待监控脚本,每 30 秒采样一次 performance_schema.data_lock_waits 表,记录当前等待中的锁数量、等待时间、涉及的索引名。正常状态下,等待中的锁数量应该在 0 - 2 之间波动,一旦连续三次采样超过 5,就触发告警。
sql复制-- 锁等待采样脚本核心SQL
SELECT
COUNT(*) AS waiting_trx_count,
AVG(TIMESTAMPDIFF(SECOND, w.REQUESTED_LOCK_TIME, NOW())) AS avg_wait_seconds,
GROUP_CONCAT(DISTINCT i.INDEX_NAME) AS involved_indexes
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks l ON w.REQUESTED_ENGINE_LOCK_ID = l.ENGINE_LOCK_ID
LEFT JOIN information_schema.innodb_sys_indexes i ON l.INDEX_ID = i.INDEX_ID
WHERE l.LOCK_STATUS = 'WAITING';
这个指标比慢查询数量更早地反映锁竞争,因为慢查询往往要等锁超时之后才会记入日志,而锁等待是实时的。等于是在事故真正爆发之前就拦住了。
5.3 变更流程里加一道“执行计划评审”
最后一条建议,也是我认为最容易被忽略但最有价值的:给所有涉及核心表的 SQL 变更增加一道“执行计划变更评审”。
很多团队上线新功能时,只在测试环境跑一下,确认功能正常就发布了。但测试环境的数据量和线上差距巨大,一条 SQL 在测试环境走索引,到了线上可能就变成全表扫描。数据库索引选择是靠统计信息估算的,数据分布一变,执行计划就可能完全不一样。
我的做法是:在发布清单中增加一个必填字段“本次变更涉及的 SQL 及其执行计划”。开发同学需要在测试环境执行 EXPLAIN ANALYZE,把分析结果贴到发布单里。DBA 只要快速扫一眼执行计划,确认没有全表扫描、没有 filesort、扫描行数合理,就可以放行。看似多了一步,但能拦截掉相当一部分“上线即慢查询”的隐患。
6. 一次死锁事故背后,我踩过的那些坑
最后再说说我在这次事故处理中实际踩过的几个坑,也算是一些个人经验吧。
第一个坑是太过依赖 SHOW ENGINE INNODB STATUS。在死锁高频发生的场景下,这条命令只能看到最近一次死锁,有可能你看到的那次并不是最严重的那次。而且输出内容冗长,快速定位关键信息需要一定经验。我的建议是:死锁频繁时,直接用 performance_schema 的表查实时状态,定位准确率会高很多。
第二个坑是加索引时没有考虑已有数据量。直接在几百万行的表上执行 ALTER TABLE ADD INDEX 是有风险的。MySQL 8.0 虽然支持在线 DDL,但具体实现还需要看索引类型和表引擎。对于大表,建议使用 pt-online-schema-change 这类工具,将加索引操作分成拷贝数据和切换表的阶段,避免 DDL 期间锁表导致业务短暂中断。我当时是在凌晨低峰期直接执行的,如果业务高峰期出了问题,代价会很大。
第三个坑是关于重试参数的设置。最初我在业务代码里设置的重试间隔是 50ms,实测下来效果不好,因为死锁发生后,InnoDB 需要一点时间释放相关锁资源,50ms 太短,第一次重试大概率还会撞上同样的死锁。后来调整为 200ms 起步、指数退避,明显改善。
第四个坑是只加了索引,没有同步优化业务 SQL。事后来看,那条 SELECT ... FOR UPDATE 的“先查后改”逻辑即使有了索引,也依然存在不必要的锁开销。如果只加索引不改业务,死锁虽然不会频繁发生,但事务的整体持锁时间依然偏长,在高并发下仍然可能成为瓶颈。所以说,索引优化和 SQL 优化通常要一起做,缺一不可。
经历过这次事故之后,我现在看任何涉及订单、库存、账户这类核心表的索引设计,都会多问一句:这条查询在并发场景下锁范围有多大?事务持锁时间会不会超过预期?这已经成了我的一种条件反射。希望这篇复盘,也能让读到这里的你建立同样的直觉。
