ERROR 1206 这个报错,我最早是在一个凌晨的告警群里看到的。当时一个归档脚本刚跑起来,监控大屏突然飘红,我翻到执行日志,里面躺着一行很扎眼的家伙:
ERROR 1206 (HY000): The total number of locks exceeds the lock table size做过 MySQL 批量更新的 DBA 或后端开发,对这个错误码多半不陌生。但很多人第一次见到它时,会误以为跟死锁(1213)或锁等待超时(1205)是同一类问题,然后对着错误日志翻了大半天,也没找到死锁回路。其实 1206 的意思是:InnoDB 的锁对象存不下了。它既不是等待,也不是死锁回滚,而是“锁太多,内存里的锁表装满了”。
这篇文章我就围绕 ERROR 1206 完整梳理一遍:它到底是哪里冒出来的、为什么会在一瞬间炸出来、怎么一步步把触发源头挖出来,以及从临时止血到长期预防的整套方案。适合所有写过全表 UPDATE、DELETE,或者维护归档任务、做过数据订正的开发者和 DBA。
1. ERROR 1206 到底在说什么:错误码拆解与触发机理
1.1 先看清报错的完整面貌
完整的报错长这样:
ERROR 1206 (HY000): The total number of locks exceeds the lock table size1206 是 MySQL 客户端返回给应用层的错误码,HY000 是通用的 SQL 状态码,它不代表具体的 SQL 语法错误。这个报错的触发时机通常很固定:在一个事务还没有提交之前,InnoDB 尝试获取新的行锁时,发现当前实例里可用的锁结构已经用完,于是直接拒绝加锁,并把这条 SQL 标记为失败。
注意一个容易被忽略的细节:SQL 失败后,事务并没有自动回滚。会话还活着,前面在这个事务里已经执行成功的修改也仍然占着锁,这会让问题更难收拾。如果应用层没有正确的回滚逻辑,这个“半成品事务”会一直挂在系统里,直到应用关闭连接或超时。
我打个比方:数据库的锁表就像房间里能摆的椅子,椅子总数固定。一个 UPDATE 语句不是只搬一把椅子,它要把扫描范围内涉及到的每一行都放一把椅子上去。当椅子摆满,再有新请求要加锁,服务员只能摆手说“坐不下了”——这就是 1206。
1.2 "lock table size"到底是什么
这里有个很常见的误解:很多人以为 lock table 是 MySQL 里的一张真实存在的表,还去 information_schema 里翻有没有一张叫 lock 的表。其实不是。
InnoDB 内部有一套锁管理系统,锁对象的数据结构本身要占内存,这部分内存跟innodb_buffer_pool_size(缓冲池大小)绑定。旧版本里,锁表容量通常按缓冲池大小的比例来估算,社区资料里常提到大约 1/16 左右。新版本对锁内存的管理做了一些调整,但这个基本约束没有变:缓冲池越大,能容纳的锁对象越多;缓冲池小,遇到大事务就很容易触发 1206。
我们可以通过下面这行命令查看缓冲池大小:
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';如果返回结果是8589934592,也就是 8GB,按老版本 1/16 的比例粗估,锁表容量大约相当于 512MB。听起来不小吧?但 InnoDB 的每一把记录锁并不是只占 1 个字节,而是几十到上百字节不等的锁结构。当一条 SQL 要加几十万甚至几百万把锁时,512MB 的锁空间,分分钟就能被吃穿。
1.3 一次 UPDATE 为什么会引爆海量锁
同样是更新一万行,为什么有时候没事,有时候就 1206?关键在于加锁范围不是由最终修改行数决定的,而是由扫描到的记录数决定的。
MySQL 默认的隔离级别是REPEATABLE READ(可重复读)。为了阻止幻读,InnoDB 在扫描辅助索引时,不仅会给满足条件的记录加 X 锁(排他锁),还会给这些记录前后的区间加间隙锁(Gap Lock)。如果你执行:
UPDATE user SET status = 'expired' WHERE last_login_time < '2024-01-01';假设这个条件命中了 300 万行,那么 InnoDB 不是只锁 300 万行就完事,而是很可能把索引扫描区间内碰到的记录全部加锁。更麻烦的是,通过辅助索引去更新时,主键索引里的聚簇记录也要加锁。也就是说,辅助索引上命中一行,主键上往往还得再来一把锁。
所以一条“看起来没那么大”的 UPDATE,最后实际产生的锁对象数量可能是命中行数的两倍甚至更多。8GB 缓冲池的实例,遇到 200 万行级扫描,1206 几乎是必然结果。
2. 从一次线上故障说起:锁表溢出的完整排查链路
2.1 故障现场还原
有一回合作项目组在做夜间数据订正,目标很简单:把一张三个月未登录的用户表打上“过期”标记。白天查询都正常,可一到脚本执行后就报 1206。当时值班同学第一反应是“是不是死锁了”,查了好一会儿错误日志,根本没有死锁记录。后来才有人注意到报错信息里的 1206 和死锁的 1213 并不是一个码。
我接手后没有直接去调参数,而是按下面这套链路一步步查,最后发现真正的问题不是缓冲池太小,而是一个长事务叠加了一条大范围 UPDATE,直接把锁水位拉爆了。
这类问题的排查思路是可以复用的,我按步骤拆开讲。
2.2 排查第一步:确认是哪个事务在加锁
拿到报错后,第一件事永远是查当前有哪些活跃事务,而不是先去分析那条 SQL。
SELECT * FROM information_schema.innodb_trx\G重点看这几个字段:
trx_id:事务 ID,用来定位具体连接。trx_state:事务状态,重点关注 RUNNING 或 LOCK WAIT。trx_started:事务启动时间,长事务一眼就能看出来。trx_rows_locked:当前事务锁定的行数。trx_rows_modified:当前事务修改的行数。
如果trx_rows_locked显示十几万甚至上百万,那基本可以断定,就是这个事务把锁表塞满了。
在 MySQL 8.0 上,还可以进一步查performance_schema.data_locks,它能列出更细粒度的锁记录,帮你看清楚锁都集中在哪张表、哪个索引、哪个区间:
SELECT * FROM performance_schema.data_locks\G5.7 版本则对应information_schema.innodb_locks。注意,如果锁已经爆了,事务可能还挂着没提交,先别急着 kill,先把锁的来源看清。
2.3 排查第二步:算一算这条 SQL 到底会加多少把锁
定位到 SQL 之后,不要凭感觉判断“我更新的是 10 万行,应该没事”。到测试库或者备库上跑一下执行计划:
EXPLAIN SELECT * FROM user WHERE last_login_time < '2024-01-01';再看实际扫描范围:
SELECT COUNT(*) FROM user WHERE last_login_time < '2024-01-01';比如执行计划显示走的是idx_last_login_time,但rows这一列可能显示 600 万。那就意味着,InnoDB 在索引扫描过程中至少要处理 600 万条记录,再加间隙锁和主键锁,锁对象数量根本不是你能从业务行数预估出来的。
还要把这些数量和锁内存上限对应起来。假设innodb_buffer_pool_size是 8GB,锁表容量按 1/16 粗估是 512MB,每把锁按 128 字节估算,能撑住的锁对象大约在 400 万到 500 万这个量级。如果你的 SQL 要加 600 万把锁,那 1206 就是板上钉钉的事。
2.4 排查第三步:排除“假性 1206”
还有一种经常被忽视的情况:1206 不是当前这条 SQL 自己造成的,而是系统里早有别的事务一直在积累锁。
比如业务代码在程序里写了个循环:
START TRANSACTION; for each record in list: UPDATE ... -- 整个循环结束才 COMMIT循环到几万行时,事务已经锁住了几万行不释放。此时另一个脚本再执行一个稍大的 INSERT ... SELECT,两边的锁对象一叠加,就把锁表顶穿了。报错虽然出现在第二个事务上,但根子可能在第一个事务那里。
这个排查思路很重要:看到 1206,不要只盯着报错那条 SQL,要看整个实例的锁总量是不是已经被别的事务抬高了。
3. 解决 1206 的四板斧:按优先级依次上手
3.1 第一板斧:把大事务切成小批次
所有解决 1206 的方案里,性价比最高的永远是分批处理。一个小事务锁几百行、几千行,提交完释放锁,再去处理下一批。锁的峰值被压住,1206 自然就不会出现。
分批的方式可以按场景选,我列一张对比表:
| 分片方式 | 适用场景 | 优点 | 需要注意的点 |
|---|---|---|---|
| 主键分段法 | 主键连续或近似连续的表 | SQL 简单直观,每批边界清晰 | 稀疏主键下会扫到多余区间,效率略低 |
| 游标法 | 过滤条件复杂、无法用主键均匀分段的场景 | 边遍历边处理,不会漏数据 | 游标本身有额外开销,需要控制每次循环的行数 |
| 时间窗口法 | 数据带时间字段、按天/小时归档 | 贴合业务语义,锁范围天然可控 | 时间字段没有索引时,每批扫描都可能全表查 |
以主键分段法为例,假设要给三个月未登录的用户打过期标记:
-- 第一批:处理 id 1 到 10000 UPDATE user SET status = 'expired' WHERE id BETWEEN 1 AND 10000 AND last_login_time < '2024-01-01'; -- 立刻提交 COMMIT;然后接着从 10001 继续下一批,直到全部处理完。每批行数建议控制在 1000 到 5000 之间,具体要看单行锁内存和当前 QPS 的余量。分批之后,除了 1206 消失,主从延迟、undo log 膨胀这些连锁问题也都会跟着好转。
3.2 第二板斧:降低隔离级别,减少间隙锁
默认REPEATABLE READ下,大范围 UPDATE 会产生大量间隙锁,这是锁数量爆炸的重要推手。如果业务允许,把隔离级别切到READ COMMITTED,InnoDB 加锁时只锁记录本身,不再额外加间隙锁,锁对象的数量可能直接减半甚至更多。
切换方式有两种,会话级和全局级:
-- 会话级 SET SESSION transaction_isolation = 'READ-COMMITTED'; -- 全局级(影响新建立的连接) SET GLOBAL transaction_isolation = 'READ-COMMITTED';为什么说“如果业务允许”?因为可重复读是 MySQL 默认的隔离级别,它对同一事务内多次 SELECT 的结果一致性有保障。如果你们的业务里有需要在事务内做多次查询并对账、生成报表的场景,降级前一定要先做兼容性测试。
我实测下来的感受是,绝大多数订单、日志、用户标签类业务,切 RC 几乎无感,但大批量 UPDATE 的锁数量下降却非常明显。这也是很多高并发团队从一开始就默认使用 RC 的原因之一。
3.3 第三板斧:合理加大缓冲池,但别当主方案
既然 1206 的本质是锁内存不够,把innodb_buffer_pool_size调大当然能缓解。锁表容量跟着缓冲池走,缓冲池大了,能容纳的锁对象就多。
但在调整之前,请先想清楚两个代价:
第一,innodb_buffer_pool_size是静态参数,改完要重启 MySQL 实例才生效。对于在线服务,重启意味着连接断开,需要维护窗口。
第二,调大缓冲池只是抬高容量上限,并没有降低锁数量本身。你今天把锁表容量从 500 万把提到 1000 万把,明天来一个扫描 1500 万行的任务,该报还是报。
我的建议是:把它当作应急止血手段,上限按物理内存的 50% 到 70% 来控制,精力仍然要花在分批和隔离级别上。如果一台机器物理内存只有 16GB,你硬要配 12GB 的缓冲池,剩下的内存给 OS、连接线程、排序缓冲留的空间就太紧了,反而会引入新的性能问题。
3.4 第四板斧:从工具和架构层面动手
如果这种大范围更新不是偶发任务,而是每天都在跑的数据归档或清理,那就不应该靠手写 SQL 硬扛了。
更合理的做法是引入专业的数据归档工具,这类工具天然会分批推进,每处理一批就提交一批,不会在一个事务里累计几十万把锁。或者把表改成按时间分区,删旧数据直接DROP PARTITION,这是 DDL 级别的操作,根本不涉及逐行加锁,1206 想出现都难。
说句实在话,业务如果长期依赖“一次性 UPDATE 几十万行”来运转,1206 只是浮在水面上的冰山一角,水下还压着 undo 膨胀、主从延迟、错误排查困难这一大堆问题。做数据生命周期管理,从设计上拆掉大事务,才是正解。
4. 那些容易误入的歧途:错误示范与正确姿势对比
4.1 一个典型错误示范
我在项目评审里见过一个“订正脚本”,核心逻辑简化后长这样:
START TRANSACTION; UPDATE user SET level = level + 1 WHERE register_time < '2023-06-01'; UPDATE user SET status = 'vip' WHERE level > 10; INSERT INTO user_log SELECT ... FROM user WHERE status = 'vip'; COMMIT;写这个脚本的同学说,他已经在测试环境跑完了,没报错。可测试环境的表只有几万行,生产环境那个表 2000 多万行。第一条 UPDATE 扫过几百万行,锁已经占了很多;第二条又叠加一个几百万行的扫描;到了第三条 INSERT ... SELECT 时,新锁加不进去,1206 瞬间炸出来。
这个例子暴露了两个经典的错误认知:
第一,用测试环境的小数据量验证生产环境的大数据量任务,完全忽略了锁数量跟数据规模的关系。第二,把多个大范围操作塞进同一个事务,锁的峰值随着语句数量线性叠加,而不是按单条语句计算。
4.2 误区清单
在解决 1206 的过程中,我还见过不少绕远路的做法,这里集中列一下:
误区一:加个索引就能避免 1206。
索引确实能缩小扫描范围,减少间隙锁,但它不能减少你要修改的行数本身。你的 UPDATE 最终要更新 200 万行,那 200 万行对应的锁就不会少。索引解决的是“定位慢”的问题,不是“锁太多”的问题。
误区二:把 buffer pool 调大就一劳永逸。
前面已经反复强调过,调大缓冲池只是抬高了锁表容量,事务产生的锁数量没变。碰到更大的批量任务,该报还是报。而且缓冲池调大之后,InnoDB 的刷盘策略、内存占用、冷数据淘汰都会跟着变化,不是没有副作用的。
误区三:1206 和死锁一样,等一会自己就恢复。
1206 不是锁等待,它不会等待某个事务释放资源后自动重试。它是直接拒绝加锁,SQL 秒级失败。很多同学以为“等会儿再查一下状态就恢复了”,结果等了半个小时,事务还在那里,锁也还在那里,白白浪费了处理窗口期。
我整理了一张速查表,方便以后一眼区分:
| 错误码 | 含义 | 处理方向 |
|---|---|---|
| 1205 | 等待锁超时,拿不到锁 | 优化锁等待时长,减少持锁时间 |
| 1206 | 锁数量超过锁表容量,拒绝加锁 | 分批、减少间隙锁、扩容锁内存 |
| 1213 | 死锁,InnoDB 自动回滚一个事务 | 优化事务顺序,统一加锁顺序 |
4.3 正确姿势:一段可复用的“分批更新”模板
下面是一个简化版的存储过程模板,适合大批量 UPDATE 场景。它按主键分段循环,每批提交一次,不会把锁累积成大峰值:
DELIMITER $$ CREATE PROCEDURE batch_update_user_status(IN p_batch_size INT) BEGIN DECLARE v_min_id BIGINT DEFAULT 0; DECLARE v_max_id BIGINT DEFAULT 0; SELECT MIN(id), MAX(id) INTO v_min_id, v_max_id FROM user WHERE last_login_time < '2024-01-01'; WHILE v_min_id <= v_max_id DO UPDATE user SET status = 'expired' WHERE id BETWEEN v_min_id AND v_min_id + p_batch_size - 1 AND last_login_time < '2024-01-01'; COMMIT; SET v_min_id = v_min_id + p_batch_size; END WHILE; END$$ DELIMITER ;调用时传一个批次大小,比如 5000:
CALL batch_update_user_status(5000);这里有个容易被忽略的工程细节:下一批的起点不要简单用v_min_id + p_batch_size去猜,更稳妥的做法是每批处理完取当前批次的MAX(id)作为下一批起点。如果表里的主键存在大量空洞,固定步长会扫描一些本来就没数据的区间,影响不大,但如果是按时间窗口分批,漏数据的问题就麻烦了。上面这个模板胜在简单直观,适合大多数主键分布均匀的表;主键稀疏或业务上不允许漏数据时,记得改成基于游标或MAX(id)的推进方式。
5. 平时怎么预防 1206:监控水位与设计兜底
5.1 监控层面
1206 这种问题,靠人工盯是盯不过来的,建议把下面几条 SQL 加到巡检脚本里,定期执行:
第一条,查长时间未提交事务:
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING' ORDER BY trx_started ASC;第二条,统计锁对象总量,MySQL 8.0 可以这样:
SELECT COUNT(*) AS total_lock_objects FROM performance_schema.data_locks;第三条,把innodb_buffer_pool_size记录下来,换算锁水位。比如锁对象总量持续高于某个阈值,就要告警。
正常的系统,锁对象数量应该很低,个位数到几十都很正常。一旦数据持续上涨,比如达到锁表容量估算值的 30% 以上,就要留意是不是有长事务或大事务在积累锁。这样可以在事务膨胀到 1206 之前,提前介入处理。
5.2 设计层面
从更长远的角度,我有四条关于设计层面的建议,都是从实际项目里沉淀出来的:
- 上线任何大批量数据操作前,先跑一遍
COUNT(*)估算影响行数,行数超过阈值就必须拆批。 - 数据删除和归档必须走分批流程,禁止一条
DELETE FROM全表。 - 业务事务要短平快,循环更新必须按批提交,不允许在循环里累积锁。
- 内存规划时,给锁内存留足余量,但不要把宝全押在内存上,拆事务才是治本。
如果你们团队经常要和这类问题打交道,还可以把“大事务检测”做成开发规范里的强制检查项:凡是 UPDATE、DELETE 语句,执行计划的扫描行数超过预设阈值的,一律要求重写为分批处理。
5.3 复盘清单
最后再给一份复盘清单,下次遇到 1206,按顺序过一遍,基本能把问题锁定在可控范围:
- 报错 SQL 影响多少行?执行计划扫描了多少行?走没走索引?
- 当前实例里有没有长事务?
innodb_trx里trx_rows_locked是不是已经很高? - 当时的事务隔离级别是什么?能不能降到
READ COMMITTED? - 锁表容量大概是多少?和当前锁数量之间还剩多少余量?
- 这个任务能不能拆批?拆批之后锁峰值能降到多少?
把这些问题全部过一遍,1206 基本不会再困扰你。
最后聊一点个人实操感受。
经历了那么多次 1206 之后,我最深的体会是:遇到这个报错,最忌讳的就是把它当作偶发错误,重启一遍或者重跑一次就翻篇。相同的报错背后,可能是完全不同的原因——缓冲池真的太小、长事务一直不提交、隔离级别导致间隙锁膨胀、批量任务设计本身就不合理。不把锁数量和锁内存水位这两条线查清楚,下一次遇到更大的任务,问题只会爆得更猛烈。
我在线上处理过的几起 1206,最终几乎都是靠分批和降低隔离级别解决的,调内存只是临时稳住了局面。另外再分享一个小技巧:想快速理解 1206,可以在测试环境把innodb_buffer_pool_size临时调得很小,比如 256MB,然后执行一次大范围 UPDATE,几秒钟就能复现。用这种方式去验证你的分批逻辑和 RC 切换效果,比在生产上硬碰硬稳妥得多。希望这篇文章能帮你看清 1206 的真面目,下次再碰到,心里先有底。