MySQL 锁问题排查实战:批量更新脚本「卡住」与 1205 超时
本文以一次真实的 UAT 环境品类税率批量更新 为例,记录从
DROP TABLE无限等待,到Lock wait timeout exceeded (1205)的完整排查过程,并梳理 MySQL 元数据锁(MDL) 与 InnoDB 行锁 的区别与应对方法。
一、背景:我在做什么?
业务场景:博思品类初始化完成后,需要将 1094 条四级品类 的 taxCategoryCode、hyTaxRate 批量写入表 md2b__category_b_o_pre(UAT)/ md2b__category_b_o(生产)。
脚本思路是典型的 Staging 表 + 批量 UPDATE:
sql
-- 1. 创建临时 staging 表
CREATE TABLE `_category_tax_stage` (...);
-- 2. INSERT 1094 条税率映射
INSERT INTO `_category_tax_stage` VALUES (...);
-- 3. 关联更新目标表
START TRANSACTION;
UPDATE `category_b_o_pre` t
INNER JOIN `_category_tax_stage` s ON t.outCode = s.outCode
SET t.taxCategoryCode = s.taxCategoryCode,
t.hyTaxRate = s.hyTaxRate,
t.updatedAt = NOW()
WHERE t.isDeleted = 0 AND t.categoryLevel = 4;
COMMIT;
-- 4. 删除 staging 表
DROP TABLE `_category_tax_stage`;
环境特点:
- UAT 共享库
trantor_workspace_staging,业务服务(kubelet Pod、商城、Console 等)并未停服 - 使用 DBeaver / DataGrip 手工执行 SQL 脚本
- 目标表
md2b__category_b_o_pre是 热表,应用连接池长期保持 Sleep 连接
在这个场景下,我连续遇到了 两类不同的「卡住」问题。
二、问题一:DROP TABLE 一直卡住
2.1 现象
执行脚本到以下语句时,客户端长时间无响应:
sql
DROP TABLE IF EXISTS `_category_tax_stage`;
CREATE TABLE `_category_tax_stage` (...);
与此同时:
SHOW TABLES LIKE '_category_tax_stage'能正常返回表名- 查询其他业务表 完全正常
看起来像是「数据库坏了」,其实只是 锁在作怪。
2.2 诊断过程
执行 SHOW FULL PROCESSLIST,发现关键一行:
text
Id: 13471574
State: Waiting for table metadata lock
Info: DROP TABLE IF EXISTS `_category_tax_stage`
ApplicationName: DBeaver 24.1.3 - Script-9.sql
说明:不是 DROP 本身慢,而是在等「元数据锁(MDL)」。
进一步用 performance_schema.metadata_locks 可以查到:
- 某个连接对
_category_tax_stage持有LOCK_STATUS = GRANTED(已占锁) - 执行 DROP 的连接是
LOCK_STATUS = PENDING(排队等待)
2.3 根因分析
第一次跑脚本时:
- 成功
CREATE+INSERT了 staging 表 - 脚本中途 Cancel、断网,或 DBeaver Auto-commit 关闭 导致事务未提交
- 该连接进入
Sleep状态,但对 staging 表仍持有 MDL 共享锁(SHARED)
第二次重跑脚本时:
DROP TABLE需要 排他 MDL(EXCLUSIVE)- 必须等所有 SHARED 锁释放
- 旧连接未释放 → DROP 无限等待
2.4 为什么 KILL「执行 DROP 的自己」没用?
这是很多人容易踩的坑。
text
连接 A(Sleep,未提交):持有 staging 表的 MDL SHARED → 占门的人(Holder)
连接 B(你):执行 DROP TABLE → 门外排队(Waiter)
连接 C(你又开窗口):再次 DROP → 继续排队(Waiter)
如果你 KILL B(正在等 DROP 的连接):
- B 死了,DROP 不执行了
- A 还活着,SHARED 锁仍在
- 表依然存在,下次 DROP 换一个新连接 C 继续等
结论:只杀 Waiter 无法解决问题,必须释放 Holder,或绕开被锁的表。
2.5 解决方案
方案 A:找到并 KILL 占 MDL 的连接
sql
SELECT
t.PROCESSLIST_ID AS id,
ml.LOCK_STATUS,
ml.LOCK_TYPE,
ml.OBJECT_NAME
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t ON t.THREAD_ID = ml.OWNER_THREAD_ID
WHERE ml.OBJECT_SCHEMA = 'trantor_workspace_staging'
AND ml.OBJECT_NAME = '_category_tax_stage';
对 LOCK_STATUS = 'GRANTED' 且不是当前查询的连接执行:
sql
KILL <holder_id>;
方案 B:绕开旧表(推荐,我们最终采用)
不再 DROP 旧表,改用新 staging 表名:
sql
CREATE TABLE IF NOT EXISTS `_category_tax_stage_v2` (...);
TRUNCATE TABLE `_category_tax_stage_v2`;
-- 后续 INSERT / UPDATE 全部走 v2
旧表 _category_tax_stage 等锁自然释放后再手动清理即可。
三、问题二:UPDATE 报 1205 行锁超时
3.1 现象
改用 _category_tax_stage_v2 后,1094 条 INSERT 全部成功。
执行 UPDATE 时报错:
text
SQL 错误 [1205] [40001]: Lock wait timeout exceeded; try restarting transaction
对应 SQL:
sql
START TRANSACTION;
UPDATE `md2b__category_b_o_pre` t
INNER JOIN `_category_tax_stage_v2` s ON t.outCode = s.outCode
SET ...
WHERE t.isDeleted = 0 AND t.categoryLevel = 4;
COMMIT;
3.2 诊断过程
查询 information_schema.innodb_trx:
| trx_mysql_thread_id | trx_started | trx_rows_locked | 说明 |
|---|---|---|---|
| 13467433 | 15:43:45 | 6036 | 占锁超过 1 小时 |
| 13473404 | 16:51:20 | 1 | 后续重试,在等 |
| 13473468 | 16:55:20 | 6 | 后续重试,在等 |
PROCESSLIST 中 13467433 的状态:
text
State: Sleep
Time: 4334 秒
Host: kubelet (应用 Pod 连接池)
Database: trantor_workspace_staging
典型特征:Sleep + innodb_trx 有记录 + trx_rows_locked 很大 → 僵尸事务(Idle in Transaction)。
3.3 根因分析
- 旧脚本用 单事务 UPDATE 上千行 ,与 UAT 应用在 同一张热表 上抢 InnoDB 行锁
- UPDATE 等待超过
innodb_lock_wait_timeout(默认 50 秒)→ 报 1205 - 更致命的是:第一次 UPDATE 的事务 没有 Rollback ,连接 13467433 进入 Sleep,仍占 6036 行 X 锁
- 之后所有 UPDATE(包括重试)都变成 Waiter,持续失败
3.4 解决方案
第一步:KILL 占行锁的 Holder
sql
KILL 13467433; -- 释放 6036 行锁
KILL 13473404;
KILL 13473468;
确认:
sql
SELECT * FROM information_schema.innodb_trx;
-- 应无 trx_rows_locked 很大的记录
第二步:分批 UPDATE,去掉大事务
将一次 UPDATE 拆成 20 批,每批 autocommit:
sql
UPDATE `category_b_o_pre` t
INNER JOIN `_category_tax_stage_v2` s ON t.outCode = s.outCode
SET t.taxCategoryCode = s.taxCategoryCode,
t.hyTaxRate = s.hyTaxRate,
t.updatedAt = NOW()
WHERE t.isDeleted = 0 AND t.categoryLevel = 4
AND MOD(CAST(s.outCode AS UNSIGNED), 20) = 0;
-- ... MOD = 1, 2, ... 19 共 20 批
第三步:验证
sql
SELECT COUNT(*) FROM category_b_o_pre
WHERE isDeleted = 0 AND categoryLevel = 4 AND hyTaxRate IS NULL;
四、核心知识点梳理
4.1 information_schema.innodb_trx 是什么?
InnoDB 当前未提交事务 的实时视图,不是历史表。
| 字段 | 含义 |
|---|---|
trx_mysql_thread_id |
连接 ID,对应 KILL 和 PROCESSLIST.Id |
trx_started |
事务开始时间 |
trx_rows_locked |
当前锁住的行数 |
trx_state |
事务状态 |
典型用途 :排查 UPDATE/DELETE 的 1205 行锁 问题。
注意:普通 SELECT 默认不进此表;MDL 问题 主要查
metadata_locks,不是innodb_trx。
4.2 MDL(Metadata Lock,元数据锁)
| 项目 | 说明 |
|---|---|
| 保护对象 | 表结构(DDL) |
| 触发语句 | DROP / CREATE / ALTER / TRUNCATE |
| 冲突规则 | DROP 需要 EXCLUSIVE,与任何 SHARED 冲突 |
| 卡住表现 | Waiting for table metadata lock |
| 诊断表 | performance_schema.metadata_locks |
为什么 SHOW TABLES 能查但 DROP 不能?
SHOW TABLES:读数据字典,轻量DROP TABLE:要独占修改表结构,必须等所有访问该表的 MDL 释放
4.3 InnoDB 行锁
| 项目 | 说明 |
|---|---|
| 保护对象 | 行数据(DML) |
| 触发语句 | UPDATE / DELETE / SELECT ... FOR UPDATE |
| 卡住表现 | Lock wait timeout exceeded (1205) |
| 诊断表 | innodb_trx + PROCESSLIST |
4.4 Holder vs Waiter:排查的第一原则
| 角色 | 典型表现 | KILL 是否有用 |
|---|---|---|
| Holder(占锁者) | Sleep + innodb_trx 有记录;MDL GRANTED |
✅ 根本解决 |
| Waiter(等待者) | Waiting for ... lock;MDL PENDING |
❌ 只取消自己 |
只杀 Waiter = 赶走排队的人,占门的人还在。
4.5 Idle in Transaction(僵尸事务)
连接已经 Sleep (没有正在执行的 SQL),但事务 未 COMMIT / ROLLBACK:
- 行锁不释放
- MDL 可能不释放
- 连接池场景下 Sleep 可达 数千秒
这是共享 UAT 环境跑批量脚本时 最危险的隐患。
五、什么场景容易遇到这类问题?
| 条件 | 风险 |
|---|---|
| UAT/生产 不停服 跑批量 DDL/DML | ⭐⭐⭐ |
| 脚本含 DROP + 反复重跑 | MDL 风险 ⭐⭐⭐ |
| 单事务大批量 UPDATE | 1205 + 僵尸锁 ⭐⭐⭐ |
| DBeaver 手动提交 / Auto-commit 关 | 中断后遗留锁 ⭐⭐⭐ |
| 多标签页 打开表数据预览 | MDL ⭐⭐ |
| 应用 连接池长连接 + 未提交事务 | 行锁 ⭐⭐⭐ |
| 更新 品类/商品/订单等热表 | 冲突 ⭐⭐⭐ |
六、最佳实践 Checklist
跑脚本前
- 选 业务低峰,或协调短暂停写
- DBeaver 开启 Auto-commit
- 单连接 执行,关闭表数据预览标签
- Staging 表用 新表名 (如
_v2),避免 DROP 旧表
脚本设计
- Staging:
CREATE IF NOT EXISTS+TRUNCATE,尽量不 DROP - UPDATE:分批 + 每批 autocommit ,避免
START TRANSACTION包全表 - 脚本末尾加 验证 SQL(matched_cnt、空值 COUNT)
卡住时(3 条 SQL 快速定位)
sql
-- 1. 未提交事务(行锁)
SELECT trx_mysql_thread_id, trx_started, trx_rows_locked
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 2. 元数据锁(DDL)
SELECT t.PROCESSLIST_ID, ml.LOCK_STATUS, ml.OBJECT_NAME
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t ON t.THREAD_ID = ml.OWNER_THREAD_ID
WHERE ml.OBJECT_SCHEMA = DATABASE();
-- 3. 谁在等什么
SHOW FULL PROCESSLIST;
处理顺序:
- 区分是 MDL 还是行锁
- 找 Holder(GRANTED / trx_rows_locked 大)
KILLHolder 或绕开被锁对象- 确认
innodb_trx干净后重试
七、我们的最终改造方案
| 改造点 | 改前 | 改后 |
|---|---|---|
| Staging 表 | DROP + CREATE 同名 | _category_tax_stage_v2 + TRUNCATE |
| UPDATE | 单事务一次更新 1094 行 | 20 批 MOD 分批 autocommit |
| 失败恢复 | 从头重跑 | staging 有数据时只重跑 UPDATE 段 |
| 环境 | UAT 热表 + 应用并发 | 低峰执行 + KILL 僵尸事务 |
八、总结
这次问题本质上不是 SQL 写错,而是 运维脚本与在线业务在共享库上抢锁 的典型场景:
- DROP 卡住 → MDL 问题 → 旧连接未释放 → 只杀 Waiter 无效 → 换 v2 表绕开
- UPDATE 1205 → InnoDB 行锁 → 僵尸事务占 6036 行 → KILL Holder + 分批 UPDATE
记住三句话:
innodb_trx看行锁,metadata_locks看 DDL- 先找 Holder,别只杀 Waiter
- 共享环境跑批量脚本:小事务、新表名、Auto-commit、低峰期
附录:相关 MySQL 参数
| 参数 | 默认值 | 说明 |
|---|---|---|
innodb_lock_wait_timeout |
50(秒) | 等行锁超时 → 1205 |
lock_wait_timeout |
31536000 | MDL 等待(通常很长,表现为「一直卡」) |
如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区一起交流 MySQL 锁排查经验。