第 9 / 23 篇

第8章 投影幂等:让重复交付成为普通情况

重复并不是例外路径

只要投影数据库和 Kafka 位点不处于同一个原子事务中,就存在数据库成功、位点尚未确认的窗口。进程在窗口内退出,重启后 broker 会再次投递该记录。把重复视为罕见脏数据,然后依靠人工删除,会让正常恢复变成危险操作。我们先承认至少一次交付,再让重复处理保持业务结果不变。

幂等的对象必须具体。把同一商品文档设置成相同值,通常可以幂等;把价格每次加一则不可以。随书消费者使用事件 after 的完整值覆盖投影,并使用历史唯一约束阻止同组坐标再次执行更新。若以后需要累计统计,应当保留事件级去重并在同一事务内更新计数,不能只依靠文档 upsert 的经验。

一次事务包含三件事

处理记录时,程序开启 PostgreSQL 事务,先向 delivery_history 插入事件身份。插入成功才应用业务变化;若唯一约束冲突,说明该消费组已经处理过该记录,跳过业务更新。随后推进数据库中的 delivery_checkpoint,最后共同提交。历史记录、查询文档和数据库检查点的可见性属于同一个数据库事务。

如果更新投影失败,事务回滚,历史插入也回滚,下一次投递仍能正常尝试。如果先单独提交历史,再更新投影,进程可能在中间退出,重启时看到历史存在而永远不再补投影。幂等表的存在本身不提供保证,关键在于它与实际状态变化共享提交边界。

数据库事务提交以后,程序再同步提交 Kafka 位点。此时第二个系统的操作失败会产生重复,不会让尚未持久化的投影被提前确认。反过来先提交位点再写数据库,会在中间崩溃时丢失投影更新,因为消费组已越过该记录。顺序决定主要故障结果:我们选择可重试的重复,避免无证据的跳过。

offset 条件是第二层防线

历史去重适用于同一消费组的重复坐标。显式回放或不同恢复场景还需要防止旧事件把新文档覆盖。投影更新条件要求分区相同且新 offset 更大。因此即使尝试重放旧创建事件,已经应用的删除标记不会倒退成未删除状态。

这个条件依赖固定 topic 和键路由,所以程序启动时验证绑定关系。新 topic 的 offset 从零开始,不能直接与旧 topic 的大数比较;换分区后的数字也没有大小关系。把这些限定写成数据库绑定和启动拒绝,比只在 README 中提醒使用者更可靠。错误组合会在消费之前退出。

真实重复实验怎样验收

首轮消费结束后,控制程序用同一消费组、同一世代显式从分区起点重放。这个操作只用于实验,生产中不能随意改变运行组的位点。消费者报告重复数等于首轮消息数,新鲜处理数为零;随后再次读取源库与投影,要求全部行与字段仍然相等。

我们还测试三种错误组合:旧消费组用于新世代、同一世代换消费组、同一世代换 topic。每次启动都必须明确报绑定错误,投影行数、历史行数和绑定数量保持不变。这样证明参数拒绝发生在数据处理前,而不是等错误结果出现再靠对账发现。

幂等历史也有生命周期

永久保存每个事件坐标会增长存储,删除历史又可能使非常旧的重复再次进入业务逻辑。对纯当前状态投影,offset 条件仍提供一部分保护,但涉及累加、外部副作用或跨对象操作时,不能随意缩短去重窗口。清理策略应与可回放范围、日志保留和重建方案一起设计。

本实验不实现历史清理,因为有限数据允许完整保留。它也不实现跨系统支付幂等或外部通知去重。若消费者要调用远程服务,需要远端接受稳定业务幂等键,并明确未知结果查询协议;本地历史事务不能原子包含一个任意 HTTP 调用。

本章练习

把投影更新想象成一次积分加一,说明只用 ON CONFLICT 更新对象为什么不够。然后画出“历史已提交、投影未提交”和“投影已提交、位点未提交”两种故障窗口,解释哪一种被当前事务边界消除了。