MySQL长事务复盘:Sleep连接、MDL排队与undo滞留

大家好,我是数据库小学妹👋我踩过的坑,你别再踩。

周四下午两点多,开发小刘在群里喊我:订单表卡死了,所有查询都在转圈。我第一反应是有慢查询,登上去翻了一遍才发现不是。没有任何一条SQL特别慢,是整张表的新请求全被堵住了,谁都不动。顺着连接和锁一路挖下去,根子是一条中午12点多就该提交的UPDATE。小刘跑完订正就去吃饭了,事务一直没提交,在后台空挂了七十多分钟。等我定位到源头,离出事已经快一个小时。

今天想把这条链完整拆一遍,从processlist里那行Sleep说起,到ALTER怎么把全表读写一起堵死,再到后台悄悄涨起来的undo,一整套都捋清楚。

一、现象:全表冻结,先看processlist

遇到卡死,我习惯先拉一把线程。SHOW FULL PROCESSLIST一敲,问题基本就现形了。

text 复制代码
+-----+---------+--------+--------+------+-------------------------------+------------------------------------+
| Id  | User    | db     | Command| Time | State                         | Info                               |
+-----+---------+--------+--------+------+-------------------------------+------------------------------------+
| 213 | dba_dev | appdb  | Sleep  | 4320 |                               | NULL                               |
| 187 | admin   | appdb  | Query  | 300  | Waiting for table metadata lock| ALTER TABLE orders ADD INDEX ...    |
| 156 | api_rw  | appdb  | Query  | 120  | Waiting for table metadata lock| SELECT * FROM orders WHERE ...      |
| 155 | api_rw  | appdb  | Query  | 118  | Waiting for table metadata lock| UPDATE orders SET ...               |
+-----+---------+---------+--------+------+-------------------------------+------------------------------------+

看State那一列,一大半会话都是Waiting for table metadata lock,堵点全指向orders。真正被卡住的不是某一条SQL,是所有要碰这张表的操作。

最扎眼的反而是Id 213:Command是Sleep,Time已经4320秒,Info空着。没经验的人会把它当成普通空闲连接直接跳过,可它才是整条链的源头。最坑的也在这:事务还开着,命令状态却是Sleep,光看processlist根本发现不了。

二、定位元凶:一条命令看排队,一条命令看事务

MDL的排队关系,直接查performance_schema.metadata_locks这张表就能看明白。

sql 复制代码
SELECT OBJECT_SCHEMA, OBJECT_NAME,
       LOCK_TYPE, LOCK_STATUS, PROCESSLIST_ID
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t
  ON t.THREAD_ID = ml.OWNER_THREAD_ID
WHERE OBJECT_SCHEMA = 'appdb' AND OBJECT_NAME = 'orders';
text 复制代码
+---------------+-------------+---------------+-------------+----------------+
| OBJECT_SCHEMA | OBJECT_NAME | LOCK_TYPE     | LOCK_STATUS | PROCESSLIST_ID |
+---------------+-------------+---------------+-------------+----------------+
| appdb         | orders      | SHARED_WRITE  | GRANTED     | 213            |
| appdb         | orders      | EXCLUSIVE     | PENDING     | 187            |
| appdb         | orders      | SHARED_READ   | PENDING     | 156            |
| appdb         | orders      | SHARED_WRITE  | PENDING     | 155            |
+---------------+-------------+---------------+-------------+----------------+

看到这张表,问题就清楚了:213攥着SHARED_WRITE不放,187的ALTER要EXCLUSIVE,在排队。156、155这些SELECT、UPDATE又排在ALTER后头。一个等一个,全堵在orders上。

接着得搞清213到底开了个什么事务。查information_schema.innodb_trx

sql 复制代码
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_seconds,
       trx_mysql_thread_id, trx_query, trx_rows_modified
FROM information_schema.innodb_trx
ORDER BY trx_started;
text 复制代码
+----------+-----------+---------------------+-------------+---------------------+-----------+-----------------+
| trx_id   | trx_state | trx_started         | run_seconds | trx_mysql_thread_id | trx_query | trx_rows_modified|
+----------+-----------+---------------------+-------------+---------------------+-----------+-----------------+
| 231472   | RUNNING   | 2026-09-04 13:36:12 | 4320        | 213                 | NULL      | 3125643         |
+----------+-----------+---------------------+-------------+---------------------+-----------+-----------------+

trx_state是RUNNING,trx_query却是NULL,trx_rows_modified三百多万行。这三个值凑一起,事情基本就清楚了:SQL早就跑完,当前没有语句在跑,事务却一直没提交,还改了一票大的。这种状态圈里叫idle in transaction。活干完了,事务不结束,锁全攥在手里。小刘中午跑订正,UPDATE执行完没提交,窗口一挂就是一下午,人早去吃饭了。

三、为什么一条UPDATE能堵住整张表:MDL的排队机制

先说个矛盾:213改的是订单表里不同的行,按说行锁不该堵住全表。真正把整张表卡住的不是行锁,是MDL(元数据锁)。这个点很多人搞混,值得拆开讲。

DML执行时会在表上加一把SHARED_WRITE元数据锁,而且要到事务提交才释放。ALTER这类DDL要的是EXCLUSIVE锁,和SHARED_WRITE互斥,只能等213先交。如果只到这一步,顶多ALTER自己等一等,全表不至于冻住。

真正的坑在排队规则。MySQL的MDL有个防止写者饿死的机制:一旦有EXCLUSIVE在排队,后面新到的SHARED_READ、SHARED_WRITE就算彼此能共存,也得老实排在EXCLUSIVE后面。于是187的ALTER一排队,156的SELECT、155的UPDATE全被挡在门外。ALTER不是自己慢,它是把整张表的口子堵死了。

还有个更阴的坑,MDL的等待超时跟行锁不是一套参数。

锁类型 谁在等 超时参数 默认值
MDL ALTER等DDL lock_wait_timeout 31536000秒(一年)
行锁 UPDATE/DELETE改同一行 innodb_lock_wait_timeout 50秒

行锁等50秒会自己报错退出,MDL默认却是一年。所以那条ALTER安安静静站队,既不超时也不报错,整张表就这么被拖了40分钟,中间没有任何环节会主动喊停。

那UPDATE自己改过的三百多万行呢?它们身上的记录锁也要等事务结束才释放,后面想改这些行的DML会先卡在行锁上。但这次行锁不是主角,MDL才是。两套锁分不清,排查就会跑偏。

四、后台还在胀:长事务把purge钉死了

锁堵在明面上,一眼能看见。长事务留下的不止这笔,还有一层看不见的,叫undo膨胀。这笔一样得算。

小刘那个事务不是一上来就UPDATE的。他先跑SELECT确认要改哪些数据,看完才动手。在MySQL默认的RR(可重复读)隔离级别下,这条SELECT会创建一个一致性快照ReadView,快照要活到事务结束才算完。

问题出在这:InnoDB的purge线程清理历史版本时,只清得掉"比所有活跃ReadView都旧"的版本。213把ReadView钉在下午一点多,等于把purge的水位线锁死。之后别的事务提交产生的历史版本全清不掉,History list length只能一路涨。

sql 复制代码
SHOW ENGINE INNODB STATUS\G
text 复制代码
------------
TRANSACTIONS
------------
History list length 26895

UPDATE那三百多万条undo(也就是改动前的旧值,回滚时全靠它)也得一直留着,事务不结束就释放不了。undo表空间的truncate一样被活跃事务挡住,只涨不缩。真拖到晚上,磁盘告警就会来敲门。长事务的可怕不在它自己慢,在它把一堆后台机制全卡住了。

五、抉择:commit还是kill

锁和undo都看清了,剩下的就是动手。小刘能叫回来最好办:UPDATE早执行完,只差提交,让他补个COMMIT,锁立刻释放,订正也保住,几乎零成本。

联系不上人的时候才考虑kill,而且姿势要对。现在没有语句在跑,KILL QUERY 213没有任何东西可杀,必须KILL 213断掉整个会话,让InnoDB回滚整个事务。这一下是三百多万行的回滚,InnoDB要拿着undo把数据一条条翻回原样,期间IO和负载都不小。回滚进度可以用SHOW ENGINE INNODB STATUS盯着,看那个事务什么时候消失。

我的标准很粗暴:改动本来就做错了、要撤,直接KILL,别犹豫。SQL跑完了、只差提交,优先找人commit。为一次没必要的回滚把生产IO打满,不划算。

小刘最后两分钟补了COMMIT,阻塞解除,ALTER走完,查询恢复。业务侧攒了一堆超时告警,整张表堵了大概40分钟。

六、复盘:三个环节叠出来的事故

回头看,这不是一条SQL的锅,是三个环节叠在了一块:长事务没人看见,ALTER恰好撞上来,中间没人兜底。

最冤的是那条ALTER吗?我不这么看。它只是正常执行,结果成了压垮业务的最后一根稻草。真正的根子在没人管的长事务,还有"想加索引就加索引"的变更习惯。锁只是表象,根上是事务纪律。

七、治理:让长事务可见,让DDL别裸奔

治理也就照着这三个环节补。

先让长事务可见。MySQL没有内置的idle-in-transaction超时,只能自己盯。innodb_trx里trx_query为空、又跑得久的,就是重点怀疑对象。我们后来加了个定时任务:事务超过60秒就报群,超过300秒直接拉清单。

sql 复制代码
SELECT trx_mysql_thread_id, trx_started,
       trx_query IS NULL AS idle_in_trx,
       trx_rows_modified
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

再用pt-kill这类工具兜底,超过N秒的idle in transaction自动断掉,别等它酿成事故。

再立变更纪律。数据订正必须分批、走短事务:几条一批,一批一提交。应用层别把autocommit关了又忘开,事务里也别夹"人肉确认"这种等待。用Spring这类事务框架,就显式配超时,别让一个@Transactional把事务无限挂下去。

最后给DDL上保险。动ALTER前,先跑一遍sys.schema_table_lock_waits,看看有没有长事务蹲着。大表加索引、加字段,能上gh-ost就上gh-ost,在线变更不吃MDL排队这一套。实在要用原生ALTER,就把lock_wait_timeout从默认的一年调成能接受的值,比如60秒,让DDL等不起就自己放弃,而不是拖着全表陪葬。

sql 复制代码
SET SESSION lock_wait_timeout = 60;

避坑清单

长事务最坑的就是看不见。它不像死锁会主动报错,processlist里只是个Sleep连接,安安静静攥着锁,等DDL撞上来,整张表才一起炸。别等出事再查,把innodb_trx盯起来,60秒以上就告警,比什么都管用。

表被堵的时候,先分清是MDL还是行锁。State是Waiting for table metadata lock,就走metadata_locks查DDL排队;报Lock wait timeout exceeded,才去查行锁。方向错了,会在processlist里绕半天。MDL默认等一年,很多人不知道,它才是"一堵堵全表"的元凶。

kill之前一定先看trx_query和trx_rows_modified。SQL跑完、只差提交的,找人commit比kill划算太多。真到要kill那步,记住idle in transaction要断会话用KILL,不是KILL QUERY。真回滚三百多万行,够你喝一壶,别手滑。

写在最后

这次事故里,ALTER最冤,它只是按规矩办事。真正该收拾的是那条没人管的长事务,还有随手就加索引的习惯。锁的账算到最后,算的都是事务纪律。长事务盯住了,死锁、MDL阻塞、从库延迟会跟着少一大截。干DBA这些年我最大的体会:一半时间在救火,另一半得用来想怎么让火别烧起来。

你遇到过这种整表突然全堵的事吗?当时查了多久才定位到源头?评论区聊聊,我猜不少人的第一反应跟我一样,也是先去看慢查询。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关推荐
云泽8081 小时前
Linux 系统编程实战:从零手写一个高性能终端进度条
linux·运维·服务器
计算机毕设定制辅导-无忧学长1 小时前
基于Spring Boot的玄幻小说个性化推荐平台设计与实现
java·vue.js·spring boot·后端·mysql·推荐算法
半摆烂日常2 小时前
自建WMS和买成品:三年成本对比
大数据·服务器·数据库·python·深度学习·低代码·numpy
风123456789~2 小时前
【架构专栏】第6章 数据库设计基础知识 1/4
数据库·架构
Nturmoils2 小时前
异构数据同步不只追延迟:用 KFS 守住不停机迁移的每一笔账
数据库
星星落进兜里2 小时前
Redis 内存缓存,面试补充
数据库·redis·面试
Lsetea3 小时前
ACME 报 rate limit:Let‘s Encrypt 限速类型与重试策略
运维·https·证书·ssl·acme
棣廷3 小时前
初识MySQL——DQL数据查询语言详解
数据库·mysql
大模型码小白3 小时前
ChatGPT 的回答也能被结构化抓取?AI Bot Scraper 对比测评
服务器·开发语言·数据库·人工智能·spring·chatgpt