06 | 如何处理消费过程中的重复消息

文章探讨了消息队列中由于重试可能导致的重复消息问题,指出大部分消息队列如rabbitmq, rocketmq, kafka提供的是至少一次(Atleastonce)而非恰好一次(Exactlyonce)的消息传递保证。为应对这种情况,提出了通过幂等性设计消费端,确保重复消费不会影响系统数据的正确性。介绍了三种实现幂等性的方法:利用数据库唯一约束、设置前置条件和记录检查操作,并强调这些方法同样适用于处理其他场景中的重复请求。最后,讨论了为何大多数消息队列不选择Exactlyonce的原因,主要是实现难度和技术复杂性。

在消息传递过程中,出现传递失败情况,发送方会重试,重试会导致产生重复消息。

因此使用消息队列的业务系统若没对重复消息处理,就可能会导致系统数据出错。

比如消费订单消息,统计下单金额的服务,就会出现重复统计,导致统计结果出错。

可能有人会问,如果消息队列本身能保证消息不重复,应用程序实现不就简单了。那有消息队列能保证这点么?

消息重复的情况必然存在

MQTT协议中,给出了三种传递消息时能够提供的服务质量标准。质量从低到高,

At most once:最多一次,无消息可靠性,允许丢消息。一般对消息可靠性要求不高的监控场景使用。比如每分钟上报一次机房温度。

At least once:至少一次,不允许丢消息。但允许少量重复出现。

Exactly once:恰好一次,消息不允许丢,也不允许重复,最高等级。

对多有消息队列都是适用的,这个服务质量标准不仅适用于MQTT。现在常用绝大部分消息队列提供的服务质量都是At least once,包括 rabbitmq, rocketmq, kafka。也就是消息队列很难保证消息不重复。

注意,kafka的exactly once是它的事务中支持的一个特性。

既然按照标准大部分消息队列无法保证消息不重复,就需要消费代码能够接受消息是可能重复的这一个现状,然后通过一些方法来消除重复消息对业务的影响。

用幂等性解决重复消费问题

一般解决重复消息的办法是,在消费端的消费消息的操作具备幂等性。

Idempotence: 如果函数f(x) 满足 f(x) = f(f(x)), f(x)满足幂等性。

这个概念拓展到计算机领域,被用来描述一个操作、方法或者服务。

一个幂等的方法,使用同样的参数,对它进行多次调用和一次调用,对系统产生的影响是一样的。

比如设置某个账户余额=100元是幂等的,但是设置某个账户余额+100元不是。执行多次和一次对系统的影响是不一样的。

从对系统结果来说:At least once + 幂等消费 = Exactly once。

设计幂等操作的方法

1. 利用数据库的唯一约束实现幂等

刚刚提及的不具备幂等特性的转账的例子:将账户X的余额+100,改为限定每个账单每个账户只可以执行一次变更操作。

具体在分布式系统中上述限制方法较多:

方法一:建一张流水表,表有3个字段:转账单ID、账户ID、变更金额。uniq key(转账单ID、账户ID)

不仅关系DB,只要支持INSERT IF NOT EXIST 语义的存储系统都可以实现。比如Redis的SETNX命令。

2. 为更新的数据设置前置条件

以前述账户余额+100为例,改为当账户余额为500的时候+100,就保证了幂等性。

有时候更新字段并不是数字,则可以加一个版本号字段,同时对版本号进行判断和递增。

3. 记录并检查操作

通用性最强,适用范围最广的实现幂等性的方法。

在执行数据更新操作前,先检查一下是否执行过这个更新操作。

具体的实现方法是,在发送消息时,每条消息执行全局唯一ID,消费时,先根据这个ID检查这条消息,没被消费过,再执行数据更新,再讲消息状态置为已消费。

分布式系统中,要做到这点不简单。

1. 全局唯一ID的生成

2. 检查消费状态,更新数据,更新消费状态,三个操作的原子性;

第一个点,在分布式系统中,很难找到一个简单、高可用、高性能的实现方案。

第二个点, 在分布式系统中,可以用分布式事务或者分布式锁,但都不是容易的事。

小结

主要介绍通过幂等来解决消息重复消费的问题。

实现幂等的方案主要有,利用数据库的唯一性约束,为数据更新设置一次性的前置条件,记录并检查操作。

这些实现幂等的方法,不仅可以用于重复消息的问题,也适用于,

在其他场景中来解决重复请求或者重复调用的问题。

比如HTTP服务;

APP或前端重复提交表单;

也可以将微服务设计成幂等,解决RPC框架自动重试导致的重复调用问题;

思考

为什么大部分消息队列都只是 at least once,而不是excatly once的实现呢?

<think>我们讨论防止RabbitMQ消息重复消费的问题。重复消费通常发生在消费处理消息后未能正确发送ACK,或者消息被重新入队(例如由于消费者连接断开)的情况下。常见的解决方案包括:1.幂等性设计:确保消息处理多次不会影响系统状态。2.消息去重:在消费者端记录已处理消息的ID,避免重复处理。下面我们通过一个示例展示如何实现消息去重。我们使用一个本地缓存(例如字典)来记录已处理消息ID(实际生产环境中可能需要分布式缓存如Redis)。注意:这里为了简单,我们使用内存缓存,但重启后缓存会丢失,因此生产环境需要持久化存储。步骤:1.生产者发送消息时,给每条消息设置一个唯一的message_id(可以是业务主键或UUID)。2.消费者在消费消息时,先检查该message_id是否已经被处理过。如果已处理,则直接ACK,不处理。如果未处理,则处理消息,然后将message_id记录到缓存中,再ACK。代码实现(Python+pika):</think> ### RabbitMQ中防止消息重复消费的解决方案 在分布式系统中,消息重复消费是常见问题,RabbitMQ 主要通过两种机制解决该问题: 1. **消息去重(幂等性设计)** 2. **消费端确认机制管理** --- #### 解决方案一:消息幂等性设计(推荐方案) **原理**:无论消息消费多少次,业务操作的结果保持不变 **实现方式**:在消费端添加唯一标识检查 ```python import pika import redis # 需要redis-py库 # 建立Redis连接作为去重缓存 r = redis.Redis(host='localhost', port=6379, db=0) def process_message(ch, method, properties, body): msg_id = properties.message_id # 检查消息是否已处理 (SETNX保证原子性) if r.setnx(f"msg_{msg_id}", "processed"): # 首次处理执行业务逻辑 print(f"处理消息: {body.decode()}") # TODO: 业务处理逻辑 # 设置过期时间防止存储膨胀 (可选) r.expire(f"msg_{msg_id}", 86400) # 24小时过期 ch.basic_ack(delivery_tag=method.delivery_tag) else: print(f"消息 {msg_id} 已处理过,直接ACK") ch.basic_ack(delivery_tag=method.delivery_tag) # 消费端连接设置 connection = pika.BlockingConnection(pika.ConnectionParameters('localhost')) channel = connection.channel() channel.queue_declare(queue='order_queue') channel.basic_consume(queue='order_queue', on_message_callback=process_message) channel.start_consuming() ``` **代码说明**: 1. 使用Redis的`SETNX`命令实现原子性检测 2. 每个消息携带唯一的`message_id`作为标识 3. 对已处理消息直接ACK避免反复重试 4. 设置过期时间防止缓存膨胀 --- #### 解决方案二:消费端确认管理机制 **原理**:精确控制消息确认时机,避免处理过程中断开连接导致重复投递 ```python def reliable_consumer(): # 连接RabbitMQ connection = pika.BlockingConnection(pika.ConnectionParameters('localhost')) channel = connection.channel() # 启用事务模式(确保业务处理与ACK的原子性) channel.tx_select() try: method, properties, body = channel.basic_get( queue='payment_queue', auto_ack=False # 必须关闭自动ACK ) if not method: return # 1. 业务处理前检查状态 msg_id = properties.message_id if is_processed(msg_id): # 实现同上个方案的去重检查 print("重复消息,直接确认") channel.tx_commit() return # 2. 执行业务处理 process_payment(body) mark_as_processed(msg_id) # 标记已处理 # 3. 成功后手动确认 channel.basic_ack(method.delivery_tag) channel.tx_commit() except Exception as e: print(f"处理失败: {e}") # 业务回滚 + 消息回滚 rollback_payment() channel.tx_rollback() ``` **关键配置**: 1. `auto_ack=False` 关闭自动ACK 2. `channel.tx_select()` 启用事务 3. 业务处理与ACK在事务中原子执行 4. 消费失败时进行业务回滚 --- ### 两种方案的对比 | 方案 | 适用场景 | 优点 | 缺点 | |------|---------|------|------| | **幂等性设计** | 所有业务场景 | 1. 实现简单<br>2. 彻底解决重复消费<br>3. 不依赖消息队列特性 | 需额外存储记录 | | **ACK管理** | 不支持幂等操作的场景 | 1. 不依赖外部存储<br>2. 符合消息队列设计理念 | 1. 实现复杂<br>2. 无法完全避免网络问题导致的重复 | --- ### 生产环境最佳实践 1. **消息标识设计**: ```python # 生产者设置唯一消息ID channel.basic_publish(exchange='', routing_key='order_queue', body=message, properties=pika.BasicProperties( message_id=str(uuid.uuid4()), delivery_mode=2 # 持久化 )) ``` 2. **死信队列设置**: ```python # 声明队列时设置死信交换 args = {"x-dead-letter-exchange": "dlx.exchange"} channel.queue_declare(queue='order_queue', arguments=args) ``` 3. **消费端重试策略**: ```python def handle_error(delivery_tag): if delivery_tag.retry_count < 3: channel.basic_reject(delivery_tag, requeue=True) else: channel.basic_reject(delivery_tag, requeue=False) # 转入死信队列 ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值