MySQL 大表 DDL 变更 — 原理、方案与实践

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 变更需要和业务方协同 纳入需求评审环节,提前识别大表变更风险
相关推荐
皮卡丘不断更1 小时前
手机优先的 Personal Ledger:把账目、学习和复盘放到同一个入口
数据库·sqlite·fastapi·开源项目·个人效率
保卫大狮兄2 小时前
设备管理从台账到报废,完整生命周期一次讲清
数据库·设备管理·设备
数据安全技术观察3 小时前
数据动态脱敏:让脱敏策略跟着数据目录走
大数据·网络·数据库
小席是个热心肠3 小时前
Redis的自我学习
数据库·redis·学习
2601_965798474 小时前
Salient Theme Setup Guide for Fast Creative WordPress Sites
数据库·web3·php·wordpress
@Mike@4 小时前
09-数据库学习笔记(数据库索引与过滤器)
数据库·笔记
程序员夏洛5 小时前
MySQL 中 count(*)、count(1) 和 count(字段名) 有什么区别?
数据库·mysql
Wang's Blog6 小时前
PostgreSQL笔记34:索引优化策略全景解析——从B-tree到HOT的核心原理与实践
数据库·笔记·postgresql
l1258656 小时前
# RAG向量数据库优化实战:HNSW索引调参与生产级性能设计
数据库·python·mysql·langchain
梦Arrebol6 小时前
Mysql内容及相关实验
数据库·mysql