消息幂等:如何保证消息不被重复消费?

应用的幂等是在分布式系统设计时必须要考虑的一个方面,如果对幂等没有额外的考虑,那么在消息失败重新投递,或者远程服务重试时,可能会出现许多诡异的问题。本文一起来看一下,在消息队列应用中,如何处理因为重复投递等原因导致的幂等问题。

对业务幂等的理解

首先明确一下,幂等并不是问题,而是业务的一个特性。幂等问题体现在对于不满足幂等性的业务,在消息重复消费,或者远程服务调用失败重试时,出现的数据不一致,业务数据错乱等现象。

幂等最早是一个数学上的概念,幂等函数指的是对一个函数或者方法,使用相同的参数执行多次,数据结果是一致的。

以 HTTP 协议为例,我们知道 HTTP 协议中定义了交互的不同方法,比如 GET 和 POST,以及 PUT、DELETE 等,其中 GET、DELETE 等方法都是幂等的,而 POST 方法不是。

这个很好理解,GET 方法用于获取资源,不管调用多少次接口,结果都不会改变,所以是幂等的,DELETE 等可以类比。

这里有一点需要注意,业务上的幂等指的是操作不影响资源本身,并不是每次读取的结果都保证一致。比如通过 GET 接口查询一条订单记录,在多次查询的时间段内,订单状态可能会有新的更新而发生变化,查询到的数据可能不同,但是读接口本身仍然是一个幂等的操作。

在业务开发中对数据的操作主要是 CRUD,即在做数据处理时的 Create、Read、Update、Delete 这几种操作。很明显,这里的 Create 操作不是幂等的,Update 操作可能幂等也可能不幂等。例如,现在有一个订单表,下面的操作就是幂等的:

UPDATE order SET status=1 WHERE id=100

下面的这个操作,就不符合幂等性的要求:

UPDATE order SET price=price+1 WHERE id=100

对应的,Read 和 Delete 操作则是幂等的。

各类中间件对幂等性的处理

幂等处理不好,可能会出现很多问题,比如使用 binlog 分发进行数据同步,如果数据库更新消息被多次消费,可能会导致数据的不一致。

  • 远程服务调用的幂等问题

因为存在网络抖动等,远程服务调用出现失败,一般是通过配置重试,保证请求调用成功率,提高整体服务的可用性。

以 Apache Dubbo 为例,我一直觉得 Dubbo 对容错的支持特别全面,它支持多种集群容错的方式,并且可以针对业务特性,配置不同的失败重试机制,包括 Failover 失败自动切换、Failsafe 失败安全、Failfast 快速失败等。比如在 Failover 下,失败会重试两次;在 Failfast 下,失败则不会重试,直接抛出异常。

Dubbo 的容错机制考虑了多种业务场景的需求,根据不同的业务场景,可以选择不同的容错机制,进而有不同的重试策略,保证业务正确性。

Dubbo RPC 的重试和容错机制不是本课时的重点,如果想对 Dubbo 集群容错方式有进一步的了解,可以点击查看 Dubbo 官方文档

  • 消息消费中的重试问题

从本质上来讲,消息队列的消息发送重试,和微服务中的失败调用重试是一样的,都是通过重试的方式,解决网络抖动、传输不稳定等导致的偶发调用失败。这两者其实是一个问题,两个问题的解决方式也可以互相借鉴。

在分布式系统中,要解决这个问题,需从中间件和业务的不同层面,来保证服务调用的幂等性。下面从消息队列投递语义,以及业务中如何处理幂等,两个方面进行拆解。

消息投递的几种语义

为了进一步规范消息的调用,业界有许多消息队列的应用协议,其中也对消息投递标准做了一些约束。

  • At most once

消息在传递时,最多会被送达一次,在这种场景下,消息可能会丢,但绝不会重复传输,一般用于对消息可靠性没有太高要求的场景,比如一些允许数据丢失的日志报表、监控信息等。

  • At least once

消息在传递时,至少会被送达一次,在这种情况下,消息绝不会丢,但可能会出现重复传输。

绝大多数应用中,都是使用至少投递一次这种方式,同时,大部分消息队列都支持到这个级别,应用最广泛。

  • Exactly once

每条消息肯定会被传输一次且仅传输一次,并且保证送达,因为涉及发送端和生产端的各种协同机制,绝对的 Exactly once 级别是很难实现的,通用的 Exactly once 方案几乎不可能存在,可以参考分布式系统的「FLP 不可能定理」。

我觉得消息投递的语义,和数据库的隔离级别很像,不同语义的实现,付出的成本也不一样。上面定义的消息投递语义,主要在消息发送端,在消费端也可以定义类似的消费语义,比如消费端保证最多被消费一次,至少被消费一次等,这两种语义是相对应的,可以认为是同一个级别的两种描述。

不同消息队列支持的投递方式

以 RocketMQ 为例,我们来看下对应的投递支持。

RocketMQ 支持 At least once 的投递语义,也就是保证每个消息至少被投递一次。在 RocketMQ 中,是通过消费端消费的 ACK 机制来实现的:

在消息消费过程中,消费端在消息消费完成后,才返回 ACK,如果消息已经 pull 到本地,但还没有消费,则不会返回 ack 响应。

在业务上应用 RcoketMQ 时,也可以根据不同的业务场景实现其他级别的投递语义,比如最多送达一次等,由于篇幅限制这里不展开详细讲解了,感兴趣的同学可以查阅 RocketMQ 相关的源码和文档学习。

业务上如何处理幂等

消息消费的幂等和我们在上一课时中提到的时序性一样,本质上也是一个系统设计的问题。

消息队列是我们为了实现系统目标而引入的手段之一,并且分布式消息队列天然存在消费时序、消息失败重发等问题。所以要保证消息队列的消费幂等,还是要回到业务中,结合具体的设计方案解决。

天然幂等不需要额外设计

参考上面对 HTTP 协议方法的幂等性分析,有部分业务是天然幂等的,这部分业务,允许重复调用,即允许重试,在配置消息队列时,还可以通过合理的重试,来提高请求的成功率。

利用数据库进行去重

业务上的幂等操作可以添加一个过滤的数据库,比如设置一个去重表,也可以在数据库中通过唯一索引来去重。

举一个例子,现在要根据订单流转的消息在数据库中写一张订单 Log 表,我们可以把订单 ID 和修改时间戳做一个唯一索引进行约束。

当消费端消费消息出现重复投递时,会多次去订单 Log 表中进行写入,由于我们添加了唯一索引,除了第一条之外,后面的都会失败,这就从业务上保证了幂等,即使消费多次,也不会影响最终的数据结果。

设置全局唯一消息 ID 或者任务 ID

还记得我们在第 15 课时「分布式调用链跟踪」中,提到的调用链 ID 吗?调用链 ID 也可以应用在这里。我们在消息投递时,给每条业务消息附加一个唯一的消息 ID,然后就可以在消费端利用类似分布式锁的机制,实现唯一性的消费。

还是用上面记录订单状态流转消息的例子,我们在每条消息中添加一个唯一 ID,消息被消费后,在缓存中设置一个 Key 为对应的唯一 ID,代表数据已经被消费,当其他的消费端去消费时,就可以根据这条记录,来判断是否已经处理过。

总结

本文分享了消息幂等的知识点,包括对幂等的理解,以及消息队列投递时的不同语义,另外简单介绍了业务上处理幂等的两种方式。

西方有一句谚语:当你有了一个锤子,你看什么都像钉子。在我刚开始学习分布式系统时,学习了各种中间件,每个中间件都希望能用上,这其实脱离了系统设计的初衷。

到这里,已经展开了许多分布式系统的常用组件,提到这个谚语,主要是希望你在做技术方案,特别是做分布式系统设计方案时,不是为了设计而设计。方案设计的目的是实现业务目标,并不是在系统中加入各种高大上的中间件,这个方案就是正确的。

我之前读过一本《系统之美》的图书,从复杂系统的角度来看,系统中的元素越多,为了维持系统的平衡,需要付出的势能必然也越大。

对应到系统设计中,系统拆解的粒度越大,对应各个组件之间的耦合就越小,但是需要解决的组件协同问题也越多,实现数据的一致性也越困难。我们在系统设计时,要避免过度设计,把握技术方案的核心目的,在这个基础上进行针对性设计。

对于本文的内容,你可以思考下当前的项目中是如何处理重复消息的,有没有考虑消息处理的幂等性?欢迎留言分享。

<think>嗯,用户之前问过如何保证RabbitMQ消息的顺序性,现在又问如何避免消息重复消费。看来他们可能是在设计一个消息系统,需要处理消息的顺序和重复这两个常见问题。用户可能是在实际项目中遇到了消息重复的情况,导致系统状态一致或者其他问题,所以需要解决方案。 首先,我需要回忆一下消息队列重复消费的原因。通常,消息队列提供至少一次(at-least-once)的投递语义,比如RabbitMQ的ACK机制。如果消费者处理消息后没有正确确认,或者在确认前发生故障,消息会被重新投递,导致重复消费。此外,生产者的重试机制也可能导致消息重复发送。 接下来,用户的需求是保证消息重复消费。可能的解决方案包括幂等性设计、去重表、分布式锁、消息全局ID等。需要把这些方案整理清楚,同时考虑同场景下的适用性。 要分析用户可能的背景。他们可能在使用RabbitMQ或者类似的消息中间件,比如Kafka。虽然问题没有特指哪个系统,但之前的提问是关于RabbitMQ的,所以可能需要结合RabbitMQ的特性来回答。过,重复消费的问题在大多数消息系统中都存在,解决方案也有共通之处。 用户可能需要的仅是技术方案,还有如何在实际中实现这些方案,比如代码示例或者配置建议。同时,他们可能关心这些方案对系统性能的影响,以及如何权衡可靠性和性能。 需要分点回答,结构清晰。首先解释重复消费的原因,然后列举解决方案,每个方案的具体实现方法和适用场景。最后给出总结,帮助用户根据具体情况选择合适的方法。 还要注意是否提到了业务层面的处理,比如状态机或版本号,因为技术手段可能需要结合业务逻辑才能达到最佳效果。另外,是否需要考虑消息中间件本身的特性,比如RabbitMQ消息确认机制,或者Kafka的offset管理。 可能用户还希望了解如何在高并发场景下高效地去重,比如使用Redis的原子操作或者布隆过滤器。同时,分布式锁的实现细节和注意事项,比如锁的超时时间和续期问题。 需要确保回答覆盖了常见的方法,并且给出实际例子,比如使用数据库唯一索引或Redis的SETNX命令。同时提醒用户注意每种方案的局限性和适用条件,比如去重表可能带来的数据库压力,或者分布式锁的性能开销。 最后,总结时强调没有银弹,需要根据业务场景选择组合同的方案,可能还需要结合业务本身的幂等性设计来达到最佳效果。</think>在分布式系统中,由于网络重试、消费者故障恢复或消息队列的 **at-least-once** 投递语义,消息重复消费是常见问题。以下是保证消息重复消费的核心方案: --- ### **1. 幂等性设计(核心手段)** - **原理**:业务逻辑设计成多次执行结果一致。 - **实现方式**: - **数据库唯一约束**:通过唯一索引(如订单ID)避免重复写入。 - **版本号机制**:更新数据时校验版本号(`UPDATE table SET value=?, version=version+1 WHERE id=? AND version=?`)。 - **状态机校验**:仅允许状态按预设流程变更(如“已支付”订单重复支付)。 --- ### **2. 消息去重表** - **实现**: 1. 消费者处理前,先查表判断消息是否已处理。 2. 若未处理,执行业务并记录消息ID到去重表。 - **优化**: - 使用 Redis 或内存缓存加速查询(需设置过期时间)。 - 结合数据库唯一索引兜底(如 `message_id` + `topic` 联合唯一键)。 --- ### **3. 消息全局唯一ID** - **生成方式**: - **业务标识**:时间戳+业务ID(如订单号)。 - **分布式ID生成器**:Snowflake算法、Redis原子自增等。 - **使用**:生产者为每条消息生成唯一ID,消费者通过ID去重。 --- ### **4. 消息队列特性控制** - **RabbitMQ**: - 关闭 `autoAck`,手动确认(`basicAck`)**业务完成后**再发送ACK。 - 结合 `deliveryTag` 确保每条消息仅确认一次。 - **Kafka**: - 维护消费者 Offset,确保故障恢复后从正确位置消费。 - 启用事务消息(Producer端)和等Producer(`enable.idempotence=true`)。 --- ### **5. 分布式锁** - **实现**:处理消息前尝试获取锁(如 Redis 的 `SETNX` 或 ZooKeeper 临时节点)。 ```java // Redis 伪代码示例 String lockKey = "msg_lock:" + messageId; if (redis.setnx(lockKey, "1")) { redis.expire(lockKey, 30); // 避免死锁 try { processMessage(); } finally { redis.del(lockKey); } } ``` - **注意**:需处理锁超时、续期和原子性问题(推荐 Redisson 等成熟库)。 --- ### **6. 业务补偿机制** - **最终一致性兜底**: - 对账系统:定期扫描业务数据与消息记录,修复一致。 - 消息轨迹追踪:记录消息状态(发送/消费成功/失败),便于排查。 --| | **高频低敏感** | 内存去重+过期清理 | 实时日志处理 | | **低频高敏感** | 数据库唯一约束 | 支付订单创建 | | **高并发场景** | Redis原子操作 | 秒杀库存扣减 | | **强一致性需求** | 分布式锁+等设计 | 金融转账 | --- ### **注意事项** 1. **去重粒度**:根据业务决定按消息ID、业务ID还是组合键去重。 2. **存储成本**:去重表需定期清理历史数据(如 TTL 或归档)。 3. **性能权衡**:同步校验可能成为瓶颈,可结合异步对账补偿。 4. **中间件差异**:Kafka分区内有序消息天然适合配合状态机实现等。 --- 通过 **幂等性设计** + **去重技术** + **业务补偿** 三层防护,可有效解决99%的重复消费问题。最终方案需根据业务容忍度、数据量和性能要求综合选择。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小小哭包

创作不易,给作者加个鸡腿吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值