RabbitMQRPC与死信队列

RabbitMQ- Remote procedure call (RPC) -- pd的后端笔记

文章目录

    • [RabbitMQ- Remote procedure call (RPC) -- pd的后端笔记](#RabbitMQ- Remote procedure call (RPC) -- pd的后端笔记)
  • 用消息队列做"请求-响应"
    • [回调队列(Callback Queue) + Correlation ID](#回调队列(Callback Queue) + Correlation ID)
      • [服务端(RPC Server)](#服务端(RPC Server))
      • [客户端(RPC Client)](#客户端(RPC Client))
  • [消息过期机制详解:Message TTL 与 Queue TTL](#消息过期机制详解:Message TTL 与 Queue TTL)
    • [Message TTL:让消息自动"过期"](#Message TTL:让消息自动“过期”)
    • [Queue TTL:让空闲队列自动"消失"](#Queue TTL:让空闲队列自动“消失”)
    • [🔄 TTL + 死信队列(DLX):过期消息的"归宿"](#🔄 TTL + 死信队列(DLX):过期消息的“归宿”)
  • 死信交换机(DLX)深度实践:让"无法投递"的消息有归宿
    • [🧪 场景一:处理"毒药消息"(Poison Message)](#🧪 场景一:处理“毒药消息”(Poison Message))
    • [🧪 场景二:订单超时自动关闭(TTL + DLX)](#🧪 场景二:订单超时自动关闭(TTL + DLX))
    • [🧪 场景三:队列满员后丢弃旧消息(x-max-length + DLX)](#🧪 场景三:队列满员后丢弃旧消息(x-max-length + DLX))
  • [Lazy Queue 深度解析:海量消息堆积的磁盘优化方案](#Lazy Queue 深度解析:海量消息堆积的磁盘优化方案)
    • [🔧 如何启用 Lazy Queue?](#🔧 如何启用 Lazy Queue?)
    • [⚙️ Lazy Queue 内部机制](#⚙️ Lazy Queue 内部机制)
  • [Publisher Confirms 详解:确保消息可靠到达 Broker](#Publisher Confirms 详解:确保消息可靠到达 Broker)
    • [🧨 问题重现:为什么需要 Publisher Confirms?](#🧨 问题重现:为什么需要 Publisher Confirms?)
    • [🔧 启用与使用方式](#🔧 启用与使用方式)
    • [🔄 异步 Confirm:高性能场景必备](#🔄 异步 Confirm:高性能场景必备)
  • [消息去重(Message Deduplication)详解:如何防止重复消费](#消息去重(Message Deduplication)详解:如何防止重复消费)
    • [✅ 方案一:业务层幂等(推荐!最常用)](#✅ 方案一:业务层幂等(推荐!最常用))
    • [方案二:使用 RabbitMQ 插件(rabbitmq_message_deduplication)](#方案二:使用 RabbitMQ 插件(rabbitmq_message_deduplication))

用消息队列做"请求-响应"

前面我们学了 RabbitMQ 的各种异步通信模式:Work Queue、Pub/Sub、Routing、Topic......

它们的共同点是:生产者发完就走,不关心结果。

但有些场景需要 同步交互:

"我发一个请求,必须等到你处理完,把结果返回给我。"

比如:

  • 查询用户积分
  • 调用支付接口
  • 获取商品库存

这时候,传统的 HTTP API 很自然。但在微服务或分布式系统中,用消息队列实现 RPC 也有独特优势:

✅ 解耦调用方与服务方

✅ 天然支持负载均衡(多个服务实例)

✅ 可结合消息持久化、重试、死信等机制提升可靠性

🔄 RPC 的核心挑战:如何"等待回复"?

  • 在 HTTP 中,客户端发请求 → 服务端处理 → 返回响应,天然同步。
  • 但在消息队列中,所有通信都是异步的,如何模拟"等待"?

回调队列(Callback Queue) + Correlation ID

复制代码
[Client]                            [Server]
   │                                    │
   │ --(1) 请求 + reply_to + corr_id--> │
   │                                    │
   │ <--(2) 响应 + corr_id------------- │
   │ (从 reply_to 队列接收)              │
元素 作用
reply_to 客户端创建一个临时队列,告诉服务端:"把结果发到这里"
correlation_id 每个请求生成唯一 ID,用于匹配"请求 ↔ 响应"

💡 因为一个客户端可能同时发多个请求,必须靠 correlation_id 区分谁是谁!

服务端(RPC Server)

python 复制代码
import pika
import time

def fib(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n - 1) + fib(n - 2)

credentials = pika.PlainCredentials('admin', 'admin')
connection = pika.BlockingConnection(
    pika.ConnectionParameters('localhost', credentials=credentials)
)
channel = connection.channel()

# 声明 rpc_queue,客户端会往这里发请求
channel.queue_declare(queue='rpc_queue')

def on_request(ch, method, props, body):
    n = int(body)
    print(f" [.] fib({n})")
    
    # 模拟耗时计算
    response = str(fib(n))
    
    # 👇 关键:把结果发回 client 指定的 reply_to 队列
    ch.basic_publish(
        exchange='',
        routing_key=props.reply_to,          # ← 回调队列
        properties=pika.BasicProperties(
            correlation_id=props.correlation_id  # ← 原样返回 corr_id
        ),
        body=response
    )
    
    # 手动 ACK 请求消息
    ch.basic_ack(delivery_tag=method.delivery_tag)

# 公平分发:一次只处理一个请求
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='rpc_queue', on_message_callback=on_request)

print(" [x] Awaiting RPC requests")
channel.start_consuming()

这里的服务端 消费客户端的请求 生产响应信息

客户端(RPC Client)

python 复制代码
import pika
import uuid
import time

class FibonacciRpcClient:
    def __init__(self):
        self.credentials = pika.PlainCredentials('admin', 'admin')
        self.connection = pika.BlockingConnection(
            pika.ConnectionParameters('localhost', credentials=self.credentials)
        )
        self.channel = self.connection.channel()
        
        # 👇 创建一个临时回调队列(exclusive=True)
        result = self.channel.queue_declare(queue='', exclusive=True)
        self.callback_queue = result.method.queue
        
        # 监听回调队列(但先不启动消费循环!)
        self.channel.basic_consume(
            queue=self.callback_queue,
            on_message_callback=self.on_response,
            auto_ack=True
        )

    def on_response(self, ch, method, props, body):
        # 👇 通过 correlation_id 判断是不是当前请求的响应
        if self.corr_id == props.correlation_id:
            self.response = body

    def call(self, n):
        self.response = None
        self.corr_id = str(uuid.uuid4())  # 生成唯一 ID
        
        # 发送 RPC 请求
        self.channel.basic_publish(
            exchange='',
            routing_key='rpc_queue',
            properties=pika.BasicProperties(
                reply_to=self.callback_queue,     # ← 告诉 server 回复到哪
                correlation_id=self.corr_id       # ← 标记请求 ID
            ),
            body=str(n)
        )
        
        # 👇 等待响应(轮询)
        while self.response is None:
            self.connection.process_data_events()  # 非阻塞式检查新消息
            
        return int(self.response)

# 使用示例
fib_rpc = FibonacciRpcClient()
print(" [x] Requesting fib(30)")
response = fib_rpc.call(30)
print(f" [.] Got {response}")

🔍 关键机制解析

  1. reply_to
    • 客户端创建一个临时独占队列(exclusive=True)
    • 通过 BasicProperties.reply_to 告诉服务端回复地址
    • 服务端直接 routing_key=props.reply_to 发回结果
  2. correlation_id
    • 每次请求生成唯一 ID(如 UUID)
    • 服务端原样返回
    • 客户端收到消息后,只处理 correlation_id 匹配的响应

✅ 这样即使多个请求并发,也不会混淆结果!

  1. 客户端"等待"技巧:process_data_events()
  • 不使用 start_consuming()(会阻塞)
  • 而是用 process_data_events() 非阻塞轮询,检查是否有新消息
  • 一旦收到匹配的响应,就退出循环

⚠️ 注意事项

  1. 性能 vs 可靠性
    • RPC over MQ 比 HTTP 延迟更高(多一次队列入队/出队)
    • 但更可靠(可持久化、重试、削峰)
  2. 超时处理(重要!)
    • 上面代码没有超时机制,如果服务端挂了,客户端会永远等待
    • 生产环境必须加超时:
python 复制代码
start_time = time.time()
while self.response is None:
    if time.time() - start_time > 5:  # 5秒超时
        raise TimeoutError("RPC timeout")
    self.connection.process_data_events(time_limit=1)
  1. 错误处理
    • 服务端异常时,应返回错误信息(如 "ERROR: invalid input")
    • 客户端需解析响应,区分成功/失败
  2. 资源清理
    • 临时回调队列会在连接关闭时自动删除(因为 exclusive=True)
适合 RPC over MQ 不适合
异步但需结果的长任务(如视频转码) 高频低延迟调用(如用户登录)
需要消息持久化/重试的业务 简单 CRUD 操作
服务端天然支持水平扩展 实时性要求极高的场景

✅ 总结

  • RPC over RabbitMQ = 请求队列 + 回调队列 + Correlation ID
  • 核心思想:用两个异步消息模拟同步调用
  • 优势:解耦、负载均衡、可靠性强
  • 代价:复杂度增加、延迟略高

虽然现在 gRPC、HTTP/2 更流行,但在需要与现有消息系统集成或强调最终一致性的场景下,RabbitMQ RPC 依然是一个优雅的解决方案。

消息过期机制详解:Message TTL 与 Queue TTL

在构建可靠的消息系统时,我们不仅要考虑"消息不丢",还要考虑"消息别堆积太久"。

有些消息天生有时效性:

  • 用户的限时优惠券(10分钟后失效)
  • 支付超时订单(30分钟未支付自动取消)
  • 实时位置更新(5秒前的位置已无意义)

如果这些消息长时间滞留在队列中,不仅浪费资源,还可能导致业务逻辑错误。

RabbitMQ 提供了两种 TTL(Time-To-Live,生存时间) 机制,让消息或队列自动过期,实现"自我清理"。

⏳ 两种 TTL:作用对象不同

类型 作用对象 行为
Message TTL 单条消息 消息入队后,超过 TTL 自动丢弃
Queue TTL 整个队列 队列空闲(无消费者、无消息)超过 TTL,自动删除

Message TTL:让消息自动"过期"

设置方式(任选其一)

✅ 方式 1:在队列上设置默认 Message TTL(推荐)

python 复制代码
channel.queue_declare(
    queue='task_queue',
    arguments={'x-message-ttl': 60000}  # 单位:毫秒 → 60秒
)
  • 所有进入该队列的消息,默认拥有 60 秒 TTL
  • 如果消息自身也设置了 TTL,则取较小值

✅ 方式 2:在发送消息时单独设置

python 复制代码
channel.basic_publish(
    exchange='',
    routing_key='task_queue',
    body=message,
    properties=pika.BasicProperties(
        expiration='30000'  # 字符串!单位毫秒 → 30秒
    )
)

⚠️ 注意:expiration 必须是字符串,且不能超过 2^32 - 1(约 49 天)

Queue TTL:让空闲队列自动"消失"

场景

  • 临时创建的 RPC 回调队列(理论上用完就删,但万一客户端崩溃?)
  • 动态生成的用户专属队列(用户离线后自动清理)
python 复制代码
channel.queue_declare(
    queue='temp_queue',
    arguments={'x-expires': 30000}  # 单位:毫秒 → 30秒
)
  • 条件:队列必须处于"未使用"状态(没有消费者 + 没有消息)
  • 一旦满足条件并持续 x-expires 时间,RabbitMQ 自动删除该队列

🔄 TTL + 死信队列(DLX):过期消息的"归宿"

默认情况下,TTL 过期的消息会被直接丢弃。

但很多时候,我们希望捕获这些过期消息做后续处理:

  • 支付超时 → 关闭订单
  • 验证码过期 → 记录失败日志
  • 任务超时 → 触发告警

这就需要 Dead Letter Exchange(死信交换机)!

步骤:

  • 声明一个死信交换机和队列
  • 原队列绑定 DLX,并指定死信路由 key
  • 过期/拒绝/达到最大重试的消息 → 自动转发到 DLX
python 复制代码
# 1. 声明死信队列
channel.queue_declare(queue='payment_timeout_queue')
channel.queue_bind(exchange='dlx', queue='payment_timeout_queue', routing_key='timeout')

# 2. 声明主队列,设置 TTL + DLX
channel.queue_declare(
    queue='payment_queue',
    arguments={
        'x-message-ttl': 1800000,          # 30分钟
        'x-dead-letter-exchange': 'dlx',    # 指定死信交换机
        'x-dead-letter-routing-key': 'timeout'  # 可选,覆盖原 routing_key
    }
)

# 3. 发送支付消息(30分钟内未处理则进死信队列)
channel.basic_publish(
    exchange='',
    routing_key='payment_queue',
    body='order_123'
)

然后无非是写一个消费者来处理死信队列消息,执行关闭订单、记录失败日志、触发告警等业务逻辑。

⚠️ 重要注意事项

  1. TTL 是"入队时间"开始计时
    • 如果队列很长,前面的消息没处理完,后面的消息即使 TTL 到了也不会立即过期(RabbitMQ 不会扫描整个队列)
    • 该消息到达队列头部(即将被消费)时,系统检查其是否已过期, 若过期也会被丢弃

📌 结论:TTL 更适合短队列或高优先级队列

  1. Queue TTL 的"未使用"定义严格
    • 只要有一个消费者连接着(即使没消费),队列就不会被删除
    • 必须完全空闲(无消息 + 无消费者)
  2. TTL 值必须是正整数
    • 设为 0 或负数会报错
    • 最大值约 49 天(受限于 32 位整数)

死信交换机(DLX)深度实践:让"无法投递"的消息有归宿

实际上,RabbitMQ 会在 三种情况下 将消息变为"死信(Dead Letter)",并转发到 DLX:

  1. 消息被拒绝(basic.reject / basic.nack)且 requeue=false
  2. 消息 TTL 过期
  3. 队列达到最大长度(x-max-length)或最大字节数(x-max-length-bytes)

今天,我们就通过 真实业务场景 + 完整代码,深入掌握 DLX 的配置、使用和最佳实践。

🧨 为什么需要 DLX?

想象这些场景:

  • 用户支付失败,重试 3 次仍失败 → 需要人工介入
  • 订单超时未支付 → 自动关闭订单
  • 消息格式错误,永远无法处理 → 不能无限重试

如果没有 DLX:

  • 消息要么无限重试(占用资源)
  • 要么静默丢弃(丢失关键信息)

有了 DLX:

  • 所有"异常"消息集中归档
  • 可被专门的"死信消费者"处理(告警、补偿、人工审核)

✅ DLX = 消息系统的"回收站" + "异常处理器"

🧪 场景一:处理"毒药消息"(Poison Message)

问题

  • 某条消息因数据错误(如 JSON 格式非法),导致消费者永远处理失败。如果不断重试,会阻塞整个队列。

解决方案

  • 消费者最多重试 N 次(如 3 次)
  • 第 N+1 次失败时,拒绝消息且不重新入队 → 触发 DLX

代码实现

主消费者(带重试计数)

python 复制代码
def callback(ch, method, properties, body):
    try:
        # 获取重试次数(通过消息 header 传递)
        retry_count = properties.headers.get('x-retry-count', 0)
        
        print(f" [x] Processing (retry={retry_count}): {body.decode()}")
        process_message(body)  # 可能抛出异常
        
        ch.basic_ack(delivery_tag=method.delivery_tag)
        
    except Exception as e:
        retry_count += 1
        if retry_count <= 3:
            # 重新入队,增加重试计数
            ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
            # 👇 但 pika 不支持直接修改 headers,需重新 publish(见下文优化)
        else:
            # 超过重试次数,拒绝且不 requeue → 进 DLX
            print(f" [!] Max retries exceeded. Sending to DLX.")
            ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

虽然RabbitMQ不允许修改消息的headers和内容,但是可以通过消费者处理后重新发布来修改headers和内容。

优化方案:用"重试队列"中转(推荐)

复制代码
[main_queue] 
   ↓ 失败
[retry_queue_1] --(TTL=5s)--> [main_queue]
   ↓ 失败
[retry_queue_2] --(TTL=10s)--> [main_queue]
   ↓ 失败
[dlx] ← 最终进入死信队列

🧪 场景二:订单超时自动关闭(TTL + DLX)

这是最经典的 DLX 应用!

复制代码
[order_queue] 
  - x-message-ttl: 1800000 (30分钟)
  - x-dead-letter-exchange: dlx
  - x-dead-letter-routing-key: order.timeout

[dlx] → [order_timeout_queue] → [TimeoutHandler]
python 复制代码
# 声明 DLX 和死信队列
channel.exchange_declare(exchange='dlx', exchange_type='direct')
channel.queue_declare(queue='order_timeout_queue')
channel.queue_bind(exchange='dlx', queue='order_timeout_queue', routing_key='order.timeout')

# 声明主队列(带 TTL + DLX)
channel.queue_declare(
    queue='order_queue',
    arguments={
        'x-message-ttl': 1800000,
        'x-dead-letter-exchange': 'dlx',
        'x-dead-letter-routing-key': 'order.timeout'
    }
)

# 发送订单消息(30分钟内未支付则超时)
channel.basic_publish(
    exchange='',
    routing_key='order_queue',
    body='order_12345'
)

🧪 场景三:队列满员后丢弃旧消息(x-max-length + DLX)

某些场景只关心最新消息(如股票行情、设备状态),旧消息可丢弃。

python 复制代码
channel.queue_declare(
    queue='latest_status',
    arguments={
        'x-max-length': 10,                     # 最多存 10 条
        'x-overflow': 'reject-publish-dlx',     # 溢出时发到 DLX
        'x-dead-letter-exchange': 'dlx'
    }
)

💡 x-overflow 默认是 drop-head(丢弃队首),设为 reject-publish-dlx 才会进 DLX。

🔍 死信消息的元数据

进入 DLX 的消息会自动添加 header,帮助你诊断问题:

Header 含义
x-death 包含死信原因、原队列、重试次数等(数组)
py 复制代码
{
  'x-death': [
    {
      'reason': 'expired',           # expired / rejected / maxlen
      'queue': 'order_queue',
      'time': '2026-02-11 10:00:00',
      'exchange': '',
      'routing-keys': ['order_queue']
    }
  ]
}

# 消费者可读取这些信息做不同处理:
def dlx_callback(ch, method, properties, body):
    death_info = properties.headers.get('x-death', [])
    if death_info:
        reason = death_info[0]['reason']
        if reason == 'expired':
            handle_timeout(body)
        elif reason == 'rejected':
            handle_poison_message(body)

⚠️ 最佳实践与注意事项

  1. 死信队列也要监控!
    • 死信队列可能堆积,需单独监控和告警
    • 建议设置死信队列的 TTL,避免永久堆积
  2. 避免 DLX 循环
    • 死信队列不能再绑定 DLX(否则死信的死信...)
    • 或确保死信消费者不会再次 reject
  3. DLX 不是万能的
    • 如果消息只是"暂时失败"(如 DB 瞬时抖动),应优先用重试机制
    • DLX 适合确定性失败或业务规则触发(如超时)
  4. 性能影响
    • 消息转发到 DLX 是同步操作,会略微增加主队列处理延迟

✅ 总结:DLX 的三大触发条件

触发条件 配置方式 典型场景
消息被拒绝(requeue=false) 消费者调用 basic_nack(requeue=False) 毒药消息、校验失败
消息 TTL 过期 队列或消息设置 x-message-ttl / expiration 订单超时、验证码失效
队列达到最大长度 队列设置 x-max-length + x-overflow=reject-publish-dlx 保留最新状态

📌 记住:DLX 不是"丢弃",而是"转移"。它让异常消息可见、可处理、可追溯。

Lazy Queue 深度解析:海量消息堆积的磁盘优化方案

在构建高可靠消息系统时,我们常面临一个矛盾:

"既要消息不丢(持久化),又要高性能(内存操作)"

默认情况下,RabbitMQ 会将所有未确认的消息缓存在内存中。

当消息生产速度 >> 消费速度时(如大促期间、消费者故障),内存很快耗尽,导致:

  • Broker 崩溃
  • 消息被阻塞(Publisher Flow Control)
  • 整个系统雪崩

为解决这个问题,RabbitMQ 3.6+ 引入了 Lazy Queue(惰性队列) ------ 一种以磁盘为中心的队列模式,专为海量消息堆积场景设计。

🧠 核心思想:从"内存优先"到"磁盘优先"

队列类型 消息存储位置 内存占用 适用场景
Classic Queue(经典队列) 内存 + 磁盘(异步刷盘) 高(消息越多,内存越大) 低延迟、高吞吐
Lazy Queue(惰性队列) 磁盘为主,仅索引在内存 极低(与消息数量几乎无关) 海量堆积、可靠性优先

🔧 如何启用 Lazy Queue?

方法一:声明队列时指定 x-queue-mode=lazy

py 复制代码
channel.queue_declare(
    queue='lazy_queue',
    arguments={'x-queue-mode': 'lazy'}
)

方法二:通过策略(Policy)全局设置(推荐)

py 复制代码
# 所有以 "bulk." 开头的队列自动变为 lazy
rabbitmqctl set_policy LazyPolicy "^bulk\\." '{"queue-mode":"lazy"}'

📊 性能对比:Lazy vs Classic

假设队列中有 1000 万条 1KB 的消息:

指标 Classic Queue Lazy Queue
内存占用 ~10 GB ~50 MB
启动速度 慢(需加载所有消息到内存) 快(只加载索引)
消费速度 快(内存读取) 稍慢(需磁盘 IO)
Broker 稳定性 容易 OOM 非常稳定

⚙️ Lazy Queue 内部机制

  1. 消息存储格式
    • 使用 Segmented Log(分段日志) 存储消息
    • 类似 Kafka 的 commit log,顺序写入,高效可靠
  2. 索引结构
    • 内存中只保留:
    • 消息 ID 到文件偏移量的映射
    • 待 ACK 的消息列表
    • 消费时,根据索引从磁盘读取消息体
  3. GC(垃圾回收)优化
    • 删除已 ACK 的消息时,标记删除,定期合并 segment 文件
    • 避免频繁小文件删除导致的磁盘碎片

✅ 典型应用场景

场景 为什么适合 Lazy Queue
日志收集/审计 消息量大、实时性要求低、必须持久化
批量数据处理 消费者周期性拉取(如每小时跑批)
备份/同步任务 允许延迟,但不能丢消息
IoT 设备上报 设备离线时消息堆积,上线后慢慢消费

🔁 与 TTL、DLX 的配合

Lazy Queue 完全兼容其他 RabbitMQ 特性:

python 复制代码
channel.queue_declare(
    queue='bulk_order_queue',
    arguments={
        'x-queue-mode': 'lazy',
        'x-message-ttl': 86400000,      # 24小时过期
        'x-dead-letter-exchange': 'dlx' # 超时进死信
    }
)

✅ 总结

  • Lazy Queue = 为海量堆积而生
  • 核心优势:内存占用恒定,避免 OOM
  • 代价:单条消息延迟略高,依赖磁盘性能
  • 适用:可靠性 > 实时性的场景

📌 记住:

"不是所有队列都需要 Lazy,但当你需要时,它能救命。"

在大促、数据迁移、消费者故障等场景下,Lazy Queue 是保障 RabbitMQ 稳定运行的关键防线。

Publisher Confirms 详解:确保消息可靠到达 Broker

在之前的系列中,我们重点解决了 "消息不丢" 的下游问题:

  • 队列和消息持久化(防 Broker 重启丢失)
  • 手动 ACK + Fair Dispatch(防消费者崩溃丢失)
  • DLX + TTL(处理异常和超时)

但还有一个关键环节没覆盖:

"生产者发出去的消息,真的到达 RabbitMQ 了吗?"

默认情况下,RabbitMQ 不会告诉生产者消息是否成功入队。

如果网络闪断、Broker 崩溃,消息可能静默丢失,而生产者毫不知情。

🧨 问题重现:为什么需要 Publisher Confirms?

默认行为(无确认)的风险

py 复制代码
channel.basic_publish(exchange='', routing_key='task_queue', body='Hello')
# 程序继续执行...
  • 这行代码只是把消息写入本地 TCP 缓冲区
  • 如果此时 RabbitMQ 宕机、网络中断,消息永远丢失
  • 生产者无法感知,以为发送成功

💥 后果:用户点击"下单",系统显示成功,但订单消息根本没进队列!

✅ 解决方案:Publisher Confirms

  • RabbitMQ 3.2+ 提供了 Publisher Confirms 机制:

Broker 收到消息并持久化后,会发送一个 ack 给生产者。

如果消息丢失(如队列不存在、磁盘满),则发送 nack。

这类似于 TCP 的 ACK 机制,但工作在应用层。

🔧 启用与使用方式

步骤 1:开启 Confirm 模式

py 复制代码
channel.confirm_delivery()  # ← 关键!开启 confirm 模式

⚠️ 必须在 basic_publish 之前调用,且每个 channel 只能开一次。

步骤 2:发送消息(同步等待确认)

py 复制代码
try:
    channel.basic_publish(
        exchange='',
        routing_key='task_queue',
        body=message,
        properties=pika.BasicProperties(delivery_mode=2)  # 持久化
    )
    print(" [x] Message confirmed by broker")
except pika.exceptions.UnroutableError:
    print(" [!] Message could not be routed (e.g., queue doesn't exist)")
except Exception as e:
    print(f" [!] Failed to send message: {e}")
  • 如果 Broker 在合理时间内(默认无超时)未确认,basic_publish 会抛出异常
  • 注意:confirm_delivery() 默认是同步阻塞的!

🔄 异步 Confirm:高性能场景必备

同步 confirm 虽然简单,但吞吐量低(每条消息都要等 ACK)。

生产环境通常用 异步 confirm + 批量确认 提升性能。

异步 Confirm 实现(带重试)

python 复制代码
import pika
import threading
import logging
import queue
import time

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class AsyncPublisher:

    def __init__(self, host='localhost', port=5672,
                 username='admin', password='admin'):

        self.host = host
        self.port = port
        self.credentials = pika.PlainCredentials(username, password)

        self._connection = None
        self._channel = None
        self._closing = False

        # 线程安全发布队列
        self._publish_queue = queue.Queue()

        # 未确认消息(使用 broker delivery_tag)
        self._unconfirmed = {}

        self._io_thread = None

    # ===============================
    # 启动 IO 线程
    # ===============================
    def start(self):
        self._io_thread = threading.Thread(
            target=self._run,
            daemon=True
        )
        self._io_thread.start()

    def _run(self):
        parameters = pika.ConnectionParameters(
            host=self.host,
            port=self.port,
            credentials=self.credentials
        )

        self._connection = pika.SelectConnection(
            parameters,
            on_open_callback=self._on_connection_open,
            on_close_callback=self._on_connection_closed
        )

        self._connection.ioloop.start()

    # ===============================
    # 连接 & Channel
    # ===============================
    def _on_connection_open(self, connection):
        logger.info("Connection opened")
        connection.channel(on_open_callback=self._on_channel_open)

    def _on_channel_open(self, channel):
        logger.info("Channel opened")
        self._channel = channel

        # 启用 publisher confirms
        self._channel.confirm_delivery(self._on_delivery_confirmation)

        # 开始消费内部发布队列
        self._connection.ioloop.call_later(0.1, self._process_publish_queue)

    def _on_connection_closed(self, connection, reason):
        logger.warning(f"Connection closed: {reason}")
        if not self._closing:
            time.sleep(5)
            self.start()

    # ===============================
    # 线程安全发布接口
    # ===============================
    def publish(self, message: str,
                exchange='',
                routing_key='reliable_queue'):

        self._publish_queue.put((exchange, routing_key, message))

    # ===============================
    # IO线程处理发布
    # ===============================
    def _process_publish_queue(self):

        while not self._publish_queue.empty():
            exchange, routing_key, message = self._publish_queue.get()

            properties = pika.BasicProperties(delivery_mode=2)

            try:
                self._channel.basic_publish(
                    exchange=exchange,
                    routing_key=routing_key,
                    body=message.encode(),
                    properties=properties
                )

                # 获取当前 delivery_tag
                tag = self._channel.get_next_publish_seq_no()
                self._unconfirmed[tag] = message

                logger.info(f"📤 Published (tag={tag}): {message}")

            except Exception as e:
                logger.exception(f"Publish failed: {e}")

        if not self._closing:
            self._connection.ioloop.call_later(
                0.1,
                self._process_publish_queue
            )

    # ===============================
    # Confirm 回调(真正异步)
    # ===============================
    def _on_delivery_confirmation(self, method_frame):

        confirmation_type = method_frame.method.NAME.split('.')[1].lower()
        delivery_tag = method_frame.method.delivery_tag
        multiple = method_frame.method.multiple

        if multiple:
            tags = [tag for tag in self._unconfirmed
                    if tag <= delivery_tag]
        else:
            tags = [delivery_tag]

        for tag in tags:
            msg = self._unconfirmed.pop(tag, None)
            if not msg:
                continue

            if confirmation_type == 'ack':
                logger.info(f"✅ Confirmed (tag={tag}): {msg}")
            else:
                logger.error(f"❌ Nacked (tag={tag}): {msg}")
                # TODO: 重试逻辑

    # ===============================
    # 停止
    # ===============================
    def stop(self):
        self._closing = True

        if self._connection:
            self._connection.ioloop.stop()

        logger.info("AsyncPublisher stopped.")


# ===============================
# 示例
# ===============================
if __name__ == "__main__":

    publisher = AsyncPublisher()
    publisher.start()

    time.sleep(2)  # 等待连接建立

    for i in range(5):
        publisher.publish(f"Message-{i}")
        time.sleep(0.2)

    time.sleep(5)
    publisher.stop()

消息去重(Message Deduplication)详解:如何防止重复消费

在分布式系统中,消息重复是常态,而非异常。

即使你用了手动 ACK、持久化、Publisher Confirms,依然可能收到重复消息:

  • 消费者处理完但 ACK 丢失 → RabbitMQ 重发
  • 生产者超时重试 → 发了两次
  • 网络抖动导致重复投递

如果业务不具备幂等性(如"扣款 100 元"),重复消费会导致:

用户被扣两次钱!库存减成负数!

🔄 为什么 RabbitMQ 不自带去重?

RabbitMQ 的设计哲学是:"快速可靠地传递消息",而不是"保证 exactly-once"。

  • 去重要消耗额外资源(内存/磁盘存储 ID)
  • 大多数场景可通过业务幂等解决
  • 因此,官方不提供内置去重机制(直到插件出现)

✅ 方案一:业务层幂等(推荐!最常用)

核心思想:让操作"多次执行 = 一次执行"

实现方式:

  1. 数据库唯一约束
sql 复制代码
CREATE TABLE order_events (
    event_id VARCHAR(64) PRIMARY KEY,  -- 消息 ID
    order_id VARCHAR(32),
    status VARCHAR(20)
);
  • 消费时先 INSERT,失败则跳过
  • 简单、高效、强一致
  1. Redis 记录已处理 ID(带 TTL)
py 复制代码
def process_message(message):
    msg_id = message['id']
    if redis.set(msg_id, "1", nx=True, ex=3600):  # 不存在才设,1小时过期
        do_business_logic()
    else:
        print("Duplicate message ignored")
  1. 状态机校验
    • 订单状态:created → paid → shipped
    • 收到 paid 事件时,检查当前状态是否为 created
    • 如果已是 paid,直接忽略

方案二:使用 RabbitMQ 插件(rabbitmq_message_deduplication)

如果你无法修改业务代码,或需要全局去重,可用官方插件。

插件原理:

  • 在队列层面维护一个 Bloom Filter 或 Set 存储已见 Message ID
  • 新消息若 ID 已存在,直接丢弃
shell 复制代码
# 启用去重插件
rabbitmq-plugins enable rabbitmq_message_deduplication

声明去重队列:

py 复制代码
channel.queue_declare(
    queue='dedup_queue',
    arguments={
        'x-message-deduplication': True,
        'x-deduplication-header-name': 'x-deduplication-header',  # 指定 ID 的 header 名
        'x-deduplication-timeout': 60000  # ID 保留时间(毫秒)
    }
)

生产者送带ID的消息

py 复制代码
channel.basic_publish(
    exchange='',
    routing_key='dedup_queue',
    body=message,
    properties=pika.BasicProperties(
        headers={'x-deduplication-header': 'msg_12345'}  # ← 去重依据
    )
)

⚠️ 插件方案的注意事项

问题 说明
内存占用 去重 ID 存内存,大量唯一 ID 可能 OOM
集群支持 去重状态不跨节点同步!只在声明队列的节点生效
持久化 默认不持久化去重状态,Broker 重启后失效
性能 Bloom Filter 快,但有极低误判率;Set 准确但更耗内存

📌 建议:仅用于小规模、单节点场景,或作为业务幂等的补充。

实验:验证业务幂等 vs 插件去重
场景:模拟重复消息

  1. 业务幂等方案:
    • 发送两条 event_id="evt_001" 的消息
    • 消费者通过 DB 唯一索引拦截第二条
    • 日志显示:"Duplicate key error → ignore"
  2. 插件方案:
    • 发送两条带相同 x-deduplication-header 的消息
    • 第二条根本不会进入队列
    • RabbitMQ 管理界面显示:消息数只 +1

✅ 对比:

  • 业务幂等:消息进了队列,但被逻辑忽略(可监控)
  • 插件去重:消息被 Broker 拦截(对消费者透明)
相关推荐
ssswywywht1 小时前
python练习
开发语言·python
BingoGo1 小时前
PHP 的问题不在语言本身,而在我们怎么写它
后端·php
喵手1 小时前
Python爬虫实战:医院科室排班智能采集系统 - 从零构建合规且高效的医疗信息爬虫(附CSV导出 + SQLite持久化存储)!
爬虫·python·爬虫实战·零基础python爬虫教学·医院科室排版智能采集系统·采集医疗信息·采集医疗信息sqlite存储
qq_256247052 小时前
Copilot “Plan Mode“ + 多模型协同实战:让复杂项目开发丝滑起飞
后端
郝学胜-神的一滴2 小时前
贝叶斯之美:从公式到朴素贝叶斯算法的实践之旅
人工智能·python·算法·机器学习·scikit-learn
计算机学姐2 小时前
基于SpringBoot的药房管理系统【个性化推荐+数据可视化】
java·spring boot·后端·mysql·spring·信息可视化·java-ee
codeGoogle2 小时前
2026 年 IM 怎么选?聊聊 4 家主流即时通讯方案的差异
android·前端·后端
好家伙VCC2 小时前
**发散创新:用 Rust构建多智能体系统,让分布式协作更高效**在人工智能快速演进的今天,**多智能体系统(
java·人工智能·分布式·python·rust
梦幻精灵_cq2 小时前
*终端渲染天花板:文心道法解码——闲聊终端渲染状态一统江山
python