MySQL 8.0 加索引写了 ALGORITHM=INPLACE 还是把业务卡死:MDL 等待、DDL 三阶段与一条能定位到连
## 背景
一张 4000 万行的订单表要加一个联合索引。执行的是:
```sql
ALTER TABLE t_order ADD INDEX idx_uid_status (user_id, status), ALGORITHM=INPLACE, LOCK=NONE;
```
按经验这是「在线 DDL」,不锁表。结果执行后 15 秒,应用开始大面积超时,连接池 200 个连接全部卡住,监控上 `Threads_running` 冲到 200,而 CPU 只有 20%——典型的等锁,不是算力问题。
## 一、INPLACE ≠ 不锁表
DDL 的执行分三个阶段:
1. **prepare**:拿 MDL(metadata lock)的 **X 锁**,检查表结构、版本,确认能用 INPLACE;
2. **ddl**:真正干活(加索引就是排序构建索引),这阶段 `LOCK=NONE` 生效,读写不受影响;
3. **commit**:再次拿 MDL X 锁,把新表定义写进数据字典,这阶段极短。
`ALGORITHM=INPLACE` 只影响第 2 阶段。**第 1 和第 3 阶段都必须拿到 MDL X 锁,而 X 锁与任何 MDL 读锁互斥**。
关键在 MDL 的特性:普通 `SELECT` 也会拿 MDL 的 **SHARED_READ** 锁,直到事务结束才释放。所以只要有一个事务开着没提交(哪怕是只读事务、或者应用忘了 commit),DDL 的 prepare 阶段就会卡住。
## 二、真正的杀手是 MDL 队列的公平锁
更糟的是,DDL 在等锁时会进入 MDL 队列,**而 MDL 队列是公平的、按顺序授予的**。也就是说:
```
时刻 T0: 事务 A 开始,拿 MDL SHARED_READ,不提交
时刻 T1: ALTER TABLE 请求 MDL X,排队等 A
时刻 T2: 业务请求 1 到达,请求 MDL SHARED_READ → 排在 DDL 后面,等
时刻 T3: 业务请求 2 到达 → 排在请求 1 后面,等
...
```
从 T2 开始,**所有对这个表的请求都被挂起**,即使它们只需要读锁。这就是「DDL 明明只是排队,业务却全挂」的原因:不是因为 DDL 锁了表,而是因为 DDL 挡住了后面所有人。
一个 15 秒的 DDL 等待,足够把 200 个连接的池子耗干。
## 三、一条查询定位到具体连接
MySQL 8.0 的 `performance_schema.metadata_locks` 是最好的入口:
```sql
SELECT
ml.OBJECT_SCHEMA, ml.OBJECT_NAME, ml.LOCK_TYPE, ml.LOCK_STATUS,
ml.OWNER_THREAD_ID,
t.PROCESSLIST_ID, t.PROCESSLIST_USER, t.PROCESSLIST_TIME,
t.PROCESSLIST_INFO
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t ON t.THREAD_ID = ml.OWNER_THREAD_ID
WHERE ml.OBJECT_SCHEMA = 'order_db'
AND ml.OBJECT_NAME = 't_order'
ORDER BY ml.OWNER_THREAD_ID;
```
输出里会看到两类行:
- `LOCK_STATUS = GRANTED`、`LOCK_TYPE = SHARED_READ`、`PROCESSLIST_TIME` 很大 → **这就是阻塞源**,一个长事务;
- `LOCK_STATUS = PENDING`、`LOCK_TYPE = EXCLUSIVE` → 这是那个排队的 DDL。
再关联 `information_schema.innodb_trx` 看它是不是真的没提交:
```sql
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
```
如果 `trx_query` 是 NULL 但 `trx_started` 很早,说明事务里的语句已经执行完、只是没 commit——最常见的是应用层 `setAutoCommit(false)` 之后忘了提交,或者连接池把连接还回去时没 rollback。
## 四、三种处置方式
**1. 立刻救火:kill 阻塞源**
```sql
KILL <PROCESSLIST_ID>;
```
只 kill 那个长事务,不要 kill DDL(DDL 被 kill 后要重新来一遍,且可能留下临时文件)。
**2. 让 DDL 快速失败而不是拖死业务**
```sql
SET SESSION lock_wait_timeout = 5;
ALTER TABLE ...;
```
`lock_wait_timeout` 默认 31536000 秒(一年),等于不超时。设成 5 秒的好处是:拿不到锁就报 `ERROR 1205 (HY000): Lock wait timeout exceeded`,DDL 退出、队列释放,业务只受 5 秒影响。**这是低峰期做 DDL 的标准姿势**,比什么都不设安全得多。
**3. 大表用 gh-ost / pt-osc**
`ALGORITHM=INPLACE` 对加索引是够用的,但对「改列类型」「加全文索引」这类必须 COPY 的操作,仍需重建表。4000 万行以上的表建议用 gh-ost 走binlog同步,能在不长时间持有 MDL 的前提下完成。
## 五、一个反直觉的点:ALGORITHM=INSTANT 也不是万能的
MySQL 8.0.12 引入的 INSTANT 只支持「在表末尾加列」「改列默认值」「重命名列」等少数操作。加索引不是 INSTANT,是 INPLACE,仍然要走 MDL。
更要注意:**INSTANT 加列次数有上限(8.0.29 之前是 64 次)**,超过之后表需要重建,`ALTER` 会退化成 COPY,那时候锁的时间是分钟级。频繁用 INSTANT 加列的表,某一天会突然「变慢」,原因就在这里。
## 小结
在线 DDL 的「在线」只覆盖中间那段真正干活的阶段,前后的 MDL 拿锁一点没省。DDL 一旦在 MDL 队列里排队,会把后面所有请求一起拦住,这是它能瞬间打满连接池的机制。做 DDL 前的固定动作应该是:查 `innodb_trx` 有没有长事务、`SET SESSION lock_wait_timeout=5`、在低峰执行、执行时盯着 `metadata_locks`。
- 点赞
- 收藏
- 关注作者
评论(0)