最终一致、Outbox 与 Inbox:失败窗口怎样被证明已经收敛
以状态机而非口号解释最终一致,贯通 Outbox、Inbox/幂等、重试、对账、版本演进与收敛指标。
Debezium 的 Outbox Event Router 展示业务事务写入 Outbox、CDC 捕获并按 aggregate id 路由的实现边界。
“最终”必须有终止条件
订单提交后库存读模型可能暂时仍是旧值,但系统必须定义最终状态:订单版本 8 对应的库存预留结果必须出现,或进入明确拒绝/补偿终态。收敛触发器可能是 Outbox relay、Broker 重投、定时扫描或人工修复;最大可接受延迟来自业务截止时间;超出后要告警、降级或阻止后续动作。
只说“消息会重试所以最终一致”没有证明。消息可能永久坏、契约不兼容、幂等记录过期、消费者停用或路由错误。每条状态迁移都要有持久证据和下一次唤醒机制,终态必须可查询。
Outbox 关闭本地提交与待发布事实的双写窗口
业务表和 Outbox 行在同一数据库事务提交,保证业务成立时存在一条待发布事实。Relay 或 CDC 读取后发布,发送成功但标记前崩溃会重复,因此 eventId 在业务事务内生成且保持稳定。payload 保存发生时事实,不能在延迟发布时重读当前表把历史改写。
Outbox 发布延迟、最老未发布年龄、扫描索引与清理水位必须治理。备份恢复可能让已发布行重新出现,消费者去重需覆盖恢复周期。Outbox 解决生产侧原子边界,不解决消费端业务事务。
Inbox 把消费去重与投影更新放入同一事务
消费者以 eventId 唯一插入 Inbox,并在同一事务更新目标状态;唯一冲突表示重复,返回已存结果而不再执行。若先在 Redis 标记已处理再写数据库,数据库回滚会永久丢更新;若先写数据库再异步标记,崩溃会重复。权威存储唯一约束才是闭合证据。
版本化投影还要拒绝旧事件覆盖新状态。收到未来版本可暂存并等待缺口,旧版本判为重复或迟到;缺口超过预算进入修复。幂等记录保留期覆盖 Broker 重投、死信重放与灾难恢复,不能按普通缓存 TTL 清理。
对账是独立于事件链的第二条证据
如果生产、发布、消费都沿同一错误路径运行,监控可能全绿但数据仍错。对账从权威表按分片、时间和版本计算摘要,与投影或外部机构比较,生成可审计差异批次。修复以条件更新和独立 repairId 执行,不能直接覆盖新版本。
指标包括 Outbox age、publish duplicate、Inbox duplicate、projection lag、version gap、reconciliation drift 与 manual pending。恢复完成是差异归零或全部进入解释明确的终态,不是队列清空。
用证据边界拆开“不知道”和“做不到”
结果未知必须持久化 operationId、目标实体、预期版本和查询入口。自动重试只有在动作尚未开始或目标以相同 id 幂等时安全。用异常类名直接判断“肯定没执行”会在网络断点后制造重复提交。
状态机必须拥有终态、租约和人工出口
每个非终态都要有下一次唤醒来源:消息重投、租约到期、协调器扫描、对账任务或人工队列。只有状态而没有唤醒,流程会永久卡住;只有重试而没有终态,会永久自旋。
Schema 演进也可能让一致性永远无法到达
新生产者发布 schema v3,旧消费者若把未知字段当错误并无限重试,投影会永久停在 v2。兼容变更先让消费者接受新旧版本,再发布生产者;无法兼容时使用新 eventType 和双读窗口。死信修复不能随意改 payload 后复用原 eventId,否则 Inbox 会把修复消息判为已处理。
对账要覆盖逻辑删除、迟到事件、重复事件与跨日边界。摘要一致只能证明聚合量相同,关键实体还需抽样或逐 key 比较;修复完成后重新计算独立摘要,防止修复脚本与检测脚本共享同一个错误。
用两个 Java 状态模型固定分布式不变量
下面的 Java 17 模型不模拟真实网络或共识实现,而是把本篇关键版本、租约、状态和容量关系变成确定输出。随后用真实数据库、Broker、锁服务或多进程环境注入延迟、重复、分区与崩溃。
javac --release 17 -Xlint:all -Werror examples/backend-development/distributed-systems/eventual-outbox/OutboxInboxDemo.java examples/backend-development/distributed-systems/eventual-outbox/ReconciliationDemo.java
java -cp examples/backend-development/distributed-systems/eventual-outbox OutboxInboxDemo
java -cp examples/backend-development/distributed-systems/eventual-outbox ReconciliationDemobusinessVersion=8 outboxEvent=evt-8 publishAttempts=2 inboxWrites=1 projectionVersion=8
authorityTotal=100 projectionTotal=97 drift=3 repaired=3 finalDrift=0 auditBatch=reconcile-42输出变化时必须解释哪条一致性、epoch、幂等或容量不变量被修改。真实实验还要验证结果未知、旧所有者恢复、状态清理和人工修复路径。
容量、观测与安全要围绕协调成本
同步协调增加往返、日志刷盘、锁持有和 quorum 等待;异步收敛增加积压、版本、去重和对账存储。容量模型至少包含峰值操作率、参与者数、每次尝试、超时窗口、重试放大、状态保留、复制倍数与恢复净速率。平均值无法覆盖分区热点和长尾暂停。
指标按 operation、state、failure stage、epoch result、shard 与 dependency 聚合;entityId、lock key、sessionId 和 transactionId 进入脱敏日志或采样 trace,不能成为时序标签。关键证据包括版本回退、stale token rejected、prepared age、outbox age、reconciliation drift、replica lag、queue age 与 retry amplification。
反向实验要打在决定落盘的前后
正常测试不足以证明分布式正确性。应在请求写出后丢响应、协调器记录决定前后崩溃、租约到期后恢复旧 worker、副本切换前制造未复制尾部、Outbox 发布后不标记、消费者提交后不确认、恢复时叠加入口流量。每个实验断言权威状态、重复次数、拒绝旧 epoch 与最终收敛。
故障注入必须有范围、自动停止和数据清理。生产演练从只读、单租户、单分片和低比例开始,保留旁路与回滚。完成标准不是组件重新可达,而是业务不变量保持、未知结果已对账、积压按预测下降且旧所有者无法继续写。
演进从缩小协调域开始
小规模优先单库本地事务、模块化单体和单一调度 owner。增长后先按业务冲突域分片、用批量与读模型减少跨域协调,再引入 Outbox、租约或 Saga。不要为了“分布式化”把一个本地不变量拆成跨服务事务。
任何新协调机制都写决策记录:解决什么不变量、网络分区时拒绝什么、权威证据在哪、超时后如何查询、状态保留多久、容量上限、故障演练与移除路径。系统复杂度只有在可验证收益覆盖恢复成本时才值得增加。
数据模型要为并发和恢复保留足够字段
清理也是协议的一部分。幂等、事务决定、锁 epoch、Outbox、Inbox 与路由版本不能按普通日志随意删除;保留期至少覆盖最大重试、灾难恢复、备份回放和人工重放窗口。清理按水位小批执行,并保留能解释历史操作的摘要。否则恢复旧备份后,已经遗忘的 operationId 会再次获得执行资格。
替代方案要比较协调域而不是比较组件列表
同一进程可用锁和本地事务解决的问题,不应先引入分布式锁;单库能保存业务与待发布事实时,Outbox 比跨库 2PC 更容易恢复;只需读己之写时,会话 token 比全局线性一致成本低;能按实体串行化时,分区队列比全局锁并发更高。架构选择优先缩小需要共同决定的状态集合。
团队门禁要阻止不可恢复的捷径
禁止无 operationId 的自动写重试、无 fencing 的跨进程锁写、无版本的缓存/投影覆盖、无终态的重试循环、无最大年龄的 prepared/Outbox/Inbox 状态、无 mappingVersion 的在线分片切换、无下游预算的恢复扩容。例外必须有 owner、期限、观测和移除计划。
代码审查要求状态转换与 SQL 条件同时出现,集成测试要求在决定落盘前后杀进程,发布检查要求新旧版本互读,运行看板要求展示最老非终态而不只展示总数。分布式正确性不是某次设计评审的结论,而是持续被反例验证的工程属性。
