RocketMQ原理—3.源码设计简单分析下

大纲

1.Producer作为生产者是如何创建出来的

2.Producer启动时是如何准备好相关资源的

3.Producer是如何从拉取Topic元数据的

4.Producer是如何选择MessageQueue的

5.Producer与Broker是如何进行网络通信的

6.Broker收到一条消息后是如何存储的

7.Broker是如何实时更新索引文件的

8.Broker是如何实现同步刷盘以及异步刷盘的

9.Broker是如何清理存储较久的磁盘数据的

10.Consumer作为消费者是如何创建和启动的

11.消费者组的多个Consumer会如何分配消息

12.Consumer会如何从Broker拉取一批消息

1.Producer作为生产者是如何创建出来的

(1)NameServer的启动

(2)Broker的启动

(3)Broker的注册和心跳

(4)通过Producer发送消息

(1)NameServer的启动

NameServer启动后的核心架构,如下图示:

NameServer启动后,会有一个NamesrvController组件管理控制NameServer的所有行为,包括内部会启动一个Netty服务器去监听一个9876端口号,然后接收处理Broker和客户端发送过来的请求。

(2)Broker的启动

Broker启动后的核心架构,如下图示:

Broker启动后,也会有一个BrokerController组件管理控制Broker的整体行为,包括初始化Netty服务器用于接收客户端的网络请求、启动处理请求的线程池、执行定时任务的线程池、初始化核心功能组件,同时还会发送注册请求到NameServer去注册自己。

(3)Broker的注册和心跳

Broker启动后,会向NameServer进行注册和定时发送注册请求作为心跳。NameServer会有一个后台进程定时检查每个Broker的最近一次心跳时间,如果长时间没心跳就认为Broker已经故障。如下图示:

(4)通过Producer发送消息

假设RocketMQ集群已经启动好了NameServer,而且还启动了一批Broker,同时Broker都已经把自己注册到NameServer里去了,NameServer也会定时检查这批Broker是否存活。那么就可以让开发好的业务系统去发送消息到RocketMQ集群里,于是需要创建一个Producer实例。

实际上我们开发好的系统,最终都需要创建一个Producer实例,然后通过Producer实例发送消息到RocketMQ的Broker上去。

下面是使用Producer实例发送消息到RocketMQ的代码,可以看到Producer是如何构造出来的。

DefaultMQProducer producer = new DefaultMQProducer("order_producer_group");
producer.setNamesrvAddr("localhost:9876");
producer.start();

构造Producer的过程很简单:也就是创建一个DefaultMQProducer对象实例。在构造方法中,首先会传入所属的Producer分组,然后设置一下NameServer的地址,最后调用它的start()方法启动这个Producer即可。

创建DefaultMQProducer对象实例是一个非常简单的过程:就是创建出一个对象,然后保存它的Producer分组。设置NameServer地址也是一个很简单的过程,就是保存一下NameServer地址。

所以,最关键的还是调用DefaultMQProducer的start()方法去启动Producer这个消息生产者。

2.Producer启动时是如何准备好相关资源的

(1)DefaultMQProducer的start()方法

(2)Producer在第一次向Topic发送消息时才拉取Topic的路由数据

(3)Producer在第一次向Broker发送消息时才与Broker建立网络连接

(1)DefaultMQProducer的start()方法

接下来分析Producer在启动时是如何准备好相关资源的。Producer内部必须要有独立的线程资源,以及需要和Broker已经建立好网络连接,这样才能把消息发送出去。

在构造Producer时,它内部便会构造一个真正用于执行消息发送逻辑的DefaultMQProducerImpl组件。所以,真正的Producer生产者其实是这个DefaultMQProducerImpl组件。那么这个组件在启动的时都干了什么呢?

public class DefaultMQProducer extends ClientConfig implements MQProducer {
    protected final transient DefaultMQProducerImpl defaultMQProducerImpl;
    ...
    @Override
    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);
            }
        }
    }
    ...
}

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    public void start() throws MQClientException {
        this.start(true);
    }
    
    public void start(final boolean startFactory) throws MQClientException {
        switch (this.serviceState) {
            case CREATE_JUST:
                this.serviceState = ServiceState.START_FAILED;
                this.checkConfig();
                if (!this.defaultMQProducer.getProducerGroup().equals(MixAll.CLIENT_INNER_PRODUCER_GROUP)) {
                    this.defaultMQProducer.changeInstanceNameToPID();
                }
                this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQProducer, rpcHook);

                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);
                }
                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;
                break;
            case RUNNING:
            case START_FAILED:
            case SHUTDOWN_ALREADY:
                throw new MQClientException("The producer service state not OK, maybe started once, " + this.serviceState + FAQUrl.suggestTodo(FAQUrl.CLIENT_SERVICE_NOT_OK), null);
            default:
                break;
        }
        this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
        this.startScheduledTask();
    }
    ...
}

其实,上述start()方法的具体逻辑暂时不需要深入分析,因为其中的逻辑并没有直接与Producer发送消息相关联。比如拉取Topic的路由数据、选择MessageQueue、跟Broker建立长连接、发送消息到Broker等这些核心逻辑,其实都封装在发送消息的方法中。

(2)Producer在第一次向Topic发送消息时才拉取Topic的路由数据

假设后续Producer要发送消息,那么就要指定往哪个Topic发送消息。因此Producer需要知道Topic的路由数据,比如Topic有哪些MessageQueue,每个MessageQueue在哪些Broker上。如下图示:

从start()方法源码可知,在Producer启动时,并不会去拉取Topic的路由数据。实际上,Producer在第一次向Topic发送消息时,才会去拉取Topic的路由数据。包括这个Topic有几个MessageQueue、每个MessageQueue在哪个Broker上。然后从中选择一个MessageQueue,接着与对应的Broker建立网络连接,最后才把消息发送过去。

(3)Producer在第一次向Broker发送消息时才与Broker建立网络连接

从start()方法源码可知,在Producer启动时,并不会和所有Broker建立网络连接。很多核心的逻辑,包括拉取Topic路由数据、选择MessageQueue、和Broker建立网络连接等,都是在Producer第一次发送消息时才进行处理的。

3.Producer是如何从拉取Topic元数据的

(1)Producer发送消息的方法

(2)Producer拉取Topic路由数据的过程

(1)Producer发送消息的方法

当调用Producer的send()方法发送消息时,最终会调用到DefaultMQProducerImpl的sendDefaultImpl()方法。

在sendDefaultImpl()方法里,开始会有一行非常关键的代码,如下所示:

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    private SendResult sendDefaultImpl(Message msg, final CommunicationMode communicationMode, final SendCallback sendCallback, final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        ...
        TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
        ...
    }
    ...
}

该行代码的意思是,每次Producer发送消息时,都会先检查一下要发送消息的那个Topic的路由数据是否在本地。如果不在,才会发送请求到NameServer去拉取Topic的路由数据,然后缓存在本地。

(2)Producer拉取Topic路由数据的过程

进入tryToFindTopicPublishInfo()方法,会发现其逻辑非常简单:就是会先检查一下自己本地是否有这个Topic的路由数据的缓存,如果没有就发送网络请求到NameServer去拉取,如果有就直接返回本地Topic路由数据缓存,如下图示:

那么Producer是如何发送网络请求到NameServer去拉取Topic路由数据的呢?这其实就对应了tryToFindTopicPublishInfo()方法内的一行代码,如下所示:

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    private TopicPublishInfo tryToFindTopicPublishInfo(final String topic) {
        TopicPublishInfo topicPublishInfo = this.topicPublishInfoTable.get(topic);
        if (null == topicPublishInfo || !topicPublishInfo.ok()) {
            this.topicPublishInfoTable.putIfAbsent(topic, new TopicPublishInfo());
            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;
        }
    }
    ...
}

通过以下这行代码,Producer就可以从NameServer拉取某个Topic的路由数据,然后更新到自己本地的缓存里去。

this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic);

Producer发送请求到NameServer的拉取Topic路由数据的过程:首先封装一个Request请求对象,然后通过Netty客户端发送请求到NameServer,接着会接收到NameServer返回的一个Response响应对象,于是就可以从Response响应对象里取出所需的Topic路由数据并更新到自己本地缓存里。更新时会做一些判断,比如Topic路由数据是否有改变过等,然后把Topic路由数据放入本地缓存。如下图示:

4.Producer是如何选择MessageQueue的

(1)Topic是由多个MessageQueue组成的

(2)选择MessageQueue的源码和算法

(1)Topic是由多个MessageQueue组成的

Producer发送消息时,会先检查一下要发送消息的Topic的路由数据是否在本地缓存。如果不在,就会通过底层的Netty网络通信模块发送一个请求到NameServer拉取Topic路由数据,然后缓存在Producer本地。当Producer拿到一个Topic的路由数据后,就应该选择要发送消息到这个Topic的哪一个MessageQueue上了。

因为Topic是一个逻辑上的概念,一个Topic的数据往往会分布式存储在多台Broker机器上,所以Topic本质是由多个MessageQueue组成的。

每个MessageQueue都可以在不同的Broker机器上,当然也可能一个Topic的多个MessageQueue在一个Broker机器上。如下图示:

只要Producer知道了要发送消息到哪个MessageQueue上去,其实就已经知道了这个MessageQueue在哪台Broker机器上,接着和该Broker机器建立连接,发送消息过去即可。

(2)选择MessageQueue的源码和算法

发送消息的源码在DefaultMQProducerImpl的sendDefaultImpl()方法中。该方法里只要Producer获取到Topic的路由数据,不管从本地缓存获取还是从NameServer拉取,就会执行下面的代码:

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    private SendResult sendDefaultImpl(Message msg, final CommunicationMode communicationMode, final SendCallback sendCallback, final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        ...
        MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);
        ...
    }
    ...
}

selectOneMessageQueue()方法其实就是在选择Topic中的一个MessageQueue,然后发送消息到这个MessageQueue。下面是选择MessageQueue的算法:

public class MQFaultStrategy {
    ...
    public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
        ...
        int index = tpInfo.getSendWhichQueue().incrementAndGet();
        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);
            if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) {
                return mq;
            }
        }
        ...
    }
    ...
}

这是一种简单的负载均衡算法:首先获取一个自增长的Index,接着就用这个Index对Topic的MessageQueue列表进行取模运算,从而获取到一个MessageQueue列表的位置,最后返回这个位置的MessageQueue。

但是如果某个Broker故障了,那么就不能把消息发送到故障Broker的MessageQueue了。所以selectOneMessageQueue()方法里还有其他代码,用来实现Broker故障时的自动回避机制。

5.Producer与Broker是如何进行网络通信的

(1)Producer是如何把消息发送给Broker的

(2)Producer和Broker基于长连接进行通信

(1)Producer是如何把消息发送给Broker的

在DefaultMQProducerImpl.sendDefaultImpl()方法中,会先获取到MessageQueue所在的Broker名称,如下所示:

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    private SendResult sendDefaultImpl(Message msg, final CommunicationMode communicationMode, final SendCallback sendCallback, final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        ...
        MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);
        if (mqSelected != null) {
            mq = mqSelected;
            brokersSent[times] = mq.getBrokerName();
            ...
        }
        ...
    }
    ...
}

获取到这个BrokerName后,就会调用sendKernelImpl()方法把消息发送到Broker上。

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    private SendResult sendDefaultImpl(Message msg, final CommunicationMode communicationMode, final SendCallback sendCallback, final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        ...
        sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);
        ...
    }
    
    private SendResult sendKernelImpl(final Message msg, final MessageQueue mq, final CommunicationMode communicationMode, final SendCallback sendCallback, final TopicPublishInfo topicPublishInfo, final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        long beginStartTime = System.currentTimeMillis();
        String brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
        if (null == brokerAddr) {
            tryToFindTopicPublishInfo(mq.getTopic());
            brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
        }
        ...
    }
    ...
}

在sendKernelImpl()方法中:

首先会通过BrokerName去本地缓存查找它的实际地址。如果找不到,就到NameServer中拉取Topic的路由数据,再次在本地缓存获取Broker的实际地址,有了这个地址才能进行网络通信。

然后会封装一个Request请求,包括请求头、发送的消息等,并且会给消息分配全局唯一ID,以及对超过4KB的消息体进行压缩。

在Request请求中,会包含生产者组、Topic名称、Topic的MessageQueue数量、MessageQueue的ID、消息发送时间、消息的flag、消息扩展属性、消息重试次数、是否批量发送等信息,如果是事务消息则带上prepared标记等。

把这些数据都封装到一个Request请求后,就会通过Netty把Request请求发送到指定的Broker上。

(2)Producer和Broker基于长连接进行通信

其中,Producer和Broker会通过Netty建立长连接,然后基于长连接进行持续通信。如下图示:

那么Broker上的Netty服务器接收到消息后,会如何进行处理?这个过程比较复杂,涉及到CommitLog、ConsumeQueue、IndexFile、Checkpoint等一系列机制,这也是RocketMQ中核心机制。

6.Broker收到一条消息后是如何存储的

(1)Broker收到消息后的处理流程

(2)Broker如何将消息写入CommitLog文件

(1)Broker收到消息后的处理流程

Broker中的Netty网络服务器获取到一条消息后:

首先,会把这条消息写入到一个CommitLog文件里。一个Broker机器上就只有一个CommitLog文件,所有Topic的消息都会写入到这个文件里。如下图示:

然后,Broker会以异步的方式把消息写入到一个ConsumeQueue文件里,因为一个Topic会有多个MessageQueue。任何一条消息都需要写入到一个MessageQueue的,一个MessageQueue其实就是对应了一个ConsumeQueue文件。所以一条写入MessageQueue的消息,必然会异步进入对应的ConsumeQueue文件,如下图示:

接着,Broker还会以异步的方式把消息写入到一个IndexFile文件里。在IndexFile文件里,会把每条消息的key和消息在CommitLog中的offset偏移量做一个索引,这样后续如果要根据消息key从CommitLog文件里查询消息,就可以根据IndexFile文件的索引来查询,如下图示:

(2)Broker如何将消息写入CommitLog文件

Broker收到一个消息后,首先会顺序写入CommitLog文件。CommitLog文件的存储目录是${ROCKETMQ_HOME}/store/commitlog,目录里会有很多CommitLog文件。每个文件默认是1GB大小,一个CommitLog文件写满了就创建一个新的CommitLog文件,文件名就是文件中的第一个偏移量。文件名如果不足20位,就用0来补齐。

00000000000000000000
000000000003052631924

Broker在把消息写入CommitLog文件时,会申请一个putMessageLock锁。也就是说,Broker写入消息到CommitLog文件时都是串行的,不会并发写入,因为并发写入文件必然会有数据错乱的问题。

下面是相关的源码片段:

public class CommitLog {
    ...
    protected final PutMessageLock putMessageLock;
    ...
    public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {
        ...
        putMessageLock.lock();
        ...
        result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
        ...
    }
    ...
}

在asyncPutMessage()方法中,获取到锁之后,会对消息做出一系列处理,包括设置消息的存储时间、创建全局唯一的消息ID、计算消息的总长度等。然后会执行MappedFile的appendMessage()方法,把消息写入到MappedFile里。

public class MappedFile extends ReferenceResource {
    ...
    public AppendMessageResult appendMessage(final MessageExtBrokerInner msg, final AppendMessageCallback cb, PutMessageContext putMessageContext) {
        return appendMessagesInner(msg, cb, putMessageContext);
    }
    
    public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb, PutMessageContext putMessageContext) {
        ...
        ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
        byteBuffer.position(currentPos);
        AppendMessageResult result;
        if (messageExt instanceof MessageExtBrokerInner) {
            result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt, putMessageContext);
        } else if (messageExt instanceof MessageExtBatch) {
            result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt, putMessageContext);
        } else {
            return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
        }
        this.wrotePosition.addAndGet(result.getWroteBytes());
        this.storeTimestamp = result.getStoreTimestamp();
        return result;
    }
    ...
}

上述源码中,其实最关键的是cb.doAppend()这行代码。cb.doAppend()会把消息追加到MappedFile映射的一块内存里去,并没有直接刷入到磁盘上的CommitLog文件,如下图示。至于具体什么时候才会把内存里的数据刷入磁盘上的CommitLog文件,这就要看配置的刷盘策略了。

另外,不管是同步刷盘还是异步刷盘,如果配置了主从同步,一旦将消息写入到CommitLog文件之后,接下来都会进行主从同步复制。

7.Broker是如何实时更新索引文件的

(1)消息如何进入CommitLog

(2)消息如何进入ConsumeQueue和IndexFile

(1)消息如何进入CommitLog

Broker收到一条消息后,会先把消息写入到CommitLog里。但是刚开始写入也仅仅是写入到MappedFile映射的一块内存,后续才会根据刷盘策略决定是否立即把数据从内存刷入磁盘。如下图示:

(2)消息如何进入ConsumeQueue和IndexFile

Broker启动时会启动一个叫ReputMessageService的线程,这个线程会把写入CommitLog的消息转发出去,也就是将消息写入(转发)到ConsumeQueue和IndexFile。如下图示:

在DefaultMessageStore的start()方法里,会启动这个ReputMessageService线程。而DefaultMessageStore的start()方法是在Broker启动时被调用的,所以相当于Broker启动时就会启动这个ReputMessageService线程。

public class BrokerController {
    ...
    public void start() throws Exception {
        //启动消息存储组件
        if (this.messageStore != null) {
            this.messageStore.start();
        }
        ...
    }
    ...
}

public class DefaultMessageStore implements MessageStore {
    ...
    public void start() throws Exception {
        ...
        this.reputMessageService.setReputFromOffset(maxPhysicalPosInLogicQueue);
        this.reputMessageService.start();
        ...
    }
    ...
}

下面是ReputMessageService线程的源码:

public class DefaultMessageStore implements MessageStore {
    ...
    class ReputMessageService extends ServiceThread {
        ...
        @Override
        public void run() {
            DefaultMessageStore.log.info(this.getServiceName() + " service started");
            while (!this.isStopped()) {
                try {
                    Thread.sleep(1);
                    this.doReput();
                } catch (Exception e) {
                    DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
                }
            }
            DefaultMessageStore.log.info(this.getServiceName() + " service end");
        }
        
        private void doReput() {
            ...
            DispatchRequest dispatchRequest = DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
            ...
            DefaultMessageStore.this.doDispatch(dispatchRequest);
            ...
        }
        ...
    }
    
    public void doDispatch(DispatchRequest req) {
        for (CommitLogDispatcher dispatcher : this.dispatcherList) {
            dispatcher.dispatch(req);
        }
    }
    ...
}

由上述代码可知:在ReputMessageService线程里,每隔1毫秒就会把最近写入CommitLog的消息进行一次转发。其中会通过doReput()方法将消息转发到ConsumeQueue和IndexFile中。

在doReput()方法里,会从CommitLog中去获取到一个DispatchRequest,也就是从CommitLog中获取一份需要进行转发的消息。

接着,就会通过调用doDispatch()方法将消息转发到ConsumeQueue和IndexFile里,其中会通过遍历CommitLogDispatcher来实现。因为这个CommitLogDispatcher的实现类有两个,分别负责把消息转发到ConsumeQueue和IndexFile。

ConsumeQueueDispatcher的写入逻辑,就是找到当前Topic的messageQueueId对应的一个ConsumeQueue文件。一个MessageQueue会对应多个ConsumeQueue文件,只要找到一个即可,然后就可以把消息写入其中。

public class DefaultMessageStore implements MessageStore {
    ...
    class CommitLogDispatcherBuildConsumeQueue implements CommitLogDispatcher {
        @Override
        public void dispatch(DispatchRequest request) {
            final int tranType = MessageSysFlag.getTransactionValue(request.getSysFlag());
            switch (tranType) {
                case MessageSysFlag.TRANSACTION_NOT_TYPE:
                case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
                    DefaultMessageStore.this.putMessagePositionInfo(request);
                    break;
                case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
                case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
                    break;
            }
        }
    }
    
    public void putMessagePositionInfo(DispatchRequest dispatchRequest) {
        ConsumeQueue cq = this.findConsumeQueue(dispatchRequest.getTopic(), dispatchRequest.getQueueId());
        cq.putMessagePositionInfoWrapper(dispatchRequest, checkMultiDispatchQueue(dispatchRequest));
    }
    ...
}

IndexDispatcher的写入逻辑,就是在IndexFile里构建对应的索引。

public class DefaultMessageStore implements MessageStore {
    ...
    class CommitLogDispatcherBuildIndex implements CommitLogDispatcher {
        @Override
        public void dispatch(DispatchRequest request) {
            if (DefaultMessageStore.this.messageStoreConfig.isMessageIndexEnable()) {
                DefaultMessageStore.this.indexService.buildIndex(request);
            }
        }
    }
    ...
}

(3)总结

当Broker把消息写入CommitLog后,会有一个后台线程每隔1毫秒拉取CommitLog中最新的一批消息,然后分别转发到ConsumeQueue和IndexFile中。

8.Broker是如何实现同步刷盘以及异步刷盘的

(1)Broker收到消息后的存储流程

(2)消息的刷盘时机和策略

(3)Broker是如何处理刷盘的

(1)Broker收到消息后的存储流程

Broker首先会将消息写入CommitLog,并且是先写入MappedFile映射的一块内存,而不是先写入磁盘。然后会有一个后台线程把CommitLog里的消息写入到ConsumeQueue和IndexFile里,如下图示:

(2)消息的刷盘时机和策略

当需要写入CommitLog的数据进入到MappedFile映射的一块内存后,就会开始执行刷盘策略。如果是同步刷盘,那么就会直接把内存里的数据写入磁盘文件。如果是异步刷盘,那么就会过一段时间后再把数据刷入磁盘文件。

在往CommitLog写数据时,会调用CommitLog的asyncPutMessage()方法,在这个方法的末尾有两行很关键的代码。一个是调用submitFlushRequest()方法,用于决定如何进行刷盘。一个是调用submitReplicaRequest()方法,用于决定如何把消息同步给Slave Broker。如下所示:

public class CommitLog {
    ...
    public CompletableFuture<PutMessageResult> asyncPutMessages(final MessageExtBatch messageExtBatch) {
        ...
        //用于决定如何进行刷盘
        CompletableFuture<PutMessageStatus> flushOKFuture = submitFlushRequest(result, messageExtBatch);
        //用于决定如何把消息同步给Slave Broker
        CompletableFuture<PutMessageStatus> replicaOKFuture = submitReplicaRequest(result, messageExtBatch);
        return flushOKFuture.thenCombine(replicaOKFuture, (flushStatus, replicaStatus) -> {
            if (flushStatus != PutMessageStatus.PUT_OK) {
                putMessageResult.setPutMessageStatus(flushStatus);
            }
            if (replicaStatus != PutMessageStatus.PUT_OK) {
                putMessageResult.setPutMessageStatus(replicaStatus);
            }
            return putMessageResult;
        });
    }
    ...
}

(3)Broker是如何处理刷盘的

接下来进入submitFlushRequest()方法看看Broker是如何处理刷盘的。

public class CommitLog {
    ...
    public CompletableFuture<PutMessageStatus> submitFlushRequest(AppendMessageResult result, MessageExt messageExt) {
        //Synchronization flush------同步刷盘
        if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
            final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
            if (messageExt.isWaitStoreMsgOK()) {
                GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(), this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
                flushDiskWatcher.add(request);
                service.putRequest(request);
                return request.future();
            } else {
                service.wakeup();
                return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
            }
        }
        //Asynchronous flush------异步刷盘
        else {
            if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
                flushCommitLogService.wakeup();
            } else  {
                commitLogService.wakeup();
            }
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }
    }
    ...
}

上述代码,就会根据配置的两种不同的刷盘策略,来分别进行处理的。

一.同步刷盘的策略是如何处理的

首先会构建一个GroupCommitRequest,然后提交给GroupCommitService去进行处理,接着调用request.future()等待同步刷盘成功。

具体的刷盘是由GroupCommitService执行的,它的doCommit()方法会执行同步刷盘的逻辑,代码如下:

public class CommitLog {
    ...
    class GroupCommitService extends FlushCommitLogService {
        ...
        private void doCommit() {
            ...
            CommitLog.this.mappedFileQueue.flush(0);
            ...
        }
        ...
    }
    ...
}

上述代码一层一层调用下去,可发现最终刷盘其实是靠MappedByteBuffer的force()方法,如下所示:

public class MappedFile extends ReferenceResource {
    ...
    public int flush(final int flushLeastPages) {
        ...
        this.mappedByteBuffer.force();
        ...
    }
    ...
}

这个MappedByteBuffer就是JDK NIO包下的API,MappedByteBuffer的force()方法会强迫将写入内存的数据刷入到磁盘文件里,执行完force()方法就代表同步刷盘成功了。

二.异步刷盘的策略是如何处理的

此时会唤醒一个flushCommitLogService组件。由于FlushCommitLogService是一个线程,它是一个抽象父类,它的子类是CommitRealTimeService。所以真正唤醒的是FlushCommitLogService的子类CommitRealTimeService线程。

在该线程里,会每隔一定时间执行一次刷盘,最大间隔是10s。所以一旦执行异步刷盘,那么最多10秒就会执行一次刷盘。

9.Broker是如何清理存储较久的磁盘数据的

(1)定时检查是否要删除磁盘上的文件

(2)触发删除文件的条件

(3)删除文件的具体操作

(1)定时检查是否要删除磁盘上的文件

默认情况下,Broker会启动一个后台线程,这个后台线程会自动检查CommitLog文件、ConsumeQueue文件,因为这些文件都会存在多个。如果发现比较旧的、超过72小时的文件,那么就会清理这些文件。

所以,默认情况下,Broker只会将消息保留3天,当然我们也可以通过fileReservedTime来自定义配置这个时间。

这个定时检查过期数据文件的线程,在DefaultMessageStore这个类里。在DefaultMessageStore的start()方法中,会调用addScheduleTask()方法每隔10s定时执行一个后台检查任务。如下所示:

public class DefaultMessageStore implements MessageStore {
    ...
    public void start() throws Exception {
        ...
        this.addScheduleTask();
        ...
    }
    
    private void addScheduleTask() {
        this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                DefaultMessageStore.this.cleanFilesPeriodically();
            }
        }, 1000 * 60, this.messageStoreConfig.getCleanResourceInterval(), TimeUnit.MILLISECONDS);
        ...
    }
    ...
}

在这个任务中,就会执行DefaultMessageStore的cleanFilesPeriodically()方法。其实也就是会周期性地清理掉磁盘上超过72小时的CommitLog、ConsumeQueue文件。

cleanFilesPeriodically()方法中包含了清理CommitLog和ConsumeQueue文件的逻辑:

public class DefaultMessageStore implements MessageStore {
    ...
    private void cleanFilesPeriodically() {
        this.cleanCommitLogService.run();
        this.cleanConsumeQueueService.run();
    }
    ...
}

(2)触发删除文件的条件

条件一:如果当前时间是预先设置的凌晨4点,就会触发执行一次删除文件的逻辑,这个时间是默认的

条件二:如果磁盘空间不足了也就是超过了85%的使用率,就会马上触发执行一次删除文件的逻辑

条件一指的是:如果磁盘没有满 ,那么每天会进行一次删除磁盘文件的操作,默认在凌晨4点执行,因为那个时候基本是业务低峰期。

条件二指的是:如果磁盘使用率超过85%了,那么此时可以允许继续在磁盘里写入数据,但会马上触发一次删除文件的操作。

注意:如果磁盘使用率超过90%了,那么此时是不允许再往磁盘里写入新数据的,同时会马上删除文件。因为一旦磁盘满了,那么写入磁盘就会失败,此时MQ就会出现故障。

(3)删除文件的具体操作

在删除文件时,无非就是对文件进行遍历。如果一个文件超过72小时都没修改过了,此时就可以删除了,哪怕有的消息可能还没有被消费,此时也不会再让消费者去消费了,直接删除掉。

10.Consumer作为消费者是如何创建和启动的

(1)Cosumer是如何创建和启动的

(2)Consumer启动时的三个核心组件总结

(1)Cosumer是如何创建和启动的

一般会通过DefaultMQPushConsumerImpl来创建Consumer,然后调用它的start()方法进行启动。

在执行start()方法启动Consumer的过程中,就会执行如下代码让Consumer和Broker建立长连接。只有建立了长连接,Consumer才能不断地从Broker中拉取消息。其中,MQClientFactory也是基于Netty来实现的。

public class DefaultMQPushConsumerImpl implements MQConsumerInner {
    ...
    public synchronized void start() throws MQClientException {
        ...
        this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQPushConsumer, this.rpcHook);
        ...
    }
    ...
}

接着看start()方法的如下代码:

public class DefaultMQPushConsumerImpl implements MQConsumerInner {
    ...
    public synchronized void start() throws MQClientException {
        ...
        this.rebalanceImpl.setConsumerGroup(this.defaultMQPushConsumer.getConsumerGroup());
        this.rebalanceImpl.setMessageModel(this.defaultMQPushConsumer.getMessageModel());
        this.rebalanceImpl.setAllocateMessageQueueStrategy(this.defaultMQPushConsumer.getAllocateMessageQueueStrategy());
        this.rebalanceImpl.setmQClientFactory(this.mQClientFactory);
        ...
    }
    ...
}

上述代码的RebalanceImpl就是专门负责Consumer重平衡的。如果ConsumerGroup中加入了一个新的Consumer,那么就会重新分配每个Consumer消费的MessageQueue。如果ConsumerGroup里某个Consumer宕机了,那么也会重新分配MessageQueue,这就是所谓的重平衡。

接着看start()方法的如下代码:

public class DefaultMQPushConsumerImpl implements MQConsumerInner {
    ...
    public synchronized void start() throws MQClientException {
        ...
        this.pullAPIWrapper = new PullAPIWrapper(mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup(), isUnitMode());
        this.pullAPIWrapper.registerFilterMessageHook(filterMessageHookList);
        ...
    }
    ...
}

这个PullAPIWrapper就是消费者专门用来拉取消息的API组件。

接着看start()方法的如下代码:

public class DefaultMQPushConsumerImpl implements MQConsumerInner {
    ...
    public synchronized void start() throws MQClientException {
        ...
        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();       
        ...
    }
    ...
}

上面代码中的OffsetStore其实就是用来存储和管理Consumer消费进度offset的一个组件。

(2)Consumer启动时的三个核心组件总结

DefaultMQPushConsumerImpl的start()方法最核心的就是这三个组件。

首先Consumer刚启动,需要根据Rebalancer组件进行重平衡,确定自己要分配哪些MessageQueue之后才去拉取消息。

然后在拉取消息时,需要根据PullAPI组件通过底层网络通信发送请求进行拉取。

接着在拉取消息的过程中,需要根据OffsetStore组件来维护offset消费进度。

如果ConsumerGroup中多了Consumer或者少了Consumer,那么就需要根据Rebalancer组件来进行重平衡。

11.消费者组的多个Consumer会如何分配消息

(1)Consumer的负载均衡问题

(2)重平衡组件如何分配MessageQueue

(1)Consumer的负载均衡问题

当一个业务系统部署多台机器时,每个系统里都启动了一个Consumer。多个Consumer会组成一个ConsumerGroup,也就是消费者组。此时就会有一个消费者组内的多个Consumer同时消费一个Topic,而且这个Topic是有多个MessageQueue分布在多个Broker上的。如下图示:

那么问题来了:如果一个业务系统部署在两台机器上,对应一个消费者组里就有两个Consumer。而业务系统需要消费的一个Topic有三个MessageQueue,那么应该怎么分配呢?这就涉及到Consumer的负载均衡问题了。

前面介绍Consumer启动时,就介绍了几个关键的组件,分别是:重平衡组件、消息拉取组件、消费进度组件。其中的重平衡组件,就是专门负责处理多个Consumer的负载均衡问题的。

(2)重平衡组件如何分配MessageQueue

那么这个RebalancerImpl重平衡组件是如何将多个MessageQueue均匀的分配给一个消费者组内的多个Consumer的?

实际上,每个Consumer在启动后,都会向所有的Broker进行注册,并且持续保持自己的心跳,让每个Broker都能感知到一个消费者组内有哪些Consumer。下图中没有画出Consumer向每个Broker进行注册以及心跳,只能大致示意一下。

每个Consumer在启动后,重平衡组件都会随机挑选一个Broker,从里面获取该消费者组里有哪些Consumer存在。

当重平衡组件知道了消费者组内有哪些Consumer后,接下来就好办了。无非就是把Topic下的MessageQueue均匀地分配给这些Consumer。这时候其实有几种算法可以进行分配,但比较常用的一种算法就是平均分配。

假设现在一共有3个MessageQueue,有2个Consumer。那么就会给1个Consumer分配2个MessageQueue,给另外1个Consumer分配剩余的1个MessageQueue。

假设现在一共有4个MessageQueue,有2个Consumer。那么就可以2个Consumer各自分配2个MessageQueue。

总之一切都是平均分配,尽量保证每个Consumer的负载差不多。这样,一旦MessageQueue负载确定后,Consumer就知道自己要消费哪几个MessageQueue的消息,于是就可以连接到那个Broker上,从里面不停拉取消息过来进行消费。

12.Consumer会如何从Broker拉取一批消息

(1)什么是消费者组

(2)集群模式消费 vs 广播模式消费

(3)MessageQueue和ConsumeQueue以及CommitLog之间的关系

(4)MessageQueue与消费者的关系

(5)Push消费模式 vs Pull消费模式

(6)Broker如何读取消息返回给消费者

(7)消费者如何处理消息、进行ACK响应以及提交消费进度

(8)消费者组出现宕机或扩容应如何处理

(9)消费源码的流程

(1)什么是消费者组

一.消费者组举例

消费者组的意思就是给一组消费者起一个名字。比如有一个Topic叫TopicOrderPaySuccess,库存系统、积分系统、营销系统、仓储系统都要去消费这个Topic中的数据,那么此时应该给这四个系统分别起一个消费者组名字,如下所示:

stock_consumer_group、marketing_consumer_group、
credit_consumer_group、wms_consumer_group

设置消费者组的方式如下所示:

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("stock_consumer_group");

假设库存系统部署了4台机器,每台机器上的消费者组的名字都是stock_consumer_group,那么这4台机器就同属于一个消费者组。以此类推,每个系统的几台机器都是属于各自的消费者组。

下图展示了两个系统,每个系统都有2台机器,每个系统都有一个自己的消费者组。

二.不同消费者组之间的关系

假设库存系统和营销系统作为两个消费者组,都订阅了TopicOrderPaySuccess这个订单支付成功消息的Topic,此时如果订单系统作为生产者发送了一条消息到这个Topic,那么这条消息会被如何消费呢?

一般情况下,这条消息进入Broker后,库存系统和营销系统作为两个消费者组,每个组都会拉取到这条消息。也就是说,这个订单支付成功的消息,库存系统会获取到一条,营销系统也会获取到一条,它们俩都会获取到这条消息。

但库存系统这个消费者组里有两台机器,是两台机器都获取到这条消息、还是只有一台机器会获取到这条消息?

一般情况下,库存系统的两台机器中只有一台机器会获取到这条消息,营销系统也是同理。

下图展示了对于同一条订单支付成功的消息,库存系统的一台机器获取到了、营销系统的一台机器也获取到了。所以在消费时,不同的系统应该设置不同的消费者组。如果不同的消费者组订阅了同一个Topic,对Topic里的同一条消息,每个消费者组都会获取到这条消息。

(2)集群模式消费 vs 广播模式消费

对于一个消费者组而言,它获取到一条消息后,如果消费者组内部有多台机器,到底是只有一台机器可以获取到这个消息,还是每台机器都可以获取到这个消息?这就是集群模式和广播模式的区别。

默认情况下都是集群模式:即一个消费者组获取到一条消息,只会交给组内的一台机器去处理,不是每台机器都可以获取到这条消息的。

但是可以通过如下设置来改变为广播模式:

consumer.setMessageModel(MessageModel.BROADCASTING);

如果修改为广播模式,那么对于消费者组获取到的一条消息,组内每台机器都可以获取到这条消息。但是相对而言广播模式用的很少,基本上都是使用集群模式来进行消费的。

(3)MessageQueue和ConsumeQueue以及CommitLog之间的关系

在创建Topic时,需要设置Topic有多少个MessageQueue。Topic中的多个MessageQueue会分散在多个Broker上,一个Broker上的一个MessageQueue会有多个ConsumeQueue文件。但在一个Broker运行过程中,一个MessageQueue只会对应一个ConsumeQueue文件。

对于Broker而言,存储在一个Broker上的所有Topic及MessageQueue数据都会写入一个统一的CommitLog文件,一个Broker收到的所有消息都会往CommitLog文件里面写。

对于Topic的各个MessageQueue而言,则是通过各个ConsumeQueue文件来存储属于MessageQueue的消息在CommitLog文件中的物理地址(即offset偏移量)。

(4)MessageQueue与消费者的关系

一个Topic上的多个MessageQueue是如何让一个消费者组中的多台机器来进行消费的?可以简单理解为,它会均匀将MessageQueue分配给消费者组的多台机器来消费。

举个例子,假设TopicOrderPaySuccess有4个MessageQueue,这4个MessageQueue分布在两个Master Broker上,每个Master Broker上有2个MessageQueue。然后库存系统作为一个消费者组,库存系统里有两台机器。那么正常情况下,最好就是让这两台机器各自负责2个MessageQueue的消费。比如库存系统的机器01从Master Broker01上消费2个MessageQueue,库存系统的机器02从Master Broker02上消费2个MessageQueue。这样就能把消费的负载均摊到两台Master Broker上。

所以大致可以认为一个Topic的多个MessageQueue会均匀分摊给消费者组内的多个机器去消费。

这里的一个原则是:一个MessageQueue只能被一个消费者机器去处理,但是一台消费者机器可以负责多个MessageQueue的消息处理。

(5)Push消费模式 vs Pull消费模式

一.一般选择Push消费模式

既然一个消费者组内的多台机器会分别负责一部分MessageQueue的消费的,那么每台机器都必须要连接到对应的Broker,尝试消费里面MessageQueue对应的消息。于是就涉及到两种消费模式了,一个是Push模式、一个是Pull模式。

这两个消费模式本质上是一样的,都是消费者主动发送请求到Broker去拉取一批消息进行处理。

Push消费模式是基于Pull消费模式来实现的,只不过它的名字叫做Push而已。在Push模式下,Broker会尽可能实时把新消息交给消费者进行处理,它的消息时效性会更好。

一般我们使用RocketMQ时,消费模式通常都选择Push模式来,因为Pull模式的代码写起来更加复杂和繁琐,而且Push模式底层本身就是基于Pull模式来实现的,只不过时效性更好而已。

二.Push消费模式的实现思路

当消费者发送请求到Broker去拉取消息时,如果有新的消息可以消费,那么就马上返回一批消息到消费机器去处理。消费者处理完之后,会接着发送请求到Broker机器去拉取下一批消息。

所以,消费者机器在Push模式下处理完一批消息,会马上发起请求拉取下一批消息,消息处理的时效性非常好,看起来就像Broker一直不停的推送消息到消费机器一样。

此外,Push模式下有一个请求挂起和长轮询的机制:当拉取消息的请求发送到Broker,Broker却发现没有新的消息可以处理时,就会让处理请求的线程挂起,默认是挂起15秒。然后在挂起期间,Broker会有一个后台线程,每隔一会就检查一下是否有新的消息。如果有新的消息,就主动唤醒被挂起的请求处理线程,然后把消息返回给消费者。

可见,常见的Push消费模式,本质也是消费者不断发送请求到Broker去拉取一批一批的消息。

(6)Broker如何读取消息返回给消费者

Broker在收到消费者的拉取请求后,是如何将消息读取出来,然后返回给消费者的?这涉及到ConsumeQueue和CommitLog。

假设一个消费者发送了拉取请求到Broker,表示它要拉取MessageQueue0中的消息,然后它之前都没拉取过消息,所以就从这个MessageQueue0中的第一条消息开始拉取。

于是,Broker就会找到MessageQueue0对应的ConsumeQueue0,从里面找到第一条消息的offset。接着Broker就需要根据ConsumeQueue0中找到的第一条消息的地址,去CommitLog中根据这个offset地址读取出这条消息的数据,然后把这条消息的数据返回给消费者。

所以消费者在消费消息时,本质就是:首先根据要消费的MessageQueue以及开始消费的位置,去找到对应的ConsumeQueue。然后在ConsumeQueue中读取要消费的消息在CommitLog中的offset偏移量。接着到CommitLog中根据offset读取出完整的消息数据,最后将完整的消息数据返回给消费者。

(7)消费者如何处理消息、进行ACK响应以及提交消费进度

消费者拉取到一批消息后,就会将这批消息传入注册的回调函数,如下所示:

consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        //处理消息
        //标记该消息已经被成功消费
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;    
    }
});

当消费者处理完这批消息后,消费者就会提交目前的一个消费进度到Broker上,然后Broker就会存储消费者的消费进度。

比如现在对ConsumeQueue0的消费进度就是在offset=1的位置,那么Broker会记录下一个ConsumeOffset来标记该消费者的消费进度。这样下次这个消费者组只要再次拉取这个ConsumeQueue的消息,就可以从Broker记录的消费位置开始继续拉取,不用重头开始拉取了。

(8)消费者组出现宕机或扩容应如何处理

此时会进入一个Rebalance环节,也就是重新给各个消费者分配各自需要处理的MessageQueue。

比如现在机器01负责MessageQueue0和MessageQueue1,机器02负责MessageQueue2和MessageQueue3。如果现在机器02宕机了,那么机器01就会接管机器02之前负责的MessageQueue2和MessageQueue3。如果此时消费者组加入了一台机器03,那么就可以把机器02负责的MessageQueue3转移给机器03,然后机器01只负责一个MessageQueue2的消费,这就是负载重平衡。

(9)消费源码的流程

拉取消息的源码入口在DefaultMQPushConsumerImpl类的pullMessage()方法,里面涉及了:拉取请求、消息流量控制、通过PullAPIWrapper与服务端进行网络交互、服务端根据ConsumeQueue文件拉取消息等事情。

相关推荐
Lin_Miao_0917 小时前
RocketMQ优势剖析-集成云原生环境
云原生·rocketmq
codeBrute2 天前
常见的RocketMQ面试题及其简要答案
rocketmq
小白的一叶扁舟2 天前
Kafka 入门与应用实战:吞吐量优化与与 RabbitMQ、RocketMQ 的对比
java·spring boot·kafka·rabbitmq·rocketmq
weisian1512 天前
消息队列篇--原理篇--RocketMQ和Kafka对比分析
分布式·kafka·rocketmq
东阳马生架构3 天前
RocketMQ原理—2.源码设计简单分析上
rocketmq
哭哭啼3 天前
rocketmq dashboard 安装
java·rocketmq·java-rocketmq
东阳马生架构4 天前
RocketMQ原理—1.RocketMQ整体运行原理
rocketmq
小虾米 ~6 天前
rocketmq基本架构
rocketmq
web150854159356 天前
从零到一:Spring Boot 与 RocketMQ 的完美集成指南
spring boot·rocketmq·java-rocketmq