MySQL 锁问题排查实战:批量更新脚本「卡住」与 1205 超时(MDL / 行锁完整复盘)

MySQL 锁问题排查实战:批量更新脚本「卡住」与 1205 超时

本文以一次真实的 UAT 环境品类税率批量更新 为例,记录从 DROP TABLE 无限等待,到 Lock wait timeout exceeded (1205) 的完整排查过程,并梳理 MySQL 元数据锁(MDL)InnoDB 行锁 的区别与应对方法。


一、背景:我在做什么?

业务场景:博思品类初始化完成后,需要将 1094 条四级品类taxCategoryCodehyTaxRate 批量写入表 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 根因分析

第一次跑脚本时

  1. 成功 CREATE + INSERT 了 staging 表
  2. 脚本中途 Cancel、断网,或 DBeaver Auto-commit 关闭 导致事务未提交
  3. 该连接进入 Sleep 状态,但对 staging 表仍持有 MDL 共享锁(SHARED)

第二次重跑脚本时

  1. DROP TABLE 需要 排他 MDL(EXCLUSIVE)
  2. 必须等所有 SHARED 锁释放
  3. 旧连接未释放 → 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 后续重试,在等

PROCESSLIST13467433 的状态:

text 复制代码
State: Sleep
Time: 4334 秒
Host: kubelet (应用 Pod 连接池)
Database: trantor_workspace_staging

典型特征:Sleep + innodb_trx 有记录 + trx_rows_locked 很大僵尸事务(Idle in Transaction)

3.3 根因分析

  1. 旧脚本用 单事务 UPDATE 上千行 ,与 UAT 应用在 同一张热表 上抢 InnoDB 行锁
  2. UPDATE 等待超过 innodb_lock_wait_timeout(默认 50 秒)→ 报 1205
  3. 更致命的是:第一次 UPDATE 的事务 没有 Rollback ,连接 13467433 进入 Sleep,仍占 6036 行 X 锁
  4. 之后所有 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,对应 KILLPROCESSLIST.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;

处理顺序

  1. 区分是 MDL 还是行锁
  2. Holder(GRANTED / trx_rows_locked 大)
  3. KILL Holder 或绕开被锁对象
  4. 确认 innodb_trx 干净后重试

七、我们的最终改造方案

改造点 改前 改后
Staging 表 DROP + CREATE 同名 _category_tax_stage_v2 + TRUNCATE
UPDATE 单事务一次更新 1094 行 20 批 MOD 分批 autocommit
失败恢复 从头重跑 staging 有数据时只重跑 UPDATE 段
环境 UAT 热表 + 应用并发 低峰执行 + KILL 僵尸事务

八、总结

这次问题本质上不是 SQL 写错,而是 运维脚本与在线业务在共享库上抢锁 的典型场景:

  1. DROP 卡住 → MDL 问题 → 旧连接未释放 → 只杀 Waiter 无效 → 换 v2 表绕开
  2. UPDATE 1205 → InnoDB 行锁 → 僵尸事务占 6036 行 → KILL Holder + 分批 UPDATE

记住三句话:

  1. innodb_trx 看行锁,metadata_locks 看 DDL
  2. 先找 Holder,别只杀 Waiter
  3. 共享环境跑批量脚本:小事务、新表名、Auto-commit、低峰期

附录:相关 MySQL 参数

参数 默认值 说明
innodb_lock_wait_timeout 50(秒) 等行锁超时 → 1205
lock_wait_timeout 31536000 MDL 等待(通常很长,表现为「一直卡」)

如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区一起交流 MySQL 锁排查经验。