RocketMQ学习笔记-入门、注册与消息发送


一、概述

RocketMQ是由阿里开源的一款高性能、高吞吐量的消息中间件。
RocketMQ设计基于主题的发布与订阅模式,其核心功能包括消息发送(producer)、消息存储(Broker)和消息消费(consumer)。

1.1 组成部分

rocketmq架构图 - rocketmq基础_JAVA


RocketMQ在架构上主要分为四部分:

1.1.1 NameServer

作为Topic路由信息的注册中心,类似于dubbo的ZooKeeper角色。NameServer应保存Broker相关元信息并给Producer和Consumer查找Broker信息。NameServer被设计成无状态的,可以横向拓展,节点之间没有通信。NameServer会维持一个路由注册表,每个Broker在启动时都会到NameServer注册,Producer在发送消息前会根据Topic到NameServer获取到Broker的路由信息,Consumer也会定时获取Topic的路由信息。

1.1.2 Broker

消息服务器是消息存储中心,主要作用是接受来自Producer的消息并存储,Consumer从这里获取,它还存储与小型相关的元数据,包括用户组、消费进度偏移量、队列信息等。从上面的部署图可以看出,多台Broker组成Master-Slave架构集群,brokerId为0代表Master,大于0代表Slave。其中,Master既可以写也可以读,Slave不可以写只可以读。
Broker消息服务器在启动时向所有NameServer注册,注册中心也与每台Broker保持长连接,并间隔10s检测Broker是否存活,如果发现宕机,则从路由注册表中移除。

1.1.3 Producer

消息发布的角色,支持分布式集群方式部署。生产者向消息服务器发送由业务应用程序系统产生的消息,RocketMQ提供了三种消息发送方法:同步、异步和单向。

  • 同步:发送者向MQ执行发送消息API时,同步等待,直到消息服务器返回发送结果。
  • 异步:发送者向MQ执行发送消息API时,指定消息发送成功后的回调函数,然后调用消息发送API后,立即返回不等待,消息发送者线程不阻塞,直到运行结束。消息发送成功或失败事件的回调任务在一个新的线程内执行。
  • 单向:发送者向MQ执行发送消息API时,不等待直接返回,也不注册回调任务。简单来说,就是只管发,不在乎消息是否成功存储在消息服务器上。

1.1.4 Comsumer

消息消费的角色,支持分布式集群方式部署。从用户角度来说,获取消息有pull/push两种方式,分别看作拉取型消费者和推送型消费者。

二、开发实践

2.1 开发环境搭建

1、下载RocketMQ开发工具包,链接。或者

wget https://downloads.apache.org/rocketmq/4.7.1/rocketmq-all-4.7.1-bin-release.zip
unzip rocketmq-all-4.7.1-bin-release.zip
cd rocketmq-all-4.7.1-bin-release

2、配置Broker公网ip

vim conf/broker.conf

# 增加配置
brokerIP1:服务器公网IP

3、修改namesrv和broker启动参数

vim bin/runserver.sh
vim bin/runbroker.sh
vim bin/tools.sh

# 修改JAVA路径为本地路径
[ ! -e "$JAVA_HOME/bin/java" ] && JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home
#[ ! -e "$JAVA_HOME/bin/java" ] && JAVA_HOME=/usr/java
#[ ! -e "$JAVA_HOME/bin/java" ] && error_exit "Please set the JAVA_HOME variable in your environment, We need java(x64)!"

# JVM具体参数
JAVA_OPT="${JAVA_OPT} -server -Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m"

其中,查找JDK路径

rocketmq-all-4.7.1-bin-release /usr/libexec/java_home -v
java_home: option requires an argument -- v
/Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home

4、启动服务

# 启动namesrv
nohup sh bin/mqnamesrv &
# 查看日志
tail -f ~/logs/rocketmqlogs/namesrv.log
INFO main - The Name Server boot success. serializeType=JSON

# 启动broker
nohup sh bin/mqbroker -n localhost:9876 autoCreateTopicEnable=true -c conf/broker.conf &
# 查看日志
tail -f ~/logs/rocketmqlogs/broker.log
INFO main - The broker[broker-a, 30.38.52.65:10911] boot success. serializeType=JSON and name server is localhost:9876

注意的是,线下环境中autoCreateTopicEnable建议为true,线上环境为false。

5、关闭服务

# 关闭broker
sh bin/mqshutdown broker

# 关闭name server:
sh bin/mqshutdown namesrv

2.2 控制台搭建

1、在GitHub克隆源代码

git clone git clone git@github.com:apache/rocketmq-externals.git

2、修改配置文件

cd rocketmq-externals/rocketmq-console/
vim src/main/resources/application.properties

# 修改为namesrv地址
rocketmq.config.namesrvAddr=127.0.0.1:9876
# 访问端口
server.port=8080

3、打包运行

# 忽略测试用例
mvn clean package -DskipTests
# 启动程序
nohup java -jar rocketmq-console-ng-1.0.1.jar &
nohup java -jar rocketmq-console-ng-1.0.1.jar -server.port=8080--rocketmq.config.namesrvAddr=127.0.0.1:9876
# 查看日志
tail -f ~/logs/consolelogs/rocketmq-console.log

4、访问localhost:8080即可进入控制台页面。

2.3 实例应用

添加依赖

<dependency>
	<groupId>org.apache.rocketmq</groupId>
	<artifactId>rocketmq-client</artifactId>
	<version>4.7.1</version>
</dependency>

如果该版本在消费消息时报错,就替换为4.5.0版本。

2.3.1 发送顺序消息

一个topic下有多个queue(默认是4个),如果将相同类型的消息发送到同一个队列中,那就保证了发送有序。
在send函数时,去实现一个MessageQueueSelector的匿名类,保证每条消息选择的队列是同一个。

public class Producer {
    public static void main(String[] args) throws MQClientException, InterruptedException {
        DefaultMQProducer producer = new DefaultMQProducer("TestRocketMQProducer");
        producer.setSendMsgTimeout(60000);
        producer.setNamesrvAddr("127.0.0.1:9876");
        producer.start();
        int messageCount = 5;
        for (int i = 0; i < messageCount; i++) {
            try {
                Message msg = new Message("TopicTest", "TagA",
                        ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
                );
                SendResult sendResult = producer.send(msg,
                        (list, message, o) -> list.get((int) message.hashCode() % list.size()),
                        0);
                System.out.printf("%s%n", sendResult);

            } catch (Exception e) {
                e.printStackTrace();
                Thread.sleep(10);
            }
        }
        producer.shutdown();
    }
}

2.3.2 发送异步消息

异步消息需要在发送时实现sendCallback()的匿名类,里面继承了发送成功/失败的处理方法。
但需要注意的是:如果只是简单的将上面那份demo代码改为异步方式,会报错,这是因为主线程提前关闭,导致异步线程受到影响,所以我采用CountDownLatch来解决。保证异步任务都执行后才关闭主线程。后面还有一个地方用到了CountDownLatch。

public class AsynProducer {
    public static void main(String[] args) throws MQClientException, InterruptedException {
        DefaultMQProducer producer = new DefaultMQProducer("TestRocketMQProducer-Asyn");
        producer.setSendMsgTimeout(60000);
        producer.setNamesrvAddr("127.0.0.1:9876");
        producer.start();
        int messageCount = 5;
        final CountDownLatch countDownLatch = new CountDownLatch(messageCount);
        for (int i = 0; i < messageCount; i++) {
            try {
                final int index = i;
                Message msg = new Message("TopicTest", "TagA",
                        ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
                );
                producer.send(msg,
                        (list, message, o) -> list.get((int) message.hashCode() % list.size()),
                        0,
                        new SendCallback() {
                            @Override
                            public void onSuccess(SendResult sendResult) {
                                countDownLatch.countDown();
                                System.out.printf("%s%n", sendResult);
                            }
                            @Override
                            public void onException(Throwable e) {
                                countDownLatch.countDown();
                                System.out.println(e);
                            }
                        });
            } catch (Exception e) {
                e.printStackTrace();
                Thread.sleep(10);
            }
        }
        countDownLatch.await();
        producer.shutdown();
    }
}

2.3.3 接受消息

消费者注册监听有两种模式,有序消费MessageListenerOrderly和并发消费MessageListenerConcurrently,这两种模式返回值不同。
此处先用MessageListenerConcurrently实现监听。后续再分析两者不同,以及push和pull的具体区别。

public class Consumer {
    public static void main(String[] args) throws InterruptedException, MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TestRocketMQPushConsumer");
        consumer.setNamesrvAddr("127.0.0.1:9876");
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        // 订阅Topic
        consumer.subscribe("TopicTest", "*");
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
                                                            ConsumeConcurrentlyContext context) {
                System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();
        System.out.printf("Consumer Started.%n");
    }
}

三、路由中心NameServer

RocketMQ没有去选用Zookeeper充当消息管理的“注册中心”,而是设计NameServer来实现元数据(Topic路由消息等)的管理。Topic路由信息无须在集群之间保持强一致,无须去追求最终一致性,基于这种理念,RocketMQ的NameServer集群之间互不通信,降低了注册中心的实现复杂度,对网络的要求也降低了不少。

3.1 启动

NameServer启动主函数是:org.apache.rocketmq.namesrv.NamesrvStartup#main,主要分为三个步骤。

3.1.1 填充配置属性

final NamesrvConfig namesrvConfig = new NamesrvConfig();
final NettyServerConfig nettyServerConfig = new NettyServerConfig();

在启动前,NameServer会先解析配置文件,然后填充NamesrvConfig、NettyServerConfig等属性类,包括监听端口(默认是9876)、Netty业务线程池个数等参数。

3.1.2 创建NamesrvController

NamesrvController实例是NameServer的核心控制器,里面包含了各类配置、注册表信息和服务,看一下其初始化方法。

public boolean initialize() {

        // 从持久化文件中加载KV配置到内存
        this.kvConfigManager.load();
        // 创建基于netty的y远程服务
        this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService);
        // 创建线程池
        this.remotingExecutor =
            Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads(), new ThreadFactoryImpl("RemotingExecutorThread_"));
        // 注册处理远程请求的处理器
        this.registerProcessor();
        // 每隔10s扫描一次Broker,移除处于不激活状态的Broker
        this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                NamesrvController.this.routeInfoManager.scanNotActiveBroker();
            }
        }, 5, 10, TimeUnit.SECONDS);
        // 每隔10分钟打印一次KV配置
        this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                NamesrvController.this.kvConfigManager.printAllPeriodically();
            }
        }, 1, 10, TimeUnit.MINUTES);
    }

主要是加载KV配置,创建NettyServer网络处理对象,然后开启两个定时任务,在RocketMQ中将此类定时器统称为心跳检测。
在rocketmq中,默认使用json数据格式存储KV配置。

3.1.3 启动服务器

注册JVM钩子函数并启动服务器,以便监听Broker、Producer的网络请求。

// JVM钩子函数,在JVM进程关闭之前,先将线程池关闭,释放资源
Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, new Callable<Void>() {
    @Override
    public Void call() throws Exception {
        controller.shutdown();
        return null;
    }
}));
// 启动服务器
controller.start();

这里用了钩子函数,可以参考一下。如果在代码中用了线程池,我们可以注册一个JVM钩子函数,在JVM进程关闭之前,先将线程池关闭,释放资源。

3.2 路由注册

首先,我们先去看一下NameServer都有哪些路由元信息。

3.2.1 路由元信息

NameServer路由实现类:org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager

private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
  • topicQueueTable:Topic消息队列路由信息,生产者消息发送时根据路由表进行负载均衡。

其中,QueueData的数据如下:

private String brokerName;
private int readQueueNums;
private int writeQueueNums;
private int perm;//读写权限,具体含义参考PermName
private int topicSynFlag;//topic同步标记,具体含义参考TopicSysFlag
  • breakerAddrTable:Broker基础信息,包含brokerName、所属集群名称、主备Broker地址,具体如下
private String cluster;
private String brokerName;
private HashMap<Long/* brokerId */, String/* broker address */> brokerAddrs;//brokerId=0表示Master,大于0表示从Slave
  • clusterAddrTable:Broker集群信息,存储集群内所有Broker名称。
  • brokerLiveTable:Broker状态信息,NameServer每次收到心跳包时就会替换该信息,里面的lastUpdateTimestamp字段就用于存储上次收到Broker心跳包的时间。
  • filterServerTable:类模式信息过滤。

了解上述路由元信息后,去看路由注册功能的实现。
RocketMQ路由注册是通过Broker和NameServer的心跳功能来实现的。Broker启动时向集群中所有的NameServer发送心跳语句,然后每隔10s~60s再发心跳包;NameServer收到Broker心跳包后会更新brokerLiveTable缓存,然后NameServer每隔10s扫描brokerLiveTable,如果连续120s没有收到心跳包,NameServer将移除该Broker的路由信息并同时关闭Broker连接。

3.2.2 Broker发送心跳包

这时,需要先找到Broker的源代码。在启动函数org.apache.rocketmq.broker.BrokerController#start中,启动了一个定时任务,用于注册心跳包。

public void start() throws Exception {
    //...
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister());
                } catch (Throwable e) {
                    log.error("registerBrokerAll Exception", e);
                }
            }
        }, 1000 * 10, Math.max(10000, Math.min(brokerConfig.getRegisterNameServerPeriod(), 60000)), TimeUnit.MILLISECONDS);
    //...
}

具体在看org.apache.rocketmq.broker.BrokerController#registerBrokerAll函数。

public synchronized void registerBrokerAll(final boolean checkOrderConfig, boolean oneway, boolean forceRegister) {
    // 将topicConfigTable封装到TopicConfigSerializeWrapper中
    TopicConfigSerializeWrapper topicConfigWrapper = this.getTopicConfigManager().buildTopicConfigSerializeWrapper();

    // 如果当前Broker没有读写权限,重新拼装topicConfigTable到TopicConfigSerializeWrapper中
    // 就是重新将不可读或者不可写的权限封装进TopicConfigSerializeWrapper中
    if (!PermName.isWriteable(this.getBrokerConfig().getBrokerPermission())
        || !PermName.isReadable(this.getBrokerConfig().getBrokerPermission())) {
        ConcurrentHashMap<String, TopicConfig> topicConfigTable = new ConcurrentHashMap<String, TopicConfig>();
        for (TopicConfig topicConfig : topicConfigWrapper.getTopicConfigTable().values()) {
            TopicConfig tmp =
                new TopicConfig(topicConfig.getTopicName(), topicConfig.getReadQueueNums(), topicConfig.getWriteQueueNums(),
                                this.brokerConfig.getBrokerPermission());
            topicConfigTable.put(topicConfig.getTopicName(), tmp);
        }
        topicConfigWrapper.setTopicConfigTable(topicConfigTable);
    }

    // forceRegister是否强制注册
    // needRegister方法是与配置的每个NameServer进行通信
    // 判断topicConfigTable是否改变了,只要其中一个namesrv改变了就需要发起注册更新
    if (forceRegister || needRegister(this.brokerConfig.getBrokerClusterName(),
                                      this.getBrokerAddr(),
                                      this.brokerConfig.getBrokerName(),
                                      this.brokerConfig.getBrokerId(),
                                      this.brokerConfig.getRegisterBrokerTimeoutMills())) {
        doRegisterBrokerAll(checkOrderConfig, oneway, topicConfigWrapper);
    }
}

进一步到org.apache.rocketmq.broker.out.BrokerOuterAPI#registerBrokerAll函数,里面主要的就是开始并发向namesrv集群注册。

// 并发向namesrv集群注册broker
for (final String namesrvAddr : nameServerAddressList) {
    brokerOuterExecutor.execute(new Runnable() {
        @Override
        public void run() {
            try {
                //  注册broker服务任务分
                RegisterBrokerResult result = registerBroker(namesrvAddr,oneway, timeoutMills,requestHeader,body);
                if (result != null) {
                    registerBrokerResultList.add(result);
                }

                log.info("register broker[{}]to name server {} OK", brokerId, namesrvAddr);
            } catch (Exception e) {
                log.warn("registerBroker Exception, {}", namesrvAddr, e);
            } finally {
                countDownLatch.countDown();
            }
        }
    });
}

再往下的函数就是打包网络请求之类的,不再深究。Rocket网络传输是基于Netty。

3.3.3 NameServer处理心跳包

org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest网络处理器解析请求类型,如果请求类型为RequestCode.REGISTER_BROKER,则请求最终转发到org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#registerBroker函数。这个函数有点长,我在关键语句上加了注释,就不再一一解释了。

public RegisterBrokerResult registerBroker(
    final String clusterName,
    final String brokerAddr,
    final String brokerName,
    final long brokerId,
    final String haServerAddr,
    final TopicConfigSerializeWrapper topicConfigWrapper,
    final List<String> filterServerList,
    final Channel channel) {
    RegisterBrokerResult result = new RegisterBrokerResult();
    try {
        try {
            // 加写锁,防止并发修改RouteInfoManager中的路由表
            this.lock.writeLock().lockInterruptibly();
            // 判断Broker所属集群是否存在,如果不存在,则创建,然后将broker名加入到集群集合中。
            Set<String> brokerNames = this.clusterAddrTable.get(clusterName);
            if (null == brokerNames) {
                brokerNames = new HashSet<String>();
                this.clusterAddrTable.put(clusterName, brokerNames);
            }
            brokerNames.add(brokerName);

            boolean registerFirst = false;
            // 从brokerAddrTable根据BrokerName尝试获取Broker信息
            BrokerData brokerData = this.brokerAddrTable.get(brokerName);
            if (null == brokerData) {
                // 如果不存在,则说明是首次注册,并新建BrokerData放入到brokerAddrTable
                registerFirst = true;
                brokerData = new BrokerData(clusterName, brokerName, new HashMap<Long, String>());
                this.brokerAddrTable.put(brokerName, brokerData);
            }
            Map<Long, String> brokerAddrsMap = brokerData.getBrokerAddrs();
            //Switch slave to master: first remove <1, IP:PORT> in namesrv, then add <0, IP:PORT>
            //The same IP:PORT must only have one record in brokerAddrTable
            // 必须保证一个地址只能有一条记录,若有重复记录,则删除掉。
            Iterator<Entry<Long, String>> it = brokerAddrsMap.entrySet().iterator();
            while (it.hasNext()) {
                Entry<Long, String> item = it.next();
                if (null != brokerAddr && brokerAddr.equals(item.getValue()) && brokerId != item.getKey()) {
                    it.remove();
                }
            }
            // 如果存在,替换原先的地址
            String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
            // 表示非第一次注册
            registerFirst = registerFirst || (null == oldAddr);

            //如果Broker为Master,并且Broker Topic配置信息发生变化或者是初次注册,则需要创建或更新Topic路由元数据
            //填充topicQueueTable就是为默认主题自动注册路由信息
            if (null != topicConfigWrapper
                && MixAll.MASTER_ID == brokerId) {
                if (this.isBrokerTopicConfigChanged(brokerAddr, topicConfigWrapper.getDataVersion())
                    || registerFirst) {
                    ConcurrentMap<String, TopicConfig> tcTable =
                        topicConfigWrapper.getTopicConfigTable();
                    if (tcTable != null) {
                        for (Map.Entry<String, TopicConfig> entry : tcTable.entrySet()) {
                            // 根据TopicConfig创建QueueData数据结构,然后更新topicQueueTable
                            this.createAndUpdateQueueData(brokerName, entry.getValue());
                        }
                    }
                }
            }
            // 更新BrokerLiveInfo,尤其是lastUpdateTimestamp信息,这是执行路由删除的重要凭证
            BrokerLiveInfo prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr,
                                                                         new BrokerLiveInfo(
                                                                             System.currentTimeMillis(),
                                                                             topicConfigWrapper.getDataVersion(),
                                                                             channel,
                                                                             haServerAddr));
            if (null == prevBrokerLiveInfo) {
                log.info("new broker registered, {} HAServer: {}", brokerAddr, haServerAddr);
            }

            if (filterServerList != null) {
                if (filterServerList.isEmpty()) {
                    this.filterServerTable.remove(brokerAddr);
                } else {
                    this.filterServerTable.put(brokerAddr, filterServerList);
                }
            }

            if (MixAll.MASTER_ID != brokerId) {
                // 如果此Broker为从节点,则需要查找该Broker的Master节点信息,并更新对应的masterAddr属性
                String masterAddr = brokerData.getBrokerAddrs().get(MixAll.MASTER_ID);
                if (masterAddr != null) {
                    BrokerLiveInfo brokerLiveInfo = this.brokerLiveTable.get(masterAddr);
                    if (brokerLiveInfo != null) {
                        result.setHaServerAddr(brokerLiveInfo.getHaServerAddr());
                        result.setMasterAddr(masterAddr);
                    }
                }
            }
        } finally {
            // 卸锁
            this.lock.writeLock().unlock();
        }
    } catch (Exception e) {
        log.error("registerBroker Exception", e);
    }

    return result;
}

NameServer与Broker保持长连接,Broker状态存储在brokerLiveTable中,NameServer每收到一个心跳包,将更新brokerLiveTable中关于Broker的状态信息以及路由表(topicQueueTable、brokerAddrTable、brokerLiveTable、filterServerTable)。更新上述路由表(HashTable)使用了锁粒度较少的读写锁,允许多个消息发送者(Producer)并发读,保证消息发送时的高并发。但同一时刻NameServer只处理一个Broker心跳包,多个心跳包请求串行执行。

3.3 路由删除

RocketMQ有两个触发点来触发路由删除:

  1. NameServer定时扫描brokerLiveTable检测上次心跳包与当前系统时间的时间差,如果时间戳大于120s,则需要移除该Broker信息。
  2. Broker在正常关闭的情况下,会执行unregisterBroker指令。

由于不管是何种方式触发的路由删除,最终的方法都是一致的,就是从topicQueueTable、brokerAddrTable、brokerLiveTable、filterServerTable删除与该Broker相关的信息。
先来看第一种方式,定位到org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#scanNotActiveBroker函数。

public void scanNotActiveBroker() {
    Iterator<Entry<String, BrokerLiveInfo>> it = this.brokerLiveTable.entrySet().iterator();
    while (it.hasNext()) {
        Entry<String, BrokerLiveInfo> next = it.next();
        long last = next.getValue().getLastUpdateTimestamp();
        // BROKER_CHANNEL_EXPIRED_TIME是120s
        if ((last + BROKER_CHANNEL_EXPIRED_TIME) < System.currentTimeMillis()) {
            RemotingUtil.closeChannel(next.getValue().getChannel());
            it.remove();
            log.warn("The broker channel expired, {} {}ms", next.getKey(), BROKER_CHANNEL_EXPIRED_TIME);
            this.onChannelDestroy(next.getKey(), next.getValue().getChannel());
        }
    }
}

逻辑很简单,遍历brokerLiveTable路由表集合,检测BrokerLiveInfo的lastUpdateTimestamp上次收到心跳包的时间如果超过当前时间120s,则认定该Broker已不可用,需要将它移除,关闭Channel,然后删除与该Broker相关的路由信息,具体看org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#onChannelDestroy函数,逻辑很简单,一层层删除。具体代码不再赘述。需要注意的是,删除信息时需要申请写锁。

3.4 路由发现

RocketMQ路由发现是非主动的,当Topic路由出现变化后,NameServer不主动推送给客户端,而是由客户端定时拉取主题最新的路由。根据主题名称拉取路由信息的命令编码为:RequestCode.GET_ROUTEINFO_BY_TOPIC,解析请求同样也在org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest函数内,最终请求转发至org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#getRouteInfoByTopic。

public RemotingCommand getRouteInfoByTopic(ChannelHandlerContext ctx,
                                           RemotingCommand request) throws RemotingCommandException {
    final RemotingCommand response = RemotingCommand.createResponseCommand(null);
    final GetRouteInfoRequestHeader requestHeader =
        (GetRouteInfoRequestHeader) request.decodeCommandCustomHeader(GetRouteInfoRequestHeader.class);

    // 获取填充好的TopicRouteData路由信息
    TopicRouteData topicRouteData = this.namesrvController.getRouteInfoManager().pickupTopicRouteData(requestHeader.getTopic());

    if (topicRouteData != null) {
        if (this.namesrvController.getNamesrvConfig().isOrderMessageEnable()) {
            // 如果找到主题对应的路由信息并且该主题为顺序消息,则从NameServer的KVConfig中获取关于顺序消息相关的配置填充路由信息
            String orderTopicConf =
                this.namesrvController.getKvConfigManager().getKVConfig(NamesrvUtil.NAMESPACE_ORDER_TOPIC_CONFIG,
                                                                        requestHeader.getTopic());
            topicRouteData.setOrderTopicConf(orderTopicConf);
        }

        byte[] content = topicRouteData.encode();
        response.setBody(content);
        response.setCode(ResponseCode.SUCCESS);
        response.setRemark(null);
        return response;
    }

    // 如果找不到路由信息,直接返回无
    response.setCode(ResponseCode.TOPIC_NOT_EXIST);
    response.setRemark("No topic route info in name server for the topic: " + requestHeader.getTopic()
                       + FAQUrl.suggestTodo(FAQUrl.APPLY_TOPIC_URL));
    return response;
}

四、消息发送

4.1 消息类

发送消息的封装类是org.apache.rocketmq.common.message.Message,基本属性包括消息所属主题topic、消息Flag(不作处理)、扩展属性properties和消息体body。

public class Message implements Serializable {
    /**
     * 消息所属主题
     */
    private String topic;
    /**
     * 消息Flag(RocketMQ不作处理)
     */
    private int flag;
    /**
     * 扩展属性
     */
    private Map<String, String> properties;
    /**
     * 消息体
     */
    private byte[] body;
    /**
     * 交易编号
     */
    private String transactionId;
}

Message扩展属性主要包含下面几个:

  • tag:消息TAG,用于消息过滤。
  • keys:Message索引键,多个用空格隔开,rocketmq可以根据这些key来检索到消息。
  • waitStoreMsgOK:消息发送时是否等消息存储完成后再返回。
  • delayTimeLevel:消息延迟级别,用于定时消息或者消息重试。

4.2 启动流程

启动流程体现在DefaultMQProducerImpl.start()函数。

this.serviceState = ServiceState.START_FAILED;
// 检查启动参数
this.checkConfig();

// 检查producerGroup是否符合要求,并改变生产者的instanceName为进程ID(为了后面创建不同进程下的clientId)
if (!this.defaultMQProducer.getProducerGroup().equals(MixAll.CLIENT_INNER_PRODUCER_GROUP)) {
    this.defaultMQProducer.changeInstanceNameToPID();
}
// 创建MQClientInstance实例,整个JVM中只有一个MQClientManager管理器,里面维护一个MQClientInstance缓存表
// clientID为IP+instanceName+(unitname可选)
this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQProducer, rpcHook);
// 向MQClientFactory注册,将当前生产者加入到MQClientInstance管理中,方便后续网络请求、心跳检测等。
boolean registerOK = mQClientFactory.registerProducer(this.defaultMQProducer.getProducerGroup(), this);
if (!registerOK) {
    this.serviceState = ServiceState.CREATE_JUST;
    throw new MQClientException("The producer group[" + this.defaultMQProducer.getProducerGroup()
                                + "] has been created before, specify another name please." + FAQUrl.suggestTodo(FAQUrl.GROUP_NAME_DUPLICATE_URL),
                                null);
}
// topic主题注册
this.topicPublishInfoTable.put(this.defaultMQProducer.getCreateTopicKey(), new TopicPublishInfo());

if (startFactory) {
    // 启动
    mQClientFactory.start();
}

log.info("the producer [{}] start OK. sendMessageWithVIPChannel={}", this.defaultMQProducer.getProducerGroup(),
         this.defaultMQProducer.isSendMessageWithVIPChannel());
this.serviceState = ServiceState.RUNNING;

大体流程如下所示:

rocketmq架构图 - rocketmq基础_java_02


需要关注的有几个点:

  • 创建MQClientInstance实例是通过MQClientManager实现的,其中,MQClientManager是一个饿汉模式的单例,在JVM中只有一个,里面维护了一个key为clientId(IP@instanceName@unitName),value为MQClientInstance的缓存集合。
  • MQClientInstance才是具体客户端的配置管理中心,它会将当前producer对象注册到MQClientInstance实例对象的producerTable集合(<String/* group */, MQProducerInner>)中,一个jvm(一个客户端)中一个producerGroup只能有一个实例。此外,MQClientInstance中也包括ClientConfig等具体配置。
  • producer.start()函数结尾,也调用了mQClientFactory.start(),在MQClientInstance实例启动中,会打开多个定时任务(startScheduledTask),里面包括从namesrv更新路由主题信息、向broker发送心跳包等。

4.3消息发送流程

消息发送过程以最基础的org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#send(Message msg)为例。大体的流程如下图所示。

rocketmq架构图 - rocketmq基础_java_03


把不相关代码,函数如下。我们将按照注释的顺序进行分析。

private SendResult sendDefaultImpl(
    Message msg,
    final CommunicationMode communicationMode,
    final SendCallback sendCallback,
    final long timeout
) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
    this.makeSureStateOK();
    // 1、消息验证(比如长度等)
    Validators.checkMessage(msg, this.defaultMQProducer);
    final long invokeID = random.nextLong();
    long beginTimestampFirst = System.currentTimeMillis();
    long beginTimestampPrev = beginTimestampFirst;
    long endTimestamp = beginTimestampFirst;
    // 2、获取主题路由信息
    TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
    if (topicPublishInfo != null && topicPublishInfo.ok()) {
        boolean callTimeout = false;
        MessageQueue mq = null;
        Exception exception = null;
        SendResult sendResult = null;
        int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1;
        int times = 0;
        String[] brokersSent = new String[timesTotal];
        for (; times < timesTotal; times++) {
            String lastBrokerName = null == mq ? null : mq.getBrokerName();
            // 3、消息队列选择
            MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);
            if (mqSelected != null) {
                mq = mqSelected;
                brokersSent[times] = mq.getBrokerName();
                try {
                    beginTimestampPrev = System.currentTimeMillis();
                    if (times > 0) {
                        //Reset topic with namespace during resend.
                        msg.setTopic(this.defaultMQProducer.withNamespace(msg.getTopic()));
                    }
                    long costTime = beginTimestampPrev - beginTimestampFirst;
                    if (timeout < costTime) {
                        callTimeout = true;
                        break;
                    }
                    // 4、发送消息
                    sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);
                    endTimestamp = System.currentTimeMillis();
                    // 5、更新Broker规避规则
                    this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false);
                    switch (communicationMode) {
                        case ASYNC:
                        case ONEWAY:
                            return null;
                        case SYNC:
                            if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
                                if (this.defaultMQProducer.isRetryAnotherBrokerWhenNotStoreOK()) {
                                    continue;
                                }
                            }

                            return sendResult;
                        default:
                            break;
                    }
                }/** catch信息忽略掉 **/
                }
            } 
        }

        if (sendResult != null) {
            return sendResult;
        }
}

4.3.1 消息长度验证

消息发送之前,首先保证Producer处于运行状态,然后验证消息是否符合相应的规范,具体的规范要求是主题名称不为空且符合命名规范、消息体不能为空、消息长度不能等于0且默认不能超过允许发送消息的最大长度4M(maxMessageSize = 1024 * 1024 * 4; )。具体体现在org.apache.rocketmq.client.Validators#checkMessage函数中。

4.3.2 查找主题路由消息

查找主题路由消息体现在org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#tryToFindTopicPublishInfo函数。

private TopicPublishInfo tryToFindTopicPublishInfo(final String topic) {
    // 首先从本地存储的路由信息表中获取
    TopicPublishInfo topicPublishInfo = this.topicPublishInfoTable.get(topic);
    // 第一次发送消息时,本地没有缓存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 {
        // 如果还未找到路由信息,使用默认Topic名称去获取,目的是当Broker开启自动创建Topic开关时,Broker接收到信息后自动创建Topic
        this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic, true, this.defaultMQProducer);
        topicPublishInfo = this.topicPublishInfoTable.get(topic);
        return topicPublishInfo;
    }
}

在org.apache.rocketmq.client.impl.factory.MQClientInstance#updateTopicRouteInfoFromNameServer(String topic, boolean isDefault, DefaultMQProducer defaultMQProducer)函数中,有一部分需要注意,如果获取的主题路由信息有变化,需要更新。

TopicPublishInfo publishInfo = topicRouteData2TopicPublishInfo(topic, topicRouteData);
publishInfo.setHaveTopicRouterInfo(true);
Iterator<Entry<String, MQProducerInner>> it = this.producerTable.entrySet().iterator();
while (it.hasNext()) {
    Entry<String, MQProducerInner> entry = it.next();
    MQProducerInner impl = entry.getValue();
    if (impl != null) {
        impl.updateTopicPublishInfo(topic, publishInfo);
    }
}

这里最终是要更新每个生产者producer的TopicPublishInfo,需要先循环this.producerTable,然后把TopicPublishInfo赋值给了producerTable里的每一个Impl(生产者),这时我们需要搞清楚这几个之间的关系。
一个生产者(MQProducerInner)拥有一个topicPublishInfoTable,存储Topic路由信息。

private final ConcurrentMap<String/* topic */, TopicPublishInfo> topicPublishInfoTable = new ConcurrentHashMap<String, TopicPublishInfo>();

一个生产者组(MQClientInstance)拥有一个producerTable,存储着组内所有生产者信息。

private final ConcurrentMap<String/* group */, MQProducerInner> producerTable = new ConcurrentHashMap<String, MQProducerInner>();

4.3.3 选择消息队列

消息在发送时要根据上文中查询到的路由信息选择消息队列,在选择消息队列时有两种方式:

  1. MQFaultStrategy.sendLatencyFaultEnable=false,默认不启用Broker故障延迟机制。
  2. MQFaultStrategy.sendLatencyFaultEnable=true,启用Broker故障延迟机制。
不启用Broker故障延迟机制

该流程较为简单,调用org.apache.rocketmq.client.impl.producer.TopicPublishInfo#selectOneMessageQueue函数。

public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
    if (lastBrokerName == null) {
        return selectOneMessageQueue();
    } else {
        int index = this.sendWhichQueue.getAndIncrement();
        for (int i = 0; i < this.messageQueueList.size(); i++) {
            int pos = Math.abs(index++) % this.messageQueueList.size();
            if (pos < 0)
                pos = 0;
            MessageQueue mq = this.messageQueueList.get(pos);
            if (!mq.getBrokerName().equals(lastBrokerName)) {
                return mq;
            }
        }
        return selectOneMessageQueue();
    }
}

public MessageQueue selectOneMessageQueue() {
    int index = this.sendWhichQueue.getAndIncrement();
    int pos = Math.abs(index) % this.messageQueueList.size();
    if (pos < 0)
        pos = 0;
    return this.messageQueueList.get(pos);
}

lastBrokerName表示上一次选择的执行发送消息失败的Broker,第一次执行消息队列选择时,lastBrokerName为null,此时直接用sendWhichQueue自增获取值,与当前路由表中信息队列个数取模,返回该位置的MessageQueue,如果消息再次发送失败的话,下次进行消息队列选择时规避上次MessageQueue所在的Broker,否则还是很有可能失败。
该方法是存在性能缺陷的,因为路由算法中的消息队列是按照Broker排序的,如果上一次选择的是宕机的Broker的第一个队列,那么随后下一次选择的宕机Broker的第二个队列,消息发送同样很有可能失败,再次引发重试,带来不必要的性能损耗。那么有什么方法,可以在一次消息发送失败后,暂时将该Broker排除在消息队列选择范围外呢。就是下面的方案。

启用Broker故障延迟机制

此时sendLatencyFaultEnable=true。先对消息队列集合进行轮询获取一个合适的消息队列,并验证是否可用;如果最后没有最佳可用队列的话,就尝试从规避的broker中选一个凑付的,然后判断这个凑付的是否能用。这里有一行代码我认为逻辑写反了,当前brokerName与上一次规避brokerName不同的时候,才允许当前可用。

public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
    if (this.sendLatencyFaultEnable) {
        try {
            int index = tpInfo.getSendWhichQueue().getAndIncrement();
            for (int i = 0; i < tpInfo.getMessageQueueList().size(); i++) {
                int pos = Math.abs(index++) % tpInfo.getMessageQueueList().size();
                if (pos < 0)
                    pos = 0;
                MessageQueue mq = tpInfo.getMessageQueueList().get(pos);
                // 验证此broker是否需要规避
                if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) {
                    // 我自己认为写错了
                    if (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName))
                        return mq;
                }
            }
            // 如果找不到合适的broker,就尝试从规避的Broker中找出一个可用的,找不到的话就返回null
            final String notBestBroker = latencyFaultTolerance.pickOneAtLeast();
            int writeQueueNums = tpInfo.getQueueIdByBroker(notBestBroker);
            if (writeQueueNums > 0) {
                final MessageQueue mq = tpInfo.selectOneMessageQueue();
                if (notBestBroker != null) {
                    mq.setBrokerName(notBestBroker);
                    mq.setQueueId(tpInfo.getSendWhichQueue().getAndIncrement() % writeQueueNums);
                }
                return mq;
            } else {
                latencyFaultTolerance.remove(notBestBroker);
            }
        } catch (Exception e) {
            log.error("Error occurred when selecting message queue", e);
        }

        return tpInfo.selectOneMessageQueue();
    }

这时候涉及到了一个新的实现类LatencyFaultToleranceImpl。

public class LatencyFaultToleranceImpl implements LatencyFaultTolerance<String> {
    // 用于保存规避的Broker
    private final ConcurrentHashMap<String, FaultItem> faultItemTable = new ConcurrentHashMap<String, FaultItem>(16);

    private final ThreadLocalIndex whichItemWorst = new ThreadLocalIndex();
    /**
     * 更新失败条目
     * @param name brokerName
     * @param currentLatency 消息发送故障延迟时间
     * @param notAvailableDuration 不可用持续时长,在这个时间内,Broker将被规避
     */
    @Override
    public void updateFaultItem(final String name, final long currentLatency, final long notAvailableDuration);
    /**
     * 判断Borker是否可用
     * @param name brokerName
     * @return
     */
    @Override
    public boolean isAvailable(final String name);
    /**
     * 移除Fault条目,意味着Broker重新参与路由计算
     * @param name
     */
    @Override
    public void remove(final String name);
    /**
     * 尝试从规避的Broker中选择一个可用的Broker,如果没有找到,将返回null
     * 排序链表的前半部分选出一个broker,即至少不是最差策略
     * @return
     */
    @Override
    public String pickOneAtLeast();
    
    class FaultItem implements Comparable<FaultItem> {
        // BrokerName
        private final String name;
        // 消息发送故障延迟时间
        private volatile long currentLatency;
        // 预测可用时间(当前时间+notAvailableDuration)
        private volatile long startTimestamp;
    }
}

在发送函数的第5步中,涉及到更新broker规避规则。

private long[] latencyMax = {50L, 100L, 550L, 1000L, 2000L, 3000L, 15000L};
private long[] notAvailableDuration = {0L, 0L, 30000L, 60000L, 120000L, 180000L, 600000L};
public void updateFaultItem(final String brokerName, final long currentLatency, boolean isolation) {
    if (this.sendLatencyFaultEnable) {
        // 获取故障延迟时间
        long duration = computeNotAvailableDuration(isolation ? 30000 : currentLatency);
        // 更新规则规则
        this.latencyFaultTolerance.updateFaultItem(brokerName, currentLatency, duration);
    }
}
private long computeNotAvailableDuration(final long currentLatency) {
    for (int i = latencyMax.length - 1; i >= 0; i--) {
        if (currentLatency >= latencyMax[i])
            return this.notAvailableDuration[i];
    }

    return 0;
}

updateFaultItem函数是根据超时时间(latencyMax)来预测故障持续时间/规避时长(notAvailableDuration)的,两者有一一对应关系,比如:

  • currentLatency大于等于50小于100,则notAvailableDuration为0
  • currentLatency大于等于100小于550,则notAvailableDuration为0
  • currentLatency大于等于550小于1000,则notAvailableDuration为300000
  • 等等

故障持续时间又决定了可用时间,startTimestamp=当前时间+notAvailableDuration。以上便是消息队列选择机制。

4.3.4 消息发送

netty发送

消息发送主要在org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#sendKernelImpl函数中,又分为同步、异步和单向发送。其主要流程如下:

rocketmq架构图 - rocketmq基础_java_04


在内部具体信息发送函数中:

  • 异步消息调用的是 NettyRemotingClient.invokeAsync方法
  • 同步消息调用的是 NettyRemotingClient.invokeSync方法

从发送消息的关键代码开始分析:NettyRemotingAbstract.invokeSyncImpl()

public RemotingCommand invokeSyncImpl(final Channel channel, final RemotingCommand request,
                                      final long timeoutMillis)
    throws InterruptedException, RemotingSendRequestException, RemotingTimeoutException {
    // 发送请求的标识,请求、响应均会包含该字段
    // 用于接收异步响应时,根据Opaque 找到对应的回调函数或者设置返回结果
    final int opaque = request.getOpaque();

    try {
        final ResponseFuture responseFuture = new ResponseFuture(channel, opaque, timeoutMillis, null, null);
        this.responseTable.put(opaque, responseFuture);
        final SocketAddress addr = channel.remoteAddress();
        // 使用writeAndFlush发请求,建立返回值的监听,这是netty-client发送消息,还有netty-server收消息
        channel.writeAndFlush(request).addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture f) throws Exception {
                if (f.isSuccess()) {
                    // 执行成功后return,跳出这个监听
                    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.");
            }
        });
        // 阻塞等待结果,使用countDownLatch挂起线程,等待收到netty的返回值
        RemotingCommand responseCommand = responseFuture.waitResponse(timeoutMillis);
        // 在clinet的channelRead0将responseCommand清零,自动返回
        // 若为空,说明超过一定时间还未获取返回值,这时抛出异常,交由用户处理
        if (null == responseCommand) {
            if (responseFuture.isSendRequestOK()) {
                throw new RemotingTimeoutException(RemotingHelper.parseSocketAddressAddr(addr), timeoutMillis,
                                                   responseFuture.getCause());
            } else {
                throw new RemotingSendRequestException(RemotingHelper.parseSocketAddressAddr(addr), responseFuture.getCause());
            }
        }

        return responseCommand;
    } finally {
        this.responseTable.remove(opaque);
    }
}

通过上面函数,有几个关键点:

  • 请求与响应的RemotingCommand中,有一个关键的opaque,用于标识一次请求。并通过opaque获取ResponseFuture,得到对应的响应结果。
  • 发送消息是使用writeAndFlush发请求,并建立返回值的监听。
  • 发送消息通过CountDownLatch阻塞,并等待响应结果。有两种情况,一个是超时后自动唤醒;另一个是client监听到响应结果后,并通过countdown()唤醒发送时的阻塞。
  • 回调函数是在netty的ResponseFuture中设置,具体流程还不清楚。
异常处理

在发送消息时候,如果遇到异常情况(比如服务端没有response返回给客户端或者response因网络而丢失),上面所述的responseTable的本地缓存Map将会出现堆积情况。这个时候需要一个定时任务来专门做responseTable的清理回收。这就是NettyRemotingAbstract.scanResponseTable所做的事情。

public void scanResponseTable() {
    final List<ResponseFuture> rfList = new LinkedList<ResponseFuture>();
    Iterator<Entry<Integer, ResponseFuture>> it = this.responseTable.entrySet().iterator();
    while (it.hasNext()) {
        Entry<Integer, ResponseFuture> next = it.next();
        ResponseFuture rep = next.getValue();

        if ((rep.getBeginTimestamp() + rep.getTimeoutMillis() + 1000) <= System.currentTimeMillis()) {
            rep.release();
            it.remove();
            rfList.add(rep);
            log.warn("remove timeout request, " + rep);
        }
    }

    for (ResponseFuture rf : rfList) {
        try {
            executeInvokeCallback(rf);
        } catch (Throwable e) {
            log.warn("scanResponseTable, operationComplete Exception", e);
        }
    }
}

在RocketMQ客户端/服务端启动的时候,会开启一个间隔时间为1s的定时任务,来执行scanResponseTable函数,检查所有的responseTable缓存中的responseFuture变量。

五、总结

  • name server的路由发现机制可以用下图来总结表示:

rocketmq架构图 - rocketmq基础_java_05

  • 消息生产者在发送消息时,如果本地路由表中未缓存topic的路由信息,则向nameServer发送获取路由信息请求,更新本地路由信息表,并且生产者每隔30s就会从nameServer更新路由表。
  • 消息发送高可用性主要通过两个手段来体现:重试与Broker规避。Broker规避就是如果在一次发送时出现了错误,那么在过来的一段时间内,生产者不会再选用该Broker上的消息队列,以提高发送消息的成功率。
  • 写的太啰嗦了,啰哩啰嗦。

六、参考文献

  1. 《RocketMQ技术内幕》