六 nacos配置中心源码解析--配置动态刷新(长轮询机制)

一 客户端动态刷新的触发

长轮询是客户端向服务端发起,那么Nacos的ConfigService(ConfigService是Nacos客户端用于访问服务端基本操作的类)就是首个分析目标

public class NacosFactory {
	// 构建ConfigService
    public static ConfigService createConfigService(Properties properties) throws NacosException {
        return ConfigFactory.createConfigService(properties);
    }
    // 利用反射创建ConfigService实例
     public static ConfigService createConfigService(Properties properties) throws NacosException {
        try {
            Class<?> driverImplClass = Class.forName("com.alibaba.nacos.client.config.NacosConfigService");
            Constructor constructor = driverImplClass.getConstructor(Properties.class);
            ConfigService vendorImpl = (ConfigService) constructor.newInstance(properties);
            return vendorImpl;
        } catch (Throwable e) {
            throw new NacosException(NacosException.CLIENT_INVALID_PARAM, e);
        }
    }
}

在实例化NacosConfigService时,执行的构造函数

public NacosConfigService(Properties properties) throws NacosException {
        ValidatorUtils.checkInitParam(properties);
        String encodeTmp = properties.getProperty(PropertyKeyConst.ENCODE);
        if (StringUtils.isBlank(encodeTmp)) {
            this.encode = Constants.ENCODE;
        } else {
            this.encode = encodeTmp.trim();
        }
        initNamespace(properties);
        // 初始化一个HttpAgent,实际工作的类时ServerHttpAgent.用的装饰者设计模式
        // MetricsHttpAgent内部也调用了ServerHttpAgent的方法,增加了监控统计信息。
        this.agent = new MetricsHttpAgent(new ServerHttpAgent(properties));
        this.agent.start();
        // 初始化一个客户端工作类ClientWorker。入参有agent,可以猜测会用agent做一些远程调用相关的操作
        this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties);
    }

我们分析一下ClientWorker这个类,看一下构造函数。构建两个定时任务调度的线程池,并启动一个定时任务。

 public ClientWorker(final HttpAgent agent, final ConfigFilterChainManager configFilterChainManager,
            final Properties properties) {
        this.agent = agent;
        this.configFilterChainManager = configFilterChainManager;
        
        // Initialize the timeout parameter
        
        init(properties);
        // 创建拥有一个核心线程数的任务调度线程池,用于执行checkConfigInfo
        this.executor = Executors.newScheduledThreadPool(1, new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                Thread t = new Thread(r);
                t.setName("com.alibaba.nacos.client.Worker." + agent.getName());
                t.setDaemon(true);
                return t;
            }
        });
        // 创建一个当前系统可利用的线程数的线程池,后续用于实现客户端的定时长轮询功能
        this.executorService = Executors
                .newScheduledThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        Thread t = new Thread(r);
                        t.setName("com.alibaba.nacos.client.Worker.longPolling." + agent.getName());
                        t.setDaemon(true);
                        return t;
                    }
                });
        // 每隔10ms执行一次checkConfigInfo,检查配置信息
        this.executor.scheduleWithFixedDelay(new Runnable() {
            @Override
            public void run() {
                try {
                    checkConfigInfo();
                } catch (Throwable e) {
                    LOGGER.error("[" + agent.getName() + "] [sub-check] rotate check error", e);
                }
            }
        }, 1L, 10L, TimeUnit.MILLISECONDS);
    }

我们来看看checkConfigInfo方法具体干些什么

  • cacheMap:AtomicReference<Map<String, CacheData>> cacheMap 用来存储监听变更的缓存集合。key是根据dataId/group/tenant(租户)拼接的值。value是对应的存储在Nacos服务端的配置文件内容
  • 长轮询任务拆分: 默认情况下,每个长轮询LongPollingRunnable任务处理3000个监听配置集。如果超过3000个,则需要启动多个LongPollingRunnable去执行
    public void checkConfigInfo() {
        // 切分任务
        int listenerSize = cacheMap.get().size();
        // 向上取整为批数
        int longingTaskCount = (int) Math.ceil(listenerSize / ParamUtil.getPerTaskConfigSize());
        if (longingTaskCount > currentLongingTaskCount) {
            for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) {
            	// 线程池执行LongPollingRunnable长轮询任务
                executorService.execute(new LongPollingRunnable(i));
            }
            currentLongingTaskCount = longingTaskCount;
        }
    }

看看LongPollingRunnable具体干些什么

  • 通过checkLocalConfig方法检查本地配置
  • 执行checkUpdateDataIds方法与服务端建立长轮询机制,从服务端获取发生变更的数据。
  • 遍历变更集合changedGroupKeys,调用getServerConfig方法,根据dataID、group、tenant去服务端读取对应的配置信息并保存在本地文件中。
class LongPollingRunnable implements Runnable {
        @Override
        public void run() {
            List<CacheData> cacheDatas = new ArrayList<CacheData>();
            List<String> inInitializingCacheList = new ArrayList<String>();
            try {
                for (CacheData cacheData : cacheMap.get().values()) {
                    if (cacheData.getTaskId() == taskId) {
                        cacheDatas.add(cacheData);
                        try {
                        	// 检查本地配置
                            checkLocalConfig(cacheData);
                            if (cacheData.isUseLocalConfigInfo()) {
                                cacheData.checkListenerMd5();
                            }
                        } catch (Exception e) {
                            LOGGER.error("get local config info error", e);
                        }
                    }
                }
                // 通过长轮询请求检查服务端对应的配置是否发生了改变。
                List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);
                if (!CollectionUtils.isEmpty(changedGroupKeys)) {
                    LOGGER.info("get changedGroupKeys:" + changedGroupKeys);
                }
                // 遍历存在变更的groupKey ,重新加载最新数据
                for (String groupKey : changedGroupKeys) {
                    String[] key = GroupKey.parseKey(groupKey);
                    String dataId = key[0];
                    String group = key[1];
                    String tenant = null;
                    if (key.length == 3) {
                        tenant = key[2];
                    }
                    try {
                    	// 去服务端读取对应的配置信息并保存在本地文件中。默认的超时时间是30s
                        String[] ct = getServerConfig(dataId, group, tenant, 3000L);
                        CacheData cache = cacheMap.get().get(GroupKey.getKeyTenant(dataId, group, tenant));
                        cache.setContent(ct[0]);
                        if (null != ct[1]) {
                            cache.setType(ct[1]);
                        }
                        LOGGER.info("[{}] [data-received] dataId={}, group={}, tenant={}, md5={}, content={}, type={}",
                                agent.getName(), dataId, group, tenant, cache.getMd5(),
                                ContentUtils.truncateContent(ct[0]), ct[1]);
                    } catch (NacosException ioe) {
                        String message = String
                                .format("[%s] [get-update] get changed config exception. dataId=%s, group=%s, tenant=%s",
                                        agent.getName(), dataId, group, tenant);
                        LOGGER.error(message, ioe);
                    }
                }
                // 触发事件通知
                // 谁来充当事件通知者的角色就是在这里
                for (CacheData cacheData : cacheDatas) {
                    if (!cacheData.isInitializing() || inInitializingCacheList
                            .contains(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant))) {
                        cacheData.checkListenerMd5();
                        cacheData.setInitializing(false);
                    }
                }
                inInitializingCacheList.clear();
                // 继续定时执行当前线程
                // 用的是另一个初始化的线程池
                executorService.execute(this);
            } catch (Throwable e) {
                LOGGER.error("longPolling error : ", e);
                executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS);
            }
        }
    }

一个好的配置中心,会在应用端的本地也保存一份,可在网路无法链接时,可以降级从本地读取配置。Nacos也有这样的实现。在==${user}\nacos\config==目录下会缓存一份服务端的配置信息。

checkLocalConfig会和本地磁盘中的文件内容进行比较,如果内存中的数据和磁盘中的数据不一致,说明数据发生了变更,需要触发事件通知。
checkUpdateDataIds基于长连接方式来监听服务端配置的变化,最后根据变化数据的key去服务端拉取最新数据。checkUpdateDataIds最终调用checkUpdateConfigStr方法。

List<String> checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws Exception {
        
        Map<String, String> params = new HashMap<String, String>(2);
        params.put(Constants.PROBE_MODIFY_REQUEST, probeUpdateString);
        Map<String, String> headers = new HashMap<String, String>(2);
        headers.put("Long-Pulling-Timeout", "" + timeout);
        if (isInitializingCacheList) {
            headers.put("Long-Pulling-Timeout-No-Hangup", "true");
        }
        
        if (StringUtils.isBlank(probeUpdateString)) {
            return Collections.emptyList();
        }
        
        try {
      
            long readTimeoutMs = timeout + (long) Math.round(timeout >> 1);
            HttpRestResult<String> result = agent
                    .httpPost(Constants.CONFIG_CONTROLLER_PATH + "/listener", headers, params, agent.getEncode(),
                            readTimeoutMs);
            
            if (result.ok()) {
                setHealthServer(true);
                return parseUpdateDataIdResponse(result.getData());
            } else {
                setHealthServer(false);
                LOGGER.error("[{}] [check-update] get changed dataId error, code: {}", agent.getName(),
                        result.getCode());
            }
        } catch (Exception e) {
            setHealthServer(false);
            LOGGER.error("[" + agent.getName() + "] [check-update] get changed dataId exception", e);
            throw e;
        }
        return Collections.emptyList();
    }

checkUpdateConfigStr方法实际上就是通过agent .httpPost调用==/v1/cs/configs/listener==接口实现长轮询请求。长轮询请求在实现层面只是设置了一个比较长的时间,默认是30s。如果服务端的数据发生了变更,客户端会收到一个HttpRestResult,服务端返回的是存在数据变更的dataID、group、tenant。获得这些信息之后,在LongPollingRunnable#run方法中调用getServerConfig去Nacos服务端上读取具体的配置内容。

二 服务端长轮询处理机制

在Nacos的config模块中找到controller包下的ConfigController类。Constants.CONFIG_CONTROLLER_PATH就是/v1/cs/configs。

@RestController
@RequestMapping(Constants.CONFIG_CONTROLLER_PATH)
public class ConfigController {
	@PostMapping("/listener")
    @Secured(action = ActionTypes.READ, parser = ConfigResourceParser.class)
    public void listener(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);
        String probeModify = request.getParameter("Listening-Configs");
        if (StringUtils.isBlank(probeModify)) {
            throw new IllegalArgumentException("invalid probeModify");
        }
        
        probeModify = URLDecoder.decode(probeModify, Constants.ENCODE);
        
        Map<String, String> clientMd5Map;
        try {
            clientMd5Map = MD5Util.getClientMd5Map(probeModify);
        } catch (Throwable e) {
            throw new IllegalArgumentException("invalid probeModify");
        }
        // do long-polling
        inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());
    }
  }

核心是inner.doPollingConfig方法。doPollingConfig是一个长轮询的处理接口

 	/**
     * 轮询接口.
     */
    public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,
            Map<String, String> clientMd5Map, int probeRequestSize) throws IOException {
        
        // 长轮询.
        if (LongPollingService.isSupportLongPolling(request)) {
            longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
            return HttpServletResponse.SC_OK + "";
        }
        
        // 不然就兼容短轮询逻辑.
        List<String> changedGroups = MD5Util.compareMd5(request, response, clientMd5Map);
        
        // 兼容短轮询result.
        String oldResult = MD5Util.compareMd5OldResult(changedGroups);
        String newResult = MD5Util.compareMd5ResultString(changedGroups);
        // 版本兼容
        String version = request.getHeader(Constants.CLIENT_VERSION_HEADER);
        if (version == null) {
            version = "2.0.0";
        }
        int versionNum = Protocol.getVersionNumber(version);
        
        // 2.0.4 版本前, 返回值设置在header中.
        if (versionNum < START_LONG_POLLING_VERSION_NUM) {
            response.addHeader(Constants.PROBE_MODIFY_RESPONSE, oldResult);
            response.addHeader(Constants.PROBE_MODIFY_RESPONSE_NEW, newResult);
        } else {
            request.setAttribute("content", newResult);
        }
        
        Loggers.AUTH.info("new content:" + newResult);
        
        // 清除缓存.
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Expires", 0);
        response.setHeader("Cache-Control", "no-cache,no-store");
        response.setStatus(HttpServletResponse.SC_OK);
        return HttpServletResponse.SC_OK + "";
    }

如果当前请求判断为长轮询,会调用longPollingService.addLongPollingClient方法。源码跟进去具体看看

  • 获取客户端的超时时间(前文分析过,默认设置的是30s),减去500ms后赋值给timeout
  • 判断 isFixedPolling ,如果为true,定时任务将会在30s后开始执行,否则在29.5s后开始执行
  • 和服务端的数据进行MD5对比,如果发生变化直接返回
  • ConfigExecutor.executeLongPolling 执行ClientLongPolling任务线程
public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
            int probeRequestSize) {
        // 设置客户端的超时时间
        String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);
        String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
        String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
        String tag = req.getHeader("Vipserver-Tag");
        int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
        // 提前500ms返回响应,为避免客户端超时.
        long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
        if (isFixedPolling()) {
            timeout = Math.max(10000, getFixedPollingInterval());
        } else {
        	// 和服务端的数据进行MD5对比,如果发生过变化则直接返回
            long start = System.currentTimeMillis();
            List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);
            if (changedGroups.size() > 0) {
                generateResponse(req, rsp, changedGroups);
                LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "instant",
                        RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                        changedGroups.size());
                return;
            } else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) {
                LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup",
                        RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                        changedGroups.size());
                return;
            }
        }
        String ip = RequestUtil.getRemoteIp(req);
        // 必须要由HTTP线程调用,否则离开后容器会立即发送响应.
        final AsyncContext asyncContext = req.startAsync();
        // AsyncContext.setTimeout()超时时间不准确,所以只能自己控制.
        asyncContext.setTimeout(0L);
        // 把客户端的长轮询请求封装成ClientLongPolling交给ConfigExecutor执行。
        ConfigExecutor.executeLongPolling(
                new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
    }

继续看看ClientLongPolling这个任务线程

  • ConfigExecutor.scheduleLongPolling启动一个定时任务,并且延时时间为29.5s
  • 将ClientLongPolling 实例本身添加到allSub队列中,它主要维护一个长轮询的订阅关系
  • 定时任务执行后,先把ClientLongPolling 实例本身从allSub队列中移除
  • 通过MD5比较客户端请求的groupKeys是否发生了变更,并将变更的结果通过response返回给客户端
class ClientLongPolling implements Runnable {
        
        @Override
        public void run() {
        	// 启动定时任务
            asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(new Runnable() {
                @Override
                public void run() {
                    try {
                        getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());
                        
                        // 删除订阅关系.
                        allSubs.remove(ClientLongPolling.this);
                        
                        if (isFixedPolling()) {
                            LogUtil.CLIENT_LOG
                                    .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "fix",
                                            RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
                                            "polling", clientMd5Map.size(), probeRequestSize);
                            // 比较数据的MD5值是否发生了变更
                            List<String> changedGroups = MD5Util
                                    .compareMd5((HttpServletRequest) asyncContext.getRequest(),
                                            (HttpServletResponse) asyncContext.getResponse(), clientMd5Map);
                            if (changedGroups.size() > 0) {
                                sendResponse(changedGroups);
                            } else {
                                sendResponse(null);
                            }
                        } else {
                            LogUtil.CLIENT_LOG
                                    .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "timeout",
                                            RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
                                            "polling", clientMd5Map.size(), probeRequestSize);
                            sendResponse(null);
                        }
                    } catch (Throwable t) {
                        LogUtil.DEFAULT_LOG.error("long polling error:" + t.getMessage(), t.getCause());
                    }
                    
                }
                
            }, timeoutTime, TimeUnit.MILLISECONDS);
            
            allSubs.add(this);
        }

所谓的长轮询就是服务端收到请求后,不立即返回,而是在延后(30-0.5)s才把请求结果返回给客户端,这就使得客户端和服务daunt之间在30s之内数据没有发生变化情况下一直处于连接状态。

那么问题来了,定时任务是延时执行,不具备实时性,我们在Nacos Dashborad或者API 修改配置之后,如何实时通知的呢?

仔细发现ClientLongPolling 的构造函数里会注册一个订阅者。这个订阅者有个监听行为–监听LocalDataChangeEvent事件,通过线程池执行一个DataChangeTask任务。所以关键在这个DataChangeTask任务干些什么。

public LongPollingService() {
// Register A Subscriber to subscribe LocalDataChangeEvent.
        NotifyCenter.registerSubscriber(new Subscriber() {
            
            @Override
            public void onEvent(Event event) {
                if (isFixedPolling()) {
                    // Ignore.
                } else {
                    if (event instanceof LocalDataChangeEvent) {
                        LocalDataChangeEvent evt = (LocalDataChangeEvent) event;
                        ConfigExecutor.executeLongPolling(new DataChangeTask(evt.groupKey, evt.isBeta, evt.betaIps));
                    }
                }
            }
            
            @Override
            public Class<? extends Event> subscribeType() {
                return LocalDataChangeEvent.class;
            }
        });
}

DataChangeTask 的run里

  • 遍历allSubs队列中客户端长轮询请求
  • 比较每一个客户端长轮询请求携带的groupKey,如果服务端变更的配置和客户端请求关注的配置一致,则直接返回
class DataChangeTask implements Runnable {
        
        @Override
        public void run() {
            try {
                ConfigCacheService.getContentBetaMd5(groupKey);
                // 遍历allSubs队列
                for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {
                    ClientLongPolling clientSub = iter.next();
                    if (clientSub.clientMd5Map.containsKey(groupKey)) {
                        // 如果beta发布且不在beta列表,则直接跳过.
                        if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) {
                            continue;
                        }
                        
                        // 如果tag发布且不在tag列表,则直接跳过.
                        if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {
                            continue;
                        }
                        
                        getRetainIps().put(clientSub.ip, System.currentTimeMillis());
                        iter.remove(); // Delete subscribers' relationships.
                        LogUtil.CLIENT_LOG
                                .info("{}|{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - changeTime), "in-advance",
                                        RequestUtil
                                                .getRemoteIp((HttpServletRequest) clientSub.asyncContext.getRequest()),
                                        "polling", clientSub.clientMd5Map.size(), clientSub.probeRequestSize, groupKey);
                        clientSub.sendResponse(Arrays.asList(groupKey));
                    }
                }
            } catch (Throwable t) {
                LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t));
            }
        }

谁来发布LocalDataChangeEvent事件呢?我们不难猜出,那就是控制台或者API操作时触发的。我们来找这个链路ConfigOpsController–> DumpService–>DumpChangeProcessor–>ConfigCacheService

三 总结

在这里插入图片描述
如果客户端发起pull请求后,发现服务端的配置和客户端的配置是保持一致的,那么服务端会先Hold住这个请求,也就是服务端拿到这个链接之后在指定时间段内一直不返回结果,直到这段时间内配置发生变化,服务端才会把原来的Hold住的请求返回。如笔者所画的上图所示,服务端收到客户端请求后,先检查配置是否发生了变更,如果没有,则设置一个定时任务,延期29.5s执行,并且把当前的客户端长轮询连接加入allSubs队列。这时候有两种方式触发该连接结果的返回

  • 第一种是等待时间到了,不管配置是否发生了改变,都会把结果返回给客户端
  • 第二种是在29.5s内任意一个时刻,通过Nacos Dashboard或者API 方式对配置进行了修改,这会触发一个事件机制,监听到该事件的任务会遍历allSubs队列,找到发生了变更的配置项对应的ClientLongPolling任务,将变更的数据通过任务中的连接进行返回。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值