主题
可靠事件投递:本地消息表与幂等
一句话:把业务变更和待发事件写进同一个本地事务,再由独立的 relay 补发,用「至少一次投递 + 消费端幂等」替代无法实现的跨系统原子提交。
本文只讨论应用侧如何让「业务落库」和「消息发出」保持一致。RocketMQ 事务消息的半消息、回查语法见 02-消息类型与使用场景,消费失败后的重试阶梯、%DLQ% 与重投方式见 07-死信队列与消费重试,这里都不重复。
示例基于 MySQL 8.0+(需要 FOR UPDATE SKIP LOCKED)与 RocketMQ 5.x。OutboxRelay、event_id、biz_key 均为示例命名。
为什么需要
业务写完库还要通知下游,最自然的写法是「更新业务表 → 调 MQ 发送」。问题在于本地事务和 MQ 是两个系统,提交和发送之间没有共同的事务边界,两种顺序各丢一种东西:
- 先写库、后发消息:事务已提交,进程在发送前崩溃、网络超时或 Broker 不可用。库里状态已变,下游永远收不到事件。因为没有留下任何待发记录,这种丢失在业务侧不可见,只能等对账发现。
- 先发消息、后写库:消息已被消费,本地事务却回滚;或者消费者在提交前就读到事件,查到的还是旧状态。下游据此创建了并不存在的订单、发了不该发的货,属于「多发脏事件」。
更麻烦的是发送超时无法判定结果:超时不等于失败,消息可能已经落盘。按失败重试会产生重复,按成功处理又可能真丢了。因此工程上不能追求「发一次且只发一次」,只能保证「不丢」,再把重复交给消费端消化。
核心机制
本地消息表:把「待发」变成事务的一部分
业务表旁边建一张 outbox 表,业务变更和一条 PENDING 记录在同一个数据库、同一个事务里提交。事务提交意味着两者同时可见,事务回滚则两者同时消失。消息队列是否可用不再阻塞业务写入。
独立的 OutboxRelay 轮询未发送记录,逐条发给 MQ,成功后把状态改成 SENT。发送失败则增加重试次数、按退避时间更新 next_retry_at,超过上限置为 FAILED 并告警。
关键在「发送成功后标记」这一步:如果进程在发送成功、标记之前崩溃,记录仍是待发送,relay 会再发一次。这是设计上接受的重复,不是 bug。反过来,如果标记成功但消息实际没到 Broker,就会丢——所以必须用同步发送的明确成功响应才标记,不能凭「调用没抛异常」判断。
图中真正的原子边界只有左边那个本地事务;relay 与 MQ 之间仍是「至少一次」。失败分支回到轮询而不是原地死等,是为了让单条坏消息不拖住整批。
多实例抢占
多个 relay 实例同时轮询会重复发送,需要先「认领」再发送。两种做法:
- 数据库行锁:
SELECT ... WHERE status='PENDING' ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED,拿到锁的事务内直接置为SENDING。SKIP LOCKED让其他实例跳过已锁行,不互相等待。 - 状态机 + 租约:
UPDATE ... SET status='SENDING', owner=?, lease_until=now()+30s WHERE ... AND status='PENDING'。实例崩溃后,租约过期的记录由定时任务改回PENDING重新认领。
租约是必须的:进程在 SENDING 状态崩溃时,没有租约这批记录会永久卡住。MySQL 8.0 以下没有 SKIP LOCKED,只能用乐观版本号或「单实例分片」规避。
与事务消息的取舍
| 维度 | 事务消息 | 本地消息表 |
|---|---|---|
| 发送方与生产者 | 必须是同一个应用 | 业务库与 MQ 解耦 |
| MQ 不可用时 | 半消息发不出去,本地事务不会执行 | 业务照常提交,relay 稍后补发 |
| 延迟 | 提交后直接投递,无轮询延迟 | 取决于轮询间隔,通常百毫秒到秒级 |
| 额外成本 | 半消息占存储,需要幂等回查接口 | 多一张表、写放大、需要清理 |
| 一致性 | 至少一次 | 至少一次 |
事务消息适合「发送方就是生产者、本地事务简单、能接受 MQ 参与写路径」的场景;outbox 适合业务库不能依赖 MQ 可用性、或者要用多种消息中间件的场景。两者都不提供恰好一次,消费端都要幂等。
消费幂等
重复可能来自 relay 重发、生产者重试、Broker 重投、DLQ 人工重放,无法在上游消除。消费端要有一个稳定的去重键:event_id 由生产端生成并随消息传递;没有全局 ID 时,用 biz_key + event_type 作为业务唯一键。
处理顺序固定为:先判重,再处理,处理和去重标记在同一事务。
- 去重表对
event_id建唯一索引,插入成功才继续处理;唯一键冲突说明已处理过。 - 重复消息必须返回成功(ACK),否则会触发无休止重试,最后进死信,把一个幂等问题放大成积压问题。
- 只靠 Redis
SETNX不够:标记成功但业务事务回滚,会留下「以为处理过」的假记录。Redis 适合做前置快速过滤,最终判据仍要落在数据库唯一约束或状态机上。 - 状态机是第二道保险:只允许
待支付 → 已支付,重复的「已支付」事件直接忽略。
顺序
需要顺序时,按 biz_key 选同一个队列/分区,让同一聚合根串行。注意 outbox relay 本身会破坏顺序:多个实例并发发送同一业务键的事件,到达顺序不确定。做法是按 hash(biz_key) 把 relay 分片,同一分片单线程发送;或者查询时保证同一 biz_key 的记录由同一个 worker 处理。
即便如此,也只能保证「同一业务键内有序」,不要依赖全局顺序。乱序的兜底是版本号或状态机:事件带 version,消费端丢弃小于当前版本的更新;状态机拒绝非法跃迁。
补偿与对账
至少一次意味着链路上一定存在「该发没发」「发了没消费」的窗口,需要主动发现:
- 定时扫描创建超过阈值仍是
PENDING、或处于SENDING且租约过期的记录。 - 对账任务按业务键比对业务表状态与消息/消费记录,输出差异清单,而不是直接改数据。
- 人工补偿入口要记录操作人、原因、原状态、补偿结果,保留审计;补偿动作本身也要幂等。
实战场景
一、“没抛异常”不等于发送成功,标记 SENT 后消息消失
现象:outbox relay 上线两周,对账发现约 0.05% 的事件下游从未收到,而这些记录在表里状态是 SENT、sent_at 有值。Broker 侧按 msgId 查不到任何痕迹。relay 代码里是这样判断成功的:
java
// 错误写法:只要不抛异常就认为成功
try {
producer.send(msg); // 异步发送,返回值被丢弃
outbox.markSent(record.id());
} catch (Exception e) {
outbox.bumpRetry(record.id());
}排查:SEND_OK 与“调用返回”是两件事,四种 SendStatus 都不抛异常。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 发送方式 | 是否用了异步 send(msg, callback) 却在外层立即标记 | 异步返回时结果还未定 |
| 返回状态 | 记录 SendStatus 分布 | FLUSH_SLAVE_TIMEOUT/SLAVE_NOT_AVAILABLE 也属“未确认” |
| Broker 侧痕迹 | 按 msgId/keys 查 ConsumeQueue 与 CommitLog | 查不到说明根本没写入 |
| 回调错误 | callback 里的 onException 是否有日志与指标 | 无记录即错误被吞 |
| 对账口径 | 源表事件数 vs 下游消费数,按小时对比 | 差异率稳定即存在系统性判定错误 |
反过来,进程在“发送成功、标记之前”崩溃会让记录仍是待发送、relay 再发一次,这是设计上接受的重复,不是 bug;真正会丢的是“标记成功但消息没到”。
对症做法:
java
// 1. 同步发送 + 明确状态判定,只有 SEND_OK 才算成功
SendResult r = producer.send(msg); // 同步
if (r.getSendStatus() == SendStatus.SEND_OK) {
outbox.markSent(record.id(), r.getMsgId()); // 存 msgId,便于事后追踪
} else {
outbox.bumpRetry(record.id(), r.getSendStatus()); // 超时/Slave 不可用:退避后重发
}
// 2. 必须异步时,标记放进回调里,并在回调中区分四类状态
producer.send(msg, new SendCallback() {
public void onSuccess(SendResult r) {
if (r.getSendStatus() == SendStatus.SEND_OK) outbox.markSent(record.id(), r.getMsgId());
else outbox.bumpRetry(record.id(), r.getSendStatus());
}
public void onException(e) { outbox.bumpRetry(record.id(), e.getClass().getSimpleName()); }
});text
3. msgId 必须落库:它是“这条记录到底发出去没有”的唯一证据,
没有它,丢消息与重复消息都无法事后区分。
4. 上限保护:超过 maxRetry 置 FAILED 并告警,不要无限退避,
否则故障被藏在一个无人查看的状态里。
5. 每小时对账(源事件数 vs 下游处理数)作为最后防线,
差异直接进入待处理队列而不是日志。复盘要点:判据是“SENT 状态的记录必须有 msgId,且 msgId 在 Broker 侧可查”;把“不抛异常即成功”列为 code review 的红线。补偿与对账的具体节奏见下节与本文的补偿小节,MQ 可用性不该阻塞业务写入,但也不该被静默失败替代。
二、两个 relay 实例互相抢同一批记录:重复三倍 + 永久卡死
现象:relay 扩到 3 个实例后,下游收到同一 event_id 的三条消息(幂等拦住了副作用,但下游 QPS 翻倍);同时表里出现一批 SENDING 状态、updated_at 停在两天前的记录,既不重发也不失败。
排查:多实例轮询必须有“认领”语义,租约缺失会让崩溃变成永久占用。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 是否共享批次 | 三个实例的 SELECT ... WHERE status='PENDING' 是否返回同一批 id | 相同即互相重复发送 |
| 认领机制 | 是否有行锁(FOR UPDATE SKIP LOCKED)或状态机 + 租约 | 两者都没有即命中 |
| 卡死记录 | status='SENDING' 且 lease_until 为空或已过但无人回收 | 崩溃后这批记录永久占用 |
| 数据库版本 | MySQL 是否 ≥ 8.0(SKIP LOCKED 从 8.0 起支持) | 5.7 需改用乐观版本号或单实例分片 |
| 下游幂等 | 重复消息是否被识别 | 幂等是唯一还在兜住的东西 |
对症做法:
sql
-- 方案 1:行锁认领(MySQL 8.0+),SKIP LOCKED 让实例互不等待
BEGIN;
SELECT id, event_id, payload FROM outbox
WHERE status = 'PENDING' AND next_retry_at <= NOW()
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE outbox SET status='SENDING', owner='relay-2',
lease_until = NOW() + INTERVAL 30 SECOND, attempts = attempts + 1
WHERE id IN (...);
COMMIT;
-- 发送成功(SEND_OK)后才置 SENT;失败则回 PENDING + 退避时间sql
-- 方案 2:乐观状态机 + 租约(跨版本通用)
UPDATE outbox
SET status='SENDING', owner=?, lease_until=NOW() + INTERVAL 30 SECOND
WHERE id=? AND status='PENDING' AND next_retry_at <= NOW();
-- affected rows = 1 才表示本实例抢到;0 说明别人已认领
-- 租约回收:进程崩溃后必须有人把过期 SENDING 放回 PENDING,否则永久卡住
UPDATE outbox SET status='PENDING', owner=NULL
WHERE status='SENDING' AND lease_until < NOW();text
1. 租约时长要大于“单次发送最坏耗时”,否则会被回收后重复发送;
两者之间留 5 倍以上余量(经验值,需校准)。
2. 实例多时按分片认领(id % shardCount = myShard),减少锁竞争,
但分片变更时要保证租约回收任务不受影响。
3. 重复发送的兜底始终是消费端幂等,relay 侧做的是“尽量减少”,不是“消除”。复盘要点:判据是“SENDING 超时记录数恒为 0(回收后 1 个周期内归零)”和“同一 event_id 的下游重复率可测且被幂等拦住”。扩 relay 实例数前先确认认领机制存在——这是一个只靠加机器就会引入故障的组件,属于扩容前必须验证的前提。
三、先标记去重再处理业务,消息被静默吞掉
现象:消费端按“规范”做了幂等:进来先往去重表插 event_id,插入成功就继续处理。某次处理逻辑抛异常,消息进入重试;重试时因为 event_id 已存在被判定“已处理”直接 ACK。结果 180 余条消息既没成功也没进死信,业务侧表现为“订单已支付但权益未发放”(经验值,需校准)。
排查:顺序错了,去重标记先于业务完成就等于把“未处理”记成“已处理”。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 代码顺序 | 是先 insert 去重记录再处理,还是同事务 | 分离且先标记即命中 |
| 事务边界 | 去重标记与业务写入是否同一事务 | 不同事务则崩溃/异常时状态不一致 |
| 异常路径 | 处理失败时去重记录是否回滚 | 不回滚即永久挡住重试 |
| 重复消息返回值 | 命中去重时返回 ACK 还是 RECONSUME | 正常应是 ACK;异常场景被误 ACK 才是问题 |
| 缺失量核对 | 源事件与业务结果按小时对账 | 差异稳定即有静默吞没 |
“重复消息必须返回成功(ACK),否则会触发无休止重试、最后进死信,把一个幂等问题放大成积压问题”这条规则没错,前提是去重标记代表“确实处理完成”。
对症做法:
java
// 正确顺序:判重 → 业务处理 + 去重标记在同一事务 → 才 ACK
public ConsumeConcurrentlyStatus consume(List<MessageExt> msgs, ConsumeConcurrentlyContext ctx) {
for (MessageExt m : msgs) {
String eventId = m.getUserProperty("event_id");
if (dedup.exists(eventId)) continue; // 已处理,直接放过
try {
txTemplate.executeWithoutResult(s -> {
benefit.grant(m); // 业务写入
dedup.insert(eventId, m.getMsgId()); // 同事务标记
});
} catch (RetryableException e) {
return ConsumeConcurrentlyStatus.RECONSUME_LATER; // 整个事务回滚,标记也未写入
} catch (BadEventException e) {
quarantine.save(m, e); // 不可重试:隔离并放行
dedup.insertAsFailed(eventId, e); // 记录“已判定不可处理”
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}sql
-- 去重表:唯一索引是幂等的物理保证;状态列区分“已处理”和“已判定不处理”
CREATE TABLE event_dedup (
event_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(16) NOT NULL, -- PROCESSED / REJECTED
msg_id VARCHAR(64),
created_at DATETIME(3) NOT NULL
);text
1. event_id 由生产端生成并随消息传递;没有全局 ID 时用 biz_key + event_type 作业务唯一键。
2. “处理”与“标记”跨库时(业务库 + 独立去重库),
把去重标记落到业务库的同一事务里,或改用业务表的唯一键约束(UPSERT 影响行数判定)。
3. 不可重试的脏数据显式记为 REJECTED,避免它被当成“未处理”无限重投。复盘要点:判据是“去重表的 PROCESSED 记录与业务结果一一对应”,用抽样对账验证:随机取 100 条 PROCESSED,业务结果必须存在。重复来源无法在上游消除(relay 重发、生产者重试、Broker 重投、DLQ 人工重放),所以幂等是消费端的固有职责;顺序与事务边界决定了它是保护还是掩眼法。与事务消息的取舍(MQ 不可用时业务是否照常提交)见 消息类型与使用场景。
最小上手
MySQL 8.0+ 表结构:
sql
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
biz_key VARCHAR(64) NOT NULL,
topic VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待发 1发送中 2已发 3失败
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_until DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_event_id (event_id),
KEY idx_pending (status, next_retry_at)
) ENGINE=InnoDB;业务方法里,业务写和 outbox 插入必须共用同一个 @Transactional。relay 的发送循环(省略错误分类与日志):
java
@Scheduled(fixedDelay = 500)
public void relay() {
// claimBatch 分两步:SELECT id ... WHERE status=0 ORDER BY id LIMIT 100
// FOR UPDATE SKIP LOCKED,再 UPDATE ... SET status=1, lease_until=now()+30s WHERE id IN (:ids)
List<OutboxEvent> batch = outboxMapper.claimBatch(100);
for (OutboxEvent e : batch) {
try {
producer.send(new Message(e.getTopic(), e.getBizKey(), e.getPayload()));
outboxMapper.markSent(e.getId()); // status=2
} catch (Exception ex) {
outboxMapper.markRetry(e.getId(), // status=0 或超限置 3
e.getRetryCount() + 1, backoff(e.getRetryCount()));
}
}
}验证「回滚不发、提交最终会发」:
- 起 MySQL 8.0 与 RocketMQ 5.x,创建 Topic
t-order-event。 - 在事务方法中插入业务行和 outbox 后主动抛异常;断言两张表都没有新行,
mqadmin queryMsgByKey -n 127.0.0.1:9876 -t t-order-event -k <event_id>查不到消息。 - 正常提交但先停掉 relay;断言 outbox 停在
status=0,恢复后变为status=2,消费者只收到一条逻辑事件。 - 把一条
status=2手工改回0,让 relay 重发;消费端应因event_id去重返回成功,业务表不产生第二次变更。
生产动作
以下参数是应用侧配置,不是中间件默认值,按需调整:
- 清理:
SENT记录保留 7 天后按主键分批删除(每批 1000 条,低峰执行),不要用大事务DELETE。FAILED在人工处理前不删。日增量大时按天分区,直接DROP PARTITION。 - 延迟:
relay.interval=500ms(示例)。核心指标是outbox_oldest_pending_seconds,它直接反映下游感知延迟;只看条数会漏掉「少量积压但很旧」的情况。 - 重试:
max_retry=8(示例),退避从 1s 翻倍到上限 5min。超过上限不是继续等,而是转FAILED并告警。 - 消费幂等冲突率:
dedup_hit_total / consume_total。少量重复正常;突然升高通常意味着 relay 重复发送、SENT 标记失败或上游批量重放。同时盯%DLQ%的maxOffset与增速,死信上涨说明重试解决不了的问题正在累积。
排障按「现象 → 指标/命令 → 判断」:
PENDING最老记录持续变老 → 查 relay 实例存活、lease_until分布、Broker 可用性 → 若记录停在SENDING,是 relay 崩溃,等租约回收;若全是PENDING,先查 MQ 连接与发送超时。FAILED增长 → 抽样payload,确认 Topic 是否存在、消息体是否超限 → 参数或格式错误属不可重试,修数据后人工重置为PENDING,不要盲目放大重试次数。- 消费重复率升高 → 对比 outbox 状态变更时间与消费时间,检查 relay 日志中的重复发送 → 崩溃窗口内的重复符合预期,确认幂等生效即可;持续重复要查 SENT 更新是否失败。
- 积压处置顺序:先确认数据库写入与 MQ 可用,再扩 relay/消费者,然后定位毒消息,最后才考虑限流上游;不要直接清空 outbox 或跳过状态。
边界
- 写放大与表体积:每条事件多一次插入和索引维护,binlog 同步量也增加;outbox 只保留到发送成功后的短窗口,不是业务日志,不能用来审计或回放历史。高吞吐场景必须分区并控制保留期。
- 轮询有延迟:通常数百毫秒到数秒,不适合要求毫秒级可见的同步链路;低延迟场景可在事务提交后主动触发一次 relay,但轮询仍是兜底。
- 跨库事务不适用:业务表和 outbox 必须在同一个库、同一个事务。如果业务本身跨多个数据库,应使用 Saga 或 TCC,outbox 只能解决单库与 MQ 之间的一致。
- 重试不能掩盖不可恢复错误:消息体格式错误、Topic 不存在、权限不足,重试多少次都不会成功,应尽快转
FAILED并告警。 - 不要宣称恰好一次:只有在「消息存储支持事务性消费 + 消费端幂等落库」同时成立时,才能逼近一次效果;普通链路只有至少一次。
讲清楚
它解决的是本地事务和消息发送无法原子提交的问题。做法是把业务变更和一条待发记录写进同一个数据库事务,消息发不发由独立的 relay 负责,成功就标记,失败就退避重试,所以最坏情况是重复而不是丢失。代价是 outbox 带来写放大和表体积增长,投递有轮询延迟,而且只能做到至少一次,消费端必须用 event_id 或业务唯一键做幂等,处理与去重标记要放在同一事务里。要求毫秒级实时、业务跨多个库、或者无法接受多一张表时,不该用它,应分别考虑事务消息、Saga 或同步调用。