一次字段迁移的生产推演:从 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 步被中断时
5ADD 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 GBOnline 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 COLUMNMySQL 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 步:预约窗口并通知

时间选择上有三条经验:

  1. 业务低谷,通常是夜间或凌晨,但你要确定自己醒着——凌晨三点的决策质量很差;
  2. 避开任何其他变更,一次只改一个变量;
  3. 避开节假日前和周五,出问题没人一起看。

通知要写到人:谁执行、影响什么、大概多久、出问题找谁。

第 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 —— 三级预案