"加了个字段,回填脚本跑了六小时,把库拖垮了。"这句话里每个词都没错:加字段是小事,回填脚本按运维脚本的写法写,拖垮是结果。回填看起来最像一条 SQL,实际上是以可控速率改写全量历史,同时不打断线上读写——它同时是数据工程、容量控制和发布流程三件事。
既然是账,就先看三笔成本,再看四个必须一直记着的数。
1. 三笔成本,与它们被低估的原因
| 成本 | 机制 | 为什么常被低估 |
|---|---|---|
| 写入压力 | 全量行重写:binlog 放大、缓冲池被一次性数据冲刷、redo 增长 | 只在测试库量级上试过;测试库没有从库,也没有长事务 |
| 锁与长事务 | 大事务持行锁、undo 不释放、MVCC 版本链变长 | 单条 UPDATE 很快,一万条一次提交就换了性质 |
| 回滚难度 | 旧值没留,回滚只能靠备份或反向脚本 | 把"可重跑"误当成"可回滚" |
最容易被忽略的是第二笔的二阶效应:长事务让 undo 膨胀、让后台清理落后,读请求要沿更长的版本链走。表现不是"回填时写冲突",而是**"回填时读变慢"**——看到写冲突会去调锁,看到读变慢往往先去怀疑缓存,方向就错了。
规模的判据也不是行数,而是写入字节 × 副本数 × 索引数。两千万行的窄表可能比五百万行的宽表轻,一张带三个二级索引的表,一次回填等于写四遍。
| 度量 | 口径要点 | 它能让什么决定变化 |
|---|---|---|
| 回填时长 | 按批累计,含暂停时间,与预计比 | 决定是否改方式或改窗口 |
| 批次失败率 | 失败批次 ÷ 已执行批次 | 决定是否回退到更小批 |
| 主从延迟峰值 | 取执行期间分位值,不取均值 | 决定批大小与限速 |
| 校验差异率 | 抽样差异行 ÷ 抽样行 | 决定能否收紧约束 |
四个数里只有延迟峰值能实时改变行为,其余三个是事后判据。所以限速要挂在延迟上,而不是挂在时钟上。
2. 四种方式,各自的前提条件
| 方式 | 适用条件 | 主要代价 | 不适用 |
|---|---|---|---|
| 一次性脚本 | 数据量小于一次批处理、有可停写窗口 | 窗口内不可写 | 表在线上被持续写 |
| 分批任务 | 大表、需要限速与可暂停 | 要断点、要幂等、要观测 | 业务无法容忍读到部分旧值 |
| 双写过渡 | 新字段必须即时正确 | 一致性校验、失败补偿、清理时点 | 写路径不能改 |
| 读取时兜底 | 旧值可计算或可缺省 | 逻辑长期留在读路径,会腐化 | 兜底需要跨服务查数 |
─── 分批按主键区间切,不用 LIMIT OFFSET(越往后越慢)
UPDATE orders SET channel_code = :resolved
WHERE id > :lastId AND id <= :lastId + :step
AND channel_code IS NULL;
─── 单批返回影响行数;为 0 且已到主键上界才算完成
选择的顺序应当从最便宜的问起:能不能不回填(读时兜底)→ 能不能分批(限速可控)→ 才考虑双写。反过来做,成本最高的方案会先落地,然后一直留在那里,因为拆除它需要一次新的迁移。
3. 批次大小由两个观测量决定,不是行数
行数只在行长稳定时才具有可比性。真正要控的是单批时长与执行期间的主从延迟:前者决定出事时损失多少,后者决定会不会把从库读崩。
| 观测量 | 目标 | 越界后的表现 |
|---|---|---|
| 单批时长 | 秒级(个位数到十几秒,按库规格核对) | 事务堆积、锁等待上升 |
| 主从延迟峰值 | 落在可接受回放余量内 | 从库读到旧值、切换风险上升 |
| binlog 生成速率 | 不超过日常峰值的若干倍 | 备份与同步链路被挤占 |
| 单批 undo 增量 | 不触发 undo 膨胀告警 | 读版本链变长,读变慢 |
// 批大小是变量:按延迟与耗时反馈收敛,不是拍一个数
int batch = 500;
while (cursor.hasMore()) {
long t0 = System.nanoTime();
int n = backfill.runBatch(cursor, batch); // 每批自带事务边界
long costMs = (System.nanoTime() - t0) / 1_000_000;
if (costMs > 8000 || replica.lagSeconds() > 3) batch = Math.max(50, batch / 2);
else if (costMs < 2000 && replica.lagSeconds() < 1) batch = Math.min(5000, batch * 2);
}
上界由"出事时还能停下来"决定:一批八秒,发现延迟抬头最多损失八秒;一批八分钟,发现时已经在填坑。这条判据比任何"每批多少行"的经验值都稳。
4. 幂等是回填的第一要求
回填一定会重跑:进程被杀、延迟告警手动停、批次失败重试。幂等的定义不是"写得对",而是重复执行结果相同。
| 写法 | 幂等 | 原因 |
|---|---|---|
SET col = f(other_col) |
是 | 结果只依赖输入行 |
SET col = col + 1 |
否 | 依赖当前值,每跑一次变一次 |
INSERT ... ON DUPLICATE KEY UPDATE |
是 | 唯一键收敛到同一行 |
先 DELETE 再 INSERT |
否 | 中间态可见,并发下会丢行 |
| 追加审计/日志行 | 否 | 每跑一次多一行 |
─── 进度落表:游标与批次结果分开存,才能回答"卡在哪"
CREATE TABLE backfill_progress (
task_name VARCHAR(64) PRIMARY KEY,
cursor_id BIGINT NOT NULL,
done_rows BIGINT NOT NULL,
failed_rows BIGINT NOT NULL,
updated_at DATETIME NOT NULL
);
断点要存游标而不是"已处理行数":跳过失败行之后行数会失真,游标不会。进度还必须对业务可见——"回填到 63%,预计还要两小时"和"还在跑",决定的是别人要不要先绕过这个功能。
5. 双写过渡的隐性成本,以及并发影响
| 成本 | 具体形态 | 什么时候清 |
|---|---|---|
| 一致性校验 | 需周期性比对两处,差异要能归因 | 差异率连续趋零后降频,不归零不切读 |
| 双写失败率 | 一侧失败若不阻断主流程,就必须补偿 | 补偿队列排空且有告警 |
| 双读歧义 | 两处值不同时以谁为准 | 切读完成、旧路径只读 |
| 清理时点 | 旧字段/旧表何时删 | 回滚窗口过去之后 |
─── 一致性校验:抽样比对,差异必须能落到具体行
SELECT o.id, o.channel_code AS new_v, l.channel AS old_v
FROM orders o JOIN orders_legacy l ON l.id = o.id
WHERE o.updated_at >= :from AND o.updated_at < :to
AND (o.channel_code IS NULL OR o.channel_code <> l.channel)
LIMIT 200;
双写最难的不是写两次,而是定义"什么时候可以只写一次"。这个时点没有判据,双写就会从过渡方案变成长期架构,代价是此后每次改动都要改两处、每次故障都要查两条链。
回填期间被同时占用的是三样东西:连接池、从库回放线程、缓冲池。所以回填任务必须用独立的连接池与独立账号——共用一个池时,"回填把池占满"会以"业务超时"的形式出现,而超时日志里看不到回填的影子。
6. 验证、顺序,与不可回滚的那一类
| 验证层次 | 做法 | 能发现 |
|---|---|---|
| 行数 | 目标条件计数与源侧对齐 | 漏跑、重复跑 |
| 抽样 | 按比例抽行比对业务口径 | 映射错误、边界值 |
| 口径比对 | 汇总指标与旧口径对齐 | 单位、精度、分类错位 |
| 读路径回归 | 回填后跑关键查询比对结果 | 索引未覆盖、过滤条件漏改 |
─── 顺序:加可空列 → 双写或兜底 → 回填 → 收紧约束 → 删旧列
ALTER TABLE orders ADD COLUMN channel_code VARCHAR(16) NULL;
─── 收紧之前先扫违规行;扫不干净就不收紧
ALTER TABLE orders MODIFY COLUMN channel_code VARCHAR(16) NOT NULL;
"回填完再上线"与"边回填边读"的取舍只有一个判据:读路径能不能区分"旧值"和"空值"。能区分(NULL 有独立语义)就可以边填边读;不能区分(NULL 被当成"没有"),就必须等回填完成再切读,否则用户会先看到一批被抹掉的数据。
不可回滚的迁移(改类型、拆表、删列)必须先有可恢复备份,并演练一次恢复;"备份存在"不是证据,"从备份恢复成功过一次"才是。DDL 与回填的顺序同样不能颠倒:先加可空列再回填,反过来会让写入路径在整个回填窗口里失败。
排错:五种现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 回填期间主从延迟持续高位 | 大事务 + 单批过宽 | 缩批、按主键切片、批间留间隔 |
| 从库/报表读到旧值 | 回放滞后,读走了从库 | 校验读走主库,或等延迟归零再验 |
| 重跑后部分行值变了 | 非幂等写法(自增、追加) | 改成 = f(源),重跑前先核对 |
| 中途失败后不知道进度 | 断点只在内存或日志里 | 进度落表,存游标不存行数 |
| 收紧约束时失败 | 仍有 NULL 或越界值 | 先跑违规行扫描,不通过不收紧 |
边界:这些情况不必建正式回填工程
| 场景 | 做法 | 理由 |
|---|---|---|
| 数据量小于一次批处理(几万行内) | 一条带条件的 UPDATE |
断点与限速都是空转 |
| 字段可在线计算,读路径已兜底 | 不回填 | 回填等于把同一逻辑写两遍 |
| 业务可容忍读旧值一段时间 | 读时懒回填 | 全量推进无收益,还多一份写入压力 |
| 数据只在离线副本使用 | 重建副本 | 副本可弃可重建,比逐行改写便宜 |
| 表已停止写入(归档表) | 停机窗口内一次做完 | 没有并发,限速没有意义 |
判据是写入放大与不可用时长的乘积。放大很小、窗口很宽,正式工程就是过度设计;放大很大、窗口为零,没有工程就是事故。
自测
- 一次回填的规模为什么要按"写入字节 × 副本数 × 索引数"估,而不是按行数?
- 长事务在回填期间最典型的症状是什么,为什么容易被误判成缓存问题?
- 批大小的两个观测量分别是什么,哪一个应该直接触发降速?
- 为什么断点要存游标而不是已处理行数?
- 双写过渡的"只写一次"时点缺少判据时,长期代价是什么?
- "边回填边读"成立的前提是哪一条读路径性质?