qinghuan
← 消息队列 / 全部笔记

单元 83 / 消息队列

P0 · 必会主线L2 · 进阶

RabbitMQ 可靠消费:确认、重试与死信各管哪一段

分别确认发布、路由和消费的结果,再处理重复投递与永久失败,理解为什么提交数据库后仍可能收到重投。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能逐段说明消息可靠性证据,给临时失败和永久失败设计不同出口。

学习理由、准备要求与知识点

为什么学它

生产确认和消费确认覆盖不同故障窗口,重试还会引入重复投递与失败积压。

先读这些内容

知识点清单

  • 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 并不是无损保险箱。原队列、重试队列和死信队列都要监控;恢复处理也要记录操作并限制速度。

正在加载流程图…

两种确认与业务事务各有边界;数据库提交到 ack 之间的故障窗口必须靠幂等协议覆盖。
查看流程图源码(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: 确认重复投递

按顺序看,每一步做了什么

  1. 发布前登记待确认事件;让超时记录保持可调查。
  2. 处理确认和不可路由回调,区分接收与路由结果。
  3. 消费事务共同提交幂等记录与业务数据。
  4. 提交成功后使用原 Channel 的投递标识确认。
  5. 失败按类别进入有限重试或隔离,并验证重放仍然幂等。

03对照实现代码

Java Client 如何处理批量发布确认 (新标签页)↗

rabbitmq-java-client v5.25.0 · ChannelN#processAsync 与 handleAckNack

以下是原创协议状态模型,只抽出未确认集合的单个与前缀移除规则。真实客户端还有监听器分发、等待线程通知、关闭状态和 nack 累积处理;模型不可代替持久发送记录。

核心机制代码 · java
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 作为身份,再记录某次发送尝试的连接代次和序列号。协议编号适合关联一次尝试,业务编号适合跨故障识别同一事实,两者职责不同。

代码先调用谁,再调用谁

  1. 网络读取线程收到 Basic.Ack 或 Basic.Nack
  2. ChannelN.processAsync 分派确认监听器
  3. handleAckNack 更新当前 Channel 的未确认状态
  4. 等待线程重新检查成功、失败、关闭或超时条件
  5. 应用把尝试结果写入事件发送记录并决定后续恢复

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 前缀覆盖的序号;每次删除严格按给定确认范围进行。

时间 登记 O(log n);单条确认 O(log n);批量确认保守 O((k+1)log n)额外空间 O(n) 状态;本次结果 O(k)

TreeSet 为平衡树。定位范围起点和每次删除均取对数上界,k 是本次实际删除数;返回序号至少需要 O(k) 时间与空间。快照复制全部 n 项为 O(n),不能把调试快照也算成常数操作。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 48 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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
  • 幂等消费

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    同一发布通道依次发送三条事件:正常路由、无匹配绑定、连接在结果到达前中断。为 Confirm 和 Return 分别建立记录字段并推演可确定的信息。

    提交内容
    发布状态表
    怎样算完成
    • 区分路由结果与发布确认
    • 为结果未知保留独立状态

    练到的知识点Publisher Confirm · mandatory return

  2. 作业 02原理问答

    消费者在两个 Channel 上各取得一个 delivery tag 相同的投递。审查封装层仅用整数 tag 确认的接口,提出需要表达的作用域。

    提交内容
    接口审查与误用用例
    怎样算完成
    • 说明确认所属连接或通道
    • 覆盖批量确认范围

    练到的知识点手动 ack · Delivery Tag

  3. 作业 03原理问答

    同一个发布序号收到回调前 Channel 被替换,新 Channel 序号重新开始。评审只按数字存储待确认记录的数据模型。

    提交内容
    确认身份与生命周期表
    怎样算完成
    • 包含通道代次
    • 区分重发记录与新的业务事件

    练到的知识点Publisher Confirm

综合应用

  1. 作业 04面试场景

    支付通知处理器偶发依赖超时,也会遇到永远无法解析的消息。定义不同失败的重试资格、次数和停止条件,并说明重投递标志能提供多少证据。

    提交内容
    失败分类与重试预算
    怎样算完成
    • 区分暂时和持续失败
    • 计算单条消息的最大尝试负担

    练到的知识点redelivery · 有界重试

  2. 作业 05设计评审

    人工从死信队列重放一周前的积分消息,用户积分规则已更新。规定原事件如何识别、采用哪版规则以及重放完成如何核对。

    提交内容
    重放协议与审计字段
    怎样算完成
    • 覆盖重复人工操作
    • 包含规则版本和原始业务副作用检查

    练到的知识点死信路由 · 幂等消费

  3. 作业 06编程实践

    使用本地 RabbitMQ 和本地持久存储实现通知收件箱演示,配置成功、解析失败、提交后断开确认三种模式,记录消息最终归属。

    提交内容
    代码、拓扑和逐事件账本
    怎样算完成
    • 不发送真实邮件或短信
    • 确认次数与业务执行次数分别统计

    练到的知识点Publisher Confirm · mandatory return · 手动 ack · Delivery Tag · redelivery · 有界重试 · 死信路由 · 幂等消费

  4. 作业 07面试场景

    优惠规则已经变更,同一事件第一次处理成功时使用旧规则,重复处理返回新规则计算结果。评审副作用幂等与返回值契约是否相同。

    提交内容
    结果契约与案例表
    怎样算完成
    • 区分历史结果和当前状态
    • 记录规则版本依据

    练到的知识点幂等消费

  5. 作业 08代码推演

    发布者把一批重发事件改成新 ID,而消费者仅按事件 ID 去重。追踪一次业务动作可能拥有的标识并写出调查所需关联字段。

    提交内容
    端到端身份图
    怎样算完成
    • 区分发布尝试与业务操作
    • 纳入客户端主动重试

    练到的知识点Publisher Confirm · 幂等消费

  6. 作业 09编程实践

    写本地确认台账与消费台账对账器,输入回调迟到、通道更换、事件重发及相同 ID 负载冲突,输出不可判定和冲突记录。

    提交内容
    程序与脱敏样本
    怎样算完成
    • 不把无回调直接当作丢失
    • 注明静态账本不能复原缺失网络事件

    练到的知识点Publisher Confirm · 幂等消费

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    发布端和消费端都有限次数重试,死信处理器又可能失败。定义统一事件生命周期,估算最坏重复次数并制定停止放大的告警条件。

    提交内容
    生命周期图与负载上界推导
    怎样算完成
    • 写明各层重试假设
    • 持续失败仍保留可追踪出口

    练到的知识点Publisher Confirm · 有界重试 · 死信路由 · 幂等消费

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造“每条发布都获得确认,消费者去重表也没有重复键,但用户仍收到重复权益”的反例,要求所有组件日志都真实。

    提交内容
    反例时间线
    怎样算完成
    • 指出身份定义的差异
    • 不给组件凭空制造错误行为

    练到的知识点Publisher Confirm · 幂等消费

  2. 作业 12思维拓展 · 开放探究

    系统要求半年后可人工重放旧事件,但去重记录仅允许保存三十天。研究不同保留方案下可以承诺的重放语义与存储成本。

    提交内容
    研究问题与成本模型
    怎样算完成
    • 至少比较两类可检验承诺
    • 不默认历史副作用可再次执行

    练到的知识点Publisher Confirm · 幂等消费

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗