跳至内容

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 precisionNaN、正负无穷、负零、比较语义
char(n) / text尾随空格、Unicode normalization、空串与 NULL
timestamp without time zone它代表本地墙上时间还是业务约定 UTC
timestamp with time zone输出 zone、精度、DST 重叠/缺口
jsonbkey 顺序、重复 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 DELETEkey 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 的摘要暴露了差异。因为当时仍处于 切流前阶段,流程才能用源端权威行修复。若那是一笔切流后的合法目标写,相同动作反而 会销毁正确数据。

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


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

最后更新于