跳转到主要内容

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.6 到 PostgreSQL 18.6,且两端都由同一 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 的摘要暴露了差异。因为当时仍处于 切流前阶段,流程才能用源端权威行修复。若那是一笔切流后的合法目标写,相同动作反而 会销毁正确数据。

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


上一节:在线迁移状态机 · 返回本章目录 · 下一节:多集群迁移环境 · 查看全书目录 · 查看索引中心