在并发环境下,锁等待和死锁是数据库常见的问题,也是让后端开发者最头疼的场景之一。只要用 InnoDB 并且有多事务并发修改同一行数据,锁等待就必然会发生;而死锁则是两个事务互相持有对方需要的锁,形成闭环。本节的重点不是讲理论,而是带你快速定位问题、看懂日志、找到根源。
22.2.1 锁等待的实时监控
锁等待的表现是:一条 UPDATE/DELETE 语句长时间没有返回,程序卡住,甚至连接池被耗尽。这时第一步就是确认当前是否有事务正在等待锁。
方法一:查看 InnoDB 标准监控输出(最常用)
执行 SHOW ENGINE INNODB STATUS\G,在输出中找到 TRANSACTIONS 段。它会列出当前所有活跃事务,并明确标出谁在等待谁。一个典型的锁等待片段如下:
---TRANSACTION 4212356784, ACTIVE 10 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 18, OS thread handle 1400..., query id 12345 localhost root updating
UPDATE inventory SET stock=stock-1 WHERE product_id=1001
------- TRX HAS BEEN WAITING 10 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `shop`.`inventory` trx id 4212356784 lock_mode X locks rec but not gap waiting
这里直接告诉你:事务正在对主键索引上的某条记录加排他锁,但需要等待。同时还会输出持有锁的事务信息,往往就在下方几行,显示该事务的状态和持有的锁。这个输出不需要额外安装工具,线上紧急排查时最常用。
方法二:查询 performance_schema 表(8.0 推荐)
MySQL 5.7 用的 information_schema.INNODB_LOCK_WAITS 在 8.0 中已被弃用,代之以 performance_schema 下的若干表。可以直接查锁等待链:
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query,
TIMEDIFF(NOW(), r.trx_wait_started) AS wait_age
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id;
这个查询会列出哪个会话在等锁,等的是哪个会话持有的锁,以及它们的当前 SQL。如果 blocking_query 是 NULL,说明那个阻塞事务当前没有正在执行的语句,很可能是开启事务后没及时提交而已。
要查更细的锁持有情况,还可以查 performance_schema.data_locks:
SELECT ENGINE_TRANSACTION_ID, OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
这些表在 8.0 中是默认开启采集的(除非 performance_schema 被关闭),并且比 SHOW ENGINE INNODB STATUS 更结构化,适合做监控脚本或可视化面板。
方法三:使用 sys 库视图(更人性化)
MySQL 8.0 自带的 sys 库提供了更易读的视图。例如 sys.innodb_lock_waits 直接展示等待关系:
SELECT waiting_trx_id, waiting_pid, waiting_query, blocking_trx_id, blocking_pid, blocking_query
FROM sys.innodb_lock_waits;
它会自动关联线程 ID 和当前执行语句,并计算等待时间,非常适合 DBA 日常巡检。
22.2.2 死锁的发现与日志解读
死锁和锁等待不同:锁等待是有人阻塞别人,死锁是双方互相阻塞形成环,InnoDB 会自动检测并回滚其中一个事务来解除僵局。当死锁发生时,应用端会收到错误:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
此时被回滚的事务需要业务代码捕获异常后重试,而排查死锁的根本在于找到死锁发生的原因——也就是两个事务分别以什么顺序加了什么锁。
开启死锁日志
默认情况下,死锁信息会打印到 MySQL 错误日志中(log_error 指定的文件),前提是 innodb_print_all_deadlocks 参数为 ON(8.0 默认就是 ON)。你可以确认一下:
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
如果为 OFF,建议改为 ON,这样每次死锁都会记录完整日志,是对线上问题复盘最宝贵的线索。
死锁日志解读步骤
死锁日志的结构非常固定,由 LATEST DETECTED DEADLOCK 开始。下面用一个真实简化的例子来解读:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-10 14:32:01 0x7f8b...
*** (1) TRANSACTION:
TRANSACTION 4212356, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8, ... query id 100 localhost root updating
UPDATE inventory SET stock=stock-1 WHERE product_id=101
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `shop`.`inventory` trx id 4212356 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `shop`.`inventory` trx id 4212356 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: ...
*** (2) TRANSACTION:
TRANSACTION 4212357, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 9, ... query id 101 localhost root updating
UPDATE inventory SET stock=stock-1 WHERE product_id=100
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `shop`.`inventory` trx id 4212357 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `shop`.`inventory` trx id 4212357 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: ...
*** WE ROLL BACK TRANSACTION (2)
解读要点:
- 找事务:日志中有 (1) 和 (2) 两个事务。每个事务都会列出
HOLDS THE LOCK(当前持有的锁)和WAITING FOR THIS LOCK(正在等待的锁)。 - 看锁对象:注意
RECORD LOCKS后的space id、page no,以及最后的PHYSICAL RECORD,这能告诉你锁的是哪张表的哪一行。例如 heap no 2 和 heap no 3,说明两个事务分别持有一行、等待另一行。 - 还原冲突过程:根据 HOLD 和 WAIT 画出等待图。在上例中:
- 事务(1) 持有 product_id=101 的行锁(HOLD),等待 product_id=100 的行锁(WAIT)。
- 事务(2) 持有 product_id=100 的行锁(HOLD),等待 product_id=101 的行锁(WAIT)。
二者互等,形成死锁。最后 InnoDB 回滚事务(2)。
- 看回滚哪个:
WE ROLL BACK TRANSACTION (2)表明事务(2) 被牺牲。应用程序需要对这个事务进行重试。
日志最后的 Record lock, heap no ... PHYSICAL RECORD 可以看到具体列的值,帮助你定位到业务上的哪条数据出了问题。
常见死锁场景
- 不同顺序访问资源:两个事务分别先更新 A 后更新 B,另一个先 B 后 A,这是最经典的死锁成因。解决方案是统一业务中的加锁顺序。
- 共享锁升级引发:事务先对某行加 S 锁,另一个事务加 S 锁,之后双方都想升级为 X 锁,就会死锁。例如
SELECT ... FOR UPDATE前先做了不加锁的查询,这种场景需要谨慎。 - 聚集索引与唯一索引的间隙锁冲突:在高并发插入不存在的数据时,唯一索引的间隙锁可能导致死锁。日志中会看到
lock_mode X locks gap before rec或insert intention字样。 - 外键未加索引:在子表上更新或删除时,如果外键列没有索引,InnoDB 会在父表上加间隙锁,容易引发死锁。
22.2.3 排查与处理的实用思路
遇到锁等待怎么办?
- 找到阻塞源:通过前面提到的
SHOW ENGINE INNODB STATUS或查询data_lock_waits,找到阻塞事务的线程 ID。 - 查看阻塞事务在干什么:
SELECT * FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = ?可以看到事务的开始时间、当前语句、锁等待与否等。 - 判断是否长事务:如果事务运行时间很长,很可能是应用开了事务却忘记提交,或者在做耗时操作(如调用外部接口)。这时需要联系业务方评估是否可 kill。
- Kill 阻塞事务(谨慎):用
KILL <thread_id>终止阻塞线程。如果事务无法回滚(已执行大量更改),回滚过程可能耗时较长并占用资源,要观察系统负载。
遇到死锁怎么办?
- 查看错误日志中的死锁记录,明确死锁涉及哪两张表、哪些索引,以及每个事务持有的锁类型。
- 结合业务逻辑回顾两个事务的执行时序,找出它们分别用到了哪些 SQL,以什么顺序读写数据。
- 根据场景优化:
- 调整事务内 SQL 的顺序,让所有事务按相同顺序访问资源。
- 能合并的 SQL 尽量合并,缩短事务持有锁的时间。
- 检查索引,确保 UPDATE/DELETE 通过索引精确定位,避免不必要的大范围锁(比如没索引导致行锁变表锁)。
- 如果死锁发生在间隙锁上,考虑隔离级别是否必要,是否可以改用读已提交(但会牺牲部分一致性保障)。
- 在应用层增加死锁重试机制:死锁是正常的并发现象,不可能完全消除,业务代码必须能够捕获死锁异常(错误码 1213)并重试整个事务。
监控告警建议
- 定期(如每分钟)检查
performance_schema.data_lock_waits是否有长时间等待(超过阈值,如 5 秒),发送告警。 - 监控
information_schema.innodb_trx中trx_started超过设定时间(如 60 秒)的事务,长事务不仅可能造成锁等待,还会阻碍 Undo Log 回收。 - 死锁本身不用特别告警(因为会自动解除),但应统计死锁频率。如果每分钟出现几十次,说明业务设计有严重问题,必须介入优化。
锁等待和死锁并不可怕,可怕的是面对问题时只能“重启试试”。只要掌握了监控手段和日志解读方法,大多数锁问题都能在几分钟内找到根因。保留一份清晰的监控和排查步骤贴在运维文档里,能让团队在凌晨故障时少掉很多头发。