☰
MySQL ERROR 1205排查指南:从锁等待超时到事务优化
2026/10/17 12:41:59 网站建设 项目流程

做后台开发或者数据库运维的人,基本都见过这个报错:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

第一次遇到的时候,我盯着屏幕上这行红字看了半天,第一反应是“我哪条SQL写错了?” 后来才发现,它不是语法问题,而是并发事务在抢锁的时候,等太久超时了。MySQL InnoDB 引擎会为行锁设置一个等待上限,默认是 50 秒。超过这个时间还没等到锁,就会直接抛 1205,告诉你“别等了,这个事务作废,自己看着办”。

这个问题在业务高峰期特别容易出现,尤其是秒杀、库存扣减、账务流水这类高并发更新场景。这篇文章我想把排查思路和根治方法完整写一遍,适合后端开发、DBA、以及所有在业务代码里写过事务的同学参考。不是贴几条命令就完事,而是把我实际踩过的坑、改过的方案、总结出来的避坑清单都整理出来。

1. 先搞清楚 ERROR 1205 到底是什么

1.1 InnoDB 的行锁和锁等待

MySQL 的 InnoDB 引擎为了保证数据一致性,在事务修改某一行数据时,会给这一行加上排他锁(X锁)。其他事务如果也想修改同一行,就必须等待前一个事务释放锁。这个“等待”本身是有时限的,由参数innodb_lock_wait_timeout控制,默认 50 秒。

我给个类比:你有一个座位,旁边有人坐了,你想坐就得等他站起来;但你不能无限期等下去,等了 50 秒他还没走,你就直接放弃,跟管理员报错“这位置我等不了了”。这个场景放在数据库里,就是 ERROR 1205。

这个机制设计的本意是避免一个事务长期霸占锁,导致其他事务无限阻塞。但问题在于,50 秒的默认值在很多业务场景下并不合理。它既可能太短——正常业务偶发阻塞超过 50 秒就报错;也可能太长——热点行被锁住时,几百个请求在后面排队,大家都傻傻等 50 秒,最终的结果就是雪崩。

要理解 1205,必须先分清两个关键后缀:try restarting transaction意味着当前这个事务已经被 MySQL 判定为“尝试重新执行”,但注意,它并不保证整个事务已经回滚。这一点后面我会详细说,因为它直接决定你在业务代码里应该怎么处理。

1.2 1205 和 1213 不是一回事

很多新手会把 1205 和死锁错误 1213 混为一谈,其实两者本质完全不同。

错误码含义发生条件
1205锁等待超时事务 A 占着锁,事务 B 在等,等待时间超过innodb_lock_wait_timeout
1213死锁事务 A 和事务 B 互相持有对方需要的锁,数据库检测到死循环,主动回滚其中一个事务

简单来说,1213 是两个事务“互相等”,数据库主动打破僵局;1205 是一个事务“单方面等”,等到不耐烦了自己放弃。排查思路上,1213 要关注“为什么会产生环”,而 1205 要关注“谁占着锁不放”。

还有一个容易被忽略的细节:innodb_lock_wait_timeout只针对 InnoDB 的行锁等待。如果你遇到的是lock_wait_timeout错误,那是另一套机制——元数据锁(MDL)的超时,发生在 DDL 语句被 DML 语句阻塞的场景,默认超时时间是 86400 秒,也就是整整一天。这两个参数名字长得很像,但解决的问题完全不同。排查的时候先分清楚是哪一种。

1.3 什么业务场景最容易触发 1205

我观察下来,线上环境触发 1205 的场景高度集中在下面几类:

第一类是热点行更新。比如一个爆款商品的库存,所有订单进来都要扣这同一个 SKU 的库存,那一行就是全系统最拥挤的“窄门”,并发稍高就会出现排队。

第二类是长事务。事务里有大量操作,或者包含网络请求、外部接口调用,导致事务迟迟不提交,行锁被长时间持有。一条 SQL 执行只要几十毫秒,但如果事务里有一个 2 秒的外部 HTTP 调用,这把锁就至少被握了 2 秒,其他人全在排队。

第三类是漏提交。代码里开了事务,中途 catch 住异常没有做 rollback,连接也没释放,事务就一直挂着。这种最气人,因为业务上看起来什么都没干,但锁已经占了好几分钟。

第四类是锁范围过大。本应该只锁 10 行,但因为索引设计不合理,变成了锁表里几千行甚至全表扫描,冲突概率指数级上升。

先判断你的场景属于哪一类,再去定位就会快很多。下面进入实操。

2. 定位:别瞎猜,先看数据库给的证据

2.1 第一个动作:查当前事务

遇到 1205,我建议你第一时间连到数据库上执行下面的 SQL,看当前有哪些活跃事务:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;

innodb_trx表记录的是当前正在执行的事务列表。重点看两个字段:trx_started表示事务开始时间,trx_query表示事务正在执行的查询。如果一个事务的trx_started已经是一个小时前,而且trx_query还是某个UPDATE语句,那不用说,它极有可能就是占着锁不撒手的那位。

有一个容易踩的坑:trx_mysql_thread_id才是这个事务对应会话的线程 ID,用了 kill 命令可以直接终止它。很多人会去查trx_id,然后拿trx_id去 kill,结果发现根本不生效。因为trx_id是事务 ID,不是线程 ID,kill 的对象只能是线程。

2.2 用 system 视图直接看阻塞关系

MySQL 从 5.7 开始提供了sys库,里面的innodb_lock_waits视图把阻塞关系整理得非常清晰,直接一条 SQL 就能看到谁在等谁:

SELECT * FROM sys.innodb_lock_waits\G

输出里关键字段是这几列:

  • waiting_pid:等待锁的会话线程 ID
  • waiting_query:等待锁的那条 SQL
  • blocking_pid:持锁阻塞别人的会话线程 ID
  • blocking_query:阻塞者正在执行的 SQL

实战中我一般习惯再加一个条件,只看等待时间超过 5 秒的:

SELECT wait_age, waiting_pid, waiting_query, blocking_pid, blocking_query FROM sys.innodb_lock_waits WHERE wait_age > '00:00:05';

这个查询能立刻告诉你“罪犯是谁”。拿到blocking_pid之后,可以再看看这个会话的历史状态,确认它到底是在执行正常的慢查询,还是已经处于空闲状态但事务没有提交。

2.3 判断是行锁问题还是事务本身就有问题

有时候 1205 只是表象,真正的问题是事务太慢。我见过一种情况:业务代码里同时更新了 3 张表,事务逻辑本身不复杂,但其中一张表的数据量已经上亿,一条UPDATE因为没有合适的索引,每次都要扫全表,单条 SQL 都要跑十几秒。这时候后面的请求自然全部堆积在锁队列里,超时报错。

所以第二步一定要看事务本身持续了多久:

SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_runtime_seconds, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx ORDER BY trx_runtime_seconds DESC LIMIT 20;

如果事务运行时间普遍很长,那问题大概率是长事务导致的锁持有时间过长。如果事务本身都很快,但锁冲突仍然严重,那就要考虑是不是热点行、锁粒度、或者应用层并发设计的问题。

在 MySQL 8.0 里,performance_schema.data_lock_waits是更底层的数据来源,sys.innodb_lock_waits本质上是基于它做的封装。如果你的环境没有sys库,可以退回去用information_schema.innodb_lock_waits这张老表,核心字段是requesting_trx_id和blocking_trx_id,用法逻辑一样。

3. 处理:先止血,再谈根治

3.1 应急手段:安全终止阻塞事务

定位出阻塞者之后,正常的应急操作是KILL掉它的会话连接:

KILL <blocking_pid>;

但这里面有几个细节必须注意,都是实战中的教训:

第一,不要一上来就 kill。先看阻塞事务的trx_started。如果它是几秒前刚开的事务,可能只是正常的业务高峰,kill 掉反而影响用户体验。我一般以 30 秒为界:事务运行超过 30 秒且其他事务在排队等待,才会考虑 kill。

第二,kill 之前确认影响范围。一个连接可能同时起着多个事务?不,一个连接同一时刻只有一个活跃事务,但一个事务里可能涉及多条 SQL。kill 掉的是整个连接,后续这个事务的所有操作都会中断。如果这个连接同时在执行一个重要的批量导入或者报表计算,kill 会造成额外损失。

第三,kill 的权限。普通账号只能 kill 自己的会话,kill 别人的会话需要具有CONNECTION ADMIN或SUPER权限。线上巡检账号如果权限不足,会直接报错,这不是数据库问题,是账号权限问题。

第四,关注复制链路。如果 MySQL 使用了主从复制,kill 一个正在主库执行长事务的会话,可能导致 binlog 写入中断,从库延迟。遇到这种情况,我建议先跟业务团队确认是否可以等待,而不是直接 “一刀切”。

3.2 参数调优:innodb_lock_wait_timeout 到底调不调

很多人一遇到 1205 就习惯性把innodb_lock_wait_timeout从 50 秒调到 120 秒,觉得“等得久一点就不报错了”。这个思路在低并发场景下可能有效,但在高并发场景下是灾难。

为什么?因为锁等待不是排队买车票。每个等待的请求都占着一个数据库连接,连接池的资源是有限的。假设连接池有 200 个连接,100 个连接都在等一把锁,等待 120 秒,那这 100 个连接在 120 秒内什么都干不了。新请求进来发现连接池满了,直接报 connection pool timeout,问题从数据库层蔓延到应用层。

我个人更推荐的做法是:热点业务把 timeout 调小,比如 5 到 10 秒;非热点业务维持默认或稍微加大。让这些拿不到锁的请求快速失败,然后由应用层重试,比集体阻塞 50 秒再失败要科学得多。

修改参数有两个层面。动态调整:

SET GLOBAL innodb_lock_wait_timeout = 10; SET SESSION innodb_lock_wait_timeout = 10;

注意SESSION级别的设置只对当前会话生效。如果应用层连接池里的连接是长期复用的,你改了GLOBAL但旧连接可能还是旧值。稳妥的做法是修改配置文件my.cnf,放进[mysqld]段:

innodb_lock_wait_timeout = 10

然后重启数据库实例。不过重启数据库属于运维大操作,直接影响线上可用性,一定要安排在业务低峰期,并且提前确认参数生效情况。

第三个参数是双刃剑,还有一个相关参数innodb_rollback_on_timeout默认是 OFF。这意味着什么?我举个例子:你开了一个事务,里面执行了三条 UPDATE,第三条在等待锁时超时了。MySQL 默认情况下只回滚第三条语句本身,前面两条的修改仍然保留,事务还处于 ACTIVATE 状态。这是一个极隐蔽的坑,业务代码里如果没有显式 catch 后调rollback(),整个事务就会变成“半成品”,数据处于不一致状态。所以 1205 之后,业务侧必须明确执行回滚,不能直接把异常吞掉就完事。

3.3 隔离级别和索引设计对锁的影响

MySQL 默认的事务隔离级别是 REPEATABLE READ,它除了锁住命中的行,还可能锁“间隙”——也就是两个索引值之间的空位,这就是常说的间隙锁。间隙锁带来的典型问题是:即使你只是更新了一行,相邻范围的数据插入也可能被阻塞。很多场景下,把隔离级别改成 READ COMMITTED 就能大幅减少锁冲突。

修改隔离级别的 SQL 是:

SET GLOBAL transaction_isolation = 'READ-COMMITTED';

但要注意前提条件:如果是主从架构,binlog 格式必须是ROW,因为 READ COMMITTED 配合 STATEMENT 格式会产生复制数据不一致的风险。修改 binlog 格式已经属于 DDL 级别的大变更,需要业务停写维护。实际上现在新部署的 MySQL 8.0 基本都默认binlog_format=ROW了,所以这个改动在现代版本里风险相对可控。

索引对锁冲突的影响更加隐蔽,也更容易被忽视。InnoDB 的行锁是基于索引的:如果一条 UPDATE 的 WHERE 条件没有走索引,InnoDB 只能扫描聚簇索引的所有记录,给每条扫描过的记录都加上锁。表面上你只想更新一行,实际上锁了全表。举个实际案例:

UPDATE order_detail SET pay_time = NOW() WHERE order_no = 'ORD123456';

如果order_no建了唯一索引,这条语句只锁一行;如果没有索引或者索引失效,它会把整张表的所有记录都扫一遍,顺带把所有记录的行锁都加上。一个 100 万行的表,后续任何写操作都会撞上锁。排查时只需要看执行计划:

EXPLAIN UPDATE order_detail SET pay_time = NOW() WHERE order_no = 'ORD123456';

如果看到type=ALL,就是全表扫描。解决办法是给order_no加索引:

ALTER TABLE order_detail ADD INDEX idx_order_no (order_no);

这一条索引带来的收益,远远大于把timeout调大 200 秒。

4. 根治:让应用层少踩锁竞争的坑

4.1 事务里到底能不能做远程调用

这是我现在写业务代码时的一条铁律:事务里绝对不能有远程调用,包括 HTTP 请求、RPC 调用、消息队列发送、外部缓存写入。

很多人写代码时没意识到问题的严重性。举个例子,一个事务要做三件事:扣用户余额、生成订单、调用第三方支付接口验单。整个事务从 begin 到 commit,数据库连接和行锁会一直持有。如果第三方接口响应慢,从 500 毫秒拖到 5 秒,数据库连接就白白占了 5 秒,行锁也握了 5 秒。在这个窗口期,所有想改同一行数据的请求全部排队,排队时间超过 50 秒的就会报 1205。

正确做法是:先完成本地数据库操作并提交事务,拿到事务结果后再去调远程接口。如果远程接口失败,通过消息队列做补偿或者反查对账,而不是在持有锁的情况下等待外部系统结果。

我见过一个比较典型的“事务内循环”反模式,伪代码长这样:

@Transactional public void batchProcess(List<Order> orders) { for (Order order : orders) { orderService.updateStatus(order); // 每条订单一次数据库交互 externalService.notify(order); // 每次还要调远程接口 } }

循环 1000 条订单,就是 1000 次数据库交互加上 1000 次远程调用,这个事务不超时才怪。改造的手段有两个方向:一是把批量处理拆分成单条独立事务,每条订单自己提交,不互相拖累;二是把远程调用挪出事务,事务里只做本地数据更新,之后用异步任务统一通知。

4.2 批量更新的正确打开方式

就算事务里没有远程调用,一次性更新几万行仍然容易出问题。因为单条 UPDATE 对大量行加锁的时候,整个过程的锁持有时间取决于扫描和修改的总耗时,期间所有相关行都处于锁定状态。

我常用的批量更新策略是“分批 + 限流 + 小停顿”。以清理过期状态为例,核心逻辑是把一个大的 UPDATE 拆成若干个小批:

UPDATE order_tab SET status = 2 WHERE status = 1 AND id > ? ORDER BY id LIMIT 500;

执行完一批之后,记录当前的最大 id,作为下一批的起点,循环往复。每批之间加个小停顿,比如SLEEP(0.1),让心数据库喘口气,也避免锁的连续冲击。

分批的批次大小没有绝对标准,需要看单行数据大小和硬件能力。我通常从 200 行开始试,观察单批的执行时间和锁等待情况。如果单批执行在 50 毫秒以内,可以适当加大;如果超过 300 毫秒,就拆小一点。

这里必须提一个坑:在 MySQL 里直接配合LIMIT使用 UPDATE 是合法的,但如果你是在主从复制架构下执行这种分批更新,要注意从库的apply速度可能跟不上,导致主从延迟。是自己写脚本循环跑,还是借助数据库的存储过程,要根据线上实际的复制延迟监控去判断。

4.3 给业务代码加一层重试保护

应用层面对 1205,最直接的做法是捕获异常并重试。但重试不是无脑重发,必须讲策略。

首先要在代码里区分 1205 和 1213。两者虽然都希望业务重试,但在事务的处理上不同。1205 可能出现“部分语句已执行、部分未执行”的情况,必须显式rollback()之后再重试。1213 相反,MySQL 已经主动回滚了整个事务,直接重试即可。

Java 伪代码示例:

public <T> T executeWithRetry(DatabaseCallback<T> action, int maxAttempt) { int attempt = 0; while (attempt < maxAttempt) { try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try { T result = action.execute(conn); conn.commit(); return result; } catch (SQLException e) { conn.rollback(); if (e.getErrorCode() == 1205 || e.getErrorCode() == 40001) { attempt++; if (attempt >= maxAttempt) { throw e; } try { Thread.sleep(200L * attempt); // 退避递增:200ms、400ms、600ms... } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; } } else { throw e; } } } catch (SQLException e) { throw new RuntimeException("数据库连接异常", e); } } throw new RuntimeException("重试次数耗尽"); }

几个细节:

  • Connection必须是每次重试都重新获取,不能复用上一个失败事务的会话。因为事务失败后,连接可能处于异常状态,直接复用在脏会话上执行 SQL 会得到不可预期结果。
  • 重试次数不要超过 5 次,等待时间走指数退避,避免重试流量叠加。
  • 重试只适合偶发的锁冲突。如果同一个操作连续重试 3 次以上都碰到 1205,大概率是热点行或者事务设计问题,这时候再多的重试只会加剧系统压力,应该立刻告警出来人工介入。

5. 复盘:一次线上 1205 的完整排查过程

5.1 案例:热点商品库存扣减引发的大面积超时

某个电商平台的商品详情页接口在晚上 8 点大促时出现大面积报错,日志里全是 1205,报错集中在库存扣减服务上。核心流程是用户下单后,事务内同时执行“扣减商品库存”“生成订单”“更新商品销量”。

当时的排查路径是这样的:

第一步,连接数没满,但数据库 CPU 不高,说明不是数据库资源瓶颈。

第二步,查sys.innodb_lock_waits,发现几十个等待会话全在等同一个 pid 上的锁。

第三步,看innodb_trx,阻塞事务的trx_started显示它已经跑了 6 秒,正常情况这个事务 100 毫秒就该完成。继续查这个事务的 SQL 历史,发现它在事务里调用了第三方物流接口查运费,接口超时重试了 2 次,白白浪费了 4 秒多。

第四步,确认了根因:事务里包含外部依赖,外部接口响应慢导致行锁被长时间持有,流量一高,后面的请求全部超过 50 秒的等待阈值。

当时做的处置和改造是:把远程接口调用从事务里挪到事务提交之后,失败走消息队列补偿;对库存扣减使用基于 Redis 的预扣减方案,数据库扣减改成异步批量落库;innodb_lock_wait_timeout从 50 秒调整到 10 秒,让拿不到锁的请求快速失败并走前端重试。改造之后,大促期间 1205 的报错基本归零。

这个案例特别典型,它就是所有 1205 排查思路的一个缩影:先看等待关系,再定位阻塞事务,分析事务为什么会持有锁那么久,最后从应用层设计上做根治。

5.2 共性避坑清单

把几次实战的教训整理成一张清单,写在这里,照着逐条检查基本不会漏:

误区正确做法
盲目把innodb_lock_wait_timeout调大高并发下缩短超时时间,让请求快速失败
一看到 1205 就 kill 所有线程先确认blocking_pid,只终止确认的阻塞者
忽略trx_started就判断事务过长必须以事务开始时间和当前时间为准
1205 后不显式 rollback 整个事务默认只回滚超时的语句,半提交状态最危险
事务里调第三方接口事务外完成外部依赖,失败用补偿机制兜底
大批量 UPDATE 一把梭分批 + LIMIT + 停顿,限制锁持有时间
不加索引导致全表锁给 WHERE 条件字段建立合适的索引
不加监控直接上线提前做好锁等待监控和长事务告警

5.3 监控预警与长效机制

1205 这类问题,靠人肉排查永远是被动的,监控告警必须前置。我最常用的几个监控指标:

第一,performance_schema采集锁等待事件。可以定期查询data_lock_waits的等待时长,如果锁等待次数在短时间内暴增,立刻告警。

第二,innodb_row_lock%状态变量。MySQL 提供了一组行锁统计信息:

SHOW STATUS LIKE 'innodb_row_lock%';

重点关注innodb_row_lock_waits和innodb_row_lock_time_avg。前者是累计等待锁的次数,后者是平均等待毫秒数。如果平均值持续升高,说明锁竞争在加剧。

第三,长事务监控。定时采集information_schema.innodb_trx,把运行超过 60 秒的事务记录到监控表,超过 300 秒的直接告警,按业务方通知处理。很多生产事故在真正爆发之前,长事务其实已经连续出现好几天了,只是没人看。

第四,慢日志和错误日志的联动。1205 出现时,错误日志里通常没有太多有用信息,但它出现的时间点可以与业务响应时间曲线做叠加分析,帮我们判断是不是某个接口或者某个特定的数据库操作引发的。

最后,还是要强调一下代码评审的重要性。每一段把外部调用放进事务的代码,每一句没有索引支撑的 UPDATE,都会是未来线上 1205 的种子。与其事后排查,不如在代码评审和开发规范阶段就把这些问题摁住。

最后分享一点个人体会

做了这么多年的数据库排障,我的感觉是:1205 这个报错本身并不可怕,它更像是一个“烟雾报警器”,真正的问题往往在烟雾后面——可能是事务边界划得太大,可能是索引悄悄失效,可能是外部系统的响应在拖后腿,也可能是流量高峰下热点被无限放大。

我踩过几次坑之后,现在遇到 1205 已经不会慌着改配置了,而是先按“谁在等、谁在占、为什么占这么久、能不能不等”这个顺序想一遍。大多数场景最后都会落到应用层代码的调整上,而不是数据库参数的微调。先把kill、timeout这些手段当作应急止血,再花时间去根治业务设计,线上的锁冲突问题才能真正解决掉,而不是反复在不同的报错之间打转。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询