MySQL异常“Lock wait timeout exceeded; try restarting transaction”深度排查与解决方案

在高并发的互联网应用中,数据库作为核心组件,其稳定性直接关系到整个系统的可用性。我们团队近期就遭遇了一次典型的MySQL锁等待超时问题,导致线上订单处理接口大面积失败,错误日志中充斥着 java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction 的异常堆栈。这个问题不仅影响了用户体验,也给我们敲响了警钟。本文将详细记录我们从发现问题、紧急止损、深入排查到最终根治的全过程,希望能为遇到同样问题的朋友提供一份详尽的参考手册。

一、问题现象与初步分析

1.1 错误日志

我们的应用基于Spring Boot + MyBatis框架,某天下午业务高峰期,监控系统突然告警,大量用户反馈订单提交后长时间无响应,最终失败。查看应用日志,发现了如下的关键错误信息:

Cause: java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
; SQL []; Lock wait timeout exceeded; try restarting transaction; nested exception is java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:259) ~[spring-jdbc-4.2.1.RELEASE.jar:4.2.1.RELEASE]
at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:75) ~[mybatis-spring-1.3.0.jar:1.3.0]
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:447) ~[mybatis-spring-1.3.0.jar:1.3.0]
...
at com.sun.proxy.$Proxy121.updateTmProcBase(Unknown Source) ~[na:na]

从堆栈信息可以清晰地看到,问题发生在执行 updateTmProcBase 这个MyBatis Mapper方法时,底层JDBC驱动抛出了 Lock wait timeout exceeded 异常。

1.2 字面原因理解

这个错误信息非常直白:锁等待超时。在InnoDB存储引擎中,默认的锁等待超时时间由参数 innodb_lock_wait_timeout 控制,其默认值为 50秒。这意味着,当一个事务(Transaction B)试图获取一个被另一个事务(Transaction A)持有的行锁时,它最多会等待50秒。如果在这50秒内,Transaction A仍未释放该锁,Transaction B就会被强制回滚,并抛出此异常。

可能引发此问题的场景通常包括:

  • 长事务:某个事务执行时间过长,持有锁迟迟不释放。
  • 高并发竞争:多个事务同时竞争修改同一行数据。
  • 分布式服务:不同服务实例操作同一数据库表,逻辑上形成了串行依赖。
  • 死锁:虽然死锁通常会被InnoDB的死锁检测机制自动发现并解决(报错为Deadlock),但在某些复杂场景下,也可能表现为长时间的锁等待。

我们的首要任务是找到那个“罪魁祸首”——那个持有锁却不释放的事务。

二、紧急排查与定位过程

排查锁问题的核心思路是:“谁在等?被谁堵了?”。我们需要利用MySQL提供的系统视图来透视当前的事务和锁状态。

2.1 第一步:查看当前所有连接和线程 (SHOW PROCESSLIST)

这是最直观的第一步。我们登录到MySQL服务器,执行以下命令:

SHOW FULL PROCESSLIST;

这条命令会列出所有当前活跃的连接线程。关键字段解释如下:

  • Id: 线程ID,可用于 KILL 命令终止线程。
  • User/Host: 连接来源。
  • db: 当前选中的数据库。
  • Command: 线程正在执行的命令类型,如 Query, Sleep
  • Time: 线程处于当前状态的时间(秒)。对于 Query,就是SQL执行了多久;对于 Sleep,就是空闲了多久。
  • State: 线程的详细状态,如 Sending data, Locked
  • Info: 正在执行的SQL语句(FULL 关键字能显示完整SQL)。

我们重点关注 Time 值很大且 StateLockedUpdatingQuery,以及 Time 非常大的 Sleep 线程(可能是未正确关闭的长连接,内部有未提交的事务)。

假设我们得到了如下(模拟)输出:

+----+------+------------------+------------+---------+------+----------+-------------------------------------------------------------+
| Id | User | Host             | db         | Command | Time | State    | Info                                                        |
+----+------+------------------+------------+---------+------+----------+-------------------------------------------------------------+
| 10 | root | localhost        | myapp_db   | Query   |    0 | starting | SHOW FULL PROCESSLIST                                       |
| 11 | app  | 192.168.1.100:5532 | myapp_db   | Sleep   | 1200 |          | NULL                                                        |
| 12 | app  | 192.168.1.101:5533 | myapp_db   | Query   |   55 | Locked   | UPDATE tm_proc_base SET status = 'PROCESSING' WHERE id = 100 |
| 13 | app  | 192.168.1.102:5534 | myapp_db   | Query   |   52 | Locked   | UPDATE tm_proc_base SET status = 'FAILED' WHERE id = 100    |
+----+------+------------------+------------+---------+------+----------+-------------------------------------------------------------+

从这里我们可以看到:

  • 线程12已经执行了55秒,状态为Locked,说明它在等待某个锁。
  • 线程13执行了52秒,同样在Locked状态。
  • 线程11是一个Sleep了1200秒(20分钟)的空闲连接,这很可疑!

Sleep 状态的线程本身不会持有锁,但如果它内部开启了一个事务但没有提交或回滚,那么它之前执行的DML语句所持有的锁依然有效。因此,线程11极有可能就是那个“幕后黑手”。

2.2 第二步:检查当前所有活跃的InnoDB事务 (INNODB_TRX)

为了验证我们的猜测,我们需要查看当前所有正在进行的InnoDB事务。执行以下查询:

SELECT * FROM information_schema.INNODB_TRX\G

\G 选项能让结果以更易读的垂直格式展示。关键字段包括:

  • trx_id: InnoDB内部的事务ID。
  • trx_state: 事务状态,如 RUNNING, LOCK WAIT
  • trx_started: 事务开始的时间。
  • trx_mysql_thread_id: 对应的MySQL线程ID(即 SHOW PROCESSLIST 中的 Id)。
  • trx_query: 事务当前正在执行的SQL语句。

假设我们得到如下输出:

*************************** 1. row ***************************
                    trx_id: 281474976710657
                 trx_state: RUNNING
               trx_started: 2026-04-24 08:40:00
     trx_requested_lock_id: NULL
          trx_wait_started: NULL
                trx_weight: 3
       trx_mysql_thread_id: 11
                 trx_query: NULL
...
*************************** 2. row ***************************
                    trx_id: 281474976710658
                 trx_state: LOCK WAIT
               trx_started: 2026-04-24 08:59:00
     trx_requested_lock_id: 281474976710658:100:3:2
          trx_wait_started: 2026-04-24 08:59:00
                trx_weight: 2
       trx_mysql_thread_id: 12
                 trx_query: UPDATE tm_proc_base SET status = 'PROCESSING' WHERE id = 100
...

这个结果证实了我们的判断:

  • 线程11 (trx_id: …657) 是一个已经运行了20分钟的 RUNNING 事务,但它当前没有在执行任何SQL (trx_query: NULL)。这表明它很可能在一个未提交的事务中“挂起”了。
  • 线程12 (trx_id: …658) 处于 LOCK WAIT 状态,它正在等待一个锁 (trx_requested_lock_id: ...),并且它的SQL正是我们日志里报错的那个更新语句。

现在我们知道了“谁在等”(线程12)和“谁可能堵了它”(线程11),但还不够精确。我们需要知道线程11到底持有什么锁。

2.3 第三步:分析锁与锁等待的关系 (data_locks & data_lock_waits)

重要提示:在MySQL 8.0及以上版本,旧的 INNODB_LOCKSINNODB_LOCK_WAITS 表已被废弃。我们应该使用 performance_schema 中的 data_locksdata_lock_waits 表。

首先,查看所有当前被持有的锁:

SELECT * FROM performance_schema.data_locks\G

然后,查看锁等待的依赖关系:

SELECT * FROM performance_schema.data_lock_waits\G

data_lock_waits 表的关键字段:

  • REQUESTING_ENGINE_TRANSACTION_ID: 请求锁的事务ID(即等待方)。
  • BLOCKING_ENGINE_TRANSACTION_ID: 持有锁并阻塞别人的事务ID(即阻塞方)。

通过关联这两个表和 INNODB_TRX 表,我们可以构建一个完整的锁等待链。

综合查询示例

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,
    b.trx_started AS blocking_started
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;

这个查询的结果会清晰地告诉我们,哪个事务(waiting)在等待哪个事务(blocking)释放锁。

假设我们得到如下结果:

+------------------+------------------+-------------------------------------------------------------+------------------+------------------+----------------+---------------------+
| waiting_trx_id   | waiting_thread   | waiting_query                                               | blocking_trx_id  | blocking_thread  | blocking_query | blocking_started    |
+------------------+------------------+-------------------------------------------------------------+------------------+------------------+----------------+---------------------+
| 281474976710658  | 12               | UPDATE tm_proc_base SET status = 'PROCESSING' WHERE id = 100| 281474976710657  | 11               | NULL           | 2026-04-24 08:40:00 |
+------------------+------------------+-------------------------------------------------------------+------------------+------------------+----------------+---------------------+

至此,真相大白!线程12的更新操作因为需要对 id=100 的行加锁,而该行已经被线程11在一个20分钟前开启的事务中锁定,所以线程12一直在等待,直到超时。

2.4 第四步:终极诊断工具 (SHOW ENGINE INNODB STATUS)

除了上述系统表,SHOW ENGINE INNODB STATUS 是一个功能强大的综合诊断命令,它会输出InnoDB引擎的详细状态报告,其中就包含最近的死锁信息和当前的事务列表。

执行:

SHOW ENGINE INNODB STATUS\G

在输出的 TRANSACTIONS 部分,你会看到类似 INNODB_TRX 的信息,但格式更紧凑。在 LATEST DETECTED DEADLOCK 部分,如果有死锁发生,会记录下详细的两个事务的SQL和锁信息。即使没有死锁,这个命令也能提供宝贵的上下文信息。

三、解决方案与验证

3.1 紧急处理:Kill掉阻塞源

在定位到阻塞源(线程11)后,最快速的恢复业务的方法就是将其终止。

KILL 11;

执行此命令后,线程11对应的事务会被回滚,它持有的所有锁都会被释放。此时,之前被阻塞的线程12和13就能立即获取到所需的锁,继续执行并完成事务。我们的订单接口在几秒钟内就恢复了正常。

3.2 根本原因分析与修复

紧急处理只是治标,我们必须找到为什么会出现这种长事务。

通过与开发团队沟通并检查代码,我们发现:

  • 一个后台批处理任务在处理某个特殊订单时,进入了复杂的业务逻辑分支。
  • 在这个分支中,由于一个外部HTTP调用没有设置超时,导致整个数据库事务被挂起,长达20分钟。
  • 该任务使用的是同一个数据库连接池,因此占用了连接并持有了事务锁。

修复方案

  1. 为所有外部调用设置合理的超时时间
  2. 优化事务边界:确保事务尽可能短小,只包裹必要的数据库操作。将耗时的非DB操作移出事务之外。
  3. 代码审查:增加对长事务风险的代码审查项。

3.3 验证修复效果

我们在测试环境中复现了该场景:

  1. 开启一个事务,执行一条 UPDATE 语句,但不提交。
  2. 在另一个会话中执行同样的 UPDATE
  3. 观察第二个会话是否会在50秒后报错。

然后,应用我们的修复代码(缩短事务、增加超时),再次测试。结果第二个会话能够正常执行,不再出现锁等待超时。

四、长期预防策略

为了避免此类问题再次发生,我们制定了以下最佳实践:

4.1 调整超时参数(谨慎使用)

虽然可以调整 innodb_lock_wait_timeout,但这通常是“掩耳盗铃”。将超时时间设得更长,只会让问题暴露得更晚,用户体验更差。我们建议保持默认的50秒,或者根据业务特性适当调低(如10-30秒),以便更快地失败和重试。

-- 查看当前值
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- 临时修改(全局,对新连接生效)
SET GLOBAL innodb_lock_wait_timeout = 30;

-- 永久修改,需写入 my.cnf 配置文件
[mysqld]
innodb_lock_wait_timeout = 30

4.2 监控与告警

建立对以下指标的监控:

  • 长事务:定期查询 INNODB_TRX 表,对运行时间超过阈值(如5秒)的事务发出告警。
  • 锁等待次数:监控 SHOW ENGINE INNODB STATUS 输出中的锁等待计数器。
  • 慢查询:优化慢查询是减少锁持有时间的根本。

4.3 应用层重试机制

在应用代码中,对捕获到 Lock wait timeout exceeded 异常的操作进行有限次数的自动重试。因为锁冲突往往是瞬时的,重试很可能成功。

// 伪代码示例
int retryCount = 0;
while (retryCount < MAX_RETRY) {
    try {
        // 执行数据库操作
        updateTmProcBase(...);
        break; // 成功则跳出
    } catch (SQLException e) {
        if (e.getMessage().contains("Lock wait timeout exceeded")) {
            retryCount++;
            Thread.sleep(100 * retryCount); // 指数退避
        } else {
            throw e; // 其他异常直接抛出
        }
    }
}

4.4 数据库设计优化

  • 确保有合适的索引:缺失索引会导致全表扫描,从而升级为表锁,极大增加锁冲突概率。
  • 统一访问顺序:在多个事务中,尽量以相同的顺序访问表和行,可以有效避免死锁。

错误做法

事务1: 更新 用户表

事务1: 更新 订单表

事务2: 更新 订单表

事务2: 更新 用户表

正确做法

事务1: 更新 用户表

事务1: 更新 订单表

事务2: 更新 用户表

事务2: 更新 订单表

总结

“Lock wait timeout exceeded” 是一个信号,它告诉我们数据库的并发控制出现了瓶颈。通过 SHOW PROCESSLISTINNODB_TRXperformance_schema.data_locks/waits 以及 SHOW ENGINE INNODB STATUS 这一套组合拳,我们可以像侦探一样精准地定位问题源头。然而,技术手段只是最后的防线,真正的解决之道在于良好的编码习惯、合理的架构设计和完善的监控体系。只有将这些结合起来,才能构建一个真正健壮、高可用的数据库服务。

Logo

电影级数字人,免显卡端渲染SDK,十行代码即可调用,工业级demo免费开源下载!

更多推荐