3、Outbox Pattern 核心原理:将消息先写入数据库的 outbox 表,再由独立进程投递

好,咱们进入正题。前面两章我们聊了双写问题的痛点和一些“看起来很美”的解决方案。这一章,我来拆解 Outbox Pattern 到底是怎么工作的。

说白了,它的核心思想就一句话:别急着发消息,先把消息存到数据库里的一张专用表里,然后让另一个专门的程序去慢慢投递。你想想看,这样一来,业务操作和消息存储就在同一个数据库事务里了,原子性就有了保障。

核心原则:将“业务数据变更”与“消息持久化”绑定在同一个本地事务中,彻底规避分布式事务带来的不确定性。

3.1 核心流程:三步走

我在项目中第一次落地这个模式时,画了一张流程图贴在工位上。现在我把这张图的核心逻辑拆解给你看。

Outbox Pattern 核心流程 Outbox Pattern 核心流程 1. 业务请求到达 2. 同一本地事务 写入业务表 + 写入 Outbox 表 3. 事务提交成功 4. 独立进程轮询 Outbox 表 读取状态为 PENDING 的记录 5. 投递到消息队列

这张图其实就说了三件事:

  1. 业务操作和写消息在同一个事务里完成。 这是整个模式的基石。没有这一步,后面全是扯淡。
  2. 独立进程异步读取 Outbox 表。 这个进程可以是定时任务,也可以是监听数据库变更日志的组件。
  3. 投递成功后更新状态。 标记为已发送,或者直接删除记录。

我的习惯: 我会在 Outbox 表里加一个 status 字段,用 PENDING、SENT、FAILED 三个状态来追踪。这样排查问题的时候,一眼就能看出哪条消息卡住了。

3.2 Outbox 表长什么样?

表结构其实不复杂。我见过很多团队把它设计得过于臃肿,其实核心字段就那么几个。

CREATE TABLE outbox (
    id              BIGSERIAL PRIMARY KEY,
    aggregate_id    VARCHAR(64)    NOT NULL,      -- 业务聚合根ID
    aggregate_type  VARCHAR(128)   NOT NULL,      -- 业务类型,比如 'order'
    event_type      VARCHAR(128)   NOT NULL,      -- 事件类型,比如 'order.created'
    payload         JSONB          NOT NULL,      -- 消息体,存业务数据
    status          VARCHAR(16)    NOT NULL DEFAULT 'PENDING',  -- PENDING / SENT / FAILED
    retry_count     INT            NOT NULL DEFAULT 0,          -- 重试次数
    created_at      TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
    sent_at         TIMESTAMPTZ,
    CONSTRAINT chk_status CHECK (status IN ('PENDING', 'SENT', 'FAILED'))
);

-- 索引:给轮询查询用
CREATE INDEX idx_outbox_status_created 
    ON outbox (status, created_at) 
    WHERE status = 'PENDING';

这里有几个设计要点,我单独拎出来说:

  • aggregate_id 和 aggregate_type:用来关联回具体的业务记录。排查问题时,你能快速定位到是哪个订单、哪个用户出问题了。
  • payload 用 JSONB:灵活,不用预先定义死消息结构。但要注意,别往里面塞太大的数据,否则会影响数据库性能。
  • 部分索引:只索引 PENDING 状态的记录。轮询进程每次只查这个索引,效率高很多。我曾经见过一个项目没加这个索引,结果 outbox 表到了 100 万行之后,轮询查询直接超时。

3.3 独立进程怎么工作?

这个独立进程,我习惯叫它 Outbox Publisher。它的工作逻辑其实就是一个循环:

  1. 每隔 N 秒(比如 1 秒)查询一次 Outbox 表,拉取一批 PENDING 状态的记录。
  2. 逐条投递到消息队列(比如 Kafka、RabbitMQ)。
  3. 投递成功后,更新状态为 SENT,记录 sent_at 时间。
  4. 如果投递失败,增加 retry_count,根据重试策略决定是否继续重试或标记为 FAILED。

伪代码大概是这样的:

while (true) {
    List<OutboxMessage> messages = outboxRepository.findPendingMessages(batchSize);
    
    for (OutboxMessage msg : messages) {
        try {
            messageQueue.send(msg.getEventType(), msg.getPayload());
            outboxRepository.markAsSent(msg.getId());
        } catch (Exception e) {
            log.error("投递失败, messageId: {}", msg.getId(), e);
            outboxRepository.incrementRetry(msg.getId());
            if (msg.getRetryCount() >= maxRetries) {
                outboxRepository.markAsFailed(msg.getId());
                // 触发告警
            }
        }
    }
    
    Thread.sleep(pollIntervalMs);
}

我曾经踩过的坑: 一开始我把轮询间隔设成了 100 毫秒,结果数据库连接池被打满了。后来改成 1 秒 + 批量拉取 100 条,压力瞬间降下来了。记住,轮询不是越快越好,要结合你的业务流量来调。

3.4 为什么它能避免双写?

我们来对比一下,没有 Outbox Pattern 时,双写问题是怎么发生的:

步骤 传统双写 Outbox Pattern
1. 写业务数据 写入数据库 ✅ 写入数据库 ✅
2. 写消息 发送到 MQ(可能失败)❌ 写入 Outbox 表(同一事务)✅
3. 事务提交 业务数据已提交,消息没发出去 业务数据和 Outbox 记录一起提交
4. 最终结果 数据不一致,需要补偿 数据一致,消息由 Publisher 保证投递

你看,关键区别就在第 2 步。传统做法是先写数据库,再发消息,这两步之间没有事务保护。而 Outbox Pattern 把消息先当成数据存起来,和业务操作在同一个事务里提交。只要事务提交了,消息就一定在 Outbox 表里。至于什么时候发出去,那是 Publisher 的事,它失败了可以重试。

一句话总结: Outbox Pattern 把“发消息”这个动作,从“业务操作”中剥离出来,变成了一个“数据持久化”的动作。消息的投递变成了一个可重试的异步任务。

3.5 一个常见的疑问:消息顺序怎么办?

这个问题我经常被问到。比如订单创建和订单取消,如果 Publisher 先投递了取消事件,消费者那边就乱套了。

我的建议是:不要在 Outbox 层面保证全局顺序,而是让消费者自己处理乱序。具体做法:

  • 在 payload 里带上事件发生的时间戳或版本号。
  • 消费者端维护一个按 aggregate_id 分组的版本号,只处理比当前版本号大的事件。
  • 如果收到旧版本的事件,直接丢弃或重新排队。

当然,如果你用的是 Kafka,并且每个 aggregate_id 的消息都发到同一个分区,那顺序是可以保证的。但这就涉及到分区策略的设计了,我们后面章节会细聊。