一、简介
1、使用场景
-
异步处理
订单支付之后,我们要扣减库存、增加积分、发送消息等等
-
流量控制
消息队列用来削峰,例如秒杀系统,平时流量很低,但是要做秒杀活动,秒杀的时候流量疯狂怼进来,我们的服务器,Redis,MySQL各自的承受能力都不一样,直接全部流量照单全收肯定有问题啊,严重点可能直接打挂了。
我们可以把请求扔到队列里面,只放出我们服务能处理的流量,这样就能抗住短时间的大流量了。
-
服务解耦
2、问题和局限性
- 引入消息队列带来的延迟问题;
- 增加了系统的复杂度;
- 可能产生数据不一致的问题。
二、选择消息队列
- 消息的可靠传递:确保不丢消息;
- Cluster:支持集群,确保不会因为某个节点宕机导致服务不可用,当然也不能丢消息;
- 性能:具备足够好的性能,能满足绝大多数场景的性能要求。
1、RabbitMQ
一个相当轻量级的消息队列,非常容易部署和使用
问题
1、RabbitMQ对消息堆积的支持并不好。当大量消息积压的时候,会导致RabbitMQ的性能急剧下降。
2、RabbitMQ大概每秒钟可以处理几万到十几万条消息。性能比较差
3、RabbitMQ使用的编程语言Erlang
2、RocketMQ
1、RocketMQ使用Java语言开发,它的贡献者大多数都是中国人,源代码相对也比较容易读懂
2、RocketMQ对在线业务的响应时延做了很多的优化,大多数情况下可以做到毫秒级的响应,如果你的应用场景很在意响应时延,那应该选择使用RocketMQ。
3、RocketMQ的性能比RabbitMQ要高一个数量级,每秒钟大概能处理几十万条消息。
4、一个劣势是,作为国产的消息队列,相比国外的比较流行的同类产品,在国际上还没有那么流行,与周边生态系统的集成和兼容程度要略逊一筹。
3、Kafka
Kafka与周边生态系统的兼容性是最好的,尤其在大数据和流计算领域,几乎所有的相关开源软件系统都会优先支持Kafka。
Kafka使用Scala和Java语言开发,设计上大量使用了批量和异步的思想,这种设计使得Kafka能做到超高的性能。Kafka的性能,尤其是异步收发的性能,是三者中最好的,但与RocketMQ并没有量级上的差异,大约每秒钟可以处理几十万条消息。
我曾经使用配置比较好的服务器对Kafka进行过压测,在有足够的客户端并发进行异步批量发送,并且开启压缩的情况下,Kafka的极限处理能力可以超过每秒2000万条消息。
但是Kafka这种异步批量的设计带来的问题是,它的同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka并不会立即发送出去,而是要等一会儿攒一批再发送,在它的Broker中,很多地方都会使用这种“先攒一波再一起处理”的设计。当你的业务场景中,每秒钟消息数量没有那么多的时候,Kafka的时延反而会比较高。所以,Kafka不太适合在线业务场景。
三、消息模型
消息队列有两种消息模型,分别是队列模型和发布/订阅模型
队列模型
队列模型是最开始的一种消息队列模型,对应着消息队列“发-存-收”的模型。生产者往某个队列里面发送消息,一个队列可以存储多个生产者的消息,一个队列也可以有多个消费者,但是消费者之间是竞争关系,也就是说每条消息只能被一个消费者消费。
发布/订阅模型
如果需要将一份消息数据分发给多个消费者,并且每个消费者都要求收到全量的消息。很显然,队列模型无法满足这个需求。于是,发布/订阅模型就诞生了。
在发布-订阅模型中,消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息
RabbitMQ的消息模型
Exchange位于生产者和队列之间,生产者将消息发送给Exchange,由Exchange上配置的策略来决定将消息投递到哪些队列中
同一份消息如果需要被多个消费者来消费,需要配置Exchange将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。
RocketMQ的消息模型
每个主题包含多个队列,通过多个队列来实现多实例并行生产和消费。需要注意的是,RocketMQ只在队列上保证消息的有序性,主题层面是无法保证消息的严格顺序的。
RocketMQ中,订阅者的概念是通过消费组(Consumer Group)来体现的。每个消费组都消费主题中一份完整的消息,不同消费组之间消费进度彼此不受影响,也就是说,一条消息被Consumer Group1消费过,也会再给Consumer Group2消费。
消费组中包含多个消费者,同一个组内的消费者是竞争消费的关系,每个消费者负责消费组内的一部分消息。如果一条消息被消费者Consumer1消费了,那同组的其他消费者就不会再收到这条消息。
在Topic的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要RocketMQ为每个消费组在每个队列上维护一个消费位置(Consumer Offset),这个位置之前的消息都被消费过,之后的消息都没有被消费过,每成功消费一条消息,消费位置就加一。这个消费位置是非常重要的概念,我们在使用消息队列的时候,丢消息的原因大多是由于消费位置处理不当导致的。
利用事务消息实现分布式事务
消息队列中的“事务”,主要解决的是消息生产者和消息消费者的数据一致性问题
1、分布式事务例子
用电商举例。通常,用户在电商APP上购物时,先把商品加到购物车里,然后几件商品一起下单,最后支付,完成购物流程,就可以愉快地等待收货了。
整个过程:订单系统创建订单后,发消息给购物车系统,将已下单的商品从购物车中删除。
其中从购物车删除已下单商品这个步骤,并不是用户下单支付这个主要流程中必需的步骤,使用消息队列来异步清理购物车是更加合理的设计
对于订单系统来说,它创建订单的过程中实际上执行了2个步骤的操作:
- 在订单库中插入一条订单数据,创建订单;
- 发消息给消息队列,消息的内容就是刚刚创建的订单。
购物车系统订阅相应的主题,接收订单创建的消息,然后清理购物车,在购物车中删除订单中的商品。
在分布式系统中,上面提到的这些步骤,任何一个步骤都有可能失败,如果不做任何处理,那就有可能出现订单数据与购物车数据不一致的情况,比如说:
- 创建了订单,没有清理购物车;
- 订单没创建成功,购物车里面的商品却被清掉了。
那我们需要解决的问题可以总结为:在上述任意步骤都有可能失败的情况下,还要保证订单库和购物车库这两个库的数据一致性。
对于购物车系统收到订单创建成功消息清理购物车这个操作来说,失败的处理比较简单,只要成功执行购物车清理后再提交消费确认即可,如果失败,由于没有提交消费确认,消息队列会自动重试。
问题的关键点集中在订单系统,创建订单和发送消息这两个步骤要么都操作成功,要么都操作失败,不允许一个成功而另一个失败的情况出现。
这就是事务需要解决的问题。
2、常见分布式事务实现
大部分传统的单体关系型数据库都完整的实现了ACID,但是,对于分布式系统来说,严格的实现ACID这四个特性几乎是不可能的
分布式事务就是要在分布式系统中的实现事务。在分布式系统中,在保证可用性和不严重牺牲性能的前提下,光是要实现数据的一致性就已经非常困难了,所以出现了很多“残血版”的一致性,比如顺序一致性、最终一致性等等。
在实际应用中,比较常见的分布式事务实现有2PC(Two-phase Commit,也叫二阶段提交)、TCC(Try-Confirm-Cancel)和事务消息。每一种实现都有其特定的使用场景,也有各自的问题,都不是完美的解决方案。
事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。比如我们在开始时提到的那个例子,在创建订单后,如果出现短暂的几秒,购物车里的商品没有被及时清空,也不是完全不可接受的,只要最终购物车的数据和订单数据保持一致就可以了。
3、消息队列是如何实现分布式事务
- 订单系统在消息队列上开启一个事务。
- 订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。
- 半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。
- 根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了“要么都成功,要么都失败”的一致性要求。
问题
这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?对于这个问题,Kafka和RocketMQ给出了2种不同的解决方案。
Kafka的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。
RocketMQ则给出了另外一种解决方案。
4、RocketMQ中的分布式事务实现
在RocketMQ中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果Producer也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ的Broker没有收到提交或者回滚的请求,Broker会定期去Producer上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。
反查本地事务的逻辑也很简单,我们只要根据消息中的订单ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ会自动根据事务反查的结果提交或者回滚事务消息。
这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。
如何保证消息的可用性/可靠性/不丢失呢
消息可能会在这三个阶段发生丢失:生产阶段、存储阶段、消费阶段。
生产
在生产阶段,主要通过请求确认机制,来保证消息的可靠传递。
- 同步发送的时候,要注意处理响应结果和异常。如果返回响应OK,表示消息成功发送到了Broker,如果响应失败,或者发生其它异常,都应该重试。
- 异步发送的时候,应该在回调方法里检查,如果发送失败或者异常,都应该进行重试。
- 如果发生超时的情况,也可以通过查询日志的API,来检查是否在Broker存储成功。
存储
如果对消息的可靠性要求非常高,可以通过配置Broker参数来避免因为宕机丢消息。
-
对于单个节点的Broker,需要配置Broker参数,在收到消息后,将消息写入磁盘后再给Producer返回确认响应,这样即使发生宕机,由于消息已经被写入磁盘,就不会丢失消息,恢复后还可以继续消费。例如,在RocketMQ中,需要将刷盘方式flushDiskType配置为SYNC_FLUSH同步刷盘。
-
如果是Broker是由多个节点组成的集群,需要将Broker集群配置成:至少将消息发送到2个以上的节点,再给客户端回复发送确认响应。这样当某个Broker宕机时,其他的Broker可以替代宕机的Broker,也不会发生消息丢失。后面我会专门安排一节课,来讲解在集群模式下,消息队列是如何通过消息复制来确保消息的可靠性的。
消费
Consumer保证消息成功消费的关键在于确认的时机,不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。因为消息队列维护了消费的位置,逻辑执行失败了,没有确认,再去队列拉取消息,就还是之前的一条。
如何处理消费过程中的重复消息
1、消息重复的情况必然存在
在MQTT协议中,提供了三种消息传递服务质量标准,从低到高分别是:
-
At most once: 至多一次。消息在传递时,最多会被送达一次。换一个说法就是,没什么消息可靠性保证,允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用,比如每分钟上报一次机房温度数据,可以接受数据少量丢失。
-
At least once: 至少一次。消息在传递时,至少会被送达一次。也就是说,不允许丢消息,但是允许有少量重复消息出现。
-
Exactly once:恰好一次。消息在传递时,只会被送达一次,不允许丢失也不允许重复,这个是最高的等级。
2、用幂等性解决重复消息问题
幂等(Idempotence) 本来是一个数学上的概念,它是这样定义的:
如果一个函数f(x)满足:f(f(x)) = f(x),则函数f(x)满足幂等性。
任意多次执行操作所产生的影响均与一次执行的影响相同
举个例子来说明一下。在不考虑并发的情况下,“将账户X的余额设置为100元”,执行一次后对系统的影响是,账户X的余额变成了100元。只要提供的参数100元不变,那即使再执行多少次,账户X的余额始终都是100元,不会变化,这个操作就是一个幂等的操作
从对系统的影响结果来说:At least once + 幂等消费 = Exactly once。
那么如何实现幂等操作呢?最好的方式就是,从业务逻辑设计上入手,将消费的业务逻辑设计成具备幂等性的操作
3、常用的设计幂等操作的方法
(1)利用数据库的唯一约束实现幂等
转账的例子:将账户X的余额加100元
为了使转账操作具备幂等性,可以通过在数据库中创建转账流水表,添加唯一约束,确保相同的转账单ID和账户ID只能存在一条记录。这样,在消息消费逻辑中,通过插入一条转账记录实现幂等性,后续的重复插入操作会失败。这思路适用于支持“INSERT IF NOT EXIST”语义的存储系统,如关系型数据库或Redis
(2)为更新的数据设置前置条件
给数据变更设置一个前置条件,如果满足条件就更新数据,否则拒绝更新数据,在更新数据的时候,同时变更前置条件中需要判断的数据。这样,重复执行这个操作时,由于第一次更新数据的时候已经变更了前置条件中需要判断的数据,不满足前置条件,则不会重复执行更新数据操作。
比如,“将账户X的余额增加100元”这个操作并不满足幂等性,我们可以把这个操作加上一个前置条件,变为:“如果账户X当前的余额为500元,将余额加100元”,这个操作就具备了幂等性。对应到消息队列中的使用时,可以在发消息时在消息体中带上当前的余额,在消费的时候进行判断数据库中,当前余额是否与消息中的余额相等,只有相等才执行变更操作。
但是,如果我们要更新的数据不是数值,或者我们要做一个比较复杂的更新操作怎么办?用什么作为前置判断条件呢?更加通用的方法是,给你的数据增加一个版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号+1,一样可以实现幂等更新。
(3)记录并检查操作
一种通用且广泛适用的实现幂等性的方法是使用“Token机制”或“GUID(全局唯一ID)机制”。
实现的思路特别简单:在执行数据更新操作之前,先检查一下是否执行过这个更新操作。
实现方法是:在发送消息时给每条消息分配一个全局唯一的ID,消费时先检查消息是否已消费过,若未消费过,则更新数据并将消费状态置为已消费。
在分布式系统中,这个方法其实是非常难实现的。首先,给每个消息指定一个全局唯一的ID就是一件不那么简单的事儿,方法有很多,但都不太好同时满足简单、高可用和高性能,或多或少都要有些牺牲。更加麻烦的是,在“检查消费状态,然后更新数据并且设置消费状态”中,三个操作必须作为一组操作保证原子性,才能真正实现幂等,否则就会出现Bug。
比如说,对于同一条消息:“全局ID为8,操作为:给ID为666账户增加100元”,有可能出现这样的情况:
- t0时刻:Consumer A 收到条消息,检查消息执行状态,发现消息未处理过,开始执行“账户增加100元”;
- t1时刻:Consumer B 收到条消息,检查消息执行状态,发现消息未处理过,因为这个时刻,Consumer A还未来得及更新消息执行状态。
这样就会导致账户被错误地增加了两次100元,这是一个在分布式系统中非常容易犯的错误,一定要引以为戒。
对于这个问题,当然我们可以用事务来实现,也可以用锁来实现,但是在分布式系统中,无论是分布式事务还是分布式锁都是比较难解决问题。
消息积压了该如何处理
发生了消息积压,就得考虑提高消费能力,一般有两种办法
-
消费者扩容:如果当前Topic的Message Queue的数量大于消费者数量,就可以对消费者进行扩容,增加消费者,来提高消费能力,尽快把积压的消息消费玩。
-
消息迁移Queue扩容:如果当前Topic的Message Queue的数量小于或者等于消费者数量,这种情况,再扩容消费者就没什么用(因为对于消费者来说,在每个分区上实际上只能支持单线程消费),就得考虑扩容Message Queue。
可以新建一个临时的Topic,临时的Topic多设置一些Message Queue,然后先用一些消费者把消费的数据丢到临时的Topic,因为不用业务处理,只是转发一下消息,还是很快的。接下来用扩容的消费者去消费新的Topic里的数据,消费完了之后,恢复原状。
顺序消息如何实现?
顺序消息是指消息的消费顺序和产生顺序相同,在有些业务逻辑下,必须保证顺序,比如订单的生成、付款、发货,这个消息必须按顺序处理才行。
顺序消息分为全局顺序消息和分区顺序消息
- 全局顺序消息指某个 Topic 下的所有消息都要保证顺序;
- 部分顺序消息只要保证每一组消息被顺序消费即可,比如订单消息,只要保证同一个订单 ID 个消息能按顺序消费即可。
分区顺序消息
每个消息队列相当于主题的一个分区,而消息在分发时会根据业务键(key)或消息选择器(selector)等策略被分配到特定的消息队列。
同一分区内的消息保证顺序,不同分区之间的消息顺序不做要求
全局顺序消息
Topic 中只有一个分区,然后Producer Consumer 的并发设置也是一
延时消息
电商的订单超时自动取消,就是一个典型的利用延时消息的例子,用户提交了一个订单,就可以发送一个延时消息,1h后去检查这个订单的状态,如果还是未付款就取消订单释放库存。
RocketMQ是支持延时消息的,只需要在生产消息的时候设置消息的延时级别:
// 实例化一个生产者来产生延时消息
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
// 启动生产者
producer.start();
int totalMessagesToSend = 100;
for (int i = 0; i < totalMessagesToSend; i++) {
Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());
// 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
message.setDelayTimeLevel(3);
// 发送消息
producer.send(message);
}
但是目前RocketMQ支持的延时级别是有限的:
private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
RocketMQ怎么实现延时消息的?
简单,八个字:临时存储+定时任务。
Broker收到延时消息了,会先发送到主题(SCHEDULE_TOPIC_XXXX)的相应时间段的Message Queue中,然后通过一个定时任务轮询这些队列,到期后,把消息投递到目标Topic的队列中,然后消费者就可以正常消费这些消息。
死信队列知道吗
死信队列用于处理无法被正常消费的消息,即死信消息。
当一条消息初次消费失败,消息队列 RocketMQ 会自动进行消息重试;达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时,消息队列 RocketMQ 不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中,该特殊队列称为死信队列。
死信消息的特点:
- 不会再被消费者正常消费。
- 有效期与正常消息相同,均为 3 天,3 天后会被自动删除。因此,需要在死信消息产生后的 3 天内及时处理。
死信队列的特点:
- 一个死信队列对应一个消费者组, 而不是对应单个消费者实例。
- 如果一个消费者组未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列。
- 死信队列通常与消息模型的消费者组相关联,而不是与主题(Topic)直接关联。这使得死信队列包含同一个消费者组下所有主题中的死信消息
RocketMQ 控制台提供对死信消息的查询、导出和重发的功能。