跳至内容

29.3 批量装载与数据校验

迁移最容易制造一种虚假的成功感:目标端已经有很多行,增量也在流动,于是团队宣布 “数据迁完了”。但全量装载只回答“怎样把字节搬过去”,数据校验才回答“搬过去的是否 还是同一份业务事实”。

本节把两者作为一个不可拆分的阶段:装载方案必须预先定义验证方法,验证失败必须能 定位到批次、分桶乃至具体主键,而不是在切流前夜才比较两个 count(*)

29.3.1 COPY、并行、约束和索引顺序

先区分三条全量路径

路径一致性边界适用场景主要代价
subscription initial copy由 table sync worker 与 slot 协调PG 到 PG,目标表已准备好并行度和变换能力受逻辑复制模型约束
pg_dump / pg_restoredump snapshot完整或选择性对象迁移需要自行衔接 dump 后的增量
COPY / \copy 管道由导出事务和位点协议定义大表、异构转换、分批装载快照、分片、错误账本和增量汇合都要自己负责

COPY 很快,但它不自动提供迁移一致性。若导出事务没有与 logical slot 的 exported snapshot 对齐,逐表 COPY 得到的可能是不同时间点;若完成全量后才创建 slot,全量与 增量之间还会留下永久缺口。第 29.2 节的“snapshot 加 stream”协议因此同样适用于手工 批量装载。

PostgreSQL 中有两个经常混淆的文件边界:

COPY shop.orders (order_id, customer_id, status, amount, updated_at)
TO '/server/path/orders.csv'
WITH (FORMAT csv, HEADER true, ENCODING 'UTF8');

COPY 的文件由数据库服务器进程读取或写入,需要服务器文件权限;psql\copy 则让客户端读写文件,通过 SQL 连接传输数据。迁移工作站通常使用 \copy, 避免给数据库角色服务器文件权限。无论选哪一种,都应:

  • 显式列出列名,不依赖物理列顺序;
  • 固定编码、日期格式、时区和 NULL 表示;
  • 记录导出查询、snapshot、源系统标识、行数、文件大小与文件摘要;
  • 把原始文件或不可变对象版本作为可追溯输入;
  • pg_stat_progress_copy 观察正在执行的 COPY,而不是从文件大小猜完成度。

binary COPY 省去文本转换,在完全同构、版本和类型实现已验证时可能更快;它不是通用 交换格式。跨 major、跨架构或有类型映射时,文本/CSV 加显式规范通常更可审计。

并行单位要可重放

一条 COPY 不能通过加一个参数变成并行任务。常见并行单位是:

不同表
同一分区表的不同叶子分区
按稳定主键范围切片
预先生成且有 manifest 的多个文件
pg_restore 的独立对象任务

切片必须互斥、完备并可复算。例如按整数主键范围切分时,记录 [lower, upper),不要用随数据变化的 LIMIT/OFFSET。按 hash 分桶时,固定 hash 算法、编码和桶数。并发量同时受源端顺序读、网络、目标 WAL、磁盘、索引维护、 autovacuum、standby 重放与连接数约束;“有 32 核就开 32 个 COPY”不是容量模型。

可先用一小段代表性数据测量:

source export MB/s
network MB/s and retransmission
target heap MB/s
WAL bytes / loaded byte
standby replay lag
checkpoint pressure
CPU spent on conversion and indexes

再逐级增加 worker,找到吞吐开始变平、延迟或 WAL 开始恶化之前的并发点。

约束、触发器和索引的顺序是风险选择

COPY FROM 会执行 check constraint 和 trigger,但不会执行 rewrite rule;外键检查、 二级索引维护和触发器都可能成为装载成本。不能因此笼统地把它们全部关闭:

做法收益风险与前提
保留 PK/UNIQUE/CHECK立即拒绝重复或非法行装载时持续维护索引
装完再建二级索引批量排序建索引通常更快装载期间查询能力弱,建索引需额外空间
按父表再子表装载可保留 FK 检查并行度下降
装入 staging 再转换错误隔离、类型转换可审计多一份空间与一次写入
暂缓 FK 后再 VALIDATE加快大批量导入切流前必须完成验证,且不能让非法数据外泄

对 online migration,目标表通常已经服务 logical apply。随意禁用 trigger、 session_replication_role 或删除 replica identity,可能同时改变增量应用语义。正确 顺序应在演练中固化,例如:

创建 schema 与必要主键
  -> 创建不参与装载路径的必要类型/扩展
  -> 全量装载或启动 initial copy
  -> 建立可延后的二级索引
  -> 验证/启用约束
  -> ANALYZE
  -> 等待增量追平
  -> 运行数据与业务校验

装载后立即 ANALYZE。否则数据虽然完整,优化器仍可能按空表或旧统计量选择计划, 把“迁移正确”误判成“新库性能不行”。

29.3.2 行数、摘要、分桶与业务不变量

校验是一架逐层缩小范围的梯子

单独的 count(*) 很弱:删掉一行再插入一行,行数完全不变。反过来,直接对十亿行做 一个全表摘要虽然更强,一旦不一致却只会得到“某处不同”。实用校验从便宜到昂贵逐层 推进:

  1. 对象 manifest:schema、表、列、类型、默认值、identity、约束、索引、分区、 publication membership;
  2. 精确行数:不能拿 pg_class.reltuples 这类估算值做最终验收;
  3. 列统计min/max/sum/null count/distinct count、状态分布;
  4. 稳定有序摘要:对规范化后的逻辑行计算 digest;
  5. 分桶摘要:发现差异后只重扫异常桶;
  6. 业务不变量:外键孤儿、金额边界、状态机、账务守恒;
  7. 代表性业务查询:从应用可见结果验证语义与性能。

本章实验把一张表的 logical manifest 表示为:

row_count
ordered row digest
numeric sum where applicable
status histogram where applicable
invariant violations

源端和目标端都用同一组显式列与规范化规则生成它,而不是比较 heap 文件或物理 WAL。 初始复制的正式证据为:

customers = 5,000
orders    = 20,000
两张表 pg_subscription_rel 状态均为 r
源、目标 logical manifest 完全相同

后续又同步 500 个 insert、200 个 update 和 100 个 delete,等 marker 被目标确认后再次 比较,manifest 仍完全相同。

摘要必须先定义规范化

下面这种拼接并不可靠:

md5(string_agg(a || '|' || b, '' ORDER BY id))

因为 NULL、分隔符转义、浮点格式、timestamp 时区、JSON key 顺序、collation 和编码 都可能制造歧义。更安全的合同至少明确:

columns: [order_id, customer_id, status, amount, updated_at]
order_by: [order_id]
null_token: "\\N"
text_encoding: UTF-8
numeric_scale: 2
timestamp_zone: UTC
timestamp_precision: microseconds
json_canonicalization: sorted-keys
row_framing: length-prefixed
digest: sha256

摘要算法不是安全认证;它是高概率发现迁移差异的工程手段。关键业务金额还应比较精确 聚合和业务不变量,不能只依赖 hash。

分桶让差异可定位

以不可变主键把行分成固定数量的桶:

bucket = stable_hash(primary_key) mod 16

每个桶分别记录行数和摘要。正式实验在目标端只改动 order_id = 1,16 个桶中只有 bucket 1 不一致;从源权威行修复后,不一致桶集合回到空。这比发现全表摘要不同后重新 传输整张表更适合持续 reconciliation。

生产中可递归细分:

table mismatch
  -> bucket mismatch
      -> primary-key range mismatch
          -> row-level diff
              -> approved repair

修复操作也要写 ledger:源权威端、主键、修复前后摘要、执行者、ticket、commit time 和复核结果。不要让“校验工具”直接静默覆盖目标。

校验也会与写入竞态

如果源端仍在写,先扫源、再扫目标,结果可能来自不同逻辑时点。可选方案包括:

  • 在同一个 exported snapshot 上导出基线;
  • 记录源端 marker LSN,等待目标确认后再比较;
  • 对持续校验连续运行两轮,只升级稳定重复的差异;
  • 按业务 updated_at 水位排除仍在变化的尾部;
  • 在冻结窗口内做最终强校验。

“这次比较相等”必须附带比较边界。否则它只能证明两个扫描偶然读到了相同结果。

29.3.3 装载速度不能牺牲可追溯错误

PostgreSQL 18 的 COPY FROM 可以对文本或 CSV 输入使用:

COPY migration_stage.orders_raw
FROM STDIN
WITH (
  FORMAT csv,
  HEADER true,
  ON_ERROR ignore,
  REJECT_LIMIT 100,
  LOG_VERBOSITY verbose
);

这给“少量脏行继续装载”提供了原生工具,但边界很窄:

  • ON_ERROR ignore 只忽略把输入字段转换为目标类型时的错误;
  • constraint、trigger、I/O 等错误不会因此都被吞掉;
  • REJECT_LIMIT 应是显式且很小的错误预算,超过立即失败;
  • verbose 日志可能包含输入值,只能进入受控证据目录;
  • 被忽略的行必须进入后续补录与复核流程,不能只在日志中存在。

一个可追溯 reject 账本至少保存:

run_id: 2026-07-29-shop-orders-01
source_object: s3://migration/orders/part-017.csv
source_sha256: ...
record_locator: line-18342
primary_key_if_known: 923812
error_class: invalid_numeric
raw_record_ref: encrypted://...
decision: pending
repair_version: null
replay_run_id: null

原始敏感行不必进入普通日志;可以保存不可逆摘要和受控对象引用。重要的是能够回答: 这行来自哪里、为什么被拒绝、是否修复、在哪个 run 重放、最终是否进入目标。

staging 比在正式表里猜错更便宜

异构或质量未知的数据优先装入 staging:

raw text columns
  -> parse and classify
      -> quarantine rejects
          -> cast into typed staging
              -> validate business rules
                  -> merge into target

这样 conversion error、业务 error 和目标冲突可以分别统计。正式表上的 transaction 仍应保持全成或全败;批次间可独立提交,但每个批次必须有不可变输入和 idempotent 重放方法。

一次大 COPY 在中途失败并回滚后,已经插入的 tuple 会成为不可见 dead tuples,占用 空间,之后可能需要 VACUUM 回收。把重试理解为“失败就再跑一次”会在有限窗口里放大 I/O 和磁盘压力。应在演练中测量失败批次的空间后果,合理拆批,并为 vacuum 留预算。

本阶段的停止线

满足以下条件,才能从“全量装载”进入“增量追平”或最终校验:

  • 每个输入文件/切片都有 manifest、行数与摘要;
  • 成功行数加拒绝行数与输入记录数守恒;
  • reject 未超过预算,且每一行都有处置状态;
  • 目标对象、精确行数、分桶摘要和业务不变量已输出;
  • deferred index 已创建,constraint 已验证,统计信息已更新;
  • 任一失败批次都能无副作用重放;
  • 校验采用的 snapshot/marker 边界已记录。

吞吐是迁移的约束,不是迁移的正确性定义。一个快到无法解释丢了哪些行的装载流程, 不具备上线资格。


上一节:CDC 与复制槽治理 · 返回本章目录 · 下一节:在线迁移状态机 · 查看全书目录 · 查看索引中心

最后更新于