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}")
🔍 关键机制解析
- reply_to
- 客户端创建一个临时独占队列(exclusive=True)
- 通过 BasicProperties.reply_to 告诉服务端回复地址
- 服务端直接 routing_key=props.reply_to 发回结果
- correlation_id
- 每次请求生成唯一 ID(如 UUID)
- 服务端原样返回
- 客户端收到消息后,只处理 correlation_id 匹配的响应
✅ 这样即使多个请求并发,也不会混淆结果!
- 客户端"等待"技巧:process_data_events()
- 不使用 start_consuming()(会阻塞)
- 而是用 process_data_events() 非阻塞轮询,检查是否有新消息
- 一旦收到匹配的响应,就退出循环
⚠️ 注意事项
- 性能 vs 可靠性
- RPC over MQ 比 HTTP 延迟更高(多一次队列入队/出队)
- 但更可靠(可持久化、重试、削峰)
- 超时处理(重要!)
- 上面代码没有超时机制,如果服务端挂了,客户端会永远等待
- 生产环境必须加超时:
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)
- 错误处理
- 服务端异常时,应返回错误信息(如 "ERROR: invalid input")
- 客户端需解析响应,区分成功/失败
- 资源清理
- 临时回调队列会在连接关闭时自动删除(因为 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'
)
然后无非是写一个消费者来处理死信队列消息,执行关闭订单、记录失败日志、触发告警等业务逻辑。
⚠️ 重要注意事项
- TTL 是"入队时间"开始计时
- 如果队列很长,前面的消息没处理完,后面的消息即使 TTL 到了也不会立即过期(RabbitMQ 不会扫描整个队列)
- 该消息到达队列头部(即将被消费)时,系统检查其是否已过期, 若过期也会被丢弃
📌 结论:TTL 更适合短队列或高优先级队列
- Queue TTL 的"未使用"定义严格
- 只要有一个消费者连接着(即使没消费),队列就不会被删除
- 必须完全空闲(无消息 + 无消费者)
- TTL 值必须是正整数
- 设为 0 或负数会报错
- 最大值约 49 天(受限于 32 位整数)
死信交换机(DLX)深度实践:让"无法投递"的消息有归宿
实际上,RabbitMQ 会在 三种情况下 将消息变为"死信(Dead Letter)",并转发到 DLX:
- 消息被拒绝(basic.reject / basic.nack)且 requeue=false
- 消息 TTL 过期
- 队列达到最大长度(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)
⚠️ 最佳实践与注意事项
- 死信队列也要监控!
- 死信队列可能堆积,需单独监控和告警
- 建议设置死信队列的 TTL,避免永久堆积
- 避免 DLX 循环
- 死信队列不能再绑定 DLX(否则死信的死信...)
- 或确保死信消费者不会再次 reject
- DLX 不是万能的
- 如果消息只是"暂时失败"(如 DB 瞬时抖动),应优先用重试机制
- DLX 适合确定性失败或业务规则触发(如超时)
- 性能影响
- 消息转发到 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 内部机制
- 消息存储格式
- 使用 Segmented Log(分段日志) 存储消息
- 类似 Kafka 的 commit log,顺序写入,高效可靠
- 索引结构
- 内存中只保留:
- 消息 ID 到文件偏移量的映射
- 待 ACK 的消息列表
- 消费时,根据索引从磁盘读取消息体
- 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)
- 大多数场景可通过业务幂等解决
- 因此,官方不提供内置去重机制(直到插件出现)
✅ 方案一:业务层幂等(推荐!最常用)
核心思想:让操作"多次执行 = 一次执行"
实现方式:
- 数据库唯一约束
sql
CREATE TABLE order_events (
event_id VARCHAR(64) PRIMARY KEY, -- 消息 ID
order_id VARCHAR(32),
status VARCHAR(20)
);
- 消费时先 INSERT,失败则跳过
- 简单、高效、强一致
- 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")
- 状态机校验
- 订单状态: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 插件去重
场景:模拟重复消息
- 业务幂等方案:
- 发送两条 event_id="evt_001" 的消息
- 消费者通过 DB 唯一索引拦截第二条
- 日志显示:"Duplicate key error → ignore"
- 插件方案:
- 发送两条带相同 x-deduplication-header 的消息
- 第二条根本不会进入队列
- RabbitMQ 管理界面显示:消息数只 +1
✅ 对比:
- 业务幂等:消息进了队列,但被逻辑忽略(可监控)
- 插件去重:消息被 Broker 拦截(对消费者透明)