3008 字
约 10 分钟
15
S13-A15 · 数据回填:历史数据迁移的工程账

"加了个字段,回填脚本跑了六小时,把库拖垮了。"这句话里每个词都没错:加字段是小事,回填脚本按运维脚本的写法写,拖垮是结果。回填看起来最像一条 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 断点与限速都是空转
字段可在线计算,读路径已兜底 不回填 回填等于把同一逻辑写两遍
业务可容忍读旧值一段时间 读时懒回填 全量推进无收益,还多一份写入压力
数据只在离线副本使用 重建副本 副本可弃可重建,比逐行改写便宜
表已停止写入(归档表) 停机窗口内一次做完 没有并发,限速没有意义

判据是写入放大与不可用时长的乘积。放大很小、窗口很宽,正式工程就是过度设计;放大很大、窗口为零,没有工程就是事故。


自测

  1. 一次回填的规模为什么要按"写入字节 × 副本数 × 索引数"估,而不是按行数?
  2. 长事务在回填期间最典型的症状是什么,为什么容易被误判成缓存问题?
  3. 批大小的两个观测量分别是什么,哪一个应该直接触发降速?
  4. 为什么断点要存游标而不是已处理行数?
  5. 双写过渡的"只写一次"时点缺少判据时,长期代价是什么?
  6. "边回填边读"成立的前提是哪一条读路径性质?
S13-A15 · 数据回填:历史数据迁移的工程账
https://www.clxhxhhr.top/posts/6337/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。