【RocketMQ】Producer消息发送流程分析

834 阅读9分钟

1. 前言

MQProducer是RocketMQ提供的生产者接口,默认实现为DefaultMQProducer,如果要发送事务消息,对应的实现类为TransactionMQProducer。本篇暂不讨论事务消息,主要分析下默认生产者的启动和发送消息的流程。 ​

DefaultMQProducer仅仅是RocketMQ暴露给用户使用的外观类,它内部持有一个生产者实现DefaultMQProducerImpl,它才是具体的执行者。使用这种结构的好处是,RocketMQ在后续版本可以任意更换实现类,这一切对用户而言是零感知的。 ​

RocketMQ基于Netty实现网络传输,Producer要和NameServer、Broker交互,所以它自然也是一个Netty客户端,Producer在启动时,会同时启动Netty客户端,然后向NameServer拉取Broker信息,向Broker发送心跳。发送消息时,从NameServer拉取Topic路由消息,轮询出一个MessageQueue,再将消息发到关联的Broker上。

2. 源码分析

笔者画了Producer启动和消息发送的时序图: 在这里插入图片描述

2.1 客户端启动

在发送消息前,首先需要创建Producer,然后启动它。

DefaultMQProducer producer = new DefaultMQProducer(GroupName);
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();

1.在DefaultMQProducer的构造函数里,会创建生产者实现类DefaultMQProducerImpl。

public DefaultMQProducer(final String namespace, final String producerGroup, RPCHook rpcHook) {
    // 命名空间
    this.namespace = namespace;
    // 生产者组名
    this.producerGroup = producerGroup;
    // 生产者实现
    defaultMQProducerImpl = new DefaultMQProducerImpl(this, rpcHook);
}

2.启动Producer,因为仅仅是外观类,它的核心还是在于启动DefaultMQProducerImpl。如果开启消息轨迹追踪,还会额外启动TraceDispatcher服务,这里暂不讨论。

public void start() throws MQClientException {
    this.setProducerGroup(withNamespace(this.producerGroup));
    this.defaultMQProducerImpl.start();
    if (null != traceDispatcher) {
        try {
            traceDispatcher.start(this.getNamesrvAddr(), this.getAccessChannel());
        } catch (MQClientException e) {
            log.warn("trace dispatcher start failed ", e);
        }
    }
}

DefaultMQProducerImpl的启动,主要做了以下事情:

  1. 校验GroupName
  2. 设置InstanceName
  3. 创建MQClientInstance
  4. 注册Producer到producerTable
  5. 启动MQClientInstance
  6. 向所有Broker发心跳

一步步看,首先checkConfig()方法会校验GroupName的合法性,不能过长,不能特殊字符,不能用DEFAULT_PRODUCER等。 ​

如果没有设置InstanceName,会自动设置为进程PID+#+时间戳。

public void changeInstanceNameToPID() {
    if (this.instanceName.equals("DEFAULT")) {
        this.instanceName = UtilAll.getPid() + "#" + System.nanoTime();
    }
}

然后,根据ClientID去获取MQClientInstance,不存在会创建新的客户端实例,将ProducerGroup注册到MQClientInstance中。

this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQProducer, rpcHook);
mQClientFactory.registerProducer(this.defaultMQProducer.getProducerGroup(), this);

注册成功后,就可以启动MQClientInstance了。 ​

4.一个MQClientInstance代表一个客户端实例,它内部维护了一个Netty客户端NettyRemotingClient用来和服务端通信,该对象较重,避免频繁创建。 MQClientInstance的启动过程,主要做了以下事情:

  1. 发请求获取NameServerAddr
  2. 启动Netty客户端
  3. 启动各种定时任务
  4. 启动消息拉去服务(消费者)
  5. 启动负载均衡服务(消费者)

首先,如果你没有手动设置NameServerAddr,客户端会尝试读取环境变量rocketmq.namesrv.domain,它的期望值是一个URL链接,客户端会每2分钟发一次HTTP请求,并更新NameServerAddr。为什么要这么做呢?硬编码写死的NameServerAddr不够灵活,因为NameServer很可能是集群环境,具体有几台机器,机器的IP都是不固定的,你可以通过配置中心灵活的下发这些数据。

if (null == this.clientConfig.getNamesrvAddr()) {
    this.mQClientAPIImpl.fetchNameServerAddr();
}

有了NameServerAddr,就可以启动Netty客户端进行通信了,MQClientAPIImpl内置了NettyRemotingClient。

this.mQClientAPIImpl.start();

Netty客户端启动后,就可以和服务端通信了,包括:从NameServer拉取Topic路由信息、发送心跳到Broker等,这些都是通过定时任务的方式进行的,所以Producer会开始启动各种定时任务。

this.startScheduledTask();

5.启动各种定时任务,如下: 每两分钟更新一次NameServerAddr:

if (null == this.clientConfig.getNamesrvAddr()) {
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

        @Override
        public void run() {
            try {
                MQClientInstance.this.mQClientAPIImpl.fetchNameServerAddr();
            } catch (Exception e) {
                log.error("ScheduledTask fetchNameServerAddr exception", e);
            }
        }
    }, 1000 * 10, 1000 * 60 * 2, TimeUnit.MILLISECONDS);
}

定时从NameServer更新Topic路由信息:

this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {

    @Override
    public void run() {
        try {
            MQClientInstance.this.updateTopicRouteInfoFromNameServer();
        } catch (Exception e) {
            log.error("ScheduledTask updateTopicRouteInfoFromNameServer exception", e);
        }
    }
}, 10, this.clientConfig.getPollNameServerInterval(), TimeUnit.MILLISECONDS);

清理下线的Broker、以及发心跳:

this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        try {
            MQClientInstance.this.cleanOfflineBroker();
            MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();
        } catch (Exception e) {
            log.error("ScheduledTask sendHeartbeatToAllBroker exception", e);
        }
    }
}, 1000, this.clientConfig.getHeartbeatBrokerInterval(), TimeUnit.MILLISECONDS);

6.Netty客户端启动,定时任务启动,最后Producer会向所有Broker主动发送一次心跳,发送心跳之后,如果是消费者,还会上传FilterClassSource到Broker,由Broker来执行消息过滤逻辑。

public void sendHeartbeatToAllBrokerWithLock() {
    if (this.lockHeartbeat.tryLock()) {
        try {
            // 发送心跳给所有的Broker
            this.sendHeartbeatToAllBroker();
            // 消费者:上传FilterClassSource,Broker消息过滤用的
            this.uploadFilterClassSource();
        } catch (final Exception e) {
            log.error("sendHeartbeatToAllBroker exception", e);
        } finally {
            this.lockHeartbeat.unlock();
        }
    } else {
        log.warn("lock heartBeat, but failed. [{}]", this.clientId);
    }
}

至此,服务就启动完成了。 ​

2.2 消息发送

Producer消息发送有三种方式:同步发送、异步发送、单向发送。同步发送是阻塞的,需要等待客户端响应ACK才会继续往下走。异步发送是非阻塞的,发送时注册一个回调函数,消息发送成功/失败会执行回调。单向发送只管把请求发出去,不在乎发送是否成功,速度是最快的,在微妙级。 ​

这里暂且只讨论同步发送,对应的API如下:

Message msg = new Message("Topic Name", "message body".getBytes());
SendResult result = producer.send(msg);

Producer消息发送过程可以拆分为三层:接口层、核心消息处理层、网络传输层。客户端调用的API就是接口层,非常的简单,但是后续Producer还要对消息做一系列的处理,最终通过Netty发送到服务端。 ​

1.接口层没什么可看的,就是调用RocketMQ提供的API,直接看DefaultMQProducerImpl的sendDefaultImpl方法。它主要做了以下事:

  1. 校验消息
  2. 获取Topic发布信息
  3. 处理消息重试

首先对发送的消息进行校验,Topic必须合法,不允许发送到系统预定义的Topic里,消息Body不能为空,空消息对于RocketMQ而言是没有意义的。

Validators.checkMessage(msg, this.defaultMQProducer);

消息校验完毕后就可以发了,但是该发到哪里呢? ​

Producer会从缓存中读取Topic对应的TopicPublishInfo,它是Topic的发布信息,包含了Topic下有哪些队列,以及Topic的路由信息。如果缓存没有数据,会请求NameServer拉取。

private TopicPublishInfo tryToFindTopicPublishInfo(final String topic) {
    // 先从本地缓存获取
    TopicPublishInfo topicPublishInfo = this.topicPublishInfoTable.get(topic);
    if (null == topicPublishInfo || !topicPublishInfo.ok()) {
        // 没有缓存,或失效
        this.topicPublishInfoTable.putIfAbsent(topic, new TopicPublishInfo());
        // 发请求从NameServer拉取,并更新缓存
        this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic);
        topicPublishInfo = this.topicPublishInfoTable.get(topic);
    }

    if (topicPublishInfo.isHaveTopicRouterInfo() || topicPublishInfo.ok()) {
        return topicPublishInfo;
    } else {
        this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic, true, this.defaultMQProducer);
        topicPublishInfo = this.topicPublishInfoTable.get(topic);
        return topicPublishInfo;
    }
}

获取到TopicPublishInfo,就可以发送了,首先会计算重试次数,如果是同步发送,会重试2次,单向和异步这里不重试。

int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1;

使用String数组记录发送失败的Broker,重试的时候跳过这些Broker。

String[] brokersSent = new String[timesTotal];

为了提高消息生产和消费的效率,也为了消息可以分片存储,单个Topic下是可以有多个消息队列MessageQueue的,那消息该发往哪个MessageQueue呢?

Producer会调用selectOneMessageQueue方法,从列表中选择一个MessageQueue,默认是轮询算法。

MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);

选中的MessageQueue包含了队列存储在哪个Broker上,它的QueueID是多少。

// 所属Topic
private String topic;
// 存储的Broker
private String brokerName;
// 队列ID
private int queueId;

有了MessageQueue,Producer就知道该把消息发到哪个Broker上了,接下来会调用消息发送的核心方法sendKernelImpl进行发送。 ​

2.sendKernelImpl是消息发送的核心方法,主要做了如下事情:

  1. 根据BrokerName找到Master地址
  2. 消息压缩处理
  3. 设置消息sysFlag
  4. 执行钩子函数
  5. 构建Header,调用API发送

根据MessageQueue所在的BrokerName找到Broker主机地址,Broker是可以部署集群的,但是消息发送只会往Master发,所以必须找到brokerId=0的机器。

public String findBrokerAddressInPublish(final String brokerName) {
    HashMap<Long/* brokerId */, String/* address */> map = this.brokerAddrTable.get(brokerName);
    if (map != null && !map.isEmpty()) {
        // 查找brokerId为0的Broker地址
        return map.get(MixAll.MASTER_ID);
    }
    return null;
}

如果本地缓存没有找到,会从NameServer拉取。

if (null == brokerAddr) {
    // 缓存没找到,尝试从NameServer拉取
    tryToFindTopicPublishInfo(mq.getTopic());
    brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
}

有了Broker主机地址,就可以发送了,但是发送前,Producer会做一系列处理。例如,调用tryToCompressMessage方法尝试对消息Body进行压缩传输,节省带宽。使用Zip压缩算法,压缩的前提是Body大于4KB。如果Body压缩了,必须让Broker知道啊,消息存储的时候必须解压才行,否则消息就不可读了。如何让Broker知道呢?Producer会使用消息的sysFlag属性进行标记,每一个Bit位的标记都有不同含义,第1位代表消息Body是否被压缩。

if (this.tryToCompressMessage(msg)) {
    // 尝试压缩消息体,如果压缩了,将sysFlag第一位置为1
    sysFlag |= MessageSysFlag.COMPRESSED_FLAG;
    msgBodyCompressed = true;
}

第3位代表是否是事务消息,如果是就置为1。

final String tranMsg = msg.getProperty(MessageConst.PROPERTY_TRANSACTION_PREPARED);
if (tranMsg != null && Boolean.parseBoolean(tranMsg)) {
    sysFlag |= MessageSysFlag.TRANSACTION_PREPARED_TYPE;
}

消息处理完毕后,准备发送,发送前执行前置钩子函数,这里就不看了。然后重点来了,创建请求头对象,发送消息对应的请求头是SendMessageRequestHeader,它会告诉Broker消息由谁生产的,属于哪个Topic,消息有哪些属性等等。

public class SendMessageRequestHeader implements CommandCustomHeader {
    @CFNotNull
    // 消息来自哪个生产者组?
    private String producerGroup;
    @CFNotNull
    // 消息所属Topic
    private String topic;
    @CFNotNull
    // 默认Topic
    private String defaultTopic;
    @CFNotNull
    // 默认Queue数量
    private Integer defaultTopicQueueNums;
    @CFNotNull
    // 消息要发送到MessageQueue的ID
    private Integer queueId;
    @CFNotNull
    /**
     * 系统标记,按Bit位标识
     * 第1位 代表Body是否压缩
     */
    private Integer sysFlag;
    @CFNotNull
    // 消息的创建时间
    private Long bornTimestamp;
    @CFNotNull
    // 消息Flag
    private Integer flag;
    @CFNullable
    // 自定义属性,HashMap拼接成字符串
    private String properties;
    @CFNullable
    // 重复消费次数
    private Integer reconsumeTimes;
    @CFNullable
    //
    private boolean unitMode = false;
    @CFNullable
    // 是否批量消息
    private boolean batch = false;
    // 最大重复消费次数
    private Integer maxReconsumeTimes;
}

请求头设置好了,就可以创建RemotingCommand对象了。RemotingCommand是RocketMQ的通信协议对象,不管是请求还是响应,通过Netty传输的都是RemotingCommand序列化后的字节序列。通过RequestCode和RequestHeader就可以快速创建,RequestCode对应请求码,告诉服务端自己要做什么请求,RequestHeader相当于请求参数,消息发送对应的RequestCode是SEND_MESSAGE

RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.SEND_MESSAGE, requestHeader);

消息发送的RequestCode还有一个优化的V2版本,它只不过是简化了RequestHeader的属性名,这样FastJson的性能会更好一些。 ​

RemotingCommand创建好了以后,就可以通过Netty将它发送给Broker了,对应的方法为invokeSyncImpl,在网络请求前后,会调用RPC的钩子函数。

doBeforeRpcHooks(addr, request);
RemotingCommand response = this.invokeSyncImpl(channel, request, timeoutMillis - costTime);
doAfterRpcHooks(RemotingHelper.parseChannelRemoteAddr(channel), request, response);

invokeSyncImpl最终就是将RemotingCommand通过Channel发送到服务端,然后注册一个回调,Broker响应后将结果写回ResponseFuture。

channel.writeAndFlush(request).addListener(new ChannelFutureListener() {
    @Override
    public void operationComplete(ChannelFuture f) throws Exception {
        if (f.isSuccess()) {
            responseFuture.setSendRequestOK(true);
            return;
        } else {
            responseFuture.setSendRequestOK(false);
        }

        responseTable.remove(opaque);
        responseFuture.setCause(f.cause());
        responseFuture.putResponse(null);
        log.warn("send a request command to channel <" + addr + "> failed.");
    }
});

至此,消息发送整个流程就结束了。 ​

3. 总结

DefaultMQProducer是RocketMQ提供的生产者外观类,核心实现是DefaultMQProducerImpl,这么做的目的是可以随时更换生产者实现,让用户无感知。生产者要和Broker通信,因此它也是一个Netty客户端,生产者启动的同时也会启动Netty客户端,然后通过一系列的定时任务去从NameServer拉取消息,以及和Broker发送心跳。 ​

消息发送时,Producer会先对消息做前置校验,校验通过后去NameServer查找Topic对应的路由信息,然后从众多MessageQueue中轮询出一个,根据MessageQueue对应的BrokerName查找Master主机地址,构建请求头SendMessageRequestHeader和RemotingCommand对象,通过Netty将请求发送给Broker。