大家好,我是数据库小学妹👋我踩过的坑,你别再踩。
周四下午两点多,开发小刘在群里喊我:订单表卡死了,所有查询都在转圈。我第一反应是有慢查询,登上去翻了一遍才发现不是。没有任何一条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这些年我最大的体会:一半时间在救火,另一半得用来想怎么让火别烧起来。
你遇到过这种整表突然全堵的事吗?当时查了多久才定位到源头?评论区聊聊,我猜不少人的第一反应跟我一样,也是先去看慢查询。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋