# 异构同步的语义损失

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

---

异构同步可以让目标端“有数据”，却无法自动保证两边表达的是同一件事。connector
显示 running、offset 持续推进、目标查询也返回 200，只说明管道在工作；类型舍入、
排序规则、事务边界和删除语义仍可能已经变化。

本节给出一套语义合同。它不仅适用于 PostgreSQL 到 MySQL、Kafka、Elasticsearch 或
数据仓库，也适用于两个配置、扩展与 locale 不同的 PostgreSQL 环境。

## 29.5.1 类型、精度、排序规则与时区 {#item-29-5-1}

### 类型映射必须是一张可测试的合同

不能只写：

```text
numeric -> decimal
timestamp -> timestamp
jsonb -> json
```

至少要写清：

| 源语义 | 目标映射需要回答 |
|---|---|
| `numeric(p,s)` | 最大精度、scale、舍入模式、溢出是失败还是截断 |
| `bigint` / unsigned integer | 目标上下界，超界行如何隔离 |
| `real` / `double precision` | `NaN`、正负无穷、负零、比较语义 |
| `char(n)` / text | 尾随空格、Unicode normalization、空串与 NULL |
| `timestamp without time zone` | 它代表本地墙上时间还是业务约定 UTC |
| `timestamp with time zone` | 输出 zone、精度、DST 重叠/缺口 |
| `jsonb` | key 顺序、重复 key、numeric 精度、缺失与 JSON null |
| UUID / enum | 原生类型还是 text，非法值和新增 enum label |
| array / range / multirange | 展开、序列化还是目标原生类型 |
| `bytea` | 编码、大小上限、二进制是否被误当字符串 |

应为每一种映射准备 boundary corpus，而不是只测正常样本：

```text
min/max
刚好超界
0 / -0
小数临界舍入
NULL / empty
非 ASCII 与组合字符
DST 切换前后
闰日
超长值
NaN / Infinity where supported
```

迁移前后都用同一个 canonical encoder 输出，比较规范化值和预期错误类别。若业务决定
允许损失，例如金额从 4 位小数舍入到 2 位，必须记录舍入规则、受影响行数、总误差和
批准人；不能让驱动默认转换替团队做决定。

### 时区问题常被样本掩盖

PostgreSQL 的 `timestamptz` 保存一个绝对时间点，显示受 session `TimeZone` 影响；
`timestamp` 不含时区。把前者格式化为本地字符串再写进后者，会永久丢掉 offset。

合同应明确：

```yaml
source_type: timestamptz
wire_form: RFC3339 with numeric offset
canonical_zone: UTC
precision: microseconds
target_type: timestamp(6) with time zone
ambiguous_local_time_policy: reject
```

还要验证 connector、JDBC/driver 与 sink session 的时区，而不只比较 database 参数。
夏令时地区的 `02:30` 可能不存在，`01:30` 可能出现两次；用七月的一条 UTC 样本无法
覆盖这些边界。

### collation 会改变“同样查询”的结果

字符值逐字节相同，也可能因 libc/ICU/provider/version 不同而产生：

- `ORDER BY` 顺序变化；
- case/accent insensitive 比较变化；
- UNIQUE index 对“相等”的判断不同；
- prefix/range query 命中集合不同；
- 分页边界漂移。

迁移 inventory 应记录数据库和列级 collation/provider/version，并在目标查询
`pg_collation` 与实际索引定义。若应用依赖稳定顺序，应在 SQL 中给出完整 tie-breaker，
例如 `ORDER BY display_name COLLATE ..., customer_id`；只靠隐含排序，本来就没有
跨环境保证。

本章正式实验使用 PostgreSQL 18.6 到 PostgreSQL 18.6，且两端都由同一 Pigsty
实验环境管理。它能证明同构 PG 逻辑复制与校验流程，不能证明上述异构类型和 collation
合同。异构结论必须在真实 source/sink 组合上另做边界语料实验。

## 29.5.2 约束、事务顺序与删除语义 {#item-29-5-2}

### 源端约束不会自动变成下游约束

源端可以依赖：

```text
PRIMARY KEY / UNIQUE
FOREIGN KEY
CHECK
EXCLUDE
domain constraint
trigger-maintained invariant
transaction isolation
deferred constraint
```

消息流通常只携带行变化，不携带这些证明。目标是搜索索引或对象存储时，甚至没有对应的
约束机制。于是“source 每次提交都合法”不能推出“sink 任意时刻都合法”。

例如源事务先创建 customer 再创建 order。若 connector 按 table 分 topic，下游并行
消费，order 可能先可见。解决方式不是祈祷消费者够快，而是明确：

- 是否保留 source transaction ID 和 commit boundary；
- 跨表事件是否要求原子可见；
- 不要求原子时，查询层如何隐藏未完成 batch；
- parent 缺失是重试、暂存、告警还是丢弃；
- checkpoint 在整个事务之后还是每条事件之后推进。

事务内 row order 也不能随意打散。账户扣款、入账和 ledger 三条事件若被三个 worker
独立提交，中间态会破坏守恒。高吞吐设计必须说明它牺牲了什么可见性，以及如何恢复。

### upsert 需要版本，delete 需要墓碑

一个简单的：

```sql
INSERT ... ON CONFLICT DO UPDATE
```

只保证当前语句不因 key 冲突失败，不保证旧事件不会覆盖新状态。目标记录通常需要
source version/commit position，并采用条件更新。

删除则至少有四种不同语义：

| 源动作 | 下游可能需要 |
|---|---|
| physical `DELETE` | key tombstone，删除投影 |
| soft delete | 保留记录并同步 `deleted_at` |
| FK cascade | 每个子变化或可重建的级联合同 |
| `TRUNCATE` | 清空整个 collection，或明示不支持并触发重建 |

若 sink 先收到 DELETE，随后重放一条旧 UPDATE，没有 version/tombstone ledger 就会把
已删除对象复活。tombstone 的保留时间必须长于最大 replay/backfill 窗口；过早压缩会
重新暴露复活风险。

PostgreSQL publication 可以发布 `TRUNCATE`，但 row filter 不会过滤它。外部 CDC
connector 是否把它转成一个控制事件、逐行 delete 还是直接不支持，要在上线前实测。

### backfill 与实时流必须共享所有权规则

backfill 可能比实时事件更晚到：

```text
snapshot contains version 7
stream has already applied version 9
backfill blindly upserts version 7
```

目标就回到了旧状态。每个写入路径都必须服从同一条条件：

```text
apply only if incoming source version is newer
or if this batch is the declared authoritative rebuild
```

重建期间可以使用新的目标 namespace/index/table，完成校验后原子交换；不要让不带
version 的历史 backfill 与实时流争写同一记录。

## 29.5.3 目标端可查询不等于语义等价 {#item-29-5-3}

### 绿灯只能证明它声明的那一层

| 绿灯 | 能证明 | 不能证明 |
|---|---|---|
| connector running | 进程存活并执行主循环 | 没有跳过 poison event |
| offset advancing | 一些事件被确认 | sink 副作用完整、顺序正确 |
| target row count 相等 | 总行数一致 | 行内容、关联和删除一致 |
| target query 成功 | 语法和服务可用 | 排序、精度、完整性等价 |
| lag 接近零 | 消费接近 source head | 历史基线正确 |

异构验收应沿一条更强的梯子：

```text
transport alive
  -> no unaccounted rejects
      -> schema/type contract passes boundary corpus
          -> row and bucket manifests agree
              -> business invariants agree
                  -> representative queries agree
                      -> workload SLO agrees
                          -> reconciliation remains stable over time
```

代表性查询不是随机挑十条 `SELECT *`，而应从业务清单中覆盖：

- equality、range、prefix、全文与排序；
- NULL、缺失字段、数组/JSON 嵌套；
- pagination 和 tie-breaker；
- 聚合、去重、金额与时区窗口；
- 删除、恢复、乱序和重复事件；
- 最大对象、热点 key 与大事务；
- 权限过滤和租户隔离。

每条都定义允许差异。例如搜索结果可能允许排名小幅变化，但不能跨租户；报表金额必须
精确相同；分析仓库允许 10 分钟最终一致，但 reconciliation 不允许永久缺口。

### 建立“允许损失登记表”

异构系统很少完全同构，现实做法不是假装零损失，而是让损失显式：

```yaml
field: customer.display_name
difference: ICU collation produces different tie order
affected_queries: customer-search
business_impact: none when customer_id is secondary key
mitigation: append customer_id to ORDER BY
validation: query-corpus/collation-03
owner: customer-platform
approved_until: permanent
```

没有登记的差异一律视为 defect；登记项也要有 owner、验证和复审条件。这个机制防止
“已知差异”在口头交接中无限扩张。

### 权威源和修复方向必须唯一

持续 reconciliation 发现不一致时，先回答：

```text
在当前阶段谁是 source of truth？
差异来自漏事件、重复、乱序、手工写还是映射改变？
修复目标会不会被下一条旧事件再次覆盖？
修复需要 rewind、rebootstrap 还是单 key replay？
该修复怎样留下 provenance？
```

切流前通常以源端为权威；切流后目标已承接新写，不能继续无条件“用源覆盖目标”。
权威边界随迁移状态改变，必须随状态机一同记录。

本章的目标端 drift 实验很能说明这一点：目标端手工修改 `order_id = 1` 后，subscription
仍是 running，目标查询也正常，但只有 bucket 1 的摘要暴露了差异。因为当时仍处于
切流前阶段，流程才能用源端权威行修复。若那是一笔切流后的合法目标写，相同动作反而
会销毁正确数据。

所以，数据“到了”是传输结论；业务“等价”是由类型合同、事务合同、校验语料和持续
对账共同支持的结论。两者不能用同一个绿色图标代替。

---

[上一节：在线迁移状态机](../04/) · [返回本章目录](../) · [下一节：多集群迁移环境](../06/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
