第 8 / 23 篇

第7章 消息结构与键:保持对象身份稳定

先看真正到达客户端的字节

连接器文档展示的逻辑事件,与 Kafka 客户端收到的 JSON 外形可能不同。Kafka Connect 的 converter 可以附带 schema 和 payload,也可以只输出 payload。消费者若默认 op 一定在顶层,却收到包含 schema 的外层对象,就会在真实集成中失败。这个错误无法靠手写一份符合预期的 JSON 单元测试发现。

本实验在连接器配置中明确设置 key 与 value 的 JsonConverter,并关闭 converter 的 schema 包装。此处关闭的是 JSON 外层携带的 Connect schema,不是关闭 MySQL 表结构变化处理,也不是说事件没有类型语义。连接器仍需维护自己的表结构历史;消费者仍需理解字段类型和可空性。

我们在早期真实运行中遇到过包装层不一致,随后改为显式连接器级配置。这个经历说明配置需要与收到的真实消息一起验收。仅在 Compose 环境变量中写一个相似名称,不能证明运行时属性一定生效。最终实验直接通过真实消息进入投影,错误形状会在解析或字段检查时失败。

before、after 与操作类型

读快照和创建事件有可用的 after,更新事件同时可能包含 before 与 after,删除事件的 after 为 null。消费者先检查 op 只属于 r、c、u、d,再根据操作选择业务行。删除时保留 before 的对象身份与字段,设置 deleted;普通更新则保存 after 的完整业务内容。

这是一种完整文档投影方式。我们不把更新事件当成字段补丁,也不尝试通过两次 after 相减推导业务命令。原因是数据变化不等于业务意图:价格减少可能来自促销、纠错或补偿,单凭行镜像不能决定是否给用户发券。CDC 消费者适合维护查询事实,不能随意把每次数据库更新解释为一次外部动作。

键和值必须相互校验

Kafka 记录键包含商品 id,值里的业务行也包含 id。消费者要求二者一致,再写入投影。这个断言可以暴露不符合当前契约的转换配置或错误消息,而不是静默把一个对象写到另一个对象的位置。我们没有接收任意 topic 的通用入口,启动绑定还要求世代、消费组与 topic 关系保持固定。

键稳定性决定顺序与压缩行为。如果把变化中的 sku 当键,修改 sku 可能让同一对象进入不同位置,旧键对应的查询记录也可能残留。对于组合主键,需要结构化编码避免字符串拼接歧义;例如租户一加对象二十三,与租户十二加对象三,不能因为缺少分隔协议而生成相同字节。当前单字段 BIGINT 不存在这个组合歧义。

删除包络与 tombstone 的职责

Debezium 删除通常先产生带 d 操作的业务包络,随后产生相同键、空值的 tombstone,用于 Kafka 日志压缩语义。消费者不能把空值当成格式错误,也不能只处理 tombstone 而忽略前面的删除信息。随书程序让 tombstone 参与交付历史和检查点更新,但不把它再次转换成业务行。

投影内部删除标记与 Kafka tombstone 是两个概念。前者防止旧业务记录重放后复活对象,后者服务于 broker 对键的压缩清理。Kafka 清理完成之前,旧创建事件仍可能存在;清理之后,也不能把压缩 topic 当成永远保存每次业务操作的审计账本。日志策略应当根据恢复需要明确设计。

事件身份与业务身份分工

同一商品会对应许多事件,因此商品 id 不能作为唯一交付身份。相反,同一个 Kafka 坐标可能被多次投递,它仍然代表同一条记录。delivery_history 使用消费组、topic、partition、offset 联合作为键,使重启和显式回放能够识别重复。投影主键则使用世代与商品 id,使多次变化聚合成当前查询状态。

这种区分也防止把重建做错。新世代需要读取完整数据,不能被旧消费组的历史去重挡掉。程序建立一对一绑定,禁止同一消费组被随意用于另一个世代,也禁止同一世代混入另一条 topic。真正需要新投影时,创建新的组和新的世代,并从明确的输入开始。

本章练习

分别写出删除包络、tombstone 和普通更新的处理结果,不需要伪造集成报告。然后修改 converter 的 schema 设置,预测当前消费者会在哪一步失败,并解释为什么应修改契约或解析器,而不是直接忽略所有缺少 op 的消息。