一条 ALTER 跑了六分十二秒:MDL 与 online DDL 的边界
加个字段的 DDL 在测试环境 0.2 秒,生产上却把整张表锁了六分钟。一个 Sleep 状态的空闲连接挂着 MDL,后面所有读写都排在 ALTER 后面。从 performance_schema 定位到 gh-ost 兜底。
周三上午十点,群里弹出一条告警:订单库写入超时率 43%。几乎同一分钟,另一个同事说,他刚在生产上执行了一条 ALTER TABLE orders ADD COLUMN channel VARCHAR(32) DEFAULT NULL。两条消息隔了十几秒,我心里大概就有数了。
这条 DDL 是评审时定下的,加一个来源渠道字段。测试环境跑过,0.2 秒返回。生产上这张表三千多万行,我们确认过 MySQL 8.0 支持 online DDL,加列属于 INSTANT,理论上不重建表、不锁表,所以连变更窗口都没申请,挑了上午十点这个「业务不算高峰」的时段。
事实是它跑了六分十二秒。这六分钟里,这张表上的所有读写全部卡住,连接池三十秒后被占满,下单链路跟着雪崩。更难受的是后半段:我把这条 ALTER kill 掉之后,写入并没有立刻恢复,又过了四十秒才缓过来。
一、先看现场:线程都在等什么
止血之后现场已经被我 kill 掉了,我先在测试库上把状态复现了一遍——两个会话,一个开着事务不提交,另一个执行 ALTER:
mysql> SELECT id,user,host,command,time,state,LEFT(info,30) AS info
-> FROM information_schema.processlist WHERE db='ispt_order'\G
*************************** 1. row ***************************
id: 48213
user: app
host: 10.20.5.3
command: Sleep
time: 4218
state: NULL
info: NULL
*************************** 2. row ***************************
id: 48310
command: Query
time: 186
state: Waiting for table metadata lock
info: ALTER TABLE orders ADD COLUMN chan
*************************** 3. row ***************************
id: 48312
command: Query
time: 174
state: Waiting for table metadata lock
info: INSERT INTO orders ...
第一行最刺眼:一个 Sleep 状态的连接躺了七十分钟,Info 是 NULL,看起来完全无害,但就是它把整张表锁死了。阻塞 DDL 的往往不是「正在跑的慢 SQL」,而是「开着事务却什么都不干」的空闲连接。
二、MDL 不是行锁,也不是表锁
MySQL 里三层锁要分清。行锁来自 InnoDB,锁索引记录;表锁来自存储引擎层,LOCK TABLES 那种;而 MDL 来自 Server 层,保护的是「表结构」这个对象,跟存储引擎没关系。
MDL 是 5.5 引入的,防止「一边查数据、一边改表结构」:查询开始时表有 5 列,执行到一半被改成 3 列,结果集就对不上了。规则是任何访问表的语句都自动申请 MDL,读拿 SHARED_READ,写拿 SHARED_WRITE,两者兼容;DDL 要拿 EXCLUSIVE,跟前两者互斥。
致命的是释放时机。MDL 的持有周期是事务,不是语句。一条 SELECT 执行完,事务没结束,SHARED_READ 就一直不放。线上默认 autocommit=1,单条语句即事务,所以平时看不出来;一旦有人 START TRANSACTION 忘了提交,锁就挂在那儿了。
-- 会话 A:一个「看起来无害」的长事务
SET autocommit = 0;
SELECT COUNT(*) FROM orders WHERE id = 1; -- 拿到 SHARED_READ,事务不结束不释放
-- 会话 B:这条 ALTER 会一直等着
ALTER TABLE orders ADD COLUMN channel VARCHAR(32) DEFAULT NULL;
lock_wait_timeout 默认 31536000 秒,整整一年。只要那个空闲事务不结束,这条 ALTER 可以等一年,期间不会有任何报错。
三、online DDL 的 online 只指执行阶段
我最初说「加列不锁表」,这句话在 8.0.12 之后对了一半。INSTANT 确实让加列只改数据字典,不重建表、不改数据页,执行阶段毫秒级。但 DDL 的生命周期至少三段:先申请 MDL,表上已有未提交事务持 SHARED_READ 就在这里等;然后执行,这一段才是 INSTANT 优化的对象;最后提交并再次确认 MDL。我们卡在第一段,六分钟全花在等锁上。
更糟的是排队规则。MDL 队列 FIFO,ALTER 进入队列后,新来的 SELECT 不会因为跟已持有的 SHARED_READ 兼容就放行——它必须排在 ALTER 后面。于是形成一条链:
| 阶段 | 谁持有 MDL | 对新请求的影响 |
|---|---|---|
| 长事务执行 SELECT 未提交 | 该会话持 SHARED_READ |
正常读写不受影响 |
| ALTER 进入队列等待 | ALTER 处于 PENDING |
新请求开始排队,不能插队 |
| 队列堆积 | 全部 PENDING |
表对所有新连接表现为不可用 |
| ALTER 被 kill 或获锁 | 队列释放 | 已排队的请求陆续恢复 |
这就是「一个空闲事务加一条 DDL 能让整张表不可用」的机理:不是 DDL 锁了表,是 DDL 站在门口堵住了所有人。
四、找出那个长事务
事后我把两条查询固定成 DDL 前的必跑项。第一条直接问 InnoDB:
SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id AS conn_id, trx_rows_locked,
LEFT(trx_query, 80) AS last_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY age_sec DESC;
它有盲区:如果事务里最后一条语句是 Sleep,trx_query 是 NULL,你还是不知道它在等什么。这时要结合 performance_schema 反查是谁占着锁:
SELECT t.PROCESSLIST_ID, t.PROCESSLIST_COMMAND, t.PROCESSLIST_TIME,
m.LOCK_TYPE, m.LOCK_STATUS
FROM performance_schema.metadata_locks m
JOIN performance_schema.threads t ON t.THREAD_ID = m.OWNER_THREAD_ID
WHERE m.OBJECT_SCHEMA = 'ispt_order' AND m.OBJECT_NAME = 'orders';
生产上跑出来的那个 4218 秒连接属于报表账号,命令是 Sleep。翻 binlog,前一条语句是一次订单状态同步任务:START TRANSACTION,循环里逐条 update,每批之间调一次外部物流接口。接口当天超时,超时 60 秒、重试 3 次,一次同步两百多批,赶上一批连续超时,事务窗口被拉到七十分钟。
任务最终失败,异常被上层 catch 住记了日志,但事务没有回滚,连接还回了池。连接池以为它干净,继续借给业务用,而它身上还挂着 SHARED_READ。这是最隐蔽的一类:连接被复用,事务还开着。
五、当时怎么救的
止血顺序很关键。我第一反应是 kill 那条 ALTER,这是错的。kill ALTER 只是让它退出队列,真正持锁的长事务还在,后面排队的请求继续堵在门口。
# 1. 先摘一半入口流量,避免新请求继续往队里堆
# 2. 杀持有 MDL 的源头会话,不是杀 ALTER
mysql -h 10.20.5.21 -uroot -p -e "KILL 48213;"
# 3. 盯队列消化
watch -n 2 "mysql -h 10.20.5.21 -uroot -p -N -e \
\"SELECT COUNT(*) FROM information_schema.processlist \
WHERE state LIKE 'Waiting for table metadata lock';\""
第三步我盯了四十秒,数字从 178 掉到 0。这四十秒是队列消化加连接池重建连接的时间,跟业务代码无关,纯粹是积压。
杀长事务有代价:它要回滚,回滚时间跟改过的行数成正比。这次只改了几万行,十几秒完事;如果是几百万行的大事务,KILL 之后可能进入漫长回滚期,期间锁仍不释放。所以「杀」永远是最后手段。
六、之后怎么改:把 DDL 变成有窗口的操作
复盘后定了三条,都落进了流程。第一,DDL 前必须跑长事务检查,innodb_trx 里有超过 60 秒的活跃事务就直接拒绝执行。
第二,DDL 必须显式声明算法和锁级别,做不到就快速失败,而不是默默降级:
ALTER TABLE orders
ADD COLUMN channel VARCHAR(32) DEFAULT NULL,
ALGORITHM = INSTANT,
LOCK = NONE;
-- 若报 ER_ALGORITHM_NOT_SUPPORTED,说明必须重建表,
-- 那就换 gh-ost,不要在业务时段硬上
第三,影响行数过百万、或不确定能否 INSTANT 的变更,一律走 gh-ost 影子表:拷影子表、改结构、增量同步、最后原子 rename,全程只有 rename 那一瞬需要短暂表锁。
gh-ost \
--host=10.20.5.21 --user=ghost --password="$GHOST_PASS" \
--database=ispt_order --table=orders \
--alter="ADD COLUMN channel VARCHAR(32) DEFAULT NULL" \
--chunk-size=1000 \
--max-load='Threads_running=30' \
--max-lag-millis=1500 \
--throttle-control-replicas=10.20.5.22:3306 \
--allow-on-master --execute
--max-load 和 --max-lag-millis 才是这套东西的价值:主库压力或从库延迟超阈值时自动暂停。加字段这种事,我宁愿它跑四小时,也不愿它锁六分钟。
顺手补了应用侧的洞:同步任务改成每批单独提交、外部调用挪到事务外,并给 @Transactional 加了 timeout = 30。它不会回滚已经挂住的事务,但至少能让框架超时抛异常、触发回滚,而不是无限期挂着。
小结
- MDL 是 Server 层的元数据锁,持有周期是事务而不是语句;一个
Sleep的空闲连接只要事务没提交,就能把整张表堵死。 - online DDL 的「不锁表」只描述执行阶段,申请 MDL 那一段照样会等,默认能等一年(
lock_wait_timeout)。 - MDL 队列 FIFO 且不许插队,DDL 一旦开始等待,后续所有读写都排在它后面,阻塞面积远大于 DDL 本身。
- 止损先杀持有 MDL 的长事务,不要先杀 ALTER;但杀大事务会触发回滚,可能比等更久。
- 大表变更用
ALGORITHM=INSTANT, LOCK=NONE显式声明让它快速失败,做不到就走 gh-ost,别在业务时段赌运气。
正在加载评论…