可靠消息投递与消费的工程设计:Outbox、幂等、死信兜底
学习笔记。记录在跨系统数据同步里,发送端如何保证消息不丢、消费端如何保证不乱不重的一整套工程设计。不涉及具体业务,只谈模式与取舍。
背景:两个经典难题
跨系统用 MQ 做数据同步,绕不开两个问题:
- 发送端:消息会丢。 "改数据库"和"发 MQ"是两个系统,没法用一个事务保证同时成功------先改库后发 MQ,发失败就丢消息;先发 MQ 后改库,库回滚就是假消息。
- 消费端:消息会重、会乱。 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 把消息真正发出去:三件套
- 事务后 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:必须等事务提交成功(数据真落库)才发,否则事务回滚了消息却发出去 = 假消息。
-
补偿 Job 兜底 :定时扫
status=0 AND next_retry_at<=now的记录重发,发成功markSent,失败retry_count++并按指数退避 算下次时间,超上限转死信(status=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 分钟重试,堆积的死信会每轮全量砸向还没恢复的下游,把它(和自己)压垮,雪崩。退避让"挂得越久、重试越稀"。
- 放弃时机合理:退避 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 + 自建死信兜底"保证不乱不重、失败可救。