一次字段迁移的生产推演:从 WOWNOW_DEVICE_ID 到 LIMI_DEVICE_ID
品牌改名这件事,代码里改个常量就完了,数据库里改一个字段名却是一场小型手术。
前段时间做了 LIMI 的品牌迁移,涉及 RISK_CONTROL_RECORD 和 RISK_DEVICE_OPERATOR 两张表的设备号字段:WOWNOW_DEVICE_ID → LIMI_DEVICE_ID。第一版脚本长这样:
-- 新加字段
ALTER TABLE RISK_CONTROL_RECORD
ADD COLUMN LIMI_DEVICE_ID VARCHAR(256) NULL COMMENT 'LIMI设备号';
ALTER TABLE RISK_DEVICE_OPERATOR
ADD COLUMN LIMI_DEVICE_ID VARCHAR(256) NULL COMMENT 'LIMI设备号';
-- 给新字段赋值
UPDATE RISK_DEVICE_OPERATOR
SET LIMI_DEVICE_ID = WOWNOW_DEVICE_ID
WHERE LIMI_DEVICE_ID IS NULL AND WOWNOW_DEVICE_ID IS NOT NULL;
UPDATE RISK_CONTROL_RECORD
SET LIMI_DEVICE_ID = WOWNOW_DEVICE_ID
WHERE LIMI_DEVICE_ID IS NULL AND WOWNOW_DEVICE_ID IS NOT NULL;
-- 删除字段
ALTER TABLE RISK_CONTROL_RECORD DROP COLUMN WOWNOW_DEVICE_ID;
ALTER TABLE RISK_DEVICE_OPERATOR DROP COLUMN WOWNOW_DEVICE_ID;
-- 删除旧索引
ALTER TABLE RISK_CONTROL_RECORD
DROP KEY IDX_ON_WDI, ALGORITHM = INPLACE, LOCK = NONE;
-- 创建新的索引
ALTER TABLE RISK_CONTROL_RECORD
ADD KEY IDX_ON_LDI (OPERATOR_NO, LIMI_DEVICE_ID),
ALGORITHM = INPLACE, LOCK = NONE;
语法没错,在测试库上跑也没错。问题在于它是一个「一把梭」脚本:六类操作串在一条流水线上,中间没有任何验证点和停顿点。任何一步出错,表就卡在一个既不是旧状态、也不是新状态的中间态里,而且没有回滚脚本能把它捞回来。
这篇文章把它当样本,从「哪里会出事」讲到「该怎么拆」,最后整理成一套可以照着走的提交流程。
一、先给这份脚本做个体检
逐条列,每条都是能触发生产事故的具体条件:
| # | 问题 | 后果 | 触发条件 |
|---|---|---|---|
| 1 | 六类操作串在一个脚本,无中间验证点 | 任一步失败即卡在中间态,无回滚 | 总会发生(网络、锁超时、超时中断) |
| 2 | 单条 UPDATE 全表迁移 | 长事务、undo 膨胀、binlog 暴涨、主从延迟 | 表超过 10 万行就开始明显 |
| 3 | 先删索引,后建索引 | 中间窗口写路径缺索引,退化为全表扫 | 只要窗口内还有写入 |
| 4 | 删列前没有数据一致性校验 | 迁移不完整直接丢数据,且不可恢复 | 第 2 步被中断时 |
| 5 | ADD COLUMN 未指定 ALGORITHM | 可能隐式走 COPY,全程锁表 | 表大 + 版本不支持 INSTANT |
| 6 | 脚本不幂等 | 重复执行直接报 Duplicate column name | 第一次失败后重试 |
| 7 | 没有备份 | 删列不可逆,DROP COLUMN 之后数据就没了 | 任何时候需要回溯 |
| 8 | 未评估表大小 | 小表无感,大表直接锁死业务 | 表超过 10G |
| 9 | 代码与数据库同批发布 | 回滚时代码回得来,列回不来 | 发布失败 |
其中第 3 条最容易被忽略,也最危险,下面单独说。
第 3 条的细节:索引不能先删后建
IDX_ON_WDI 的列组合是 (OPERATOR_NO, WOWNOW_DEVICE_ID),新索引是 (OPERATOR_NO, LIMI_DEVICE_ID)。这是替代关系,不是新增关系。
脚本里先 DROP KEY IDX_ON_WDI 再 ADD KEY IDX_ON_LDI,两条语句之间有几十毫秒到几秒的窗口。这期间如果代码还在按 OPERATOR_NO 查设备——
SELECT * FROM RISK_CONTROL_RECORD WHERE OPERATOR_NO = 'xxx';
本来走索引前缀,现在没索引了,直接全表扫描。风控表通常写入频繁、单次查询扫几十万行,几个并发就能把 IO 打满,业务从「慢」直接变成「不可用」。
正确顺序永远是:先建新的,再把代码切过去,最后删旧的。三者之间要隔开观察期,不能在同一条流水线上。
顺带确认一个前提:字段长度是扩容还是缩容
新旧字段类型必须核对。如果 WOWNOW_DEVICE_ID 是 VARCHAR(64),新列写 VARCHAR(256) 是扩容,安全;反过来就是静默截断,而且不报错。
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('RISK_CONTROL_RECORD', 'RISK_DEVICE_OPERATOR')
AND COLUMN_NAME IN ('WOWNOW_DEVICE_ID', 'LIMI_DEVICE_ID');
顺便看 COLLATION_NAME 是否一致——字符集不同会导致隐式转换,索引直接失效,这类问题在联表查询里特别隐蔽。
二、为什么必须拆成四个阶段
一句话原则:让每一个中间状态都是可运行的。
这条原则在上一篇讲运维的文章里提过(expand-contract),这里落到具体字段上:
| 阶段 | 数据库状态 | 代码状态 | 可回滚性 |
|---|---|---|---|
| 1. expand 加列 | 新旧两列共存,新列为空 | 不感知新列 | 完全可回滚(删列即可) |
| 2. migrate 迁数据 | 两列数据一致 | 不感知 | 可回滚(数据无副作用) |
| 3. switch 切代码 | 两列数据一致,双写 | 写双列,读新列 | 可回滚(改回读旧列) |
| 4. contract 收缩 | 只剩新列 | 只感知新列 | 不可回滚(但有备份) |
关键在第三阶段之后:只有代码完全不再引用旧列,才允许执行第四阶段。「不再引用」的判断依据不能是记忆,得是证据——全库搜代码、搜同步任务、搜 BI 报表、搜定时脚本。
三、阶段 1:加列
先算清楚表有多大,这决定了后面所有操作的姿势:
SELECT TABLE_NAME,
TABLE_ROWS,
ROUND(DATA_LENGTH / 1024 / 1024) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024) AS index_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('RISK_CONTROL_RECORD', 'RISK_DEVICE_OPERATOR');
对照决策:
| 数据量 | DDL 方案 |
|---|---|
| < 1 GB | 直接 ALTER,影响可忽略 |
| 1~50 GB | Online DDL(INPLACE / INSTANT),低峰执行 |
| > 50 GB 或写入极高 | gh-ost / pt-osc,影子表 + binlog 同步 |
加列语句本身,关键是显式指定 ALGORITHM:
-- 01_expand.sql
ALTER TABLE RISK_CONTROL_RECORD
ADD COLUMN LIMI_DEVICE_ID VARCHAR(256) NULL COMMENT 'LIMI设备号',
ALGORITHM = INSTANT;
ALTER TABLE RISK_DEVICE_OPERATOR
ADD COLUMN LIMI_DEVICE_ID VARCHAR(256) NULL COMMENT 'LIMI设备号',
ALGORITHM = INSTANT;
为什么不省略 ALGORITHM?因为省略时 MySQL 会自动降级——不支持 INSTANT 就试 INPLACE,再不行就 COPY,全程锁表写,而你在执行前完全不知道它选了哪条路。显式写 ALGORITHM = INSTANT,不支持就直接报错,要么秒级完成,要么根本不动手,这才是我想要的确定性。
INSTANT 的版本边界要记住:
| 操作 | INSTANT 支持条件 |
|---|---|
ADD COLUMN(加在最后) | MySQL 8.0.12+ |
ADD COLUMN(任意位置) | MySQL 8.0.29+ |
DROP COLUMN | MySQL 8.0.29+(之前是 INPLACE,需重建表) |
| 单表 instant 操作次数上限 | 64 次,超过后下一次需要重建 |
先确认版本:
SELECT VERSION();
如果是 5.7,加列虽然也是 INPLACE 不锁 DML,但仍要重建表(拷贝 + 应用增量),大表耗时会到分钟级甚至小时级,必须放低峰。
幂等:让脚本可以安全重跑
生产脚本一定会因为各种原因重跑——网络断了、锁等待超时、你手抖按了 Ctrl+C。所以每条语句都要能承受「已经执行过」:
-- 8.0 没有 IF NOT EXISTS 语法,用 information_schema 判断
SELECT COUNT(*) INTO @exist
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'RISK_CONTROL_RECORD'
AND COLUMN_NAME = 'LIMI_DEVICE_ID';
SET @sql = IF(@exist = 0,
'ALTER TABLE RISK_CONTROL_RECORD ADD COLUMN LIMI_DEVICE_ID VARCHAR(256) NULL COMMENT ''LIMI设备号'', ALGORITHM=INSTANT',
'SELECT ''column already exists, skip'' AS msg');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
啰嗦,但比在生产上看到 ERROR 1060 (42S21): Duplicate column name 然后停下来人工判断要省事得多。
四、阶段 2:迁数据
这是整个迁移里唯一会长时间影响线上的一步,也是最容易写出事故的一步。
为什么不能一条 UPDATE 搞定
一条 UPDATE 更新 500 万行,意味着:
- 一个事务持有 500 万个行锁直到提交,期间相关行全部阻塞;
- undo log 撑到几个 GB,回滚段膨胀,可能把磁盘吃掉;
- ROW 格式 binlog 一行一个 event,binlog 瞬间涨几百 MB~几 GB,从库单线程重放,主从延迟迅速拉开;
- 中途失败要全部回滚,回滚本身又要跑一遍,耗时可能超过正向执行。
分批迁移的正确写法
不要用 LIMIT 循环自增,因为每批都要从表头重新扫一遍跳过已处理的行,批数一多复杂度就退化成 O(n²)。用主键区间推进:
#!/usr/bin/env bash
set -euo pipefail
DB=${DB:?}
TABLE=${TABLE:?}
COL_OLD=${COL_OLD:?}
COL_NEW=${COL_NEW:?}
PK=${PK:-ID}
BATCH=${BATCH:-5000}
SLEEP=${SLEEP:-0.2}
read -r MIN_ID MAX_ID < <(mysql -N -B "$DB" -e \
"SELECT IFNULL(MIN($PK),0), IFNULL(MAX($PK),0) FROM $TABLE;")
echo "[$(date -Is)] range $MIN_ID ~ $MAX_ID, batch=$BATCH"
cur=$MIN_ID
total=0
while [ "$cur" -lt "$MAX_ID" ]; do
next=$((cur + BATCH))
rows=$(mysql -N -B "$DB" -e "
UPDATE $TABLE
SET $COL_NEW = $COL_OLD
WHERE $PK > $cur AND $PK <= $next
AND $COL_NEW IS NULL
AND $COL_OLD IS NOT NULL;
SELECT ROW_COUNT();")
total=$((total + rows))
echo "[$(date -Is)] $cur~$next affected=$rows total=$total"
cur=$next
sleep "$SLEEP"
done
echo "[$(date -Is)] done, total=$total"
几个设计点:
- 每批一个独立事务,提交即释放锁,undo 和 binlog 都是小块;
sleep 0.2是给从库追赶的时间。数据量大的时候这个参数比什么优化都有效;- 幂等:
AND $COL_NEW IS NULL保证重复跑不会覆盖已迁移的数据; - 可断点续跑:记下最后成功的
cur,断了从那里接着来。
数据量超过千万行时,把 BATCH 降到 1000~2000,SLEEP 提到 0.5,让主从延迟始终压在 1 秒内。慢一点没关系,主从延迟拉起来才是真事故——从库读到的还是旧数据,用户会看到「刚保存又变回去了」。
边迁边看主从延迟
# 每 5 秒看一次延迟
watch -n 5 "mysql -e 'SHOW REPLICA STATUS\G' | grep -E 'Seconds_Behind|Running'"
Seconds_Behind_Master 不做准(大事务时会跳变),要更准就用 pt-heartbeat。延迟超过 10 秒就停下来等,别硬推。
迁移完必须校验,不是「跑完就完事」
-- 1) 应迁未迁,必须为 0
SELECT COUNT(*) AS missing
FROM RISK_CONTROL_RECORD
WHERE WOWNOW_DEVICE_ID IS NOT NULL
AND LIMI_DEVICE_ID IS NULL;
-- 2) 新旧不一致,必须为 0
SELECT COUNT(*) AS mismatch
FROM RISK_CONTROL_RECORD
WHERE WOWNOW_DEVICE_ID IS NOT NULL
AND (LIMI_DEVICE_ID IS NULL OR LIMI_DEVICE_ID <> WOWNOW_DEVICE_ID);
-- 3) 两侧非空计数比对,必须相等
SELECT
SUM(WOWNOW_DEVICE_ID IS NOT NULL) AS old_not_null,
SUM(LIMI_DEVICE_ID IS NOT NULL) AS new_not_null
FROM RISK_CONTROL_RECORD;
-- 4) 抽样肉眼确认
SELECT ID, OPERATOR_NO, WOWNOW_DEVICE_ID, LIMI_DEVICE_ID
FROM RISK_CONTROL_RECORD
ORDER BY ID DESC LIMIT 20;
第 2 条是最关键的,但写法有坑:<> 遇到 NULL 会返回 NULL 而不是 TRUE,所以必须显式处理 IS NULL。用 <=> 也能写,但更容易看错,我倾向展开写。
两张表都要校验。RISK_DEVICE_OPERATOR 和 RISK_CONTROL_RECORD 的行数量级可能差很多,别用一张表的结论套另一张。
五、阶段 3:切代码
数据库加好列、数据迁完,接下来是代码。这里的原则是先双写,再切读。
// 写:两个字段都写,保证任一时刻两边数据一致
public void save(RiskControlRecord record) {
record.setWownowDeviceId(record.getLimiDeviceId()); // 旧字段继续写
record.setLimiDeviceId(record.getLimiDeviceId()); // 新字段同时写
mapper.insert(record);
}
// 读:优先新字段,为空时回退旧字段(兼容历史数据)
public String resolveDeviceId(RiskControlRecord record) {
String v = record.getLimiDeviceId();
return (v != null && !v.isEmpty()) ? v : record.getWownowDeviceId();
}
双写的价值在于:切换期间不管从哪条路径读,都不会读到空值。而「读新回退旧」保证即使某个写入路径漏了双写,展示层也不会退化成空。
有个容易漏的时间缝隙:双写上线的那一刻,之前几秒写入的数据还没被迁移脚本覆盖。所以双写上线后,要把阶段 2 的迁移脚本再跑一次(它幂等,只会补漏),然后再校验一遍,才允许切读。
切换用开关控制,别用代码发布来切:
// 配置中心里的开关,出问题改配置即可,不用发版
@Value("${feature.device-id.read-new:false}")
private boolean readNewDeviceId;
读路径出问题,把开关拨回 false,秒级恢复,不用等一次发布。这是整个迁移里最便宜的一份保险。
六、阶段 4:收缩
到这里代码已经完全不读旧列了。剩下的三件事,按顺序做。
4.1 先建新索引(其实应该在切换前就建好)
新索引必须在代码开始按新字段查询之前就存在,否则查询会走全表扫。所以严格说它属于阶段 3 的前置动作:
-- 03_add_index.sql(代码切读之前执行)
ALTER TABLE RISK_CONTROL_RECORD
ADD KEY IDX_ON_LDI (OPERATOR_NO, LIMI_DEVICE_ID),
ALGORITHM = INPLACE, LOCK = NONE;
ADD INDEX 的 INPLACE 不锁 DML,但要扫全表建索引,时间与表大小成正比,而且期间会持续吃 IO。两个细节:
innodb_online_alter_log_max_size默认 128MB,限制的是 DDL 期间累积的并发 DML 量。写量大的表建议临时调到 1GB,否则可能中途失败(失败会回滚 DDL,安全但白干一场)。- 结束时需要拿一次短暂的排他 MDL。如果此时有长事务在跑,就会排队等待,而它后面的所有查询也会排队——这就是「DDL 把库堵死」的经典场景。执行前先确认没有长事务:
SELECT trx_id, trx_started, trx_rows_locked, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 5;
建之前也顺手查一下索引冗余,别白建:
SHOW INDEX FROM RISK_CONTROL_RECORD;
如果已经存在以 OPERATOR_NO 开头的联合索引,新索引可能是冗余的(除非你需要它做覆盖索引,或者第二列的选择性差异很大)。顺便算一下长度:VARCHAR(256) 在 utf8mb4 下最多占 1024 字节,加上 OPERATOR_NO,要确认没超过 InnoDB 索引 3072 字节的上限(DYNAMIC 行格式)。
4.2 删旧索引(代码切读之后)
-- 04_drop_old_index.sql
ALTER TABLE RISK_CONTROL_RECORD
DROP KEY IDX_ON_WDI,
ALGORITHM = INPLACE, LOCK = NONE;
删索引是纯元数据操作,瞬间完成。它的前提是:代码里已经没有任何按 WOWNOW_DEVICE_ID 查询的地方。删之前全库搜一遍:
grep -rn "WOWNOW_DEVICE_ID\|wownnowDeviceId" --include="*.java" --include="*.xml" --include="*.sql" .
搜不到,再考虑还有哪些不在代码仓库里的东西可能读它:数据同步任务、BI 报表、离线数仓的同步配置、定时跑的统计脚本。这些往往比代码本身更致命,因为它们不会因为发布而失败,只会安静地算出错误的报表,等业务方发现已经过去两周。
4.3 删旧列,但要先留备份
-- 05_backup_column.sql:先备份要删的数据
CREATE TABLE BAK_RCR_WDI_20261030 (
ID BIGINT NOT NULL PRIMARY KEY,
WOWNOW_DEVICE_ID VARCHAR(256) NULL
) ENGINE = InnoDB;
-- 大表分批插入,别一条 INSERT SELECT 打穿
INSERT INTO BAK_RCR_WDI_20261030 (ID, WOWNOW_DEVICE_ID)
SELECT ID, WOWNOW_DEVICE_ID FROM RISK_CONTROL_RECORD
WHERE ID > 0 AND ID <= 1000000 AND WOWNOW_DEVICE_ID IS NOT NULL;
-- ... 按主键区间重复
备份表保留 30 天再删。它占的那点磁盘,和「删错了想回滚」的代价完全不成比例。
然后才是删列:
-- 06_drop_old_column.sql
ALTER TABLE RISK_CONTROL_RECORD
DROP COLUMN WOWNOW_DEVICE_ID,
ALGORITHM = INSTANT; -- 8.0.29+;否则用 INPLACE, LOCK=NONE
ALTER TABLE RISK_DEVICE_OPERATOR
DROP COLUMN WOWNOW_DEVICE_ID,
ALGORITHM = INSTANT;
这一步是全流程唯一不可逆的操作。 执行前把上面所有校验再跑一遍,尤其是那两个「必须为 0」的计数。
七、提交脚本的流程
脚本写对只是一半,另一半是它怎么被审、怎么被执行、执行完怎么留痕。下面是按我们的规模(没有专职 DBA、后端兼管)定的七步。
第 1 步:写变更说明
在 MR 里必须回答清楚六个问题,答不上来的不准提:
## 变更目的
品牌迁移:WOWNOW_DEVICE_ID → LIMI_DEVICE_ID(涉及 2 张表)
## 影响面
- 表:RISK_CONTROL_RECORD(约 xxx 万行 / xx GB)、RISK_DEVICE_OPERATOR(约 xx 万行)
- 涉及接口:风控设备上报、设备信息查询
- 是否有其他系统在读旧字段:已确认无(代码已 grep,报表/同步任务已确认)
## 执行步骤与耗时预估
| 步骤 | 语句 | 预估耗时 | 是否锁表 |
| 1 加列 | ALTER ADD COLUMN ... INSTANT | < 1s | 否 |
| 2 迁数据 | 分批 UPDATE,5000/批 | 约 8 分钟 | 行锁,逐批释放 |
| 3 建索引 | ALTER ADD KEY ... INPLACE | 约 3 分钟 | 不锁 DML,吃 IO |
| 4 删旧列 | ALTER DROP COLUMN ... | < 1s | 否 |
## 回滚方案
- 阶段 1~3 内:删新列 / 数据无副作用,直接反向 DDL
- 阶段 4 后:从 BAK_RCR_WDI_20261030 回填(已验证可恢复)
- 代码层:配置开关 feature.device-id.read-new=false,秒级生效
## 校验方法
见 02_verify.sql,两个计数必须为 0
## 执行窗口
2026-10-30 22:00 ~ 23:00(业务低谷,无其他发布)
第 2 步:在预发环境完整演练一遍
预发不是为了验证 SQL 能不能跑(那在本地就能验),是为了拿到真实的耗时和锁行为。表结构和数据量接近生产时,那 8 分钟和 3 分钟才有参考价值。
演练时顺便确认三件事:脚本能重跑、校验 SQL 能发现问题(故意留一条数据不迁,看校验能不能抓到)、主从延迟在可接受范围。
第 3 步:提交到变更仓库
db-changes/
2026/
20261030_risk_device_id_rename/
01_expand.sql
02_migrate.sh
02_verify.sql
03_add_index.sql
04_drop_old_index.sql
05_backup_column.sql
06_drop_old_column.sql
rollback/
01_drop_new_column.sql
02_restore_from_backup.sql
README.md -- 就是第 1 步那份说明
一个阶段一个文件,不是为了好看,是为了执行时能停下来。把六类操作塞进一个文件的人,等于放弃了对节奏的控制权。
第 4 步:评审清单
评审的人(哪怕是你自己隔一天再看)逐条打勾:
- 每步都写了
ALGORITHM,没有依赖 MySQL 自动选择 -
UPDATE是分批的,每批独立事务,有主键区间 - 删索引前,新索引已经建好并且代码已经切过去
- 删列前有备份表,且备份已验证可查询
- 脚本幂等,重复执行不报错
- 每个阶段之间有校验 SQL,且校验失败会停止
-
rollback/目录里的脚本能对应到具体阶段 - 已确认没有代码/任务在读旧字段
- 执行窗口在其他发布之外,且执行人当时有空盯
第 5 步:预约窗口并通知
时间选择上有三条经验:
- 业务低谷,通常是夜间或凌晨,但你要确定自己醒着——凌晨三点的决策质量很差;
- 避开任何其他变更,一次只改一个变量;
- 避开节假日前和周五,出问题没人一起看。
通知要写到人:谁执行、影响什么、大概多久、出问题找谁。
第 6 步:执行
执行时的纪律比脚本本身重要:
# 开两个终端:一个执行,一个持续看主从延迟和活跃事务
mysql -e "SHOW REPLICA STATUS\G" | grep Seconds_Behind
# 全程记录,输出同时进日志文件
bash 02_migrate.sh 2>&1 | tee -a /var/log/db-change/20261030.log
阶段之间必须停下来看校验结果,不能一口气跑完再回头看。尤其是迁数据到建索引之间、建索引到删旧列之间,中间隔着的是「可回滚」和「不可回滚」的分界线。
第 7 步:观察期与归档
改完不是结束。留观 24 小时,盯四个指标:
-- 主从延迟
SHOW REPLICA STATUS\G
-- 长事务(超过 60 秒的都值得看一眼)
SELECT trx_id, trx_started, trx_rows_locked, LEFT(trx_query, 80)
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;
-- 锁等待
SELECT * FROM performance_schema.data_locks LIMIT 20;
-- 表大小变化(确认 DDL 没有产生意外的空间膨胀)
SELECT TABLE_NAME, ROUND(DATA_LENGTH/1024/1024) data_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'RISK%';
外加业务侧:错误率、P99 延迟、有没有新增的慢查询。
最后把执行日志、校验结果、实际耗时归档到变更目录下。下次做同类迁移,这份记录就是最准的估算依据。
八、回滚预案:分三级,别只有一个
很多人写的「回滚方案」只有一句「执行 rollback.sql」,这在实际事故里没法用。要分三级,按代价从小到大试:
| 级别 | 手段 | 耗时 | 适用 |
|---|---|---|---|
| L1 | 配置开关切回旧读路径 | 秒级 | 读逻辑有问题,数据本身没问题 |
| L2 | 数据回填(从备份表反向 UPDATE) | 分钟级 | 数据迁错、迁漏 |
| L3 | 反向 DDL(加回旧列 / 删掉新列) | 秒级~分钟级 | 结构性问题 |
顺序是 L1 → L2 → L3。因为 L1 最快且零副作用,多数「切了新字段后行为异常」的问题,一个开关就能挡住。
注意 L3 的一个陷阱:DROP COLUMN 之后再加回来,数据不会回来,只有结构回来了。所以 L3 必须配合 L2 的备份表一起用。这也是为什么备份表要保留 30 天——它是 L3 真正可用的前提。
九、整条时间轴
把上面的阶段摊到日历上,大概是这样:
| 时间 | 动作 | 关键点 |
|---|---|---|
| T-3 天 | 评估表大小、预发演练、写变更说明 | 拿到真实耗时 |
| T-1 天 | 评审通过、预约窗口、通知相关方 | 确认无其他变更 |
| T0 22:00 | 阶段 1 加列(INSTANT,秒级) | 校验列已存在 |
| T0 +10min | 阶段 2 分批迁数据 | 盯主从延迟,延迟 >10s 就停 |
| T0 +40min | 阶段 2 校验 | 两个计数必须为 0 |
| T0 +1h | 阶段 3 建新索引(INPLACE) | 先确认无长事务 |
| D+1 | 代码发布:双写 + 读新回退旧 | 开关默认 false |
| D+1 | 补漏迁移(重跑阶段 2) | 覆盖双写上线前的缝隙 |
| D+3 | 打开读新开关,观察 | 出问题拨回 false |
| D+7 | 阶段 4.2 删旧索引 | 已确认无代码引用 |
| D+14 | 阶段 4.3 删旧列 | 备份表已就位 |
| D+44 | 删备份表 | 保留满 30 天 |
从 T0 到 D+14,整个迁移跨了两周。这不算慢,这叫把不可逆操作压缩到最少。 一把梭的版本十分钟能跑完,但那十分钟里出任何问题,你都没有退路。
小结
回头看那份原始脚本,它的问题不在于语法或写法,而在于把所有不可逆的操作挤进了一个没有停顿的时间窗口:
- 加列可逆、迁数据可逆、建索引可逆;
- 删索引、删列不可逆;
- 这五件事被串成了一条流水线,于是整条流水线都变成了不可逆的。
拆成四阶段的意义,就是把「可逆」和「不可逆」分开,中间插上验证点、观察期和备份。代价是慢了十天,收益是每个决策点都还有退路。
一句话总结这份推演:
生产上的字段迁移,不是一次「执行」,而是一次「切换」。切换的本质是让新老两套状态长期共存,直到你能证明旧的已经没人用了。
真到了删列那一刻,你应该有的不是勇气,是证据。
附:执行前速查卡
1. 表多大? information_schema.TABLES —— 决定 DDL 方案
2. 版本多少? SELECT VERSION() —— 决定 INSTANT 能不能用
3. 有没有长事务? information_schema.innodb_trx —— 决定 MDL 会不会堵
4. 备份了吗? BAK_ 表 / mysqldump —— 不可逆操作的前提
5. 校验为 0 吗? 两个 COUNT(*) —— 删列前的最后一道闸
6. 谁在读旧字段? grep + 报表 + 同步任务 —— 删索引前的证据
7. 回滚怎么走? 开关 → 回填 → 反向 DDL —— 三级预案