第4章 业务模型:选择能够解释的变化
一个足够小但不失真的目录
实验源表 inventory.products 初始有两千行。id 是 BIGINT 主键,sku 是可显示的商品编码,price_cents 是整数价格,active 表示业务可用状态,revision 是由实验写入程序显式维护的业务修订号。这些字段刻意避免复杂序列化,让我们能够看清同步协议,而不是把大部分篇幅花在某种搜索引擎的配置上。
修订号不等于 binlog 位置。它只在实验 SQL 中递增,不是由数据库自动为所有写入保证的全局序列。连接器不能替业务程序补足漏增的 revision,消费者也不会把它当成唯一去重依据。真正的交付身份来自 Kafka 的 topic、partition、offset;对象更新顺序则在相同主键始终进入同一分区的前提下,使用分区内 offset 防止旧记录覆盖新记录。
价格为整数使完整对账有明确标准。若改用 DECIMAL,连接器可能产生带编码规则的值,客户端需要按数据类型正确还原;若改用浮点,则需要先决定业务接受的表示和比较语义。把所有数字统一转换成字符串也不是通用答案,它会改变查询排序与聚合行为。投影字段类型应当写进契约,并在真实样例上验证。
查询文档与恢复元数据分开
products_projection 使用 generation 与 id 作为复合主键。generation 标识投影世代,使在线版本和重建版本能够同时存在。document 保存业务字段,deleted 保存删除状态,source_partition 与 source_offset 保存最近一次应用的位置。恢复元数据不直接暴露给商品查询,却决定了重放与重建能否安全执行。
删除记录仍保留原对象信息,主要是为了课堂观察和阻止更旧事件覆盖。生产中这会涉及数据最小化与保留期限,需要缩减或清理敏感内容。本书商品数据是合成的,不存在个人身份信息,也没有实现合规删除工作流。不能因为查询结果隐藏了对象,就声称数据库、日志与备份已经清除了所有副本。
delivery_history 为每个消费组保存已应用的事件坐标,唯一约束把重复投递变成显式冲突。delivery_checkpoint 保存下一个已在数据库中处理的位置,和业务投影在同一 PostgreSQL 事务内写入。它有助于诊断数据库已经走到哪里,但本实现启动恢复仍使用 Kafka 提交位点;没有偷偷用数据库检查点跳过 broker 尚未确认的事件。
为什么不用一张万能事件表
把全部变化原封不动塞进一张 JSON 表可以保留信息,却不自动产生正确查询。业务仍需决定主键、删除、字段默认值、版本兼容和重建边界。我们让事件历史承担去重,让商品文档承担读取,使不同目标的约束清楚可见。历史表也不是无限增长的免费保险,它会带来索引体积和清理成本,第十六章会讨论容量边界。
同样,主键更新不能当成普通属性修改。连接器可能用删除旧键与创建新键来表达身份迁移,消费逻辑需要处理两个对象的生命周期。本实验不修改 id,因此不能宣称测试了主键迁移。sku 改变则是普通文档更新,因为 id 保持不变。设计评审时应当把“字段可修改”与“对象身份可迁移”分开讨论。
为每种操作准备可区分的标记
快照期间插入的 id 为三千零一,停机期间插入的 id 为四千零一。id 一被修改,id 二被删除;停机阶段再对一百个对象做价格递增,并删除 id 十。这样每次对账失败都能回到具体操作,不会只看到一个模糊的总数差异。标记编号没有生产含义,只为实验建立可追踪的因果关系。
源表起初没有 note。后续真实执行 ALTER TABLE 增加可空列,再修改 id 一的 note。旧对象的源库默认值为 null,在线投影需要显式完成同样的字段归一化,新世代快照则会直接读取完整结构。这个设计让 schema 演进不仅是“消息能解析”,还包含查询文档语义的一致性。
先写下不变量
我们要求同一世代每个 id 最多一条投影记录,删除对象不进入查询结果,已应用的同组事件不能再次修改内容,同一分区内旧 offset 不能覆盖新 offset,投影与数据库检查点在事务提交时共同可见。每个不变量分别由主键、查询条件、历史唯一约束、条件更新和事务实现。
不变量也有边界:offset 不能跨分区比较,世代不能随意共用消费组历史,连接器更换 topic 后不能沿用旧 topic 的数值水位。实验用新的世代和新的消费组完成重建,就是为了避免把这些命名空间压扁成一个整数。
本章练习
为商品增加多租户字段,说明唯一主键、Kafka 键、投影主键和对账排序需要怎样变化。再指出:仅把 tenant_id 放入 document,而仍然用全局 id 作为键,会在哪些情况下覆盖另一租户的数据。