乐趣区

关于java:Nacos配置中心交互模型是-push-还是-pull-你应该这么回答

本文案例收录在 https://github.com/chengxy-nd…

大家好,我是小富~

对于 Nacos 大家应该都不太生疏,出身阿里名声在外,能做动静服务发现、配置管理,十分好用的一个工具。然而这样的技术用的人越多面试被问的概率也就越大,如果只停留在应用层面,那面试可能要吃大亏。

比方咱们明天要探讨的话题,Nacos在做配置核心的时候,配置数据的交互模式是服务端推过来还是客户端被动拉的?

这里我先抛出答案:客户端被动拉的!

接下来咱们扒一扒 Nacos 的源码,来看看它具体是如何实现的?

配置核心

Nacos 之前简略回顾下配置核心的由来。

简略了解配置核心的作用就是对配置对立治理,批改配置后利用能够动静感知,而无需重启。

因为在传统我的项目中,大多都采纳动态配置的形式,也就是把配置信息都写在利用内的 ymlproperties这类文件中,如果要想批改某个配置,通常要重启利用才能够失效。

但有些场景下,比方咱们想要在利用运行时,通过批改某个配置项,实时的管制某一个性能的开闭,频繁的重启利用必定是不能承受的。

尤其是在微服务架构下,咱们的应用服务拆分的粒度很细,少则几十多则上百个服务,每个服务都会有一些本人特有或通用的配置。如果此时要扭转通用配置,难道要我挨个改几百个服务配置?很显然这不可能。所以为了解决此类问题配置核心应运而生。

推与拉模型

客户端与配置核心的数据交互方式其实无非就两种,要么推push,要么拉pull

推模型

客户端与服务端建设 TCP 长连贯,当服务端配置数据有变动,立即通过建设的长连贯将数据推送给客户端。

劣势:长链接的长处是实时性,一旦数据变动,立刻推送变更数据给客户端,而且对于客户端而言,这种形式更为简略,只建设连贯接收数据,并不需要关怀是否有数据变更这类逻辑的解决。

弊病:长连贯可能会因为网络问题,导致不可用,也就是俗称的 假死 。连贯状态失常,但实际上已无奈通信,所以要有的心跳机制KeepAlive 来保障连贯的可用性,才能够保障配置数据的胜利推送。

拉模型

客户端被动的向服务端发申请拉配置数据,常见的形式就是轮询,比方每 3s 向服务端申请一次配置数据。

轮询的长处是实现比较简单。但弊病也不言而喻,轮询无奈保证数据的实时性,什么时候申请?距离多长时间申请一次?都是不得不思考的问题,而且轮询形式对服务端还会产生不小的压力。

长轮询

开篇咱们就给出了答案,nacos采纳的是客户端被动拉 pull 模型,利用长轮询(Long Polling)的形式来获取配置数据。

额?以前只听过轮询,长轮询又是什么鬼?它和传统意义上的轮询(暂且叫短轮询吧,不便比拟)有什么不同呢?

短轮询

不论服务端配置数据是否有变动,不停的发动申请获取配置,比方领取场景中前段 JS 轮询订单领取状态。

这样的害处不言而喻,因为配置数据并不会频繁变更,若是始终发申请,势必会对服务端造成很大压力。还会造成推送数据的提早,比方:每 10s 申请一次配置,如果在第 11s 时配置更新了,那么推送将会提早 9s,期待下一次申请。

为了解决短轮询的问题,有了长轮询计划。

长轮询

长轮询可不是什么新技术,它不过是由服务端管制响应客户端申请的返回工夫,来缩小客户端有效申请的一种优化伎俩,其实对于客户端来说与短轮询的应用并没有实质上的区别。

客户端发动申请后,服务端不会立刻返回申请后果,而是将申请挂起期待一段时间,如果此段时间内服务端数据变更,立刻响应客户端申请,若是始终无变动则等到指定的超时工夫后响应申请,客户端从新发动长链接。

Nacos 初识

为了后续演示操作不便我在本地搭了个 Nacos 留神: 运行时遇到个小坑,因为 Nacos 默认是以 cluster 集群的形式启动,而本地搭建通常是单机模式 standalone,这里需手动改一下启动脚本startup.X 中的启动模式。

间接执行 /bin/startup.X 就能够了,默认用户明码均是nacos

几个概念

Nacos配置核心的几个外围概念:dataIdgroupnamespace,它们的层级关系如下图:

dataId:是配置中心里最根底的单元,它是一种 key-value 构造,key通常是咱们的配置文件名称,比方:application.ymlmybatis.xml,而 value 是整个文件下的内容。

目前反对 JSONXMLYAML 等多种配置格局。

group:dataId 配置的分组治理,比方同在 dev 环境下开发,但同环境不同分支须要不同的配置数据,这时就能够用分组隔离,默认分组DEFAULT_GROUP

namespace:我的项目开发过程中必定会有 devtestpro 等多个不同环境,namespace则是对不同环境进行隔离,默认所有配置都在 public 里。

架构设计

下图简要形容了 nacos 配置核心的架构流程。

客户端、控制台通过发送 Http 申请将配置数据注册到服务端,服务端长久化数据到 Mysql。

客户端拉取配置数据,并批量设置对 dataId 的监听发动长轮询申请,如服务端配置项变更立刻响应申请,如无数据变更则将申请挂起一段时间,直到达到超时工夫。为缩小对服务端压力以及保障配置核心可用性,拉取到配置数据客户端会保留一份快照在本地文件中,优先读取。

这里我省略了比拟多的细节,如鉴权、负载平衡、高可用方面的设计(其实这部分才是真正值得学的,后边另出文讲吧),次要弄清客户端与服务端的数据交互模式。

下边咱们以 Nacos 2.0.1 版本源码剖析,2.0 当前的版本改变较多,和网上的很多材料略有些不同
地址:https://github.com/alibaba/na…

客户端源码剖析

Nacos配置核心的客户端源码在 nacos-client 我的项目,其中 NacosConfigService 实现类是所有操作的外围入口。

说之前先理解个客户端数据结构 cacheMap,这里大家重点记住它,因为它简直贯通了 Nacos 客户端的所有操作,因为存在多线程场景为保证数据一致性,cacheMap 采纳了 AtomicReference 原子变量实现。

/**
 * groupKey -> cacheData.
 */
private final AtomicReference<Map<String, CacheData>> cacheMap = new AtomicReference<Map<String, CacheData>>(new HashMap<>());

cacheMap是个 Map 构造,key 为 groupKey,是由 dataId, group, tenant(租户) 拼接的字符串;value 为 CacheData 对象,每个 dataId 都会持有一个 CacheData 对象。

获取配置

Nacos获取配置数据的逻辑比较简单,先取本地快照文件中的配置,如果本地文件不存在或者内容为空,则再通过 HTTP 申请从远端拉取对应 dataId 配置数据,并保留到本地快照中,申请默认重试 3 次,超时工夫 3s。

获取配置有 getConfig()getConfigAndSignListener()这两个接口,但 getConfig() 只是发送一般的 HTTP 申请,而 getConfigAndSignListener() 则多了发动长轮询和对 dataId 数据变更注册监听的操作addTenantListenersWithContent()

@Override
public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {return getConfigInner(namespace, dataId, group, timeoutMs);
}

@Override
public String getConfigAndSignListener(String dataId, String group, long timeoutMs, Listener listener)
        throws NacosException {String content = getConfig(dataId, group, timeoutMs);
    worker.addTenantListenersWithContent(dataId, group, content, Arrays.asList(listener));
    return content;
}

注册监听

客户端注册监听,先从 cacheMap 中拿到 dataId 对应的 CacheData 对象。

public void addTenantListenersWithContent(String dataId, String group, String content,
                                          List<? extends Listener> listeners) throws NacosException {group = blank2defaultGroup(group);
    String tenant = agent.getTenant();
    // 1、获取 dataId 对应的 CacheData,如没有则向服务端发动长轮询申请获取配置
    CacheData cache = addCacheDataIfAbsent(dataId, group, tenant);
    synchronized (cache) {
        // 2、注册对 dataId 的数据变更监听
        cache.setContent(content);
        for (Listener listener : listeners) {cache.addListener(listener);
        }
        cache.setSyncWithServer(false);
        agent.notifyListenConfig();}
}

如没有则向服务端发动长轮询申请获取配置,默认的 Timeout 工夫为 30s,并把返回的配置数据回填至 CacheData 对象的 content 字段,同时用 content 生成 MD5 值;再通过 addListener() 注册监听器。

CacheData也是个出场频率十分高的一个类,咱们看到除了 dataId、group、tenant、content 这些相干的根底属性,还有几个比拟重要的属性如:listenersmd5(content 实在配置数据计算出来的 md5 值),以及注册监听、数据比对、服务端数据变更告诉操作都在这里。

其中 listeners 是对 dataId 所注册的所有监听器汇合,其中的 ManagerListenerWrap 对象除了持有 Listener 监听类,还有一个 lastCallMd5 字段,这个属性很要害,它是判断服务端数据是否更变的重要条件。

在增加监听的同时会将 CacheData 对象以后最新的 md5 值赋值给 ManagerListenerWrap 对象的 lastCallMd5 属性。

public void addListener(Listener listener) {
    ManagerListenerWrap wrap =
        (listener instanceof AbstractConfigChangeListener) ? new ManagerListenerWrap(listener, md5, content)
            : new ManagerListenerWrap(listener, md5);
}

看到这对 dataId 监听设置就完事了?咱们发现所有操作都围着 cacheMap 构造中的 CacheData 对象,那么大胆猜想下肯定会有专门的工作来解决这个数据结构。

变更告诉

客户端又是如何感知服务端数据已变更呢?

咱们还是从头看,NacosConfigService类的结构器中初始化了一个 ClientWorker,而在ClientWorker 类的结构器中又启动了一个线程池来轮询cacheMap

而在 executeConfigListen() 办法中有这么一段逻辑,查看 cacheMap 中 dataId 的 CacheData 对象内,MD5 字段与注册的监听 listener 内的 lastCallMd5 值,不雷同示意配置数据变更则触发safeNotifyListener 办法,发送数据变更告诉。

void checkListenerMd5() {for (ManagerListenerWrap wrap : listeners) {if (!md5.equals(wrap.lastCallMd5)) {safeNotifyListener(dataId, group, content, type, md5, encryptedDataKey, wrap);
        }
    }
}

safeNotifyListener()办法独自起线程,向所有对 dataId 注册过监听的客户端推送变更后的数据内容。

客户端接管告诉,间接实现 receiveConfigInfo() 办法接管回调数据,解决本身业务就能够了。

configService.addListener(dataId, group, new Listener() {
    @Override
    public void receiveConfigInfo(String configInfo) {System.out.println("receive:" + configInfo);
    }

    @Override
    public Executor getExecutor() {return null;}
});

为了了解更直观我用测试 demo 演示下,获取服务端配置并设置监听,每当服务端配置数据变动,客户端监听都会收到告诉,一起看下成果。

public static void main(String[] args) throws NacosException, InterruptedException {
    String serverAddr = "localhost";
    String dataId = "test";
    String group = "DEFAULT_GROUP";
    Properties properties = new Properties();
    properties.put("serverAddr", serverAddr);
    ConfigService configService = NacosFactory.createConfigService(properties);
    String content = configService.getConfig(dataId, group, 5000);
    System.out.println(content);
    configService.addListener(dataId, group, new Listener() {
        @Override
        public void receiveConfigInfo(String configInfo) {System.out.println("数据变更 receive:" + configInfo);
        }
        @Override
        public Executor getExecutor() {return null;}
    });

    boolean isPublishOk = configService.publishConfig(dataId, group, "我是新配置内容~");
    System.out.println(isPublishOk);

    Thread.sleep(3000);
    content = configService.getConfig(dataId, group, 5000);
    System.out.println(content);
}

后果和料想的一样,当向服务端 publishConfig 数据变动后,客户端能够立刻感知,愣是用被动拉 pull 模式做出了服务端实时推送的成果。

数据变更 receive: 我是新配置内容~
true
我是新配置内容~

服务端源码剖析

Nacos配置核心的服务端源码次要在 nacos-config 我的项目的 ConfigController 类,服务端的逻辑要比客户端稍简单一些,这里咱们重点看下。

解决长轮询

服务端对外提供的监听接口地址 /v1/cs/configs/listener,这个办法内容不多,顺着doPollingConfig 往下看。

服务端依据申请 header 中的 Long-Pulling-Timeout 属性来辨别申请是长轮询还是短轮询,这里咱们只关注长轮询局部,接着看 LongPollingService(记住这个 service 很要害)类中的addLongPollingClient() 办法是如何解决客户端的长轮询申请的。

失常客户端默认设置的申请超时工夫是30s,但这里咱们发现服务端“偷偷”的给减掉了500ms,当初超时工夫只剩下了29.5s,那为什么要这样做呢?

用官网的解释之所以要提前 500ms 响应申请,为了最大水平上保障客户端不会因为网络延时造成超时,思考到申请可能在负载平衡时会消耗一些工夫,毕竟 Nacos 最后就是依照阿里本身业务体量设计的嘛!

此时对客户端提交上来的 groupkey 的 MD5 与服务端以后的 MD5 比对,如 md5 值不同,则阐明服务端的配置项产生过变更,间接将该 groupkey 放入 changedGroupKeys 汇合并返回给客户端。

MD5Util.compareMd5(req, rsp, clientMd5Map)

如未产生变更,则将客户端申请挂起,这个过程先创立一个名为 ClientLongPolling 的调度工作 Runnable,并提交给scheduler 定时线程池延后 29.5s 执行。

ConfigExecutor.executeLongPolling(new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));

这里每个长轮询工作携带了一个 asyncContext 对象,使得每个申请能够提早响应,等延时达到或者配置有变更之后, 调用 asyncContext.complete() 响应实现。

asyncContext 为 Servlet 3.0 新增的个性,异步解决,使 Servlet 线程不再须要始终阻塞,期待业务处理完毕才输入响应;能够先开释容器调配给申请的线程与相干资源,加重零碎累赘,其响应将被延后,在解决完业务或者运算后再对客户端进行响应。

ClientLongPolling工作被提交进入延迟线程池执行的同时,服务端会通过一个 allSubs 队列保留所有正在被挂起的客户端长轮询申请工作,这个是客户端注册监听的过程。

如延时期间客户端据数始终未变动,延时工夫达到后将本次长轮询工作从 allSubs 队列剔除,并响应申请 response,这是 勾销监听。收到响应后客户端再次发动长轮询,周而复始。

到这咱们晓得服务端是如何挂起客户端长轮询申请的,一旦申请在挂起期间,用户通过治理平台操作了配置项,或者服务端收到了来自其余客户端节点批改配置的申请。

怎么能让对应已挂起的工作立刻勾销,并且及时告诉客户端数据产生了变更呢?

数据变更

治理平台或者客户端更改配置项接地位 ConfigController 中的 publishConfig 办法。

值得注意得是,在 publishConfig 接口中有这么一段逻辑,某个 dataId 配置数据被批改时会触发一个数据变更事件Event

ConfigChangePublisher.notifyConfigChange(new ConfigDataChangeEvent(false, dataId, group, tenant, time.getTime()));

认真看 LongPollingService 会发现在它的构造方法中,正好订阅了数据变更事件,并在事件触发时执行一个数据变更调度工作DataChangeTask

DataChangeTask内的次要逻辑就是遍历 allSubs 队列,上边咱们晓得,这个队列中保护的是所有客户端的长轮询申请工作,从这些工作中找到蕴含以后产生变更的 groupkeyClientLongPolling工作,以此实现数据更变推送给客户端,并从 allSubs 队列中剔除此长轮询工作。

而咱们在看给客户端响应 response 时,调用 asyncContext.complete() 完结了异步申请。

结束语

上边只揭开了 nacos 配置核心的冰山一角,实际上还有十分多重要的技术细节都没提及到,倡议大家没事看看源码,源码不须要通篇的看,只有抓住外围局部就够了。就比方明天这个题目以前我真没太在意,忽然被问一下子吃不准了,果决看下源码,而且这样记忆比拟粗浅(他人嚼碎了喂你的常识总是比本人咀嚼的差那么点意思)。

nacos的源码我集体感觉还是比拟 奢侈 的,代码并没有过多炫技,看起来绝对轻松。大家不要对看源码有什么冲突,它也不过是他人写的业务代码而已,just so so!


我是小富~,如果对你有用 在看 关注 反对下,咱们下期见~

整顿了几百本各类技术电子书,有须要的同学自取。技术群快满了,想进的同学能够加我好友,和大佬们一起吹吹技术。

电子书地址

集体公众号:程序员内点事,欢送交换

退出移动版