29.3 批量装载与数据校验
迁移最容易制造一种虚假的成功感:目标端已经有很多行,增量也在流动,于是团队宣布 “数据迁完了”。但全量装载只回答“怎样把字节搬过去”,数据校验才回答“搬过去的是否 还是同一份业务事实”。
本节把两者作为一个不可拆分的阶段:装载方案必须预先定义验证方法,验证失败必须能
定位到批次、分桶乃至具体主键,而不是在切流前夜才比较两个 count(*)。
29.3.1 COPY、并行、约束和索引顺序
先区分三条全量路径
| 路径 | 一致性边界 | 适用场景 | 主要代价 |
|---|---|---|---|
| subscription initial copy | 由 table sync worker 与 slot 协调 | PG 到 PG,目标表已准备好 | 并行度和变换能力受逻辑复制模型约束 |
pg_dump / pg_restore | dump 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(*) 很弱:删掉一行再插入一行,行数完全不变。反过来,直接对十亿行做
一个全表摘要虽然更强,一旦不一致却只会得到“某处不同”。实用校验从便宜到昂贵逐层
推进:
- 对象 manifest:schema、表、列、类型、默认值、identity、约束、索引、分区、 publication membership;
- 精确行数:不能拿
pg_class.reltuples这类估算值做最终验收; - 列统计:
min/max/sum/null count/distinct count、状态分布; - 稳定有序摘要:对规范化后的逻辑行计算 digest;
- 分桶摘要:发现差异后只重扫异常桶;
- 业务不变量:外键孤儿、金额边界、状态机、账务守恒;
- 代表性业务查询:从应用可见结果验证语义与性能。
本章实验把一张表的 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 与复制槽治理 · 返回本章目录 · 下一节:在线迁移状态机 · 查看全书目录 · 查看索引中心