RocketMQ架构:客户端分析

MQClientInstance客户端实例
        MQClientInstance是RocketMQ中一个非常重量级的对象,汇积了所有底层功能,包括请求的拉取、开线程定时刷新本地缓存数据、消费负载、心跳检测等等。因此针对一个MQ集群,这样的实例应该只创建一个,避免多个实例导致并发问题,同时也可以减少客户端不必要的消耗。    

public class MQClientInstance {
    ...
    private final ClientConfig clientConfig;
    private final String clientId;    
    private final ConcurrentMap<String/* group */, MQProducerInner> producerTable = new ConcurrentHashMap<String, MQProducerInner>();
    private final ConcurrentMap<String/* group */, MQConsumerInner> consumerTable = new ConcurrentHashMap<String, MQConsumerInner>();
    private final ConcurrentMap<String/* group */, MQAdminExtInner> adminExtTable = new ConcurrentHashMap<String, MQAdminExtInner>();
    private final NettyClientConfig nettyClientConfig;  
    private final ConcurrentMap<String/* Topic */, TopicRouteData> topicRouteTable = new ConcurrentHashMap<String, TopicRouteData>();
    private final ConcurrentMap<String/* Broker Name */, HashMap<Long/* brokerId */, String/* address */>> brokerAddrTable = new ConcurrentHashMap<String, HashMap<Long, String>>();
    private final PullMessageService pullMessageService;
    private final RebalanceService rebalanceService;
    ...
    public void start() throws MQClientException {
        synchronized (this) {
            switch (this.serviceState) {
                case CREATE_JUST:
                    this.serviceState = ServiceState.START_FAILED;
                    // 支持从第三方配置中心拉取NameServer配置信息
                    if (null == this.clientConfig.getNamesrvAddr()) {
                        this.mQClientAPIImpl.fetchNameServerAddr();
                    }
                    // Start request-response channel
                    this.mQClientAPIImpl.start();
                    // 开始相关定时器
                    this.startScheduledTask();
                    // 启动消息拉取服务
                    this.pullMessageService.start();
                    // 启动消息负载服务
                    this.rebalanceService.start();
                    // Start push service
                    this.defaultMQProducer.getDefaultMQProducerImpl().start(false);
                    
                    this.serviceState = ServiceState.RUNNING;
                    break;
                ...
            }
        }
    }

    private void startScheduledTask() {
        //...默认每隔30秒更新一次本地topic信息
        this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    MQClientInstance.this.updateTopicRouteInfoFromNameServer();
                } ...
            }
        }, 10, this.clientConfig.getPollNameServerInterval(), TimeUnit.MILLISECONDS);
        //...默认每隔30秒发送一次心跳检测
        this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    MQClientInstance.this.cleanOfflineBroker();
                    MQClientInstance.this.sendHeartbeatToAllBrokerWithLock();
                } ...
            }
        }, 1000, this.clientConfig.getHeartbeatBrokerInterval(), TimeUnit.MILLISECONDS);
        ...
    } 

}

MQClientInstance核心功能分析
      不管是Producer还是Consumer,在启动前都会先启动客户端,MQClientInstance核心功能如下:

  1. 启动相关定时器:比如默认每30秒从Broker上定时拉取本地producer或consumer感兴趣的Topic信息,更新本地缓存;
    默认每30秒向所有Master发送一次心跳包,告诉本节点上的消费者、生产者信息等等。
  2. 启动消息拉取服务:典型的生产者-消费者模式,内部监听一个队列,当有PullRequest拉取对象到来时发送拉取请求。
    注:本质上RocketMQ并没有真正实现推模式,推模式在内部仍然是通过循环向消息服务端发送拉请求来实现的。
  3. 启动消息负载服务:确定本节点消费者对应的消费队列<默认每20秒更新一次>,确定完成之后追加PullRequest对象到拉取队列中,触发拉取服务进行消息拉取。负载原理大概是这样:从本地缓存中获取Topic的<MessageQueue >列表,实时从Broker上拉取组内所有消费者列表,对两个列表进行排序,队列按照queueId排序,消费者按clientId排序。 然后根据负载算法进行分配队列,由于不同节点上的消费者clientId不一样,所以在消费者列表中的下标也不一样,也就不会出现多个消费者拉取同一队列的情况。但由于消费者数量的增减会直接影响到分配的队列,所以每次分配完成之后都需要进行一次update操作,与上次分配的队列进行一个对比,移除旧队列或添加新队列。
    public abstract class RebalanceImpl {
        //...消息负载
        private void rebalanceByTopic(final String topic, final boolean isOrder) {
            switch (messageModel) {
                case BROADCASTING: {  //广播模式
                    Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
                    if (mqSet != null) {
                        boolean changed = this.updateProcessQueueTableInRebalance(topic, mqSet, isOrder);...
                    } ...
                    break;
                }
                case CLUSTERING: {    //集群模式下,进行负载分配
                    // 获取Topic下的MessageQueue列表
                    Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
                    // 实时从Broker中获取组内所有消费者
                    List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);...
                    if (mqSet != null && cidAll != null) {
                        List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
                        mqAll.addAll(mqSet);
                        //排序
                        Collections.sort(mqAll);
                        Collections.sort(cidAll); 
                        AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;                    
                        //确定本节点上的消费者,对应的消费队列 
                        List<MessageQueue> allocateResult = null;
                        try {
                            allocateResult = strategy.allocate(this.consumerGroup,this.mQClientFactory.getClientId(),mqAll,cidAll);
                        }...
                        //...由于消费都数量的增减,会引起此节点消费者对应消费队列的变化,所以这里进行更新操作 
                        boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
                        if (changed) {
                            ...
                            this.messageQueueChanged(topic, mqSet, allocateResultSet);
                        }
                    }
                    break;
                }...
            }
        } 
    }

    RocketMQ提供了6种负载算法,其中最常用的为AllocateMessageQueueAveragely和AllocateMessageQueueAveragelyByCircle,前者为平均分配,后者为平均轮询分配。假设有q1-q4四个队列,c1,c2两个消费者,如果采用前者则c1分配到q1,q2队列,c2分配到q3,q4队列;采用后者则c1分配q1,q3,c2分配q2,q4队列。

    public class DefaultMQPushConsumer extends ClientConfig implements MQPushConsumer {
       public DefaultMQPushConsumer(final String consumerGroup) {
            this(consumerGroup, null, new AllocateMessageQueueAveragely());
        }
    }
    public class AllocateMessageQueueAveragely implements AllocateMessageQueueStrategy {
        //...参数说明:消息者组名、当前消费者ID、topic下所有队列、组内所有消息者ID
        @Override
        public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
            List<String> cidAll) {
            ...
            int index = cidAll.indexOf(currentCID);
            int mod = mqAll.size() % cidAll.size();
            int averageSize =
                mqAll.size() <= cidAll.size() ? 1 : (mod > 0 && index < mod ? mqAll.size() / cidAll.size()
                    + 1 : mqAll.size() / cidAll.size());
            int startIndex = (mod > 0 && index < mod) ? index * averageSize : index * averageSize + mod;
            int range = Math.min(averageSize, mqAll.size() - startIndex);
            for (int i = 0; i < range; i++) {
                result.add(mqAll.get((startIndex + i) % mqAll.size()));
            }
            return result;
        }...
    }

     

  4.  发送拉取请求
    异步向Broker发送拉取请求并回调处理结果。请求参数包括当前消费者所属组、订阅的Topic、本次拉取队列、Tag、offset进度偏移量、最大拉取的消息条数等等。Broker根据Topic及队列编号找到相应的队列,再根据offset找出相应数量的消息条数返回给客户端一个PullResult,返回结构中包含本次拉取的消息列表、下次拉取偏移量等信息。 如果没有消息,Broker默认会挂起客户端请求,此时如果Borker配置了启用了长轮询机制,则Broker每5秒检查一次是否有消息到来,同时有消息到来时也会自动触发唤醒操作,向客户端返回消息。如果未启用长轮询机制,则默认1秒后检查一次,如果没有则返回为空。客户端在回调函数中根据Tag进行过滤出感兴趣的消息。
  5. 回调业务处理器<监听器>,将拉取结果放入消息端线程池的无界队列中,设置下次拉取偏移量,继续发送拉取请求。
     PullCallback pullCallback = new PullCallback() {
                @Override
                public void onSuccess(PullResult pullResult) {
                    if (pullResult != null) {
                        ...
                        long prevRequestOffset = pullRequest.getNextOffset();
                        pullRequest.setNextOffset(pullResult.getNextBeginOffset());
                        ...
                        //拉取状态,是否有消息
                        switch (pullResult.getPullStatus()) {
                            case FOUND:
                                    //提交到消费线程池的队列中,交由线程池进行消费,该线程池内部使用的是无界队列  
                                  DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
                                        pullResult.getMsgFoundList(),
                                        processQueue,
                                        pullRequest.getMessageQueue(),
                                        dispatchToConsume);
                                    //再次发起拉取请求
                                    if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
                                        DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
                                            DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
                                    } else {
                                        DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                                    }
                                }...
                    }
                }

     

生产者Producer(Group)
         上面说过,客户端在启动时,会每隔30秒从NameServer上获取Topic路由信息并本地缓存(如Topic对应的Broker、MessageQueue列表、Broker角色),生产者发送消息时选择Topic下其中一个队列进行消息发送。

public class DefaultMQProducerImpl implements MQProducerInner {
    ...
    private final DefaultMQProducer defaultMQProducer;
    private final ConcurrentMap<String/* topic */, TopicPublishInfo> topicPublishInfoTable = new ConcurrentHashMap<String, TopicPublishInfo>();
    ...
    private MQClientInstance mQClientFactory;
...
}
public class TopicPublishInfo {
    private boolean orderTopic = false;    //是否为顺序消息
    private boolean haveTopicRouterInfo = false;
    private List<MessageQueue> messageQueueList = new ArrayList<MessageQueue>(); //Topic下的消息队列,按Borker来排序,size=Broker数量*Topic下队列数量(默认4)
    private volatile ThreadLocalIndex sendWhichQueue = new ThreadLocalIndex();  //选择队列的索引
    private TopicRouteData topicRouteData; //Topic路由信息
...
}
public class MessageQueue implements Comparable<MessageQueue>, Serializable {
    private String topic;      //所属Topic
    private String brokerName; //所处Broker
    private int queueId;       //所在队列ID
...
}
public class TopicRouteData extends RemotingSerializable {
    private String orderTopicConf;
    private List<QueueData> queueDatas;  //Topic的队列元数据
    private List<BrokerData> brokerDatas; //Topic分布的Broker元数据
    private HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable; //Broker上过滤列表
...
}

生产者Producer(Group)发送流程及负载算法

  1. 基础验证:比如大小不得超过4M。
  2. 选择消息队列MessageQueue:首次随机产生一个2^32次方之内的数字(取绝对值),然后每次以(该数++)%messageQueueList.size=所选队列下标,由于每次递增,所以能够保证消息在发送方的负载。
    关于消息发送失败有两种机制:一种为重试机制,一种为Broker延迟机制
    重试机制<默认>:如果发送异常,默认重试2次,每次重试时将上方的数++,并记录住当前失败的brokerName,以便在后续的循环过程中规避这个有问题的Broker。eg: 假设第一次选取的队列1,位于broker-a上,发送失败,则后续的队列2、3、4由于都位于broker-a上,所以都将被忽略,直到找到非broker-a上的队列。这里队列选择有两种机制,。
    延迟机制:重试机制的缺点在于,同时发A,B两条消息到同一Topic时,AB的流程是完全一样的。也就是说即使broker-a在A发送期间已经出现不可用,B发送时仍然会去进行一次同样的发送。延迟机制的出现弥补了这一缺点,当在A发送期间broker-a出现不可用状态时,会设置broker-a不可用时间,从而在一定时间内规避重复的开销。
    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);
                        if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) { //节点是否可用
                            if (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName))
                                return mq;
                        }
                    }
                  ....
                } catch (Exception e) {
                    log.error("Error occurred when selecting message queue", e);
                }
    
                return tpInfo.selectOneMessageQueue();
            }
    
            return tpInfo.selectOneMessageQueue(lastBrokerName);
        }
    
    public class TopicPublishInfo {
    
        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)) { //过滤掉有问题的broker
                        return mq;
                    }
                }
                return selectOneMessageQueue();
            }
        }
    }
  3. 消息发送:找到队列即找到了brokerName,再与brokerName对应的Master节点建立连接并发送请求。
    消息ID的生成:由IP、进程ID、类加载器的hashCode、毫秒值、一个递增的AtomicInteger等等组成。
    消息压缩:对于超过4KB的消息将进行zip压缩
    批量发送:原理是将单条消息内容使用固定格式进行存储(方便服务端解析),再将所有body聚合成一个byte[]数组,所以其总长度默认也不能超过4M。
    发送方式:有同步、异步、单向三种。
     
  4. 总结:Producer表面上是面向Topic来发送消息,实际底层是通过MessageQueue来确定消息归属地。
  5. 说点题外话,在RocketMQ发送延迟消息真的是太简单了msg.setDelayTimeLevel(级别),想当年用RabbitMQ时,为了实现消息延迟,还要定义主交换机,备用交换机,备用队列,相比之下简直不要太容易。

消费方Consumer (Group) 
     消费消息主要有4 个步骤: 消息队列负载、消息拉取、消息消费、消息消费进度存储。关于消息负载、消息拉取在上方MQClientInstance核心功能分析已进行了相关介绍。这里介绍一下后两者。
    广播模式和集群模式:广播模式下,组内消费者消费Topic下的所有消息,每个消费者消费行为独立,消费进度存储在消费者本地,就可以满足需求,下次消费者启动时加载本地偏移量即可。而集群模式下,组内消息者共同消费消息,同一队列同一时间只会被一个消费者消费,但消费过程随便着队列或消费者数量的变化,会重新负载,所以不能保存在本地,即不能以本地偏移量为准,而需要将消费进度保存在Broker上,以便所有消费者统一视图。
     本地消费进度默认存储在user.home/.rocketmq_offsets下

public class LocalFileOffsetStore implements OffsetStore {
    public final static String LOCAL_OFFSET_STORE_DIR = System.getProperty(
        "rocketmq.client.localOffsetStoreDir",
        System.getProperty("user.home") + File.separator + ".rocketmq_offsets");
...
}


     并发消费与顺序消费:RocketMQ支持对同一个队列中的消息进行顺序消费,但不支持对Topic下的所有消息进行顺序消费,可以通过在Topic下只配置一个队列来实现这种需求。
     并发消费指消费端线程池中的线程可以并发地对同一个队列的消息进行消费,而顺序消费(一般为集群模式),是指对队列只能串行消费,这里需要向Broker发起锁定队列的请求,在Broker端会存储消息消费队列的锁占用情况,避免由于重新负载而破坏顺序消费,如果重新负载后该队列不再由该消费者持有,则释放锁。与并发消费最本质的区别是消费时必须成功锁定消息消费队列。 
      消费失败与重试:如果客户端返回ACK,则Broker针对该消费组会创建一个重试Topic(名称为【%RETRY%+消费组名称】),默认情况下重试Topic下只有一个队列。注意:这个Topic是针对消费组而非整个原有Topic的,也就是说只有该组下的消费者才会订阅该主题,其它组不会订阅。
      比如说cg1,cg2两个组同时订阅了topic1主题,当cg1中的某个消费者针对某条消息返回reconsume_later后,Broker会创建一个名为%RETRY%+cg1的主题,cg1下的消费者会订阅该主题,而cg2不会。
      Broker会将找出失败的消息,并产生一条新的消息,放入到重试Topic的队列中,以便该消费组内的成员再次拉取。如果经过一定次数的尝试<默认16>,仍然失败,Broker会将该消息放入一个名为【%DLQ%+组名】主题的队列中,该Topic只有写权限,所以它下面的消息不会再被消费,除非人工干预。    

public class SendMessageProcessor extends AbstractSendMessageProcessor implements NettyRequestProcessor {
     ...
     private CompletableFuture<RemotingCommand> asyncConsumerSendMsgBack(ChannelHandlerContext ctx,
                                                                        RemotingCommand request) throws RemotingCommandException {
        ...
        // 创建新的重试主题
        String newTopic = MixAll.getRetryTopic(requestHeader.getGroup());
        int queueIdInt = Math.abs(this.random.nextInt() % 99999999) % subscriptionGroupConfig.getRetryQueueNums();
        ...
        TopicConfig topicConfig = this.brokerController.getTopicConfigManager().createTopicInSendMessageBackMethod(
            newTopic,
            subscriptionGroupConfig.getRetryQueueNums(),
            PermName.PERM_WRITE | PermName.PERM_READ, topicSysFlag);  //可读可写权限
        ...
        int delayLevel = requestHeader.getDelayLevel();
        //...超过最大重试次数或延迟等级<0,则进入死信队列
        if (msgExt.getReconsumeTimes() >= maxReconsumeTimes 
            || delayLevel < 0) {
            newTopic = MixAll.getDLQTopic(requestHeader.getGroup());
            queueIdInt = Math.abs(this.random.nextInt() % 99999999) % DLQ_NUMS_PER_GROUP;

            topicConfig = this.brokerController.getTopicConfigManager().createTopicInSendMessageBackMethod(newTopic,                    DLQ_NUMS_PER_GROUP,
                    PermName.PERM_WRITE, 0);   //只有写权限,没有读权限,所以消费者无法获取
            ...
        }...

        MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
        msgInner.setTopic(newTopic);
        msgInner.setBody(msgExt.getBody());
        msgInner.setFlag(msgExt.getFlag());
        ...
            return response;
        });
    }

}

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值