NameServer 路由注册、故障剔除

NameServer 的主要作用是为消息生产者和消息消费者提供关于 topic 的路由信息,那么 NameServer 就需要存储路由的基础信息,并且能够管理 Broker 节点,包括路由注册、路由删除等功能。

路由元信息

NameServer 的路由实现类是 org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager。在了解路由注册之前,我们先看一下 NameServer 到底存储了哪些信息,如代码清单2-6 所示。

代码清单2-6 RouteInfoManager路由元数据
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;
java
  • topicQueueTable:topic 消息队列的路由信息,消息发送时根据路由表进行负载均衡。

  • brokerAddrTable:Broker 基础信息,包含 brokerName、所属集群名称、主备 Broker 地址。

  • clusterAddrTable:Broker 集群信息,存储集群中所有 Broker 的名称。

  • brokerLiveTable:Broker 状态信息,NameServer 每次收到心跳包时会替换该信息。

  • filterServerTable:Broker 上的 FilterServer 列表,用于类模式消息过滤。类模式过滤机制在 4.4 及以后版本被废弃。

QueueData、BrokerData、BrokerLiveInfo 类图如图2-3 所示。

image 2025 01 17 14 18 29 609
Figure 1. 图2-3 路由元数据类图

RocketMQ 基于订阅发布机制,一个 topic 拥有多个消息队列,一个 Broker 默认为每一主题创建 4 个读队列和 4 个写队列。多个 Broker 组成一个集群,BrokerName 由相同的多台 Broker 组成主从架构,brokerId=0 代表主节点,brokerId>0 表示从节点。BrokerLiveInfo 中的 lastUpdateTimestamp 存储上次收到 Broker 心跳包的时间。

RocketMQ 2 主 2 从数据结构如图2-4 所示。

image 2025 01 17 14 20 00 150
Figure 2. 图2-4 RocketMQ 2主2从数据结构展示图

对应运行时的内存结构如图2-5、图2-6所示。

image 2025 01 17 14 21 13 782
Figure 3. 图2-5 topicQueueTable、brokerAddrTable运行时内存结构
image 2025 01 17 14 21 35 076
Figure 4. 图2-6 brokerLiveTable、clusterAddrTable运行时内存结构

路由注册

RocketMQ 路由注册是通过 Broker 与 NameServer 的心跳功能实现的。Broker 启动时向集群中所有的 NameServer 发送心跳语句,每隔 30s 向集群中所有的 NameServer 发送心跳包,NameServer 收到 Broker 心跳包时会先更新 brokerLiveTable 缓存中 BrokerLiveInfo 的 lastUpdateTimestamp,然后每隔 10s 扫描一次 brokerLiveTable,如果连续 120s 没有收到心跳包,NameServer 将移除该 Broker 的路由信息,同时关闭 Socket 连接。

  1. Broker发送心跳包

    Broker 发送心跳包的核心代码如代码清单2-7、代码清单2-8 所示。

    代码清单2-7 Broker发送心跳包(BrokerController#start)
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        public void run() {
            try {
                BrokerController.this.registerBrokerAll(true, false);
            } catch (Throwable e) {
                log.error("registerBrokerAll Exception", e);
            }
        }
    }, 1000 * 10, 1000 * 30, TimeUnit.MILLISECONDS);
    java
    代码清单2-8 Broker发送心跳包(BrokerOuterAPI#registerBrokerAll)
    List<String> nameServerAddressList = this.remotingClient.getNameServerAddressList();
    
    if (nameServerAddressList != null) {
        for (String namesrvAddr : nameServerAddressList) {
            // 遍历所有 NameServer 列表
            try {
                RegisterBrokerResult result = this.registerBroker(
                    namesrvAddr,
                    clusterName,
                    brokerAddr,
                    brokerName,
                    brokerId,
                    haServerAddr,
                    topicConfigWrapper,
                    filterServerList,
                    oneway,
                    timeoutMills
                );
                // 向 NameServer 注册
                if (result != null) {
                    registerBrokerResult = result;
                }
                log.info("register broker to name server {} OK", namesrvAddr);
            } catch (Exception e) {
                log.warn("registerBroker Exception, {}", namesrvAddr, e);
            }
        }
    }
    java

    该方法遍历 NameServer 列表,Broker 消息服务器依次向 NameServer 发送心跳包,如代码清单2-9 所示。

    代码清单2-9 BrokerOuterAPI#registerBroker(网络发送代码)
    RegisterBrokerRequestHeader requestHeader = new RegisterBrokerRequestHeader();
    requestHeader.setBrokerAddr(brokerAddr);
    requestHeader.setBrokerId(brokerId);
    requestHeader.setBrokerName(brokerName);
    requestHeader.setClusterName(clusterName);
    requestHeader.setHaServerAddr(haServerAddr);
    
    RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.REGISTER_BROKER, requestHeader);
    
    RegisterBrokerBody requestBody = new RegisterBrokerBody();
    requestBody.setTopicConfigSerializeWrapper(topicConfigWrapper);
    requestBody.setFilterServerList(filterServerList);
    
    request.setBody(requestBody.encode());
    
    if (oneway) {
        try {
            this.remotingClient.invokeOneway(namesrvAddr, request, timeoutMills);
        } catch (RemotingTooMuchRequestException e) {
            // 忽略
        }
        return null;
    }
    
    RemotingCommand response = this.remotingClient.invokeSync(namesrvAddr, request, timeoutMills);
    java

    下面分析发送心跳包的具体逻辑,首先封装请求包头(Header)。

    • brokerAddr:broker 地址。

    • brokerId:brokerId=0 表示主节点,brokerId>0 表示从节点。

    • brokerName:broker 名称。

    • clusterName:集群名称。

    • haServerAddr:主节点地址,初次请求时该值为空,从节点向 NameServer 注册后返回。

    • requestBody:topicConfigWrapper,主题配置,topicConfigWrapper 内部封装的是 TopicConfigManager 中的 topicConfigTable,内部存储的是 Broker 启动时默认的一些 topic,如 MixAll.SELF_TEST_TOPIC、MixAll.DEFAULT_TOPIC(AutoCreateTopic-Enable=true)、MixAll.BENCHMARK_TOPIC、MixAll.OFFSET_MOVED_EVENT、BrokerConfig#brokerClusterName、BrokerConfig#brokerName。Broker 中 topic 默认存储在 ${Rocket_Home}/store/confg/topics.json 中。

    filterServerList,消息过滤服务器列表。

    RocketMQ 网络传输基于 Netty,本书不具体剖析网络实现细节,在这里介绍一下网络跟踪方法。对于每一个请求,RocketMQ 都会定义一个 RequestCode。在服务端会有相应的网络处理器(processor 包中),只须整库搜索 RequestCode,即可找到相应的处理逻辑。对 Netty 感兴趣的读者,可以参考笔者发布的文章( https://blog.csdn.net/prestigeding/article/details/53977445 )。

  2. NameServer处理心跳包

org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor 是网络处理器解析请求类型,如果请求类型为RequestCode.REGISTER_BROKER,则请求最终转发到 RouteInfoManager#registerBroker,如代码清单 2-10 所示。

代码清单2-10 RouteInfoManager#registerBroker clusterAddrTable的维护
this.lock.writeLock().lockInterruptibly();
Set<String> brokerNames = this.clusterAddrTable.get(clusterName);
if (null == brokerNames) {
    brokerNames = new HashSet<String>();
    this.clusterAddrTable.put(clusterName, brokerNames);
}
brokerNames.add(brokerName);
java

第一步:路由注册需要加写锁,防止并发修改 RouteInfoManager 中的路由表。首先判断 Broker 所属集群是否存在,如果不存在,则创建集群,然后将 broker 名加入集群 Broker 集合,如代码清单 2-11 所示。

代码清单2-11 RouteInfoManager#registerBroker 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);
}
String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
registerFirst = registerFirst || (null == oldAddr);
java

第二步:维护 BrokerData 信息,首先从 brokerAddrTable 中根据 broker 名尝试获取 Broker 信息,如果不存在,则新建 BrokerData 并放入 brokerAddrTable,registerFirst 设置为 true;如果存在,直接替换原先的 Broker 信息,registerFirst 设置为 false,表示非第一次注册,如代码清单2-12 所示。

代码清单2-12 RouteInfoManager#registerBroker topicQueueTable的维护
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());
            }
        }
    }
}
java

第三步:如果 Broker 为主节点,并且 Broker 的 topic 配置信息发生变化或者是初次注册,则需要创建或更新 topic 路由元数据,并填充 topicQueueTable,其实就是为默认主题自动注册路由信息,其中包含 MixAll.DEFAULT_TOPIC 的路由信息。当消息生产者发送主题时,如果该主题未创建,并且 BrokerConfig 的 autoCreateTopicEnable 为 true,则返回 MixAll.DEFAULT_TOPIC 的路由信息,如代码清单2-13 所示。

代码清单2-13 RouteInfoManager#createAndUpdateQueueData
private void createAndUpdateQueueData(final String brokerName, final TopicConfig topicConfig) {
    QueueData queueData = new QueueData();
    queueData.setBrokerName(brokerName);
    queueData.setWriteQueueNums(topicConfig.getWriteQueueNums());
    queueData.setReadQueueNums(topicConfig.getReadQueueNums());
    queueData.setPerm(topicConfig.getPerm());
    queueData.setTopicSynFlag(topicConfig.getTopicSysFlag());

    List<QueueData> queueDataList = this.topicQueueTable.get(topicConfig.getTopicName());

    if (null == queueDataList) {
        queueDataList = new LinkedList<QueueData>();
        queueDataList.add(queueData);
        this.topicQueueTable.put(topicConfig.getTopicName(), queueDataList);
        log.info("new topic registered, {} {}", topicConfig.getTopicName(), queueData);
    } else {
        boolean addNewOne = true;
        Iterator<QueueData> it = queueDataList.iterator();

        while (it.hasNext()) {
            QueueData qd = it.next();
            if (qd.getBrokerName().equals(brokerName)) {
                if (qd.equals(queueData)) {
                    addNewOne = false;
                } else {
                    log.info("topic changed, {} OLD: {} NEW: {}", topicConfig.getTopicName(), qd, queueData);
                    it.remove();
                }
            }
        }

        if (addNewOne) {
            queueDataList.add(queueData);
        }
    }
}
java

根据 topicConfig 创建 QueueData 数据结构,然后更新 topicQueueTable,如代码清单2-14 所示。

代码清单2-14 RouteInfoManager#registerBroker
BrokerLiveInfo prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr,
    new BrokerLiveInfo(System.currentTimeMillis(),
        topicConfigWrapper.getDataVersion(),
        channel,
        haServerAddr));

if (null == prevBrokerLiveInfo) {
    log.info("new broker registered, {} HAServer: {}", brokerAddr, haServerAddr);
}
java

第四步:更新 BrokerLiveInfo,存储状态正常的 Broker信息表,BrokeLiveInfo 是执行路由删除操作的重要依据,如代码清单2-15 所示。

代码清单2-15 RouteInfoManager#registerBroker
if (filterServerList != null) {
    if (filterServerList.isEmpty()) {
        this.filterServerTable.remove(brokerAddr);
    } else {
        this.filterServerTable.put(brokerAddr, filterServerList);
    }
}

if (MixAll.MASTER_ID != brokerId) {
    String masterAddr = brokerData.getBrokerAddrs().get(MixAll.MASTER_ID);
    if (masterAddr != null) {
        BrokerLiveInfo brokerLiveInfo = this.brokerLiveTable.get(masterAddr);
        if (brokerLiveInfo != null) {
            result.setHaServerAddr(brokerLiveInfo.getHaServerAddr());
            result.setMasterAddr(masterAddr);
        }
    }
}
java

第五步:注册 Broker 的过滤器 Server 地址列表,一个 Broker 上会关联多个 FilterServer 消息过滤服务器,此部分内容将在第 6 章详细介绍。如果此 Broker 为从节点,则需要查找该 Broker 的主节点信息,并更新对应的 masterAddr 属性。

设计亮点:NameServer 与 Broker 保持长连接,Broker 的状态信息存储在 brokerLive-Table 中,NameServer 每收到一个心跳包,将更新 brokerLiveTable 中关于 Broker 的状态信息以及路由表(topicQueueTable、brokerAddrTable、brokerLiveTable、filterServer-Table)。更新上述路由表(HashTable)使用了锁粒度较少的读写锁,允许多个消息发送者并发读操作,保证消息发送时的高并发。同一时刻 NameServer 只处理一个 Broker 心跳包,多个心跳包请求串行执行。这也是读写锁经典的使用场景,更多关于读写锁的信息,可以参考笔者的博文: http://blog.csdn.net/prestigeding/article/details/53286756

路由删除

根据上文所述,Broker 每隔 30s 向 NameServer 发送一个心跳包,心跳包中包含 BrokerId、Broker地址、Broker名称、Broker 所属集群名称。如果 Broker 宕机,NameServer 无法收到心跳包,此时 NameServer 如何剔除失效的 Broker 呢?

NameServer 会每隔 10s 扫描一次 brokerLiveTable 状态表,如果 BrokerLive 的 lastUpdate-Timestamp 时间戳距当前时间超过 120s,则认为 Broker 失效,移除该 Broker,关闭与 Broker 的连接,同时更新 topicQueueTable、brokerAddrTable、brokerLiveTable、filterServerTable。

RocketMQ 有两个触发点来触发路由删除操作。

  1. NameServer 定时扫描 brokerLiveTable,检测上次心跳包与当前系统时间的时间戳,如果时间戳大于 120s,则需要移除该 Broker 信息。

  2. Broker 在正常关闭的情况下,会执行 unregisterBroker 指令。

因为不管是何种方式触发的路由删除,删除方法是一样的,都是从 topicQueueTable、brokerAddrTable、brokerLiveTable、filterServerTable 中删除与该 Broker 相关的信息,所以 RocketMQ 用这两种方式维护路由信息时会抽取公共代码,本节将以第一种方式为例展开分析,如代码清单2-16 所示。

代码清单2-16 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();

        if ((last + BROKER_CHANNEL_EXPIRED_TIME) < System.currentTimeMillis()) {
            RemotingUtil.closeChannel(next.getValue().getChannel());
            it.remove();
            log.warn("The broker channel expired, {} {}ms", next.getKey(), BROKER_CHANNEL_EXPIRED_TIME);
            this.onChannelDestroy(next.getKey(), next.getValue().getChannel());
        }
    }
}
java

我们知道 scanNotActiveBroker 在 NameServer 中每 10s 执行一次。逻辑也很简单,先遍历 brokerLiveInfo 路由表(HashMap),检测 BrokerLiveInfo 的 LastUpdateTimestamp 上次收到心跳包的时间,如果超过 120s,则认为该 Broker 已不可用,然后将它移除并关闭连接,最后删除与该 Broker 相关的路由信息,如代码清单2-17 所示,路由表维护过程需要申请写锁。

代码清单2-17 RouteInfoManager#onChannelDestroy
this.lock.writeLock().lockInterruptibly();
this.brokerLiveTable.remove(brokerAddrFound);
this.filterServerTable.remove(brokerAddrFound);
java

第一步:申请写锁。根据 brokerAddress 从 brokerLiveTable、filterServerTable 中移除 Broker 相关的信息,如代码清单2-18 所示。

代码清单2-18 RouteInfoManager#onChannelDestroy
String brokerNameFound = null;
boolean removeBrokerName = false;
Iterator<Entry<String, BrokerData>> itBrokerAddrTable = this.brokerAddrTable.entrySet().iterator();
while (itBrokerAddrTable.hasNext() && (null == brokerNameFound)) {
    BrokerData brokerData = itBrokerAddrTable.next().getValue();
    Iterator<Entry<Long, String>> it = brokerData.getBrokerAddrs().entrySet().iterator();
    while (it.hasNext()) {
        Entry<Long, String> entry = it.next();
        Long brokerId = entry.getKey();
        String brokerAddr = entry.getValue();
        if (brokerAddr.equals(brokerAddrFound)) {
            brokerNameFound = brokerData.getBrokerName();
            it.remove();
            log.info("remove brokerAddr[{}, {}] from brokerAddrTable, because channel destroyed", brokerId, brokerAddr);
            break;
        }
    }
    if (brokerData.getBrokerAddrs().isEmpty()) {
        removeBrokerName = true;
        itBrokerAddrTable.remove();
        log.info("remove brokerName[{}] from brokerAddrTable, because channel destroyed", brokerData.getBrokerName());
    }
}
java

第二步:维护 brokerAddrTable。遍历 HashMap<String/* brokerName /, BrokerData>brokerAddrTable,从BrokerData的HashMap<Long/ brokerId /, String/ broker address*/> brokerAddrs 中,找到具体的 Broker,从 BrokerData 中将其移除。如果移除后在 BrokerData 中不再包含其他 Broker,则在 brokerAddrTable 中移除该 brokerName 对应的条目,如代码清单2-19 所示。

代码清单2-19 RouteInfoManager#onChannelDestroy
if (brokerNameFound != null && removeBrokerName) {
    Iterator<Entry<String, Set<String>>> it = this.clusterAddrTable.entrySet().iterator();
    while (it.hasNext()) {
        Entry<String, Set<String>> entry = it.next();
        String clusterName = entry.getKey();
        Set<String> brokerNames = entry.getValue();
        boolean removed = brokerNames.remove(brokerNameFound);
        if (removed) {
            log.info("remove brokerName[{}], clusterName[{}] from clusterAddrTable, because channel destroyed", brokerNameFound, clusterName);
            if (brokerNames.isEmpty()) {
                log.info("remove the clusterName[{}] from clusterAddrTable, because channel destroyed and no broker in this cluster", clusterName);
                it.remove();
            }
            break;
        }
    }
}
java

第三步:根据 BrokerName,从 clusterAddrTable 中找到 Broker 并将其从集群中移除。如果移除后,集群中不包含任何 Broker,则将该集群从 clusterAddrTable 中移除,如代码清单2-20 所示。

代码清单2-20 RouteInfoManager#onChannelDestroy
if (removeBrokerName) {
    Iterator<Entry<String, List<QueueData>>> itTopicQueueTable = this.topicQueueTable.entrySet().iterator();
    while (itTopicQueueTable.hasNext()) {
        Entry<String, List<QueueData>> entry = itTopicQueueTable.next();
        String topic = entry.getKey();
        List<QueueData> queueDataList = entry.getValue();
        Iterator<QueueData> itQueueData = queueDataList.iterator();
        while (itQueueData.hasNext()) {
            QueueData queueData = itQueueData.next();
            if (queueData.getBrokerName().equals(brokerNameFound)) {
                itQueueData.remove();
                log.info("remove topic[{} {}], from topicQueueTable, because channel destroyed", topic, queueData);
            }
        }
        if (queueDataList.isEmpty()) {
            itTopicQueueTable.remove();
            log.info("remove topic[{}] all queue, from topicQueueTable, because channel destroyed", topic);
        }
    }
}
java

第四步:根据 BrokerName,遍历所有主题的队列,如果队列中包含当前 Broker 的队列,则移除,如果 topic 只包含待移除 Broker 的队列,从路由表中删除该 topic,如代码清单2-21 所示。

代码清单2-21 RouteInfoManager#onChannelDestroy
finally {
    this.lock.writeLock().unlock();
}
java

第五步:释放锁,完成路由删除。

路由发现

RocketMQ 路由发现是非实时的,当 topic 路由出现变化后,NameServer 不主动推送给客户端,而是由客户端定时拉取主题最新的路由。根据主题名称拉取路由信息的命令编码为 GET_ROUTEINTO_BY_TOPIC。RocketMQ 路由结果如图2-7 所示。

image 2025 01 17 14 56 10 569
Figure 5. 图2-7 RocketMQ路由结果实体
  • orderTopicConf:顺序消息配置内容,来自kvConfig。

  • List queueDatas:topic队列元数据。

  • List brokerDatas:topic分布的broker元数据。

  • HashMap filterServerTable:Broker上过滤服务器的地址列表。

NameServer 路由发现实现类为 DefaultRequestProcessor#getRouteInfoByTopic,如代码清单2-22 所示。

代码清单2-22 DefaultRequestProcessor#getRouteInfoByTopic
public RemotingCommand getRouteInfoByTopic(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException {
    final RemotingCommand response = RemotingCommand.createResponseCommand(null);
    final GetRouteInfoRequestHeader requestHeader = (GetRouteInfoRequestHeader) request.decodeCommandCustomHeader(GetRouteInfoRequestHeader.class);

    TopicRouteData topicRouteData = this.namesrvController.getRouteInfoManager().pickupTopicRouteData(requestHeader.getTopic());

    if (topicRouteData != null) {
        if (this.namesrvController.getNamesrvConfig().isOrderMessageEnable()) {
            String orderTopicConf = this.namesrvController.getKvConfigManager()
                .getKVConfig(NamesrvUtil.NAMESPACE_ORDER_TOPIC_CONFIG, requestHeader.getTopic());
            topicRouteData.setOrderTopicConf(orderTopicConf);
        }

        byte[] content = topicRouteData.encode();
        response.setBody(content);
        response.setCode(ResponseCode.SUCCESS);
        response.setRemark(null);
        return response;
    }

    response.setCode(ResponseCode.TOPIC_NOT_EXIST);
    response.setRemark("No topic route info in name server for the topic: " + requestHeader.getTopic() + FAQUrl.suggestTodo(FAQUrl.APPLY_TOPIC_URL));
    return response;
}
java

第一步:调用 RouterInfoManager 的方法,从路由表 topicQueueTable、brokerAddrTable、filterServerTable 中分别填充 TopicRouteData 中的 List<QueueData>、List<BrokerData> 和 filterServer 地址表。

第二步:如果找到主题对应的路由信息并且该主题为顺序消息,则从 NameServer KVConfig 中获取关于顺序消息相关的配置填充路由信息。如果找不到路由信息 Code,则使用 TOPIC_NOT_EXISTS,表示没有找到对应的路由。