配置的动态刷新客户端有两种方式完成客户端主动Pull拉取配置,Nacos Server主动Push配置数据
nacos采取的是Pull模式来完成配置的管理和动态刷新
1: Pull模式: 长轮询机制
nacos采用了长轮询机制实现,客户端发起一个Pull拉取配置请求,nacos server建立一个延时任务队列,每隔29.5s处理一个任务,处理任务便是花费0.5s检查配置有没有变更。不管有没有变更,都返回配置数据给客户端。之所以叫长轮询,是因为客户端和nacos建立的连接是一个30s的长连接。
缺点:没有实时性,配置无变化会发起空pull,连接浪费资源。不过这里的缺点在下面的push都解决了。
2: Push模式:
当通过nacos dashboard或者nacos api修改了配置后,nacos检查pull任务队列,可能会在任务中29.5s内的任意时刻,开始处理任务,和上述流程一致,检查配置变更数据,返回给Pull请求最新配置。所以Push模式实质上还是利用了Pull请求的任务来完成。
PS: 客户端本身会有一个定时线程每隔10ms检查一次本地配置(硬盘中存储)和内存(JVM)配置是否一致。通过上述动态刷新的只是内存中的配置。
长轮询
是由服务端控制响应客户端请求的返回时间,来减少客户端无效请求的一种优化手段,其实对于客户端来说与轮询的使用并没有本质上的区别。
客户端发起请求后,服务端不会立即返回请求结果,而是将请求挂起等待一段时间,如果此段时间内服务端数据变更,立即响应客户端请求,若是一直无变化则等到指定的超时时间后响应请求,客户端重新发起长链接。
客户端、控制台通过发送Http请求将配置数据注册到服务端,服务端持久化数据到Mysql。
客户端拉取配置数据,并批量设置对dataId的监听发起长轮询请求,如服务端配置项变更立即响应请求,如无数据变更则将请求挂起一段时间,直到达到超时时间。为减少对服务端压力以及保证配置中心可用性,拉取到配置数据客户端会保存一份快照在本地文件中,优先读取。
客户端源码分析
客户端数据结构cacheMap,是个Map结构,key为groupKey,是由dataId, group, tenant(租户)拼接的字符串;value为CacheData对象,每个dataId都会持有一个CacheData对象。
/**
* groupKey -> cacheData.
*/
private final AtomicReference<Map<String, CacheData>> cacheMap = new AtomicReference<Map<String, CacheData>>(
new HashMap<String, CacheData>());
1.获取配置
先取本地快照文件中的配置,如果本地文件不存在或者内容为空,则再通过HTTP请求从远端拉取对应dataId配置数据,并保存到本地快照中,请求默认重试3次,超时时间
获取配置有getConfig()和getConfigAndSignListener()这两个接口,但getConfig()只是发送普通的HTTP请求,而getConfigAndSignListener()则多了发起长轮询和对dataId数据变更注册监听的操作addTenantListenersWithContent()。
/**
* 客户端获取配置
*
* @param dataId dataId
* @param group group
* @param timeoutMs read timeout
* @return config value
* @throws NacosException NacosException
*/
@Override
public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
return getConfigInner(namespace, dataId, group, timeoutMs);
}
/**
* 客户端获取配置并注册监听器。
如果想在程序第一次开始获取配置的时候自己拉取,并且注册的监听器用于以后的配置更新,可以保持原代码不变,
只需要添加系统参数:enableRemoteSyncConfig = "true" (但有网络开销); 因此我们建议您直接使用该接口
*
*/
@Override
public String getConfigAndSignListener(String dataId, String group, long timeoutMs, Listener listener)
throws NacosException {
String content = getConfig(dataId, group, timeoutMs);
worker.addTenantListenersWithContent(dataId, group, content, Arrays.asList(listener));
return content;
}
2.注册监听
客户端注册监听,先从cacheMap中拿到dataId对应的CacheData对象。
/**
* Add listeners for tenant with content.
*
* @param dataId dataId of data
* @param group group of data
* @param content content
* @param listeners listeners
* @throws NacosException nacos exception
*/
public void addTenantListenersWithContent(String dataId, String group, String content,
List<? extends Listener> listeners) throws NacosException {
group = blank2defaultGroup(group);
String tenant = agent.getTenant();
// 获取dataId对应的CacheData,如没有则向服务端发起长轮询请求获取配置
CacheData cache = addCacheDataIfAbsent(dataId, group, tenant);
synchronized (cache) {
// 注册对dataId的数据变更监听
cache.setContent(content);
for (Listener listener : listeners) {
cache.addListener(listener);
}
cache.setSyncWithServer(false);
agent.notifyListenConfig();
}
}
/**
* 如没有向服务端发起长轮询请求获取配置,默认的Timeout时间为30s,
* 并把返回的配置数据回填至CacheData对象的content字段,同时用content生成MD5值;再通过addListener()注册监听器。
*
* @param dataId data id if data
* @param group group of data
* @param tenant tenant of data
* @return cache data
*/
public CacheData addCacheDataIfAbsent(String dataId, String group, String tenant) throws NacosException {
CacheData cache = getCache(dataId, group, tenant);
if (null != cache) {
return cache;
}
String key = GroupKey.getKeyTenant(dataId, group, tenant);
synchronized (cacheMap) {
CacheData cacheFromMap = getCache(dataId, group, tenant);
// 多个监听器在同一个 dataid+group 和竞争条件下,所以再次仔细检查
// double check again
// other listener thread beat me to set to cacheMap
if (null != cacheFromMap) {
cache = cacheFromMap;
// 重置以便服务器不会挂起此检查
cache.setInitializing(true);
} else {
cache = new CacheData(configFilterChainManager, agent.getName(), dataId, group, tenant);
int taskId = cacheMap.get().size() / (int) ParamUtil.getPerTaskConfigSize();
cache.setTaskId(taskId);
// fix issue # 1317
if (enableRemoteSyncConfig) {
ConfigResponse response = getServerConfig(dataId, group, tenant, 3000L, false);
cache.setContent(response.getContent());
}
}
Map<String, CacheData> copy = new HashMap<String, CacheData>(this.cacheMap.get());
copy.put(key, cache);
cacheMap.set(copy);
}
LOGGER.info("[{}] [subscribe] {}", agent.getName(), key);
MetricsMonitor.getListenConfigCountMonitor().set(cacheMap.get().size());
return cache;
}
/**
* 其中listeners是对dataId所注册的所有监听器集合,其中的ManagerListenerWrap对象除了持有Listener监听类,还有一个lastCallMd5字段,这个属性很关键,它是判断服务端数据是否更变的重要条件。
* 在添加监听的同时会将CacheData对象当前最新的md5值赋值给ManagerListenerWrap对象的lastCallMd5属性。
* @param listener listener
*/
public void addListener(Listener listener) {
if (null == listener) {
throw new IllegalArgumentException("listener is null");
}
ManagerListenerWrap wrap =
(listener instanceof AbstractConfigChangeListener) ? new ManagerListenerWrap(listener, md5, content)
: new ManagerListenerWrap(listener, md5);
if (listeners.addIfAbsent(wrap)) {
LOGGER.info("[{}] [add-listener] ok, tenant={}, dataId={}, group={}, cnt={}", name, tenant, dataId, group,
listeners.size());
}
}
3.变更通知
客户端又是如何感知服务端数据已变更呢?
我们还是从头看,NacosConfigService类的构造器中初始化了一个ClientWorker,而在ClientWorker类的构造器中又启动了一个线程池来轮询cacheMap。
@Override
public void startInternal() throws NacosException {
executor.schedule(new Runnable() {
@Override
public void run() {
while (true) {
try {
listenExecutebell.poll(5L, TimeUnit.SECONDS);
executeConfigListen();
} catch (InterruptedException e) {
LOGGER.info("[ rpc listen execute ] Interrupted, stop the config listen task now.");
break;
} catch (Exception e) {
LOGGER.error("[ rpc listen execute ] [rpc listen] exception", e);
}
}
}
}, 0L, TimeUnit.MILLISECONDS);
}
//executeConfigListen() 中调用checkListenerMd5()方法,检查cacheMap中dataId的CacheData对象内,
//MD5字段与注册的监听listener内的lastCallMd5值,不相同表示配置数据变更则触发safeNotifyListener方法,发送数据变更通知。
void checkListenerMd5() {
for (ManagerListenerWrap wrap : listeners) {
if (!md5.equals(wrap.lastCallMd5)) {
safeNotifyListener(dataId, group, content, type, md5, encryptedDataKey, wrap);
}
}
}
//safeNotifyListener()方法单独起线程,向所有对dataId注册过监听的客户端推送变更后的数据内容。
//客户端接收通知,直接实现receiveConfigInfo()方法接收回调数据,处理自身业务就可以了。
服务端源码分析
Nacos配置中心的服务端源码主要在nacos-config项目的ConfigController类
1.处理长轮询
服务端对外提供的监听接口地址/v1/cs/configs/listener,这个方法内容不多,顺着doPollingConfig往下看。
/**
* 客户端侦听配置更改。
*/
@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)) {
LOGGER.warn("invalid probeModify is blank");
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");
}
//
inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());
}
/**
* 轮询接口.
*/
public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,
Map<String, String> clientMd5Map, int probeRequestSize) throws IOException {
// 长轮询 ,服务端根据请求header中的Long-Pulling-Timeout属性来区分请求是长轮询还是短轮询
if (LongPollingService.isSupportLongPolling(request)) {
longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
return HttpServletResponse.SC_OK + "";
}
// else 兼容短轮询逻辑
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);
}
// Disable cache.
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 + "";
}
/**
* Add LongPollingClient.
*
* @param req HttpServletRequest.
* @param rsp HttpServletResponse.
* @param clientMd5Map clientMd5Map.
* @param probeRequestSize probeRequestSize.
*/
public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
int probeRequestSize) {
//客户端提交的请求超时时间:30s
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");
//延迟时间:0.5
int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
// 实际超时时间:29.5,提前500ms返回响应,为避免客户端超时
long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
if (isFixedPolling()) {
timeout = Math.max(10000, getFixedPollingInterval());
// Do nothing but set fix polling timeout.
} else {
long start = System.currentTimeMillis();
//对客户端提交上来的groupkey的MD5与服务端当前的MD5比对,如md5值不同,则说明服务端的配置项发生过变更,直接将该groupkey放入changedGroupKeys集合并返回给客户端。
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);
//这里每个长轮询任务携带了一个asyncContext对象,使得每个请求可以延迟响应,等延时到达或者配置有变更之后,调用asyncContext.complete()响应完成。
// 一定要由HTTP线程调用,否则离开后容器会立即发送响应
//asyncContext 为 Servlet 3.0新增的特性,异步处理,使Servlet线程不再需要一直阻塞,等待业务处理完毕才输出响应;
//可以先释放容器分配给请求的线程与相关资源,减轻系统负担,其响应将被延后,在处理完业务或者运算后再对客户端进行响应。
final AsyncContext asyncContext = req.startAsync();
// AsyncContext.setTimeout()的超时时间不准,所以只能自己控制
asyncContext.setTimeout(0L);
//如未发生变更,则将客户端请求挂起,这个过程先创建一个名为ClientLongPolling的调度任务Runnable,并提交给scheduler定时线程池延后29.5s执行。
ConfigExecutor.executeLongPolling(
new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
}
2.数据变更
管理平台或者客户端更改配置项接位置ConfigController中的publishConfig方法。
@PostMapping(params = "import=true")
@Secured(action = ActionTypes.WRITE, parser = ConfigResourceParser.class)
public RestResult<Map<String, Object>> importAndPublishConfig(HttpServletRequest request,
@RequestParam(value = "src_user", required = false) String srcUser,
@RequestParam(value = "namespace", required = false) String namespace,
@RequestParam(value = "policy", defaultValue = "ABORT") SameConfigPolicy policy, MultipartFile file)
throws NacosException {
Map<String, Object> failedData = new HashMap<>(4);
if (Objects.isNull(file)) {
return RestResultUtils.buildResult(ResultCodeEnum.DATA_EMPTY, failedData);
}
namespace = NamespaceUtil.processNamespaceParameter(namespace);
if (StringUtils.isNotBlank(namespace) && persistService.tenantInfoCountByTenantId(namespace) <= 0) {
failedData.put("succCount", 0);
return RestResultUtils.buildResult(ResultCodeEnum.NAMESPACE_NOT_EXIST, failedData);
}
List<ConfigAllInfo> configInfoList = new ArrayList<>();
List<Map<String, String>> unrecognizedList = new ArrayList<>();
try {
ZipUtils.UnZipResult unziped = ZipUtils.unzip(file.getBytes());
ZipUtils.ZipItem metaDataZipItem = unziped.getMetaDataItem();
if (metaDataZipItem != null && Constants.CONFIG_EXPORT_METADATA_NEW.equals(metaDataZipItem.getItemName())) {
// new export
RestResult<Map<String, Object>> errorResult = parseImportDataV2(unziped, configInfoList,
unrecognizedList, namespace);
if (errorResult != null) {
return errorResult;
}
} else {
RestResult<Map<String, Object>> errorResult = parseImportData(unziped, configInfoList, unrecognizedList,
namespace);
if (errorResult != null) {
return errorResult;
}
}
} catch (IOException e) {
failedData.put("succCount", 0);
LOGGER.error("parsing data failed", e);
return RestResultUtils.buildResult(ResultCodeEnum.PARSING_DATA_FAILED, failedData);
}
if (CollectionUtils.isEmpty(configInfoList)) {
failedData.put("succCount", 0);
return RestResultUtils.buildResult(ResultCodeEnum.DATA_EMPTY, failedData);
}
final String srcIp = RequestUtil.getRemoteIp(request);
String requestIpApp = RequestUtil.getAppName(request);
final Timestamp time = TimeUtils.getCurrentTime();
//数据库修改
Map<String, Object> saveResult = persistService
.batchInsertOrUpdate(configInfoList, srcUser, srcIp, null, time, false, policy);
for (ConfigInfo configInfo : configInfoList) {
//数据变更事件Event
ConfigChangePublisher.notifyConfigChange(
new ConfigDataChangeEvent(false, configInfo.getDataId(), configInfo.getGroup(),
configInfo.getTenant(), time.getTime()));
ConfigTraceService
.logPersistenceEvent(configInfo.getDataId(), configInfo.getGroup(), configInfo.getTenant(),
requestIpApp, time.getTime(), InetUtils.getSelfIP(),
ConfigTraceService.PERSISTENCE_EVENT_PUB, configInfo.getContent());
}
// unrecognizedCount
if (!unrecognizedList.isEmpty()) {
saveResult.put("unrecognizedCount", unrecognizedList.size());
saveResult.put("unrecognizedData", unrecognizedList);
}
return RestResultUtils.success("导入成功", saveResult);
}
长轮询方法中:LongPollingService的构造方法中,正好订阅了数据变更事件,并在事件触发时执行一个数据变更调度任务DataChangeTask
public LongPollingService() {
allSubs = new ConcurrentLinkedQueue<ClientLongPolling>();
ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS);
// 将本地数据更改事件注册到通知中心。
NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize);
// 订阅监听本地数据变更事件
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内的主要逻辑就是遍历allSubs队列,allSubs队列中维护的是所有客户端的长轮询请求任务,
* 从这些任务中找到包含当前发生变更的groupkey的ClientLongPolling任务,以此实现数据更变推送给客户端,并从allSubs队列中剔除此长轮询任务。
*/
final Queue<ClientLongPolling> allSubs;
class DataChangeTask implements Runnable {
@Override
public void run() {
try {
ConfigCacheService.getContentBetaMd5(groupKey);
for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {
ClientLongPolling clientSub = iter.next();
if (clientSub.clientMd5Map.containsKey(groupKey)) {
// 如果发布的标签不在 beta 列表中,则跳过。
if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) {
continue;
}
// 如果已发布的标签不在标签列表中,则跳过。
if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {
continue;
}
getRetainIps().put(clientSub.ip, System.currentTimeMillis());
iter.remove(); // 删除订阅者的关系。
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));
}
}
void sendResponse(List<String> changedGroups) {
// Cancel time out task.
if (null != asyncTimeoutFuture) {
asyncTimeoutFuture.cancel(false);
}
generateResponse(changedGroups);
}
/**
* 客户端响应response时,调用asyncContext.complete()结束了异步请求。
* @param changedGroups
*/
void generateResponse(List<String> changedGroups) {
if (null == changedGroups) {
// 告诉客户端 发送 http 响应。
asyncContext.complete();
return;
}
HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();
try {
final String respString = MD5Util.compareMd5ResultString(changedGroups);
// 禁用缓存。
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-cache,no-store");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println(respString);
asyncContext.complete();
} catch (Exception ex) {
PULL_LOG.error(ex.toString(), ex);
asyncContext.complete();
}
}