引言:
从源码角度分析下,RocketMQ生产者是如何启动的?为发送消息做了哪些准备?
-
Rocket MQ消息概况
-
相关概念回顾
Producer group生产者组:
同一类Producer的集合,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事物消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。
一个Producer Group下包含多个Producer实例,可以是多台机器,也可以是一台机器的多个进程,或者一个进程的多个Producer对象。一个Producer Group可以发送多个Topic消息, Producer Group作用如下:标识一类Producer 可以通过运维工具查询这个发送消息应用下有多个Producer实例,发送分布式事务消息时,如果Producer中途意外宕机,Broker会主动回调Producer Group内的任意一台机器来确认 事务状态。
Offset: RocketMQ中,有很多offset的概念。但通常我们只关心暴露到客户端的offset。一般我们不特指的话,就是指逻辑Message Queue下面的offset。 注: 逻辑offset的概念在RocketMQ中字面意思实际上和真正的意思有一定差别,这点在设计上显得有点混乱。祥见下面的解释。 可以认为一条逻辑的message queue是无限长的数组。一条消息进来下标就会涨1,而这个数组的下标就是offset。 max offset: 字面上可以理解为这是标识message queue中的max offset表示消息的最大offset。但是从源码上看,这个offset实际上是最新消息的offset+1,即:下一条消息的offset。 min offset: 标识现存在的最小offset。而由于消息存储一段时间后,消费会被物理地从磁盘删除,message queue的min offset也就对应增长。这意味着比min offset要小的那些消息已经不在broker上了,无法被消费。 consumer offset: 字面上,可以理解为标记Consumer Group在一条逻辑Message Queue上,消息消费到哪里即消费进度。但从源码上看,这个数值是消费过的最新消费的消息offset+1,即实际上表示的是下次拉取的offset位置。
-
三种消息发送
RocketMQ支持三种消息发送方式:同步/异步/one way
先来看看如何发送?
public static void main(String[] args) throws MQClientException, InterruptedException {
DefaultMQProducer producer = new DefaultMQProducer(PRODUCER_GROUP);
// Uncomment the following line while debugging, namesrvAddr should be set to your local address
//producer.setNamesrvAddr(DEFAULT_NAMESRVADDR);
producer.start();
for (int i = 0; i < 128; i++) {
try {
Message msg = new Message(TOPIC, TAG, "OrderID188", "Hello world".getBytes(StandardCharsets.UTF_8));
// 1.同步发送
SendResult sendResult = producer.send(msg);
// 2. 异步发送
producer.send(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
countDownLatch.countDown();
System.out.printf("%-10d OK %s %n", index, sendResult.getMsgId());
}
@Override
public void onException(Throwable e) {
countDownLatch.countDown();
System.out.printf("%-10d Exception %s %n", index, e);
e.printStackTrace();
}
});
// 3. one way
producer.sendOneway(msg);
System.out.printf("%s%n", sendResult);
} catch (Exception e) {
e.printStackTrace();
}
}
countDownLatch.await(5, TimeUnit.SECONDS);
producer.shutdown();
}
2.Message的结构
基础属性包括,消息所属主题 topic ,消息 Flag(RocketMQ 不做处理)、扩展属性、消息体 、事务ID。
Flag的种类包括:
其中总共8位,每一位都有其代表的意义,可以用位运算快速得出属性
if ((sysFlag & MessageSysFlag.COMPRESSED_FLAG) == MessageSysFlag.COMPRESSED_FLAG) {
...
}
Message的全参数构造器如下:
public Message(String topic, String tags, String keys, int flag, byte[] body, boolean waitStoreMsgOK) {
this.topic = topic;
this.flag = flag;
this.body = body;
if (tags != null && tags.length() > 0) {
this.setTags(tags);
}
if (keys != null && keys.length() > 0) {
this.setKeys(keys);
}
this.setWaitStoreMsgOK(waitStoreMsgOK);
}
//Eg
Message msg = new Message("TopicTest" /* Topic */,
"TagA" /* Tag */, "xxx", /* keys*/
MessageSysFlag.MULTI_TAGS_FLAG, /* flag */
("Hello RocketMQ " +
i).getBytes(StandardCharsets.UTF_8) /* Message body */
, true);
Message 扩展属性主要包含下面几个 。
tag :消息 TAG ,用于消息过滤 。
keys: Message 索引键, 多个用空格隔开, RocketMQ 可以根据这些 key 快速检索到消息 。
waitStoreMsgOK :消息发送时是否等消息存储完成后再返回 。
delayTimeLevel : 消息延迟级别,用于定时消息或消息重试 。
这些扩展属性存储在 Message 的 properties 中 。
那么此时一条消息,大致如图所示
-
生产者启动流程
RocketMQ的组件大多都遵循下图所示的流程
生产者启动时序图:
-
生产者实例化
从生产者构造器开始看。
DefaultMQProducer producer = new DefaultMQProducer(PRODUCER_GROUP);
public DefaultMQProducerImpl(final DefaultMQProducer defaultMQProducer, RPCHook rpcHook) {
this.defaultMQProducer = defaultMQProducer;
this.rpcHook = rpcHook;
this.asyncSenderThreadPoolQueue = new LinkedBlockingQueue<>(50000);
this.defaultAsyncSenderExecutor = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors(),
1000 * 60,
TimeUnit.MILLISECONDS,
this.asyncSenderThreadPoolQueue,
new ThreadFactoryImpl("AsyncSenderExecutor_"));
if (defaultMQProducer.getBackPressureForAsyncSendNum() > 10) {
semaphoreAsyncSendNum = new Semaphore(Math.max(defaultMQProducer.getBackPressureForAsyncSendNum(), 10), true);
} else {
semaphoreAsyncSendNum = new Semaphore(10, true);
log.info("semaphoreAsyncSendNum can not be smaller than 10.");
}
if (defaultMQProducer.getBackPressureForAsyncSendNum() > 1024 * 1024) {
semaphoreAsyncSendSize = new Semaphore(Math.max(defaultMQProducer.getBackPressureForAsyncSendNum(), 1024 * 1024), true);
} else {
semaphoreAsyncSendSize = new Semaphore(1024 * 1024, true);
log.info("semaphoreAsyncSendSize can not be smaller than 1M.");
}
}
2.2生产者启动
DefaultMQProducerlmpl的 start 方法来跟踪
分别是:CREATE_JUST、RUNNING、START_FAILED、SHUTDOWN_ALREADY
对于producer启动来说,需要关心的状态就只有 CREATE_JUST,这也是 Producer 实例化之后默认的状态,在初始化时就会设置一个默认值,如下所示:
// 源码位置:
// 子项目: client
// 包名: org.apache.rocketmq.client.impl.producer;
// 文件: DefaultMQProducerImpl
// 行数: 111
private ServiceState serviceState = ServiceState.CREATE_JUST;
当其调用了 start() 成功之后,Producer 就会将状态修改为 RUNNING,失败了就会变成 START_FAILED 。
Step1: 参数校验
与快递员发快递类似,首先得检查一下地址是不是合法的,送个火星,这可办不到啊,检查 productGroup 是否符合要求;并改变生产者 的 instanceName 为进程 ID 。
检查参数是否合法
改变生产者 的 instanceName 为进程 ID
public void changeInstanceNameToPID() {
if (this.instanceName.equals("DEFAULT")) {
this.instanceName = UtilAll.getPid() + "#" + System.nanoTime();
}
}
进程id+ 纳秒时间戳,这里有 nanoTime 纳秒时间戳的加持,想要得到相同 instanceName 的概率非常非常小,给大家举个具体的例子:41794#17228372610333
Step2: 创建 MQClientInstance 实例
MQClinetInstance是什么?
MQClinetInsatance是RMQ的客户端,无论是生产者还是消息者底层都会与broker进行打交道,从源码层面上,这部分的功能被抽象成一个单独的类,负责和broker打交道。
类定义在client包中,可以理解为是一个工厂,是对生产者、消费者以及控制台三者的合集,内部封装了netty客户端,消息的生产,消费和负载均衡的实现类等。另外MQClientInstance的实例化并不是直接new后使用,而是通过MQClientManager这个单例类,使用饿汉模式设计保证线程安全。
每一个clientid与 MQClientInstance一一对应。MQClientInstance封装了 RocketMQ 网络处理 API ,是消息生产者( Producer )、消息消费者( Consumer )与 NameServer、 Broker 打交道的网络通道 。
整个JVM 实例中只存在一个 MQ ClientManager 实例,维护一个 MQClientlnstance 缓存表
private ConcurrentMap<String/* clientId */, MQClientInstance> factoryTable =
new ConcurrentHashMap<>();
如上图所示,MQClientManager采用了简单的饿汉单例模式(类加载时就创建了单例对象)。
而MQClientInstance则是通过getOrCreateMQClientInstance方法,从命名得知获取或者创建,应该是利用了factoryTable这个缓存表。
public MQClientInstance getOrCreateMQClientInstance(final ClientConfig clientConfig, RPCHook rpcHook) {
// 生成 clientId
String clientId = clientConfig.buildMQClientId();
// 从这个 table 里先获取一次
MQClientInstance instance = this.factoryTable.get(clientId);
// 第一次进来, table 肯定没有数据, 所以它一定是 null
if (null == instance) {
// 所以肯定会进到这里来, 调用构造函数将其实例化出来
instance =
new MQClientInstance(clientConfig.cloneClientConfig(),
this.factoryIndexGenerator.getAndIncrement(), clientId, rpcHook);
// 生成好之后就会写入 factoryTable 中, 所以后续再次调用这个方法就能够获取到了
MQClientInstance prev = this.factoryTable.putIfAbsent(clientId, instance);
if (prev != null) {
instance = prev;
log.warn("Returned Previous MQClientInstance for clientId:[{}]", clientId);
} else {
log.info("Created new MQClientInstance for clientId:[{}]", clientId);
}
}
return instance;
}
整体流程如下图所示:
Q:为什么使用一个缓存表?
A: 避免多次调用重复生成MQClientInstance
Q: 如何保证一定返回的是对象是单例?
A:
instance =
new MQClientInstance(clientConfig.cloneClientConfig(),
this.factoryIndexGenerator.getAndIncrement(), clientId, rpcHook);
MQClientInstance prev = this.factoryTable.putIfAbsent(clientId, instance);
虽然concurrenthashMap#putIfAbsent可以保证,不存在就添加的原子性,但是由于上面这两句,即实例化和putIfAbsent语句并不能保证原子性。因此putIfAbsent的结果可能会有两个实例,因此使用prev变量来接受返回值。
在方法返回之前判断一下instance和prev的关系。
if (prev != null) {
instance = prev;
log.warn("Returned Previous MQClientInstance for clientId:[{}]", clientId);
} else {
log.info("Created new MQClientInstance for clientId:[{}]", clientId);
}
clientld 为客户端 IP+ instance+ (unitname 可选),如果在同一台物理服务器部署两个应用程序,岂不是 clientld 相同, 会造成混乱?
为了避免这个问题 , 如果 instance 为默认值 DEFAULT 的话, RocketMQ 会自动将instance 设置为进程 ID ,这样避免了不同进程的相互影响,但同 一 个 NM 中 的不同消费者和不同生产者在启动时获取到的 MQC!ientlnstane 实例都是同 一 个 。
{InstanceName}
public String buildMQClientId() {
StringBuilder sb = new StringBuilder();
sb.append(this.getClientIP());
sb.append("@");
sb.append(this.getInstanceName());
if (!UtilAll.isBlank(this.unitName)) {
sb.append("@");
sb.append(this.unitName);
}
if (enableStreamRequestType) {
sb.append("@");
sb.append(RequestType.STREAM);
}
return sb.toString();
}
Step3: 向MQClientlnstance 登记 Producer 信息
Step4: 启动 MQClientInstance
接下来,Producer 会调用 MQClientInstance 的 start() 方法来初始化一些核心逻辑。
投递 Message 的很多核心逻辑都在 MQClientInstance 当中,所以我们有必要来看看这里都做了什么。我们知道 Producer 需要投递 Message 到 Broker,那么必然需要和 Broker 建立连接。Producer 也需要和 NameServer 通信来获取 Broker 的相关元数据。所以这里首先就是启动用于通信的 Channel。 除此之外还需定义如何是pull还是push消息。
case为CREATE_JUST的时候看下面的注解就是我们刚刚罗列的需要的功能点
public void start() throws MQClientException {
synchronized (this) {
this.serviceState = ServiceState.START_FAILED;
// 如果没有namesrvAddr则去查找,fetchNameServerAddr方法下面再详细说
if (null == this.clientConfig.getNamesrvAddr()) {
this.mQClientAPIImpl.fetchNameServerAddr();
}
// 启动请求相应通道,打开channel
this.mQClientAPIImpl.start();
// 启动定时任务
this.startScheduledTask();
// 启动拉取消息服务
this.pullMessageService.start();
// 启动负载均衡服务
this.rebalanceService.start();
// 启动消息推送服务,注意这里又回去调用了DefaultMQProducerImpl的start方法,但是参数是false。
this.defaultMQProducer.getDefaultMQProducerImpl().start(false);
this.serviceState = ServiceState.RUNNING;
}
}
- 设置nameserver的地址
可以看到就是没有在config中指定的时候,就每隔30s去请求一次,调用http接口去寻址,前提需配置hosts信息,客户端默认每隔两分钟去访问一次这个http地址,并更新本地namesrvAddr地址。
org.apache.rocketmq.client.impl.MQClientAPIImpl#fetchNameServerAddr
public String fetchNameServerAddr() {
try {
String addrs = this.topAddressing.fetchNSAddr();
if (!UtilAll.isBlank(addrs)) {
if (!addrs.equals(this.nameSrvAddr)) {
log.info("name server address changed, old=" + this.nameSrvAddr + ", new=" + addrs);
this.updateNameServerAddressList(addrs);
this.nameSrvAddr = addrs;
return nameSrvAddr;
}
}
} catch (Exception e) {
log.error("fetchNameServerAddr Exception", e);
}
return nameSrvAddr;
}
org.apache.rocketmq.common.namesrv.DefaultTopAddressing#fetchNSAddr()
@Override
public final String fetchNSAddr() {
if (!topAddressingList.isEmpty()) {
for (TopAddressing topAddressing : topAddressingList) {
String nsAddress = topAddressing.fetchNSAddr();
if (!Strings.isNullOrEmpty(nsAddress)) {
return nsAddress;
}
}
}
// Return result of default implementation
return fetchNSAddr(true, 3000);
}
org.apache.rocketmq.common.namesrv.DefaultTopAddressing#fetchNSAddr(boolean, long)
public final String fetchNSAddr(boolean verbose, long timeoutMills) {
String url = this.wsAddr;
try {
if (null != para && para.size() > 0) {
if (!UtilAll.isBlank(this.unitName)) {
url = url + "-" + this.unitName + "?nofix=1&";
}
else {
url = url + "?";
}
for (Map.Entry<String, String> entry : this.para.entrySet()) {
url += entry.getKey() + "=" + entry.getValue() + "&";
}
url = url.substring(0, url.length() - 1);
}
else {
if (!UtilAll.isBlank(this.unitName)) {
url = url + "-" + this.unitName + "?nofix=1";
}
}
HttpTinyClient.HttpResult result = HttpTinyClient.httpGet(url, null, null, "UTF-8", timeoutMills);
if (200 == result.code) {
String responseStr = result.content;
if (responseStr != null) {
return clearNewLine(responseStr);
} else {
LOGGER.error("fetch nameserver address is null");
}
} else {
LOGGER.error("fetch nameserver address failed. statusCode=" + result.code);
}
} catch (IOException e) {
if (verbose) {
LOGGER.error("fetch name server address exception", e);
}
}
if (verbose) {
String errorMsg =
"connect to " + url + " failed, maybe the domain name " + MixAll.getWSAddr() + " not bind in /etc/hosts";
errorMsg += FAQUrl.suggestTodo(FAQUrl.NAME_SERVER_ADDR_NOT_EXIST_URL);
LOGGER.warn(errorMsg);
}
return null;
}
- 开启通信的channel
建立底层通信channel,MQClientAPIImpl.start方法最后调用RemotingClient的start方法,而remotingClient调用了netty建立底层通信。
@Override
public void start() {
if (this.defaultEventExecutorGroup == null) {
this.defaultEventExecutorGroup = new DefaultEventExecutorGroup(
nettyClientConfig.getClientWorkerThreads(),
new ThreadFactoryImpl("NettyClientWorkerThread_"));
}
//netty 启动
Bootstrap handler = this.bootstrap.group(this.eventLoopGroupWorker).channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.SO_KEEPALIVE, false)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, nettyClientConfig.getConnectTimeoutMillis())
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
if (nettyClientConfig.isUseTLS()) {
if (null != sslContext) {
pipeline.addFirst(defaultEventExecutorGroup, "sslHandler", sslContext.newHandler(ch.alloc()));
LOGGER.info("Prepend SSL handler");
} else {
LOGGER.warn("Connections are insecure as SSLContext is null!");
}
}
ch.pipeline().addLast(
nettyClientConfig.isDisableNettyWorkerGroup() ? null : defaultEventExecutorGroup,
new NettyEncoder(),
new NettyDecoder(),
new IdleStateHandler(0, 0, nettyClientConfig.getClientChannelMaxIdleTimeSeconds()),
new NettyConnectManageHandler(),
new NettyClientHandler());
}
});
if (nettyClientConfig.getClientSocketSndBufSize() > 0) {
LOGGER.info("client set SO_SNDBUF to {}", nettyClientConfig.getClientSocketSndBufSize());
handler.option(ChannelOption.SO_SNDBUF, nettyClientConfig.getClientSocketSndBufSize());
}
if (nettyClientConfig.getClientSocketRcvBufSize() > 0) {
LOGGER.info("client set SO_RCVBUF to {}", nettyClientConfig.getClientSocketRcvBufSize());
handler.option(ChannelOption.SO_RCVBUF, nettyClientConfig.getClientSocketRcvBufSize());
}
if (nettyClientConfig.getWriteBufferLowWaterMark() > 0 && nettyClientConfig.getWriteBufferHighWaterMark() > 0) {
LOGGER.info("client set netty WRITE_BUFFER_WATER_MARK to {},{}",
nettyClientConfig.getWriteBufferLowWaterMark(), nettyClientConfig.getWriteBufferHighWaterMark());
handler.option(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(
nettyClientConfig.getWriteBufferLowWaterMark(), nettyClientConfig.getWriteBufferHighWaterMark()));
}
if (nettyClientConfig.isClientPooledByteBufAllocatorEnable()) {
handler.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
}
// 定时任务 扫描存活的namesrv
TimerTask timerTaskScanResponseTable = new TimerTask() {
@Override
public void run(Timeout timeout) {
try {
NettyRemotingClient.this.scanResponseTable();
} catch (Throwable e) {
LOGGER.error("scanResponseTable exception", e);
} finally {
timer.newTimeout(this, 1000, TimeUnit.MILLISECONDS);
}
}
};
this.timer.newTimeout(timerTaskScanResponseTable, 1000 * 3, TimeUnit.MILLISECONDS);
int connectTimeoutMillis = this.nettyClientConfig.getConnectTimeoutMillis();
// 定时任务 扫描存活的namesrv
TimerTask timerTaskScanAvailableNameSrv = new TimerTask() {
@Override
public void run(Timeout timeout) {
try {
NettyRemotingClient.this.scanAvailableNameSrv();
} catch (Exception e) {
LOGGER.error("scanAvailableNameSrv exception", e);
} finally {
timer.newTimeout(this, connectTimeoutMillis, TimeUnit.MILLISECONDS);
}
}
};
this.timer.newTimeout(timerTaskScanAvailableNameSrv, 0, TimeUnit.MILLISECONDS);
}
- 启动一些定时任务
private void startScheduledTask() {
//如果当前客户端没有指定setNamesrvAddr,启动查找NamesrvAddr地址服务,每两分钟一次
MQClientInstance.this.mQClientAPIImpl.fetchNameServerAddr();
//从NameServer获取topic信息后,更新客户端topic路由信息
MQClientInstance.this.updateTopicRouteInfoFromNameServer();
//定时清理已经不存在的broker服务
MQClientInstance.this.cleanOfflineBroker();
//定时发送心跳服务
MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();
//定时做consumer offset持久化
MQClientInstance.this.persistAllConsumerOffset();
//定时调整消费线程池
MQClientInstance.this.adjustThreadPool();
}
分析一下生产者定时任务的作用以及设置定时任务的目的。总体来说分为三点,一是减少消息发送失败的概率,,二是记录当前消息消费的状态(如果客户端是consumer的话),三.(如果客户端是consumer的话)是根据当前需要发送的消息量(即任务量)来调整线程数,以保证消息能及时消费。如果客户端是consumer的话还需要考虑后面两点
要减少消息失败的概率,可以从物理架构图入手。
站在Producer集群出发
- NameServer 也就是服务发现(后用sd简写)的地址变更了,或者有sd的实例宕机了,那么producer需要及时感知。否则topic broker等元数据信息就不能及时获取到了。
- 从sd中获取到下线的broker及时从本地缓存中清理,避免消息发送到当机宕机的broker中
| 作用及目的 | 任务 | 备注 |
|---|---|---|
| 作用:减少消息发送失败的概率 | 查找NameServer的地址 | 2min一次 |
| 作用:减少消息发送失败的概率 | 扫描可用的nameserver | org.apache.rocketmq.remoting.netty.NettyRemotingClient#scanAvailableNameSrv。频率需配置这个任务是在启动Netty的时候注册的 |
- 如果nameserver的地址并没有及时更新或者因为网络等其他原因建立通信channel失效了,清理netty客户端缓存的地址。 |
| 作用:减少消息发送失败的概率 | 更新topic信息 |
MQClientInstance是消费者和生产者共用的先看生产者部分
这里就根据拿到 Topic 元数据当中的 Broker 相关数据,和本地维护的 Broker 数据进行对比,清理掉在 Topic 元数据中不存在的 Broker。具体实现在:
org.apache.rocketmq.client.impl.factory.MQClientInstance#updateTopicRouteInfoFromNameServer(java.lang.String, boolean, org.apache.rocketmq.client.producer.DefaultMQProducer)| | 作用:减少消息发送失败的概率 | 向所有broker发送心跳 |两层含义:一来告诉 Broker 我还活着,二来定时刷新 Broker 存的客户端数据。发送心跳的可以是 Producer,也可以是 Consumer,具体看谁在使用 MQClientInstance。- 如果是 Producer,那么心跳所包含的数据很少,就只有当前客户端的所有生产者组。
- 如果是 Consumer,那数据就多了,比如都有哪些消费者组的名称、消费的模式是广播还是集群、从哪里开始消费数据、消费者消费的 Topic 的简要数据等。 |
| 作用:减少消息发送失败的概率 | 清理下线brokerorg.apache.rocketmq.client.impl.factory.MQClientInstance#cleanOfflineBroker | |
| 作用:记录当前消息消费的状态 | 持久化消息offsetorg.apache.rocketmq.client.impl.factory.MQClientInstance#persistAllConsumerOffset | 其实就是如果当前客户端是 Consumer,就会将当前消费到哪儿了持久化起来,不然下次重启就不知道从哪里开始,从头开始?那已经消费过的消息再消费一次不就变成重复消费了吗?所以定时持久化 Offset 是非常必要的一个操作。 |
| 作用:调整线程池大小 | |
1. 遍历每一个消费者组
-
对每一个实例的线程池调整调整的策略:消费队列数和线程数大小来衡量消费队列数如果大于线程数阈值1,说明任务多了,人少了,需要提高线程,增加干活的人消费队列数如果小于线程数阈值2,说明任务少,人多了,减少线程数,不需要那么多人干活了 |
-
启动拉取消息服务
-
启动负载均衡服务
-
启动推送服务
-
修改状态为启动态(RUNNING)
到这里生产者启动的流程就差不多了,最后贴一张类图(核心是这个MQClientInstance客户端)。
下一节将深入看看Rocket MQ是如何将一条消息发送出去的?
参考:
《Rocket MQ技术内幕》