一、什么是 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 执行时间 与 业务可接受的中断时间 不匹配
二、mysql ddl 的底层原理
2.1 mysql 5.6 之前(copy 方式)
alter table orders add column remark varchar(255);
执行过程:
- 获取表的 mdl 独占锁(阻塞所有读写!)
- 创建一张临时表(包含新字段)
- 逐行复制原表数据到临时表
- 删除原表
- 临时表改名为原表名
- 释放 mdl 锁
问题:整个过程表不可读写,4000万行复制可能需要30分钟+
2.2 mysql 5.6+ online ddl(inplace 方式)
alter table orders add column remark varchar(255), algorithm=inplace;
执行过程:
- 获取 mdl 共享锁(允许读写)
- 在引擎层原地修改表结构(不复制全部数据)
- 期间记录增量变更到 online ddl log
- 结束时短暂升级为 mdl 独占锁(通常毫秒级)
- 应用增量,释放锁
改善:执行期间允许 dml 操作(读写不阻塞)
但是:仍然需要重建表(取决于操作类型),耗时仍然长
2.3 mysql 8.0.12+ instant ddl
alter table orders add column remark varchar(255), algorithm=instant;
执行过程:
- 只修改数据字典中的表定义(元数据)
- 不重建表、不复制数据
- 瞬间完成(毫秒级)
限制:
- 只支持在表末尾加列(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 锁雪崩
-- 设置 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 会等待
- 不支持有触发器的表:原表已有触发器时冲突
使用示例
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 消费是独立线程) |
| 可控性 | 弱 | 强(可以暂停、限速、测试模式) |
使用示例
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+)
-- 瞬间完成,不受数据量影响 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 停服变更(最后手段)
# 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 变更前的评估脚本
-- 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 的包装脚本
-- 设置超时保护(避免长时间等待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 的长事务
-- 查找持有 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 示例)
/**
* 方案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 预留备用字段的表设计
-- 建表时预留扩展字段,避免日后 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 归档示例
-- 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 变更需要和业务方协同 | 纳入需求评审环节,提前识别大表变更风险 |
以上就是mysql数据库大表ddl变更操作的原理、方案与实践教学的详细内容,更多关于mysql大表ddl操作的资料请关注代码网其它相关文章!
发表评论