可能是最亲民的MQ一文解读

284 阅读9分钟

现在的后端几乎都是微服务架构,在各个微服务之间,RPC与MQ可以说是最常用的两种中间件了,本文主要整体回顾MQ的相关特性,希望常学常新。

1. 什么是MQ

每每提到MQ,都会有三个“伴生词”同步出现:异步、解耦、削峰。

那么,这三个特性从何而来?我们先结合一个下单的例子来看看MQ在微服务架构中的定位:

用户下单后,需要在用户账户扣钱,更新商品库存、注销优惠卷、删除购物车中已下单的商品......如果上述的每个操作都是一个服务,那么订单服务就需要通过RPC调用上述的所有服务,如果再加一个服务,通知商家有人下单了,那么订单服务就需要跟着做改造。

image.png

public placeOrder() {
  payMoney();
  updateStock();
  useCoupon();
  deleteShoppingCart();
  // 增加通知
  noticeSeller();
}

上面这种状态,每增加一个新服务,订单的同学都要做改造,成本太大了,有没有一种方式可以在订单不做改造的情况下支持业务的扩展呢?

我们对上面的五种业务进行分析,交易、库存与优惠卷是与订单强相关的(事务关系,共同成功失败)。而购物册、通知等并没有那么强的业务一致性要求,那么,我们能不能在订单成功后发一个通知,让这类一致性要求没有那么高的业务自己去监听这个通知,来实现解耦呢?这就是我们常说的“发布-订阅模式”,也是MQ的工作原理。

image.png

这里其实就已经涉及到了MQ最常说的两个特性:异步与解耦。

2. 消息的生产消费

常见的MQ通常由三部分组成:消息生产者、MQ服务器、消息消费者。

image.png

消息的生产肯定是生产者主动触发的,但是到了消息消费环节,就有两种不同的形式了:

  • push模式:MQ服务器主动把消息分发给消费者。
  • pull模式:消费者主动去MQ拉取消息。

对比两种模式,push的实时性肯定更好,但是正如上图所示的,在真实场景中,生产者和消费者应用往往都是以集群的方式存在的,MQ服务器在push消息的时候,无法充分感知消费者的资源分配情况。而且,我们回顾上一章节引入MQ的场景,往往引入MQ的业务间,对于实时性的要求都没有那么强,因此,push模式并不主流。

pull模式能更好的进行负载均衡与资源调度,消费者可以根据自身情况适时调整拉取的消息数量,Kafka采取的就是这种模式,消费者会定期从MQ服务器轮询拉取消息。

RocketMQ本质上也是pull模式,消费者会定期去MQ服务器拉取消息,如果拉取时,MQ服务器中没有消息,那么RocketMQ会维护一个长链接,这段时间内有新消息到达,MQ服务器会立即将消息返回给消费者,另外,RocketMQ在拉取消息时是同时拉取一批而不是一条消息,来提升效率。

3. 消息分类

image.png

回到上面的例子,订单发送成功之后,如果是从购物车中下单的商品,需要在购物车中删除。但是,在真实场景下,可能只有少部分商品是购物车下单的,如果购物车监听了全量消息,就需要对所有消息进行判断。

在RocketMQ中,Tag为消息提供了一个额外的维度,使得消息在同一个Topic下可以根据业务需求进一步细分。通过Tag,生产者可以在发送消息时为每条消息打上一个或多个标签,而消费者则可以根据这些标签过滤并只接收他们感兴趣的消息。

在设计消息系统时,应根据实际业务需求合理规划Tag的使用,避免过度细分或过度集中。为了提高效率,RocketMQ在服务端采用了Tag的hashCode进行快速过滤,过多的Tag会导致哈希碰撞的发生,进而影响效率。

4. 消息的一致性

分布式系统的事务一直都是一个宏大的命题,随着各种中间件的引入,这个问题的复杂度也会随之上升。尤其是MQ这种特殊的中间件,生产者发出去的消息就是泼出去的水,如何保障一系列操作的一致性是一个值得讨论的问题。

我们可以把一条MQ消息的生命周期分为三个阶段:生产者投递消息到MQ、MQ存储消息、MQ将消息投递到生产者。如果我们想保证全链路的完全一致性,是很困难的(异步与解耦的副作用),但是分阶段的确保一致性,达到最终一致的效果,还是可以实现的。

image.png

  1. 生产者->MQ

还是以上面的流程为例。订单、交易、库存、优惠卷是同步操作,我们可以把MQ消息投递看成是DB操作,默认消息投递成功,就一定可以成功消费(这就需要后续MQ服务器与消费者来保障了),这样就能确保订单、交易、库存、优惠卷与消息投递的事务性了。

有两种方法可以实现生产者到MQ的一致性:

  • 两阶段提交: RocketMQ使用两阶段提交协议的变体来保证事务消息的一致性。
    • 预提交阶段:生产者发送一个半事务消息到MQ,并在本地执行业务操作但未提交事务。
    • 提交/回滚阶段:根据本地事务的成功或失败,生产者向MQ发送提交或回滚指令。如果MQ收到了提交指令,消息会被投递给消费者;如果收到了回滚指令或者超时未收到任何指令,MQ会丢弃这条消息。
  • 本地消息表: 应用在执行数据库操作的同时,会在本地记录一条消息的状态。当数据库事务提交成功后,再将消息发送到MQ。如果发送失败,可以通过重试机制重新发送。这种方式需要应用自行管理消息与业务操作的一致性。
  1. MQ

消息从生产者投递到了MQ之后,一致性就要靠MQ来维护了。

  • 持久化:MQ接收到生产者投递的消息后,可以先持久化存储,存储成功后再回复生产者。有两种持久化形式:
    • DB:存储消息到数据库中,通过DB维护消息再分布式系统中的一致性,可靠性强,但是DB的引入会导致效率降低以及架构复杂度的进一步提升。
    • 本地日志(文件系统):可以通过在本地维护一个日志文件,以顺序IO的形式进行消息的持久化,这样的持久化效率是很高的,RocketMQ采取的就是这种方法。
  1. MQ->消费者
  • 死信队列与重试机制:对于消费失败的消息,RocketMQ可以配置重试策略。超过最大重试次数后,消息会被转移到死信队列,以便进一步分析和处理,避免因不断重试而导致系统不稳定。

重试机制尽可能的保证了消息不丢失,但这就导致消息有可能会被MQ重复分发给消费者,这是一个互斥的问题,因此,我们在使用MQ时,需要在消费端保证幂等性。

5. 消息有序性

在我们使用MQ的业务中,其实大部分对消息的顺序是没有强需求的。对于需要保证顺序的场景,MQ也是支持的,在这里,我们可以把MQ理解为一组集群部署的队列,每个队列都满足先进先出的要求,我们只需要确保需要保证顺序的消息都投递到同一个物理队列中就可以了。在Rocket中,每个Topic可以包含多个队列,通过控制消息分配到特定队列,可以保证这部分消息的顺序;在Kafka中,确保相关消息被发送到同一个Partition中,就可以保证Partition中消息的顺序性。

在某些场景下,可以通过业务逻辑来补偿有序性的需求。例如,对于一系列操作的消息,可以在消息体中携带序列号,消费端按照序列号重新排序后再进行处理。保证消息的有序性通常需要在消息队列的设计、部署和使用上做出权衡,可能会牺牲一部分性能或增加系统的复杂度。实际应用中,应根据业务需求的严格程度来决定采取何种程度的有序性保障措施。

6. 定时消息

RocketMQ为延时消息设计了特殊的延时队列,每个延时等级例如1s、5s、10s ... 2h、1d等)都对应一个或多个队列,用于存放该等级的所有延时消息。

收到延迟消息后,RocketMQ会将消息投递到对应的队列中,由一个线程轮询拿出到时间的消息,投递回本来的队列。

轮询到期消息如果每次都遍历队列,那效率就太低了,RocketMQ采用了时间轮的数据结构。时间轮的核心思想是利用环形数组(也称为循环队列)和指针来管理定时任务。

时间轮的核心是一个固定大小的环形数组,数组的每个槽(Bucket)代表一个时间间隔,可以理解为时钟的一个刻度。数组的大小决定了时间轮的精度和范围。有一个或多个指针在环形数组上移动,模拟时间的流逝。当指针移动到某个槽时,就表示那个时间间隔已经到达。每个槽内可以存储一个或多个待执行的任务索引(通常是一个链表或队列),这些任务就是在该时间间隔到期时需要执行的操作。通过索引直接访问到期任务,避免了遍历整个任务列表。