跳到正文

可靠事件投递:本地消息表与幂等 ​

一句话:把业务变更和待发事件写进同一个本地事务,再由独立的 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()));
        }
    }
}

验证「回滚不发、提交最终会发」:

  1. 起 MySQL 8.0 与 RocketMQ 5.x,创建 Topic t-order-event。
  2. 在事务方法中插入业务行和 outbox 后主动抛异常;断言两张表都没有新行,mqadmin queryMsgByKey -n 127.0.0.1:9876 -t t-order-event -k <event_id> 查不到消息。
  3. 正常提交但先停掉 relay;断言 outbox 停在 status=0,恢复后变为 status=2,消费者只收到一条逻辑事件。
  4. 把一条 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 或同步调用。

把一门技术讲透到既懂也会用