RocketMQ核心源码解读(1)

源码环境搭建

1、主要功能模块

RocketMQ的官⽅Git仓库地址:github.com/apache/rock... 可以⽤git把项⽬clone下来或者直接下载代码包。也可以到RocketMQ的官⽅⽹站上下载指定版本的源码: rocketmq.apache.org/dowloading/... 。源码下很多的功能模块,其中⼤部分的功能模块都是可以⻅名知义的:

  • broker: Broker 模块(broke 启动进程)
  • client :消息客户端,包含消息⽣产者、消息消费者相关类
  • example: RocketMQ 例代码
  • namesrv:NameServer模块
  • store:消息存储模块
  • remoting:远程访问模块

2、源码启动服务

将源码导⼊IDEA后,需要先对源码进行编译。编译指令 clean install -Dmaven.test.skip=true

编译完成后就可以开始调试代码了。调试时需要按照以下步骤:

2.1 启动nameServer

展开namesrv模块,运⾏NamesrvStartup类即可启动NameServer服务。

对这个NamesrvStartup类做⼀个简单的解读都知道,可以通过-c参数指定⼀个properties配置⽂件,并通过-p参数打印出nameserver所有⽣效的参数配置。

配置完成后,去掉-p参数,再次执⾏,就可以启动nameserver服务了。启动成功,可以在控制台看到这样⽇志

js 复制代码
load config properties file OK, /Users/roykingw/namesrv/namesrv.properties
The Name Server boot success. serializeType=JSON, address 0.0.0.0:9876

2.2 启动Broker

类似的,Broker服务的启动⼊⼝在broker模块的BrokerStatup类。

Broker服务,同样可以通过-c参数指定broker.conf⽂件,并通过-p或者-m参数打印出⽣效的配置信息。

然后重新启动,即可启动Broker。

2.3 调⽤客户端

服务启动好了之后,就可以使⽤客户端收发消息了。

客户端代码在example模块中,具体使⽤⽅式略过。

3、读源码的方法

整个源码环境调试好后,接下来就可以开始详细调试源码了。但是对于RocketMQ的源码,不建议打断点调试,因为线程和定时任务太多,打断点很难调试到。

RocketMQ的源码有个特点,就是 ⼏乎没有注释。

  1. 带着问题读源码。如果没有⾃⼰的思考,源码不如不读!!!
  2. ⼩步快⾛。不要觉得⼀两遍就能读懂源码。这⾥我会分为三个阶段来带你逐步加深对源码的理解。
  3. 分步总结。带上⾃⼰的理解,及时总结。对各种扩展功能,尝试验证。对于RocketMQ,试着去理解源码中的各种单元测试。

⼆、源码热身阶段

1、NameServer的启动过程

1、关注的问题

在RocketMQ集群中,实际记性消息存储、推送等核⼼功能点额是Broker。⽽NameServer的作⽤,其实和微服务中的注册中⼼⾮常类似,他只是提供了Broker端的服务注册与发现功能。

第⼀次看源码,不要太过陷⼊具体的细节,先搞清楚NameServer的⼤体结构。

2、源码重点

NameServer的启动⼊⼝类是org.apache.rocketmq.namesrv.NamesrvStartup。其中的核⼼是构建并启动⼀个NamesrvController。这个Cotroller对象就跟MVC中的Controller是很类似的,都是响应客户端的请求。只不过,他响应的是基于Netty的客户端请求。

另外还启动了⼀个ControllerManager服务,这个服务主要是⽤来保证服务⾼可⽤的,这⾥暂不解读。

另外,他的实际启动过程,其实可以配合NameServer的启动脚本进⾏更深⼊的理解。我们这最先关注的是他的整体结构:

解读出以下⼏个重点:

1、这⼏个配置类就可以⽤来指导如何优化Nameserver的配置。⽐如,如何调整nameserver的端⼝?⾃⼰试试从源码中找找答案。

2、在之前的4.x版本当中,Nameserver中是没有ControllerManager和NettyRemotingClient的,这意味着现在NameServer现在也需要往外发Netty请求了。

3、稍微解读下Nameserver中核⼼组件例如RouteInfoManager的结构,可以发现RocketMQ的整体源码⻛格其实就是典型的MVC思想。 Controller响应⽹络请求,各种Manager和其中包含的Service处理业务,内存中的各种Table保存消息。

2、Broker服务启动过程

1、关注重点

Broker是整个RocketMQ的业务核⼼。所有消息存储、转发这些重要的业务都是Broker进⾏处理。

这⾥重点梳理Broker有哪些内部服务。这些内部服务将是整理Broker核⼼业务流程的起点。

2、源码重点

Broker启动的⼊⼝在BrokerStartup这个类,可以从他的main⽅法开始调试。

启动过程关键点:重点也是围绕⼀个BrokerController对象,先创建,然后再启动。

⾸先: 在BrokerStartup.createBrokerController⽅法中可以看到Broker的⼏个核⼼配置:

  • BrokerConfig : Broker服务配置
  • MessageStoreConfig : 消息存储配置。 这两个配置参数都可以在broker.conf⽂件中进⾏配置
  • NettyServerConfig :Netty服务端占⽤了10911端⼝。同样也可以在配置⽂件中覆盖。
  • NettyClientConfig : Broker既要作为Netty服务端,向客户端提供核⼼业务能⼒,⼜要作为Netty客户端,向NameServer注册⼼跳。
  • AuthConfig:权限相关的配置。

这些配置是我们了解如何优化 RocketMQ 使⽤的关键。

js 复制代码
this.messageStore.start();//启动核⼼的消息存储组件
this.timerMessageStore.start(); //时间轮服务,主要是处理指定时间点的延迟消息。
this.remotingServer.start(); //Netty服务端
this.fastRemotingServer.start(); //启动另⼀个Netty服务端。
this.brokerOuterAPI.start();//启动客户端,往外发请求
this.topicRouteInfoManager.start(); //管理Topic路由信息
BrokerController.this.registerBrokerAll: //向所有依次NameServer注册⼼跳。
this.brokerStatsManager.start();//服务状态

我们现在不需要了解这些核⼼组件的具体功能,只要有个⼤概,Broker中有⼀⼤堆的功能组件负责具体的业务。后⾯等到分析具体业务时再去深⼊每个服务的细节。

我们需要抽象出Broker的⼀个整体结构:

可以看到Broker启动了两个Netty服务,他们的功能基本差不多。实际上,在应⽤中,可以通过producer.setSendMessageWithVIPChannel(true),让少量⽐较重要的producer⾛VIP的通道。⽽在消费者端,也可以通过consumer.setVipChannelEnabled(true),让消费者⽀持VIP通道的数据。

3、Netty服务注册框架

1、关注重点?

RocketMQ实际上是⼀个复杂的分布式系统, NameServer,Broker,Client之间需要有⼤量跨进程的RPC调⽤。这些复杂的RPC请求是怎么管理,怎么调⽤的呢?这是我们去理解RocketMQ底层业务的基础。这⼀部分的重点就是去梳理RocketMQ的这⼀整套基于Netty的远程调⽤框架。

需要说明的是,RocketMQ整个服务调⽤框架绝⼤部分是使⽤Netty框架封装的。所以,要看懂这部分代码,需要你对Netty框架有⾜够的了解。

2、源码重点 Netty的所有远程通信功能都由remoting模块实现。remoting模块中有两个对象最为重要。 就是RPC的服务端RemotingServer以及客户端RemotingClient。在RocketMQ中,涉及到的远程服务⾮常多,同⼀个服务,可能既是RPC的服务端也可以是RPC的客户端。例如Broker服务,对于Client来说,他需要作为服务端响应他们发送消息以及拉取消息等请求,所以Broker是需要RemotingServer的。⽽另⼀⽅⾯,Broker需要主动向NameServer发送⼼跳请求,这时,Broker⼜需要RemotingClient。因此,Broker既是RPC的服务端⼜是RPC的客户端。

对于这部分的源码,就可以从remoting模块中RemotingServer和RemotingClient的初始化过程⼊⼿。有以下⼏个重点是需要梳理清楚的:

1、RemotingServer和RemotingClient之间是通过什么协议通讯的?

RocketMQ中,RemotingServer是⼀个接⼝,在这个接⼝下,提供了两个具体的实现类,NettyRemotingServer和MultiProtocolRemotingServer。他们都是基于Netty框架封装的,只不过处理数据的协议不一样。也就是说,RocketMQ可以基于不同协议实现RPC访问。其实这也就为RocketMQ提供多种不同语言的客户端打下了基础。

2、哪些组件需要Netty服务端?哪些组件需要Netty客户端?

之前简单梳理过,NameServer和Broker的服务内部都是既有RemotingServer和RemotingClient的。 那么作为客户端的Producer和Consumer,是不是就只需要RemotingClient呢?其实也不是,事务消息的Producer也需要响应Broker的事务状态回查,他也是需要NettyServer的。

这⾥需要注意的是,Netty框架是基于Channel⻓连接发起的RPC通信。只要⻓连接建⽴了,那么数据发送是双向的。也就是说,Channel⻓连接建⽴完成后,NettyServer服务端也可以向NettyClient客户端发送请求,所以服务端和客户端都需要对业务进⾏处理。

3、Netty框架最核⼼的部分是如何构架处理链,RocketMQ是如何构建的呢?

服务端构建处理链的核心代码:

js 复制代码
// org.apache.rocketmq.remoting.netty.NettyRemotingServer
protected ChannelPipeline configChannel(SocketChannel ch) {
    return ch.pipeline().addLast(defaultEventExecutorGroup, HANDSHAKE_HANDLER_NAME, new HandshakeHandler()) .addLast(defaultEventExecutorGroup,encoder, //请求编码器 
    new NettyDecoder(), //请求解码器 
    distributionHandler, //请求计数器 
    new IdleStateHandler(0, 0, nettyServerConfig.getServerChannelMaxIdleTimeSeconds()), //⼼跳管理器 
    connectionManageHandler, //连接管理器 
    serverHandler //核⼼的业务处理器
    );
}

我们这⾥主要分析业务请求如何管理。分两个部分来看

1、请求参数:

从请求的编解码器可以看出,RocketMQ的所有RPC请求数据都封装成RemotingCommand对象。RemotingCommand对象中有⼏个重要的属性:

js 复制代码
private int code; //响应码,表示请求处理成功还是失败
private int opaque = requestId.getAndIncrement(); //服务端内部会构建唯⼀的请求ID。
private transient CommandCustomHeader customHeader; //⾃定义的请求头。⽤来区分不同的业务请求
private transient byte[] body; //请求参数体
private int flag = 0; //参数类型, 默认0表示请求,1表示响应

2、处理逻辑 所有核⼼的业务请求都是通过⼀个NettyServerHandler进⾏统⼀处理。他处理时的核⼼代码如下:

js 复制代码
@ChannelHandler.Sharable
public class NettyServerHandler extends SimpleChannelInboundHandler<RemotingCommand> {
//统⼀处理所有业务请求

@Override
protected void channelRead0(ChannelHandlerContext ctx, RemotingCommand msg) {
    int localPort = RemotingHelper.parseSocketAddressPort(ctx.channel().localAddress());
    NettyRemotingAbstract remotingAbstract = NettyRemotingServer.this.remotingServerTable.get(localPort);
    if (localPort != -1 && remotingAbstract != null) {
        remotingAbstract.processMessageReceived(ctx, msg); //核⼼处理请求的⽅法
        return;
    }
    // The related remoting server has been shutdown, so close the connected channel
    RemotingHelper.closeChannel(ctx.channel());
}

@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
    //调整channel的读写属性
}

}

2.1、在最核心的处理请求的processMessageReceived⽅法中,会将请求类型分为 REQUEST__COMMAND 和 RESPONSE_COMMAND来处理。为什么会有两种不同类型的请求呢?

这是因为客户端的业务请求会有两种类型:⼀种是客户端发过来的业务请求,另⼀种是客户上次发过来的业务请求,可能并没有同步给出相应。这时就需要客户端再发⼀个response类型的请求,获取上⼀次请求的响应。这也就能⽀持异步的RPC调⽤。

2.2、如何处理request类型的请求?

服务端和客户端都会维护⼀个processorTable。这是个HashMap,key是服务码,也就对应RemotingCommand的code。value是对应的运⾏单元Pair<NettyRequestProcessor, ExecutorService>。包含了执⾏线程的线程池和具体处理业务的Processor。 ⽽这些Processor,是由业务系统⾃⾏注册的。

也就是说,想要看每个服务具体有哪些业务能⼒,就只要看他们注册了哪些Processor就知道了。

Broker服务注册,详⻅ BrokerController.registerProcssor()⽅法。

NameServer的服务注册方法,重点如下:

js 复制代码
private void registerProcessor() {
    if (namesrvConfig.isClusterTest()) { //是否测试集群模式,默认是false。也就是说现在阶段不推荐。
        this.remotingServer.registerDefaultProcessor(new ClusterTestRequestProcessor(this,
        namesrvConfig.getProductEnvName()), this.defaultExecutor);
    } else {
        // Support get route info only temporarily
        ClientRequestProcessor clientRequestProcessor = new ClientRequestProcessor(this);
        this.remotingServer.registerProcessor(RequestCode.GET_ROUTEINFO_BY_TOPIC,
        clientRequestProcessor, this.clientRequestExecutor);
        this.remotingServer.registerDefaultProcessor(new DefaultRequestProcessor(this), this.defaultExecutor);
    }
}

另外,NettyClient也会注册⼀个⼤的ClientRemotingProcessor,统一处理所有请求。注册⽅法⻅ org.apache.rocketmq.client.impl.MQClientAPIImpl类的构造⽅法。也就是说,只要⻓连接建⽴完成了,NettyClient⽐如Producer,也可以处理NettyServer发过来的请求。

2.3、如何处理response类型的请求?

NettyServer处理完request请求后,会先缓存到responseTable中,等NettyClient下次发送response类型的请求,再来获取。这样就不⽤阻塞Channel,提升请求的吞吐量。优雅的⽀持了异步请求。

** 2.4、关于RocketMQ的同步结果推送与异步结果推送**

RocketMQ的RemotingServer服务端,会维护⼀个responseTable,这是⼀个线程同步的Map结构。 key为请求的ID,value是异步的消息结果。ConcurrentMap<Integer /* opaque */, ResponseFuture> 。

处理同步请求(NettyRemotingAbstract#invokeSyncImpl)时,处理的结果会存⼊responseTable,通过ResponseFuture提供⼀定的服务端异步处理⽀持,提升服务端的吞吐量。 请求返回后,⽴即从responseTable中移除请求记录。

实际上,同步也是通过异步实现的。

js 复制代码
//org.apache.rocketmq.remoting.netty.ResponseFuture
//发送消息后,通过countDownLatch阻塞当前线程,造成同步等待的效果。
public RemotingCommand waitResponse(final long timeoutMillis) throws InterruptedException {
    this.countDownLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
    return this.responseCommand;
}
//等待异步获取到消息后,再通过countDownLatch释放当前线程。
public void putResponse(final RemotingCommand responseCommand) {
    this.responseCommand = responseCommand;
    this.countDownLatch.countDown();
}

处理异步请求(NettyRemotingAbstract#invokeAsyncImpl)时,处理的结果依然会存⼊responsTable,等待客户端后续再来请求结果。但是他保存的依然是⼀个ResponseFuture,也就是在客户端请求结果时再去获取真正的结果。

另外,在RemotingServer启动时,会启动⼀个定时的线程任务,不断扫描responseTable,将其中过期的response清除掉。

js 复制代码
//org.apache.rocketmq.remoting.netty.NettyRemotingServer
TimerTask timerScanResponseTable = new TimerTask() {
    @Override
    public void run(Timeout timeout) {
        try {
            NettyRemotingServer.this.scanResponseTable();
        } catch (Throwable e) {
            log.error("scanResponseTable exception", e);
        } finally {
            timer.newTimeout(this, 1000, TimeUnit.MILLISECONDS);
        }
    }
};
this.timer.newTimeout(timerScanResponseTable, 1000 * 3, TimeUnit.MILLISECONDS);

整体RPC框架流程如下图:

可以看到,RocketMQ基于Netty框架实现的这⼀套基于服务码的服务注册机制,即可以让各种不同的组件都按照⾃⼰的需求注册⾃⼰的服务⽅法,⼜可以以⼀种统⼀的⽅式同时⽀持同步请求和异步请求。所以这⼀套框架,其实是⾮常简洁易⽤的。在使⽤Netty框架进⾏相关应⽤开发时,都可以借鉴他的这⼀套服务注册机制。例如开发⼀个⼤型的IM项⽬,要添加好友、发送⽂本、发送图⽚、发送附件、甚至还有表情、红包等等各种各样的请求。这些请求如何封装,就可以参考这⼀套服务注册框架。

4、Broker⼼跳注册管理

1、关注重点

把RocketMQ的服务调⽤框架整理清楚之后,接下来就可以从⼀些具体的业务线来进行详细梳理了。

之前介绍过,Broker会在启动时向所有NameServer注册⾃⼰的服务信息,并且会定时往NameServer发送⼼跳信息。⽽NameServer会维护Broker的路由列表,并对路由表进行实时更新。这⼀轮就重点梳理这个过程。

2、源码重点

Broker启动后会⽴即发起向NameServer注册⼼跳。⽅法⼊⼝:BrokerController.this.registerBrokerAll。 然后启动⼀个定时任务,以10秒延迟,默认30秒的间隔持续向NameServer发送⼼跳。

js 复制代码
//K4 Broker向NameServer进⾏⼼跳注册
if (!isIsolated && !this.messageStoreConfig.isEnableDLegerCommitLog() && !this.messageStoreConfig.isDuplicationEnable()) {
    changeSpecialServiceStatus(this.brokerConfig.getBrokerId() == MixAll.MASTER_ID);
    this.registerBrokerAll(true, false, true);
}
//启动后定时注册
scheduledFutures.add(this.scheduledExecutorService.scheduleAtFixedRate(new AbstractBrokerRunnable(this.getBrokerIdentity())
{
    @Override
    public void run0() {
        try {
            if (System.currentTimeMillis() < shouldStartTime) {
                BrokerController.LOG.info("Register to namesrv after {}", shouldStartTime);
                return;
            }
            if (isIsolated) {
                BrokerController.LOG.info("Skip register for broker is isolated");
                return;
            }
            BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister());
        } catch (Throwable e) {
            BrokerController.LOG.error("registerBrokerAll Exception", e);
        }
    }
}, 1000 * 10, Math.max(10000, Math.min(brokerConfig.getRegisterNameServerPeriod(), 60000)), TimeUnit.MILLISECONDS));

NameServer内部会通过RouteInfoManager组件及时维护Broker信息。具体参⻅org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager的registerBroker⽅法

同时在NameServer启动时,会启动定时任务,扫描不活动的Broker。⽅法⼊⼝:NamesrvController.initialize⽅法,往下跟踪到startScheduleService⽅法。。

3、极简化的服务注册发现流程 为什么RocketMQ要⾃⼰实现⼀个NameServer,⽽不⽤Zookeeper、Nacos这样现成的注册中⼼?

⾸先,依赖外部组件会对产品的独⽴性形成侵⼊,不利于⾃⼰的版本演进。Kafka要抛弃Zookeeper就是⼀个先例。

另外,其实更重要的还是对业务的合理设计。NameServer之间不进⾏信息同步,⽽是依赖Broker端向所有NameServer同时发起注册。这让NameServer的服务可以非常轻量。如果可能,你可以与Nacos或Zookeeper的核⼼流程做下对⽐。NameServer集群只要有⼀个节点存活。整个集群就能正常提供服务,⽽Zookeeper,Nacos等都是基于多数派同意的机制,需要集群中超过半数的节点存活才能正常提供服务。

但是,要知道,这种极简的设计,其实是以牺牲数据一致性为代价的。Broker往多个NameServer同时发起注册,有可能部分NameServer注册成功,⽽部分NameServer注册失败了。这样,多个NameServer之间的数据是不一致的。作为通⽤的注册中⼼,这是不可接受的。但是对于RocketMQ,这⼜变得可以接受了。因为客户端从NameServer上获得Broker列表后,只要有⼀个正常运行的Broker就可以了,并不需要完整的Broker列表。

5、Producer发送消息过程

1、关注重点

⾸先:回顾下我们之前的Producer使⽤案例。

Producer有两种:

  • ⼀种是普通发送者:DefaultMQProducer。只负责发送消息,发送完消息,就可以停⽌了。
  • 另一种是事务消息发送者: TransactionMQProducer。⽀持事务消息机制。需要在事务消息过程中提供事务状态确认的服务,这就要求事务消息发送者虽然是⼀个客户端,但是也要完成整个事务消息的确认机制后才能退出。

事务消息机制后⾯将结合Broker进⾏整理分析。这⼀步暂不关注。我们只关注DefaultMQProducer的消息发送过程。

然后:整个Producer的使⽤流程,⼤致分为两个步骤:⼀是调⽤start⽅法,进⾏⼀⼤堆的准备⼯作。 ⼆是各种send⽅法,进⾏消息发送。

那我们重点关注以下⼏个问题:

  1. Producer启动过程中启动了哪些服务。也就是了解RocketMQ的Client客户端的基础结构。
  2. Producer如何管理broker路由信息。 可以设想一下,如果Producer启动了之后,NameServer挂了,那么Producer还能不能发送消息?希望你先从源码中进⾏猜想,然后⾃⼰设计实验进⾏验证。
  3. 关于Producer的负载均衡。也就是Producer到底将消息发到哪个MessageQueue中。这⾥可以结合顺序消息机制来理解⼀下。消息中那个莫名其妙的MessageSelector到底是如何⼯作的。

2、源码重点

1、Producer的核⼼启动流程

所有Producer的启动过程,最终都会调⽤到DefaultMQProducerImpl#start⽅法。在start⽅法中的通过⼀个mQClientFactory对象,启动⽣产者的⼀⼤堆重要服务。

这个mQClientFactory是最为重要的⼀个对象,负责⽣产所有的Client,包括Producer和Consumer。

这⾥其实就是⼀种设计模式,虽然有很多种不同的客户端,但是这些客户端的启动流程最终都是统一的,全是交由mQClientFactory对象来启动。⽽不同之处在于这些客户端在启动过程中,按照服务端的要求注册不同的信息。例如⽣产者注册到producerTable,消费者注册到consumerTable,管理控制端注册到adminExtTable

2、发送消息的核⼼流程

核⼼流程如下:

1、发送消息时,会维护⼀个本地的topicPublishInfoTable缓存,DefaultMQProducer会尽量保证这个缓存数据是最新的。但是,如果NameServer挂了,那么DefaultMQProducer还是会基于这个本地缓存去找Broker。只要能找到Broker,还是可以正常发送消息到Broker的。 --可以在⽣产者示例中,start后打⼀个断点,然后把NameServer停掉,这时,Producer还是可以发送消息的。

2、⽣产者如何找MessageQueue: 默认情况下,⽣产者是按照轮训的⽅式,依次轮训各个MessageQueue。但是如果某⼀次往⼀个Broker发送请求失败后,下一次就会跳过这个Broker。

js 复制代码
//org.apache.rocketmq.client.impl.producer.TopicPublishInfo
//QueueFilter是⽤来过滤掉上⼀次失败的Broker的,表示上⼀次向这个Broker发送消息是失败的,这时就尽量不要再往这个Broker发送消息了。
private MessageQueue selectOneMessageQueue(List<MessageQueue> messageQueueList, ThreadLocalIndex sendQueue, QueueFilter ...filter) {
    if (messageQueueList == null || messageQueueList.isEmpty()) {
        return null;
    }
    if (filter != null && filter.length != 0) {
        for (int i = 0; i < messageQueueList.size(); i++) {
            int index = Math.abs(sendQueue.incrementAndGet() % messageQueueList.size());
            MessageQueue mq = messageQueueList.get(index);
            boolean filterResult = true;
            for (QueueFilter f: filter) {
                Preconditions.checkNotNull(f);
                filterResult &= f.filter(mq);
            }
            if (filterResult) {
                return mq;
            }
        }
        return null;
    }
    int index = Math.abs(sendQueue.incrementAndGet() % messageQueueList.size());
    return messageQueueList.get(index);
}

3、如果在发送消息时传了Selector,那么Producer就不会⾛这个负载均衡的逻辑,⽽是会使⽤Selector去寻找⼀个队列。 具体参⻅org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#sendSelectImpl ⽅法。

js 复制代码
//K4 Producer顺序消息的发送⽅法
public MessageQueue invokeMessageQueueSelector(Message msg, MessageQueueSelector selector, Object arg, final long timeout) throws MQClientException, RemotingTooMuchRequestException {
long beginStartTime = System.currentTimeMillis();
this.makeSureStateOK();
Validators.checkMessage(msg, this.defaultMQProducer);
TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
if (topicPublishInfo != null && topicPublishInfo.ok()) {
    MessageQueue mq = null;
    try {
    List<MessageQueue> messageQueueList = mQClientFactory.getMQAdminImpl().parsePublishMessageQueues(topicPublishInfo.getMessageQueueList());
    Message userMessage = MessageAccessor.cloneMessage(msg);
    String userTopic = NamespaceUtil.withoutNamespace(userMessage.getTopic(),
    mQClientFactory.getClientConfig().getNamespace());
    userMessage.setTopic(userTopic);
    //由selector选择出⽬标mq
    mq = mQClientFactory.getClientConfig().queueWithNamespace(selector.select(messageQueueList, userMessage, arg));
    } catch (Throwable e) {
        throw new MQClientException("select message queue threw exception.", e);
    }
    long costTime = System.currentTimeMillis() - beginStartTime;
    if (timeout < costTime) {
        throw new RemotingTooMuchRequestException("sendSelectImpl call timeout");
    }
    if (mq != null) {
        return mq;
    } else {
        throw new MQClientException("select message queue return null.", null);
    }

}
validateNameServerSetting();
throw new MQClientException("No route info for this topic, " + msg.getTopic(), null);
}

6、Consumer拉取消息过程

1、关注重点

结合我们之前的示例,回顾下消费者这⼀块的⼏个重点问题:

  • 消费者也是有两种,推模式消费者和拉模式消费者。优秀的MQ产品都会有⼀个⾼级的⽬标,就是要提升整个消息处理的性能。⽽要提升性能,服务端的优化⼿段往往不够直接,最为直接的优化⼿段就是对消费者进行优化。所以在RocketMQ中,整个消费者的业务逻辑是⾮常复杂的,甚⾄某种程度上来说,⽐服务端更复杂,所以,在这⾥我们重点关注⽤得最多的推模式的消费者。
  • 消费者组之间有集群模式和⼴播模式两种消费模式。我们就要了解下这两种集群模式是如何做的逻辑封装。
  • 然后我们关注下消费者端的负载均衡的原理。即消费者是如何绑定消费队列的,哪些消费策略到底是如何落地的。
  • 最后我们来关注下在推模式的消费者中,MessageListenerConcurrently 和MessageListenerOrderly这两种消息监听器的处理逻辑到底有什么不同,为什么后者能保持消息顺序。

2、源码重点

Consumer的核⼼启动过程和Producer是⼀样的, 最终都是通过mQClientFactory对象启动。不过之间添加了⼀些注册信息。整体的启动过程如下:

3、⼴播模式与集群模式的Offset处理

在DefaultMQPushConsumerImpl的start⽅法中,启动了⾮常多的核⼼服务。 ⽐如,对于⼴播模式与集群模式的Offset处理

js 复制代码
if (this.defaultMQPushConsumer.getOffsetStore() != null) {
    this.offsetStore = this.defaultMQPushConsumer.getOffsetStore();
} else {
    switch (this.defaultMQPushConsumer.getMessageModel()) {
        case BROADCASTING:
            this.offsetStore = new LocalFileOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
            break;
        case CLUSTERING:
            this.offsetStore = new RemoteBrokerOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
            break;
        default:
            break;
    }
    this.defaultMQPushConsumer.setOffsetStore(this.offsetStore);
}
this.offsetStore.load();

可以看到,⼴播模式是使⽤LocalFileOffsetStore,在Consumer本地保存Offset,⽽集群模式是使⽤RemoteBrokerOffsetStore,在Broker端远程保存offset。⽽这两种Offset的存储⽅式,最终都是通过维护本地的offsetTable缓存来管理Offset。

4、Consumer与MessageQueue建⽴绑定关系

start⽅法中还⼀个⽐较重要的东⻄是给rebalanceImpl设定了⼀个AllocateMessageQueueStrategy,⽤来给Consumer分配MessageQueue的。

js 复制代码
this.rebalanceImpl.setMessageModel(this.defaultMQPushConsumer.getMessageModel());
//Consumer负载均衡策略
this.rebalanceImpl.setAllocateMessageQueueStrategy(this.defaultMQPushConsumer.getAllocateMessageQueueStrategy());

这个AllocateMessageQueueStrategy就是⽤来给Consumer和MessageQueue之间建⽴⼀种对应关系的。也就是说,只要Topic当中的MessageQueue以及同⼀个ConsumerGroup中的Consumer实例都没有变动,那么某⼀个Consumer实例只是消费固定的⼀个或多个MessageQueue上的消息,其他Consumer不会来抢这个Consumer对应的MessageQueue。

关于具体的分配策略,可以看下RocketMQ中提供的AllocateMessageQueueStrategy接⼝实现类。你能⾃⼰尝试看懂他的分配策略吗?

这⾥,你可以想⼀下为什么要让⼀个MessageQueue只能由同⼀个ConsumerGroup中的⼀个Consumer实例来消费。

其实原因很简单,因为Broker需要按照ConsumerGroup管理每个MessageQueue上的Offset,如果⼀个MessageQueue上有多个同属⼀个ConsumerGroup的Consumer实例,他们的处理进度就会不⼀样。这样的话,Offset就乱套了。

5、顺序消费与并发消费

同样在start⽅法中,启动了consumerMessageService线程,进⾏消息拉取。

js 复制代码
//K6 消费者顺序消费与并发消费的区别
if (this.getMessageListenerInner() instanceof MessageListenerOrderly) {//顺序消费监听器
    this.consumeOrderly = true;
    this.consumeMessageService =
    new ConsumeMessageOrderlyService(this, (MessageListenerOrderly) this.getMessageListenerInner());
    //POPTODO reuse Executor ? ⼀种新的MessageQueue⼯作模式。还在TODO中,就暂不关注了。
    this.consumeMessagePopService = new ConsumeMessagePopOrderlyService(this,
    (MessageListenerOrderly) this.getMessageListenerInner());
} else if (
    this.getMessageListenerInner() instanceof MessageListenerConcurrently) { //并发消费监听器
    this.consumeOrderly = false;
    this.consumeMessageService = new ConsumeMessageConcurrentlyService(this, (MessageListenerConcurrently) this.getMessageListenerInner());
    //POPTODO reuse Executor ?
    this.consumeMessagePopService = new ConsumeMessagePopConcurrentlyService(this,
    (MessageListenerConcurrently) this.getMessageListenerInner());
}
this.consumeMessageService.start();
// POPTODO
this.consumeMessagePopService.start();

可以看到, Consumer通过registerMessageListener⽅法指定的回调函数,都被封装成了ConsumerMessageService的⼦实现类。

js 复制代码
@Override
public void run() {
    logger.info(this.getServiceName() + " service started");
    while (!this.isStopped()) {
        try {
            MessageRequest messageRequest = this.messageRequestQueue.take();
            if (messageRequest.getMessageRequestMode() == MessageRequestMode.POP) {
                this.popMessage((PopRequest) messageRequest);
            } else {
                this.pullMessage((PullRequest) messageRequest);
            }
        } catch (InterruptedException ignored) {
        } catch (Exception e) {
            logger.error("Pull Message Service Run Method exception", e);
        }
    }
    logger.info(this.getServiceName() + " service end");
}

⽽对于这两个服务实现类的调⽤,会延续到DefaultMQPushConsumerImpl的pullCallback对象中。也就是Consumer每拉过来⼀批消息后,就向Broker提交下⼀个拉取消息的的请求。

js 复制代码
PullCallback pullCallback = new PullCallback() {

    @Override
    public void onSuccess(PullResult pullResult) {
        if (pullResult != null) {
        //...
        switch (pullResult.getPullStatus()) {
            case FOUND:
            //...
            DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(pullResult.getMsgFoundList(), processQueue, pullRequest.getMessageQueue(), dispatchToConsume);
            //...
            break;
            //...
        }
    }

}

⽽这⾥提交的,实际上是⼀个ConsumeRequest线程。⽽提交的这个ConsumeRequest线程,在两个不同的ConsumerService中有不同的实现。

这其中,两者最为核心的区别在于ConsumeMessageConcurrentlyService只是控制⼀批请求的⼤⼩,⽽并不控制从哪个MessageQueue上拉取消息。

js 复制代码
@Override

public void submitConsumeRequest(final List<MessageExt> msgs, final ProcessQueue processQueue, final MessageQueue messageQueue, final boolean dispatchToConsume) {
    final int consumeBatchSize = this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
    if (msgs.size() <= consumeBatchSize) {
        ConsumeRequest consumeRequest = new ConsumeRequest(msgs, processQueue, messageQueue);
        try {//往线程池中提交ConsumeRequest。注意,在线程池中,多个ConsumeRequest是并发执⾏的。所以如果没有并发控制,会有多个线程同时拉取消息。
            this.consumeExecutor.submit(consumeRequest);
        } catch (RejectedExecutionException e) {
            this.submitConsumeRequestLater(consumeRequest);
        }

    } else {
        for (int total = 0; total < msgs.size(); ) {
            List<MessageExt> msgThis = new ArrayList<>(consumeBatchSize);
            for (int i = 0; i < consumeBatchSize; i++, total++) {
                if (total < msgs.size()) {
                    msgThis.add(msgs.get(total));
                } else {
                    break;
                }
            }

            ConsumeRequest consumeRequest = new ConsumeRequest(msgThis, processQueue, messageQueue);

            try {
                this.consumeExecutor.submit(consumeRequest);
            } catch (RejectedExecutionException e) {
                for (; total < msgs.size(); total++) {
                    msgThis.add(msgs.get(total));
                }
                this.submitConsumeRequestLater(consumeRequest);
            }
        }
    }

}

⽽ConsumerMessageOrderlyService是锁定了⼀个队列,处理完了之后,再消费下⼀个队列。

js 复制代码
@Override

public void submitConsumeRequest(final List<MessageExt> msgs, final ProcessQueue processQueue, final MessageQueue messageQueue, final boolean dispatchToConsume) {
    if (dispatchToConsume) {//不做请求批量⼤⼩控制,直接提交请求
        ConsumeRequest consumeRequest = new ConsumeRequest(processQueue, messageQueue);
        this.consumeExecutor.submit(consumeRequest);
    }
}

//ConsumerMessageOrderlyService中定义的consumerRequest
public void run() {
    // ....
    final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
    synchronized (objLock) {
    //....
    }
}

为什么给队列加个锁,就能保证顺序消费呢?结合顺序消息的实现机制理解⼀下。

从源码中可以看到,Consumer提交请求时,都是往线程池⾥异步提交的请求。如果不加队列锁,那么就算Consumer提交针对同⼀个MessageQueue的拉取消息请求,这些请求都是异步执⾏,他们的返回顺序是乱的,⽆法进⾏控制。给队列加个锁之后,就保证了针对同⼀个队列的第⼆个请求,必须等第⼀个请求处理完了之后,释放了锁,才可以提交。这也是在异步情况下保证顺序的基础思路。

6、实际拉取消息还是通过PullMessageService完成的。

start⽅法中,相当于对很多消费者的服务进⾏初始化,包括指定⼀些服务的实现类,以及启动⼀些定时的任务线程,⽐如清理过期的请求缓存等。最后,会随着mQClientFactory组件的启动,启动⼀个PullMessageService。实际的消息拉取都交由PullMesasgeService进⾏。

所谓消息推模式,其实还是通过Consumer拉消息实现的。

js 复制代码
//org.apache.rocketmq.client.impl.consumer.PullMessageService下的pullMessage⽅法
private void pullMessage(final PullRequest pullRequest) {
    final MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup());

    if (consumer != null) {
        DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl) consumer;
        impl.pullMessage(pullRequest);
    } else {
        log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
    }
}

另外还⼀种pop⼯作模式也是在PullMessagService下的popMessage⽅法触发。

7、客户端负载均衡管理总结

从之前Producer发送消息的过程以及Conmer拉取消息的过程,我们可以抽象出RocketMQ中⼀个消息分配的管理模型。这个模型是我们在使⽤RocketMQ时,很重要的进⾏性能优化的依据。

1 Producer负载均衡

Producer发送消息时,默认会轮询⽬标Topic下的所有MessageQueue,并采⽤递增取模的⽅式往不同的MessageQueue上发送消息,以达到让消息平均落在不同的queue上的⽬的。⽽由于MessageQueue是分布在不同的Broker上的,所以消息也会发送到不同的broker上。

在之前源码中看到过,Producer轮训时,如果发现往某⼀个Broker上发送消息失败了,那么下⼀次会尽量避免再往同⼀个Broker上发送消息。但是,如果你的应⽤场景允许发送消息⻓延迟,也可以给Producer设定setSendLatencyFaultEnable(true)。这样对于某些Broker集群的⽹络不是很好的环境,可以提⾼消息发送成功的⼏率。

同时⽣产者在发送消息时,可以指定⼀个MessageQueueSelector。通过这个对象来将消息发送到⾃⼰指定的MessageQueue上。这样可以保证消息局部有序。

2 Consumer负载均衡

Consumer也是以MessageQueue为单位来进⾏负载均衡。分为集群模式和⼴播模式。

1、集群模式

在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的⼀个实例即可。RocketMQ采⽤主动拉取的⽅式拉取并消费消息,在拉取的时候需要明确指定拉取哪⼀条message queue。

⽽每当实例的数量有变更,都会触发⼀次所有实例的负载均衡,这时候会按照queue的数量和实例的数量平均分配queue给每个实例。

每次分配时,都会将MessageQueue和消费者ID进⾏排序后,再⽤不同的分配算法进⾏分配。内置的分配的算法共有六种,分别对应AllocateMessageQueueStrategy下的六种实现类,可以在consumer中直接set来指定。默认情况下使用的是最简单的平均分配策略。

  • AllocateMachineRoomNearby: 将同机房的Consumer和Broker优先分配在一起。

这个策略可以通过⼀个machineRoomResolver对象来定制Consumer和Broker的机房解析规则。然后还需要引⼊另外⼀个分配策略来对同机房的Broker和Consumer进⾏分配。⼀般也就⽤简单的平均分配策略或者轮询分配策略。

源码中有测试代码AllocateMachineRoomNearByTest。

在示例中:Broker的机房指定⽅式: messageQueue.getBrokerName().split("-")[0],⽽Consumer的机房指定⽅式:clientID.split("-")[0]

clinetID的构建⽅式:⻅ClientConfig.buildMQClientId⽅法。按他的测试代码应该是要把clientIP指定为IDC1-CID-0这样的形式。

  • AllocateMessageQueueAveragely:平均分配。将所有MessageQueue平均分给每⼀个消费者
  • AllocateMessageQueueAveragelyByCircle: 轮询分配。轮流的给⼀个消费者分配⼀个MessageQueue。
  • AllocateMessageQueueByConfig: 不分配,直接指定⼀个messageQueue列表。类似于⼴播模式,直接指定所有队列。
  • AllocateMessageQueueByMachineRoom:按逻辑机房的概念进⾏分配。⼜是对BrokerName和ConsumerIdc有定制化的配置。
  • AllocateMessageQueueConsistentHash。源码中有测试代码AllocateMessageQueueConsitentHashTest。这个⼀致性哈希策略只需要指定⼀个虚拟节点数,是⽤的⼀个哈希环的算法,虚拟节点是为了让Hash数据在换上分布更为均匀。

最常⽤的就是平均分配和轮训分配了。例如平均分配时的分配情况是这样的:

⽽轮训分配就不计算了,每次把⼀个队列分给下⼀个Consumer实例。

2、⼴播模式

⼴播模式下,每⼀条消息都会投递给订阅了Topic的所有消费者实例,所以也就没有消息分配这⼀说。⽽在实现上,就是在Consumer分配Queue时,所有Consumer都分到所有的Queue。

⼴播模式实现的关键是将消费者的消费偏移量不再保存到broker当中,⽽是保存到客户端当中,由客户端⾃⾏维护⾃⼰的消费偏移量。

相关推荐
林太白12 分钟前
也许看了Electron你会理解Tauri,扩宽你的技术栈
前端·后端·electron
松果集18 分钟前
【Python3】练习一
后端
anganing19 分钟前
Web 浏览器预览 Excel 及打印
前端·后端
肯定慧22 分钟前
B1-基于大模型的智能办公应用软件
后端
TinyKing31 分钟前
一、getByRole 的作用
后端
brzhang41 分钟前
我们复盘了100个失败的AI Agent项目,总结出这3个“必踩的坑”
前端·后端·架构
郝同学的测开笔记1 小时前
云原生探索系列(十九):Go 语言 context.Context
后端·云原生·go
小小琪_Bmob后端云1 小时前
引流之评论区截流实验
前端·后端·产品
考虑考虑1 小时前
JDK9中的takeWhile
java·后端·java ee