Transactional Outbox 投递对账:从本地提交到 Broker Receipt
业务行与事件分别写数据库和消息系统会留下双写窗口,而发送超时又无法证明消息没有到达。本文把 outbox 事件、dispatcher claim、未知投递与消费去重组成可对账协议。
本文要点
- 主题聚焦:TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
订单事务已经提交,进程却在发送 OrderCreated 前崩溃,消费者永远看不到变化;反过来先发消息再提交数据库,回滚后又会出现一条指向不存在订单的事件。把两个调用包进 try/catch 不会得到跨系统原子性,发布请求超时后继续重试还可能让同一订单被履约两次。
事件驱动架构解释异步解耦,PostgreSQL 事务与隔离解释本地原子提交。Transactional Outbox 的边界更窄:业务事实和待投递意图只在同一个数据库事务里共同接受,broker 投递则以可重放、可去重的独立阶段完成。
先冻结事件身份与载荷
发布 outboxEnvelope.v1,字段固定 eventId、envelope generation、aggregate type/ID/version、event type、payload schema/digest、tenant、occurredAt 和创建事务标识。还要在提交时解析并冻结 destination.v1:broker cluster identity、topic、routing/partition key 与算法、route policy generation、serializer、durability 和 required ACK。eventId 在业务命令首次接受时产生,所有 attempt 沿用;业务变化与 PENDING envelope 同事务插入。
schema: outboxEnvelope.v1
eventId: evt-01J...
aggregate: order/o-42@17
type: order.created.v2
payloadSchema: sha256:6f1...
payloadDigest: sha256:a83...
routingKey: tenant-7.order.o-42
destination: kafka-prod/orders@route-19
delivery: {acks: all, serializer: order-v2}
state: PENDING
状态限定为 PENDING -> CLAIMED -> SENDING -> PUBLISHED -> RETAINED -> PURGED。发送后丢 ACK 进入 DISPATCH_UNKNOWN -> RECONCILING;查到 receipt 就 PUBLISHED,策略允许同目标重投时以 CAS 进入 RETRY_CLAIMED 并把 attempt generation 加一,再走 SENDING。无法证明时保持 UNKNOWN。任何 retry 都必须使用冻结的 destination digest;目标迁移不得改原 envelope。
多个 dispatcher 可用短 claim 并行扫描,但 claim 带 owner、generation、leaseUntil 和 attempt ID。数据库领取只决定谁可尝试发送,不代表消息已发布。旧 worker 在停顿后恢复时,其 generation 已失效,只能上报审计结果;它不能覆盖新 owner 的 receipt。后台任务队列负责吞吐,claim fence 负责事实资格。
唯一接受点在 receipt 事务
唯一接受点 acceptDispatchReceipt 锁定 event 行,匹配 event ID、envelope generation、payload 与 destination digest、一个已登记的 SENDING/UNKNOWN attempt 及尚未设置的 publish slot,再验证 broker receipt 并置为 PUBLISHED。它不要求 ACK 来自当前 claim:旧 attempt 的迟到真 ACK 与新 attempt ACK 都竞争同一 slot,胜者写主 receipt,其余作为 duplicate transport receipt 留证;不同 payload/目标立即隔离。旧 worker 无权再次 send,但它已发请求的结果不会因 claim 过期被丢掉。
若 broker 不支持按 event ID 查询,reconciler 只能对同一冻结目标签发新 RETRY_CLAIMED;消费者仍以 (consumerId, eventId) 原子去重。需要换 cluster/topic 时,先证明旧目标未接受,再创建显式 envelope generation 和 migration receipt;旧目标仍 UNKNOWN 时不得跨目标重投。业务确实要求双目标投递,则 delivery set、每目标状态和完成规则必须成为新的业务合同,不能伪装成普通 retry。
Broker 的 producer 幂等通常只覆盖特定会话或分区条件,不能替代业务 event ID。消费者在同一事务写消费 receipt 和自身状态,再提交消息位点;收到重复事件时返回原 receipt。外部邮件、支付等效果仍需稳定业务幂等键。MCP 超时、重试与幂等说明未知结果为何不能换键,HTTP 幂等键可约束消费端调用。
对账器比较三个集合:已提交 outbox、已接受 publish receipt、各关键 consumer 的消费水位。PENDING 超龄说明 dispatcher 漏扫,PUBLISHED 却长期无消费 receipt 说明路由或消费者故障,broker 中存在未知 event ID 则说明旁路发布。清理必须晚于最大重放窗口和强制消费者水位,不能看到 ACK 就删除唯一证据。
用断点证明不会丢也不会变成新事件
故障注入覆盖提交前后杀进程、claim 过期后旧 ACK 迟到、UNKNOWN 与 retry claim 竞争、旧新 attempt 都获 ACK、迁移期间旧目标不可查询、route policy 改版、receipt 回滚、消息乱序重复、消费者提交后丢 ACK 及清理误判。断言业务提交却无 outbox、跨目标未知重投、同 event ID payload/destination 分歧和每个 consumer 重复效果均为 0,所有 UNKNOWN 可按 attempt 追踪。
发布门要求一百万次轨迹中丢失事件、第二逻辑 event ID、跨 destination retry 和消费者重复效果均为 0,迟到有效 ACK 收敛到 publish slot 的比例 100%,UNKNOWN p99 在 15 分钟内对账。上线先 shadow,再接管 5% 低风险类型。回滚只切 dispatcher alias;旧实现必须理解冻结 destination、attempt generation 与同一 publish slot,已发布事件继续对账,绝不能退回同步盲发。
Related