RocketMQ原始碼分析之路由中心

Java科代表發表於2019-03-25

早期的rocketmq版本的路由功能是使用zookeeper實現的,後來rocketmq為了追求效能,自己實現了一個效能更高效且實現簡單的路由中心NameServer,而且可以通過部署多個路由節點實現高可用,但它們之間並不能互相通訊,這也就會導致在某一個時刻各個路由節點間的資料並不完全相同,但資料某個時刻不一致並不會導致訊息傳送不了,這也是rocketmq追求簡單高效的一個做法。

路由啟動

看了Nameserver的原始碼後大呼驚歎,整個NameServer總共就由這麼幾個類類組成:

rocketmq nameserver

其中NamesrvStartup為啟動類,NamesrvController為核心控制器,RouteInfoManager為路由資訊表。

知道了這幾個類的功能之後,我們就直接定位到NamesrvStartup啟動類的啟動方法:

org.apache.rocketmq.namesrv.NamesrvStartup#main0:

public static NamesrvController main0(String[] args) {
    try {
        // 步驟一
        NamesrvController controller = createNamesrvController(args);
        // 步驟二
        start(controller);
        String tip = "The Name Server boot success. serializeType=" + RemotingCommand.getSerializeTypeConfigInThisServer();
        log.info(tip);
        System.out.printf("%s%n", tip);
        return controller;
    } catch (Throwable e) {
        e.printStackTrace();
        System.exit(-1);
    }
    return null;
}
複製程式碼

整個NameServer服務啟動的流程程式碼都在main0(String[] args)方法了,看起來是不是很簡單,我們繼續往下擼它的具體實現:

步驟一:

org.apache.rocketmq.namesrv.NamesrvStartup#createNamesrvController:

這個方法的程式碼有點多,下面我會拆分成幾段進行分析:

// 建立命令列引數物件,這裡定義了 -h 和 -n引數
Options options = ServerUtil.buildCommandlineOptions(new Options());
// 根據Options和執行時引數args生成命令列物件,buildCommandlineOptions定義了-c引數(Name server config properties file)和-p引數(Print all config item)
commandLine = ServerUtil.parseCmdLine("mqnamesrv", args, buildCommandlineOptions(options), new PosixParser());
if (null == commandLine) {
    System.exit(-1);
    return null;
}
複製程式碼

這裡使用了Apache Commons CLI 命令列解析工具,它可以幫助開發者快速構建啟動命令,並且幫助你組織命令的引數、以及輸出列表等。

這段主要是根據執行時傳遞的引數生成commandLine命令列物件,用於解析執行時類似於 -c 指定檔案路徑,然後填充到namesrvConfig和nettyServerConfig物件中。

final NamesrvConfig namesrvConfig = new NamesrvConfig();
final NettyServerConfig nettyServerConfig = new NettyServerConfig();
nettyServerConfig.setListenPort(9876);
if (commandLine.hasOption('c')) {
    // 讀取命令列-c引數指定的配置檔案
    String file = commandLine.getOptionValue('c');
    if (file != null) {
        // 將檔案轉成輸入流
        InputStream in = new BufferedInputStream(new FileInputStream(file));
        properties = new Properties();
        // 載入到屬性物件
        properties.load(in);
        // 裝載配置
        MixAll.properties2Object(properties, namesrvConfig);
        MixAll.properties2Object(properties, nettyServerConfig);
    }
}
複製程式碼

這段是createNamesrvController(String[] args)方法最為核心的程式碼,從程式碼知道先建立NamesrvConfig和NettyServerConfig物件,接著利用commandLine命令列工具讀取-c指定的配置檔案路徑,然後將其讀取到流中,生成properties物件,最後將namesrvConfig和nettyServerConfig物件進行初始化。

final NamesrvController controller = new NamesrvController(namesrvConfig, nettyServerConfig);

// remember all configs to prevent discard
controller.getConfiguration().registerConfig(properties);
複製程式碼

到這裡就水到渠成地利用namesrvConfig和nettyServerConfig物件建立NamesrvController物件,然後在註冊一遍properties防止丟失。

createNamesrvController(String[] args)這一步也是啟動nameserver最為關鍵的操作,它為我們啟動時提供了namesrvConfig和nettyServerConfig配置物件,以及建立NamesrvController核心控制器。

步驟二:

org.apache.rocketmq.namesrv.NamesrvStartup#start:

public static NamesrvController start(final NamesrvController controller) throws Exception {

    if (null == controller) {
        throw new IllegalArgumentException("NamesrvController is null");
    }

    // 對核心控制器進行初始化操作
    boolean initResult = controller.initialize();
    if (!initResult) {
        controller.shutdown();
        System.exit(-3);
    }

    // 註冊一個鉤子函式,用於JVM程式關閉時,優雅地釋放netty服務、執行緒池等資源
    Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, new Callable<Void>() {
        @Override
        public Void call() throws Exception {
            controller.shutdown();
            return null;
        }
    }));

    // 核心控制器啟動操作
    controller.start();

    return controller;
}
複製程式碼

步驟二也是一個次級的啟動流程控制方法,該方法主要對核心控制器進行初始化操作,同時註冊一個鉤子函式,用於JVM程式關閉時,優雅地釋放netty服務、執行緒池等資源,最後對核心控制器進行啟動操作,接下來我們繼續擼詳細實現:

org.apache.rocketmq.namesrv.NamesrvController#initialize:

public boolean initialize() {
    // 載入KV配置
    this.kvConfigManager.load();
    // 建立Netty網路服務物件
    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);

    // ...

    return true;
}
複製程式碼

該方法主要是對核心控制器進行啟動前的一些初始化操作,包括根據NamesrvConfig的kvConfigPath儲存KV配置屬性的路徑載入KV配置,建立定時任務:每個10s掃描一次Broker,並定時剔除不活躍的Broker;每個10分鐘列印一遍KV配置。

這裡的每個10s掃描一次Broker,並定時剔除不活躍的Broker,這裡是路由刪除的一些邏輯,後面會講到。

org.apache.rocketmq.namesrv.NamesrvController#start:

public void start() throws Exception {
    this.remotingServer.start();

    if (this.fileWatchService != null) {
        this.fileWatchService.start();
    }
}
複製程式碼

到這裡對啟動進行最後一步操作,即建立Netty服務,我們知道rocketmq是通過netty來進行通訊,對應netty的一些細節這裡就不展開講了,後面我也會計劃寫一些關於netty的系列文章,敬請期待。

路由啟動時序圖:

nameServer startup

路由註冊

路由註冊即是Broker向Nameserver註冊的過程,它們是通過Broker的心跳功能實現的,那麼既然Nameserver是用來儲存Broker的註冊資訊,那麼我們就先來看看Nameserver到底儲存了哪些資訊,回到文章最開始的那張結構圖,我們知道RouteInfoManager為路由資訊表:

org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager:

public class RouteInfoManager {
    private static final InternalLogger log = InternalLoggerFactory.getLogger(LoggerName.NAMESRV_LOGGER_NAME);
    private final static long BROKER_CHANNEL_EXPIRED_TIME = 1000 * 60 * 2;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    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訊息佇列路由資訊,包括topic所在的broker名稱,讀佇列數量,寫佇列數量,同步標記等資訊,rocketmq根據topicQueueTable的資訊進行負載均衡訊息傳送。
  • brokerAddrTable:Broker節點資訊,包括brokername,所在叢集名稱,還有主備節點資訊。
  • clusterAddrTable:Broker叢集資訊,儲存了叢集中所有的Brokername。
  • brokerLiveTable:Broker狀態資訊,Nameserver每次收到Broker的心跳包就會更新該資訊。

這裡也先講一下rocketmq是基於訂閱釋出機制,我之前也寫過一篇文章《rocketmq的消費模式》,我們可知一個Topic擁有多個訊息佇列,如果不指定佇列的數量,一個Broker會為每個Topic建立4個讀佇列和4個寫佇列,多個Broker組成叢集,Broker會通過傳送心跳包將自己的資訊註冊到路由中心,路由中心brokerLiveTable儲存Broker的狀態,它會根據Broker的心跳包更新Broker狀態資訊。

步驟一:Broker傳送心跳包

org.apache.rocketmq.broker.BrokerController#start:

public void start() throws Exception {
    
    // 初次啟動,這裡會強制執行傳送心跳包
    this.registerBrokerAll(true, false, true);
    
    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);
}
複製程式碼

Broker在核心控制器啟動時,會強制傳送一次心跳包,接著建立一個定時任務,定時向路由中心傳送心跳包。

org.apache.rocketmq.broker.BrokerController#registerBrokerAll:

public synchronized void registerBrokerAll(final boolean checkOrderConfig, boolean oneway, boolean forceRegister) {
    // 建立一個topic包裝類
    TopicConfigSerializeWrapper topicConfigWrapper = this.getTopicConfigManager().buildTopicConfigSerializeWrapper();

    // 這裡比較有趣,如果該broker沒有讀寫許可權,那麼會新建一個臨時的topicConfigTable,再set進包裝類
    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);
    }

     // 判斷是否該Broker是否需要傳送心跳包
    if (forceRegister || needRegister(this.brokerConfig.getBrokerClusterName(),
                                      this.getBrokerAddr(),
                                      this.brokerConfig.getBrokerName(),
                                      this.brokerConfig.getBrokerId(),
                                      this.brokerConfig.getRegisterBrokerTimeoutMills())) {
        // 執行傳送心跳包
        doRegisterBrokerAll(checkOrderConfig, oneway, topicConfigWrapper);
    }
}
複製程式碼

該方法是Broker執行傳送心跳包的核心控制方法,它主要做了topic的包裝類封裝操作,判斷Broker此時是否需要執行傳送心跳包,但我查了下org.apache.rocketmq.common.BrokerConfig#forceRegister欄位的值永遠等於true,也就是該判斷永遠為true,即每次都需要傳送心跳包。

我們定位到needRegister遠端呼叫到路由中心的方法:

org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#isBrokerTopicConfigChanged:

public boolean isBrokerTopicConfigChanged(final String brokerAddr, final DataVersion dataVersion) {
    DataVersion prev = queryBrokerTopicConfig(brokerAddr);
    return null == prev || !prev.equals(dataVersion);
}

public DataVersion queryBrokerTopicConfig(final String brokerAddr) {
    BrokerLiveInfo prev = this.brokerLiveTable.get(brokerAddr);
    if (prev != null) {
        return prev.getDataVersion();
    }
    return null;
}
複製程式碼

發現,Broker是否需要傳送心跳包由該Broker在路由中心org.apache.rocketmq.namesrv.routeinfo.BrokerLiveInfo#dataVersion決定,如果dataVersion為空或者當前dataVersion不等於brokerLiveTable儲存的brokerLiveTable,Broker就需要傳送心跳包。

步驟二:Nameserver處理心跳包

Nameserver的netty服務監聽收到心跳包之後,會呼叫到路由中心以下方法進行處理:

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 {
            this.lock.writeLock().lockInterruptibly();

            // 獲取叢集下所有的Broker,並將當前Broker加入clusterAddrTable,由於brokerNames是Set結構,並不會重複
            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;

            // 獲取Broker資訊,如果是首次註冊,那麼新建一個BrokerData並加入brokerAddrTable
            BrokerData brokerData = this.brokerAddrTable.get(brokerName);
            if (null == brokerData) {
                registerFirst = true;
                brokerData = new BrokerData(clusterName, brokerName, new HashMap<Long, String>());
                this.brokerAddrTable.put(brokerName, brokerData);
            }
            // 這裡判斷Broker是否是已經註冊過
            String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
            registerFirst = registerFirst || (null == oldAddr);

            // 如果是Broker是Master節點嗎,並且Topic資訊更新或者是首次註冊,那麼建立更新topic佇列資訊
            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()) {
                            this.createAndUpdateQueueData(brokerName, entry.getValue());
                        }
                    }
                }
            }

            // 更新BrokerLiveInfo狀態資訊
            BrokerLiveInfo prevBrokerLiveInfo = 
                this.brokerLiveTable.put(brokerAddr,new BrokerLiveInfo(System.currentTimeMillis(),topicConfigWrapper.getDataVersion(),channel,haServerAddr));
        } finally {
            this.lock.writeLock().unlock();
        }
    } catch (Exception e) {
        log.error("registerBroker Exception", e);
    }

    return result;
}
複製程式碼

該方法是處理Broker心跳包的最核心方法,它主要做了對RouteInfoManager路由資訊的一些更新操作,包括對clusterAddrTable、brokerAddrTable、topicQueueTable、brokerLiveTable等路由資訊。

路由註冊時序圖:

Broker register

路由刪除

前面部分我們分析了Nameserver啟動時會建立一個定時任務,定時剔除不活躍的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過期時間,那麼就剔除該Broker
        if ((last + BROKER_CHANNEL_EXPIRED_TIME) < System.currentTimeMillis()) {
            // 關閉Broker對應的channel
            RemotingUtil.closeChannel(next.getValue().getChannel());
            it.remove();
            log.warn("The broker channel expired, {} {}ms", next.getKey(), BROKER_CHANNEL_EXPIRED_TIME);
            // 從brokerLiveTable、brokerAddrTable、topicQueueTable移除Broker相關資訊
            this.onChannelDestroy(next.getKey(), next.getValue().getChannel());
        }
    }
}
複製程式碼

剔除Broker資訊的邏輯比較簡單,首先從BrokerLiveInfo獲取狀態資訊,判斷Broker的心跳時間是否已超過限定值,若超過之後就執行剔除邏輯。

分析完了rocketmq自帶的路由中心原始碼,其實我們自己實現一個路由中心貌似也不難。有時候我們發現公司有些專案可以獨立拆分出來做成中介軟體的形式,也就是單獨部署,其它業務依賴client包呼叫中介軟體服務,比如簡訊、推送、郵件、配置等模組。如果我們把這些中介軟體做成高可用叢集部署,也可以考慮自己實現一個路由中心。

掃面下方二維碼,關注我的公眾號,開車帶你臨摹各種原始碼,來不及解釋了快上車!

微信公眾號「Java科代表」

相關文章