1. 死锁是什么?为什么你的数据库会“卡死”?

想象一下,你和朋友在一条狭窄的走廊里迎面相遇,你们都礼貌地侧身想让对方先过,结果你们同时向左移,又同时向右移,反复几次,谁也没法通过,就这么僵持住了。MySQL里的死锁,跟这个场景几乎一模一样,只不过主角换成了数据库里的事务,而那条“走廊”就是它们都想访问的同一行数据。

我处理过不少线上数据库的“卡死”报警,很多时候应用突然超时、页面转圈圈,背后元凶就是死锁。简单来说,死锁就是两个或更多的事务,在执行过程中,因为竞争资源(比如同一条数据记录)而陷入了一种互相等待的循环。事务A锁住了记录1,想再去锁记录2;同时,事务B锁住了记录2,想再去锁记录1。结果就是,A在等B释放记录2,B在等A释放记录1,两个人(事务)大眼瞪小眼,谁也进行不下去,数据库引擎一看这情况,就知道“死锁”发生了。

为什么会出现这种尴尬局面呢?根据我这些年的经验,最常见的原因就出在应用代码的编写顺序上。比如,一个后台任务在更新用户订单表(先更新订单A,再更新订单B),而同时,一个用户在前端触发了某个操作,执行的逻辑是更新订单B,再更新订单A。当这两个操作在极短的时间内并发执行时,死锁的“完美条件”就凑齐了。除此之外,表上没有合适的索引导致全表扫描锁住大量记录、事务过大过长、或者使用了SELECT ... FOR UPDATE这样的语句但范围没控制好,都可能是死锁的导火索。理解死锁的本质,是我们解决它的第一步——它不是数据库的bug,而是一种在多线程并发环境下几乎必然会出现的一种状态,我们的目标不是消灭它(这几乎不可能),而是快速发现、精准分析并妥善处理它。

2. 实战第一步:如何快速发现和确认死锁?

当你的应用开始报超时错误,或者监控图表上数据库的活跃线程数异常飙升时,你的第一反应不应该是重启服务,而是立刻登录数据库,看看是不是死锁在作祟。这里有几个我常用的“侦查”命令,能帮你快速定位问题。

2.1 查看当前活跃进程:SHOW PROCESSLIST

这通常是排查问题的起点。在MySQL命令行里执行 SHOW FULL PROCESSLIST;,你会看到一个所有连接线程的列表。重点关注 State 和 Info 列。如果发现大量线程的 State 是“Waiting for table metadata lock”、“Waiting for lock”或者干脆是“Locked”,并且 Info 列显示它们正在执行的SQL语句涉及相同的表,那死锁的嫌疑就非常大了。这个命令能给你一个全局的视野,看看是不是有某个“慢查询”或者“大事务”堵住了后面一堆请求。

2.2 深入事务内部:查询 INFORMATION_SCHEMA.INNODB_TRX

SHOW PROCESSLIST 看的是连接线程,而 INFORMATION_SCHEMA.INNODB_TRX 这个系统表则直接揭示了InnoDB存储引擎层面所有正在运行的事务详情。执行 SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX\G(用\G替换分号可以纵向显示,更易读),你会看到每个事务的详细信息。

这里有几个关键字段你需要特别关注:

  • trx_id: 事务ID。
  • trx_state: 事务状态,如果是 LOCK WAIT,说明这个事务正在等待锁;如果是 RUNNING,则正在执行。
  • trx_started: 事务开始时间。如果一个事务运行了很长时间(比如几分钟甚至几小时),它很可能是“罪魁祸首”,持有着锁不释放。
  • trx_wait_started: 如果事务在等待,这里显示它开始等待的时间。
  • trx_mysql_thread_id: 这个就是对应 SHOW PROCESSLIST 里的 Id,是连接线程ID,也是我们后续执行 KILL 命令要用到的关键ID。
  • trx_query: 事务正在执行的SQL语句。这能直接告诉你这个事务在干嘛。

通过这个视图,你可以清晰地看到哪些事务被阻塞了(LOCK WAIT),以及是哪个长事务(看trx_started)可能阻塞了它们。这是分析死锁链条的核心依据。

2.3 获取死锁的完整“案发现场”报告:SHOW ENGINE INNODB STATUS

这是诊断死锁的“终极武器”。执行 SHOW ENGINE INNODB STATUS\G,在输出结果中,找到名为 LATEST DETECTED DEADLOCK 的部分。如果近期发生过死锁,MySQL会在这里保留一份最详细的记录。

这份报告就像一份犯罪现场勘查报告,它会告诉你:

  1. 发生时间:死锁发生的具体时间点。
  2. 涉及的事务:通常是两个事务(事务A和事务B)。
  3. 每个事务正在做什么:会显示它们最后尝试执行的SQL语句。
  4. 它们持有和等待的锁:精确到记录级别,告诉你事务A持有了哪条记录的锁(holds the lock),又在等待哪条记录的锁(waits for it);事务B也一样。正是这个“持有-等待”关系的循环,构成了死锁。
  5. 最终裁决:InnoDB引擎会选择其中一个事务作为“牺牲品”(WE ROLL BACK TRANSACTION),将其回滚,从而打破死锁,让另一个事务得以继续。

举个例子,你可能会在报告里看到类似这样的信息(已简化):

LATEST DETECTED DEADLOCK
------------------------
2023-10-27 14:05:00 0x7f8e12345670
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 100, OS thread handle 12345, query id 7890 localhost root updating
UPDATE `orders` SET `status` = 'shipped' WHERE `id` = 1  -- 事务1最后执行的语句

*** (1) HOLDS THE LOCK(S): ... (锁住了 id=2 的记录)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: ... (正在等待 id=1 的记录锁)

*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 3 sec starting index read
UPDATE `orders` SET `status` = 'paid' WHERE `id` = 2  -- 事务2最后执行的语句

*** (2) HOLDS THE LOCK(S): ... (锁住了 id=1 的记录)
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... (正在等待 id=2 的记录锁)

*** WE ROLL BACK TRANSACTION (2)  -- InnoDB选择回滚了事务2

看,这就非常清楚了:事务1锁了订单2,想更新订单1;事务2锁了订单1,想更新订单2。典型的循环等待死锁。InnoDB自动回滚了事务2,让事务1成功了。但你的应用会收到事务2失败的错误,需要做好重试或异常处理。

3. 常规解决手段:使用KILL命令终止阻塞进程

当你通过上面的方法定位到那个“罪魁祸首”——通常是那个运行时间极长、状态为Sleep但持有锁不释放,或者状态为Lock wait的线程时,最直接的干预手段就是使用 KILL 命令。这个命令就像是数据库的“强制结束任务”功能。

操作很简单,首先从 SHOW PROCESSLIST 或 INFORMATION_SCHEMA.INNODB_TRX 中找到你要终止的线程的 Id(或 trx_mysql_thread_id)。假设这个ID是 12345,那么就在MySQL命令行中执行:

KILL 12345;

执行后,这个连接会被终止,它正在执行的事务会被回滚,它持有的所有锁也会被释放。之后,其他被它阻塞的线程通常就能立刻继续执行了。你可以再次执行 SHOW PROCESSLIST 和检查 INFORMATION_SCHEMA.INNODB_TRX 来确认阻塞是否已经解除。

但是,这里有一个非常重要的“坑”,也是很多新手困惑的地方:KILL 命令不是立即生效的“秒杀”。 你执行 KILL 后,马上再去查进程列表,很可能会看到那个线程的状态变成了 Killed。这是什么意思?这并不意味着它已经死了,而是表示数据库服务器已经收到了终止这个连接的指令,正在后台执行“清理”工作。

这个清理工作可能包括回滚一个很大的事务(比如更新了上百万行数据),或者等待某个耗时的操作(比如磁盘I/O)完成到一个安全点。在这个过程中,这个线程依然会占用着连接,甚至可能依然持有部分锁。所以,如果你 KILL 了一个正在回滚大事务的线程,你可能会发现数据库的CPU或IO依然很高,并且阻塞可能还会持续一段时间。这是正常现象,你需要耐心等待数据库完成回滚。你可以通过监控 Innodb_rows_rolled_back 状态变量来观察回滚进度。

4. 当KILL也无效时:深入排查与高级解决方案

有时候,你会遇到更棘手的情况:执行了 KILL 命令,但线程状态长时间停留在 Killed,不见消失,阻塞依旧存在。或者,系统里有大量异常连接需要清理。这时候就需要一些更深入的排查方法和“组合拳”。

4.1 批量找出并生成KILL语句

面对多个僵死或异常线程,手动一个个查ID再KILL效率太低。我们可以利用系统表来批量生成KILL命令。一个非常实用的查询是找出所有运行时间超长的事务对应的线程ID,并直接生成KILL语句:

SELECT CONCAT('KILL ', trx_mysql_thread_id, ';') AS kill_command
FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60; -- 找出运行超过60秒的事务

执行这个查询,你会得到一列像 KILL 100;、KILL 101; 这样的结果。你可以把这些语句复制出来,批量执行,效率高很多。同样,你也可以结合 SHOW PROCESSLIST 的信息进行更复杂的过滤,比如只KILL来自特定IP、执行特定操作的用户连接。

4.2 检查系统级锁与元数据锁

如果KILL无效,线程一直处于Killed状态,我们需要怀疑是不是遇到了更底层的阻塞。除了InnoDB的行锁,MySQL还有表级锁和元数据锁(Metadata Lock, MDL)。

  • 表级锁:比如执行 LOCK TABLES ... WRITE 或者某些特定的DDL操作时,会持有表锁。你可以通过 SHOW OPEN TABLES WHERE In_use > 0; 来查看当前哪些表被显式锁住了。
  • 元数据锁(MDL):这是MySQL 5.5引入的,用于保护表结构(schema)的一致性。一个经典的死锁场景是:一个长查询(比如大表的全表扫描)正在读表,它持有了该表的MDL读锁;此时,另一个线程想修改表结构(如加索引、改字段),它需要获取MDL写锁,就会被阻塞。而如果后续又有新的查询想读这个表,它们会被排在DDL操作后面等待,也可能被阻塞。如果第一个长查询一直不结束,DDL和后续所有查询都会卡住。KILL 掉DDL操作后面的查询可能没用,因为根源是那个长查询。

排查MDL锁,可以查询 performance_schema(需要先启用)中的 metadata_locks 表,或者使用像 pt-deadlock-logger 这样的工具。对于疑似MDL锁导致的问题,通常需要找到并KILL掉那个持有MDL读锁的长查询(通过 SHOW PROCESSLIST 找执行时间很长的SELECT语句)。

4.3 终极重启与预防策略

如果所有SQL层面的操作都无法解决,线程始终处于 Killed 状态,且数据库已经严重不可用,那么作为最后的手段,你可能需要考虑重启MySQL服务实例。重启会强制清理所有连接和事务状态。但这是有代价的:所有未完成的事务都会丢失,可能造成数据不一致,业务影响巨大。因此,这必须是经过充分评估和业务协调后的决策。

与其总是救火,不如做好防火。预防死锁远比解决死锁重要。以下是我总结的几个核心预防策略:

  1. 保持事务短小精悍:事务越快结束,持有锁的时间就越短,发生冲突的概率就越低。避免在事务里执行网络调用、复杂的业务逻辑或长时间的计算。
  2. 以固定的顺序访问资源:这是解决文章开头“走廊相遇”问题的根本方法。在应用代码层面,确保所有需要更新多个记录(或表)的业务逻辑,都按照一个全局一致的顺序来访问它们。比如,总是先更新ID小的订单,再更新ID大的订单。
  3. 为查询创建合适的索引:确保你的WHERE、UPDATE、DELETE条件都能用到索引。没有索引会导致全表扫描,InnoDB会给扫描过的所有行加锁,极大增加锁冲突和死锁的概率。使用 EXPLAIN 命令检查你的SQL执行计划。
  4. 降低事务隔离级别:如果业务允许,可以考虑使用 READ COMMITTED 隔离级别,它比默认的 REPEATABLE READ 引入的锁要少一些(例如,会释放不符合条件的行的锁)。但这需要评估对业务一致性的影响。
  5. 使用乐观锁或悲观锁:对于高并发更新场景,可以考虑使用版本号(乐观锁)来替代 SELECT ... FOR UPDATE(悲观锁),减少锁的持有时间。
  6. 设置合理的锁等待超时:通过 innodb_lock_wait_timeout 参数(默认50秒)设置一个合理的锁等待超时时间。当一个事务等待锁超过这个时间后,它会自动回滚并报错,这可以防止线程无限期等待,但需要应用端做好错误重试。

处理MySQL死锁是一个从应急响应到根因分析,再到架构预防的完整闭环。每次遇到死锁,都不要仅仅满足于用KILL命令解决当下问题。一定要去分析 SHOW ENGINE INNODB STATUS 输出的死锁日志,找到根本原因,是索引问题就加索引,是业务顺序问题就调整代码顺序。把这些经验沉淀下来,你的系统才会越来越稳健。数据库的稳定性,往往就体现在对这些“魔鬼细节”的处理上。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐