外盒模式解决什么
业务写入与事件发布要原子:如果在同一事务里写业务表与 outbox(外盒)表,再由中继任务读取外盒投递消息,就不会出现「数据库已提交但消息没发出去」的丢事件问题。代价是引入中继与至少一次的投递语义,消费端必须幂等,同时要监控外盒积压。
外盒流程(MongoDB 事务示意)
外盒流程(MongoDB 事务示意)
// 副本集或分片集群事务内:业务写入 + 外盒事件
const session = db.getMongo().startSession();
session.startTransaction();
try {
session.getDatabase("app").orders.insertOne(
{ _id: 1001, status: "paid", amount: 128 },
{ session }
);
session.getDatabase("app").outbox.insertOne(
{ eventId: UUID(), type: "order.paid", payload: { orderId: 1001 }, status: "pending" },
{ session }
);
session.commitTransaction();
} catch (e) {
session.abortTransaction(); // 任一步失败则整体回滚,不会出现「写库成功但事件缺失」
throw e;
}
// 中继:轮询 pending 并投递,成功后标记 sent(至少一次投递)基本信息
| 中继实现 | 轮询并按条件抢占更新,或直接消费外盒集合的变更流 |
|---|---|
| 投递语义 | 至少一次,消费端按事件 ID 幂等去重 |
| 并发保护 | 抢占式更新要带状态条件,避免多个中继重复投递同一事件 |
| 可观测 | 外盒积压量、最旧未投递事件的时间、投递失败次数 |
| 有 CDC 时 | 由变更流直接消费数据变更可省去外盒,但事件格式与过滤条件要在日志层设计 |
典型使用场景
- 订单状态变更通知:保证「状态改了」与「通知发出」不脱节
- 跨服务数据同步:可靠地把变更投递给搜索、缓存与下游系统
- 对账与审计:事件与业务数据成对留存,便于追溯
- 异步任务触发:支付成功后可靠触发发货、积分等后续流程
- 灰度迁移:新旧双写期间靠外盒事件驱动新链路补数据
环节与失败处理
| 环节 | 失败可能 | 处理方式 |
|---|---|---|
| 事务内双写 | 业务写成功、事件未写 | 同事务提交,任一步失败整体回滚 |
| 中继投递 | 投递成功但标记失败 | 至少一次语义,消费端按事件 ID 去重 |
| 中继抢占 | 多个中继重复处理同一事件 | 按状态条件原子更新抢占,加超时回收 |
| 外盒清理 | 已投递事件长期堆积 | 定期归档或删除已投递记录并保留审计所需窗口 |
| 积压监控 | 中继停摆无人发现 | 对积压量与最旧事件时间设置告警 |
外盒事件建议带唯一事件 ID 与业务主键:消费端用唯一索引去重,重复投递不会产生重复副作用。
中继与业务不要共用同一个低优先级连接池并无限重试:投递积压时反向拖慢业务库,故障从下游扩散到核心链路。