双写问题
服务创建订单后必须发布 OrderCreated 事件。如果先提交订单再发布,两步之间崩溃会丢失事件;如果先发布再提交,事务失败会让消费者看到不存在的订单。单独重试发布或提交都解决不了,因为两个系统不共享事务。
Outbox 把事件放进与业务数据相同的数据库。事务同时插入订单和事件行。要么都存在,要么都不存在。中继随后发布事件,失败就重试。提交与发布之间的间隙变成延迟,而不是丢失。
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
BEGIN;
INSERT INTO orders (id, customer_id, total)
VALUES ('order-1', 'customer-9', 4200);
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('order-1', 'OrderCreated', '{"orderId":"order-1","total":4200}');
COMMIT;
业务写入和事件插入在同一事务中。Outbox 插入应与业务变更位于同一仓储或工作单元;有时记得写 Outbox、有时忘记的服务方法就是潜在缺陷。
中继与消费
中继认领未发布行,发布后标记为已发布。轮询简单但增加延迟和数据库负载;变更数据捕获读取数据库日志,扩展性更好,但运维复杂。无论使用哪种机制,中继都必须幂等:同一行发布两次可以接受,丢失不行。
消费者必须按事件 id 或业务键去重。保存已处理 id 的保留窗口至少与 broker 可能重投的窗口一样长。顺序是每个聚合内保证,不是全局保证;不要假设不同订单的事件有固定顺序。如果聚合内顺序重要,加入序列号并拒绝或缓冲乱序事件。
运维关注
监控最旧未发布行的年龄、发布失败和消费者滞后。健康的 Outbox 积压很小且有界。积压超过阈值,或某行超过投递 SLO 仍未发布,应告警。通过保留策略清理已发布行;审计要求需要时归档负载。
Outbox 负载应包含消费者无需回调生产者就能行动的信息,但不应携带密钥或无界文档。事件模式要版本化并兼容演进。事件是公开契约;不版本化就修改会破坏你无法控制的消费者。
何时使用
当本地数据库变更必须可靠触发外部事件,且丢失或凭空产生事件不可接受时,使用 Outbox。优点是不用分布式事务也能原子化;缺点是额外存储、需要运维中继,以及至少一次投递要求消费者去重。与在应用代码中直接发布相比,Outbox 增加延迟和基础设施,但消除了双写故障模式。如果偶尔丢失事件可以接受,且事件只是提示性的,直接发布加重试可能更简单。