1. 事故现场:一个看似普通的周五晚上
先交代一下背景。我们有一套电商积分系统,平时流量不算大,数据库压力一直很平稳。某天晚上10点多,核心交易库的报警突然响了:死锁数量飙升,紧接着订单创建接口超时率拉满,一波用户反馈“支付成功后订单一直转圈”。
我当时第一反应是“又有人改代码把事务写坏了”,但翻了最近发布记录,当天根本没有上线任何东西。这就比较诡异了——没有任何变更,系统却在运行中自己崩溃。登录到数据库实例上看,SHOW ENGINE INNODB STATUS 里堆着大段的死锁日志,涉及两张表:用户积分明细表(points_record)和订单表(orders)。两个事务互相等待对方持有的行锁,典型的循环等待。
但真正让我意外的不是死锁本身,而是死锁背后的触发原因。排查到最后发现,问题的源头竟然是积分明细表上少了一个普通索引。一张日均写入量不到10万行的表,一个并不复杂的查询,因为没有索引,硬生生把一次普通的积分发放操作拖成了秒级,然后连环引发了事务阻塞、锁持有时间拉长、最终演变成死锁。
这次事故之后,我把整个排查过程完整梳理了一遍。想借这篇文章把“缺索引 → 慢查询 → 长事务 → 锁竞争 → 死锁”这条链路讲清楚,也把排查中用到的具体手段和SQL分享出来。无论你是DBA、后端开发还是运维,这套思路应该都有参考价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缺索引到死锁的完整因果链拆解
2.1 死锁的本质:循环等待资源
先校准一个概念。死锁(Deadlock)并不是数据库独有的问题,操作系统、多线程编程里都有,本质就一句话:多个主体各自持有一部分资源,同时又在等待对方手里的资源,谁都不肯放手,于是形成循环等待。
MySQL InnoDB里出现死锁,最典型的场景是:
code复制事务A:持有订单表id=100的行锁,等待积分明细表的某行锁
事务B:持有积分明细表的某行锁,等待订单表id=100的行锁
两个事务就这么僵住了。InnoDB的死锁检测机制(innodb_deadlock_detect,默认开启)每隔一段时间会检查是否存在等待环,一旦发现就选择回滚代价较小的事务,让另一个事务继续执行。但问题在于——被回滚的事务在应用层会抛出异常,如果业务代码没有做重试机制,用户那边看到的就是“操作失败”。如果死锁频繁发生,接口超时率自然就上去了。
2.2 缺索引如何成为蝴蝶效应的起点
那么,缺索引和死锁到底怎么扯上关系的?
关键在于InnoDB的行锁机制。InnoDB的行锁是通过索引来定位和加锁的,这句话是理解整条因果链的核心。执行一条UPDATE/DELETE时,存储引擎要做的第一件事是找到目标行,而找目标行的方式决定了锁的范围:
- 走索引定位:精准命中某一行或某个小范围,只对命中的索引记录加锁。
- 全表扫描:只能一条条扫过去,每扫到一条符合条件的记录就加锁,而且因为不知道后面还有没有匹配的行,InnoDB还会对扫描过程中遇到的所有记录都加锁,再加上间隙锁(Gap Lock)来防止幻读,锁范围会被放大非常大。
回到我们的场景。
积分明细表 points_record 上有一个字段 user_id,业务上高频执行的是一条累计积分查询:
sql复制SELECT SUM(points) FROM points_record
WHERE user_id = ? AND expire_time > NOW();
结果这张表只在主键 id 上有索引,user_id 上没有任何索引。这个表数据量约2000万行,每次执行这条SQL都要全表扫描,平均耗时从几十毫秒一路劣化到2秒以上。
问题到这里还只是“慢”,离死锁还有一步之遥。接着往下走:
- 积分的发放是一个事务,先更新用户账户余额表,再向
points_record插入一条记录,最后查询该用户当前的积分总额(就是上面那条慢SQL)。 - 用户下单时,订单事务会扣减用户账户余额,同时读取积分明细做抵扣。
- 当并发请求上来时,积分发放事务因为慢查询持有锁的时间被大幅拉长,另一个订单事务持有了它需要的账户余额行锁,两边互相等待,死锁就这么形成了。
核心教训:慢查询坏的不仅是响应时间,它在事务里还会把锁的持有时间无限拉长,这才是生产环境里最致命的地方。
2.3 为什么平时测试没发现
这个案例里还有一个很值得思考的点:为什么这种问题在测试环境、预发环境都没暴露出来?
原因很简单:测试环境的数据量太小了。几千行数据,没有索引走全表扫描也就几毫秒,根本看不出来问题。只有上了生产,数据量到了千万级,索引缺失的代价才被放大到秒级。这也是我一直强调的——跟索引相关的性能验证,必须用“量级足够大”的数据来测,不能用测试环境的几条数据来判断SQL是否健康。
3. 事故排查实录:从报警到定位的全过程
3.1 第一反应:确认优先排查优先级
事故发生时,最忌讳的就是慌。我习惯按一个固定顺序去排查,能大幅缩小范围:
- 看监控大盘,确认影响范围(是单条SQL慢,还是DB整体负载高)。
- 看慢查询日志,找出拖垮数据库的元凶SQL。
- 看
SHOW ENGINE INNODB STATUS里的死锁日志,拿到死锁涉及的表和事务。 - 看
information_schema.innodb_trx,确认当前活跃的长事务。 - 结合业务代码,理清事务边界和SQL执行顺序。
这次事故前两步非常快。监控系统显示数据库CPU从10%飙升到95%,慢查询日志里堆满了那条 points_record 的累计查询,单次执行时间已经到2.3秒,扫描行数接近全表。到这里,问题的主线已经清楚了。
3.2 死锁日志的正确打开方式
再看死锁日志。很多同学遇到死锁,看到一堆英文就头疼,其实 LATEST DETECTED DEADLOCK 这段日志的信息量极大,关键字段就几个:
code复制*** (1) TRANSACTION:
TRANSACTION 421234567, ACTIVE 5 sec
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 123456, OS thread handle 123456789
UPDATE orders SET status = 'PAID' WHERE order_id = 'xxx'
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 33 page no 3 n bits 80
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 44 page no 5 n bits 72
*** (2) TRANSACTION:
TRANSACTION 421234568, ACTIVE 6 sec
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
INSERT INTO points_record ...
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 44 page no 5 n bits 72
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 33 page no 3 n bits 80
*** WE ROLL BACK TRANSACTION (2)
解读方法:
HOLDS THE LOCK(S):这个事务当前占着哪些锁。WAITING FOR THIS LOCK TO BE GRANTED:它在等谁的锁。WE ROLL BACK TRANSACTION (2):InnoDB选择回滚了哪个事务。
从日志里能清楚看到:事务1占了 orders 表的锁,在等 points_record 的锁;事务2占了 points_record 的锁,在等 orders 表的锁。典型循环等待。
但这只是表象。我们要回答的不是“死锁怎么发生的”,而是“为什么锁被持有这么久”。顺着事务1和事务2的SQL往业务代码里找,才发现事务2里有那条慢查询,这才是问题根源。
3.3 排查用到的核心SQL
这里把当时用到的排查SQL整理出来,以后遇到类似问题可以直接抄。
查看当前所有正在执行的事务:
sql复制SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
查看锁等待关系(8.0之后推荐用 performance_schema):
sql复制SELECT * FROM performance_schema.data_lock_waits;
或者用 sys 库的现成视图(更直观):
sql复制SELECT * FROM sys.innodb_lock_waits;
查看最近一次死锁日志:
sql复制SHOW ENGINE INNODB STATUS;
只看 LATEST DETECTED DEADLOCK 段就行。注意这个命令输出的内容很多,如果死锁发生很久了,日志可能已经被覆盖,所以事故现场一定要第一时间抓这个输出,越晚信息越少。
查看当前所有连接和正在执行的SQL:
sql复制SHOW FULL PROCESSLIST;
另外,performance_schema.events_statements_current 可以看每个线程正在执行的语句的历史,对定位“事务里到底执行了哪些SQL”很有帮助。
注意:MySQL 8.0里
information_schema.innodb_locks和innodb_lock_waits已经被弃用,新版本请用performance_schema.data_locks和data_lock_waits。如果你还在用5.7,旧表还是可用的。
3.4 锁等待问题 vs 死锁问题
排查过程中容易混为一谈的一个点是:锁等待和死锁不是一回事。
- 锁等待(Lock Wait):事务A等事务B释放锁,等了几秒甚至几十秒,最后超时失败。这是单向等待。
- 死锁(Deadlock):两个事务互相等待,形成环,InnoDB主动介入回滚。
这两者的处理方式完全不同。锁等待超时可以通过调大 innodb_lock_wait_timeout(默认50秒)来缓解业务报错,但死锁只能靠代码层面的重试机制来解决。不过从根因角度看,两者往往共享同一个病因:事务持有锁的时间过长。而事务持有锁时间过长,最常见的元凶就是慢SQL——尤其是事务内部的慢SQL。
4. 根因修复:加索引之外还要做什么
4.1 从执行计划到索引设计
定位到根因后,修复方案其实很清晰:给 points_record 的 user_id 加索引。但加索引不是无脑执行一条 CREATE INDEX 就完事,顺序很重要。
当时我先用 EXPLAIN 确认了查询的执行路径:
sql复制EXPLAIN SELECT SUM(points) FROM points_record
WHERE user_id = 12345 AND expire_time > NOW();
结果 type=ALL,rows=19876342,典型的全表扫描。
然后加索引。但这里有个细节:查询条件里有 user_id 和 expire_time 两个字段,到底加单列索引还是联合索引?我的判断依据是——先看区分度:
sql复制SELECT COUNT(DISTINCT user_id) / COUNT(*) AS selectivity
FROM points_record;
user_id 的区分度很高,每个用户对应的记录数不算多,这时候加单列索引就能把扫描范围缩到很小。加完 user_id 上的索引后,expire_time 的过滤其实是在索引基础上做一次回表过滤,行数已经非常小了,查询性能就能从秒级降到毫秒级。
但如果 user_id 区分度不高(比如一个用户对应几十万条记录),那就得考虑联合索引 (user_id, expire_time),让 expire_time 的过滤也走索引,减少回表次数。
最终执行:
sql复制ALTER TABLE points_record ADD INDEX idx_user_id (user_id);
加完后再看执行计划,type=ref,rows 从1987万降到几百。SQL执行时间从2.3秒降到8毫秒,事务持有锁的时间大幅缩短。
4.2 为什么不能只靠死锁重试机制
有人可能会说:“死锁偶发,我在业务代码里加重试不就行了?”理论上可以,但这是典型的“治标不治本”。
- 重试机制只能解决死锁状态的业务报错,但慢查询带来的锁等待依然存在,接口的P99延迟并不会因为重试而变好。
- 重试会放量,在慢SQL并发很高的情况下,重试反而加剧数据库压力,拖垮更多请求。
- 这种思路掩盖了真正的设计缺陷,下次换个场景(比如批量任务并发)还会爆发。
所以我的原则一直很明确:先治根因,再考虑兜底。重试机制可以加,但它是最后一道防线,不是主要解药。
4.3 顺手梳理其他索引隐患
这次事故还有一个收获:既然 points_record 这种表已经暴露出索引缺失的风险,那就不能只修这一处。我当时顺手做了一件事——把整个核心库的所有慢查询日志翻出来,找出所有 type=ALL 的语句,一个个过执行计划:
sql复制SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 50;
或者用 pt-query-digest 对慢日志做聚合分析:
bash复制pt-query-digest /var/log/mysql/slow.log | head -100
结果又找出两张小表的全表扫描查询,虽然目前数据量不大风险不高,但按照现在的增长速度,再过半年就是定时炸弹。与其等它爆炸,不如现在就补上索引。
4.4 上线加索引的正确姿势
生产环境加索引还有一个容易被忽视的坑:大表加索引会导致长时间元数据锁(MDL),阻塞其他DML操作。
points_record 当时是2000万行的表,直接执行 ALTER TABLE 会先把整张表拷贝一份再重建索引,期间所有写操作都会被堵住。我们当时用了 MySQL 8.0 的 ALGORITHM=INPLACE 来避免全表拷贝:
sql复制ALTER TABLE points_record
ADD INDEX idx_user_id (user_id),
ALGORITHM=INPLACE, LOCK=NONE;
ALGORITHM=INPLACE 表示不拷贝整表数据,LOCK=NONE 表示允许并发的DML操作。但要注意,这两个参数的可行性取决于表结构和版本,执行前先确认一下,避免语法报错后手忙脚乱。如果表特别大(上亿行),更稳妥的做法是先在备库加索引,再通过主从切换来上线。
5. 复盘感悟与防死锁体检清单
5.1 这次事故最值得反思的地方
复盘时我在想,为什么一张不太起眼的积分明细表,能引发这么大的事故?
原因在于:业务量的增长是缓慢而隐蔽的。每周加几万条数据,查询慢个几十毫秒,根本感知不到。但量变引起质变,当数据量跨过一个临界点,全表扫描的时间从“还能接受”变成“难以容忍”,同时并发量也在增长,两者叠加,系统就像被踩了油门却忘了换挡的汽车,迟早要爆。
慢SQL的排查不应该是等发生事故才做的事。日常巡检中就应该关注:
- 慢查询日志里的TOP N语句。
- 执行计划中
type=ALL的查询。 - 单条SQL扫描行数是否随着数据量增长而线性膨胀。
5.2 防死锁的实战体检清单
这次事故之后,我给自己总结了一份“防死锁体检清单”,分享出来:
| 检查项 | 检查方式 | 合格标准 |
|---|---|---|
| 事务内SQL是否走索引 | EXPLAIN 查看执行计划 |
type 不能是 ALL |
| 事务执行时间 | information_schema.innodb_trx 查看 trx_started |
事务持续时间不超过1秒 |
| 锁等待情况 | sys.innodb_lock_waits |
没有长时间锁等待记录 |
| 事务边界是否合理 | 代码Review | 事务内不能有RPC调用、外部API请求 |
| 并发事务是否按固定顺序访问表 | 代码Review | 避免两个事务以相反顺序更新同一组表 |
| 大表索引维护 | 定期巡检慢日志 | 无 type=ALL 的DML |
其中“事务边界是否合理”这句话展开一下。一个事务里如果夹带了HTTP调用、消息队列发送、甚至只是简单的 sleep,锁的持有时间就会被这些外部因素无限拉长。锁持有越久,死锁概率越高,这是铁律。所以事务里只放必要的数据库操作,其他事情统统挪出去。
5.3 InnoDB死锁检测的代价
还有一个容易被忽略的知识点。InnoDB的死锁检测是默认开启的(innodb_deadlock_detect=ON),它的工作机制是:每个事务在等待锁时,会检查等待图里是否存在环。但这个检查本身有代价——当并发事务数非常多时,死锁检测本身会成为性能瓶颈。
业内有一种激进做法:在高并发场景下关闭死锁检测,完全依赖 innodb_lock_wait_timeout 来兜底。但我个人不太建议这么干,除非你能保证事务都非常短,否则关掉死锁检测后,死锁事务可能会挂很久才被超时机制杀掉,反而更容易拖垮数据库。这次事故中死锁检测其实帮了大忙——它快速回滚了代价较小的事务,避免了整库卡死。让机制发挥它该有的作用,比绕开机制更重要。
5.4 最后再分享一个实操小技巧
如果你遇到无法立即停服务加索引的场景,有一个临时缓兵之计:用频繁查询来反向驱动索引的创建。当然这不是真的能凭空创建索引,而是想说——如果你暂时无法加索引,至少可以通过改写SQL来缓解:
对这次案例中的累计查询,可以在应用层做一层缓存,比如用Redis把每个用户的积分总额缓存起来,设置5分钟过期。这样大部分请求根本不会打到数据库,慢查询压力骤降。等窗口期再执行加索引操作。
我之前在其他项目里用过这个思路救急,效果立竿见影。但它只是过渡方案,缓存一致性、过期策略都要仔细设计,不能一缓了之。最终还是得把索引补上,才是长久之计。
另外索引不是越多越好,每个索引都有写放大和存储成本。索引设计的目标是用最少的索引覆盖最多的查询模式,核心表的核心查询路径要做到心中有数,而不是等到线上出了问题再回头补课。这次的经历就是最真实的教训——一条索引值不了几个钱,但它能省下的,可能是你一整夜的运维精力,和一批用户的信任。
