RocketMQ消息"推送"浅析(上)

6,821 阅读6分钟

      以往的经验告诉我们:推送模式往往需要Broker与Consumer两者建立长链接,Poducer将消息成功发送至Broker后,Broker会源源不断的主动的将消息推送至Consumer。

      而Roket MQ的实现另辟蹊径,ta的推送模式本质上是对拉取模式的包装。将手动拉取的动作交给一个专门的线程负责来实现其自动化。

      这意味着Rocket MQ不存在真正的推送模式。

从PullMessageService说起

      实现消息拉取自动化的关键就是PullMessageService。 通常了解一个类从ta的继承关系入手会获得巨大回报,但是有时候因为继承关系复杂导致我们无从下手,所幸ta的继承体系比较简单这有利于我们尽快摸清脉络。 image.png       从UML图示中我们能够自然的产生以下联想:

  1. 实现Runnable接口,可能run方法就是消息自动拉取的入口
  2. 基于第一点我们推断可能存在某线程默默支持run方法的运行
  3. 基于前两点和命名约定我们有理由相信ServiceThread必然跟线程有联系
public abstract class ServiceThread implements Runnable {

    /* 内部持有一个线程 */
    private Thread thread;
    /* 线程安全的boolean类型 */
    private final AtomicBoolean started = new AtomicBoolean(false);
    
    public void start() {
        /* started保证了线程只会被启动一次 */
        if (!started.compareAndSet(false, true)) {
            return;
        }
        stopped = false;
        /* getServiceName()抽象方法交由子类实现,用以获取线程名称 */
        this.thread = new Thread(this, getServiceName());
        this.thread.setDaemon(isDaemon);
        this.thread.start();
    }
    
}

      ServiceThread源码完美契合我们之前的猜想,由此我们可以将焦点转移到PullMessageService的run方法中来。

public class PullMessageService extends ServiceThread {

    /* PullRequest队列 */
    private final LinkedBlockingQueue<PullRequest> pullRequestQueue 
        = new LinkedBlockingQueue<>();
    
    @Override
    public void run() {
        /* 每次拉取消息都要检测自身状态,改变stopped的状态可以停止拉取消息逻辑 */
        while (!this.isStopped()) {
            /* pullRequestQueue 队列为空会阻塞 */
            PullRequest pullRequest = this.pullRequestQueue.take();
            /* 进行消息拉取 */
            this.pullMessage(pullRequest);
        }
    }
}

      这显然是一个生产者,消费者模型。pullRequestQueue队列中存在PullRequest对象时,run方法会take出对象,执行拉取逻辑,如果生产者队列为空,那么阻塞队列,暂停消费行为。消息拉取的细节直到此刻我们依然不得见,不妨看看pullMessage方法。

public class PullMessageService extends ServiceThread {

    private final MQClientInstance mQClientFactory;
    
    public PullMessageService(MQClientInstance mQClientFactory) {
        this.mQClientFactory = mQClientFactory;
    }

    private void pullMessage(PullRequest pullRequest) {
        MQConsumerInner consumer = this.mQClientFactory
            .selectConsumer(pullRequest.getConsumerGroup());
        
        DefaultMQPushConsumerImpl impl = 
            (DefaultMQPushConsumerImpl) consumer;
            
        impl.pullMessage(pullRequest);
    }

}

      也就是说PullMessageService产生实例对象的过程中要求必须传入mQClientFactory对象。通过MQClientInstance#selectConsumer()可以查询到当前pullRequest对应的Consumer对象,然后执行强转逻辑,这里能够直接强转是因为只有推送模式会用到PullMessageService对象。而后将拉取消息的逻辑委托给DefaultMQPushConsumerImp#pullMessage()。

      这一瞬间涌入的陌生对象是在太多,如果笔者不加以交代,恐怕读者很难接受,简单的分析一下mQClientFactory对象对应的MQClientInstance类。

MQClientInstance

      先看看MQClientInstance对象是怎么诞生的,每一个Consumer启动之时都会填充mQClientFactor成员变量,但是MQClientInstanc设计为单例模式, 一个JVM中的所有消费者、生产者持有同一个MQClientInstance,Consumer在最后会将自己注册进mQClientFactory。

public class DefaultMQPushConsumerImpl implements MQConsumerInner {

    private MQClientInstance mQClientFactory;

    public synchronized void start() throws MQClientException {
        switch (this.serviceState) {
            case CREATE_JUST: {
                /* 初始化 MQClientInstance */
                mQClientFactory = MQClientManager.getInstance()
                    .getOrCreateMQClientInstance(this.defaultMQPushConsumer, this.rpcHook);
                mQClientFactory.registerConsumer(
                    this.defaultMQPushConsumer.getConsumerGroup(), this
                );
                /* 虽然每个Consumer都调用此方法,但只启动一次 */
                mQClientFactory.start()
            }
            default:
                break;
        }
    }

}

      简而言之就是MQClientInstance实例的成员变量consumerTable维护者所有的消费者信息,key为ConsumerGroup Name,value就是Consumer本身。

public class MQClientInstance { 
    
    private final ConcurrentMap<String, MQConsumerInner> consumerTable 
        = new ConcurrentHashMap<>();
        
    public boolean registerConsumer(String group, MQConsumerInner consumer) {
        if (null == group || null == consumer) {
            return false;
        }

        MQConsumerInner prev = this.consumerTable
            .putIfAbsent(group, consumer);
        return true;
    }

}

      所以上文中可以通过MQClientInstance对象根据ConsumerGroup Name查询到与之对应的消费者对象。Consumer、MQClientInstance二者相互引用,形成循环依赖关系。 image.png

PullMessageService初始化、启动时机

      直到此刻我们PullMessageService依然只是一个普通对象,因为没有start()的线程就像没有物质的爱情,只是一盘散沙,不用风吹,就散了。

  • 我们一直都知道PullMessageService可以查询到消费者,但刚刚我们分析Consumer的启动源码时并未提及。
  • 同时如果你总结一下发现其实同一个JVM中的多个消费者貌似使用的也是同一个PullMessageService对象,不然还有什么必要搜索一下呢。       看看对应关系,感觉PullMessageService应该与MQClientInstance有联系。
public class MQClientInstance {
   
    private final PullMessageService pullMessageService;
    
    public MQClientInstance(ClientConfig clientConfig, 
        int instanceIndex, String clientId, RPCHook rpcHook) {
        this.rebalanceService = new RebalanceService(this);
    }
    
   
    public void start() throws MQClientException {
        synchronized (this) {
            switch (this.serviceState) {
                case CREATE_JUST:
                    this.rebalanceService.start();
                    break;
                default:
                    break;
            }
        }
    }

}

      果不其然MQClientInstance对象执行构造方法,会初始化pullMessageService对象,start方法执行中同时调用了pullMessageService.start()将拉取消息的线程启动。

关于生产

      上面笔者介绍了谁负责拉取消息,怎么的机制来拉取,拉取线程又是怎么启动的,但是因为PullMessageService内部是一个典型的生产-消费模型,我们仅仅算是介绍了消费过程。

  • 生产队列如何获得消息拉取请求?
  • 拉取请求的对象又是何时诞生?
  • LinkedBlockingQueue是一个无界队列会不会有内存溢出的风险呢?       这一个个问题萦绕在我心头,挥之不去。

      观察到生产队列是一个privat修饰的成员变量,外界是没有权限访问的,那么基本思路就是查阅一下PullMessageService中操作该属性的方法我们应该就能顺藤摸瓜得到答案。

public class PullMessageService extends ServiceThread {

    public void executePullRequestLater(PullRequest pullRequest, 
        long timeDelay) {
        if (!isStopped()) {
            this.scheduledExecutorService.schedule(
                () -> executePullRequestImmediately(pullRequest),
                timeDelay,
                TimeUnit.MILLISECONDS
            );
        }
    }

    public void executePullRequestImmediately(PullRequest pullRequest) {
        this.pullRequestQueue.put(pullRequest);  
    }
    
}

      调用这两个方法会为队列生产拉取请求的对象,其中一个会立即往队列中填充,另外一个会交给scheduledExecutorService调度,经过timeDelay的短暂延迟后执行put逻辑。

      一路跟踪下去发现所有的调用入口都是DefaultMQPushConsumerImpl的executePullRequestLater和executePullRequestImmediately方法,方法十分简单就是通过自身持有的mQClientFactory对象获取到消息拉取对象然后调用该对象的入队方法。

private void executePullRequestLater(PullRequest pullRequest, 
    long timeDelay) {
    this.mQClientFactory.getPullMessageService()
        .executePullRequestLater(pullRequest, timeDelay);
}

public void executePullRequestImmediately(PullRequest pullRequest) {
    this.mQClientFactory.getPullMessageService()
        .executePullRequestImmediately(pullRequest);
}

      调用关系如下:

image.png image.png       可以看到几乎所有的调用都存在于pullMessage方法,而在此方法中有没有看到PullRequest构造实例的过程,排除过后就仅仅只剩下负载均衡实现类RebalancePushImpl。

      一路追踪发现原来是在RebalancePushImpl父类RebalanceImpl中构造了一个拉取请求对象。

      具体原理就是RebalanceImpl负责ConsumeQueue的负载均衡,故而RebalanceImpl总是第一时间知道每个Consumer分配到到了哪些队列,因此ta就可能根据自己分配得到的队列构造PullRequest对象。此部分的逻辑在updateProcessQueueTableInRebalance中有具体体现。

public void dispatchPullRequest(List<PullRequest> pullRequestList) {
    for (PullRequest pullRequest : pullRequestList) {
        this.defaultMQPushConsumerImpl
            .executePullRequestImmediately(pullRequest);
    }
}

      构造完毕的PullRequest对象通过dispatchPullRequest进行入队,这里其实就是一切消息拉取的源头。

      介绍到此处,拉取机制最重要的核心原理、重要机制算是都讲解完毕了。接下来我们就看看,消息拉取动作触发,具体处理委托给DefaultMQPushConsumerImpl#pullMessage()之后还有哪些实现细节。我准备从三个对象作为切入点。

三个重要对象的说明

      我们先来看看与本文关系最密切的拉取请求对象的构成

public class PullRequest {

    /* 消费组 */
    private String consumerGroup;

    /* 待拉取消费队列 */
    private MessageQueue messageQueue;

    /* 消息处理队列,从Broker拉取到的消息会存放至此队列,而后提交到消费者消费线程池 */
    private ProcessQueue processQueue;

    /* 下一次拉取的启始偏移量 */
    private long nextOffset;
    
}

      属性构成比较简单,主要是为了引申出另外两个关键对象MessageQueue、ProcessQueue。

public class MessageQueue implements Comparable<MessageQueue>, 
    Serializable {

    private String topic;
    private String brokerName;
    private int queueId;
    
}
public class ProcessQueue {

    /* 消息临时存储容器 TreeMap<消息在ConsumerQueue中的偏移量,消息实体> */
    private final TreeMap<Long, MessageExt> msgTreeMap = new TreeMap<>();
    /* ProcessQueue中消息数量 */
    private final AtomicLong msgCount = new AtomicLong();
    /* ProcessQueue消息占用内存总和 */
    private final AtomicLong msgSize = new AtomicLong();

}

⚠️:ProcessQueue仅仅摘录了本文相关的属性,实际上ta比这个远远复杂的多。

      为了方便说明上述两个对象的意义与用途,笔者准备了一幅示意图,相信通过图示能够比较容易说明。 image.png       MessageQueue功能比较单一,ta就像一个身份证,我通过该对象就可以确认唯一一个队列,如下MessageQueue对象就指定的是图中的queue0队列。

{
    "topic": "topic",
    "brokerName": "broker0",
    "queueId": 0
}

      ProcessQueue官方定义为:Queue consumption snapshot,即queue在Client端的一个快照。其实我觉得官方定义的并不太准确,在我看来ta是queue的一个片段,ta保存着queue中的部分消息,而且还保管其他消费相关的信息比如是否能触发Client的消费流控,是否被丢弃等等关键信息。

DefaultMQPushConsumerImpl#pullMessage

      结合前文我们已经知道拉取消息的请求对象是负载均衡的相关实现中诞生的,也是由ta将各个队列对应的拉取请求入队,而后被take()阻塞的线程被唤醒,执行pullMessage的操作,Consumer的pullMessage方法中又会分情况将各个PullRequest重新放回队列,如此循环往复,不停拉取。

      由于PullRequest与分配到的queue是严格一一对应,所以根本不用关心过多产生这个对象,即使是无界队列也完全没问题。

      pullMessage方法实在太长,摘录一下关键代码进行分析:

  1. check状态,根据情况决定是舍弃还是延迟该请求
public void pullMessage(PullRequest pullRequest) {
    /* 通过 pullRequest 获取到 processQueue */
    ProcessQueue processQueue = pullRequest.getProcessQueue();
    /**
     * 由于Consumer节点,或者Broker Queue数量变换,导致负载均衡结果变动
     * 有可能之前分布在此 Consumer 节点上的 Queue 会被分配到别处,
     * 此时该 Queue 对应的快照 processQueue,本节点无权消费,置于被丢弃状态
     */
    if (processQueue.isDropped()) {
        /* 
         * 这里是直接丢弃pullRequest
         * 除非下一次负载均衡将该队列又分配回来,否则请求再也没有循环流转的机会
         */
        return;
    }

    /* 检查当前Consumer运行状态是否ServiceState.RUNNING,不是则直接抛出异常 */
    try {
        this.makeSureStateOK();
    } catch(MQClientException e) {
        /* 放弃本次,默认延迟3s再次拉取,pullRequest又入队了 */
        executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
        return;
    }
    
    /* 若当前消费者被挂起,则将拉取任务延迟1s放至 PullMessageService 的拉取队列中,结束本次拉取 */
    if (this.isPause()) {
        /* 同样pullRequest又入队了 */
        executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_SUSPEND);
        return;
    }
}
  1. 判断是否需要触发流量控制,触发后请求对象默认延迟50ms后再次入队

  2. 众多条件包装成请求对象RemotingCommand

  3. 执行RPC拉取消息

  4. 执行回调,修改下一次拉取消息起始偏移量,根据状态决定是立即还是延迟将PullRequest对象入队等待执行下一次的拉取       总结一下: image.png

如果本文对您有用,冒昧求一个赞👍