初识nacos

最近在整合nacos做配置的热下发,总结下。

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service的首字母简称,一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。

阿里开源产品:
什么是 Nacos

如 Nacos 全景图所示,Nacos 无缝支持一些主流的开源生态,例如

  • Spring Cloud
  • Apache Dubbo and Dubbo Mesh
  • Kubernetes and CNCF。

使用 Nacos 简化服务发现、配置管理、服务治理及管理的解决方案,让微服务的发现、管理、共享、组合更加容易。

当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置就会让人抓狂,而且很容易出错。我们需要一种统一配置管理方案,可以集中管理所有实例的配置。

Nacos 一方面可以将配置集中管理,另一方可以在配置变更时,及时通知微服务,实现配置的热更新。

创建配置

在 Nacos 控制面板中添加配置文件

然后在弹出的表单中,填写配置信息:

注意:项目的核心配置,需要热更新的配置才有放到 nacos 管理的必要。基本不会变更的一些配置(例如数据库连接)还是保存在微服务本地比较好。

拉取配置

首先我们需要了解 Nacos 读取配置文件的环节是在哪一步,在没加入 Nacos 配置之前,获取配置是这样:

加入 Nacos 配置,它的读取是在 application.yml 之前的:

这时候如果把 nacos 地址放在 application.yml 中,显然是不合适的,Nacos 就无法根据地址去获取配置了。

因此,nacos 地址必须放在优先级最高的 bootstrap.yml 文件。

引入 nacos-config 依赖

引入 nacos-config 依赖    com.alibaba.cloud    spring-cloud-starter-alibaba-nacos-config

添加 bootstrap.yml

然后,在 user-service 中添加一个 bootstrap.yml 文件,内容如下:

spring:
application:
name: userservice # 服务名称
profiles:
active: dev #开发环境,这里是dev
cloud:
nacos:
server-addr: localhost:8848 # Nacos地址
config:
file-extension: yaml # 文件后缀名

根据 spring.cloud.nacos.server-addr 获取 nacos地址,再根据 “服务名称-环境.后缀名”这个格式与nacos配置管理的Data ID格式必须相对应,来读取配置。

在这个例子例中,就是去读取userservice-dev.yaml

使用代码来验证是否拉取成功

nacos配置完在 user-service 中的 UserController 中添加业务逻辑,读取 pattern.dateformat 配置并使用(@Value读取配置):

启动服务后,访问:http://localhost:8081/user/now

配置热更新

热更新最终的目的,是修改 nacos 中的配置后,微服务中无需重启即可让配置生效,也就是配置热更新

有两种方式:

方式一:@value读取配置时,搭配@RefreshScope

@RefreshScope

@Value注入的变量所在类上添加注解@RefreshScope

方式二:直接用@ConfigurationProperties读取配置

@ConfigurationProperties

使用@ConfigurationProperties注解读取配置文件,就不需要加@RefreshScope注解。

在 user-service 服务中,添加一个 自定义配置类,用来单独读取patterrn.dateformat属性

可能有人会有疑问,为什么一次长轮询需要等待一定时间超时,超时后又发起长轮询,为什么不让服务端一直 hold 住?主要有两个层面的考虑,一是连接稳定性的考虑,长轮询在传输层本质上还是走的 TCP 协议,如果服务端假死、fullgc 等异常问题,或者是重启等常规操作,长轮询没有应用层的心跳机制,仅仅依靠 TCP 层的心跳保活很难确保可用性,所以一次长轮询设置一定的超时时间也是在确保可用性。除此之外,在配置中心场景,还有一定的业务需求需要这么设计。在配置中心的使用过程中,用户可能随时新增配置监听,而在此之前,长轮询可能已经发出,新增的配置监听无法包含在旧的长轮询中,所以在配置中心的设计中,一般会在一次长轮询结束后,将新增的配置监听给捎带上,而如果长轮询没有超时时间,只要配置一直不发生变化,响应就无法返回,新增的配置也就没法设置监听了。

配置中心长轮询设计

客户端发起长轮询

客户端发起一个 HTTP 请求,请求信息包含配置中心的地址,以及监听的 dataId(本文出于简化说明的考虑,认为 dataId 是定位配置的唯一键)。若配置没有发生变化,客户端与服务端之间一直处于连接状态。

服务端监听数据变化

服务端会维护 dataId 和长轮询的映射关系,如果配置发生变化,服务端会找到对应的连接,为响应写入更新后的配置内容。如果超时内配置未发生变化,服务端找到对应的超时长轮询连接,写入 304 响应。

304 在 HTTP 响应码中代表“未改变”,并不代表错误。比较契合长轮询时,配置未发生变更的场景。

客户端接收长轮询响应

首先查看响应码是 200 还是 304,以判断配置是否变更,做出相应的回调。之后再次发起下一次长轮询。

服务端设置配置写入的接入点

主要用配置控制台和 client 发布配置,触发配置变更。

这几点便是配置中心实现长轮询的核心步骤,也是指导下面章节代码实现的关键。但在编码之前,仍有一些其他的注意点需要实现阐明。

配置中心往往是为分布式的集群提供服务的,而每个机器上部署的应用,又会有多个 dataId 需要监听,实例级别 * 配置数是一个不小的数字,配置中心服务端维护这些 dataId 的长轮询连接显然不能用线程一一对应,否则会导致服务端线程数爆炸式增长。一个 Tomcat 也就 200 个线程,长轮询也不应该阻塞 Tomcat 的业务线程,所以需要配置中心在实现长轮询时,往往采用异步响应的方式来实现。而比较方便实现异步 HTTP 的常见手段便是 Servlet3.0 提供的 AsyncContext 机制

Servlet3.0 并不是一个特别新的规范,它跟 Java 6 是同一时期的产物。例如 SpringBoot 内嵌的 Tomcat 很早就支持了 Servlet3.0,你无需担心 AsyncContext 机制不起作用。

SpringMVC 实现了 DeferredResult 和 Servlet3.0 提供的 AsyncContext 其实没有多大区别,我并没有深入研究过两个实现背后的源码,但从使用层面上来看,AsyncContext 更加的灵活,例如其可以自定义响应码,而 DeferredResult 在上层做了封装,可以快速的帮助开发者实现一个异步响应,但没法细粒度地控制响应。所以下文的示例中,我选择了 AsyncContext。

配置中心长轮询实现

1 客户端实现

@Slf4jpublic class ConfigClient {    private CloseableHttpClient httpClient;    private RequestConfig requestConfig;    public ConfigClient() {        this.httpClient = HttpClientBuilder.create().build();        // ① httpClient 客户端超时时间要大于长轮询约定的超时时间        this.requestConfig = RequestConfig.custom().setSocketTimeout(40000).build();    }    @SneakyThrows    public void longPolling(String url, String dataId) {        String endpoint = url + "?dataId=" + dataId;        HttpGet request = new HttpGet(endpoint);        CloseableHttpResponse response = httpClient.execute(request);        switch (response.getStatusLine().getStatusCode()) {            case 200: {                BufferedReader rd = new BufferedReader(new InputStreamReader(response.getEntity()                    .getContent()));                StringBuilder result = new StringBuilder();                String line;                while ((line = rd.readLine()) != null) {                    result.append(line);                }                response.close();                String configInfo = result.toString();                log.info("dataId: [{}] changed, receive configInfo: {}", dataId, configInfo);                longPolling(url, dataId);                break;            }            // ② 304 响应码标记配置未变更            case 304: {                log.info("longPolling dataId: [{}] once finished, configInfo is unchanged, longPolling again", dataId);                longPolling(url, dataId);                break;            }            default: {                throw new RuntimeException("unExcepted HTTP status code");            }        }    }    public static void main(String[] args) {        // httpClient 会打印很多 debug 日志,关闭掉        Logger logger = (Logger)LoggerFactory.getLogger("org.apache.http");        logger.setLevel(Level.INFO);        logger.setAdditive(false);        ConfigClient configClient = new ConfigClient();        // ③ 对 dataId: user 进行配置监听         configClient.longPolling("http://127.0.0.1:8080/listener", "user");    }}

主要有三个注意点:

  • RequestConfig.custom().setSocketTimeout(40000).build() :httpClient 客户端超时时间要大于长轮询约定的超时时间。很好理解,不然还没等服务端返回,客户端会自行断开 HTTP 连接。
  • response.getStatusLine().getStatusCode() == 304 :前文介绍过,约定使用 304 响应码来标识配置未发生变更,客户端继续发起长轮询。
  • configClient.longPolling(“http://127.0.0.1:8080/listener”, “user”):在示例中,我们处于简单考虑,仅仅启动一个客户端,对单一的 dataId:user 进行监听(注意,需要先启动 server 端)。

2 服务端实现

@RestController@Slf4j@SpringBootApplicationpublic class ConfigServer {    @Data    private static class AsyncTask {        // 长轮询请求的上下文,包含请求和响应体        private AsyncContext asyncContext;        // 超时标记        private boolean timeout;        public AsyncTask(AsyncContext asyncContext, boolean timeout) {            this.asyncContext = asyncContext;            this.timeout = timeout;        }    }    // guava 提供的多值 Map,一个 key 可以对应多个 value    private Multimap dataIdContext = Multimaps.synchronizedSetMultimap(HashMultimap.create());    private ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("longPolling-timeout-checker-%d")        .build();    private ScheduledExecutorService timeoutChecker = new ScheduledThreadPoolExecutor(1, threadFactory);    // 配置监听接入点    @RequestMapping("/listener")    public void addListener(HttpServletRequest request, HttpServletResponse response) {        String dataId = request.getParameter("dataId");                // 开启异步        AsyncContext asyncContext = request.startAsync(request, response);        AsyncTask asyncTask = new AsyncTask(asyncContext, true);        // 维护 dataId 和异步请求上下文的关联        dataIdContext.put(dataId, asyncTask);        // 启动定时器,30s 后写入 304 响应        timeoutChecker.schedule(() -> {            if (asyncTask.isTimeout()) {                dataIdContext.remove(dataId, asyncTask);                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);                asyncContext.complete();            }        }, 30000, TimeUnit.MILLISECONDS);    }    // 配置发布接入点    @RequestMapping("/publishConfig")    @SneakyThrows    public String publishConfig(String dataId, String configInfo) {        log.info("publish configInfo dataId: [{}], configInfo: {}", dataId, configInfo);        Collection asyncTasks = dataIdContext.removeAll(dataId);        for (AsyncTask asyncTask : asyncTasks) {            asyncTask.setTimeout(false);            HttpServletResponse response = (HttpServletResponse)asyncTask.getAsyncContext().getResponse();            response.setStatus(HttpServletResponse.SC_OK);            response.getWriter().println(configInfo);            asyncTask.getAsyncContext().complete();        }        return "success";    }    public static void main(String[] args) {        SpringApplication.run(ConfigServer.class, args);    }}

对上述实现的一些说明:

@RequestMapping(“/listener”) ,配置监听接入点,也是长轮询的入口。在获取 dataId 之后,使用 request.startAsync 将请求设置为异步,这样在方法结束后,不会占用 Tomcat 的线程池。

接着 dataIdContext.put(dataId, asyncTask) 会将 dataId 和异步请求上下文给关联起来,方便配置发布时,拿到对应的上下文。注意这里使用了一个 guava 提供的数据结构 Multimap dataIdContext ,它是一个多值 Map,一个 key 可以对应多个 value,你也可以理解为 Map ,但使用 Multimap 维护起来可以更方便地处理一些并发逻辑。至于为什么会有多值,很好理解,因为配置中心的 Server 端会接受来自多个客户端对同一个 dataId 的监听。

timeoutChecker.schedule() 启动定时器,30s 后写入 304 响应。再结合之前客户端的逻辑,接收到 304 之后,会重新发起长轮询,形成一个循环。

@RequestMapping(“/publishConfig”) ,配置发布的入口。配置变更后,根据 dataId 一次拿出所有的长轮询,为之写入变更的响应,同时不要忘记取消定时任务。至此,完成了一个配置变更后推送的流程。

3 启动配置监听

先启动 ConfigServer,再启动 ConfigClient。客户端打印长轮询的日志如下:

22:18:09.185 [main] INFO moe.cnkirito.demo.ConfigClient - longPolling dataId: [user] once finished, configInfo is unchanged, longPolling again22:18:39.197 [main] INFO moe.cnkirito.demo.ConfigClient - longPolling dataId: [user] once finished, configInfo is unchanged, longPolling again

发布一条配置:

curl -X GET "localhost:8080/publishConfig?dataId=user&configInfo=helloworld"

服务端打印日志如下:

2021-01-24 22:18:50.801  INFO 73301 --- [nio-8080-exec-6] moe.cnkirito.demo.ConfigServer           : publish configInfo dataId: [user], configInfo: helloworld

客户端接受配置推送:

22:18:50.806 [main] INFO moe.cnkirito.demo.ConfigClient - dataId: [user] changed, receive configInfo: helloworld

六 实现细节思考

为什么需要定时器返回 304

上述的实现中,服务端采用了一个定时器,在配置未发生变更时,定时返回 304,客户端接收到 304 之后,重新发起长轮询。在前文,已经解释过了为什么需要超时后重新发起长轮询,而不是由服务端一直 hold,直到配置变更再返回,但可能有读者还会有疑问,为什么不由客户端控制超时,服务端去除掉定时器,这样客户端超时后重新发起下一次长轮询,这样的设计不是更简单吗?无论是 Nacos 还是 Apollo 都有这样的定时器,而不是靠客户端控制超时,这样做主要有两点考虑:

  • 和真正的客户端超时区分开。
  • 仅仅使用异常(Exception)来表达异常流,而不应该用异常来表达正常的业务流。304 不是超时异常,而是长轮询中配置未变更的一种正常流程,不应该使用超时异常来表达。

客户端超时需要单独配置,且需要比服务端长轮询的超时要长。正如上述的 demo 中客户端超时设置的是 40s,服务端判断一次长轮询超时是 30s。这两个值在 Nacos 中默认是 30s 和 29.5s,在 Apollo 中默认是是 90s 和 60s。

长轮询包含多组 dataId

在上述的 demo 中,一个 dataId 会发起一次长轮询,在实际配置中心的设计中肯定不能这样设计,一般的优化方式是,一批 dataId 组成一个组批量包含在一个长轮询任务中。在 Nacos 中,按照 3000 个 dataId 为一组包装成一个长轮询任务。

七 长轮询和长连接

讲完实现细节,本文最核心的部分已经介绍完了。再回到最前面提到的数据交互模式上提到的推模型和拉模型,其实在写这篇文章时,我曾经问过交流群中的小伙伴们“配置中心实现动态推送的原理”,他们中绝大多数人认为是长连接的推模型。然而事实上,主流的配置中心几乎都是使用了本文介绍的长轮询方案,这又是为什么呢?

我也翻阅了不少博客,显然他们给出的理由并不能说服我,我尝试着从自己的角度分析了一下这个既定的事实:

长轮询实现起来比较容易,完全依赖于 HTTP 便可以实现全部逻辑,而 HTTP 是最能够被大众接受的通信方式。长轮询使用 HTTP,便于多语言客户端的编写,大多数语言都有 HTTP 的客户端。

那么长连接是不是真的就不适合用于配置中心场景呢?有人可能会认为维护一条长连接会消耗大量资源,而长轮询可以提升系统的吞吐量,而在配置中心场景,这一假设并没有实际的压测数据能够论证,benchmark everything!please~

另外,翻阅了一下 Nacos 2.0 的 milestone,我发现了一个有意思的规划,Nacos 的注册中心(目前是短轮询 + udp 推送)和配置中心(目前是长轮询)都有计划改造为长连接模式。

再回过头来看,长轮询实现已经将配置中心这个组件支撑的足够好了,替换成长连接,一定需要找到合适的理由才行。

八 总结

本文介绍了长轮询、轮询、长连接这几种数据交互模型的差异性。

分析了 Nacos 和 Apollo 等主流配置中心均是通过长轮询的方式实现配置的实时推送的。实时感知建立在客户端拉的基础上,因为本质上还是通过 HTTP 进行的数据交互,之所以有“推”的感觉,是因为服务端 hold 住了客户端的响应体,并且在配置变更后主动写入了返回 response 对象再进行返回。

参考文献:
1、认识长轮询:配置中心是如何实现推送的? – 知乎

2、AsyncContext异步处理http请求_CRUD的W的博客-CSDN博客_asynccontext

3、Nacos-配置管理+配置热更新_二后生的博客-CSDN博客_nacos热更新原理