做后台开发或者数据库运维的人基本都见过这个报错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_timeout1213死锁事务 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不是线程 IDkill 的对象只能是线程。2.2 用 system 视图直接看阻塞关系MySQL 从 5.7 开始提供了sys库里面的innodb_lock_waits视图把阻塞关系整理得非常清晰直接一条 SQL 就能看到谁在等谁SELECT * FROM sys.innodb_lock_waits\G输出里关键字段是这几列waiting_pid等待锁的会话线程 IDwaiting_query等待锁的那条 SQLblocking_pid持锁阻塞别人的会话线程 IDblocking_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_formatROW了所以这个改动在现代版本里风险相对可控。索引对锁冲突的影响更加隐蔽也更容易被忽视。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;如果看到typeALL就是全表扫描。解决办法是给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(ListOrder 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(DatabaseCallbackT 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这些手段当作应急止血再花时间去根治业务设计线上的锁冲突问题才能真正解决掉而不是反复在不同的报错之间打转。