Inbox / Outbox:不用分布式事务,如何可靠地收发消息
用本地事务、状态机和幂等处理,解决“业务提交”和“外部消息发送”无法原子化的问题。
在一个订单系统中,支付成功后通常要更新订单状态、发消息给库存和物流、发送通知。问题在于:数据库事务和 MQ、HTTP 回调等网络调用,不能天然组成一个原子操作。
如果先发消息再提交数据库,事务失败时下游会看到一条并不存在的状态变更;如果先提交数据库再发消息,进程可能在发送前崩溃,导致下游永远收不到通知。
Inbox / Outbox 的目标不是制造“恰好一次”,而是把这类不可避免的故障变成可恢复、可观察的本地状态:at-least-once 投递、幂等处理与最终一致性。
两个模式分别解决什么
| 模式 | 方向 | 要解决的问题 |
|---|---|---|
| Inbox | 接收外部消息 | 消息到达后快速 ACK,同时避免进程崩溃和重复投递造成丢失或重复执行业务。 |
| Outbox | 向外发送消息 | 本地业务提交成功后,确保待发送事件不会因 MQ / RPC 瞬时失败或进程崩溃而丢失。 |
它们经常配合使用:入口使用 Inbox 接住 Webhook 或 MQ 事件;业务处理成功后,在同一事务中写 Outbox,保障出口通知下游。
Inbox:先落库,再 ACK
适用于支付回调、订阅回调、物流 Webhook,以及消费 MQ 后需要更新本地业务状态的场景。
外部 MQ / Webhook
→ INSERT inbox_message (PENDING)
→ COMMIT
→ ACK / HTTP 200
→ 异步处理器认领任务
→ 执行业务
→ DONE,或 FAILED 后重试
入口事务只做一次本地写入,不在请求线程里完成耗时业务。若在数据库提交后、ACK 前进程崩溃,外部系统会重投;数据库唯一键会把同一消息识别为重复,从而避免重复创建任务。唯一键只负责阻断重复入队,真正的业务副作用仍要由状态机或业务唯一约束保证幂等。
关键前提是:ACK 本身无法与数据库提交原子化。可靠性不是“不重复投递”,而是“即使重投,也能安全处理”。
Outbox:业务与待发送事件同事务写入
Outbox 的关键不是“定时扫描”,而是把业务状态和待发送事件放进同一个本地事务。
BEGIN
UPDATE orders SET status = 'PAID' ...
INSERT outbox_task (..., status = INIT)
COMMIT
异步发送器:认领任务 → 事务外发送 MQ / HTTP → 标记 SUCCESS 或 FAILED
事务成功时,订单状态与待发送任务同时可见;事务回滚时二者同时消失。发送动作可以失败,但任务仍在数据库中,后续能够安全重试。
这避免了两种常见错误:
| 做法 | 故障窗口 |
|---|---|
| 先发消息,后提交数据库 | 消息已送达,数据库却回滚;下游看到了幻象状态。 |
| 先提交数据库,后发消息 | 数据库成功后进程崩溃;消息永远丢失。 |
| 业务与 Outbox 同事务写入 | 提交后必有可恢复的待发送记录;发送由异步执行器负责。 |
任务表不是队列,也需要状态机
Inbox 和 Outbox 都应使用显式状态,而不是“扫描后直接执行”。一个足够通用的状态机是:
PENDING / INIT
→ PROCESSING
→ DONE / SUCCESS
→ FAILED → PROCESSING
→ MANUAL_REQUIRED
至少应记录这些字段:
| 字段 | 用途 |
|---|---|
message_id、biz_key |
传输层和业务层去重。 |
status |
驱动扫描、状态迁移与告警。 |
retry_count、max_retry_count |
限制自动重试次数。 |
next_retry_time |
让失败任务按退避策略再次可执行。 |
claimed_at、claim_token |
检测悬挂任务,并防止旧执行器覆盖新状态。 |
last_error |
支持排障和人工介入。 |
对同一业务事件,建议至少有两层保护:
- 用
(source, message_id)去掉同一条外部消息的物理重投。 - 用
(message_type, biz_key)去掉同一业务语义的重复事件。
message_id 可能来自传输系统,而 biz_key 应来自业务事实,例如 orderId + eventType。biz_key 不能只用 orderId:支付、退款等不同事件必须带上事件类型或等价维度,避免合法事件被误判为重复。两者不应互相替代。
并发处理的关键:短事务认领
多实例部署时,不能在持有数据库行锁的情况下调用 MQ 或 RPC。推荐分两步:
下面的 SKIP LOCKED 示例适用于 MySQL 8+、PostgreSQL 等支持该语法的数据库;若数据库不支持,需要改用与其兼容的任务认领策略。
-- 短事务内:只认领
SELECT id
FROM t_outbox_task
WHERE status IN ('INIT', 'FAILED')
AND next_retry_time <= NOW()
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE t_outbox_task
SET status = 'PROCESSING',
claimed_at = NOW(),
claim_token = :token
WHERE id IN (...);
提交后释放锁,再在事务外发送。回写成功或失败时附带 claim_token 条件;这样即使任务超时被回收,旧执行器晚到的回写也不会覆盖新执行器的结果。
重试、悬挂回收与人工介入
网络失败不是异常分支,而是日常路径。失败后应使用指数退避加随机抖动:
next_retry_time = now + min(base × 2^retry_count, cap) + jitter
进程可能在 PROCESSING 中崩溃,因此还需要定时回收超过阈值的 claimed_at。回收不能简单把状态改回 FAILED,还应:
- 增加
retry_count; - 计算新的
next_retry_time; - 清空旧
claim_token; - 到达上限后转为
MANUAL_REQUIRED并告警。
否则一条毒消息会在 PROCESSING 与 FAILED 之间无限循环。
什么时候应该使用
适合:
- 支付、订阅、物流等可能重复投递的 Webhook;
- 本地状态变更后必须通知库存、物流、积分或分析系统;
- 可以接受百毫秒到秒级最终一致延迟;
- 下游能够按照消息 ID、业务唯一键或状态机实现幂等。
不适合:
- 要求同步完成且无法接受异步延迟的强实时交互;
- 下游无法承受任何重复副作用、又无法改造成幂等的场景;
- 业务表与任务表不能在同一个本地事务中提交;
- 即使通知丢失也没有业务影响的低价值链路。
不要忽略运维成本
Inbox / Outbox 用数据库换取可靠性,也会带来写入、扫描与表膨胀。上线前应定义:
- 待处理数量、失败率、端到端延迟和
MANUAL_REQUIRED数量等监控指标; - 任务归档和清理策略,避免索引扫描随历史数据增长而退化;
- 按业务 key 保序的策略。多实例认领、重试和回收都可能打乱顺序;需要顺序时,应分区串行化或由业务状态机吸收乱序。
结语
Inbox / Outbox 不是分布式事务的轻量替代品,也不承诺“消息绝不重复”。它的价值在于把“数据库成功与网络调用失败之间的空窗”变成一条持久化、可重试、可告警的状态机。
当系统能够接受最终一致,并且愿意认真实现幂等、重试、回收和运维闭环时,它是大多数可靠消息场景中简单而稳健的默认方案。