Files
cs-note/hhs/MQ/03-协议与标准/5-AMQP-协议.md
T
2026-06-08 23:08:57 +08:00

29 KiB
Raw Blame History

tags, create time, update time
tags create time update time
MQ
2026-05-24 19:52 2026-06-07 20:30

AMQP 协议

概述

AMQP(Advanced Message Queuing Protocol)是应用最广泛的消息队列开放标准之一。本文重点解析 AMQP 0-9-1 版本的核心模型、Exchange 路由机制,并与 1.0 版本进行对比,最后给出 Go 语言的完整收发示例。

正文

AMQP 的版本线

AMQP 并非只有一个版本,理解版本差异是正确使用它的前提:

  • AMQP 0-8:早期版本,功能有限,已基本淘汰。
  • AMQP 0-9-1:2008 年发布,是 RabbitMQ 实现的版本。定义了完整的 Broker 模型(Exchange/Queue/Binding),是目前使用最广泛的版本。严格来说它不是 OASIS 正式标准,而是社区规范。
  • AMQP 0-10:由另一组人维护,与 0-9-1 差异较大,采用者较少(如 Apache Qpid)。
  • AMQP 1.0:2012 年成为 OASIS 标准,后纳入 ISO/IEC 19464。这是一个完全重新设计的协议,去掉了 Exchange 等抽象概念,引入 Link/Terminus 模型。Azure Service Bus、ActiveMQ Artemis 等支持此版本。

[!question] 0-9-1 和 1.0 看起来像是两个完全不同的协议。为什么同一个标准组织会允许如此大的断裂?你觉得这是技术进步还是标准碎片化?

AMQP 0-9-1 核心模型

AMQP 0-9-1 的消息流转模型是理解 RabbitMQ 的基础:

graph LR
    Producer["Producer"]
    Exchange["Exchange"]
    QueueA["Queue A"]
    QueueB["Queue B"]
    ConsumerA["Consumer A"]
    ConsumerB["Consumer B"]

    Producer -->|"发送消息 + Routing Key"| Exchange
    Exchange -->|"Binding Key: order.*"| QueueA
    Exchange -->|"Binding Key: log.#"| QueueB
    QueueA --> ConsumerA
    QueueB --> ConsumerB

核心组件及其职责:

组件 职责
Connection TCP 长连接,负责认证和加密
Channel Connection 内的虚拟连接,多路复用,避免频繁建 TCP
Exchange 消息路由引擎,根据规则将消息分发到 Queue
Queue 消息存储容器,消费者从这里拉取或推送消息
Binding Exchange 和 Queue 之间的绑定关系,携带路由规则

消息投递流程:Producer 将消息发送到 Exchange(不是直接发到 Queue),Exchange 根据自身的类型和 Binding 规则决定消息去往哪些 Queue。这种间接路由模型是 AMQP 最核心的设计思想。

Connection 与 Channel

一个 TCP Connection 上可以复用多个 Channel,这是 AMQP 的性能优化手段:

// 建立一个 Connection
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
if err != nil {
    log.Fatalf("连接失败: %v", err)
}
defer conn.Close()

// 在同一 Connection 上打开两个 Channel
ch1, _ := conn.Channel()
ch2, _ := conn.Channel()
// ch1 和 ch2 互不干扰,可以并行操作

为什么需要 Channel?因为每个线程/协程持有独立的 Channel 可以避免锁竞争,而且建立 TCP 连接的开销远大于创建 Channel。

[!tip] Channel 不是线程安全的 amqp091-go 文档明确指出:Channel 不是线程安全的。多个 goroutine 不能同时操作同一个 Channel。正确做法是每个 goroutine 持有自己的 Channel,或者加锁。这也是为什么 Connection 上可以开多个 Channel 的设计初衷——一个 goroutine 对应一个 Channel。

四种 Exchange 类型

Exchange 是消息路由的核心,不同类型的 Exchange 适用于不同的路由策略。

Direct Exchange

精确匹配 Routing Key 和 Binding Key。消息只会到达 Binding Key 完全匹配的 Queue。

graph LR
    P["Producer"] -->|"RoutingKey: order.created"| Ex["Direct Exchange"]
    Ex -->|"BindKey: order.created"| Q1["Order Queue"]
    Ex -->|"BindKey: payment.created"| Q2["Payment Queue"]

适用场景:点对点的消息分发,比如将订单消息路由到订单处理队列。

Fanout Exchange

忽略 Routing Key,将消息广播到所有绑定的 Queue。

graph LR
    P["Producer"] -->|"RoutingKey: (忽略)"| Ex["Fanout Exchange"]
    Ex --> Q1["Analytics Queue"]
    Ex --> Q2["Logging Queue"]
    Ex --> Q3["Audit Queue"]

适用场景:日志广播、事件通知,一份消息多个消费者各自处理。

Topic Exchange

基于模式匹配的路由。Binding Key 和 Routing Key 都用 . 分隔单词,支持两个通配符:* 匹配一个单词,# 匹配零或多个单词。

graph LR
    P["Producer"] -->|"RoutingKey: order.pay.success"| Ex["Topic Exchange"]
    Ex -->|"BindKey: order.*.success"| Q1["Success Queue"]
    Ex -->|"BindKey: order.#"| Q2["All Orders Queue"]
    Ex -->|"BindKey: log.#"| Q3["Log Queue"]

适用场景:灵活的事件订阅,消费者可以按需订阅感兴趣的消息子集。

Headers Exchange

不依赖 Routing Key,而是根据消息 Headers 中的键值对进行匹配。绑定时指定一组键值对和匹配规则(x-match=all 表示全部匹配,x-match=any 表示任一匹配)。

适用场景:消息属性复杂、需要多维度过滤的场景。实际使用较少,因为 Topic Exchange 在大多数情况下已经够用。

[!question] 四种 Exchange 类型中,Fanout 最简单、Headers 最复杂。但在实际项目中,Direct 和 Topic 的使用频率远高于另外两种。你觉得这是为什么?

[!tip]- 思考:为什么 Direct 和 Topic 占据"甜蜜点"? 核心原因:Direct + Topic 刚好覆盖了绝大多数路由需求。

Exchange 路由能力 问题
Fanout 无差别广播 太"粗暴"——要么全发,要么不发,无法做任何过滤
Headers 多维度 Header 键值匹配 太"重"——需要构造 Headers map,匹配语义不直观,调试困难
Direct 精确匹配 Routing Key 刚好满足点对点路由,简单直接
Topic 通配符模式匹配 在精确匹配之上加了一层灵活的"订阅"能力

关键洞察:Topic 是 Direct 的超集。当 Binding Key 不含通配符时,Topic Exchange 的行为就退化成了 Direct Exchange。所以一个 Topic Exchange 往往就能同时胜任 Direct 的场景,团队只需要掌握一种 Exchange 类型。

Fanout 为什么用得少? 场景太窄——"一模一样的消息广播给所有人"的需求在真实业务中不多见。订单事件要广播给积分和通知服务,但不给日志服务——Topic 的 order.* 就够了。Fanout 最大的短板是无法做任何路由过滤,业务稍微复杂就不够用。

Headers 为什么用得少? x-match: all/any + 键值对匹配,远不如 order.*.success 一目了然;需要检查消息 Headers map,性能和可读性都不如 Routing Key 字符串匹配。Headers 解决的问题,Topic 用更简单的方式解决了 90%。

一句话总结:Direct 处理确定性路由,Topic 处理模式化订阅。两者组合几乎能覆盖所有业务场景,而 Fanout 和 Headers 分别处于"太简单"和"太复杂"的极端,适用面天然就窄。

Virtual Host:多租户隔离

Virtual Host(vhost)是 AMQP 中的逻辑隔离单元。每个 vhost 拥有独立的 Exchange、Queue 和 Binding 集合,不同 vhost 之间的资源完全隔离。

典型用途:

  • 环境隔离:/dev、/staging、/prod 各用一个 vhost
  • 租户隔离:SaaS 平台为每个租户分配独立 vhost
  • 权限控制:可以按 vhost 粒度分配用户权限
// 连接时指定 vhost
conn, _ := amqp.Dial("amqp://user:pass@localhost:5672/production")

AMQP 1.0 vs 0-9-1 的关键差异

AMQP 1.0 并非 0-9-1 的升级版,而是重新设计。核心差异:

维度 0-9-1 1.0
路由模型 Exchange + Binding + Queue Link + Terminus(Source/Target)
Broker 角色 Broker 负责路由和存储 Broker 是可选的,可以点对点
协议复杂度 中等,概念清晰 较高,状态机复杂
生态 RabbitMQ 生态完善 Azure、ActiveMQ 支持
标准化 非正式标准 ISO/IEC 19464

1.0 中 Producer 通过 Link 直接连接到 Target(类似 Queue),不再经过 Exchange 路由。这简化了点对点通信,但失去了 0-9-1 灵活的路由能力。

[!question] AMQP 1.0 去掉了 Exchange 概念。如果让你重新设计,你会保留 Exchange 还是去掉它?Exchange 的灵活性和复杂度之间如何权衡?

[!tip]- 思考:Exchange 的去留之争 Exchange 的本质是一个发布-订阅解耦层——把「消息从哪来」和「消息到哪去」彻底分离。

这个间接层带来的核心能力:

能力 没有 Exchange 会怎样
一对多路由 Producer 需要知道所有目标队列,耦合到下游
动态订阅 新增消费者需要改 Producer 代码或配置
路由策略可替换 路由逻辑硬编码在 Producer 或 Broker 配置里

AMQP 1.0 为什么去掉 Exchange? 1.0 的设计哲学是「极简传输层 + 智能端点」:Link/Terminus 模型让 Producer 直连 Target,Broker 成为可选角色,路由逻辑推给应用层。这降低了协议复杂度,但代价是失去了协议级的集中路由能力。

如果让我重新设计:保留 Exchange 的间接路由思想,但简化实现。 理由:

  1. 路由是通用需求,不应推给应用层。当系统有 50 个 Producer、20 个 Queue 时,路由规则散落在各处是运维噩梦。Exchange 把路由集中管理,是正确的关注点分离。

  2. 但 0-9-1 的四种类型是过度设计。实际项目中 Topic 一种就够了——Fanout 等价于 Binding Key 为 # 的 Topic,Direct 等价于不含通配符的 Topic,Headers 极少有人真正需要。

理想的折中方案:只保留一种 Pattern-based Router(类似 Topic),支持通配符匹配,默认不指定 routing key 时等价于 Fanout。

一句话总结:Exchange 的间接路由思想是正确的——它解决的是真实的架构问题。但 0-9-1 把它做复杂了(4 种类型),1.0 又把它做没了。协议设计中,「去掉一个概念」和「简化一个概念」是两件完全不同的事。

消息可靠性三件套

在生产环境中,消息丢失是最不能接受的问题。先来看一个具体场景:

你在开发电商下单服务。用户点击「下单」后,订单服务发送一条 order.created 消息到 MQ,积分服务异步消费这条消息给用户加积分。现在问你三个问题:

  1. 消息发出去了,但 Broker 网络闪断,Producer 怎么知道消息有没有到?
  2. 消息到了 Broker,但 Broker 意外重启,消息还在不在?
  3. 消息推给了 Consumer,Consumer 处理到一半崩了,积分加了还是没加?

这三个问题分别对应 AMQP 可靠性保障的三个层次——生产端确认、Broker 端持久化、消费端确认。缺少任何一个环节,消息都可能静默丢失。

消息生命周期与丢失窗口

一条消息从 Producer 到 Consumer 的完整生命周期,存在三个关键的「丢失窗口」:

sequenceDiagram
    participant P as Producer
    participant B as Broker
    participant Q as Queue
    participant C as Consumer

    P->>B: 发送消息
    Note over P,B: 窗口 1: 网络丢包或 Broker 崩溃,<br/>Producer 不知道消息是否到达

    B->>Q: 路由并存储
    Note over B,Q: 窗口 2: 消息仅在内存中,<br/>Broker 重启后丢失

    Q->>C: 推送消息
    Note over Q,C: 窗口 3: Consumer 崩溃,<br/>未确认的消息何去何从?

    C->>Q: ACK 确认
    Note over Q,C: 确认后 Broker 才删除消息

三件套的分工一目了然:

丢失窗口 保护机制 核心思想
窗口 1:Producer → Broker Publisher Confirm Broker 告知 Producer「我收到了」
窗口 2:Broker 存储阶段 消息持久化 消息落盘,重启不丢
窗口 3:Broker → Consumer 手动 ACK Consumer 告知 Broker「我处理完了」

[!question] 三个窗口中,你觉得哪个窗口的丢失后果最严重?为什么?(提示:想想哪个阶段的丢失完全无感知,连报警都不会触发。)

[!tip]- 思考:哪个窗口最致命? 窗口 2(Broker 存储阶段)的丢失后果最严重。 它是唯一一个「静默丢失」的窗口——所有参与者都以为消息已经安全了,但实际上它已经消失了。

逐一分析三个窗口:

窗口 丢失时谁会察觉? 是否会触发报警?
窗口 1:Producer → Broker Producer 会收到 Confirm 超时或 Nack,发送端知道失败了 ✅ 会——Producer 可以重发、打日志、告警
窗口 2:Broker 存储阶段 没有任何一方知道。Producer 已收到 Confirm,Consumer 还没开始消费 ❌ 不会——静默丢失
窗口 3:Broker → Consumer Broker 知道消息没被 ACK,会自动重新入队 ✅ 会——消息回到队列,不会丢

窗口 2 的致命之处在于时序上的夹缝:

  1. Broker 已经收到了消息,返回了 Publisher Confirm → Producer 认为「已送达」✅
  2. 消息还在内存中,还没刷入 WAL → 磁盘上没有这消息 ❌
  3. Broker 崩溃重启 → 内存中的消息蒸发,但 Producer 已经认为投递成功,不会重发
  4. Consumer 从未收到过这条消息,也不知道它曾经存在过

结果:用户下了单,积分没有加,Producer 日志显示「发送成功」,Broker 重启后日志是全新的——没有任何地方有这条消息的痕迹。

这就是为什么 Confirm 的时机应该配置为「消息写入 WAL 之后」,而非仅仅「Broker 收到消息就返回」。把窗口 1 和窗口 2 合并为一个原子操作,才能真正堵住这个静默丢失的漏洞。

生产端保障:Publisher Confirm

Consumer 端有 ACK 机制,那 Producer 怎么确认消息真的到了 Broker?答案是 Publisher Confirm——不是 Broker「收到了消息」就确认,而是 Broker 将消息成功路由到所有匹配的 Queue 之后才返回确认。这是一个容易混淆的细节。

// 开启 Confirm 模式
err := ch.Confirm(false)
if err != nil {
    log.Fatalf("开启 Confirm 失败: %v", err)
}

// 获取确认通知 channel(带缓冲避免阻塞)
confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 1))

go func() {
    for conf := range confirms {
        if conf.Ack {
            log.Printf("消息 %d 已被 Broker 确认", conf.DeliveryTag)
        } else {
            log.Printf("消息 %d 被 Nack,需要重发", conf.DeliveryTag)
        }
    }
}()

代码要点:

  • ch.Confirm(false) 开启 Confirm 模式。参数 false 表示不等待 Broker 的 basic.ack(异步模式)。
  • NotifyPublish 返回一个 channel,Broker 确认消息后会往里写入 Confirmation。这个 channel 必须有足够缓冲(至少等于并发发送的消息数),否则 Broker 回调时会阻塞。
  • DeliveryTag 是消息在当前 Channel 内的序号,从 1 开始递增。可以用来匹配发送端的重试逻辑。

[!question] 为什么 Confirm 比事务更好? AMQP 也支持事务(Tx.Select / Tx.Commit),但事务是同步阻塞的——每条消息必须等上一条的事务提交完成后才能继续发送,吞吐量降低 2-5 倍。Publisher Confirm 是异步流水线的——Producer 可以连续发送多条消息,Broker 批量返回 Confirm。这就是为什么生产环境几乎只用 Confirm,不用事务。

[!tip] 同步 Confirm vs 异步 Confirm 上面的代码是异步 Confirm(吞吐量最高)。如果你的场景对吞吐要求不高但想要最简单的写法,也可以用同步 Confirm:

// 同步 Confirm:发一条等一条
ch.Confirm(false)
confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 1))

ch.PublishWithContext(ctx, "events", "order.created", false, false, msg)
conf := <-confirms  // 阻塞等待这条消息的确认
if !conf.Ack {
    log.Println("Broker 拒绝了这条消息,需要重发")
}

选型建议:吞吐量要求高 → 异步 Confirm;逻辑简单、消息量小 → 同步 Confirm。但永远不要用事务替代 Confirm。

Broker 端保障:消息持久化

持久化解决的问题很直观:Broker 重启后消息还在不在?

但要实现持久化,需要同时满足三个条件,缺一不可:

[!warning] 持久化的三个条件

  1. Exchange 声明时 durable: true —— Broker 重启后 Exchange 依然存在
  2. Queue 声明时 durable: true —— Broker 重启后 Queue 依然存在
  3. Message 的 DeliveryMode 设置为 amqp.Persistent(值为 2)—— 消息体本身要落盘

只声明了 durable 的 Queue 但消息没有设 Persistent,Broker 重启后消息依然会丢失。 这是最常见的坑:Queue 配置正确,但发送端忘了设 DeliveryMode,导致 Broker 重启后队列还在,消息却没了。

为什么三个条件要同时满足?因为它们是不同层面的东西:

条件 保护对象 如果缺失
Exchange durable 路由元数据 重启后 Exchange 消失,消息无法路由
Queue durable 队列元数据 重启后 Queue 消失,消息无处存储
Message Persistent 消息体本身 重启后消息从内存清除

RabbitMQ 内部的持久化机制是 WAL(Write-Ahead Log):Persistent 消息先写入 WAL 日志,再由后台线程异步刷入 .rdx 数据文件。WAL 保证了即使 Broker 崩溃,已写入日志的消息不会丢失。内存中的消息可以被消费者立即读取(不等落盘),兼顾了可靠性和性能。

[!question] 如果消息在 WAL 写入之前 Broker 就崩了,这条消息会怎样? 答案是:丢了。这是持久化的物理极限——没有任何系统能保证「零丢失」,只能做到「已确认写入的数据不丢」。这也正是 Publisher Confirm 的意义:Confirm 的时机可以配置为消息写入 WAL 之后,而非仅仅是收到消息就返回。这样 Producer 收到 Confirm 时,可以确信消息已经安全落盘。

消费端保障:手动 ACK

消费端是消息生命周期的最后一环,也是最容易出问题的一环。核心问题是:消费者从 Queue 拿到消息后,如果在处理过程中崩了,这条消息怎么办?

自动确认 vs 手动确认

AMQP 有两种确认模式,选择不同,消息的命运截然不同:

模式 autoAck 行为 风险
自动确认 true Broker 发出消息后立即删除 Consumer 崩溃 → 消息永久丢失
手动确认 false 等 Consumer 发送 Ack 后才删除 Consumer 崩溃 → 消息自动重新入队

自动确认等于「发了就不管」——Broker 把消息从队列中删掉的那一刻,如果 Consumer 还没处理完就挂了,这条消息就像从来没存在过。生产环境必须使用手动确认。

Ack / Nack / Reject 三件套

手动确认模式下,Consumer 有三种操作:

操作 含义 multiple 参数 requeue 参数
Ack 处理成功,可以删除 true = 批量确认所有 DeliveryTag ≤ 当前的消息 无
Nack 处理失败 true = 批量否定确认 true = 重新入队,false = 丢弃或进入死信队列
Reject 处理失败(单条) 不支持 true = 重新入队,false = 丢弃或进入死信队列

Nack 和 Reject 的区别:Nack 支持 multiple=true 批量操作,Reject 只能操作单条。实际项目中通常用 Nack,更灵活。

for msg := range msgs {
    err := processOrder(msg.Body) // 业务逻辑
    if err == nil {
        msg.Ack(false)   // 成功:确认当前这一条
    } else if isTransientError(err) {
        msg.Nack(false, true)   // 临时失败:重新入队,稍后重试
    } else {
        msg.Nack(false, false)  // 永久失败:不重新入队,进入死信队列
    }
}

[!warning] 消息重投递与幂等性 当 Consumer 发送 Nack(false, true) 或崩溃后消息被自动重新入队时,这条消息会被再次投递给同一个或另一个 Consumer。Consumer 端可以通过 msg.Redelivered 字段判断是否是重投递。

这意味着同一条消息可能被处理两次。如果你的业务逻辑是「给用户加积分」,重复处理就会重复加积分——这是灾难性的。

手动 ACK 解决了消息不丢的问题,但引入了消息重复的问题。 要让整条链路可靠,Consumer 端的业务逻辑必须是幂等的——无论处理多少次,结果都一样。常见的幂等方案:

方案 原理 适用场景
数据库唯一约束 message_id 设 UNIQUE,重复插入失败 写数据库场景
Redis SETNX 用 message_id 做 key,成功才执行 高并发场景
状态机 检查当前状态,只有「待处理」才执行 订单/支付状态流转

一句话:想要可靠,就必须接受「消息可能重复」,并让业务幂等。这是 AMQP 可靠性模型的基本约束。

进阶:Prefetch 与公平调度

理解了手动 ACK 之后,你可能会想到一个问题:Consumer 需要处理完一条消息、发送 ACK 之后才能收到下一条——如果 Consumer 处理一条消息需要 1 秒,那它 1 秒内只能处理 1 条,Broker 的 Queue 会持续积压。

更合理的做法是让 Consumer 同时处理多条消息(比如一个 goroutine 池)。但这就带来了新的风险:如果 Broker 不加限制地推送,慢 Consumer 的缓冲区会堆积大量未处理消息,浪费内存;而快 Consumer 又闲着。

Prefetch 就是解决这个问题的流控机制。 它控制 Broker 向单个消费者最多推送多少条未 ACK 的消息:

// 每次最多给这个消费者推 10 条未确认消息
err := ch.Qos(
    10,     // prefetchCount:预取数量
    0,      // prefetchSize:预取大小(字节),0 表示不限
    false,  // global:false 表示仅对当前 Channel 生效
)

Prefetch 实现了「按能力分配」的公平调度:

graph LR
    Q["Queue<br/>100 条消息"] -->|"推 10 条"| F["快 Consumer<br/>已 ACK 8 条"]
    Q -->|"推 10 条"| S["慢 Consumer<br/>已 ACK 1 条"]
    F -->|"还有 2 条额度"| Q
    S -->|"额度已满, 等待"| Q

快 Consumer 处理完 8 条后,Prefetch 额度释放,Broker 立即补充新消息;慢 Consumer 10 条额度一直满着,Broker 不再给它推——这样就自然实现了负载均衡。

[!question] Prefetch 设多少合适? 没有标准答案,取决于消息处理耗时。经验公式:prefetchCount = 单条处理耗时(秒) × 期望吞吐量(条/秒)。

例如:单条处理 50ms,期望每秒 200 条 → prefetchCount = 0.05 × 200 = 10。

设太小(如 1):Consumer 逐条处理,频繁等待 Broker 推送,吞吐上不去。设太大(如 1000):回到不设 Prefetch 的老问题。10-30 是大多数场景的起点,再根据监控微调。

[!tip] global 参数的陷阱 ch.Qos(10, 0, false) 的第三个参数 global 很容易误解:

  • global=false(推荐):Prefetch 限制作用于单个 Consumer——每个消费者各自最多 10 条未确认
  • global=true:Prefetch 限制作用于整个 Channel——该 Channel 上所有消费者共享 10 条额度

大多数场景应该用 false,让每个 Consumer 独立控制。

Go 代码:完整收发示例

使用 amqp091-go 库(RabbitMQ 官方维护的 Go 客户端)演示 Topic Exchange 的消息收发。

[!warning] API 已更新 amqp091-go v1.7+ 中,Publish 已被标记为废弃,应使用 PublishWithContext 并传入 context.Context,以支持超时和取消。

发送端:

package main

import (
	"context"
	amqp "github.com/rabbitmq/amqp091-go"
	"log"
	"time"
)

func main() {
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	if err != nil {
		log.Fatalf("连接失败: %v", err)
	}
	defer conn.Close()

	ch, err := conn.Channel()
	if err != nil {
		log.Fatalf("打开 Channel 失败: %v", err)
	}
	defer ch.Close()

	// 声明 Topic Exchange(durable=true,Broker 重启后保留)
	ch.ExchangeDeclare("events", "topic", true, false, false, false, nil)

	// 声明两个队列(durable=true,持久化队列)
	q1, _ := ch.QueueDeclare("order-events", true, false, false, false, nil)
	q2, _ := ch.QueueDeclare("log-events", true, false, false, false, nil)

	// 绑定:order 相关事件 → order-events 队列
	ch.QueueBind(q1.Name, "order.*", "events", false, nil)
	// 绑定:所有事件 → log-events 队列
	ch.QueueBind(q2.Name, "#", "events", false, nil)

	// 发送消息,使用 PublishWithContext 替代已废弃的 Publish
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	err = ch.PublishWithContext(ctx, "events", "order.created", false, false,
		amqp.Publishing{
			ContentType:  "application/json",
			DeliveryMode: amqp.Persistent, // 持久化消息,Broker 崩溃后不丢
			Body:         []byte(`{"order_id": 12345, "amount": 99.9}`),
		})
	if err != nil {
		log.Fatalf("发送失败: %v", err)
	}
	log.Println("消息已发送")
}

代码要点:

  • ExchangeDeclare 的第三个参数 true 表示持久化,Broker 重启后 Exchange 依然存在。
  • QueueBind 将 Queue 绑定到 Exchange 并指定 Binding Key,这里用 order.* 匹配 order.created、order.paid 等。
  • PublishWithContext 传入带超时的 context.Context,超时后会返回错误而非永久阻塞。
  • DeliveryMode: amqp.Persistent 确保消息被写入磁盘,配合 durable 的 Exchange 和 Queue 实现完整的持久化链路。

接收端:

package main

import (
	amqp "github.com/rabbitmq/amqp091-go"
	"log"
)

func main() {
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	if err != nil {
		log.Fatalf("连接失败: %v", err)
	}
	defer conn.Close()

	ch, err := conn.Channel()
	if err != nil {
		log.Fatalf("打开 Channel 失败: %v", err)
	}
	defer ch.Close()

	// 设置 Prefetch:每次最多推 10 条未确认消息
	ch.Qos(10, 0, false)

	// 消费 order-events 队列
	// consumer="" 让 Broker 自动生成唯一 tag
	// autoAck=false,手动确认
	msgs, err := ch.Consume("order-events", "", false, false, false, false, nil)
	if err != nil {
		log.Fatalf("注册消费者失败: %v", err)
	}

	for msg := range msgs {
		log.Printf("收到订单消息: %s", msg.Body)
		// 处理业务逻辑...
		msg.Ack(false) // 手动确认,false 表示仅确认当前这一条
	}
}

[!tip] Ack(false) vs Ack(true)

  • msg.Ack(false) — 只确认当前这一条消息
  • msg.Ack(true) — 确认当前消息以及所有 DeliveryTag 比它小的消息(批量确认)

实际项目中通常用 false,逐条确认更安全,避免业务逻辑中途失败时影响已确认的消息。

连接恢复策略

网络抖动是生产环境的常态。amqp091-go 本身不提供自动重连,需要自行实现:

func connectWithRetry(url string) (*amqp.Connection, error) {
    for {
        conn, err := amqp.Dial(url)
        if err == nil {
            log.Println("连接成功")
            return conn, nil
        }
        log.Printf("连接失败: %v,5 秒后重试...", err)
        time.Sleep(5 * time.Second)
    }
}

[!tip] 生产级方案 手写重连循环只是入门。生产中更常见的做法是:

  1. 监听 conn.NotifyClose() channel,连接断开时触发重连
  2. 同时监听 conn.NotifyBlocked() 通知 Broker 主动阻塞连接(如内存告警时)
  3. 在重连后重新声明 Exchange、Queue、Binding 并恢复消费者——因为 AMQP 的声明是幂等的(参数一致时不会报错),所以可以放心重复声明

amqp.Publishing 常用属性

amqp.Publishing 结构体有很多字段,生产中常用的几个:

属性 类型 说明
ContentType string 消息体格式,如 "application/json"
DeliveryMode uint8 amqp.Transient(1) 或 amqp.Persistent(2)
MessageId string 消息唯一 ID,用于幂等去重
CorrelationId string 关联 ID,用于 RPC 场景匹配请求和响应
ReplyTo string 回复队列名,RPC 模式中告诉服务端把结果发到哪
Expiration string 消息过期时间(毫秒),如 "60000" 表示 1 分钟后过期
Headers map 自定义键值对,Headers Exchange 根据此字段路由

[!question] RPC 模式 AMQP 支持天然的 RPC 模式:Client 发消息时设置 ReplyTo(回调队列名)和 CorrelationId(请求 ID),Server 处理后将结果发到 ReplyTo 队列。Client 通过 CorrelationId 匹配请求和响应。虽然现在更多用 gRPC 做 RPC,但理解这个模式有助于理解 AMQP 的设计灵活性。

关联笔记