ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL 1205锁等待超时:从行锁原理到生产实践排查与根治

MySQL 1205锁等待超时:从行锁原理到生产实践排查与根治 凌晨两点被监控电话叫醒打开手机看到业务群的告警刷屏订单表大面积报错Lock wait timeout exceeded; try restarting transaction。登录数据库一查SHOW PROCESSLIST里全是Sleep真正执行更新语句的线程却卡在等待锁上。这是很多后端开发和 DBA 都经历过的经典场面。1205这个错误码看起来像一个简单的超时提示但它背后牵涉的是 InnoDB 的锁机制、事务边界、索引设计、业务代码质量甚至连接池的配置。这篇文章把我这几年处理过的 1205 案例攒在一起从原理讲到具体排查命令再给出一套能落地的优化方案希望对正在被这个错误折磨的同行有点用。1. 1205出现的那一刻数据库里到底发生了什么1.1 InnoDB的行锁机制一次更新请求的真实路径要理解 1205先得知道 InnoDB 是怎么给数据加锁的。假设你执行一条UPDATE user SET nickname 新昵称 WHERE id 123InnoDB 并不会锁住整张 user 表而是通过主键索引定位到 id123 这条记录然后在这条记录上加一把行锁。如果另外有一个事务也想修改这条记录它就必须排队等这把锁释放。这里有个容易忽略的细节InnoDB 的行锁是针对索引记录的锁也就是说如果你的 SQL 连索引都没走或者走的是全表扫描那 InnoDB 找不到精确的索引记录只能退而求其次给扫描过程中经过的所有记录都加上锁。这就是为什么有些 1205 看起来像是所有写操作都卡死了其实不是数据库崩了而是锁的粒度已经从一条记录膨胀到了几十万条记录甚至整个表。拿生活里的场景类比行锁就像电影院里的座位锁——系统先按照座位号帮你锁定具体的那一个座位其他观众可以正常买其他座位。但如果系统设计有缺陷买票时必须先把整排座位都占住才能选其中某一个这时候任何一个想买这排座位的观众都会被堵住。InnoDB 的索引失效干的就是这种事。还要补充一个点InnoDB 默认的隔离级别是REPEATABLE READ在这个隔离级别下不仅会给目标记录加锁还会在索引范围边界上加间隙锁Gap Lock用于防止其他事务在这个范围内插入新记录。这本来是 MVCC 多版本并发控制里保证可重复读的手段但代价是锁的范围进一步扩大相邻键之间的间隙也会被锁住。很多我只是更新一条记录为什么旁边的记录也更新不了的现象根源就在间隙锁上。1.2 1205和1213不是一回事锁等待超时 vs 死锁很多资料把 1205 和 1213 混在一起讲实际上这是两个完全不同的错误1205 - Lock wait timeout exceeded事务 A 持有某条记录的锁事务 B 想要同一把锁一直在等待。超过了innodb_lock_wait_timeout配置的时间事务 B 就放弃了抛出 1205。这个错误里绝大多数情况只有一方在等另一方拿着锁迟迟不提交或回滚。1213 - Deadlock found事务 A 和事务 B 各自持有一把锁同时还想拿对方手里的锁形成循环等待。InnoDB 的死锁检测机制发现这个环后会主动把其中一个事务回滚掉让另一个继续执行。我自己的经验是1213 更像是一个交通死锁的瞬间事故通常发生频率低因为 InnoDB 的死锁检测会很快介入默认会实时检测。而 1205 则是慢性的、持续的阻塞往往意味着业务代码或者数据模型本身有问题比如某个长事务长期占锁不退。这里有一个排查上的关键点遇到 1205不要先入为主地怀疑死锁。绝大多数时候你只需要找到那个占着锁不撒手的长事务把它处理掉1205 就自然消失了。而死锁则需要从业务逻辑层去梳理多个更新操作之间的加锁顺序。1.3 默认的50秒到底意味着什么InnoDB 的锁等待超时时间由参数innodb_lock_wait_timeout控制默认值是 50 秒。也就是说一个事务可以等另一把锁最多 50 秒超了就报 1205。很多人在踩坑后的第一反应是把这个值调大比如调到 300 秒甚至 600 秒。这种操作短期看能减少报错量但长远看是在掩盖问题。你想一下如果业务线程在数据库里等锁等了 300 秒那这 300 秒内该线程占着一个数据库连接不放连接池的活跃连接数会迅速飙升。数据库连接池又不是无线扩容的连接耗尽的后果比报 1205 更严重所有请求直接拿不到连接服务彻底不可用。还有一点容易被忽略MySQL 客户端交互模式下的wait_timeout和这个innodb_lock_wait_timeout是两码事。前者是连接空闲多久就断开后者是行锁等待多久才超时。我在排查问题时经常看到有人把这两个参数混在一起说结果改了wait_timeout对 1205 一点用都没有。所以我的建议是innodb_lock_wait_timeout保持默认的 50 秒是可以的如果你要做微调也别超过 60 秒。反正真到了业务需要等锁 50 秒以上才能执行的情况你的 SQL 设计大概率已经出问题了靠调参救不了命。2. 生产环境里我遇到的几种典型1205现场2.1 场景一长事务占着行锁不撒手最常见的 1205 源头是业务代码里的长事务。我之前排查过一个案例一个会员服务的接口事务里做了好几件事——先查用户余额再调第三方风控接口紧接着写入订单流水最后更新用户积分。事务从开启到提交的整个过程中间夹了一次 HTTP 调用平均耗时 3 到 8 秒。平时并发量小大家没感觉。但大促期间流量翻倍同一批热门商品的库存记录被集中更新结果用户在下单时更新自己的积分字段也报 1205。你看这个场景里有两个问题一是单条事务耗时过长占用了数据库的行锁二是在事务内发起了外部网络调用把锁的持有时间从毫秒级放大到了秒级。数据库根本不在乎你在事务里干的活是 SQL 还是 HTTP 请求它只知道你这把锁一直不释放。遇到这种情况我总是给业务团队强调一句话事务的边界就是锁的边界。打开事务之后能少干活就少干活能快速提交就快速提交。外部调用必须移出事务或者至少移到事务提交之后。另一个场景是报表类的定时任务凌晨跑一个批处理循环更新几万行数据每条记录更新之间还夹杂着大量业务计算。这种任务如果跑得慢很容易就把某些热门记录的锁占住第二天早上在线用户一操作就撞上 1205。大促期间尤其明显业务方和运维方互相甩锅最后发现是定时任务和在线交易抢同一批数据。2.2 场景二索引失效导致锁范围扩散成雪崩如果说长事务是人为占锁时间太长那索引失效就是锁的范围被无规律拉大。有次线上环境出现了一个诡异现象一个只更新单条记录的接口在某个时间段内频繁报 1205而其他更新语句却正常。我们拿到那条 SQL 去数据库里看执行计划发现typeALL意味着全表扫描。表里当时有 300 多万行一条本该精确更新一行的语句实际上是扫过几百万行、给扫描到的所有行都加了锁。为什么索引会失效常见原因其实很朴素条件字段没有索引隐式类型转换导致索引失效或者表里的字符集排序规则和传入参数不一致。我当时遇到的案例是where status PAIDstatus 字段本身有索引但查询条件是status 1MySQL 把字符串转成数字去匹配索引就废了于是从这个 case 上看到一个CONVERT的可怕代价。全表扫描加锁带来的不只是慢更致命的是锁的覆盖范围极大。一个用户更新一条记录把整张表上百万条记录都锁了其他用户更新任何一条记录都要排队。这时候 1205 就像传染病一样扩散开谁跑谁中招。那次之后我把这种索引失效排查写进了团队的 SOP凡是遇到 1205除了查事务和锁等待必须同步检查相关 SQL 的执行计划确认type不是ALL。尤其是 UPDATE 和 DELETE 语句它们对锁范围的影响比 SELECT 大得多。2.3 场景三批量任务与在线请求争抢同一把锁还有一种特别容易被忽略的场景后台运营批量更新和前台用户在线操作同时落在同一条或同一批记录上。我碰过一个典型案例电商平台的运营每两小时跑一次批处理把一批商品的库存和价格批量更新。批处理逻辑是读一次商品列表然后循环里逐条执行 UPDATE。到了晚上高峰期前台用户疯狂下单也在更新同一批商品库存。结果就是批处理每条 UPDATE 的执行本身不慢但它和在线订单的更新语句在锁上打架谁先抢到锁谁就继续抢不到的就一直等等到超出 50 秒就报 1205。这种批量和在线争锁的场景最麻烦的地方在于它不是单条语句的问题而是整批任务的锁抢占模式。批处理任务通常事务边界模糊有的代码会在一个大循环里开启一个事务全部更新完之后才提交。等于说运营跑一个批处理就锁住了一大批商品记录持续几十分钟。这段时间内所有涉及这些商品的在线订单全部受影响。3. 现场排查的完整链路从SHOW ENGINE INNODB STATUS到sys库3.1 第一步用SHOW ENGINE INNODB STATUS抓取锁等待现场排查 1205 的入门命令是SHOW ENGINE INNODB STATUS它会把当前 InnoDB 引擎内部的一些关键信息打印出来。重点看TRANSACTIONS段这部分会列出当前活跃事务以及它们的锁状态。SHOW ENGINE INNODB STATUS\G输出里有一个LOCK WAIT的标识后面会明确指出哪个事务在等哪把锁以及锁的等待时间。如果运气好你甚至能看到具体的 SQL 语句片段和持锁事务的trx_id。不过要提醒一句SHOW ENGINE INNODB STATUS只显示最近一次的锁等待信息而且内容比较粗糙不适合做持续监控。它更像是一个现场快照。真正要排查复杂问题时还需要和其他系统表配合使用。另外如果错误日志开启了MySQL 在报 1205 的同时也会把相关的锁等待信息写入错误日志。开启方式SET GLOBAL innodb_print_all_deadlocks 1; SHOW VARIABLES LIKE innodb_print_all_deadlocks;这个参数的正则规范写法请参考 MySQL 官方文档。打开后InnoDB 会把所有死锁和锁等待的详细信息记录到错误日志里方便事后复盘。3.2 从information_schema里找到谁在堵谁这是我自己最常用、也最推荐给新手的排查方式。MySQL 在information_schema库里提供了几张和锁相关的表INNODB_TRX当前所有未提交的事务包含事务 ID、状态、开始时间、执行的 SQL 等。INNODB_LOCKS当前被持有的锁信息注意8.0 里这张表的语义有变化更推荐用PERFORMANCE_SCHEMA.data_locks。INNODB_LOCK_WAITS锁等待关系表直接告诉我们谁在等谁。最直观的排查 SQL 是这样的SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx WHERE trx_state RUNNING ORDER BY trx_started ASC;把这条 SQL 的结果拉出来你就能看到当前所有活跃事务的启动时间和 SQL 内容。通常那个trx_started时间最早、trx_stateRUNNING、而且一直没提交的事务就是锁的持有者。接下来看锁等待关系SELECT waiting_trx_id, blocking_trx_id, waiting_pid, blocking_pid FROM sys.innodb_lock_waits;如果你用的是 MySQL 5.7 以上的版本可以直接用sys库的这个视图它把INNODB_TRX、PROCESSLIST、INNODB_LOCK_WAITS关联好了输出里直接显示等待方的 pid 和阻塞方的 pid。这样你就能顺藤摸瓜找到阻塞那个事务的数据库连接 ID。拿到blocking_pid之后再去performance_schema.threads或者SHOW FULL PROCESSLIST里看这个 PID 对应的 SQL 是在跑什么就能定位到罪魁祸首。我还习惯把trx_started的时间专门拉出来看一下。如果某个事务启动时间超过几分钟还没提交不管它是不是本次 1205 的元凶都应该引起警觉。这种长事务是潜伏的地雷指不定什么时候就引爆。3.3 用performance_schema.data_lock_waits做更细粒度的确认MySQL 8.0 里information_schema.innodb_locks和innodb_lock_waits逐渐淡出官方推荐用performance_schema.data_locks和performance_schema.data_lock_waits来查锁信息。一个比较通用的查询SELECT w.ENGINE_TRANSACTION_ID AS waiting_trx_id, w.THREAD_ID AS waiting_thread_id, b.ENGINE_TRANSACTION_ID AS blocking_trx_id, b.THREAD_ID AS blocking_thread_id FROM performance_schema.data_lock_waits AS w JOIN performance_schema.data_lock_waits AS b ON w.BLOCKING_ENGINE_LOCK_ID b.ENGINE_LOCK_ID;这个查询能明确列出等待方和阻塞方的线程 ID再结合performance_schema.threads找到对应 PROCESSLIST IDSELECT PROCESSLIST_ID, SQL_TEXT FROM performance_schema.threads WHERE THREAD_ID 你的等待线程ID;注意performance_schema表的查询需要一定的权限而且开了之后会有一点点性能开销但相比在 1205 毛线球里打转这个开销完全值得。3.4 排查中容易犯的三个错排查锁等待时新手最容易踩三个坑。第一个坑只看SHOW PROCESSLIST看到一堆Sleep就以为数据库没事。实际上Sleep状态的连接里很可能就有事务没提交、正在持有锁。SHOW PROCESSLIST默认不显示事务信息必须结合INNODB_TRX才能看清全貌。第二个坑一看到持锁事务就无脑KILL。如果那个事务是一个正在执行的关键业务操作KILL 掉会造成数据不一致甚至引发更大的业务问题。正确的做法是先确认这个事务是孤立的还是正常业务的一部分。如果只是一个长期挂起的事务回滚它没问题如果它是某个核心接口正在处理的请求你得先通知业务方处理。第三个坑调整innodb_lock_wait_timeout当作解决问题的捷径。我见过一个团队把这个参数调到 600最后连接池被打满整个服务瘫痪。调参可以作为一种临时止血手段但绝不能作为最终方案。真正要解决的是锁的持有者为什么要持锁这么久锁的范围为什么会这么大。4. 从根上消除1205索引、事务、隔离级别与业务重试4.1 索引设计才是锁粒度最根本的控制手段从源头治理 1205第一条原则就是把锁牢牢控制在目标行上。这就要求 UPDATE 和 DELETE 语句的 WHERE 条件必须走最优索引。我在团队里定了一条规矩凡是 UPDATE、DELETE 语句上线必须附带EXPLAIN执行计划确认。看两个关键指标type不能是ALL全表扫描rows预估值不能超过某条线。举个例子。假设有个订单表orders线上跑着这样一条更新语句UPDATE orders SET status CANCELLED WHERE order_no 20240101000001 AND user_id 123;如果只在user_id上有索引在order_no上没有索引那这条 SQL 的执行计划会怎么做它会走user_id的索引定位到该用户的所有订单然后逐行匹配order_no。如果该用户有几千个订单锁的范围就是这几千行。更差的情况是如果user_id上也没索引那就变成全表扫描锁全表。最优方案是建一个联合索引(order_no, user_id)这样 InnoDB 能直接定位到唯一记录锁的范围只有一行。这里要强调一下联合索引的字段顺序有讲究选择性高的字段放前面才能让索引 B 树的定位更精准。在这个例子里order_no的区分度远比user_id高所以放前面是对的。还有一个容易被忽略的场景外键约束带来的锁。如果子表上有外键指向父表那么更新父表记录时InnoDB 会在子表上对应的外键列上加共享锁防止外键关系被破坏。这个锁虽然不阻塞其他读但会阻塞针对同一子表记录的写操作。遇到这种特殊情况要考虑外键设计是否真的必要。4.2 大事务拆分把锁的持有时间降下来解决长事务占锁最直接的方法是缩短事务的执行时间。这个道理人人都懂但落实到代码上常常做不好。以我之前见过的一个定时任务为例逻辑是# 错误示例循环10万次全部在一个事务里 for item in item_list: update_item_stock(item) COMMIT这个任务在事务里循环更新了 10 万行记录占锁时间可能达到十几分钟。一旦在线业务也要更新这些记录1205 就是家常便饭。拆分的方案是# 拆分每处理1000条就提交一次 batch_size 1000 for i in range(0, len(item_list), batch_size): batch item_list[i:i batch_size] for item in batch: update_item_stock(item) COMMIT批处理中每个批次独立成事务锁只在一个批次内持有。这样在线请求最多等这一个批次的几千条记录处理完而不是等全部 10 万条。要注意的是拆分会带来一个副作用一个逻辑上的大任务变成多个小任务后如果中途失败前面已提交的批次无法通过数据库事务回滚。所以拆分批量任务时需要额外设计补偿机制或状态字段。比如给每个批次带上batch_no处理完一批标记一次进度失败后可以从标记处继续。我还在团队内部强调过一个点不要在事务里做 Redis 操作、HTTP 调用、消息队列发送。这些外部调用的耗时是不可控的可能把锁的持有时间放大到秒级甚至分钟级。正确做法是在事务里只写数据库外部操作放到事务提交之后。如果外部操作失败再用补偿机制去处理。4.3 隔离级别调整RC有时是1205的良药前面提过InnoDB 默认隔离级别是REPEATABLE READRR在这个级别下UPDATE/DELETE 会加间隙锁防止插入新记录破坏可重复读。如果业务对数据一致性没有那么高的要求或者可以接受一定程度的不可重复读把它降到READ COMMITTEDRC会有一个明显收益间隙锁被消除锁的粒度进一步缩小。有个实际案例某个系统在 RR 隔离级别下频繁出现 1205业务场景是一个聚合任务批量更新多个分组下的记录而在线请求也频繁更新这些分组。我们找到的根因就是间隙锁批量任务锁住了一个分组的间隙在线请求想往这个分组里插入新记录或者更新边界记录就一直等。后来把全局隔离级别调整到 RC间隙锁消失锁冲突明显减少1205 几乎绝迹。但调整隔离级别之前必须确认几件事基于语句的复制statement-based binlog在 RC 下不安全可能导致主从数据不一致。必须设置binlog_format ROW。业务逻辑里是否依赖可重复读语义。如果事务里先读组数据再基于这个读到的值去计算并更新在 RC 下可能两次读结果不一致这会让业务逻辑偏离预期。是否已经有大量代码假设隔离级别是 RR。改隔离级别属于变更要评估全量影响面不是改一个参数那么简单。如果你用的是 MySQL 8.0 或 Percona 分支RC 已经是默认推荐配置很多新项目直接按 RC 设计也确实省去了不少锁冲突的烦恼。4.4 业务侧重试1205最实用的一道兜底防线即使做了索引优化、事务拆分、隔离级别调整1205 在高并发场景下依然可能零星出现。因为数据库锁本身就是一种竞争资源极端流量的瞬间冲突没办法完全消灭。这时候业务代码里的重试机制就是最后一道防线。重试设计有一个关键点不是无脑重试而是要有策略。直接照抄一段重试代码RETRY_TIMES 3 RETRY_INTERVAL 0.2 for attempt in range(RETRY_TIMES): try: update_order_status(order_id, new_status) break except LockWaitTimeout: if attempt RETRY_TIMES - 1: raise time.sleep(RETRY_INTERVAL)这里有两个细节值得琢磨。第一个是重试次数和间隔。太频繁的重试会给数据库造成额外压力而且如果锁没释放重试多少次都没用。0.2 到 0.5 秒的间隔成本很低但能大幅度错开和持锁事务的竞争窗口。第二个是重试后的操作怎样保持幂等。比如更新订单状态重试时必须基于最新状态重新计算不能拿旧数据直接覆盖否则可能产生脏写。如果业务对一致性要求高重试之外的兜底建议是把失败的操作写入重试队列或者本地任务表由后台定时任务补偿。这样既不会让终端用户等待太久也能保证最终一致。5. 复盘后的几条硬经验5.1 建监控和预警不在故障发生后才开始查1205 最磨人的地方在于它是个结果指标等你看到它的时候锁冲突其实已经持续一段时间了。与其等错误爆发不如提前盯住两个信号活跃事务数量超过阈值、事务平均持续时间超过阈值。我在团队里部署了一套简单的监控每 30 秒采集一次information_schema.innodb_trx统计trx_started超过 30 秒的活跃事务数量一旦超过预设阈值比如 3 个触发告警并自动抓取当前阻塞链信息发给值班群同时记录当时的活跃 SQL 和连接来源方便事后复盘。这套东西不复杂但价值极大。很多 1205 在真正大面积爆发之前都会有小规模的长事务迹象。提前发现并处理掉就不用半夜爬起来救火了。5.2 建立锁事件自行归档机制我推荐每个涉及数据库的团队都做一本1205 排查档案。每次遇到 1205把以下信息记录下来出现时间、涉及的表和索引持锁事务和执行具体事务的 SQL对应的业务场景和代码模块当时打掉的根因和索引/事务归属。时间久了你会发现大部分 1205 都能归入有限几类模式要么是批量操作和在线业务打架要么是索引失效导致锁扩散要么是连接池配置不当把锁等待时间拉长。把这些模式沉淀成常见的 SOP团队再遇到问题时直接用处理手册对照即可不用反复从零排查。5.3 最后一个小的失败技巧先止损再优化如果线上 1205 已经大面积爆发你千万别想着靠一份索引优化方案解决问题。优先做的动作应该是找到阻塞的源头事务确认这不是关键业务后 WLOG 里 KILL 掉关于 KILL 的使用需要确认是安全的先让业务恢复然后才谈得上优化索引、拆分事务。我见过有人在故障现场非要一查到底花了二十分钟剖析锁问题结果业务方已经炸了。先止损是运维的第一原则等系统稳住了你有的是时间慢慢看根因。毕竟1205 的背后往往不是一条 SQL 的锅而是索引、事务、隔离级别、连接池、批处理策略共同作用的结果。
返回列表