消息队列关于宏观业务方面的几点分析;
全文摘抄自 极客时间——消息队列高手课》
为什么需要消息队列
1.数据同步:包括业务服务之间的业务数据同步(主要是状态)、DB间的数据同步等等
2.异步通知:包括发送IM消息、异步日志、异步短信/邮件(尤其是批量数据)或注册/开启任务
3.信息收集:主要用于数据统计、监控、搜索引擎等等
4.服务解耦:主要用于重构和新设计时,对频繁变动的接口服务进行解耦(通常是被需求给逼的)
5.分布式事务消息:尤其是对数据—致性有要求的异步处理场景
6.削峰:秒杀、限流
如何选择消息队列
RabbitMQ
优点
RabbitMQ一个比较有特色的功能是支持非常灵活的路由配置,和其他消息队列不同的是,它在生产者(Producer)和队列(Queue))之间增加了一个Exchange模块,你可以理解为交换机。
这个Exchange模块的作用和交换机也非常相似,根据配置的路由规则将生产者发出的消息分发到不同的队列中。路由的规则也非常灵活,甚至你可以自己来实现路由规则。基于这个Exchange,可以产生很多的玩儿法,如果你正好需要这个功能,RabbitMQ是个不错的选择。
缺点
第一个问题是,RabbitMQ对消息堆积的支持并不好,在它的设计理念里面,消息队列是一个管道,大量的消息积压是一种不正常的情况,应当尽量去避免。当大量消息积压的时候,会导致RabbitMQ的性能急剧下降。
第二个问题是,RabbitMQ的性能是我们介绍的这几个消息队列中最差的,根据官方给出的测试数据综合我们日常使用的经验,依据硬件配置的不同,它大概每秒钟可以处理几万到十几万条消息。其实,这个性能也足够支撑绝大多数的应用场景了,不过,如果你的应用对消息队列的性能要求非常高,那不要选择 RabbitMQ。
最后一个问题是RabbitMQ使用的编程语言Erlang,这个编程语言不仅是非常小众的语言,更麻烦的是,这个语言的学习曲线非常陡峭。大多数流行的编程语言,比如Java、C/C++、Python和JavaScript,虽然语法、特性有很多的不同,但它们基本的体系结构都是一样的,你只精通一种语言,也很容易学习其他的语言,短时间内即使做不到精通,但至少能达到“会用”的水平。
RocketMQ
优点
RocketMQ对在线业务的响应时延做了很多的优化,大多数情况下可以做到毫秒级的响应,如果你的应用场景很在意响应时延,那应该选择使用RocketMQ.
RocketMQ的性能比 RabbitMQ要高一个数量级,每秒钟大概能处理几十万条消息。
缺点
RocketMQ的一个劣势是,作为国产的消息队列,相比国外的比较流行的同类产品,在国际上还没有那么流行,与周边生态系统的集成和兼容程度要略逊—筹。
Kafka
优点
Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域,几乎所有的相关开源软件系统都会优先支持Kafka。
Kafka使用Scala和Java语言开发,设计上大量使用了批量和异步的思想,这种设计使得Kafka能做到超高的性能。Kafka 的性能,尤其是异步收发的性能,是三者中最好的,但与RocketMQ并没有量级上的差异,大约每秒钟可以处理几十万条消息。
在有足够的客户端并发进行异步批量发送,并且开启压缩的情况下,Kafka 的极限处理能力可以超过每秒2000万条消息。
缺点
但是Kafka这种异步批量的设计带来的问题是,它的同步收发消息的响应时延比较高,因为当客户端发送—条消息的时候,Kafka并不会立即发送出去,而是要等一会儿攒一批再发送,在它的Broker中,很多地方都会使用这种“先攒一波再一起处理”的设计。当你的业务场景中,每秒钟消息数量没有那么多的时候,Kafka 的时延反而会比较高。所以,Kafka不太适合在线业务场景。
Pulsar
Pulsar是一个新兴的开源消息队列产品,最早是由Yahoo开发,目前处于成长期,流行度和成熟度相对没有那么高。与其他消息队列最大的不同是,Pulsar采用存储和计算分离的设计。
选型建议
如果说,消息队列并不是你将要构建系统的主角之一,你对消息队列功能和性能都没有很高的要求,只需要一个开箱即用易于维护的产品,我建议你使用RabbitMQ。
如果你的系统使用消息队列主要场景是处理在线业务,比如在交易系统中用消息队列传递订单,那 RocketMQ的低延迟和金融级的稳定性是你需要的。
如果你需要处理海量的消息,像收集日志、监控信息或是前端的埋点这类数据,或是你的应用场景大量使用了大数据、流计算相关的开源产品,那 Kafka是最适合你的消息队列。、
消息模型:主题和队列有什么区别?
如果你研究过超过一种消息队列产品,你可能已经发现,每种消息队列都有自己的一套消息模型型,像队列(Queue)、主题(Topic)或是分区(Partition)这些名词概念,在每个消息队列模型中都会涉及一些,含义还不太一样。
早期的消息队列,就是按照“队列”的数据结构来设计的。我们一起看下这个图,生产者(Producer)发消息就是入队操作,消费者(Consumer)收消息就是出队也就是删除操作,服务端存放消息的容器自然就称为“队列”。这就是最初的—种消息模型:队列模型。
如果有多个生产者往同一个队列里面发送消息,这个队列中可以消费到的消息,就是这些生产者生产的所有消息的合集。消息的顺序就是这些生产者发送消息的自然顺序。如果有多个消费者接收同一个队列的消息,这些消费者之间实际上是竞争的关系,每个消费者只能收到队列中的一部分消息,也就是说任何—条消息只能被其中的一个消费者收到。
如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例刘,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。这个时候,里个队列励两疋不了需求,一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。
显然这是个比较蠢的做法,同样的—份消息数据被复制到多个队列中会浪费资源,更重要的是,生产者必须知道有多少个消费者。为每个消费者单独发送一份消息,这实际上违背了消息队
列“解耦”这个设计初衷。
为了解决这个问题,演化出了另外一种消息模型:“发布–订阅模型(Publish-SubscribePattern) ”。
在发布–订阅模型中,消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息。
在消息领域的历史上很长的一段时间,队列模式和发布–订阅模式是并存的,有些消息队列同时支持这两种消息模型,比如ActiveMQ。我们仔细对比一下这两种模型,生产者就是发布者,消费者就是订阅者,队列就是主题,并没有本质的区别。它们最大的区别其实就是,一份消息数据能不能被消费多次的问题。
RabbitM的消息模型
这个例外就是RabbitMQ,它是少数依然坚持使用队列模型的产品之一。那它是怎么解决多个消费者的问题呢?你还记得我在上节课中讲到RabbitMQ的一个特色Exchange模块吗?在RabbitMQ中,Exchange位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而是将消息发送给Exchange,由Exchange 上配置的策略来决定将消息投递到哪些队列中。
同一份消息如果需要被多个消费者来消费,需要配置Exchange将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。这也可以变相地实现新发布-订阅模型中,“一份消息数据可以被多个订阅者来多次消费”这样的功能。
RocketMQ的消息模型
讲完了RabbitMQ的消息模型,我们再来看看RocketMQ。RocketMQ使用的消息模型是标准的发布–订阅模型,在RocketMQ的术语表中,生产者、消费者和主题与我在上面讲的发布-订阅模型中的概念是完全—样的。
但是,在RocketMQ也有队列(Queue)这个概念,并且队列在RocketMQ中是一个非常重要的概念,那队列在RocketMQ中的作用是什么呢?这就要从消息队列的消费机制说起。
几乎所有的消息队列产品都使用一种非常朴素的“请求–确认”机制,确保消息不会在传递过程中由于网络或服务器故障丢失。具体的做法也非常简单。在生产端,生产者先将消息发送给服务端,也就是Broker,服务端在收到消息并将消息写入主题或者队列中后,会给生产者发送确认的响应。
如果生产者没有收到服务端的确认或者收到失败的响应,则会重新发送消息;在消费端,消费者在收到消息并完成自己的消费业务逻辑(比如,将数据保存到数据库中)后,也会给服务端发送消费成功的确认,服务端只有收到消费确认后,才认为一条消息被成功消费,否则它会给消费者重新发送这条消息,直到收到对应的消费成功确认。
这个确认机制很好地保证了消息传递过程中的可靠性,但是,引入这个机制在消费端带来了一个不小的问题。什么问题呢?为了确保消息的有序性,在某—条消息被成功消费之前是不能被消费的,否则就会出现消息空洞,违背了有序性这个原则。
也就是说,每个主题在任意时刻,至多只能有一个消费者实例在进行消费,那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。为了解决这个问题,RocketMQ在主题下面增加了队列的概念。
每个主题包含多个队列,通过多个队列来实现多实例并行生产和消费。需要注意的是,RocketMQ只在队列上保证消息的有序性,主题层面是无法保证消息的严格顺序的。
RocketMQ中,订阅者的概念是通过消费组(Consumer Group)来体现的。每个消费组都消费主题中一份完整的消息,不同消费组之间消费进度彼此不受影响,也就是说,一条消息被Consumer Group1消费过,也会再给Consumer Group2消费。
消费组中包含多个消费者,同一个组内的消费者是竞争消费的关系,每个消费者负责消费组内的一部分消息。如果一条消息被消费者Consumer1消费了,那同组的其他消费者就不会再收到这条消息。
在Topic的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要RocketMQ为每个消费组在每个队列上维护一个消费位置(ConsumerOffset),这个位置之前的消息都被消费过,之后的消息都没有被消费过,每成功消费—条消息,消费位置就加—。这个消费位置是非常重要的概念,我们在使用消息队列的时候,丢消息的原因大多是由于消费位置处理不当导致的。
Kafka的消息模型
我们再来看看另一种常见的消息队列Kafka,Kafka的消息模型和RocketMQ是完全一样的,唯一的区别是,在Kafka中,队列这个概念的名称不一样,Kafka 中对应的名称是“分区(Partition) ",含义和功能是没有任何区别的。
消息的队列中的事务
消息队列中的“事务”,主要解决的是消息生产者和消息消费者的数据—致性问题。
依然拿我们熟悉的电商来举个例子。一般来说,用户在电商APP上购物时,先把商品加到购物车里,然后几件商品一起下单,最后支付,完成购物流程,就可以愉快地等待收货了。
这个过程中有一个需要用到消息队列的步骤,订单系统创建订单后,发消息给购物车系统,将已下单的商品从购物车中删除。因为从购物车删除已下单商品这个步骤,并不是用户下单支付这个主要流程中必需的步骤,使用消息队列来异步清理购物车是更加合理的设计。
对于订单系统来说,它创建订单的过程中实际上执行了2个步骤的操作:
1.在订单库中插入—条订单数据,创建订单;
2.发消息给消息队列,消息的内容就是刚刚创建的订单。
购物车系统订阅相应的主题,接收订单创建的消息,然后清理购物车,在购物车中删除订单中的商品。
在分布式系统中,上面提到的这些步骤,任何一个步骤都有可能失败,如果不做任何处理,那就有可能出现订单数据与购物车数据不—致的情况,比如说:
- 创建了订单,没有清理购物车;
- 订单没创建成功,购物车里面的商品却被清掉了。
那我们需要解决的问题可以总结为∶在上述任意步骤都有可能失败的情况下,还要保证订单库和购物车库这两个库的数据─致性。
对于购物车系统收到订单创建成功消息清理购物车这个操作来说,失败的处理比较简单,只要成功执行购物车清理后再提交消费确认即可,如果失败,由于没有提交消费确认,消息队列会自动重试。
消息队列是如何实现分布式事务的?
在实际应用中,比较常见的分布式事务实现有2PC (Two-phase Commit,也叫二阶段提交)、TCC(Try-Confirm-Cancel)和事务消息。每一种实现都有其特定的使用场景,也有各自的问题,都不是完美的解决方案。
事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。比如我们在开始时提到的那个例子,在创建订单后,如果出现短暂的几秒,购物车里的商品没有被及时清空,也不是完全不可接受的,只要最终购物车的数据和订单数据保持—致就可以了。
事务消息需要消息队列提供相应的功能才能实现,Kafka和RocketMQ都提供了事务相关功能。
回到订单和购物车这个例子,我们一起来看下如何用消息队列来实现分布式事务。
首先,订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯—区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。
半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了“要么都成功,要么都失败”的一致性要求。
如果你足够细心,可能已经发现了,这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?对于这个问题,Kafka和RocketMQ给出了⒉种不同的解决方案。
Kafka的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。RocketMQ则给出了另外—种解决方案。
RocketMQ中的分布式事务实现
在RocketMQ中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果Producer也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ的Broker没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。
为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知RocketMQ本地事务是成功还是失败。
在我们这个例子中,反查本地事务的逻辑也很简单,我们只要根据消息中的订单ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ会自动根据事务反查的结果提交或者回滚事务消息。
这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。
综合上面讲的通用事务消息的实现和RocketMQ的事务反查机制,使用RocketMQ事务消息功能实现分布式事务的流程如下图:
如何确保消息不会丢失?
利用消息队列的有序性
我们可以利用消息队列的有序性来验证是否有消息丢失。原理非常简单,在 Producer端,我们给每个发出的消息附加一个连续递增的序号,然后在Consumer端来检查这个序号的连续性。
如果没有消息丢失,Consumer 收到消息的序号必然是连续递增的,或者说收到的消息,其中的序号必然是上一条消息的序号+1。如果检测到序号不连续,那就是丢消息了。还可以通过缺失的序号来确定丢失的是哪条消息,方便进—步排查原因。
大多数消息队列的客户端都支持拦截器机制,你可以利用这个拦截器机制,在Producer '发送消息之前的拦截器中将序号注入到消息中,在Consumer 收到消息的拦截器中检测序号的连续性,这样实现的好处是消息检测的代码不会侵入到你的业务代码中,待你的系统稳定后,也方便将这部分检测的逻辑关闭或者删除。
如果是在一个分布式系统中实现这个检测方法,有几个问题需要你注意。
首先,像Kafka和RocketMQ这样的消息队列,它是不保证在Topic 上的严格顺序的,只能保证分区上的消息是有序的,所以我们在发消息的时候必须要指定分区,并且,在每个分区单独检测消息序是的连续性。
确保消息可靠传递
讲完了检测消息丢失的方法,接下来我们一起来看一下,整个消息从生产到消费的过程中,哪些地方可能会导致丢消息,以及应该如何避免消息丢失。
你可以看下这个图,一条消息从生产到消费完成这个过程,可以划分三个阶段,为了方便描述,我给每个阶段分别起了个名字。
- 生产阶段:在这个阶段,从消息在Producer 创建出来,经过网络传输发送到 Broker端。
- 存储阶段:在这个阶段,消息在Broker端存储,如果是集群,消息会在这个阶段被复制到其他的副本上。
- 消费阶段:在这个阶段,Consumer 从、 Broker上拉取消息,经过网络传输发送到Consumer上。
1.生产阶段
在生产阶段,消息队列通过最常用的请求确认机制,来保证消息的可靠传递:当你的代码调用发消息方法时,消息队列的客户端会把消息发送到 Broker,Broker 收到消息后,会给客户端返回一个确认响应,表明消息已经收到了。客户端收到响应后,完成了一次正常消息的发送。
只要Producer 收到了 Broker的确认响应,就可以保证消息在生产阶段不会丢失。有些消息队列在长时间没收到发送确认响应后,会自动重试,如果重试再失败,就会以返回值或者异常的方式告知用户。
你在编写发送消息代码时,需要注意,正确处理返回值或者捕获异常,就可以保证这个阶段的消息不会丢失。以Kafka为例,我们看一下如何可靠地发送消息:
同步发送时,只要注意捕获异常即可。
try {
RecordMetadata metadata = producer.send(record).get();
System.out.println(”"消息发送成功。");
}catch (Throwable e) {
System.out.println("消息发送失败!");
system.out.println(e);
}
异步发送时,则需要在回调方法里进行检查。这个地方是需要特别注意的,很多丢消息的原因就是,我们使用了异步发送,却没有在回调中检查发送结果。
producer.send(record,(metadata, exception) -> {
if(metadata != null) {
System.out.println("消息发送成功。");
}else if{
System.out.println("消息发送失败!");
System.out.println(exception) ; }
});
2.存储阶段
在存储阶段正常情况下,只要Broker在正常运行,就不会出现丢失消息的问题,但是如果Broker 出现了故障,比如进程死掉了或者服务器宕机了,还是可能会丢失消息的。
如果对消息的可靠性要求非常高,可以通过配置Broker参数来避免因为宕机丢消息。
对于单个节点的Broker,需要配置Broker参数,在收到消息后,将消息写入磁盘后再给Producer返回确认响应,这样即使发生宕机,由于消息已经被写入磁盘,就不会丢失消息,恢复后还可以继续消费。例如,在RocketMQ中,需要将刷盘方式 flushDiskType 配置为SYNC_FLUSH同步刷盘。
如果是Broker是由多个节点组成的集群,需要将Broker集群配置成:至少将消息发送到2个以上的节点,再给客户端回复发送确认响应。这样当某个Broker宕机时,其他的 Broker可以替代宕机的Broker,也不会发生消息丢失。后面我会专门安排一节课,来讲解在集群模式下,消息队列是如何通过消息复制来确保消息的可靠性的。
3.消费阶段
消费阶段采用和生产阶段类似的确认机制来保证消息的可靠传递,客户端从Broker拉取消息后,执行用户的消费业务逻辑,成功后,才会给Broker 发送消费确认响应。如果Broker没有收到消费确认响应,下次拉消息的时候还会返回同—条消息,确保消息不会在网络传输过程中丢失,也不会因为客户端在执行消费逻辑中出错导致丢失。
你在编写消费代码时需要注意的是,不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。
总结
- 在生产阶段,你需要捕获消息发送的错误,并重发消息。
- 在存储阶段,你可以通过配置刷盘和复制相关的参数,让消息写入到多个副本的磁盘上,来确保消息不会因为某个 Broker宕机或者磁盘损坏而丢失。
- 在消费阶段,你需要在处理完全部消费业务逻辑之后,再发送消费确认。
如何处理消费过程中的重复消息?
消息重复的情况必然存在
在MQTT协议中,给出了三种传递消息时能够提供的服务质量标准,这三种服务质量从低到高依次是:
- At most once:至多一次。消息在传递时,最多会被送达一次。换一个说法就是,没什么消息可靠性保证,允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用,比如每分钟上报一次机房温度数据,可以接受数据少量丢失。
- At least once:至少一次。消息在传递时,至少会被送达一次。也就是说,不允许丢消息,但是允许有少量重复消息出现。
- Exactly once:恰好一次。消息在传递时,只会被送达一次,不允许丢失也不允许重复,这个是最高的等级。
这个服务质量标准不仅适用于MQTT,对所有的消息队列都是适用的。我们现在常用的绝大部分消息队列提供的服务质量都是 At least once,包括RocketMQ、RabbitMQ和Kafka都是这样。也就是说,消息队列很难保证消息不重复。
用幂等性解决重复消息问题
一个幂等操作的特点是,其任意多次执行所产生的影响均与—次执行的影响相同。
如果我们系统消费消息的业务逻辑具备幂等性,那就不用担心消息重复的问题了,因为同一条消息,消费一次和消费多次对系统的影响是完全一样的。也就可以认为,消费多次等于消费一次。
从对系统的影响结果来说: At least once+幂等消费= Exactly once。
那么如何实现幂等操作呢?最好的方式就是,从业务逻辑设计上入手,将消费的业务逻辑设计成具备幂等性的操作。但是,不是所有的业务都能设计成天然幂等的,这里就需要一些方法和技巧来实现幂等。
下面介绍几种常用的设计幂等操作的方法:
1.利用数据库的唯—约束实现幂等
例如我们刚刚提到的那个不具备幂等特性的转账的例子:将账户X的余额加100元。在这个例子中,我们可以通过改造业务逻辑,让它具备幂等性。
首先,我们可以限定,对于每个转账单每个账户只可以执行一次变更操作,在分布式系统中,这个限制实现的方法非常多,最简单的是我们在数据库中建—张转账流水表,这个表有三个字段:转账单ID、账户ID和变更金额,然后给转账单ID和账户ID这两个字段联合起来创建一个唯一约束,这样对于相同的转账单ID和账户ID,表里至多只能存在一条记录。
这样,我们消费消息的逻辑可以变为:“在转账流水表中增加─条转账记录,然后再根据转账记录,异步操作更新用户余额即可。”在转账流水表增加─条转账记录这个操作中,由于我们在这个表中预先定义了“账户ID转账单ID”的唯一约束,对于同一个转账单同一个账户只能插入一条记录,后续重复的插入操作都会失败,这样就实现了一个幂等的操作。我们只要写一个SQL,正确地实现它就可以了。
基于这个思路,不光是可以使用关系型数据库,只要是支持类似“INSERT IF NOT EXIST”语义的存储类系统都可以用于实现幂等,比如,你可以用Redis 的 SETNX命令来替代数据库中的唯—约束,来实现幂等消费。
2.为更新的数据设置前置条件
另外—种实现幂等的思路是,给数据变更设置一个前置条件,如果满足条件就更新数据,否则拒绝更新数据,在更新数据的时候,同时变更前置条件中需要判断的数据。这样,重复执行这个操作时,由于第一次更新数据的时候已经变更了前置条件中需要判断的数据,不满足前置条件,则不会重复执行更新数据操作。
比如,刚刚我们说过,“将账户×的余额增加100元”这个操作并不满足幂等性,我们可以把这个操作加上一个前置条件,变为:“如果账户X当前的余额为500元,将余额加100元”,这个操作就具备了幂等性。对应到消息队列中的使用时,可以在发消息时在消息体中带上当前的余额,在消费的时候进行判断数据库中,当前余额是否与消息中的余额相等,只有相等才执行变更操作。
但是,如果我们要更新的数据不是数值,或者我们要做一个比较复杂的更新操作怎么办?用什么作为前置判断条件呢?更加通用的方法是,给你的数据增加一个版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号+1,一样可以实现幂等更新。
3.记录并检查操作
如果上面提到的两种实现幂等方法都不能适用于你的场景,我们还有一种通用性最强,适用范围最广的实现幂等性方法:记录并检查操作,也称为“Token机制或者GUID(全局唯一ID)机制”,实现的思路特别简单:在执行数据更新操作之前,先检查一下是否执行过这个更新操作。
具体的实现方法是,在发送消息时,给每条消息指定一个全局唯一的ID,消费时,先根据这个ID检查这条消息是否有被消费过,如果没有消费过,才更新数据,然后将消费状态置为已消费。
原理和实现是不是很简单?其实一点儿都不简单,在分布式系统中,这个方法其实是非常难实现的。首先,给每个消息指定一个全局唯一的ID就是—件不那么简单的事儿,方法有很多,但都不太好同时满足简单、高可用和高性能,或多或少都要有些牺牲。更加麻烦的是,在“检查消费状态,然后更新数据并且设置消费状态”中,三个操作必须作为一组操作保证原子性,才能真正实现幂等,否则就会出现Bug。
比如说,对于同—条消息:“全局ID为8,操作为:给ID为666账户增加100元”,有可能出现这样的情况:
- t0时刻:Consumer A收到条消息,检查消息执行状态,发现消息未处理过,开始执行“账户增加100元”;
- t1时刻:Consumer B收到条消息,检查消息执行状态,发现消息未处理过,因为这个时刻,ConsumerA还未来得及更新消息执行状态。
这样就会导致账户被错误地增加了两次100元,这是一个在分布式系统中非常容易犯的错误,一定要引以为戒。
对于这个问题,当然我们可以用事务来实现,也可以用锁来实现,但是在分布式系统中,无论是分布式事务还是分布式锁都是比较难解决问题。
消息积压了该如何处理?
优化消息收发性能,预防消息积压的方法有两种,增加批量或者是增加并发,在发送端这两种方法都可以使用,在消费端需要注意的是,增加并发需要同步扩容分区数量,否则是起不到效果的。
对于系统发生消息积压的情况,需要先解决积压,再分析原因,毕竟保证系统的可用性是首先要解决的问题。快速解决积压的方法就是通过水平扩容增加Consumer的实例数量。