Nacos配置中心数据同步原理

Nacos配置中心数据同步原理

一、Nacos概览

Nacos 能帮助我们发现、配置和管理微服务,提供了一组简单易用的特性集,帮助我们快速实现动态服务发现、服务配置、服务元数据及流量管理。

关键特性

  • 服务发现和服务健康监测
  • 动态配置服务
  • 动态 DNS 服务
  • 服务及其元数据管理

持久化

默认情况下,Nacos使用嵌入式数据库实现数据的存储。所以,如果启动多个默认配置下的Nacos节点,数据存储是存在一致性问题的。为了解决这个问题,Nacos采用了集中式存储的方式来支持集群化部署,目前只支持MySQL的存储。

同步原理

大概的原理就是Nacos 服务端保存了配置信息,客户端连接到服务端之后,根据 dataID,group可以获取到具体的配置信息,当服务端的配置发生变更时,客户端会收到通知,拿到变更后的最新配置信息。

那么问题来了,这里是服务端推给客户端,还是客户端主动去服务端拉?

二、源码解析

ConfigService

既然是配置中心,当然是要从配置中心最重要的一个接口ConfigService入手,下面看一下它有哪些方法。

public interface ConfigService {
	//拉取配置信息
    String getConfig(String dataId, String group, long timeoutMs) throws NacosException;
    //获取配置信息并注册监听器
    String getConfigAndSignListener(String dataId, String group, long timeoutMs, Listener listener) throws NacosException;
    //新增一个监听器,当服务端发生数据修改时,客户端会触发监听器回调,推荐使用异步线程
    void addListener(String dataId, String group, Listener listener) throws NacosException;
    //推送配置信息
    boolean publishConfig(String dataId, String group, String content) throws NacosException;
    //移除配置信息
    boolean removeConfig(String dataId, String group) throws NacosException;
    //移除监听器
    void removeListener(String dataId, String group, Listener listener);
    //获取服务端状态
    String getServerStatus();
    

ConfigFactory

接着看一下是如何创建这个ConfigService实例的。找到ConfigFactory工厂类,可以看出这里是通过反射调用了实现类NacosConfigService的构造方法来创建ConfigService实例。

public class ConfigFactory {
    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

接着看一下这个实现类NacosConfigService,这里我们只关注与数据同步有关的部分,先看构造方法,具体实现方法等实例化完再看。

public class NacosConfigService implements ConfigService {
	//超时时间,看下面的单位应该是30秒
	private static final long POST_TIMEOUT = 3000L;
	/**
     * http agent http代理
     */
    private HttpAgent agent;
    /**
     * longpolling 长轮询
     */
    private ClientWorker worker;
    //过滤器调用链
    private ConfigFilterChainManager configFilterChainManager = new ConfigFilterChainManager();
	//构造函数,入参是配置信息
    public NacosConfigService(Properties properties) throws NacosException {
        String encodeTmp = properties.getProperty(PropertyKeyConst.ENCODE);
        if (StringUtils.isBlank(encodeTmp)) {
            encode = Constants.ENCODE;
        } else {
            encode = encodeTmp.trim();
        }
        initNamespace(properties);
        //实例化MetricsHttpAgent对象
        //装饰者模式,两个类都实现了HttpAgent接口,MetricsHttpAgent实际也是调用了ServerHttpAgent内部的方法
        agent = new MetricsHttpAgent(new ServerHttpAgent(properties));
        //这个start方法等会再看
        agent.start();
        //实例化ClientWorker对象
        worker = new ClientWorker(agent, configFilterChainManager, properties);
    }

下面重点看一下MetricsHttpAgentServerHttpAgentClientWorker这几个类的实现。

MetricsHttpAgent

这个类其实只是在ServerHttpAgent的基础上做了一层修饰,看起来应该是加了监控之类的东西,重点的实现还是在ServerHttpAgent,所以这个类不多讲,直接看ServerHttpAgent

agent = new MetricsHttpAgent(new ServerHttpAgent(properties));

ServerHttpAgent

这个类重点看starthttpGethttpPosthttpDelete这几个方法。

start

调用ServerListManager里的start方法

    public synchronized void start() throws NacosException {
        serverListMgr.start();
    }

启动了一个间隔30秒的定时任务,接着看一下线程池里面做了什么。

    public synchronized void start() throws NacosException {
        if (isStarted || isFixed) {
            return;
        }
        GetServerListTask getServersTask = new GetServerListTask(addressServerUrl);
		//省略
        TimerService.scheduleWithFixedDelay(getServersTask, 0L, 30L, TimeUnit.SECONDS);
        isStarted = true;
    }

其实很简单,就是去拿Nacos服务的ip地址列表,如果跟之前的不一样就更新,一样就直接返回。

    class GetServerListTask implements Runnable {
        @Override
        public void run() {
            /**
             * get serverlist from nameserver
             */
            try {
                updateIfChanged(getApacheServerList(url, name));
            } catch (Exception e) {
                LOGGER.error("[" + name + "][update-serverlist] failed to update serverlist from address server!",
                    e);
            }
        }
    }

httpGet

可以理解为其实就是一个get请求。

httpPost

可以理解为其实就是一个post请求。

httpDelete

可以理解为其实就是一个delete请求。

ClientWorker

创建线程池

这个类是重点,先看一下构造方法,创建了两个线程池,有一个是间隔10毫秒执行一次。

    public ClientWorker(final HttpAgent agent, final ConfigFilterChainManager configFilterChainManager, final Properties properties) {
        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;
            }
        });
        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;
            }
        });
		//启动线程池,间隔10毫秒执行一次,相当于是一直执行了
        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,取出一批的方法,交给executorService线程池去执行。

    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++) {
                // 要判断任务是否在执行 这块需要好好想想。 任务列表现在是无序的。变化过程可能有问题
                executorService.execute(new LongPollingRunnable(i));
            }
            currentLongingTaskCount = longingTaskCount;
        }
    }

这里最重点的就是看这个LongPollingRunnable内部类,先说明一下,考虑到服务端故障的问题,客户端会将每次拉取到的最新数据保存在本地的 snapshot 文件中,以后会优先从文件中获取配置信息的值。
这里面有两个check,一个是检查本地缓存,从注释check failover config来看,本地缓存应该是用来做故障转移的,假如服务端挂了,还可以获取本地文件的配置。另一个就是检查服务端最新的配置信息,将最新的配置信息更新到缓存。

本地检查

    			// check failover config
                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);
                        }
                    }
                }

看一下检查本地缓存的checkLocalConfig方法。

  • 没有 -> 有,从故障转移文件中获取出来,并更新到缓存中。
        if (!cacheData.isUseLocalConfigInfo() && path.exists()) {
            String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
            String md5 = MD5.getInstance().getMD5String(content);
            cacheData.setUseLocalConfigInfo(true);
            cacheData.setLocalConfigInfoVersion(path.lastModified());
            cacheData.setContent(content);

            LOGGER.warn("[{}] [failover-change] failover file created. dataId={}, group={}, tenant={}, md5={}, content={}",
                agent.getName(), dataId, group, tenant, md5, ContentUtils.truncateContent(content));
            return;
        }
  • 有 -> 没有。不通知业务监听器,从server拿到配置后通知。
        if (cacheData.isUseLocalConfigInfo() && !path.exists()) {
            cacheData.setUseLocalConfigInfo(false);
            LOGGER.warn("[{}] [failover-change] failover file deleted. dataId={}, group={}, tenant={}", agent.getName(),
                dataId, group, tenant);
            return;
        }
  • 有变更
        if (cacheData.isUseLocalConfigInfo() && path.exists()
            && cacheData.getLocalConfigInfoVersion() != path.lastModified()) {
            String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
            String md5 = MD5.getInstance().getMD5String(content);
            cacheData.setUseLocalConfigInfo(true);
            cacheData.setLocalConfigInfoVersion(path.lastModified());
            cacheData.setContent(content);
            LOGGER.warn("[{}] [failover-change] failover file changed. dataId={}, group={}, tenant={}, md5={}, content={}",
                agent.getName(), dataId, group, tenant, md5, ContentUtils.truncateContent(content));
        }

接着往下,从上面设置的cacheData.setUseLocalConfigInfo可以看出,如果缓存有数据,需要启动Md5值的监听器,具体没细看,应该是比较md5值有没有变化。

     if (cacheData.isUseLocalConfigInfo()) {
           cacheData.checkListenerMd5();
     }

服务端检查

从服务端拿到配置信息,更新到缓存中,然后看一下是怎么从服务端拿数据的。

	  List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);

先看checkUpdateDataIds方法,大概就是首次加载的时候会先初始化缓存。

    /**
     * 从Server获取值变化了的DataID列表。返回的对象里只有dataId和group是有效的。 保证不返回NULL。
     */
    List<String> checkUpdateDataIds(List<CacheData> cacheDatas, List<String> inInitializingCacheList) throws IOException {
        StringBuilder sb = new StringBuilder();
        for (CacheData cacheData : cacheDatas) {
            if (!cacheData.isUseLocalConfigInfo()) {
                sb.append(cacheData.dataId).append(WORD_SEPARATOR);
                sb.append(cacheData.group).append(WORD_SEPARATOR);
                if (StringUtils.isBlank(cacheData.tenant)) {
                    sb.append(cacheData.getMd5()).append(LINE_SEPARATOR);
                } else {
                    sb.append(cacheData.getMd5()).append(WORD_SEPARATOR);
                    sb.append(cacheData.getTenant()).append(LINE_SEPARATOR);
                }
                if (cacheData.isInitializing()) {
                    // cacheData 首次出现在cacheMap中&首次check更新
                    inInitializingCacheList
                        .add(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant));
                }
            }
        }
        boolean isInitializingCacheList = !inInitializingCacheList.isEmpty();
        return checkUpdateConfigStr(sb.toString(), isInitializingCacheList);
    }

然后看checkUpdateConfigStr方法,发送一个长轮询的请求到服务端,监听服务端的变化。

    /**
     * 从Server获取值变化了的DataID列表。返回的对象里只有dataId和group是有效的。 保证不返回NULL。
     */
    List<String> checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws IOException {


        List<String> params = new ArrayList<String>(2);
        params.add(Constants.PROBE_MODIFY_REQUEST);
        params.add(probeUpdateString);

        List<String> headers = new ArrayList<String>(2);
        headers.add("Long-Pulling-Timeout");
        headers.add("" + timeout);

        // told server do not hang me up if new initializing cacheData added in
        if (isInitializingCacheList) {
            headers.add("Long-Pulling-Timeout-No-Hangup");
            headers.add("true");
        }

        if (StringUtils.isBlank(probeUpdateString)) {
            return Collections.emptyList();
        }

        try {
            // In order to prevent the server from handling the delay of the client's long task,
            // increase the client's read timeout to avoid this problem.

            long readTimeoutMs = timeout + (long) Math.round(timeout >> 1);
            HttpResult result = agent.httpPost(Constants.CONFIG_CONTROLLER_PATH + "/listener", headers, params,
                agent.getEncode(), readTimeoutMs);

            if (HttpURLConnection.HTTP_OK == result.code) {
                setHealthServer(true);
                return parseUpdateDataIdResponse(result.content);
            } else {
                setHealthServer(false);
                LOGGER.error("[{}] [check-update] get changed dataId error, code: {}", agent.getName(), result.code);
            }
        } catch (IOException e) {
            setHealthServer(false);
            LOGGER.error("[" + agent.getName() + "] [check-update] get changed dataId exception", e);
            throw e;
        }
        return Collections.emptyList();
    }

如果服务端的配置发生变化,则去服务端拉取数据。

      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]);
      }

通过前面的agent.httpGet发起http请求拿到配置信息。

    public static final String CONFIG_CONTROLLER_PATH = BASE_PATH + "/configs";
    public String[] getServerConfig(String dataId, String group, String tenant, long readTimeout)
        throws NacosException {
        result = agent.httpGet(Constants.CONFIG_CONTROLLER_PATH, null, params, agent.getEncode(), readTimeout);
        //省略
   }

成功拉取到配置信息之后,保存到本地的snapshot文件。

    case HttpURLConnection.HTTP_OK:
               LocalConfigInfoProcessor.saveSnapshot(agent.getName(), dataId, group, tenant, result.content);

走到这里,实例化NacosConfigService的流程就走完了,接着看它实现的几个方法。

NacosConfigService

getConfig

优先从本地缓存中获取配置,如果为空,则从服务端拉取配置,具体拉取配置的方法是getServerConfig,上面服务端检查那里讲过了。

    public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
        return getConfigInner(namespace, dataId, group, timeoutMs);
    }
    private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException {
        // 优先使用本地配置
        String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
        if (content != null) {
            LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}", agent.getName(),
                dataId, group, tenant, ContentUtils.truncateContent(content));
            cr.setContent(content);
            configFilterChainManager.doFilter(null, cr);
            content = cr.getContent();
            return content;
        }
		//若本地配置为空,则从服务端拉取配置信息
        try {
            String[] ct = worker.getServerConfig(dataId, group, tenant, timeoutMs);
            cr.setContent(ct[0]);

            configFilterChainManager.doFilter(null, cr);
            content = cr.getContent();

            return content;
        } catch (NacosException ioe) {
            if (NacosException.NO_RIGHT == ioe.getErrCode()) {
                throw ioe;
            }
            LOGGER.warn("[{}] [get-config] get from server error, dataId={}, group={}, tenant={}, msg={}",
                agent.getName(), dataId, group, tenant, ioe.toString());
        }
}

剩下的几个方法不是本次讲解的重点,了解一下大概的用法就行了。

getConfigAndSignListener

拉取配置并增加监听器

publishConfig

把配置通过httpPost发送到服务端,更新配置。

removeConfig

把配置通过httpDelete发送到服务端,删除配置。

addListener

增加监听器

removeListener

移除监听器

三、总结

看完整个流程,可以回答前面提的那个问题,客户端是以拉的形式更新配置的,基于http长轮询实现的。
客户端通过一个定时任务来检查自己监听的配置项的数据,一旦服务端的数据发生变化时,客户端将会获取到最新的数据,并将最新的数据保存在一个 CacheData 对象中,然后会重新计算 CacheData 的 md5 属性的值,此时就会对该 CacheData 所绑定的 Listener 触发 receiveConfigInfo 回调。

你可能感兴趣的:(配置中心,java,中间件)