# 批量装载与数据校验

LLMS 索引： [llms.txt](/llms.txt)

---

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

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

## 29.3.1 `COPY`、并行、约束和索引顺序 {#item-29-3-1}

### 先区分三条全量路径

| 路径 | 一致性边界 | 适用场景 | 主要代价 |
|---|---|---|---|
| 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 中有两个经常混淆的文件边界：

```sql
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` 不能通过加一个参数变成并行任务。常见并行单位是：

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

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

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

```text
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，可能同时改变增量应用语义。正确
顺序应在演练中固化，例如：

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

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

## 29.3.2 行数、摘要、分桶与业务不变量 {#item-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 表示为：

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

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

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

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

### 摘要必须先定义规范化

下面这种拼接并不可靠：

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

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

```yaml
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。

### 分桶让差异可定位

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

```text
bucket = stable_hash(primary_key) mod 16
```

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

生产中可递归细分：

```text
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 装载速度不能牺牲可追溯错误 {#item-29-3-3}

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

```sql
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 账本至少保存：

```yaml
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：

```text
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 与复制槽治理](../02/) · [返回本章目录](../) · [下一节：在线迁移状态机](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
