一 前言
传统的静态配置方式想要修改某个配置时,必须重新启动一次应用,如果是数据库连接串的变更,那可能还容易接受一些,但如果变更的是一些运行时实时感知的配置,如某个功能项的开关,重启应用就显得有点大动干戈了,配置中心正是为了解决此类问题应运而生的,特别是在微服务架构体系中,更倾向于使用配置中心来统一管理配置,
配置中心最核心的能力就是配置的动态推送,常见的配置中心如 Nacos、Apollo 等都实作了这样的能力,在早期接触配置中心时,我就很好奇,配置中心是如何做到服务端感知配置变化实时推送给客户端的,在没有研究过配置中心的实作原理之前,我一度认为配置中心是通过长连接来做到配置推送的,事实上,目前比较流行的两款配置中心:Nacos 和 Apollo 恰恰都没有使用长连接,而是使用的长轮询,本文便是介绍一下长轮询这种听起来好像已经是上个世纪的技术,老戏新唱,看看能不能品出别样的韵味,文中会有代码示例,呈现一个简易的配置监听流程,
二 资料互动模式
众所周知,资料互动有两种模式:Push(推模式)和 Pull(拉模式),
推模式指的是客户端与服务端建立好网络长连接,服务方有相关资料,直接通过长连接通道推送到客户端,其优点是及时,一旦有资料变更,客户端立马能感知到;另外对客户端来说逻辑简单,不需要关心有无资料这些逻辑处理,缺点是不知道客户端的资料消费能力,可能导致资料积压在客户端,来不及处理,
拉模式指的是客户端主动向服务端发出请求,拉取相关资料,其优点是此程序由客户端发起请求,故不存在推模式中资料积压的问题,缺点是可能不够及时,对客户端来说需要考虑资料拉取相关逻辑,何时去拉,拉的频率怎么控制等等,
三 长轮询与轮询
在开头,重点介绍一下长轮询(Long Polling)和轮询(Polling)的区别,两者都是拉模式的实作,
“轮询”是指不管服务端资料有无更新,客户端每隔定长时间请求拉取一次资料,可能有更新资料回传,也可能什么都没有,配置中心如果使用「轮询」实作动态推送,会有以下问题:
- 推送延迟,客户端每隔 5s 拉取一次配置,若配置变更发生在第 6s,则配置推送的延迟会达到 4s,
- 服务端压力,配置一般不会发生变化,频繁的轮询会给服务端造成很大的压力,
- 推送延迟和服务端压力无法中和,降低轮询的间隔,延迟降低,压力增加;增加轮询的间隔,压力降低,延迟增高,
“长轮询”则不存在上述的问题,客户端发起长轮询,如果服务端的资料没有发生变更,会 hold 住请求,直到服务端的资料发生变化,或者等待一定时间超时才会回传,回传后,客户端又会立即再次发起下一次长轮询,配置中心使用「长轮询」如何解决「轮询」遇到的问题也就显而易见了:
- 推送延迟,服务端资料发生变更后,长轮询结束,立刻回传回应给客户端,
- 服务端压力,长轮询的间隔期一般很长,例如 30s、60s,并且服务端 hold 住连接不会消耗太多服务端资源,
以 Nacos 为例的长轮询流程如下:
可能有人会有疑问,为什么一次长轮询需要等待一定时间超时,超时后又发起长轮询,为什么不让服务端一直 hold 住?主要有两个层面的考虑,一是连接稳定性的考虑,长轮询在传输层本质上还是走的 TCP 协议,如果服务端假死、fullgc 等例外问题,或者是重启等常规操作,长轮询没有应用层的心跳机制,仅仅依靠 TCP 层的心跳保活很难确保可用性,所以一次长轮询设定一定的超时时间也是在确保可用性,除此之外,在配置中心场景,还有一定的业务需求需要这幺设计,在配置中心的使用程序中,用户可能随时新增配置监听,而在此之前,长轮询可能已经发出,新增的配置监听无法包含在旧的长轮询中,所以在配置中心的设计中,一般会在一次长轮询结束后,将新增的配置监听给捎带上,而如果长轮询没有超时时间,只要配置一直不发生变化,回应就无法回传,新增的配置也就没法设定监听了,
四 配置中心长轮询设计
上文的图中,介绍了长轮询的流程,本节会详解配置中心长轮询的设计细节,
客户端发起长轮询
客户端发起一个 HTTP 请求,请求信息包含配置中心的地址,以及监听的 dataId(本文出于简化说明的考虑,认为 dataId 是定位配置的唯一键),若配置没有发生变化,客户端与服务端之间一直处于连接状态,
服务端监听资料变化
服务端会维护 dataId 和长轮询的映射关系,如果配置发生变化,服务端会找到对应的连接,为回应写入更新后的配置内容,如果超时内配置未发生变化,服务端找到对应的超时长轮询连接,写入 304 回应,
客户端接收长轮询回应
首先查看回应码是 200 还是 304,以判断配置是否变更,做出相应的回呼,之后再次发起下一次长轮询,
服务端设定配置写入的接入点
主要用配置控制台和 client 发布配置,触发配置变更,
这几点便是配置中心实作长轮询的核心步骤,也是指导下面章节代码实作的关键,但在编码之前,仍有一些其他的注意点需要实作阐明,
配置中心往往是为分布式的集群提供服务的,而每个机器上部署的应用,又会有多个 dataId 需要监听,实体级别 * 配置数是一个不小的数字,配置中心服务端维护这些 dataId 的长轮询连接显然不能用执行绪一一对应,否则会导致服务端执行绪数爆炸式增长,一个 Tomcat 也就 200 个执行绪,长轮询也不应该阻塞 Tomcat 的业务执行绪,所以需要配置中心在实作长轮询时,往往采用异步回应的方式来实作,而比较方便实作异步 HTTP 的常见手段便是 Servlet3.0 提供的 AsyncContext 机制,
SpringMVC 实作了 DeferredResult 和 Servlet3.0 提供的 AsyncContext 其实没有多大区别,我并没有深入研究过两个实作背后的原始码,但从使用层面上来看,AsyncContext 更加的灵活,例如其可以自定义回应码,而 DeferredResult 在上层做了封装,可以快速的帮助开发者实作一个异步回应,但没法细粒度地控制回应,所以下文的示例中,我选择了 AsyncContext,
五 配置中心长轮询实作
1 客户端实作
@Slf4j
public class ConfigClient {
private CloseableHttpClient httpClient;
private RequestConfig requestConfig;
public ConfigClient() {
// ① httpClient 客户端超时时间要大于长轮询约定的超时时间
this.requestConfig = RequestConfig.custom().setSocketTimeout(40000).build();
this.httpClient = HttpClientBuilder.create().setDefaultRequestConfig(this.requestConfig).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
@SpringBootApplication
public 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<String, AsyncTask> 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<AsyncTask> 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<String, AsyncTask> dataIdContext ,它是一个多值 Map,一个 key 可以对应多个 value,你也可以理解为 Map<String,List> ,但使用 Multimap 维护起来可以更方便地处理一些并发逻辑,至于为什么会有多值,很好理解,因为配置中心的 Server 端会接受来自多个客户端对同一个 dataId 的监听,
- timeoutChecker.schedule() 启动定时器,30s 后写入 304 回应,再结合之前客户端的逻辑,接收到 304 之后,会重新发起长轮询,形成一个回圈,
- @RequestMapping("/publishConfig") ,配置发布的入口,配置变更后,根据 dataId 一次拿出所有的长轮询,为之写入变更的回应,同时不要忘记取消定时任务,至此,完成了一个配置变更后推送的流程,
3 启动配置监听
先启动 ConfigServer,再启动 ConfigClient,客户端打印长轮询的日志如下:
发布一条配置:
curl -X GET "localhost:8080/publishConfig?dataId=user&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 的客户端,
那么长连接是不是真的就不适合用于配置中心场景呢?有人可能会认为维护一条长连接会消耗大量资源,而长轮询可以提升系统的吞吐量,而在配置中心场景,这一假设并没有实际的压测资料能够论证,
另外,翻阅了一下 Nacos 2.0 的 milestone,我发现了一个有意思的规划,Nacos 的注册中心(目前是短轮询 + udp 推送)和配置中心(目前是长轮询)都有计划改造为长连接模式,
再回过头来看,长轮询实作已经将配置中心这个组件支撑的足够好了,替换成长连接,一定需要找到合适的理由才行,
八 总结
本文介绍了长轮询、轮询、长连接这几种资料互动模型的差异性,
分析了 Nacos 和 Apollo 等主流配置中心均是通过长轮询的方式实作配置的实时推送的,实时感知建立在客户端拉的基础上,因为本质上还是通过 HTTP 进行的资料互动,之所以有“推”的感觉,是因为服务端 hold 住了客户端的回应体,并且在配置变更后主动写入了回传 response 物件再进行回传,
原文链接:认识长轮询:配置中心是如何实作推送的?
0 评论