分布式消息中间件-RocketMQ

                                                               

 

MQ背景&选型                                                                                

消息队列作为高并发系统的核心组件之一,能够帮助业务系统解构提升开发效率和系统稳定性。主要具有以下优势:

削峰填⾕谷(主要解决瞬时写压力大于应用服务能力导致消息丢失、系统奔溃等问题)系统解耦(解决不同重要程度、不同能力级别系统之间依赖导致一死全死)

提升性能(当存在一对多调用时,可以发一条消息给消息系统,让消息系统通知相关系统)蓄流压测(线上有些链路路不好压测,可以通过堆积一定量量消息再放开来压测)

⽬目前主流的MQ主要是Rocketmq、kafka、Rabbitmq,Rocketmq相比于Rabbitmq、kafka具有主要优势特性有:

  • 支持事务型消息(消息发送和DB操作保持两方的最终一致性,rabbitmq和kafka不支持)
  • 支持结合rocketmq的多个系统之间数据最终一致性(多方事务,二方事务是前提)
  • 支持延迟消息(rabbitmq和kafka不支持)
  • 支持指定次数和时间间隔的失败消息重发(kafka不支持,rabbitmq需要手动确认)
  • 支持consumer端tag过滤,减少不必要的网络传输(rabbitmq和kafka不支持)
  • 支持重复消费(rabbitmq不支持,kafka支持)

 

集群部署                                                                                          

分布式消息中间件-RocketMQ_第1张图片

1) Name Server

Name Server是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。

2) Broker

Broker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave的对应关系通过指定相同的Broker id,不同的Broker Id来定义,BrokerId为0表示Master,非0表示Slave。

每个Broker与Name Server集群中的所有节点建立长连接,定时(每隔30s)注册Topic信息到所有Name Server。Name Server定时(每隔10s)扫描所有存活broker的连接,如果Name Server超过2分钟没有收到心跳,则Name Server断开与Broker的连接。

3) Producer

Producer与Name Server集群中的其中一个节点(随机选择)建立长连接,定期从Name Server取Topic 路路由信息,并向提供Topic服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状

态,可集群部署。

Producer每隔30s(由ClientConfifig的pollNameServerInterval)从Name server获取所有topic队列的

最新情况,这意味着如果Broker不可用,Producer最多30s能够感知,在此期间内发往Broker的所有消

息都会失败。

Producer每隔30s(由ClientConfifig中heartbeatBrokerInterval决定)向所有关联的broker发送心跳,

Broker每隔10s中扫描所有存活的连接,如果Broker在2分钟内没有收到心跳数据,则关闭与Producer 的连接。

4) Consumer

Consumer与Name Server集群中的其中一个节点(随机选择)建立长连接,定期从Name Server取Topic 路路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。

Consumer既可以从Master订阅消息,也可以从Slave订阅消息,订阅规则由Broker配置决定。

Consumer每隔30s从Name server获取topic的最新队列情况,这意味着Broker不可用时,Consumer 最多最需要30s才能感知。

Consumer每隔30s(由ClientConfifig中heartbeatBrokerInterval决定)向所有关联的broker发送心

跳,Broker每隔10s扫描所有存活的连接,若某个连接2分钟内没有发送心跳数据,则关闭连接;并向该

Consumer Group的所有Consumer发出通知,Group内的Consumer重新分配队列,然后继续消费。当Consumer得到master宕机通知后,转向slave消费,slave不能保证master的消息100%都同步过来了,因此会有少量量的消息丢失。但是一旦master恢复,未同步过去的消息会被最终消费掉。

消费者对列是消费者连接之后(或者之前有连接过)才创建的。我们将原生的消费者标识由 {IP}@{消费者group}扩展为 {IP}@{消费者group}{topic}{tag},(例例如xxx.xxx.xxx.xxx@mqtest_producer

group_2m2sTest_tag-zyk)。任何一个元素不同,都认为是不同的消费端,每个消费端会拥有一份自

己消费对列(默认是broker对列数量量*broker数量量)。

 

关键特性及其实现原理                                                                     

顺序消费                                                                                                               

消息有序指的是可以按照消息的发送顺序来消费。

例例如:一笔订单产生了3条消息,分别是订单创建、订单付款、订单完成。消费时,必须按照顺序消费才有意义,与此同时多笔订单之间又是可以并行消费的。

例例如生产者差生了2条消息:M1、M2,要保证这两条消息的顺序,应该怎样做?可能脑中想到的是这样的:

分布式消息中间件-RocketMQ_第2张图片

 

    但是这个模型存在的问题是:如果M1和M2分别发送到两台Server上,就不能保证M1先到达MQ集群,

  也不能保证M1被先消费。换个⻆角度看,如果M2先与M1到达MQ集群,甚⾄至M2被消费后,M1才到达消

  费端,这时候消息就乱序了,说明以上模型是不能保证消息的顺序的。

  如何才能在MQ集群保证消息的顺序?一种简单的方式就是将M1、M2发送到同一个Server上:

分布式消息中间件-RocketMQ_第3张图片

根据先到达先被消费的原则,M1会先于M2被消费,这样就保证了消息的顺序。

但是这个模型也仅仅是在理论上可以保证消息的顺序,在实际场景中可能会遇到下⾯面的问题:网络延迟问题。

分布式消息中间件-RocketMQ_第4张图片

 

只要将消息从一台服务器器发往另一台服务器器,就会存在网络延迟问题,如上图所示,如果发送M1耗时

大于发送M2耗时,那么仍然存在可能M2被先消费,仍然不能保证消息的顺序,即使M1和M2同时到达消费端,由于不清楚消费端1和消费端2的负载情况,仍然可能出现M2先于M1被消费的情况。

那如何解决这个问题呢?

将M1和M2发往同一个消费者,且发送M1后,需要消费端响应成功后才能发送M2。

但是这里又会存在另外的问题:如果M1被发送到消费端后,消费端1没有响应,那么是继续发送M2

呢,还是重新发送M1?一般来说为了保证消息一定被消费,肯定会选择重发M1到另外一个消费端2,如下图,保证消息顺序的正确方式:

分布式消息中间件-RocketMQ_第5张图片

但是仍然会有问题:消费端1没有响应Server时,有两种情

况,一种是M1确实没有到达(数据可能在网络传输中丢失),另一种是消费端已经消费M1并且已经发回响应消息,但是MQ Server没有收到。如果是第二种情况,会导致M1被重复消费。回过头来看消息顺序消费问题,严格的顺序消息非常容易易理解,也可以通过⽂文中所描述的方式来简化处理,总结起来,要实现严格的顺序消息,简单可行的办法就是:
 

保证 ⽣产者—MQServer—消费者 是“⼀对⼀对⼀”的关系。 注意 这样的设计⽅案问题:

1.并⾏度会成为消息系统的瓶颈(吞吐量不够)

2.产⽣更多的异常处理。⽐如:只要消费端出现问题,就会导致整个处理流程阻塞,我们不得不花费更多的 精 ⼒来解决阻塞的问题。

我们最终的⽬目标是要集群的高容错性和高吞吐量量,这似乎是一对不可调和的⽭矛盾,那么阿里是如何解决的呢?

世界上解决一个计算机问题最简单的方法:“恰好”不需要解决它!—— 阿里资深技术专家 沈沈询

 

有些问题,看起来很重要,但实际上我们可以通过合理的设计将问题分解来规避,如果硬要把时间花在解决问题本身,实际上不仅效率低下,⽽而且也是一种浪费。从这个⻆角度来看消息的顺序问题,可以得出两个结论:

1.不关注乱序的应⽤⼤量存在
2.队列⽆序并不意味着消息⽆序

 

所以从业务层⾯面来保证消息的顺序,⽽而不仅仅是依赖于消息系统,是不是我们更更应该寻求的一种合理的

方式?

最后从源码⻆角度分析RocketMQ怎么实现发送顺序消息。

RocketMQ通过轮询所有队列的方式来确定消息被发送到哪一个队列(负载均衡策略略)。

比如下⾯面的

// RocketMQ通过MessageQueueSelector中实现的算法来确定消息发送到哪⼀个队列上
// RocketMQ默认提供了两种MessageQueueSelector实现:随机/Hash
// 当然你可以根据业务实现⾃⼰的MessageQueueSelector来决定消息按照何种策略发送到消息队列中
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
 @Override
 public MessageQueue select(List mqs, Message msg, Object arg)
{
 Integer id = (Integer) arg;
 int index = id % mqs.size();
 return mqs.get(index);
 }
}, orderId);

 

在获取到路路由信息以后,会根据MessageQueueSelector实现的算法来选择一个队列,同一个OrderId 获取到的肯定是同一个队列。

rivate SendResult send() {
 // 获取topic路由信息
 TopicPublishInfo topicPublishInfo =
this.tryToFindTopicPublishInfo(msg.getTopic());
 if (topicPublishInfo != null && topicPublishInfo.ok()) {
 MessageQueue mq = null;
 // 根据我们的算法,选择⼀个发送队列
 // 这⾥的arg = orderId
 mq = selector.select(topicPublishInfo.getMessageQueueList(), msg, arg);
 if (mq != null) {
 return this.sendKernelImpl(msg, mq, communicationMode, sendCallback,
timeout);
 }
 }
}

 

消息重复                                                                                                               

造成消息重复的根本原因是:网络不可达。

只要通过网络交换数据,就无法避免这个问题。所以解决这个问题的办法是绕过这个问题。

那么问题就变成了:如果消费端收到两条一样的消息,应该怎样处理?

1.消费端处理消息的业务逻辑要保持幂等性。

2.保证每条数据都有唯一编号,且保证消息处理成功与去重表的日志同时出现。第1条很好理解,只要保持幂等性,不管来多少条重复消息,最后处理的结果都一样。

第2条原理就是利利用一张日志表来记录已经处理成功的消息的ID,如果新到的消息ID已经在日志表中,那么久不在处理这条消息。

 

第1条解决方案,很明显应该在消费端实现,不属于消息系统要实现的功能。

第2条可以由消息系统实现,也可以由业务端实现。正常情况下出现重复消息的概率其实很小,如果由消息系统来实现的话,肯定会对消息系统的吞吐量量和高可用有影响,所以最好还是由业务端自己处理消息重复的问题,这也是RocketMQ不解决消息重复问题的原因。RocketMQ不保证消息不重复,如果你的业务系统需要保证严格的不重复消息,需要你自己在业务端去重。

 

事务消息                                                                                                               

RocketMQ除了支持普通消息,顺序消息,另外还支持事务消息。首先讨论一下什么是事务消息以及支持事务消息的必要性。我们以一个转账的场景为例例来说明这个问题:Bob向Smith转账100元。

在单机环境下,执行事务的情况大概是下⾯面这个样子:(单机环境下转账事务示意图)

分布式消息中间件-RocketMQ_第6张图片

 

当用户增长到一定的程度,Bob和Smith各自的账户和余额信息不再同一台服务器器上了,那么上⾯面的流程就会变成这样:(集群环境下转账事务示意图)

分布式消息中间件-RocketMQ_第7张图片

这时候会发现,同样的一个转账业务,在集群环境下,耗时会成倍地增长,这显然是不能接受的,那么如何来规避这个问题?

大事务 = 小事务 + 异步

 

将大事务拆分成多个小事务异步执行。这样基本上能够将跨机事务的执行效率优化到与单机一致。转账的事务可以分解成如下两个小事务:(小事务+异步消息)

 

 分布式消息中间件-RocketMQ_第8张图片

图中执行本地事务(Bob账户扣款)和发送异步消息应该保证同步成功或者同步失败,也就是扣款成功了,发送消息也一定要成功,如果扣款失败了,就不能发送消息。问题来了,我们是先扣款还是先发送消息呢?

首先看下线发送消息的情况,大致的示意图如下:(事务消息:先发送消息)

分布式消息中间件-RocketMQ_第9张图片

 

存在的问题是:如果消息发送成功,但是扣款失败,消费端就会消费此消息,进⽽而向Smith账户加钱。

先发消息不行,那就先扣款吧,大致的示意图如下:(事务消息:先扣款)

分布式消息中间件-RocketMQ_第10张图片

存在的问题和上⾯面类似:如果扣款成功,发送消息失败,就会出现Bob扣钱了,但是Smith账户未加钱。

可能还会想到别的办法来解决这个问题:直接将发消息放到Bob扣款的扣款的事务中去,如果发送失败,就抛出异常,事务回滚。这样的处理方式也符合“恰好”不需要解决的原则。

 

RocketMQ支持事务消息,下⾯面来看看RocketMQ是怎样来实现发送事务消息的:

rocketMQ分三个阶段:

第一阶段发送Prepared消息时,会拿到消息的地址。

第二阶段执行本地事务。

第三阶段通过第一阶段拿到的地址去访问消息,并修改消息的状态。

 

我们来看下RocketMQ的源码,是如何处理事务消息的。Producer发送事务消息的部分。

TransactionCheckListener transactionCheckListener = new
TransactionCheckListenerImpl();// 构造事务消息的⽣产者
TransactionMQProducer producer = new TransactionMQProducer("groupName");
// 设置事务决断处理类
producer.setTransactionCheckListener(transactionCheckListener);
// 本地事务的处理逻辑,相当于示例中检查Bob账户并扣钱的逻辑
TransactionExecuterImpl tranExecuter = new TransactionExecuterImpl();
producer.start()
// 构造MSG,省略构造参数
Message msg = new Message(......);
// 发送消息
SendResult sendResult = producer.sendMessageInTransaction(msg, tranExecuter,
null);
producer.shutdown();

 

接着查看*sendMessageInTransaction*方法的源码,总共分为三个阶段:

1.发送Prepared消息

2.执行本地事务

3.发送确认消息

// ================================事务消息的发送过程=============================================
public TransactionSendResult sendMessageInTransaction(.....) {
 // 逻辑代码,⾮实际代码
 // 1.发送消息
 sendResult = this.send(msg);
 // sendResult.getSendStatus() == SEND_OK
 // 2.如果消息发送成功,处理与消息关联的本地事务单元
if(sendResult.getStatus==SEND_OK)
 LocalTransactionState localTransactionState =
tranExecuter.executeLocalTransactionBranch(msg, arg);
 // 3.结束事务
 this.endTransaction(sendResult, localTransactionState, localException);
}

 

 

*endTransaction()*方法会将请求发往broker(mq server)去更更新事务消息的最终状态,如下:

1.根据sendResult找到Prepared消息,sendResult包含事务消息的ID。

2.根据location更更新消息的最终状态。

如果*endTransaction()*方法执行失败,数据没有发送发到broker,导致事务消息的状态更更新失败,

broker会有回查线程定时(默认1分钟)扫描每个事务状态的表格⽂文件,如果已经提交或者回滚消息则直接跳过,如果是prepared状态的则会向Producer发起checkTransaction请求,Producer会调用

DefaultMQProducerImpl.checkTransactionState()方法来处理broker的定时回调请求,⽽而

checkTransactionState会调用我们的事务设置的决断方法来决定是回滚事务还是继续执行,最后调用 endTransactionOneway让broker来更更新消息的最终状态。

  再回到转账的例例子,如果Bob的账户余额已经减少,且消息发送成功,Smith端开始消费这条消息,这

个时候回出现消费失败和消费超时两个问题,如何解决?

 

 

Producer如何发送消息                                                                                        

Producer轮询某Topic下所有队列的方式来实现发送方的负载均衡,如下图所示:

 分布式消息中间件-RocketMQ_第11张图片

RocketMQ的客户端发送消息的源码:

// 构造Producer
DefaultMQProducer producer = new DefaultMQProducer("ProducerGroupName");
// 初始化Producer,整个应⽤⽣命周期内,只需要初始化1次
producer.start();
// 构造Message
Message msg = new Message("TopicTest1",// topic
 "TagA",
 // tag:给消息打标签,⽤于区分⼀类消息,可为null
 "OrderID188", // key:⾃定义Key,可以⽤于去重,可为null
 ("Hello MetaQ").getBytes());// body:消息内容
// 发送消息并返回结果
SendResult sendResult = producer.send(msg);
// 清理资源,关闭⽹络连接,注销⾃⼰
producer.shutdown();

 

在整个生命周期内,生产者需要调用一次start方法来初始化,初始化主要完成的任务有:

1.如果没有指定namesrv地址,将会自动寻址

2.启动定时任务:更更新namesrv地址、从namesrv更更新Topic路路由信息、清理已经挂掉的broker、向所有的

broker发送心跳...

3.启动负载均衡的服务

初始化完成后,开始发送消息,发送消息的主要代码如下:

private SendResult sendDefaultImpl(Message msg,......) {
 // 检查Producer的状态是否是RUNNING
 this.makeSureStateOK();
 // 检查msg是否合法:是否为null、topic,body是否为空、body是否超⻓
 Validators.checkMessage(msg, this.defaultMQProducer);
 // 获取topic路由信息
 TopicPublishInfo topicPublishInfo =
this.tryToFindTopicPublishInfo(msg.getTopic());
 // 从路由信息中选择⼀个消息队列
 MessageQueue mq = topicPublishInfo.selectOneMessageQueue(lastBrokerName);
 // 将消息发送到该队列上去
 sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback,
timeout);
}

代码中需要关注两个方法*tryToFindTopicPublishInfo**selectOneMessageQueue*。前⾯面说过在

Producer初始化时,会启动定时任务获取路路由信息并更更新到本地缓存,所以

tryToFindTopicPublishInfo会首先从缓存中获取Topic路路由信息,如果没有获取到,则会自己去

namesrv获取路路由信息,*selectOneMessageQueue*方法通过轮询的方式,返回一个队列,以达到负载的⽬目的。

如果Producer发送消息失败,会自动重试,重试的策略略:

1.重试次数

2.总的耗时(包含重试n次的耗时)

3.同时满⾜足上⾯面两个条件后,Producer会选择另外一个队列发送消息。

 

消息存储                                                                                                               

RocketMQ的消息存储是由comsume queue 和 cimmit log配合完成的。

consume queue是消息的逻辑队列,相当于字典的⽬目录,用来指定消息在物理⽂文件commit log上的位置。

CommitLog

要想知道RocketMQ如何存储消息,我们先看看CommitLog。在RocketMQ中,所有topic的消息都存储在一个称为CommitLog的⽂文件中,该⽂文件默认最大为1GB,超过1GB后会轮到下一个CommitLog⽂文件。通过CommitLog,RocketMQ将所有消息存储在一起,以顺序IO的方式写⼊入磁盘,充分利利用了磁盘顺序写减少了IO争用提高数据存储的性能,消息在CommitLog中的存储格式如下:

分布式消息中间件-RocketMQ_第12张图片

ConsumeQueue

一个ConsumeQueue表示一个topic的一个queue,类似于kafka的一个partition,但是rocketmq在消息存储上与kafka有着非常大的不同,RocketMQ的ConsumeQueue中不存储具体的消息,具体的消息由CommitLog存储,ConsumeQueue中只存储路路由到该queue中的消息在CommitLog中的offffset,消

息的大小以及消息所属的tag的hash(tagCode),一共只占20个字节,整个数据包如下:

分布式消息中间件-RocketMQ_第13张图片

 

消息存储方式                                                                                                        

前⽂文已经描述过,RocketMQ的消息存储由CommitLog和ConsumeQueue两部分组成,其中

CommitLog用于存储原始的消息,⽽而ConsumeQueue用于存储投递到某一个queue中的消息的位置信息,消息的存储如下图所示:

分布式消息中间件-RocketMQ_第14张图片

消费者在读取消息时,先读取ConsumeQueue,再通过ConsumeQueue中的位置信息读取

CommitLog,得到原始的消息。

 

消息订阅                                                                                                               

RocketMQ消息订阅有两种模式:

一种是push模式,即MQServer主动向消费端推送。

另一种是Pull模式,即消费端在需要时,主动到MQServer拉取。

但是再具体实现时,Push和Pull模式都是采用消费端主动拉取的方式。

首先看下消费端的负载均衡:

分布式消息中间件-RocketMQ_第15张图片

消费端会通过RebalanceService线程,10s做一次基于Topic下的所有队列负载:

1.遍历Consumer下所有的Topic,然后根据Topic订阅所有的消息
2.获取同⼀Topic和Consume Group下的所有Consumer
3.然后根据具体的分配策略来分配消费队列,分配的策略包含:平均分配、消费端配置等

 

如上图所示,如果有5个队列,2个Consumer,那么第一个Consumer消费3个队列,第二个Consumer 消费2个队列。这里采用的就是平均分配策略略。它类似于分⻚页的过程,Topic下⾯面所有的Queue就是记录,Consumer的个数就相当于总的⻚页数,那么每⻚页有多少条记录,就类似于Consumer会消费哪些队列。

通过这样的策略略来达到大体上的平均消费,这样的设计也可以很方便便地⽔水平扩展来提高Consumer的消费能力。

消费端的Push模式是通过长轮询的模式来实现的,就如同下图:(Push模式示意图)

 分布式消息中间件-RocketMQ_第16张图片

Consumer端每隔一段时间主动向broker发送拉消息请求,broker在收到Pull请求后,如果有消息就立即返回数据,Consumer端收到返回的消息后,再回调消费者设置的Listener方法。如果broker在收到

Pull请求时,消息队列里没有数据,broker端会阻塞请求指导有数据传递或超时才返回。

 

RocketMQ的其他特性                                                                                          

1.定时消息

2.消息的刷盘策略略

3.主动同步策略略:同步双写、异步复制

4.海海量量消息堆积能力

5.高效通信

 

RocketMQ的最佳实践                                                                                          

     Producer最佳最佳实践

1.一个应用尽可能用一个Topic,消息子类型用tags来标识,tags可以由应用自由设置。只有发送消息设

置了tags,消费方在订阅消息时,才可以利利用tags在broker做消息过滤。

 

2.每个消息在业务层⾯面的唯一标识码,要设置到keys字段,方便便将来定位消息丢失问题。

 

3.消息发送成功或者失败,要打印消息日志,务必打印sendResult和key字段

 

4.对于消息不可丢失应用,务必要有消息重发机制。例如:消息发送失败,存储到数据库,能有定时程

序尝试重发或者人工触发重发。

 

5.某些应用如果不关注消息是否发送成功,请直接使用sendOneWay方法发送消息。

     Consumer最佳实践

1.消费过程要做到幂等

2.尽量量使用批量量方式消费,可以很大程度上提高消费吞吐量量。

3.优化每条消息的消费过程  其他配置

线上应该关闭autoCreateTopicEnable,即在配置⽂文件中将其设置为false。

RocketMQ在发送消息时,会首先获取路路由信息。如果是新的消息,由于MQServer上面还没有创建对

应的Topic,这个时候,如果上⾯面的配置打开的话(autoCreateTopicEnable=true),会返回默认Topic 的路路由信息(RocketMQ会在每台Broker上⾯面创建名为TBW102的Topic),然后Producer会选择一台

Broker发送消息,选中的Broker在存储消息时,发现消息的Topic还没有创建,就会自动创建Topic。后果就是:以后所有该Topic的消息,都将发送到这台Broker上,达不到负载均衡的⽬目的。

所以基于⽬目前RocketMQ的设计,建议关闭自动创建Topic的功能,然后根据消息量量的大小,手动创建

Topic。

你可能感兴趣的:(分布式专题,RocketMQ)