
1. 1205到底在说什么一条SQL等锁等了太久被超时机制拦了下来先说一个我印象特别深的故障场景。某天晚上业务高峰期监控群里突然炸锅大量订单状态更新失败日志里反复出现同一个错误码——1205 Lock wait timeout exceeded; try restarting transaction。更麻烦的是这个报错不是一两条而是持续了十几分钟像滚雪球一样越滚越多最后不得不先停掉部分任务才缓过来。当时第一反应是哪个SQL把表锁住了但查下来发现根本不是锁表而更像是有人在一条事务里占着几行记录迟迟不交出去后面的更新请求全堵在它后面排队。这个排队不是无限排的MySQL有一个专门的配置项叫innodb_lock_wait_timeout默认值是50秒。也就是说一条SQL在尝试获取锁时如果等了50秒还拿不到引擎就会果断放弃并抛出一个MySQL 1205错误告诉你我不想等了你自己想想怎么办。要注意这个超时统计的是等待锁的时间不是SQL执行时间。就算一条SQL本身只要10毫秒就能跑完但如果前面有别的会话锁住了它需要的行它一样要等。等多久不由它说了算而由innodb_lock_wait_timeout这个全局参数说了算。如果你直接拿这个报错去搜很多资料会笼统地说检查有没有锁表、kill阻塞事务但实际上1205的处理远比这个细致。它涉及事务隔离级别、索引命中情况、并发更新顺序、甚至连接池和SQL执行时长等多个环节。这篇博文我不打算讲教科书式的定义而是把从看到报错到定位阻塞源头再到彻底根治的整个链路结合我这些年踩过的坑一篇讲透。在讲具体排查之前先明确一个关键点1205不是死锁。死锁的报错是1213 Deadlock found when trying to get lock两者的处理思路完全不同。1213出现时InnoDB引擎会自动回滚其中一个事务来解除僵局而1205出现时MySQL只是通知你超时了并没有替你做什么补救动作。死锁是大家都卡死引擎出面解决锁等待超时是大家都在排队排到你超时了你被踢出队列。这个本质区别决定了后面的所有操作处理1205的核心不是让引擎自动恢复而是手动找出那个占着茅坑不拉屎的事务把它处理掉。2. 什么场景最爱报1205我在生产环境里遇到过的四类高频触发场景理论说再多不如看真实场景。我梳理了在生产环境里反复出现1205的四类典型场景每个都是我实际遇到过、并且最后定位到根因的。你可以对照自己的业务看看有没有踩中类似的坑。2.1 开了事务忘了提交最隐蔽、也最常见这是所有1205场景里最常见的一种。业务代码里写了一个事务里面做了更新操作但程序在后续逻辑中抛了异常而异常处理没有回滚事务更致命的是连接一直保持打开状态。这种情况下事务没有结束占用的行锁就一直不释放。举一个我真实排查过的例子。一个库存扣减接口写成这样Transactional public void deductStock(Long productId, Integer count) { // 1. 扣库存 stockMapper.deduct(productId, count); // 2. 写流水 logMapper.insert(..., new RuntimeException(模拟异常)); // 3. 调用外部发货系统 shippingClient.ship(productId); }第三步调外部接口延迟特别高要十几秒。如果用户请求量大前面的事务还没结束后面的扣库存SQL全堵在第一步的UPDATE锁上。等到第50秒后面排队的事务就抛1205了。这个案例中1205的直接原因不是某一条SQL跑得慢而是事务整体持续的时间太长导致行锁被长期占据。这种情况在autocommit0的会话里更容易出现——如果代码里手动BEGIN之后忘了COMMIT那锁就会一直挂着。2.2 索引设计不当行锁升级成伪表锁InnoDB的锁机制是这样的只有在走索引进行数据定位的时候才会把锁精准地加到对应的索引记录上。如果一个更新操作走不了索引需要全表扫描定位目标行那么InnoDB就会对扫描到的每一行都加锁实际上会对所有扫描的记录加锁并在最后统一决定是否释放。更麻烦的是当更新条件无法命中索引时锁冲突的概率成倍上升。比如这么一条SQLUPDATE order_table SET status PAID WHERE order_no SO20240101001;如果order_no列没有索引MySQL为了找到这一行只能把整张表扫一遍。扫描过程中所有被碰到的记录都会加上锁。如果两个事务同时执行类似的更新扫到的范围一重叠后到的事务就只能在锁上等待。我之前接手过一个系统order_no字段明明在业务里用得非常多但因为历史原因一直没建索引。上线初期数据量小问题不明显等数据涨到千万级之后1205的频率突然飙升。后来给order_no加了一个普通索引报错立竿见影地消失了。2.3 多个事务按不同顺序更新同一批数据这类场景在批量处理任务里特别常见。比如一个定时任务把所有订单按状态流转一遍另一个后台任务又按订单号处理同一批订单。两个任务并发执行但加锁的顺序不一样。假设订单A和订单B都需要更新任务1先更新A再更新B锁的获取顺序是A→B任务2先更新B再更新A锁的获取顺序是B→A。当任务1拿着A的锁、任务2拿着B的锁时两个任务就互相耗上了。任务1等B任务2等A谁都不放手。这里就出现了一个很有趣的现象这种互相等待如果最终形成了环InnoDB会自动检测并抛1213死锁但如果还没有形成严格的等待环只是你等我一下我等你一下的错峰排队最终结果往往就是一个事务等超时报1205。要说怎么区分其实从InnoDB的报错日志里能看到线索1213会明确告诉你Deadlock found并且列出受害事务1205则只是Lock wait timeout exceeded不会说明谁是加害者需要你自己去找。2.4 DDL和DML的锁竞争这算是一个比较冷门但真会发生的场景。在MySQL 8.0里执行ALTER TABLE时默认使用INPLACE算法理论上可以允许一部分DML并发执行但仍然会在特定阶段获取排他锁。如果DDL操作的表数据量大或者DDL过程中有长时间查询堵在前面后面的普通更新就会排在DDL后面等待。有一次我执行一个ALTER TABLE给表加字段因为表有四亿行数据在线变更跑了快十分钟。这期间这张表上的更新操作全部堵住客户端连接数直接打满后续所有请求都报1205。处理这类问题我的建议是大表DDL尽量用pt-osc或gh-ost这类在线变更工具不要直接裸执行ALTER TABLE实在要直接执行放在业务低峰期并且先用SHOW PROCESSLIST确认没有长事务。3. 完整的排查链路从报错日志一路定位到阻塞源头现在到了这篇文章最核心的部分——遇到1205之后到底怎么一步步排查。我尽量把每一步的命令和判断逻辑都写清楚你照着走就能定位到问题。3.1 第一步确认错误现场和影响范围拿到1205报错先别急着登录数据库。先问自己三个问题报错是偶发的还是持续的报错集中发生在哪张表、哪条SQL报错时间点前后有没有做过变更发布、DDL、批量任务这三个问题能帮你快速缩小排查范围。如果是偶发的多半是某一次长事务碰巧撞上了如果是持续的那基本上可以断定有事务一直占着锁没有释放。MySQL的SHOW ENGINE INNODB STATUS命令是排查锁问题的第一站。执行它然后在输出内容里搜索LATEST DETECTED DEADLOCK或者TRANSACTIONS这一段SHOW ENGINE INNODB STATUS\G输出内容会比较长重点看TRANSACTIONS小节里面会列出当前所有活跃事务、它们持有的锁和等待的锁。这个命令能给你一个全局视野但它不是最精确的定位工具。3.2 第二步用information_schema查当前事务快照SHOW ENGINE INNODB STATUS是宏观视角接下来要用数据字典表看微观细节。下面这条SQL是MySQL 5.7和8.0通用的能查出所有当前正在运行的事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx ORDER BY trx_started ASC;这里有几个关键信息trx_started事务开始时间。如果事务已经跑了很久比如超过一分钟那它很可能就是阻塞源头。trx_mysql_thread_id这个事务对应的会话线程ID后面KILL要用。trx_query事务当前正在执行的SQL。如果是NULL说明事务处于空闲状态——注意空闲事务也可能持锁这是最容易忽略的一个坑。trx_rows_locked锁定的行数。数字越大越可疑。判断标准很简单trx_state为RUNNING且trx_started超过长时间比如60秒以上就要警惕多条记录里trx_started最早的那一条大概率就是持有锁的元凶。3.3 第三步查锁等待关系确定谁在等谁找到活跃事务后还要进一步确认它们之间的等待关系。在MySQL 8.0中用下面这两张表查询锁等待关系最直观SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM performance_schema.data_lock_waits w JOIN information_schema.innodb_trx r ON w.REQUESTING_ENGINE_TRANSACTION_ID r.trx_id JOIN information_schema.innodb_trx b ON w.BLOCKING_ENGINE_TRANSACTION_ID b.trx_id;这条SQL的返回结果会直接告诉你哪个线程waiting_thread在等待哪个线程blocking_thread释放锁以及阻塞方的SQL是什么。在MySQL 5.7及更早版本中对应的表名是information_schema.innodb_lock_waits、innodb_locks字段命名也有差异但查询思路是一样的。还有一个更省事的办法直接查sys库的视图SELECT * FROM sys.innodb_lock_waits;这个视图已经把事务ID、线程ID、SQL语句、等待时间都拼好了一屏看下来就能锁定目标。我平时生产环境排查基本都是先用sys.innodb_lock_waits拿结果再回头核对innodb_trx里的详细状态。3.4 第四步确认阻塞事务后安全地KILL找到阻塞源头后处理方式分两步走。第一步把阻塞事务的完整SQL和状态再确认一遍。怎么确认用trx_mysql_thread_id去SHOW PROCESSLIST里看一眼SHOW PROCESSLIST;或者直接查询SELECT * FROM performance_schema.threads WHERE processlist_id 线程ID;这里要注意别一上来就KILL。如果blocking_query显示当前正在执行一条大查询你可以再等等如果事务已经空闲很久持有锁不释放那该杀就杀。第二步执行KILL。这里有个细节值得单独讲一下。MySQL的KILL有两种方式-- 方式一按会话ID杀掉连接事务会直接回滚 KILL 123; -- 方式二先断开连接让事务在下次访问时中断效果类似但更温和 KILL CONNECTION 123;大多数情况下我直接用KILL 123就行。事务回滚后锁自然就释放了。杀完之后怎么验证重新执行一遍第3.3步的查询如果返回空集说明锁等待关系已经解除了。再让业务侧重新发起刚才失败的请求确认能正常执行。3.5 附带送你一个实用SQL一键定位元凶为了方便日常排查我把前面几步整合成一个常用脚本存成SQL文件备用SELECT now(), timediff(now(), a.trx_started) AS trx_age, a.trx_id, a.trx_state, a.trx_mysql_thread_id AS thread_id, c.processlist_user, c.processlist_host, c.processlist_db, c.processlist_command, c.processlist_time, a.trx_query FROM information_schema.innodb_trx a LEFT JOIN performance_schema.threads c ON a.trx_mysql_thread_id c.processlist_id WHERE a.trx_state COMMITTING ORDER BY trx_started ASC;执行结果按事务开始时间升序排列最早的那条就是最可疑的阻塞源。这个方法比一条条翻SHOW ENGINE INNODB STATUS高效得多值得存下来。4. 针对不同根因的根治方案从数据库配置到SQL改写KILL掉阻塞事务只是救火真正的问题还在。下面这部分是根据不同根因给出的根治方案每一类我都写了具体的操作建议。4.1 长事务问题拿到事务提交时间来曝光它处理长事务首先要让问题可见。业务系统里如果存在Transactional嵌套或者事务内调用RPC的场景代码审查时就要特别小心。我在项目里一般会用定时任务扫描innodb_trx表把执行超过30秒的事务告警出来SELECT trx_id, trx_mysql_thread_id, timediff(now(), trx_started) AS duration FROM information_schema.innodb_trx WHERE timediff(now(), trx_started) 00:00:30;配合一个简单的监控脚本每天定时跑一次发现问题直接通知到IM群。这套机制上线后长事务基本被消灭在萌芽阶段。代码层面有几条硬性规范值得推行事务内禁止调用外部接口RPC/HTTP这类调用必须放到事务提交之后事务内避免执行批量大数据操作单事务更新行数控制在合理范围内使用Transactional时显式设置timeout参数超时自动回滚。以Spring为例比较推荐的写法是Transactional(timeout 10) public void updateOrderStatus(...) { // 业务逻辑 }4.2 索引缺失问题用EXPLAIN验证更新SQL是否走了索引排查1205时EXPLAIN是绕不开的工具。对于更新语句MySQL也支持通过EXPLAIN查看执行计划EXPLAIN UPDATE order_table SET status PAID WHERE order_no SO20240101001;重点看type和key两列type如果显示ALL说明是全表扫描肯定没走索引key如果为NULL说明没有可用索引。这类SQL导致的锁竞争解决办法很直接——给频繁作为更新条件的字段建索引ALTER TABLE order_table ADD INDEX idx_order_no (order_no);建完索引后再看执行计划type变成ref说明已经走索引定位了。锁的粒度从扫到的所有行缩减为精确命中的行1205的出现率会直线下降。但我要提醒一句索引不是万能的。即使走了索引如果更新条件太宽泛比如WHERE status UNPAID命中了几十万行InnoDB照样会锁定大量记录。这种场景要考虑分批次更新下面讲。4.3 大批量更新问题拆分成小事务分批提交批量更新几十万行数据是1205的重灾区。一条UPDATE如果扫描的行数太多持锁时间就会很长中途一旦有其他业务请求需要更新重叠区域就会排队超时。解决思路是把大事务拆成小事务。假设我们要更新一张千万级表里所有status OLD的记录为NEW可以用循环分批执行-- 伪代码示例 SET batch_size 1000; SET processed 0; REPEAT UPDATE order_table SET status NEW WHERE status OLD LIMIT batch_size; SET processed processed ROW_COUNT(); -- 暂停一小段时间给其他事务让路 DO SLEEP(0.1); UNTIL processed 0 END REPEAT;这样每一批只更新1000行持锁时间极短其他事务最多等几百毫秒就能拿到锁完全不会触碰到50秒的超时阈值。对于Java/Python服务写法也类似写一个for循环每次取一批主键ID执行批量更新然后休眠一下再继续。我见过不少团队图省事直接用一条SQL更新全表结果每逢凌晨跑批就引发线上事故。分批更新虽然慢一点但胜在安全而且不会把CPU和I/O打满。4.4 配置调优innodb_lock_wait_timeout到底该调大还是调小调试1205时innodb_lock_wait_timeout是一个绕不开的参数。这个值默认是50秒也就是说一条SQL在锁上最多等50秒就会放弃。问题是调大还是调小我的观点是不要简单粗暴地调大。调大会让业务端感受不到锁冲突前台请求会长时间挂起拖垮连接池最终引发连锁故障。我见过有人把这个参数调到300秒结果数据库连接被耗光应用整体假死。正确的做法是保持默认值50秒不变优先解决为什么会让SQL等50秒这个根源问题。等长事务、锁竞争都被治理好之后这个参数基本不用动。如果你确实想微调可以用下面的命令动态修改不需要重启SET GLOBAL innodb_lock_wait_timeout 30;但我建议这是最后的手段千万不要把这个参数当成解决问题的办法本身。另一个容易被忽略的参数是transaction_isolation也就是事务隔离级别。MySQL默认是REPEATABLE READ这个级别下同样的SQL在事务内多次读取会保持一致但锁的持有时间通常比READ COMMITTED更长。如果你的业务并不需要可重复读可以考虑把它降为READ COMMITTED。这个调整能减少一部分锁冲突但会影响业务语义一定要先和研发确认再改配置。4.5 DDL引发的锁竞争大表变更必须走在线工具之前提到的四亿行表加字段的例子后来我是用pt-osc解决的。这类工具的原理是创建一张原表结构的新表然后按照主键分批把数据从原表复制到新表复制完成后在极短的时间内做一次原子切换。整个过程不长时间持有排他锁业务可以正常读写。pt-osc是Percona Toolkit里的工具基本用法pt-online-schema-change \ Dyour_database,torder_table \ --alter ADD COLUMN remark VARCHAR(255) DEFAULT NULL \ --no-check-replication-filters \ --execute如果你用的不是Percona分支也可以考虑gh-ost原理类似。总之大表DDL千万不要裸跑这是一个我从事故里学到的教训。5. 应用层面的防御从连接池到重试策略的最后一公里数据库侧的整改做完后应用侧的一些习惯也需要调整。很多1205问题表面上看是数据库的锅实际上根源在应用代码的使用方式上。5.1 连接池大小与锁等待的联动关系连接池大小对锁等待的影响很多人没有意识到。假设连接池最大是50业务高峰期同时来了200个请求。如果其中有一个请求的事务长时间持锁剩余的199个请求就会在数据库的锁队列里排队而不是在应用层排队。等50秒超时后这些请求陆续报错。所以排查1205时我会顺手看一眼连接池的配置。如果maximum-pool-size设得过大比如100或200数据库的并发线程数会堆得很高反而加剧锁竞争。建议连接池大小控制在数据库CPU核心数的2到4倍范围内具体值通过压测确定。5.2 给关键更新加重试机制1205和1213不同它不会自动回滚所以应用层需要自己判断是否需要重试。我在项目里会封装一个带重试的更新方法核心逻辑是捕获到Lock wait timeout相关异常时先把事务回滚然后等待一小段时间再重新发起更新。重试次数一般设3次间隔从100毫秒开始按指数退避的方式递增。Retryable(value CannotAcquireLockException.class, maxAttempts 3, backoff Backoff(delay 100, multiplier 2)) public void updateWithRetry(...) { // 更新逻辑 }这里要注意重试必须是新开一个事务不能在一个已经标记为rollback-only的事务里继续执行。如果你用SpringRetryable注解默认就是走独立事务的可以放心用。5.3 善用锁等待监控来提前预警最后分享一个我个人的习惯在每一个核心业务表上我会建立一个锁等待的监控看板。其实不需要多么复杂的工具只要把前面提到的sys.innodb_lock_waits查询做成一个定时任务每5分钟跑一次把结果发到监控系统即可。只要某个表上出现超过3秒的锁等待就自动告警。这种提前发现远远比事后救火有效。1205这个错误最坑的一点是它往往只在业务高峰才爆发而等到爆发时整个系统已经面临大面积超时的风险了。通过持续的锁等待监控可以在问题还没演变成故障之前就介入处理。处理一次1205的过程其实是在提醒你系统地审视数据库的使用方式事务是不是开得太随意了SQL是不是走了不该走的全表扫描批量更新是不是一脚油门踩到底了这些问题每一个都值得认真对待而不仅仅是把报错的SQL拿来优化一下就完事。