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 表的完整生命周期。
4.6 避坑指南:我踩过的几个坑
最后分享几个实战中容易忽略的点:
- 字段类型别用 TEXT:payload 用 JSON 类型,MySQL 会做合法性校验。用 TEXT 存非法 JSON,消费端解析必挂。
- 索引别乱建:只建必要的索引。我见过一张表建了 8 个索引,写入性能惨不忍睹。
- 状态更新必须带条件:
UPDATE ... WHERE id=? AND status=?,否则并发场景下状态会乱。 - 重试次数要监控:超过
max_retries的事件,要报警。别让失败事件石沉大海。
我的习惯:每周巡检一次 Outbox 表,看看有没有大量失败事件堆积。如果有,说明系统某个环节出了问题,需要及时排查。