MySQL ERROR 1205锁等待超时排查全指南:从锁机制到源头预防 1. 看到ERROR 1205先稳住如果你是后端开发者或者DBA这个报错大概率不陌生ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction干净利落一行话翻译过来就是当前事务在等待一把被别的会话持有的锁等到了MySQL配置的超时上限默认50秒系统直接让你这轮事务作废。第一次遇到的时候说实话容易慌——尤其在高并发的生产环境里这个报错一连串出现第一反应往往是是不是死锁了是不是数据库挂了我的经验是先区分两件事**死锁Deadlock和锁等待超时Lock wait timeout**是完全不同的两个问题。死锁是多个事务互相持有对方需要的锁形成环路MySQL会立刻检测到并主动回滚其中一方报错是ERROR 1213。而1205是单纯的等不到锁可能是对方事务执行时间太长没提交也可能是锁冲突太频繁导致排队超时。只有先把这个问题判断清楚才不会用错排查手段。这篇文章我会从锁机制讲起接着给出定位持锁事务的具体SQL再谈处置方式和源头预防最后附上一份我自己整理过的典型场景速查。都是实际踩坑之后的总结照着操作基本能解决95%的非极端情况。注意本文所有SQL与示例基于MySQL 5.7和8.0通用语法部分系统表在8.0中有变更我会在涉及处单独说明。2. 拆开看1205是怎么发生的2.1 锁等待的完整链路InnoDB引擎默认使用行级锁事务A执行UPDATE user_info SET status 1 WHERE id 100时会对id100这一行的记录锁和索引锁发出占用请求。在事务A未提交或未回滚之前这把锁会一直持有。此时事务B执行同一条SQL它会发现目标行的锁被占用于是进入等待队列。MySQL的锁等待检测机制设置了一个计时器也就是参数innodb_lock_wait_timeout默认值是50秒。如果50秒到了事务A还没释放锁事务B就会收到我们开头的那个报错。这里有个关键细节等待是按单个锁请求计时的不是按整个事务累计计时。事务B可能在同一个事务里先后请求多把锁每一把锁都有独立的50秒等待窗口。这个特性导致一个坑你看到某个事务总体执行时间不到50秒但它依然报1205原因可能是它在不同时刻分别等待过多次锁其中某一次到达了上限。2.2 为什么默认是50秒而不是更短或更长innodb_lock_wait_timeout默认50秒不是随便定的。太短比如5秒容易让持锁事务在正常业务操作中被打断尤其是在批量更新场景下一个事务处理1000行数据可能需要十几秒等锁的事务会频繁报错。太长比如300秒会导致业务侧的感知时间过长——用户的请求会卡在那里很久同时连接池的并发线程被大量占满继而引发连环效应连接不够用、其他请求排长队、CPU和内存压力飙升。50秒是一个折中值既给了正常事务足够的执行时间又不会让业务无限制地卡下去。我个人在实际维护中只在极少数批量任务场景下把它调大过这个后面专门说。2.3 1205的三种表现形态同样是1205在不同环境下表现差异很大排查方向完全不一样偶发出现多半是某个瞬时大事务持锁其他请求碰巧撞上。重点查那个时间点有没有慢SQL或大批量操作。高频出现说明业务本身就存在锁竞争比如大量并发更新同一行、同一批数据。这时候不是靠重启或等待能解决的必须改代码或做架构调整。固定时间点出现最常见的元凶是定时任务。比如每天凌晨跑的批处理脚本恰好和夜间在线业务哪怕是低峰的发生锁冲突。排查时优先看定时任务重叠的时间线。我曾经遇到过一种很隐蔽的情况一个定时任务每5分钟扫一次表扫到某条数据就做更新而在线业务会偶发性地同时更新同一条数据。从监控看定时任务本身执行很快但两个操作在同一秒撞上就偶发1205。这种问题排查最费时间因为复现率不高只能靠完整记录锁等待日志才能定位。3. 定位持锁事务找到等不到的那把锁3.1 第一步查看当前正在执行的SQL和事务收到1205报错后先不要急着kill进程。第一步是查看当前数据库的事务与锁状态。我最常用的查询是这几条-- 查看当前所有正在执行的事务 SELECT * FROM information_schema.INNODB_TRX\G注意重点看这几列trx_id事务ID、trx_state事务状态RUNNING或LOCK WAIT、trx_started事务开始时间、trx_query事务最后执行的SQL。其中信息量最大的是trx_started——如果某个事务状态是RUNNING而且开始时间是几十分钟之前那基本可以断定它就是阻塞源头。接下来查锁等待关系-- MySQL 5.7及之前版本 SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread_id, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread_id, b.trx_query AS blocking_query FROM information_schema.INNODB_LOCK_WAITS w JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id r.trx_id JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id b.trx_id;-- MySQL 8.0替代方案 SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread_id, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread_id, b.trx_query AS blocking_query FROM performance_schema.data_lock_waits w JOIN performance_schema.data_trx r ON w.requesting_trx_id r.trx_id JOIN performance_schema.data_trx b ON w.blocking_trx_id b.trx_id;如果查出来是空结果不要急着下结论。1205报错发生在等待超时的那一刻等到你去查的时候持锁事务可能已经提交或回滚了。这时候看INNODB_TRX里近期是否有长事务更能说明问题。3.2 第二步用系统视图直接看阻塞链条MySQL的sys库自带了一个视图能把阻塞关系整理得更直观SELECT * FROM sys.innodb_lock_waits\G这个视图的输出包含了等待者、阻塞者、各自的线程ID、执行SQL、锁类型和等待时长。我个人的习惯是先用它快速定位再用INNODB_TRX去细查事务细节。另一个值得用的工具是SHOW ENGINE INNODB STATUSSHOW ENGINE INNODB STATUS\G看结果里的TRANSACTIONS段落。如果当前存在锁等待这里会列出各个事务的状态包括持有锁的事务在做什么SQL、等待锁的事务卡在哪。这个命令是排查锁问题最原始的入口信息比系统表更丰富缺点是人肉读起来比较费劲。经验之谈排查锁问题时不要只截取报错SQL本身去分析。锁等待涉及至少两个会话报错方只是冰山一角真正的关键信息在被阻塞方看不到的地方——也就是持锁方那一侧。3.3 第三步从线程ID反查业务源头拿到阻塞者的trx_mysql_thread_id后可以进一步查这个线程来自哪个连接、执行了什么SQL-- 找到线程对应的连接信息 SELECT id, user, host, db, command, time, state, info FROM information_schema.PROCESSLIST WHERE id 阻塞者的trx_mysql_thread_id;如果info列为空或NULL说明该连接当前没有正在执行的SQL——这往往意味着事务已经执行完业务代码但应用侧没有提交或回滚。这种空闲事务持有锁的情况是生产环境里最常见的1205诱因。再进一步把线程ID关联到应用连接池或者日志里去。比如很多框架会打印SQL执行日志带上线程ID可以反查到具体是哪个服务实例、哪个业务方法在操作。这一步在大型应用里特别有用如果数据库层能看到大量空闲持锁连接且都来自同一个应用服务那问题大概率出在那个服务的连接池或事务管理配置上。3.4 临时救急查看实时锁等待监控如果问题正在发生而且暂时不想干扰线上可以用performance_schema查看实时锁信息-- 查看当前被锁住的记录 SELECT OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA FROM performance_schema.data_locks;LOCK_DATA这一列很重要它显示了具体是哪一行的主键值被锁住。配合INNODB_TRX和data_lock_waits就能精确到哪条记录、哪个事务、哪个线程。这个粒度对于排查热点行冲突极其好用。4. 处置手段是杀事务还是调参数4.1 分清场景再做决定定位到阻塞事务之后要先判断它属于哪类短事务但量大比如一个UPDATE涉及上万行本身只需要几秒但高频执行导致持续排队。这种情况倾向于优化SQL做分批处理而不是kill。空闲事务持锁事务开始很久但最近一条SQL执行时间很短连接处于Sleep状态。这就是典型的忘了提交或者事务里嵌入了外部调用。大事务正在跑比如一次删几百万行的分批清理任务。如果这个任务可以被截断kill是合理选择如果不能等它跑完。4.2 Kill的正确姿势确实需要kill阻塞事务时用以下方式操作-- 找到要kill的线程ID SELECT trx_mysql_thread_id FROM information_schema.INNODB_TRX WHERE trx_id xxxx; -- 执行kill KILL 线程ID;有一点必须注意kill的是阻塞事务所在的数据库连接线程不是锁本身。KILL会触发该事务整体回滚如果这个事务已经执行了大量SQL回滚过程会继续持锁一段时间——大事务的回滚经常比执行还慢。所以在kill之前心态上要有个预期如果阻塞者是一个跑了一半的大事务kill之后可能还要等它rollback完锁才会真正释放。我在一次线上事故里就遇到过这种情况阻塞源是一个正在给千万级大表加索引的会话kill之后回滚花了将近三分钟业务整体的报错时间比预想的更长。所以如果条件允许推荐先用KILL QUERY只杀掉当前正在执行的SQL不终止会话试试对于某些卡住了单条SQL的情况更轻量。4.3 调参数要克制别把治标当治本innodb_lock_wait_timeout这个参数线上不少团队会去调大。我的建议是除非有非常明确的场景否则不要高于默认值太多。举个例子如果你的业务有比较耗时的批量任务比如一次更新10万行每行耗时1毫秒整体要10秒那么其他事务等待这批锁最多也就10秒左右默认的50秒其实完全够用。真正需要调大的场景是某类业务事务本身就要20-30秒同时线上并发不高、锁冲突频率低等锁方大多在几秒内能拿到锁。这种情况下调到120-180秒是合理的但不能无脑调大否则会掩盖持锁方的问题让连接池和前端服务承受更大压力。参数修改方法-- 会话级 SET SESSION innodb_lock_wait_timeout 120; -- 全局级不需要重启但只对新建连接生效 SET GLOBAL innodb_lock_wait_timeout 120;别忘了如果用的是连接池已有连接不受全局参数更新的影响需要重启应用或让连接池重建连接才能生效。我碰到过有人改了参数但业务没感知就是栽在这个细节上。另一个容易踩坑的参数是innodb_rollback_on_timeout。默认情况下1205报错后InnoDB只会回滚触发超时的那条SQL语句而不是整个事务。也就是说事务里前面的操作仍然有效需要应用层决定是否整体回滚。如果业务的事务逻辑要求超时即全部回滚建议把这个参数设为ON让数据库在锁超时时直接回滚整个事务。这个选择会在后面讲错误处理的最佳实践时再展开。4.4 确认事务隔离级别的影响大多数线上库存、订单系统用的是默认的REPEATABLE READ。在这个级别下InnoDB的锁定行为还额外包括间隙锁gap lock也就是说WHERE条件带范围查询时不仅会锁住命中的行还会锁住范围里的间隙防止其他事务插入。这直接导致锁冲突面比想象的大得多。如果业务对一致性要求没那么高比如一些纯查询报表、日志表、非核心流水数据切换到READ COMMITTED后间隙锁会减少很多-- 查看当前会话隔离级别 SELECT transaction_isolation; -- 修改会话隔离级别 SET SESSION transaction_isolation READ-COMMITTED;从8.0开始默认隔离级别仍是REPEATABLE READ如果要在全局修改注意先和业务方确认对一致性读的影响。有一种情况不该改业务里依赖REPEATABLE READ的幻读保护比如做检查-插入模式的防重复逻辑改成READ COMMITTED会引入新的并发问题。5. 从源头防让锁冲突根本发生不了5.1 事务里尽量不要做无关操作这是最重要的一条。事务范围越小持锁时间越短其他人等待的时间就越短。常见的反模式包括在事务里调用外部API、发短信、调消息队列等待外部响应期间锁一直被攥着在事务里做耗时很长的复杂计算或深分页查询事务里先SELECT一批数据循环到Java/Python代码里逐条处理再逐条UPDATE整个循环可能几百毫秒甚至几秒正确做法是把非数据库操作移到事务外面数据库事务只留下“查询-判断-更新”三个动作把持锁时间压缩到毫秒级。我曾经接手过的一个项目订单状态更新逻辑里嵌了一个调用第三方物流查询接口的代码平时接口返回50ms以内问题不大但某天第三方系统故障接口超时10秒才返回期间事务一直持锁未提交商品库存这一行的锁被占用所有下单请求集体1205。根因就是这么简单——事务里塞了外部调用。5.2 多行更新、批量操作要讲究顺序多条SQL一起更新时尽量保证所有事务都按相同的顺序更新数据。比如事务里先更新订单表、再更新明细表那么所有相关事务都按这个顺序执行。一致的操作顺序可以大幅降低死锁概率而死锁一旦发生其中一个事务会被强制回滚那个回滚的临时锁波动也可能引发连锁的1205。批量更新数据时我强烈推荐按主键排序后分批执行-- 不好的批量更新方式一次更新几十万行 UPDATE order_info SET status CLOSED WHERE create_time 2024-01-01; -- 推荐的分批方式按主键范围循环每批5000行 UPDATE order_info SET status CLOSED WHERE id BETWEEN 1 AND 5000 AND create_time 2024-01-01; -- 循环下一批 id BETWEEN 5001 AND 10000 ...为什么按主键分批有效第一每批的持锁时间短不会把锁攥几十秒第二主键范围扫描能有效利用索引避免全表扫描锁大量行第三如果中途某批失败重试的成本也低。5.3 警惕索引失效带来的锁扩散InnoDB的行锁是锁在索引记录上的。如果UPDATE/ DELETE的WHERE条件没走索引会退化成锁多行严重时甚至要锁全表——这对并发系统是灭顶之灾远不止1205这么简单还会波及整个实例的可用性。排查SQL是否走索引最直接的办法EXPLAIN SELECT * FROM order_info WHERE create_time 2024-01-01\G看possible_keys和key字段。如果key为NULL且rows估算值非常大那就意味着这条SQL要扫很多行锁的覆盖面非常广。批处理SQL尤其要注意这点——白天跑一次全表扫描更新可能把所有业务都卡死。5.4 处理热点行冲突的两种进阶方案秒杀、抢购、下单扣库存这类场景大家更新的是同一行库存。并发几百甚至上千必然排队。就算每笔事务只花10毫秒高并发下排队排到超时也只是时间问题。第一种思路在应用侧做热点行合并。把同一秒内的多次扣减请求合并成一次SQL更新减少对行的争抢次数。举个例子与其一个请求执行一次UPDATE stock SET count count - 1 WHERE goods_id 100不如引入一个中间层先聚合5个请求然后一次性执行SET count count - 5。这样对数据库的锁竞争直接降到五分之一1205的出现概率也会明显下降。第二种思路使用MySQL 8.0的SKIP LOCKED语法来做简单队列消费。比如多个后台任务要抢占同一条待处理记录时-- 跳过已经被其他事务锁定的行抢到未处理任务的行 SELECT * FROM task_queue WHERE status PENDING ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED;SKIP LOCKED的效果是当前事务直接跳过被他人锁定的行只操作没被锁的行。这样多个任务线程之间就不会互相等待也就不会出现锁等待超时。这个特性在任务队列类场景非常实用前提是业务逻辑能接受跳过被锁记录这项约束。5.5 设置合理的重试机制即使做了上面所有优化1205依然无法百分之百避免。分布式系统里偶发的锁冲突需要靠应用层来兜底。在代码层面推荐给会触发锁冲突的数据库操作加上重试逻辑。我的常用做法是给事务方法包装一层重试器遇到Lock wait timeout exceeded错误码1205或死锁1213时等待几十到几百毫秒后重试最多重试3次。下面这段伪代码展示了核心思路Component public class TransactionRetryHandler { private static final int MAX_RETRY 3; private static final long RETRY_DELAY_MS 100; public T T executeWithRetry(SupplierT action) { for (int attempt 0; attempt MAX_RETRY; attempt) { try { return action.get(); } catch (DeadlockLoserDataAccessException e) { // 错误码1213或1205的处理 if (attempt MAX_RETRY - 1) { throw e; } try { Thread.sleep(RETRY_DELAY_MS * (attempt 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } throw new IllegalStateException(unreachable); } }注意对象重试的间隔建议逐次增加退避不要3次重试都隔相同时间。如果3次重试后仍然超时不要继续死磕先把事务标记为回滚同时保留原始的超时异常上报监控让DBA或开发去排查更深层的问题。重试逻辑看起来简单但有几个细节必须处理重试时事务边界必须重新开启不能用同一条已回滚的事务去执行重试重试的上下文比如用户请求、幂等标记需要保留重试导致的重复插入问题需要靠业务唯一键来兜底比如在扣减库存这种非幂等操作上重试前要重新SELECT当前值再计算。6. 典型场景与排查速查结合我过去处理过的各种案例整理了一份锁等待超时的排查速查表。不少现场问题可以对照这个表来定位方向。现象可能根因优先排查手段处置思路某条UPDATE偶发1205其他事务持锁时间稍长查INNODB_TRX中是否有短暂运行的长事务确认业务并发峰值优化持锁事务SQL大量1205连续爆发热点行被高频更新查data_locks中LOCK_DATA是否集中在同一主键热点行合并、拆库存、异步扣减定时任务时间段集中报错定时批处理与在线业务冲突对比定时任务时间与报错时间线错峰执行、按主键分批、降低隔离级别连接池大部分连接阻塞空闲事务未提交查PROCESSLIST中Sleep状态但trx非空的连接修正应用事务边界检查连接池空闲超时报错后整个事务所有操作都回滚事务中其他语句早前已成功确认是否设置了rollback_on_timeout按业务预期决定是否开启全事务回滚某个SQL执行很快但总是1205WHERE条件没走索引EXPLAIN看key字段和扫行数加索引、改写SQL、避免索引失效与批量导入同时段报错LOAD DATA或大批量INSERT持锁查PROCESSLIST中该导入连接导入避开高峰、分批提交、关闭自动提交再补充几个实际排查中容易忽略的点binlog格式与锁等待的关系如果业务采用STATEMENT或MIXED格式的binlog某些UPDATE/DELETE语句在从库回放时同样要请求行锁。从库的延迟和锁冲突不会体现在主库的报错日志里但应用侧连到从库的只读请求可能会报1205。排查时如果主库一切正常而应用报错的是只读请求千万别忽略从库的锁状态。内存临时表和锁某些SQL会创建内部临时表临时表在InnoDB下使用MEMORY存储不受InnoDB行锁控制但如果查询里涉及了带行锁的普通表和临时表的JOIN锁的等待依然存在。这种场景比较少但遇到离奇的1205时可以考虑。5.7和8.0的information_schema差异8.0起INNODB_LOCK_WAITS和INNODB_LOCKS被废弃改用performance_schema的data_lock_waits和data_locks。如果沿用老SQL查询8.0会直接报表不存在的错误。我上面给出的8.0替代方案可以直接用。真正让我对1205的处理思路发生转变的是一次线上连续报错三十分钟的故障。当时我还在忙着kill阻塞事务一遍又一遍地清理临时锁冲突后来发现问题根本不是某次偶然持锁太久而是某个服务的连接池配置把连接空闲超时调到了最大值导致大量连接被复用且事务一直没释放。那以后我处理锁问题就有了一个习惯先看事务再查SQL最后才看参数不到万不得已不轻易kill和调参。因为kill和调参都是在治标真正的问题常常藏在你一开始看不到的地方。另外再说一个测试环境容易踩的坑——很多人喜欢把测试库的innodb_lock_wait_timeout调成5秒来快速触发锁冲突、方便调试但生产库是默认的50秒。结果就是测试环境疯狂报错、生产环境看起来没事等真正上了生产低峰期没事高峰期直接暴雷。调试锁问题时间参数尽量和生产保持一致别让测试环境给你一个错误的安全感。最后提醒一句如果在业务高峰期发现1205正在大面积爆先不要急着去改代码或者加索引。把持锁方确认清楚把锁等待关系拉出来尽快让关键业务先恢复该kill就kill然后再冷静下来做根因分析和长期优化。现场救火讲究的是快、准、稳日常治理讲究的是细水长流。