读完这篇,你能做什么
能逐段说明消息可靠性证据,给临时失败和永久失败设计不同出口。
学习理由、准备要求与知识点
为什么学它
生产确认和消费确认覆盖不同故障窗口,重试还会引入重复投递与失败积压。
先读这些内容
知识点清单
- Publisher Confirm
- mandatory return
- 手动 ack
- Delivery Tag
- redelivery
- 有界重试
- 死信路由
- 幂等消费
运行前先确认AMQP 0-9-1、RabbitMQ 4.3 文档、Java Client 5.25.0;算法为 Java 17 单进程确认状态模型。真实持久性还取决于队列类型、复制、磁盘及应用事务配置。
01先弄懂它是什么
说消息成功前,先说明成功到哪一步:生产者交给本地网络、Broker 收到、消费者提交业务、生产者最终知道业务结果,是四件事,中间任何一步都可能出故障。明确要确认的是哪件事,再选择保存什么记录和接收什么确认。只说“绝对不丢”,会混淆设备损坏、确认丢失和业务处理失败。
publisher confirm 是 Broker 向生产者反馈发布结果;consumer ack 则是消费者向 Broker 声明这次投递已处理,应用需要自己保证该声明与业务结果一致。
前者不等待业务数据库提交,后者不管理生产者的发送记录。消息没有匹配队列也可能收到发布确认,路由失败还要结合 mandatory 和 return 检查。持久消息、持久队列与确认各有作用,单独写 durable 不代表整个下单流程已成功。
把数据库修改和网络 ack 放在一起看,就能理解为什么重复无法简单消失。先 ack 后提交,提交失败会造成业务缺失;先提交后 ack,若进程在两步之间退出,消息可能重新投递。一般选择后一种顺序,再用稳定 eventId、唯一约束及事务内去重保证同一业务作用不重复。我们追求的是可恢复的业务效果,而不是假定网络永远能准确反馈。
文中这几个词,先认一认
- Publisher confirm
- Broker 向生产者反馈发布接收情况,不包含消费者的业务处理结果。
- Delivery tag
- 当前 Channel 内用于识别投递或确认序列的编号,不能拿到另一 Channel 随意确认。
- Requeue
- 把消息重新安排给消费者处理,重新排队本身不会修复数据或业务错误。
- Dead-letter exchange
- 接收死信重新发布的交换机,还需要正确绑定、接收队列及相应可靠性设置。
02看看它怎么工作
发布端应先获得序列号并登记关联事件,再发送消息。异步确认回调报告单个序号,或以 multiple 表示截至某号的一组发布。记录处于待定状态时,超时不能被等价解释成 Broker 没收到:结果可能只是丢在返回链路上。重试要沿用业务事件标识;若为同一事实每次重试生成新 eventId,下游唯一约束将无法识别重复。
消费端收到消息后开启数据库事务,登记 eventId 并修改业务数据,两者成功提交后才 ack。若唯一约束冲突,要确认这是已完成的同一业务操作,而不是未完成的占位或不同负载冒用同一 ID。跨服务扣款等外部副作用不能被本地数据库事务包住,需要接收方幂等键、状态查询或补偿协议。一个本地事务边界不能覆盖所有网络参与者。
消息失败后先分清原因。临时依赖故障可以等一会再试,但要限制次数;格式错误或违反业务规则的消息应单独隔离,不能不断放回主队列,这种放回操作叫 requeue。死信交换机 DLX 会把特定消息重新转发到别处,转发本身也可能失败。
quorum queue(仲裁队列)可配置至少一次死信转发,但要满足相应队列策略和满队列处理设置,普通 DLX 并不是无损保险箱。原队列、重试队列和死信队列都要监控;恢复处理也要记录操作并限制速度。
正在加载流程图…
查看流程图源码(Mermaid)
sequenceDiagram
participant P as 发布者
participant B as Broker
participant C as 消费者
participant D as 业务数据库
P->>B: 发送稳定 eventId
B-->>P: publisher confirm
B->>C: 投递消息与 deliveryTag
C->>D: 同一事务登记去重并修改业务
D-->>C: 提交成功
C-->>B: consumer ack
Note over C,B: 若提交后 ack 丢失,可能重复投递
B->>C: 再次投递同一 eventId
C->>D: 查询或约束判定已处理
C-->>B: 确认重复投递按顺序看,每一步做了什么
- 发布前登记待确认事件;让超时记录保持可调查。
- 处理确认和不可路由回调,区分接收与路由结果。
- 消费事务共同提交幂等记录与业务数据。
- 提交成功后使用原 Channel 的投递标识确认。
- 失败按类别进入有限重试或隔离,并验证重放仍然幂等。
03对照实现代码
rabbitmq-java-client v5.25.0 · ChannelN#processAsync 与 handleAckNack
以下是原创协议状态模型,只抽出未确认集合的单个与前缀移除规则。真实客户端还有监听器分发、等待线程通知、关闭状态和 nack 累积处理;模型不可代替持久发送记录。
import java.util.*;
final class ConfirmModel {
private final NavigableSet<Long> pending = new TreeSet<>();
synchronized void sent(long sequence) {
pending.add(sequence); // 应在消息发送前建立关联。
}
synchronized void confirmed(long sequence, boolean multiple) {
if (multiple)
pending.headSet(sequence, true).clear(); // 包含当前序号的前缀视图。
else
pending.remove(sequence); // 单条确认不覆盖之前的空洞。
notifyAll(); // 等待者被唤醒后仍需循环检查自己的条件。
}
}真实异步处理入口区分 Basic.Ack 和 Basic.Nack,分发确认监听器并更新未确认状态。两者都意味着该次发布已得到协议反馈,但业务动作不同:ack 可以记录相应接收结果,nack 要进入明确失败处理。只把集合中的元素删掉而不记录结果,会失去后续排障所需的信息。
multiple 表示确认到某个序号为止的一段消息,所以有序集合适合一次找出这段记录。headSet 返回的是原集合的一部分视图,不是独立副本:清空它,也会删除原集合中对应的记录。教学模型用 inclusive 参数说明是否包含末尾序号,避免用“序号加一”表示包含末尾时发生整数溢出。
固定版本的 handleAckNack 在待确认集合清空时才调用 notifyAll,关闭流程也会通知;wait 本身还可能虚假唤醒。因此 waitForConfirms 要循环检查通道是否关闭、集合是否清空以及还剩多少等待时间。
集合为空后,还要读取 onlyAcksReceived,区分全部为 ack 还是其中有 nack。通知只是提示重新检查,不能代替 Broker 确认;共享状态的传递仍依赖相应的同步操作。
同步模型只解决进程内互斥。进程崩溃后内存集合消失,重连也会产生新的 Channel 序列空间,因此持久事件记录应使用业务 eventId 作为身份,再记录某次发送尝试的连接代次和序列号。协议编号适合关联一次尝试,业务编号适合跨故障识别同一事实,两者职责不同。
代码先调用谁,再调用谁
- 网络读取线程收到
Basic.Ack或Basic.Nack ChannelN.processAsync分派确认监听器handleAckNack更新当前 Channel 的未确认状态- 等待线程重新检查成功、失败、关闭或超时条件
- 应用把尝试结果写入事件发送记录并决定后续恢复
04动手写一个例子
有序集合维护乱序到达的发布确认
实现一个确认跟踪器:发布登记正整数序号,single 确认只移除一个序号,multiple 确认移除所有不大于给定序号的待定项,并返回本次移除列表。重复确认允许返回空列表;这是本地关联模型,不模拟 Broker 的全部协议错误。
- 输入 1
登记 1,2,3,5;single(3)- 输出 1
移除 [3];待定 [1,2,5]
- 输入 2
随后 multiple(2)- 输出 2
移除 [1,2];待定 [5]
若仅维护一个“最大已确认序号”,先收到单条 3 的确认就会误认为 1 和 2 也完成。问题本质是维护带空洞的待定集合。哈希集合方便单条删除,但查询小于等于某编号的全部元素需要扫描;平衡搜索树让单条定位和前缀遍历都符合序号关系。
收到 multiple 时从有序集合的最小元素开始,依次删除直到元素大于确认上界。每个被删除元素只属于本次结果,不会在后续确认中再次删除,因而重复回调天然返回空列表。代码通过迭代器原地移除范围中的项,避免遍历集合时直接调用集合删除触发迭代修改错误。
返回移除序号只是状态转移的可验证结果,调用方仍须知道回调是 ack 还是 nack,才能更新成功或失败日志。我们把同一同步锁覆盖登记、确认与快照,保证返回列表和集合状态来自一次明确操作;高吞吐系统可选择单线程事件循环管理这些状态,减少锁竞争,但不能在没有同步协议的多线程共享集合上照搬代码。
为什么这样写是对的pending 恰好包含已经登记且尚未被 single 精确覆盖或 multiple 前缀覆盖的序号;每次删除严格按给定确认范围进行。
TreeSet 为平衡树。定位范围起点和每次删除均取对数上界,k 是本次实际删除数;返回序号至少需要 O(k) 时间与空间。快照复制全部 n 项为 O(n),不能把调试快照也算成常数操作。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
static final class Tracker {
private final NavigableSet<Long> pending = new TreeSet<>();
synchronized void sent(long id) {
if (id <= 0) throw new IllegalArgumentException("positive id required");
if (!pending.add(id)) throw new IllegalArgumentException("duplicate pending id");
}
synchronized List<Long> confirm(long id, boolean multiple) {
if (id <= 0) throw new IllegalArgumentException("positive id required");
List<Long> removed = new ArrayList<>();
if (!multiple) {
if (pending.remove(id)) removed.add(id); // 精确删除,不跨越空洞。
return removed;
}
Iterator<Long> it = pending.headSet(id, true).iterator();
while (it.hasNext()) {
removed.add(it.next()); // 保存结果供业务关联。
it.remove(); // 通过迭代器安全地移除原集合元素。
}
return removed;
}
synchronized List<Long> snapshot() {
return List.copyOf(pending); // 返回快照,不暴露可修改的内部视图。
}
}
static void eq(Object expected, Object actual) {
if (!Objects.equals(expected, actual))
throw new AssertionError(expected + " != " + actual);
}
public static void main(String[] args) {
Tracker t = new Tracker();
eq(List.of(), t.confirm(1, true)); // 空集合确认。
for (long id : new long[]{1, 2, 3, 5}) t.sent(id);
eq(List.of(3L), t.confirm(3, false)); // 乱序单条不能确认 1、2。
eq(List.of(1L, 2L, 5L), t.snapshot());
eq(List.of(1L, 2L), t.confirm(2, true));
eq(List.of(), t.confirm(2, true)); // 重复确认没有重复副作用。
t.sent(Long.MAX_VALUE);
eq(List.of(5L, Long.MAX_VALUE), t.confirm(Long.MAX_VALUE, true));
boolean bad = false;
try { t.sent(0); } catch (IllegalArgumentException e) { bad = true; }
if (!bad) throw new AssertionError("zero accepted");
eq(List.of(), t.snapshot());
System.out.println("PASS 8");
}
}这些特殊情况也要试一试
- 单条乱序确认必须保留前面的未确认空洞。
- 重复确认返回空结果,不能再次累计成功数量。
- 最大
long序号不能通过 id+1 计算包含边界。 - 重连应重建尝试范围,不把不同 Channel 的序号混用。
进阶选读 · 有限重试调度与指数退避的资源预算
场景依赖恢复时积压十万条失败消息,全部立即重试会再次压垮数据库。
做法以 nextAttemptTime 为键维护最小堆,用 min(cap, base×2^attempt) 计算退避并加入抖动,达到最大次数或总时限转人工隔离。只按错误可恢复性进入队列,并用并发信号量、重试流量预算和幂等键约束执行。计算退避使用饱和乘法,避免溢出。
复杂度N 条待重试任务入堆与出堆为 O(log N),存储 O(N);持久调度还需索引与批量扫描成本。若只要求离散时间精度,可讨论分层时间轮降低平均维护成本。
好处与代价更长退避降低冲击却延长最终可见时间,最大重试数防止无限资源占用却要求人工恢复出口。只用堆而不持久化无法承受进程重启。
05项目里怎么用
数据库事务里先插入幂等记录再修改业务,发生异常时一起回滚。唯一键可由业务操作类型与 eventId 组成,避免两个不同操作误用同一命名空间。记录可以保存负载摘要与处理结果,同一 ID 携带不同参数时应拒绝并调查。去重保留期至少覆盖可能的消息重放窗口,否则删掉去重记录后老消息又会成为首次处理。
在手动确认的常见用法里,prefetch 限制已交给消费者、但还没收到确认的消息数量。上限过大,消费者可能持有大量任务,占内存,也增加故障后重新投递的数量;上限过小,又可能把时间花在等待下一次网络传输上。
先测单条处理时间、工作并发数和下游容量,再逐步调整。监控时把 ready(还在队列里等待投递)与 unacked(已投递但未确认)分开看,再结合处理时延和失败率,才能判断消息是真的处理完了,还是只是搬到了消费者手里。
死信队列需要负责人、告警阈值与重放流程。重放前修复失败根因,抽样验证业务影响,保留原 eventId 和历史尝试信息,并允许暂停与分批限速。不要直接把全部死信搬回主队列后宣布恢复;恢复标准是失败事实已得到可核对的业务结果,且重放不会造成第二次扣款或重复发券。
容易踩的坑
- 提前 ack 会让后续事务失败失去正常重投递机会。
- 持久化、复制和确认的组合条件应按队列类型核实。
- 无限 requeue 形成毒消息忙循环,消耗正常请求资源。
- 在另一个 Channel 上确认
deliveryTag可能导致协议错误。 - 只在进程内
Set去重,重启后无法保证业务幂等。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 逐条同步等待发布确认 | 实现和错误定位简单。 | 往返延迟限制吞吐。 | 低吞吐管理操作或最小验证。 |
| 批量等待确认 | 摊薄等待成本,流程较容易理解。 | 批次失败定位和重试范围更粗。 | 中等吞吐且批次能够幂等重试。 |
| 异步确认加持久发送记录 | 吞吐与故障追踪兼顾。 | 需要关联尝试、限制待定量并实现恢复。 | 持续高吞吐且要求可审计的业务事件。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么 publisher confirm 不表示消费成功?
展开参考答案与推导
确认发生在发布者与 Broker 之间,消费者及其业务事务不在这个协议确认边界内。
02先提交再 ack 的故障窗口如何覆盖?
展开参考答案与推导
提交后的确认可能丢失,重投递用稳定业务 ID 和事务内唯一约束判定已经完成,再确认本次重复投递。
03multiple(7) 与 single(7) 对 pending={3,7,9} 的影响为何不同?
展开参考答案与推导
前者移除 3 和 7,后者只移除 7;最大已确认值不能表达单条确认留下的空洞。
换个情况,还会处理吗
01确认超时但订单已创建,应该马上发一条新 ID 消息吗?
展开参考答案与推导
先把结果标为未知并查询业务状态;需要重试时沿用原事实的 eventId。新 ID 会破坏去重并可能制造第二个业务作用。
02死信目标暂不可用,怎样评估是否会丢转发?
展开参考答案与推导
查实际队列类型和死信策略,再做受控故障实验;普通死信与满足条件的 quorum 至少一次转发保证不同,同时监控死信转发与原队列状态。
03unacked 一直上涨而 CPU 很低,先看什么?
展开参考答案与推导
检查数据库或网络等待、线程池堵塞、确认代码路径及 prefetch;CPU 低不等于有处理余力,被阻塞的线程可能已占满并发槽。
写代码试一试
01为 Tracker 添加负确认回调,分别累计 ack 和 nack 结果。
- 输入 / 条件
pending={1,2,3},nack multiple(2)- 输出 / 预期
失败 {1,2},待定 {3}
提示 移除规则共享,结果分类独立。
展开参考答案与推导
confirm 返回本次实际移除集合,调用者仅对这批 ID 写入失败记录;重复 nack 返回空列表,不重复累计。
02实现不会溢出的上限退避。
- 输入 / 条件
base=100,cap=1000,attempt=4- 输出 / 预期
1000
提示 每轮先判断当前值是否超过 cap/2。
展开参考答案与推导
循环倍增时若将越界直接返回 cap,同时校验 base、cap 与次数;测试零次、大次数和 Long.MAX_VALUE 上限。
03用数据库事务描述“登记成功但业务失败”的测试。
- 输入 / 条件
第一次修改抛异常,第二次同 eventId 正常执行- 输出 / 预期
首次两项都回滚,第二次业务只成功一次
提示 去重表和业务表必须在同一事务。
展开参考答案与推导
在真实学习数据库注入业务异常,检查记录未留存;恢复后重投递并查询唯一记录及最终业务状态,不能仅 mock 两次方法调用。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能逐段说明消息可靠性证据,给临时失败和永久失败设计不同出口。
本篇重点加练
发布确认与消费幂等分别保护不同身份,结果未知时两端必须保持可核对的业务关联。
- Publisher Confirm
- 幂等消费
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
同一发布通道依次发送三条事件:正常路由、无匹配绑定、连接在结果到达前中断。为 Confirm 和 Return 分别建立记录字段并推演可确定的信息。
- 提交内容
- 发布状态表
- 怎样算完成
- 区分路由结果与发布确认
- 为结果未知保留独立状态
练到的知识点Publisher Confirm · mandatory return
- 作业 02原理问答
消费者在两个 Channel 上各取得一个 delivery tag 相同的投递。审查封装层仅用整数 tag 确认的接口,提出需要表达的作用域。
- 提交内容
- 接口审查与误用用例
- 怎样算完成
- 说明确认所属连接或通道
- 覆盖批量确认范围
练到的知识点手动 ack · Delivery Tag
- 作业 03原理问答
同一个发布序号收到回调前 Channel 被替换,新 Channel 序号重新开始。评审只按数字存储待确认记录的数据模型。
- 提交内容
- 确认身份与生命周期表
- 怎样算完成
- 包含通道代次
- 区分重发记录与新的业务事件
练到的知识点Publisher Confirm
综合应用
- 作业 04面试场景
支付通知处理器偶发依赖超时,也会遇到永远无法解析的消息。定义不同失败的重试资格、次数和停止条件,并说明重投递标志能提供多少证据。
- 提交内容
- 失败分类与重试预算
- 怎样算完成
- 区分暂时和持续失败
- 计算单条消息的最大尝试负担
练到的知识点redelivery · 有界重试
- 作业 05设计评审
人工从死信队列重放一周前的积分消息,用户积分规则已更新。规定原事件如何识别、采用哪版规则以及重放完成如何核对。
- 提交内容
- 重放协议与审计字段
- 怎样算完成
- 覆盖重复人工操作
- 包含规则版本和原始业务副作用检查
练到的知识点死信路由 · 幂等消费
- 作业 06编程实践
使用本地
RabbitMQ和本地持久存储实现通知收件箱演示,配置成功、解析失败、提交后断开确认三种模式,记录消息最终归属。- 提交内容
- 代码、拓扑和逐事件账本
- 怎样算完成
- 不发送真实邮件或短信
- 确认次数与业务执行次数分别统计
练到的知识点Publisher Confirm · mandatory return · 手动 ack · Delivery Tag · redelivery · 有界重试 · 死信路由 · 幂等消费
- 作业 07面试场景
优惠规则已经变更,同一事件第一次处理成功时使用旧规则,重复处理返回新规则计算结果。评审副作用幂等与返回值契约是否相同。
- 提交内容
- 结果契约与案例表
- 怎样算完成
- 区分历史结果和当前状态
- 记录规则版本依据
练到的知识点幂等消费
- 作业 08代码推演
发布者把一批重发事件改成新 ID,而消费者仅按事件 ID 去重。追踪一次业务动作可能拥有的标识并写出调查所需关联字段。
- 提交内容
- 端到端身份图
- 怎样算完成
- 区分发布尝试与业务操作
- 纳入客户端主动重试
练到的知识点Publisher Confirm · 幂等消费
- 作业 09编程实践
写本地确认台账与消费台账对账器,输入回调迟到、通道更换、事件重发及相同 ID 负载冲突,输出不可判定和冲突记录。
- 提交内容
- 程序与脱敏样本
- 怎样算完成
- 不把无回调直接当作丢失
- 注明静态账本不能复原缺失网络事件
练到的知识点Publisher Confirm · 幂等消费
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
发布端和消费端都有限次数重试,死信处理器又可能失败。定义统一事件生命周期,估算最坏重复次数并制定停止放大的告警条件。
- 提交内容
- 生命周期图与负载上界推导
- 怎样算完成
- 写明各层重试假设
- 持续失败仍保留可追踪出口
练到的知识点Publisher Confirm · 有界重试 · 死信路由 · 幂等消费
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造“每条发布都获得确认,消费者去重表也没有重复键,但用户仍收到重复权益”的反例,要求所有组件日志都真实。
- 提交内容
- 反例时间线
- 怎样算完成
- 指出身份定义的差异
- 不给组件凭空制造错误行为
练到的知识点Publisher Confirm · 幂等消费
- 作业 12思维拓展 · 开放探究
系统要求半年后可人工重放旧事件,但去重记录仅允许保存三十天。研究不同保留方案下可以承诺的重放语义与存储成本。
- 提交内容
- 研究问题与成本模型
- 怎样算完成
- 至少比较两类可检验承诺
- 不默认历史副作用可再次执行
练到的知识点Publisher Confirm · 幂等消费
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
RabbitMQ:Consumer Acknowledgements and Publisher Confirms (新标签页)↗
阅读生产确认、消费确认和重投递,识别发送成功与业务成功之间的空窗。
- 实践参考文档英文
RabbitMQ:Dead Letter Exchanges (新标签页)↗
阅读死信触发条件和路由安全限制,为失败消息定义恢复出口。
- 深入实践指南英文
RabbitMQ:Reliability Guide (新标签页)↗
阅读连接故障与重复投递,设计重试、幂等和恢复后的验证。
- 深入参考文档英文
RabbitMQ:可靠发布教程 (新标签页)↗
比较逐条、批量、异步确认与未确认消息关联。
- 深入参考文档英文
RabbitMQ:消费者指南 (新标签页)↗
联系 prefetch、消费者容量与失败恢复。
- 深入参考文档英文
ChannelN 5.25.0 源码 (新标签页)↗
定位
processAsync、handleAckNack与waitForConfirms的协作。