29.5 异构同步的语义损失
异构同步可以让目标端“有数据”,却无法自动保证两边表达的是同一件事。connector 显示 running、offset 持续推进、目标查询也返回 200,只说明管道在工作;类型舍入、 排序规则、事务边界和删除语义仍可能已经变化。
本节给出一套语义合同。它不仅适用于 PostgreSQL 到 MySQL、Kafka、Elasticsearch 或 数据仓库,也适用于两个配置、扩展与 locale 不同的 PostgreSQL 环境。
29.5.1 类型、精度、排序规则与时区
类型映射必须是一张可测试的合同
不能只写:
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,而不是只测正常样本:
min/max
刚好超界
0 / -0
小数临界舍入
NULL / empty
非 ASCII 与组合字符
DST 切换前后
闰日
超长值
NaN / Infinity where supported迁移前后都用同一个 canonical encoder 输出,比较规范化值和预期错误类别。若业务决定 允许损失,例如金额从 4 位小数舍入到 2 位,必须记录舍入规则、受影响行数、总误差和 批准人;不能让驱动默认转换替团队做决定。
时区问题常被样本掩盖
PostgreSQL 的 timestamptz 保存一个绝对时间点,显示受 session TimeZone 影响;
timestamp 不含时区。把前者格式化为本地字符串再写进后者,会永久丢掉 offset。
合同应明确:
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.4 到 PostgreSQL 18.4,且两端都由同一 Pigsty 实验环境管理。它能证明同构 PG 逻辑复制与校验流程,不能证明上述异构类型和 collation 合同。异构结论必须在真实 source/sink 组合上另做边界语料实验。
29.5.2 约束、事务顺序与删除语义
源端约束不会自动变成下游约束
源端可以依赖:
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 需要墓碑
一个简单的:
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 可能比实时事件更晚到:
snapshot contains version 7
stream has already applied version 9
backfill blindly upserts version 7目标就回到了旧状态。每个写入路径都必须服从同一条条件:
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 目标端可查询不等于语义等价
绿灯只能证明它声明的那一层
| 绿灯 | 能证明 | 不能证明 |
|---|---|---|
| connector running | 进程存活并执行主循环 | 没有跳过 poison event |
| offset advancing | 一些事件被确认 | sink 副作用完整、顺序正确 |
| target row count 相等 | 总行数一致 | 行内容、关联和删除一致 |
| target query 成功 | 语法和服务可用 | 排序、精度、完整性等价 |
| lag 接近零 | 消费接近 source head | 历史基线正确 |
异构验收应沿一条更强的梯子:
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 不允许永久缺口。
建立“允许损失登记表”
异构系统很少完全同构,现实做法不是假装零损失,而是让损失显式:
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 发现不一致时,先回答:
在当前阶段谁是 source of truth?
差异来自漏事件、重复、乱序、手工写还是映射改变?
修复目标会不会被下一条旧事件再次覆盖?
修复需要 rewind、rebootstrap 还是单 key replay?
该修复怎样留下 provenance?切流前通常以源端为权威;切流后目标已承接新写,不能继续无条件“用源覆盖目标”。 权威边界随迁移状态改变,必须随状态机一同记录。
本章的目标端 drift 实验很能说明这一点:目标端手工修改 order_id = 1 后,subscription
仍是 running,目标查询也正常,但只有 bucket 1 的摘要暴露了差异。因为当时仍处于
切流前阶段,流程才能用源端权威行修复。若那是一笔切流后的合法目标写,相同动作反而
会销毁正确数据。
所以,数据“到了”是传输结论;业务“等价”是由类型合同、事务合同、校验语料和持续 对账共同支持的结论。两者不能用同一个绿色图标代替。
上一节:在线迁移状态机 · 返回本章目录 · 下一节:多集群迁移环境 · 查看全书目录 · 查看索引中心