一 客户端动态刷新的触发
长轮询是客户端向服务端发起,那么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任务,将变更的数据通过任务中的连接进行返回。