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_idbiz_key 传输层和业务层去重。
status 驱动扫描、状态迁移与告警。
retry_countmax_retry_count 限制自动重试次数。
next_retry_time 让失败任务按退避策略再次可执行。
claimed_atclaim_token 检测悬挂任务,并防止旧执行器覆盖新状态。
last_error 支持排障和人工介入。

对同一业务事件,建议至少有两层保护:

  1. (source, message_id) 去掉同一条外部消息的物理重投。
  2. (message_type, biz_key) 去掉同一业务语义的重复事件。

message_id 可能来自传输系统,而 biz_key 应来自业务事实,例如 orderId + eventTypebiz_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,还应:

否则一条毒消息会在 PROCESSINGFAILED 之间无限循环。

什么时候应该使用

适合:

不适合:

不要忽略运维成本

Inbox / Outbox 用数据库换取可靠性,也会带来写入、扫描与表膨胀。上线前应定义:

结语

Inbox / Outbox 不是分布式事务的轻量替代品,也不承诺“消息绝不重复”。它的价值在于把“数据库成功与网络调用失败之间的空窗”变成一条持久化、可重试、可告警的状态机。

当系统能够接受最终一致,并且愿意认真实现幂等、重试、回收和运维闭环时,它是大多数可靠消息场景中简单而稳健的默认方案。