侧边栏壁纸
  • 累计撰写 149 篇文章
  • 累计收到 2 条评论

MySQL 突发 Lock wait timeout exceeded 报错?排查长事务未提交、行锁阻塞与死锁我踩过的几个坑

2026-10-3 / 0 评论 / 8 阅读
AI 摘要由 AI 生成

文章探讨了MySQL中“Lock wait timeout exceeded”错误的原因及排查方法。指出错误本质是事务间锁竞争,而非资源耗尽。文章详细介绍了如何通过分析锁等待和持锁事务定位问题,并强调了正确使用SHOW PROCESSLIST和sys库中的锁分析视图进行排查的重要性。

周二下午三点刚过,告警群里突然接连弹出十几个接口超时报警,核心下单和状态更新接口的响应耗时瞬间拉成了一条竖线。

翻开应用服务日志,满屏都是同一行鲜红的异常堆栈:

java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:130)
    at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
    at ...

对应的 MySQL 底层错误码是 ERROR 1205 (HY000)。

有同事第一反应是“数据库被刷崩了”,打算重启应用容器。但我随手查了下数据库监控,CPU 使用率不到 20%,内存水位正常,磁盘 IO 几乎没有压力,唯独连接池的活动连接数直线上升,全卡在等待状态。

这种时候盲目重启服务不仅没用,反而可能让阻塞扩散。Lock wait timeout exceeded 的本质不是数据库资源耗尽,而是两个或多个事务在争夺同一把行锁或者间隙锁,后来的事务等了太久,超过了 InnoDB 规定的超时阈值。

那天折腾完复盘,我把定位持锁源头、分析锁竞争以及应用层防护里最容易踩翻的几个关键点理了一遍。

应急排查:别死盯着 SHOW PROCESSLIST

业务已经报错的时候,第一要务是找出到底谁霸占着锁不松手,然后直接切断它的连接让业务止血。

很多人的习惯动作是登录 MySQL 敲一行:

SHOW PROCESSLIST;

或者查系统进程表:

SELECT id, user, host, db, command, time, state, info 
FROM information_schema.processlist 
WHERE command != 'Sleep' 
ORDER BY time DESC;

结果查出来一堆执行时间四五十秒、状态全是 Updating 或 Searching rows for update 的业务 SQL。你顺手把这几个超时的线程 kill 掉,业务接口依然源源不断报错。

为什么?因为这些显示正在等待的线程全是受害者,真正的元凶大概率根本不在这里。

在真实的业务场景下,持锁的事务早就把它的 SQL 执行完了,此时它所在的数据库连接处于 Sleep 状态,只是应用程序因为各种原因(比如事务没提交、中间卡在 RPC 调用里)迟迟没有执行 COMMIT 或者 ROLLBACK。

既然它的状态是 Sleep,你在上面那条过滤掉 Sleep 的排查语句里自然完全看不到它。

真正有效的定位命令

在 MySQL 5.7 和 8.0 里,直接查 sys 系统库提供的锁分析视图是最快也是最准的:

SELECT 
    waiting_pid,
    waiting_account,
    waiting_query,
    blocking_pid,
    blocking_account,
    blocking_statement,
    locked_table,
    locked_type,
    waiting_lock_mode,
    blocking_lock_mode
FROM sys.innodb_lock_waits;

这条查询会把“谁在等(waiting_pid)”和“谁在挡着(blocking_pid)”清清楚楚列成一行。

如果你用的是更底层的 information_schema 库,或者在某些精简安装的数据库里没有开启 sys 库,可以直接用下面这条 SQL 抓出当前所有正在运行的事务和它们的持锁情况:

SELECT 
    t.trx_mysql_thread_id AS blocked_thread_id,
    t.trx_id,
    t.trx_state,
    t.trx_started,
    TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS duration_sec,
    t.trx_rows_locked,
    t.trx_query,
    p.user,
    p.host,
    p.command,
    p.state
FROM information_schema.innodb_trx t
JOIN information_schema.processlist p ON t.trx_mysql_thread_id = p.id
ORDER BY t.trx_started ASC;

跑出来的第一行,往往就是霸占锁最久的那个连接。

当时我看到排在最前头的一个事务已经活跃了 480 秒,trx_rows_locked(锁定的行数)有 24 行,而它的 command 赫然写着 Sleep,对应的 trx_query 是一片空白。

拿到它的 trx_mysql_thread_id 是 18042,直接在控制台执行:

KILL 18042;

回车敲下的瞬间,后面排队的十几个等待事务立即拿到锁并成功提交,业务接口报错当场归零。

坑一:在 @Transactional 里调用远程 HTTP 接口

找到被杀掉的连接后,顺着线程绑定的应用 IP 和端口回去翻代码,很快就找到了祸根。

那段业务逻辑大致长这样:

@Transactional(rollbackFor = Exception.class)
public void handlePaymentCallback(PaymentCallbackRequest req) {
    // 1. 查出订单并加上行锁更新状态
    Order order = orderMapper.selectForUpdate(req.getOrderId());
    order.setStatus(OrderStatus.PAID);
    orderMapper.updateById(order);

    // 2. 调用第三方结算系统做对账校验(远程 HTTP RPC)
    SettleResult result = settleRemoteClient.verify(order.getAccount(), order.getAmount());

    // 3. 记录对账流水
    settleLogMapper.insert(result);
}

乍一看逻辑很通顺,但在高并发下这是最典型的长事务自杀行为。

第一步 selectForUpdate 和 updateById 一执行,InnoDB 就会对当前订单记录加上排他锁(X Lock)。

偏偏第二步调用的第三方结算系统刚好在那个时间段出现了网络抖动,原本 50 毫秒返回的接口硬生生拖到了 60 秒超时。

在这整整一分钟里,数据库连接一直被这个 Java 线程占着,事务挂起,这一行的排他锁也一直死死焊在数据库里。

此时用户的移动端或者前端页面因为等待超时开始疯狂重试,后续打进来的更新请求全部挤在这个订单的锁等待队列里。

InnoDB 默认的行锁等待超时时间(innodb_lock_wait_timeout)是 50 秒。只要前面的长事务卡过 50 秒,后面的每一个重试请求就会按部就班地抛出 Lock wait timeout exceeded。

代码里的规范其实很简单:远程 RPC、HTTP 调用、发送短信、第三方支付以及耗时复杂运算,绝对不能放进数据库事务的括号里。

正确的做法是先查数据、走完外部调用拿到确定结果,最后才开一个小事务,用最快的速度把数据库状态落盘并提交。

坑二:UPDATE 条件没建索引,行锁偷偷升级成全表排他锁

第二类坑更隐蔽。线上甚至只有两三个并发更新,业务代码里也没有慢 RPC,但依然会偶发撞上锁等待超时。

有一次我们的优惠券领取表跑批量更新,执行的 SQL 类似这样:

UPDATE user_coupon 
SET status = 1, used_time = NOW() 
WHERE user_id = 10086 AND coupon_batch_id = 'SPRING_2026';

研发同学认为:“我改的是 user_id 为 10086 的记录,别人改 user_id 为 10087 的记录,大家各改各的行,怎么可能会互斥超时?”

问题出在索引上。

当时这张表上只有一个 id 主键索引和一个按时间的联合索引,并没有在 (user_id, coupon_batch_id) 上建索引。

在 MySQL 默认的 Repeatable Read(可重复读)隔离级别下,InnoDB 为了保证事务隔离性以及防止幻读,执行 UPDATE 时依赖的是 Next-Key Lock(记录锁加上间隙锁)。

如果 WHERE 条件里没有命中索引,InnoDB 没法精确定位到某几行,就只能通过聚簇索引做全表扫描。

最致命的细节在于:在扫描整张表的过程中,MySQL 会给扫过的每一行记录以及所有行之间的间隙全部加上排他锁(X Lock)!

虽然 MySQL 底层有一个机制:在确定某一行不符合 WHERE 条件后,会尽早释放不相关行的锁。但在并发量稍大的情况下,全表扫描持有锁的窗口期极长。

如果另一个事务并发执行类似的 UPDATE 语句,无论它想修改的是哪一个用户的记录,只要全表扫描的锁路径发生重叠,后一个事务就会被硬生生卡在等待队列里,直到 50 秒超时报错。

避免这个坑的排查方式非常朴素:任何在线上执行的 UPDATE 和 DELETE,写完之后拿 EXPLAIN 跑一遍执行计划。

EXPLAIN UPDATE user_coupon 
SET status = 1, used_time = NOW() 
WHERE user_id = 10086 AND coupon_batch_id = 'SPRING_2026';

如果看到 type 是 ALL,或者 possible_keys 为空,这句 SQL 在高并发下随时就是一颗锁死整张表的定时炸弹。

坑三:大批量 DELETE/UPDATE 一把梭把间隙锁死

还有一种常见情况是定时脚本清历史日志或者跑批量迁移:

DELETE FROM operation_log WHERE created_at < '2026-01-01 00:00:00';

即使 created_at 字段上有索引,如果这一批需要删除的数据有几十万甚至上百万行,依然会酿成灾难。

一次性删除大量连续记录,InnoDB 会在整片索引范围上施加庞大的间隙锁(Gap Lock)。在这批数据提交之前,所有试图往这个时间段或者临近范围插入新数据的业务写入都会被阻塞挂起。

更糟糕的是,这么大的单次事务会产生海量的 undo log,导致从库复制延迟飙升。

处理这类历史数据清理,唯一的顺手做法就是把大事务拆成微事务,小步快跑:

# 每次只删 1000 条,配合短暂休眠释放锁资源
while true; do
    affected=$(mysql -e "DELETE FROM operation_log WHERE created_at < '2026-01-01' LIMIT 1000; SELECT ROW_COUNT();" -s -N)
    if [ "$affected" -eq 0 ]; then
        break
    fi
    sleep 0.05
done

每次只锁极小的区间,删完立刻提交,让出 CPU 和锁队列给线上核心业务,业务层根本感知不到后台正在删数据。

坑四:超时后只回滚了报错单句,上层重试引发死锁级联

这个坑是 InnoDB 官方行为规范里最反直觉的一个细节。

很多人以为,当一条 SQL 抛出 Lock wait timeout exceeded 异常时,整个数据库事务就已经自动回滚了。

事实恰恰相反。

在 MySQL 的默认配置下,InnoDB 发生锁等待超时时,仅仅会回滚最后那条超时的 SQL 语句,而当前事务中之前已经成功执行的语句并不会被回滚!

如果你的业务代码把数据库异常 catch 住后,仅仅打印了一行日志,或者直接拿同一个连接发起逻辑重试,前面那些语句所持有的行锁依然留在内存里。

旧锁不释放,重试的新 SQL 又申请新锁,原本简单的锁等待很快就会演变成更复杂的死锁交叉,甚至导致数据库连接池里的连接被这批“半死不活”的未完成事务迅速占满。

怎么根治这种悬挂事务

首先是数据库参数层面。如果你希望在发生锁超时的时候由内核强制回滚整笔事务,可以在 MySQL 配置文件里开启这个开关:

[mysqld]
innodb_rollback_on_timeout = ON

默认这个参数是 OFF(只回滚当前失败的语句)。改完需要重启 MySQL 实例生效。开启后,一旦出现 1205 错误,InnoDB 会主动替你做整单 ROLLBACK,避免出现残留锁。

其次是应用代码层面。不管框架怎么封装,捕获到包含 Lock wait timeout 的持久层异常时,不能只在循环里盲目重试,必须确保当前事务调用了明确的 connection.rollback() 或者触发了 Spring 的回滚传播链。

线上参数调优与日常防御

除了在业务逻辑上避开大事务和无索引更新,生产环境的参数配置也值得审视。

1. 缩短默认锁等待超时时间

MySQL 默认的 innodb_lock_wait_timeout = 50 秒对于现代 OLTP 高并发系统来说太长了。

在一个接口要求 200 毫秒返回的电商或金融系统里,让一个数据库连接傻等 50 秒毫无实际意义。上游的 Nginx、网关和前端早在 3 秒或者 5 秒的时候就已经超时掐断连接了,数据库却还在傻傻等待,最终白白占死连接池。

可以在全局或者会话级把超时时间压缩到 5 到 10 秒:

SET GLOBAL innodb_lock_wait_timeout = 5;

让拿不到锁的请求快速失败并把异常抛给上层降级,反而能有效防止单点慢事务拖垮整个集群。

2. 巡检长时间未提交的幽灵事务

防范胜于救火。在运维层面,挂一个定时脚本或者通过监控工具定期巡检活跃时间过长的事务:

SELECT 
    trx_id,
    trx_mysql_thread_id,
    trx_state,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
    trx_rows_locked
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 30;

只要发现有事务活跃超过 30 秒且连接处于 Sleep 状态,及时触发钉钉或者企业微信告警,通常能在业务接口大面积报错之前把问题扼杀在萌芽状态。

评论一下?

OωO
取消