4、Outbox 表结构设计:字段设计、状态机、幂等键、重试次数

表结构设计这事,看着简单,但坑特别多。我见过不少团队,Outbox 表就扔三五个字段,结果线上跑着跑着就出幺蛾子。说白了,这张表是系统的「保险箱」,设计得不够严谨,数据就可能丢。

今天我把自己的设计经验拆开来讲。你跟着走一遍,基本就能避开我踩过的那些坑。

4.1 核心字段:一个都不能少

先看我最常用的一张 Outbox 表结构。嗯,这是经过多次生产验证的版本。

CREATE TABLE outbox_event (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_id        VARCHAR(64)  NOT NULL COMMENT '事件唯一ID(幂等键)',
    aggregate_type  VARCHAR(64)  NOT NULL COMMENT '聚合类型,如 "order"',
    aggregate_id    VARCHAR(64)  NOT NULL COMMENT '聚合ID,如订单号',
    event_type      VARCHAR(128) NOT NULL COMMENT '事件类型,如 "OrderCreated"',
    payload         JSON         NOT NULL COMMENT '事件体,JSON格式',
    status          TINYINT      NOT NULL DEFAULT 0 COMMENT '状态:0=待发送,1=发送中,2=已发送,3=失败',
    retry_count     INT          NOT NULL DEFAULT 0 COMMENT '已重试次数',
    max_retries     INT          NOT NULL DEFAULT 3 COMMENT '最大重试次数',
    next_retry_at   DATETIME     DEFAULT NULL COMMENT '下次重试时间',
    created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uk_event_id (event_id),
    INDEX idx_status_next_retry (status, next_retry_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

每个字段都有它的用途,我挑几个重点说说。

字段 作用 我的经验
event_id 全局唯一,用于幂等 用 UUID 或雪花算法生成,别用自增 ID
aggregate_type + aggregate_id 定位是哪个业务对象产生的事件 排查问题时,这两个字段能帮你快速定位
payload 事件的具体数据 用 JSON 类型,MySQL 5.7+ 支持,查询方便
status 事件当前生命周期状态 状态机核心字段,后面细讲
retry_count + max_retries 控制重试行为 别让事件无限重试,会拖死系统
next_retry_at 控制重试时机 配合索引,实现高效扫描

关键索引设计:idx_status_next_retry (status, next_retry_at) 这个联合索引是性能命脉。后台任务每次只扫「待发送且到了重试时间」的事件,不会全表扫描。

4.2 状态机:事件的一生

状态机是 Outbox 表的核心逻辑。我习惯用四个状态,不多不少。

状态流转:
待发送 (0) → 发送中 (1) → 已发送 (2)
                ↓ (失败)
              失败 (3) → (重试) → 待发送 (0)

为什么会这样设计?

  • 待发送 (0):事件刚写入,还没被消费。后台任务会扫描这个状态的事件。
  • 发送中 (1):正在投递到消息队列。这个状态很重要,防止重复投递。
  • 已发送 (2):投递成功,可以归档或删除了。
  • 失败 (3):投递失败,等待重试。

我曾经踩过的坑:一开始我没加「发送中」状态,结果并发场景下两个 worker 同时拿到同一个事件,发了两次。加了状态机 + 乐观锁才解决。

状态更新的 SQL 要带条件判断,保证原子性:

-- 从待发送变为发送中,只有当前状态是 0 才能更新成功
UPDATE outbox_event 
SET status = 1, updated_at = NOW()
WHERE id = ? AND status = 0;

如果影响行数为 0,说明已经被别的 worker 抢走了。直接跳过就行。

4.3 幂等键:防止重复消费的杀手锏

幂等键这事,说白了就是给每个事件一个「身份证」。不管投递多少次,消费端看到同一个 ID,只处理一次。

我个人习惯用 event_id 作为幂等键。生成规则很简单:

  • UUID:简单粗暴,全球唯一。但字符串长,索引效率稍低。
  • 雪花算法 ID:趋势递增,索引友好。我比较推荐这个。
  • 业务组合键:比如 order_id + event_type + timestamp。适合有明确业务语义的场景。

我的建议:如果系统规模不大,直接用 UUID 省事。如果日均百万级事件,用雪花算法。别在幂等键上搞太复杂的逻辑,简单就是美。

消费端怎么做幂等?看这段伪代码:

// 消费端伪代码
boolean consume(OutboxEvent event) {
    // 1. 尝试插入消费记录,event_id 是唯一键
    int rows = insertIntoProcessedEvent(event.eventId);
    if (rows == 0) {
        // 已经处理过了,直接返回成功
        return true;
    }
    
    // 2. 执行业务逻辑
    try {
        processBusinessLogic(event.payload);
        return true;
    } catch (Exception e) {
        // 业务失败,删除消费记录,允许重试
        deleteProcessedEvent(event.eventId);
        return false;
    }
}

这里的关键是「先记录,后处理」。记录成功了,才说明这个事件归我管。如果业务处理失败,删除记录,让事件可以被重新消费。

4.4 重试次数:别让失败事件拖垮系统

重试策略设计不好,系统很容易雪崩。我见过一个案例,重试间隔太短,失败事件每秒重试一次,结果消息队列被打满,正常业务都受影响。

我常用的重试策略是「指数退避 + 最大次数限制」:

重试次数 等待时间 说明
第 1 次 10 秒 可能是网络抖动,短时间重试
第 2 次 1 分钟 稍微等久一点
第 3 次 10 分钟 指数增长
第 4 次 1 小时 再失败就进死信队列
超过 max_retries 不再重试 标记为最终失败,人工介入

更新 next_retry_at 的 SQL 示例:

-- 重试失败后,计算下次重试时间
UPDATE outbox_event 
SET status = 3,
    retry_count = retry_count + 1,
    next_retry_at = DATE_ADD(NOW(), INTERVAL 
        CASE retry_count
            WHEN 0 THEN 10 SECOND
            WHEN 1 THEN 1 MINUTE
            WHEN 2 THEN 10 MINUTE
            WHEN 3 THEN 1 HOUR
            ELSE NULL  -- 超过最大次数,不再重试
        END
    ),
    updated_at = NOW()
WHERE id = ? AND status = 1;

关键点:当 retry_count >= max_retries 时,next_retry_at 设为 NULL。后台任务扫描时,只扫 next_retry_at <= NOW() 的记录,这些事件就不会再被扫到。

4.5 整体流程:一张图看懂

下面这张 SVG 图,把字段、状态机、幂等、重试串起来了。你仔细看看,基本就是 Outbox 表的完整生命周期。

Outbox 表结构设计核心流程 Outbox 表结构设计核心流程 业务写入事件 核心字段 • event_id (幂等键) • aggregate_type/id • payload (JSON) • status / retry_count 状态机流转 待发送 (0) 发送中 (1) 已发送 (2) 失败 (3) 重试 重试策略 • 指数退避:10s→1m→10m→1h • max_retries 上限控制 • next_retry_at 精准调度 • 超限后进入死信队列 ⚠ 避免无限重试 消费端幂等处理 • 先记录 event_id 到 processed_event 表 • 唯一键约束保证只处理一次 • 业务失败则删除记录,允许重试 设计要点总结 1. 字段设计:event_id 幂等 + aggregate 定位 + payload 数据 + status 状态 + retry 控制 2. 状态机:待发送 → 发送中 → 已发送 / 失败 → 重试 → 待发送

4.6 避坑指南:我踩过的几个坑

最后分享几个实战中容易忽略的点:

  • 字段类型别用 TEXT:payload 用 JSON 类型,MySQL 会做合法性校验。用 TEXT 存非法 JSON,消费端解析必挂。
  • 索引别乱建:只建必要的索引。我见过一张表建了 8 个索引,写入性能惨不忍睹。
  • 状态更新必须带条件:UPDATE ... WHERE id=? AND status=?,否则并发场景下状态会乱。
  • 重试次数要监控:超过 max_retries 的事件,要报警。别让失败事件石沉大海。

我的习惯:每周巡检一次 Outbox 表,看看有没有大量失败事件堆积。如果有,说明系统某个环节出了问题,需要及时排查。