可靠消息投递与消费的工程设计:Outbox、幂等、死信兜底

可靠消息投递与消费的工程设计:Outbox、幂等、死信兜底

学习笔记。记录在跨系统数据同步里,发送端如何保证消息不丢、消费端如何保证不乱不重的一整套工程设计。不涉及具体业务,只谈模式与取舍。

背景:两个经典难题

跨系统用 MQ 做数据同步,绕不开两个问题:

  1. 发送端:消息会丢。 "改数据库"和"发 MQ"是两个系统,没法用一个事务保证同时成功------先改库后发 MQ,发失败就丢消息;先发 MQ 后改库,库回滚就是假消息。
  2. 消费端:消息会重、会乱。 MQ 基本都是 at-least-once(至少投递一次),重复投递、乱序到达是常态,消费逻辑必须能扛。

下面分发送端、消费端两头讲。


一、发送端:Transactional Outbox(本地消息表)

1.1 问题:双写不一致

java 复制代码
// 反例:无法保证两步原子
tx.begin();
bizMapper.update(data);     // 改库
tx.commit();
mq.send(msg);               // 发 MQ ------ 这一步失败/超时?库已改、消息丢了

跨"数据库"和"MQ"两个资源,没有廉价的分布式事务。

1.2 方案:把"发消息"变成一次数据库写

核心思想:不直接发 MQ,而是在同一个事务里,往一张 outbox(本地消息表)插一条"待发送消息"记录。 业务数据和消息记录绑在一个本地事务里,天然原子。

sql 复制代码
CREATE TABLE outbox (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_type      VARCHAR(64),   -- 业务类型
  biz_id        VARCHAR(64),   -- 业务主键
  payload       TEXT,          -- 消息体 JSON
  status        TINYINT,       -- 0待发送 1已发送 2死信
  retry_count   INT,
  next_retry_at DATETIME,
  UNIQUE KEY uk_biz (biz_type, biz_id)   -- 幂等:同一件事只发一条
);
java 复制代码
tx.begin();
bizMapper.update(data);                 // 业务数据
outboxMapper.insert(buildOutbox(msg));  // 消息记录,status=0
tx.commit();                            // 两者一起成功 or 一起回滚(原子)

1.3 把消息真正发出去:三件套

  1. 事务后 best-effort 立即发一次(降延迟):
java 复制代码
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override public void afterCommit() {      // 等事务 commit 成功后才发
        try {
            producer.send(payload);            // 发成功
            outboxMapper.markSent(id);         // 标记已发送
        } catch (Exception e) {
            log.warn("best-effort send failed, leave to compensator", e);  // 失败不管,交补偿
        }
    }
});

为什么用 afterCommit:必须等事务提交成功(数据真落库)才发,否则事务回滚了消息却发出去 = 假消息。

  1. 补偿 Job 兜底 :定时扫 status=0 AND next_retry_at<=now 的记录重发,发成功 markSent,失败 retry_count++ 并按指数退避 算下次时间,超上限转死信(status=2)+ 告警。

  2. 幂等 :靠 (biz_type, biz_id) 唯一索引,重复写入冲突即跳过。

1.4 ⚠️ 一个高频坑:异步 Producer 的"发送成功"是假的

很多 MQ 的 producer 是异步缓冲 的(如 Kafka 的 send()、某些 SDK 的 addUserMessage()):消息进本地缓冲就返回,真正投递到 broker 由后台线程做,结果通过回调通知。

所以下面这种写法有坑:

java 复制代码
boolean ok = producer.sendBuffered(payload);  // 只是"入缓冲成功",不是"broker 收到"
if (ok) outboxMapper.markSent(id);            // ❌ 过早:还没真发出去就标已发送

丢消息窗口:① 入缓冲后、后台线程发出前进程崩溃 → 缓冲在内存丢,outbox 却已 status=1,补偿不管;② 异步投递最终失败,回调里若不处理 → 同样丢。

正确做法:只有拿到投递确认(broker ack / 成功回调)才 markSent:

java 复制代码
producer.send(payload, (meta, ex) -> {
    if (ex == null) outboxMapper.markSent(id);     // 确认后才标已发
    else            outboxMapper.markFailed(id);   // 失败→留给补偿重试
});

一句话原则:markSent 的依据必须是"投递确认",不是"入缓冲/没抛异常"。


二、消费端:幂等 + 手动 checkpoint

2.1 at-least-once:消费必然可能重复

既然 MQ 至少投递一次,消费逻辑必须幂等------重复消费同一条消息,结果不变。常见手段:唯一键去重、状态机前置条件、以数据版本覆盖。

2.2 checkpoint:消费进度的"书签"

MQ(Kafka/类似)里消息按 offset 顺序排列,消费者要提交"读到哪了"的位点,这就是 checkpoint / 提交 offset:

  • 提交了(位点前移)→ 这条算消费成功,不再投递;
  • 不提交(位点不动)→ MQ 会重投,相当于重试。

关键配置:关掉自动提交,由业务按结果手动控制:

properties 复制代码
checkpoint.auto.commit = false   # 否则处理失败也会被自动提交 → 丢消息
java 复制代码
boolean ok = handle(msg);
if (ok) checkpointer.checkpoint(offset);   // 成功才提交
// 失败:不提交 → MQ 重投重试

2.3 "MQ 通知 + 回查" 替代"消息带全量数据"

一个很实用的模式:MQ 消息只当"通知"(带个 key),真实数据消费时反查生产方(HTTP/RPC)为准。

好处:

  • 天然防乱序:不管消息到达顺序,回查拿到的永远是生产方当前最新态;
  • 天然幂等:重复消费回查结果一致,覆盖本地无副作用;
  • 比"定时全量拉取对账"更实时、更省。

代价:多一次回查调用;依赖生产方提供查询接口。


三、消费端兜底:自建死信表 + 重放 + 人工

3.1 为什么自建

RabbitMQ / RocketMQ 有原生死信队列(DLQ),但有些 MQ 没有平台级的消费重试计数和 DLQ 。这时需要业务自建一张死信表,把"重试计数 + 退避 + 状态 + 兜底"自己管起来。

3.2 死信表状态机

复制代码
0 待重试(PENDING) → 1 重试中(REPLAYING) → 2 已恢复(RESOLVED)
0 待重试          → 3 已忽略/死信(IGNORED,终态)
  • 失败累加 :saveOrIncrRetry------首次 INSERT,重复 retry_count++(并发用唯一键冲突捕获转 UPDATE);按指数退避算 next_retry_at;达上限转死信终态。
  • 成功清除 :消费成功 markResolved。
  • 重放 Job :定时扫 status=待重试 AND next_retry_at<=now,重新执行消费逻辑;成功 markResolved,失败继续 saveOrIncrRetry。
  • CAS 防并发重放 :UPDATE ... SET status=重试中 WHERE id=? AND status=待重试,只有抢到的那个线程影响 1 行,防止多实例重复重放同一条。
  • 人工兜底 :admin 后台可 replay(手动重放)/ ignore(标脏数据忽略)/ resend(手动补发)。
java 复制代码
// 指数退避:失败越多等越久,并封顶
private static final int[] BACKOFF_MIN = {1, 5, 30, 120, 480, 1440};  // 分钟
int idx = Math.min(retryCount - 1, BACKOFF_MIN.length - 1);
nextRetryAt = now.plusMinutes(BACKOFF_MIN[idx]);

3.3 为什么要退避(而不是固定间隔狂重试)

  1. 防重试风暴:下游挂久了,若固定 1 秒/1 分钟重试,堆积的死信会每轮全量砸向还没恢复的下游,把它(和自己)压垮,雪崩。退避让"挂得越久、重试越稀"。
  2. 放弃时机合理:退避 N 次能覆盖几小时~几天,给下游充分恢复窗口;固定短间隔很快就耗尽、把瞬时故障误判成永久失败。

四、一个设计讨论:消费失败重试,别让"两套重试"打架

消费失败后怎么重试,有两种常见做法,容易不小心同时用上、互相打架:

做法 机制 问题
A. 不 checkpoint 让 MQ 重投 失败就不提交位点,MQ 按自己节奏重投 重投节奏不可控(MQ 定);若还叠加自建退避表,就是两套重试并行,退避被 MQ 快速重投架空,还重复消费
B. 立刻 checkpoint + 自管退避(推荐) 失败即写死信表(待重试)+ 立刻 checkpoint ,重试全程由 Job 按退避驱动 单一重试驱动、退避 100% 可控、无重复消费

推荐 B 。关键顺序:先写表(事务提交) → 再 checkpoint;万一中间崩溃,没 checkpoint,MQ 重投一次,幂等重处理,不丢。

教训:如果你既"不 checkpoint 让 MQ 重投"、又建了"退避重放 Job",等于同一条消息被两条路同时重试,退避表形同虚设。要么全交给 MQ、要么全交给 Job,别混。


五、贯穿全局的几个通用点

  • 幂等:发送端用唯一键防重发;消费端用唯一键/回查/状态覆盖防重复消费。
  • 乐观锁(CAS / 条件更新) :UPDATE ... WHERE 旧状态/旧版本,靠"影响行数"判断有没有被并发改过,比悲观锁轻、不阻塞、不死锁。
  • best-effort vs 可靠:快速路用 best-effort(失败不较劲),慢速路(补偿/Job)保证最终完成------又快又不丢。
  • 最终一致 + 对账兜底 :所有自动重试都救不回时,靠人工 admin;更稳的系统还会加定期全量对账,把漏掉的补齐。

六、一张图收尾

复制代码
发送端(生产者)
  业务事务 { 改数据 + 写 outbox(PENDING) }  原子
      │ 事务后 best-effort 发一次(确认才 markSent)
      │ 补偿 Job 扫 PENDING 重发(退避, 超限→死信)
      ▼
    [ MQ ]
      ▼
消费端(消费者)
  收到(通知) → 回查生产方拿权威数据 → 幂等覆盖本地
      ├ 成功 → checkpoint(提交位点)
      └ 失败 → 写死信表(待重试,退避) → checkpoint
                 → 重放 Job 按退避重试
                      ├ 成功 → 已恢复
                      └ 到上限 → 死信终态 → 告警 → 人工(重放/忽略/补发)

小结

目标 手段
发不丢 Transactional Outbox:事务内写消息表 + 事务后 best-effort + 补偿 Job;markSent 认准投递确认
收不乱 MQ 通知 + 回查生产方为权威;幂等消费
收不重 at-least-once 前提下做幂等(唯一键/状态覆盖)
位点不乱提 手动 checkpoint(auto.commit=false),成功才提交
失败能救 自建死信表:退避重试 + CAS 防并发重放 + 重放 Job + 人工兜底
别雪崩 指数退避 + 封顶;重试风暴防护
并发安全 乐观锁(条件更新 / CAS)

本质就一句:发送端用"本地消息表"把 MQ 投递纳入数据库事务保证不丢;消费端用"幂等 + 手动 checkpoint + 自建死信兜底"保证不乱不重、失败可救。

相关推荐
bro_Java6661 小时前
《二叉树的基础知识、操作方法与代码示例》
java·数据结构·算法·广度优先
dishugj2 小时前
HANA集群计划内切换与维护操作
java·linux·数据库
yuniko-n2 小时前
【JUC】线程间通信
java·开发语言
yuniko-n2 小时前
【JUC】关于 Java 线程介绍
java·开发语言
HelloWorld工程师2 小时前
@Primary 注解
java·spring
代码什么用12 小时前
Spring基础使用
java·后端·spring
Su米苏12 小时前
Spring AI 中MCP 与普通 @Tool的区别
java·人工智能·spring
码云数智-园园12 小时前
unique_ptr 还是 shared_ptr?C++ 智能指针选型与内存泄漏实战分析
java·开发语言
老板一杯拿铁12 小时前
Java 异常体系详解:从字节码原理到最佳实践
java