MySQL 大表 DDL 变更 --- 原理、方案与实践
一、什么是 DDL 变更
1.1 基本概念
| 术语 | 全称 | 含义 | 示例 |
|---|---|---|---|
| DDL | Data Definition Language | 数据定义语言,修改表结构 | ALTER TABLE / CREATE TABLE / DROP TABLE |
| DML | Data Manipulation Language | 数据操作语言,操作数据 | INSERT / UPDATE / DELETE / SELECT |
| MDL | Metadata Lock | 元数据锁,保护表结构不被并发修改 | DDL 执行时自动获取 |
1.2 为什么大表 DDL 是难题
小表(几万行):ALTER TABLE → 秒级完成,无感知
中表(几百万行):ALTER TABLE → 几十秒到几分钟,可接受
大表(几千万行):ALTER TABLE → 几十分钟到几小时,线上不可接受
核心矛盾:
DDL 执行时间 与 业务可接受的中断时间 不匹配
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、MySQL DDL 的底层原理
2.1 MySQL 5.6 之前(Copy 方式)
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);
执行过程:
1. 获取表的 MDL 独占锁(阻塞所有读写!)
2. 创建一张临时表(包含新字段)
3. 逐行复制原表数据到临时表
4. 删除原表
5. 临时表改名为原表名
6. 释放 MDL 锁
问题:整个过程表不可读写,4000万行复制可能需要30分钟+
2.2 MySQL 5.6+ Online DDL(Inplace 方式)
ALTER TABLE orders ADD COLUMN remark VARCHAR(255), ALGORITHM=INPLACE;
执行过程:
1. 获取 MDL 共享锁(允许读写)
2. 在引擎层原地修改表结构(不复制全部数据)
3. 期间记录增量变更到 Online DDL Log
4. 结束时短暂升级为 MDL 独占锁(通常毫秒级)
5. 应用增量,释放锁
改善:执行期间允许 DML 操作(读写不阻塞)
但是:仍然需要重建表(取决于操作类型),耗时仍然长
2.3 MySQL 8.0.12+ INSTANT DDL
ALTER TABLE orders ADD COLUMN remark VARCHAR(255), ALGORITHM=INSTANT;
执行过程:
1. 只修改数据字典中的表定义(元数据)
2. 不重建表、不复制数据
3. 瞬间完成(毫秒级)
限制:
- 只支持在表末尾加列(8.0.29+ 支持任意位置)
- 新列必须允许 NULL 或有默认值
- 不支持加索引、改列类型等操作
2.4 各版本 DDL 能力对比
| 操作 | 5.6 之前 | 5.6/5.7 Online DDL | 8.0.12+ INSTANT |
|---|---|---|---|
| 加可空列 | Copy(锁表) | Inplace(不锁表但重建) | Instant(秒完成) |
| 加索引 | Copy | Inplace(不锁表但耗时) | Inplace |
| 改列类型 | Copy | Copy(锁表!) | Copy |
| 删列 | Copy | Inplace | Inplace |
| 改表名 | 瞬间 | 瞬间 | 瞬间 |
三、MDL 锁详解
3.1 什么是 MDL 锁
MDL(Metadata Lock)是 MySQL 5.5 引入的机制,保证 DDL 和 DML 不冲突:
DML 操作(SELECT/INSERT/UPDATE/DELETE)→ 获取 MDL 读锁(共享锁)
DDL 操作(ALTER TABLE)→ 需要 MDL 写锁(独占锁)
MDL 读锁之间不互斥 → 多个 DML 可以并发执行
MDL 写锁与任何锁互斥 → DDL 必须等所有 DML 释放读锁后才能获取写锁
3.2 MDL 锁导致的雪崩
时刻T1:事务A执行 SELECT(持有MDL读锁,事务未提交)
时刻T2:DBA执行 ALTER TABLE(等待MDL写锁)
时刻T3:新来的事务B执行 SELECT → 被MDL写锁请求阻塞!
时刻T4:新来的事务C执行 INSERT → 被MDL写锁请求阻塞!
...
所有新请求都被阻塞 → 连接池耗尽 → 服务不可用
根本原因:MDL锁是公平的,写锁请求排队后,新的读锁请求也要排队
3.3 如何避免 MDL 锁雪崩
sql
-- 设置 DDL 等待超时(不要无限等待)
SET lock_wait_timeout = 5; -- 等5秒拿不到锁就放弃
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);
-- 如果报错 Lock wait timeout exceeded → 换个时间再试
四、大表 DDL 变更方案
4.1 pt-online-schema-change(Percona 工具)
原理
1. CREATE TABLE orders_new LIKE orders;(创建新表)
2. ALTER TABLE orders_new ADD COLUMN remark VARCHAR(255);(新表加字段)
3. 在原表创建3个触发器:
- AFTER INSERT → INSERT INTO orders_new
- AFTER UPDATE → UPDATE orders_new(或REPLACE INTO)
- AFTER DELETE → DELETE FROM orders_new
4. 分批复制原表数据到新表:
INSERT INTO orders_new SELECT * FROM orders WHERE id BETWEEN 1 AND 1000;
INSERT INTO orders_new SELECT * FROM orders WHERE id BETWEEN 1001 AND 2000;
...
5. 数据追平后,RENAME TABLE 原子交换:
RENAME TABLE orders TO orders_old, orders_new TO orders;
6. 删除触发器,删除旧表
优点
- 执行期间原表可正常读写
- 分批复制,不会一次性占用大量资源
缺点/限制
- 触发器开销:高频写入时,每次 DML 都额外触发一次写操作(写放大 2x)
- 高频写入可能追不上:复制期间增量太多,永远追不平
- RENAME 需要短暂独占锁:如果有长事务持有 MDL 读锁,RENAME 会等待
- 不支持有触发器的表:原表已有触发器时冲突
使用示例
bash
pt-online-schema-change \
--alter "ADD COLUMN remark VARCHAR(255) DEFAULT NULL" \
--host=db.example.com \
--port=3306 \
--user=admin \
--password=secret \
--chunk-size=1000 \
--max-load="Threads_running=50" \
--critical-load="Threads_running=100" \
D=mydb,t=orders \
--execute
关键参数:
--chunk-size=1000:每批复制 1000 行--max-load:负载超过阈值时暂停复制--critical-load:负载超过临界值时终止操作
4.2 gh-ost(GitHub 工具)
原理
与 pt-osc 的关键区别:不使用触发器!
改为监听 MySQL binlog 捕获增量变更。
1. CREATE TABLE orders_ghost LIKE orders;
2. ALTER TABLE orders_ghost ADD COLUMN remark VARCHAR(255);
3. 启动 binlog 监听线程,实时捕获原表的变更事件
4. 分批复制原表数据到 ghost 表
5. binlog 增量实时追加到 ghost 表
6. 数据追平后,RENAME 交换(原子操作)
为什么比 pt-osc 更适合高频写入场景
| 对比 | pt-osc(触发器) | gh-ost(binlog) |
|---|---|---|
| 增量同步方式 | 触发器(同步,在 DML 事务内) | binlog 异步消费 |
| 对写入性能的影响 | 大(每次 DML 多一次触发器操作) | 小(binlog 是 MySQL 本身就在写的) |
| 高频写入追赶能力 | 弱(触发器自身是瓶颈) | 强(binlog 消费是独立线程) |
| 可控性 | 弱 | 强(可以暂停、限速、测试模式) |
使用示例
bash
gh-ost \
--alter="ADD COLUMN remark VARCHAR(255) DEFAULT NULL" \
--database=mydb \
--table=orders \
--host=db.example.com \
--user=admin \
--password=secret \
--chunk-size=1000 \
--max-load="Threads_running=50" \
--ok-to-drop-table \
--execute
4.3 INSTANT DDL(MySQL 8.0.12+)
sql
-- 瞬间完成,不受数据量影响
ALTER TABLE orders
ADD COLUMN remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
ALGORITHM=INSTANT;
-- 验证是否支持 INSTANT
ALTER TABLE orders
ADD COLUMN remark VARCHAR(255) DEFAULT NULL,
ALGORITHM=INSTANT, LOCK=NONE;
-- 如果不支持会报错:ALGORITHM=INSTANT is not supported
INSTANT 支持的操作:
- 加可空列(末尾)
- 加有默认值的列
- 修改 ENUM/SET 增加值
- 修改列默认值
- 删除列默认值
INSTANT 不支持的操作:
- 加索引
- 改列类型/长度
- 删除列(8.0.29+ 支持)
- 在非末尾位置加列(8.0.29+ 支持)
4.4 停服变更(最后手段)
bash
# 1. 确认低峰期(凌晨2-4点)
# 2. 通知相关方
# 3. 停止服务
# 或:停止所有实例的 Java 进程
# 4. 确认无连接
# 5. 执行 DDL(无并发,瞬间获取MDL锁)
ALTER TABLE orders ADD COLUMN remark VARCHAR(255) DEFAULT NULL;
-- 4000万行约 5-15 分钟
# 6. 确认执行完成
DESC orders; -- 确认新字段存在
# 7. 启动服务
五、方案选择决策流程
DDL 变更需求
│
├─→ 确认 MySQL 版本
│ ├── 8.0.12+ 且操作支持 INSTANT → 直接 INSTANT DDL(秒级)
│ └── 5.7 或不支持 INSTANT ↓
│
├─→ 评估表数据量
│ ├── < 100 万 → 直接 ALTER(Online DDL,通常 < 1分钟)
│ └── > 100 万 ↓
│
├─→ 评估写入频率
│ ├── 低频(< 100 TPS) → pt-osc 或 DMS 无锁变更
│ └── 高频(> 100 TPS) ↓
│
├─→ 评估数据量 + 写入频率组合
│ ├── < 1000 万 + 高频 → gh-ost(binlog 方式)
│ └── > 1000 万 + 高频 ↓
│
├─→ 是否可以新建子表替代
│ ├── 可以 → 新建子表(零风险,推荐)
│ └── 不可以 ↓
│
└─→ 申请停服窗口
→ 低峰期停服 → ALTER TABLE → 启动服务
六、通用示例代码
6.1 DDL 变更前的评估脚本
sql
-- 1. 查看表数据量
SELECT table_name, table_rows,
ROUND(data_length/1024/1024, 2) AS data_mb,
ROUND(index_length/1024/1024, 2) AS index_mb
FROM information_schema.tables
WHERE table_schema = 'mydb' AND table_name = 'orders';
-- 2. 查看当前表的活跃连接(是否有长事务持有MDL锁)
SELECT * FROM information_schema.processlist
WHERE db = 'mydb' AND command != 'Sleep'
ORDER BY time DESC;
-- 3. 查看 MySQL 版本(决定是否可用 INSTANT)
SELECT VERSION();
-- 4. 查看表结构(确认当前字段)
SHOW CREATE TABLE orders\G
-- 5. 估算 ALTER 耗时(测试环境执行)
SET profiling = 1;
ALTER TABLE orders_test ADD COLUMN remark VARCHAR(255);
SHOW PROFILES;
6.2 安全执行 DDL 的包装脚本
sql
-- 设置超时保护(避免长时间等待MDL锁导致雪崩)
SET SESSION lock_wait_timeout = 10; -- 最多等10秒
SET SESSION innodb_lock_wait_timeout = 10;
-- 执行 DDL
ALTER TABLE orders ADD COLUMN remark VARCHAR(255) DEFAULT NULL COMMENT '备注';
-- 如果超时报错:ERROR 1205 (HY000): Lock wait timeout exceeded
-- → 说明有长事务持有锁,需要找到并处理后重试
6.3 查找阻塞 DDL 的长事务
sql
-- 查找持有 MDL 锁的事务
SELECT
t.trx_id,
t.trx_state,
t.trx_started,
TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS running_seconds,
t.trx_mysql_thread_id,
p.user,
p.host,
p.db,
p.command,
p.info AS current_sql
FROM information_schema.innodb_trx t
JOIN information_schema.processlist p ON t.trx_mysql_thread_id = p.id
ORDER BY t.trx_started ASC;
-- 如果发现运行很久的事务,可以 KILL(需确认不影响业务)
-- KILL <trx_mysql_thread_id>;
6.4 新建子表替代方案(Java 示例)
java
/**
* 方案B:新建轻量子表存储新字段.
* 适用于:大表无法 ALTER 时,将新字段存到独立小表.
*/
// 1. 新表 DDL(瞬间完成,空表)
// CREATE TABLE order_extend_new (
// id INT AUTO_INCREMENT PRIMARY KEY,
// order_id INT NOT NULL,
// new_field_a INT DEFAULT NULL,
// new_field_b VARCHAR(100) DEFAULT NULL,
// UNIQUE KEY uk_order_id (order_id)
// );
// 2. 实体类
@Entity
@Table(name = "order_extend_new")
public class OrderExtendNew extends BaseEntity {
@Column(name = "order_id")
private Integer orderId;
@Column(name = "new_field_a")
private Integer newFieldA;
@Column(name = "new_field_b")
private String newFieldB;
}
// 3. Repository
public interface OrderExtendNewRepository extends JpaRepository<OrderExtendNew, Integer> {
OrderExtendNew findByOrderId(Integer orderId);
}
// 4. 写入(在原有事务内一起写)
@Transactional
public void createOrder(OrderDto dto) {
Order order = orderRepository.save(buildOrder(dto));
// 写新扩展表
if (dto.getNewFieldA() != null) {
OrderExtendNew extend = new OrderExtendNew();
extend.setOrderId(order.getId());
extend.setNewFieldA(dto.getNewFieldA());
orderExtendNewRepository.save(extend);
}
}
// 5. 读取(多一次查询)
public OrderDto getOrder(Integer orderId) {
Order order = orderRepository.findById(orderId).get();
OrderDto dto = convertToDto(order);
// 从新表读扩展字段
OrderExtendNew extend = orderExtendNewRepository.findByOrderId(orderId);
if (extend != null) {
dto.setNewFieldA(extend.getNewFieldA());
}
return dto;
}
6.5 预留备用字段的表设计
sql
-- 建表时预留扩展字段,避免日后 ALTER
CREATE TABLE order_detail (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NOT NULL,
item_sku_id INT NOT NULL,
qty INT NOT NULL,
price DECIMAL(14,2) NOT NULL,
-- 业务字段...
-- 预留扩展字段(日后需要时直接使用,无需ALTER)
ext_int_1 INT DEFAULT NULL COMMENT '预留整型字段1',
ext_int_2 INT DEFAULT NULL COMMENT '预留整型字段2',
ext_int_3 INT DEFAULT NULL COMMENT '预留整型字段3',
ext_str_1 VARCHAR(255) DEFAULT NULL COMMENT '预留字符串字段1',
ext_str_2 VARCHAR(255) DEFAULT NULL COMMENT '预留字符串字段2',
ext_str_3 VARCHAR(255) DEFAULT NULL COMMENT '预留字符串字段3',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
INDEX idx_order_id (order_id)
) COMMENT='订单明细表';
-- 使用时只需更新注释(不改结构,无需DDL):
ALTER TABLE order_detail
MODIFY COLUMN ext_int_1 INT DEFAULT NULL COMMENT '是否定日达 0否1是';
-- MODIFY COLUMN 改注释在 MySQL 5.7 中也是 Online 的
七、数据归档方案
7.1 为什么要归档
表持续增长:
2024年1月:1000万行 → ALTER 2分钟
2024年6月:2000万行 → ALTER 5分钟
2025年1月:3000万行 → ALTER 10分钟
2025年6月:4000万行 → ALTER 15分钟 + DMS失败!
归档后:
热表只保留近3个月数据:500万行 → ALTER 1分钟
历史表按月分:每月1表,每表约500万行
7.2 归档示例
sql
-- 1. 创建归档表
CREATE TABLE orders_archive_202601 LIKE orders;
-- 2. 迁移旧数据到归档表
INSERT INTO orders_archive_202601
SELECT * FROM orders WHERE create_time < '2026-02-01';
-- 3. 删除热表中的旧数据(分批删除,避免长事务)
DELETE FROM orders WHERE create_time < '2026-02-01' LIMIT 10000;
-- 重复执行直到删除完毕
-- 4. 定时任务自动归档(每月1日执行)
八、经验教训总结
| # | 教训 | 规避措施 |
|---|---|---|
| 1 | 4000 万 + 高频写入 = 在线 DDL 工具也可能失败 | 提前在测试环境验证,准备备选方案 |
| 2 | 表数据量是需要持续治理的,不能放任增长 | 建立数据归档机制,设置表大小告警 |
| 3 | DMS 无锁变更有适用边界,不是万能的 | 了解工具原理,评估是否适用当前场景 |
| 4 | 新功能字段优先考虑新表 | 养成习惯:大表不加字段,小表加字段 |
| 5 | MySQL 版本是基础设施投资 | 推动升级 8.0,INSTANT DDL 彻底解决问题 |
| 6 | 停服变更虽然粗暴但最可靠 | 建立低峰期停服变更的标准流程和审批机制 |
| 7 | DDL 变更需要和业务方协同 | 纳入需求评审环节,提前识别大表变更风险 |