先问清楚:谁的“已经处理过”,要记到什么时候
一条订单事件要交给审计程序和搜索索引程序。审计已经处理,并不意味着搜索索引可以跳过。同样,昨天处理过的事件,今天删除了去重记录,再收到它时也可能被当作新事件。
这节接在本地 outbox 练习和事务与事件的崩溃恢复之后,新增三个验收点:消费者作用域、同 ID 内容冲突、去重记录的保留期。先用一次进程中断建立基线,再故意破坏去重所依赖的条件。
一个事件,可以让两个处理者各做一次
本实验把去重身份写成 (consumer_id, event_id)。consumer_id 表示一项稳定的业务处理职责,例如 audit 或 search,不是本次进程号、机器名或随机字符串。
| 本次输入 | 本实验的处理 |
|---|---|
audit 第一次收到 order-created:001 | 写入审计效果和去重记录 |
audit 再收到相同事件、相同内容 | 跳过重复效果 |
search 第一次收到同一个事件 | 正常处理,形成搜索效果 |
audit 收到同 ID、不同金额 | 拒绝,不把它当作正常重试 |
这是本实验选定的消费契约:两个订阅者应分别完成自己的工作。若数据库把 event_id 单独设为唯一键,第二个订阅者就可能被第一个订阅者的记录挡住。反过来,如果每次重启都给同一职责换一个 consumer_id,旧记录也挡不住重放。
事件身份和业务对象身份同样要分开。Debezium 的 outbox 文档区分了用于事件识别的 id 和用于消息键的 aggregateid;一个订单可以产生多条事件,不能简单把订单号当作每条消息唯一的身份。Debezium:Basic outbox table
运行一次真实的子进程中断
下载 Python 实验,在文件所在目录执行:
python outbox-crash-replay.py
程序只使用 Python 标准库,在自己创建的临时目录内建立两个 SQLite 文件,以直接函数调用代替消息队列。发送端把订单和 outbox 同事务写入;消费端把 inbox 登记和本地效果同事务写入。另一个子进程完成消费提交后,调用 os._exit(71) 直接退出,来不及更新发送端的 sent。父进程检查退出码,再以新连接检查文件中的状态。
这一窗口说明“消费者已完成”和“发送者已记录确认”不是同一个提交点。AWS 的模式说明也明确要求考虑重复消息,并让消费者能处理重复投递;outbox 本身不会消除发送后的确认空档。AWS:Transactional outbox pattern
本次运行环境为 Python 3.14.0 / SQLite 3.50.4,核心输出如下:
producer rollback: orders=0 outbox=0
child exit=71: pending=1 audit_effects=1
replay with history: skipped; audit_effects=1
independent subscriber: audit_effects=1 search_effects=1
same ID, changed payload: rejected; audit_total=100
consumer rollback: inbox=0 effects=0; retry applied once
delete audit history, replay old event: audit_effects=2 search_effects=1
PASS: rollback, crash replay, subscriber scope, payload conflict, retention
child exit=71 是预设的实验故障,不是脚本整体失败。父进程只有在全部断言成立时才输出 PASS 并正常退出。请使用上面的普通运行命令;脚本会拒绝禁用断言的 -O 或 -OO 模式。结束时关闭连接,删除自己创建的临时数据库;没有连接云服务或现有业务数据库。
看代码时盯住这三个地方
第一,登记和效果一起提交。 程序用 BEGIN IMMEDIATE 开始写事务,在同一事务中检查旧登记、写入新登记、增加本地效果。消费提交前的故障会让二者一起回滚,之后可以重试。SQLite 仍然只允许同时存在一个写事务,BEGIN IMMEDIATE 可能遇到写锁冲突;本实验运行的是顺序消费,没有验证多工作者吞吐。SQLite:Transactions
第二,匹配 ID 之后还检查内容。 本例把固定字段的 JSON 排序后存入 inbox,再次收到同一消费身份时比较完整内容。金额从 100 改成 999,会触发冲突;原效果仍为 100。这项检查是在本例中额外实现的,不是主键自动完成的。AWS Powertools 的文档也把 payload validation 列为需要单独配置的行为。Powertools:Payload validation
本例只处理字符串和整数,没有声称实现跨语言的 JSON 规范化标准。真正的业务需要先固定参与比较的字段、数字表示、事件版本和缺省值规则。
第三,删除登记就失去了这层记忆。 最后一段只删除 audit 对第一条事件的 inbox 记录,保留已产生的效果,再重放原事件。审计效果从 1 增至 2,搜索效果仍为 1。效果表故意没有另一道唯一约束,因此能清楚看见 inbox 被清理后的后果。
这不是在模拟某个云服务的 TTL 定时器,而是直接删除一条记录来证明:如果去重只依赖这条记录,删除后旧事件仍可到达,重复效果就可能重新出现。 Powertools 明确把其幂等行为限定在有效时间窗口内,过期后相同输入可以再次执行;这是该工具的契约,不是所有消息系统统一的过期规则。Powertools:Adjusting expiration window
清理策略必须和重放策略一起设计
不能只看“正常重试最多几分钟”就删除记录,还要纳入队列积压、故障恢复、人工重放和备份恢复可能带回多旧的事件。可选择保留精简的去重凭证、让业务状态另有约束,或明确拒绝超出受支持时间范围的事件。哪一种适用,取决于业务是否允许重建、补偿或再次执行;本实验没有替生产系统选定期限。
还要区分“相同事件重投”和“一个新的业务请求”。如果确实发生了一次新的订单变更,应生成新事件身份;不能为了强行通过已有去重记录而悄悄换 ID。
留给你的验收题
- 把 inbox 的主键改成只有
event_id,运行实验,记录最先失败的场景与它代表的业务损失。 - 将相同审计职责改名为
audit-v2后重放。解释这是新职责的独立处理,还是部署改名造成的重复;写出你的迁移规则。 - 假设正常重试最多 1 天,人工恢复却可能重放 7 天前的数据。设计一份去重记录清理条件,说明每项条件如何被观测和验证。
- 将效果设想为外部发邮件。画出本地提交与邮件发送之间的失败窗口,说明还需要外部服务提供什么能力;不要直接把表内去重当作邮件去重。
已验证范围: 两个本地 SQLite 文件、拥有的子进程直接退出、重新打开连接、顺序重复消费,以及上面三项新增边界。未运行 MQ、Debezium、AWS Powertools 或真实外部副作用,也未验证断电、多实例竞争与生产保留策略。代码显式控制事务并关闭连接,可对照 Python 3.14 sqlite3 事务说明 阅读。