上个礼拜写了nacos 原理之为什么修改了配置文件后应用端会立刻生效-服务端篇1 之后,今天接着写服务端篇 2 。
接下来让我们看看在服务端修改配置文件的过程。
1. 在修改(新增)配置文件的时候可以通过抓包分析出请求后端的地址是 nacos/v1/cs/configs ,对应着 com.alibaba.nacos.config.server.controller.ConfigController.publishConfig() 方法。
- 我们来看看 publishConfig() 这个方法干了些什么事:
/**
* 增加或更新非聚合数据。
*
* @throws NacosException
*/
@PostMapping
@ToLeader
@Secured(action = ActionTypes.WRITE, parser = ConfigResourceParser.class)
public Boolean publishConfig(HttpServletRequest request, HttpServletResponse response,
@RequestParam("dataId") String dataId, @RequestParam("group") String group,
@RequestParam(value = "tenant", required = false, defaultValue = StringUtils.EMPTY) String tenant,
@RequestParam("content") String content,
@RequestParam(value = "tag", required = false) String tag,
@RequestParam(value = "appName", required = false) String appName,
@RequestParam(value = "src_user", required = false) String srcUser,
@RequestParam(value = "config_tags", required = false) String configTags,
@RequestParam(value = "desc", required = false) String desc,
@RequestParam(value = "use", required = false) String use,
@RequestParam(value = "effect", required = false) String effect,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "schema", required = false) String schema)
throws NacosException {
final String srcIp = RequestUtil.getRemoteIp(request);
String requestIpApp = RequestUtil.getAppName(request);
// check tenant
ParamUtils.checkTenant(tenant);
ParamUtils.checkParam(dataId, group, "datumId", content);
ParamUtils.checkParam(tag);
Map<String, Object> configAdvanceInfo = new HashMap<String, Object>(10);
MapUtils.putIfValNoNull(configAdvanceInfo, "config_tags", configTags);
MapUtils.putIfValNoNull(configAdvanceInfo, "desc", desc);
MapUtils.putIfValNoNull(configAdvanceInfo, "use", use);
MapUtils.putIfValNoNull(configAdvanceInfo, "effect", effect);
MapUtils.putIfValNoNull(configAdvanceInfo, "type", type);
MapUtils.putIfValNoNull(configAdvanceInfo, "schema", schema);
ParamUtils.checkParam(configAdvanceInfo);
if (AggrWhitelist.isAggrDataId(dataId)) {
log.warn("[aggr-conflict] {} attemp to publish single data, {}, {}",
RequestUtil.getRemoteIp(request), dataId, group);
throw new NacosException(NacosException.NO_RIGHT,
"dataId:" + dataId + " is aggr");
}
final Timestamp time = TimeUtils.getCurrentTime();
String betaIps = request.getHeader("betaIps");
ConfigInfo configInfo = new ConfigInfo(dataId, group, tenant, appName, content);
configInfo.setType(type);
if (StringUtils.isBlank(betaIps)) {
if (StringUtils.isBlank(tag)) {
persistService.insertOrUpdate(srcIp, srcUser, configInfo, time,
configAdvanceInfo, true);
}
else {
persistService
.insertOrUpdateTag(configInfo, tag, srcIp, srcUser, time, true);
}
}
else {
// beta publish
persistService
.insertOrUpdateBeta(configInfo, betaIps, srcIp, srcUser, time, true);
}
ConfigTraceService.logPersistenceEvent(dataId, group, tenant, requestIpApp, time.getTime(),
InetUtils.getSelfIp(), ConfigTraceService.PERSISTENCE_EVENT_PUB, content);
return true;
}
我们主要看下
persistService.insertOrUpdate(srcIp, srcUser, configInfo, time, configAdvanceInfo, true);
普通发布的过程。假设用的数据库是 mysql 而不是嵌入式数据库。
/**
* 写入主表,插入或更新
*/
public void insertOrUpdate(String srcIp, String srcUser, ConfigInfo configInfo, Timestamp time, Map<String, Object> configAdvanceInfo, boolean notify) {
try {
addConfigInfo(srcIp, srcUser, configInfo, time, configAdvanceInfo,
notify);
}
catch (DataIntegrityViolationException ive) { // 唯一性约束冲突
updateConfigInfo(configInfo, srcIp, srcUser, time, configAdvanceInfo,
notify);
}
EventDispatcher.fireEvent(
new ConfigDataChangeEvent(false, configInfo.getDataId(),
configInfo.getGroup(), configInfo.getTenant(),
time.getTime()));
}
这个方法做了两件事:
- 尝试添加配置:
- 插入成功。
- 插入失败,违反唯一性约束,则更新数据。
- 转发了一个 ConfigDataChangeEvent 数据变更事件。
我们先看下添加配置。
/**
* 添加普通配置信息,发布数据变更事件
*/
public void addConfigInfo(final String srcIp, final String srcUser,
final ConfigInfo configInfo, final Timestamp time,
final Map<String, Object> configAdvanceInfo, final boolean notify) {
boolean result = tjt.execute(status -> {
try {
long configId = addConfigInfoAtomic(-1, srcIp, srcUser, configInfo, time,
configAdvanceInfo);
String configTags = configAdvanceInfo == null ?
null :
(String) configAdvanceInfo.get("config_tags");
addConfigTagsRelation(configId, configTags, configInfo.getDataId(),
configInfo.getGroup(), configInfo.getTenant());
insertConfigHistoryAtomic(0, configInfo, srcIp, srcUser, time, "I");
if (notify) {
EventDispatcher.fireEvent(
new ConfigDataChangeEvent(false, configInfo.getDataId(),
configInfo.getGroup(), configInfo.getTenant(),
time.getTime()));
}
}
catch (CannotGetJdbcConnectionException e) {
LogUtil.fatalLog.error("[db-error] " + e.toString(), e);
throw e;
}
return Boolean.TRUE;
});
}
其中添加配置方法中又做了 4 件事
- addConfigInfoAtomic() 插入 config_info 记录。
- addConfigTagsRelation() 插入 config_tags_relation 记录。
- insertConfigHistoryAtomic() 插入 his_config_info 历史记录表。为以后恢复配置提供了可能。
- 转发了 ConfigDataChangeEvent 配置数据变更事件。
为啥同一个 ConfigDataChangeEvent 事件要转发两次?见 github issue 应该是重复了。
现在我们看 ConfigDataChangeEvent 这个事件是怎么处理的。
/**
* fire event, notify listeners.
*/
static public void fireEvent(Event event) {
if (null == event) {
throw new IllegalArgumentException("event is null");
}
for (AbstractEventListener listener : getEntry(event.getClass()).listeners) {
try {
listener.onEvent(event);
} catch (Exception e) {
log.error(e.toString(), e);
}
}
}
监听器 listener 会注册关心的事件,根据事件找出其对应的监听器集合 listeners ,然后挨个通知监听器去处理事件。
再看下 onEvent 事件的处理:
@Override
public void onEvent(Event event) {
// 并发产生 ConfigDataChangeEvent
if (event instanceof ConfigDataChangeEvent) {
ConfigDataChangeEvent evt = (ConfigDataChangeEvent) event;
long dumpTs = evt.lastModifiedTs;
String dataId = evt.dataId;
String group = evt.group;
String tenant = evt.tenant;
String tag = evt.tag;
Collection<Member> ipList = memberManager.allMembers();
// 其实这里任何类型队列都可以
Queue<NotifySingleTask> queue = new LinkedList<NotifySingleTask>();
for (Member member : ipList) {
queue.add(new NotifySingleTask(dataId, group, tenant, tag, dumpTs,
member.getAddress(), evt.isBeta));
}
EXECUTOR.execute(new AsyncTask(httpclient, queue));
}
}
nacos server 端是集群的话,由多台机器组成。ipList 就是集群的 ip 集合。在修改配置的时候,是由集群中的某一台机器接收到请求之后,把相关的更新持久到数据库中,然后需要挨个通知集群。
每台机器对应一个异步通知任务 NotifySingleTask ,然后把这些任务放到队列中,然后再把这个队列和 httpclient 封装成一个异步任务 AsyncTask ,由执行器 EXECUTOR 去执行。
接下来让我们再看看 AsyncTask 这个任务的执行逻辑。
class AsyncTask implements Runnable {
public AsyncTask(CloseableHttpAsyncClient httpclient,
Queue<NotifySingleTask> queue) {
this.httpclient = httpclient;
this.queue = queue;
}
@Override
public void run() {
executeAsyncInvoke();
}
private void executeAsyncInvoke() {
while (!queue.isEmpty()) {
NotifySingleTask task = queue.poll();
String targetIp = task.getTargetIP();
if (memberManager.hasMember(targetIp)) {
// 启动健康检查且有不监控的ip则直接把放到通知队列,否则通知
boolean unHealthNeedDelay = memberManager.isUnHealth(targetIp);
if (unHealthNeedDelay) {
// target ip 不健康,则放入通知列表中
ConfigTraceService
.logNotifyEvent(task.getDataId(), task.getGroup(),
task.getTenant(), null, task.getLastModified(),
InetUtils.getSelfIp(),
ConfigTraceService.NOTIFY_EVENT_UNHEALTH, 0,
task.target);
// get delay time and set fail count to the task
asyncTaskExecute(task);
}
else {
HttpGet request = new HttpGet(task.url);
request.setHeader(NotifyService.NOTIFY_HEADER_LAST_MODIFIED,
String.valueOf(task.getLastModified()));
request.setHeader(NotifyService.NOTIFY_HEADER_OP_HANDLE_IP,
InetUtils.getSelfIp());
if (task.isBeta) {
request.setHeader("isBeta", "true");
}
httpclient.execute(request,
new AsyncNotifyCallBack(httpclient, task));
}
}
}
}
private Queue<NotifySingleTask> queue;
private CloseableHttpAsyncClient httpclient;
}
最主要的还是这一行代码,其他的都是些容错性处理,最终还是会执行到这一行代码,即通知其他 server 配置发生了改变。
httpclient.execute(request, new AsyncNotifyCallBack(httpclient, task));
通知的地址是:v1/cs/ops/communication/dataChange
添加配置已经看完了,接下来再看看 ConfigDataChangeEvent 事件。
这个事件在添加配置中已经触发过一次了,这儿又触发一次,我觉得是重复触发了。
2. 现在看看接收配置文件变更通知的处理器方法
v1/cs/ops/communication/dataChange 地址对应的控制器方法是 com.alibaba.nacos.config.server.controller.CommunicationController.notifyConfigInfo() 。
现在来看看 notifyConfigInfo() 方法。
/**
* 通知配置信息改变
*/
@GetMapping("/dataChange")
public Boolean notifyConfigInfo(HttpServletRequest request,
@RequestParam("dataId") String dataId, @RequestParam("group") String group,
@RequestParam(value = "tenant", required = false, defaultValue = StringUtils.EMPTY)
String tenant,
@RequestParam(value = "tag", required = false) String tag) {
dataId = dataId.trim();
group = group.trim();
String lastModified = request.getHeader(NotifyService.NOTIFY_HEADER_LAST_MODIFIED);
long lastModifiedTs = StringUtils.isEmpty(lastModified) ? -1 : Long.parseLong(lastModified);
String handleIp = request.getHeader(NotifyService.NOTIFY_HEADER_OP_HANDLE_IP);
String isBetaStr = request.getHeader("isBeta");
if (StringUtils.isNotBlank(isBetaStr) && trueStr.equals(isBetaStr)) {
dumpService.dump(dataId, group, tenant, lastModifiedTs, handleIp, true);
} else {
dumpService.dump(dataId, group, tenant, tag, lastModifiedTs, handleIp);
}
return true;
}
主要调用了复制服务的复制方法,即 com.alibaba.nacos.config.server.service.dump.DumpService.dump()。现在再继续看这个方法。
public void dump(String dataId, String group, String tenant, long lastModified,
String handleIp, boolean isBeta) {
String groupKey = GroupKey2.getKey(dataId, group, tenant);
dumpTaskMgr.addTask(groupKey,
new DumpTask(groupKey, lastModified, handleIp, isBeta));
}
新建了一个复制任务,然后把这个复制任务添加到复制任务管理器中,即
dumpTaskMgr.addTask(groupKey, new DumpTask(groupKey, lastModified, handleIp, isBeta));
接下来看看这个 TaskManager ,最重要的是 process() 这个方法,有个线程在不断地执行这个方法:
/**
*
*/
protected void process() {
for (Map.Entry<String, AbstractTask> entry : this.tasks.entrySet()) {
AbstractTask task = null;
this.lock.lock();
try {
// 获取任务
task = entry.getValue();
if (null != task) {
if (!task.shouldProcess()) {
// 任务当前不需要被执行,直接跳过
continue;
}
// 先将任务从任务Map中删除
this.tasks.remove(entry.getKey());
MetricsMonitor.getDumpTaskMonitor().set(tasks.size());
}
} finally {
this.lock.unlock();
}
if (null != task) {
// 获取任务处理器
TaskProcessor processor = this.taskProcessors.get(entry.getKey());
if (null == processor) {
// 如果没有根据任务类型设置的处理器,使用默认处理器
processor = this.getDefaultTaskProcessor();
}
if (null != processor) {
boolean result = false;
try {
// 处理任务
result = processor.process(entry.getKey(), task);
} catch (Throwable t) {
log.error("task_fail", "处理task失败", t);
}
if (!result) {
// 任务处理失败,设置最后处理时间
task.setLastProcessTime(System.currentTimeMillis());
// 将任务重新加入到任务Map中
this.addTask(entry.getKey(), task);
}
}
}
}
process() 主要还是在取出任务,然后交给 TaskProcessor processor 去执行任务。再接着看看 DumpProcessor:
class DumpProcessor implements TaskProcessor {
DumpProcessor(DumpService dumpService) {
this.dumpService = dumpService;
}
@Override
public boolean process(String taskType, AbstractTask task) {
final PersistService persistService = dumpService.getPersistService();
DumpTask dumpTask = (DumpTask)task;
String[] pair = GroupKey2.parseKey(dumpTask.groupKey);
String dataId = pair[0];
String group = pair[1];
String tenant = pair[2];
long lastModified = dumpTask.lastModified;
String handleIp = dumpTask.handleIp;
boolean isBeta = dumpTask.isBeta;
String tag = dumpTask.tag;
ConfigDumpEvent.ConfigDumpEventBuilder build = ConfigDumpEvent.builder()
.namespaceId(tenant)
.dataId(dataId)
.group(group)
.isBeta(isBeta)
.tag(tag)
.lastModifiedTs(lastModified)
.handleIp(handleIp);
if (isBeta) {
// beta发布,则dump数据,更新beta缓存
ConfigInfo4Beta cf = persistService.findConfigInfo4Beta(dataId, group, tenant);
build.remove(Objects.isNull(cf));
build.betaIps(Objects.isNull(cf) ? null : cf.getBetaIps());
build.content(Objects.isNull(cf) ? null : cf.getContent());
return DumpConfigHandler.configDump(build.build());
} else {
if (StringUtils.isBlank(tag)) {
ConfigInfo cf = persistService.findConfigInfo(dataId, group, tenant);
build.remove(Objects.isNull(cf));
build.content(Objects.isNull(cf) ? null : cf.getContent());
build.type(Objects.isNull(cf) ? null : cf.getType());
return DumpConfigHandler.configDump(build.build());
} else {
ConfigInfo4Tag cf = persistService.findConfigInfo4Tag(dataId, group, tenant, tag);
build.remove(Objects.isNull(cf));
build.content(Objects.isNull(cf) ? null : cf.getContent());
return DumpConfigHandler.configDump(build.build());
}
}
}
final DumpService dumpService;
}
在这里只看非 beta 、非 tag 发布,最主要的就是取出 task 中的参数,然后由 builder 构建出 ConfigDumpEvent 事件,然后交给 DumpConfigHandler 去复制配置:
DumpConfigHandler.configDump();
最主要的是这一段代码:
result = ConfigCacheService
.dump(dataId, group, namespaceId, content, lastModified, type);
if (result) {
ConfigTraceService
.logDumpEvent(dataId, group, namespaceId, null, lastModified,
event.getHandleIp(), ConfigTraceService.DUMP_EVENT_OK,
System.currentTimeMillis() - lastModified,
content.length());
}
这段代码主要是做了两件事:
- dump 复制内容。
- 触发复制日志事件,记录日志的事件就不看了。
dump 复制:
/**
* 保存配置文件,并缓存md5.
*/
static public boolean dump(String dataId, String group, String tenant, String content, long lastModifiedTs, String type) {
String groupKey = GroupKey2.getKey(dataId, group, tenant);
CacheItem ci = makeSure(groupKey);
ci.setType(type);
final int lockResult = tryWriteLock(groupKey);
assert (lockResult != 0);
if (lockResult < 0) {
dumpLog.warn("[dump-error] write lock failed. {}", groupKey);
return false;
}
try {
final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);
if (md5.equals(ConfigCacheService.getContentMd5(groupKey))) {
dumpLog.warn(
"[dump-ignore] ignore to save cache file. groupKey={}, md5={}, lastModifiedOld={}, "
+ "lastModifiedNew={}",
groupKey, md5, ConfigCacheService.getLastModifiedTs(groupKey), lastModifiedTs);
} else if (!PropertyUtil.isDirectRead()) {
DiskUtil.saveToDisk(dataId, group, tenant, content);
}
updateMd5(groupKey, md5, lastModifiedTs);
return true;
} catch (IOException ioe) {
dumpLog.error("[dump-exception] save disk error. " + groupKey + ", " + ioe.toString(), ioe);
if (ioe.getMessage() != null) {
String errMsg = ioe.getMessage();
if (NO_SPACE_CN.equals(errMsg) || NO_SPACE_EN.equals(errMsg) || errMsg.contains(DISK_QUATA_CN)
|| errMsg.contains(DISK_QUATA_EN)) {
// 磁盘写满保护代码
fatalLog.error("磁盘满自杀退出", ioe);
System.exit(0);
}
}
return false;
} finally {
releaseWriteLock(groupKey);
}
}
如果文件的内容没有改变,则只记录日志后就结束。
如果文件的内容改变了,如果是以集群模式启动的或者用的不是嵌入式数据库则需要把配置文件的内容保存到硬盘。为啥单机模式并且是嵌入式数据库就不需要保存?修改配置文件的时候,先是由一台机器,保存到硬盘的配置会是在啥时候用到呢?是灾备?数据库访问不通的时候才用?在 nacos 群里问了别人确实是这样的,file 内容只有在数据库访问不通的时候才会读文件。单机模式嵌入式数据库是在本机的,不会存在数据库故障的,所以就不需要保存文件了(评论里有人说保存文件是为了缓存配置内容,从数据库中读取大文本比较慢,所以客户端拉取配置文件的时候会从文件中读取,看了下代码确实是这样的,具体见评论)。
然后更新 md5 。看看更新 md5 的时候还做了些啥:
public static void updateMd5(String groupKey, String md5, long lastModifiedTs) {
CacheItem cache = makeSure(groupKey);
if (cache.md5 == null || !cache.md5.equals(md5)) {
cache.md5 = md5;
cache.lastModifiedTs = lastModifiedTs;
EventDispatcher.fireEvent(new LocalDataChangeEvent(groupKey));
}
}
更新完 md5 之后还触发了一个本地数据变更事件 LocalDataChangeEvent 。
再继续看 LocalDataChangeEvent 这个事件是怎么处理的。
最终到了 LongPollingService 的 onEvent 方法这儿了。
@Override
public void onEvent(Event event) {
if (isFixedPolling()) {
// ignore
} else {
if (event instanceof LocalDataChangeEvent) {
LocalDataChangeEvent evt = (LocalDataChangeEvent)event;
scheduler.execute(new DataChangeTask(evt.groupKey, evt.isBeta, evt.betaIps));
}
}
}
最终执行的还是 DataChangeTask 这个任务,看看这个任务是怎么执行的。
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发布且不在beta列表直接跳过
if (isBeta && !betaIps.contains(clientSub.ip)) {
continue;
}
// 如果tag发布且不在tag列表直接跳过
if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {
continue;
}
getRetainIps().put(clientSub.ip, System.currentTimeMillis());
iter.remove(); // 删除订阅关系
LogUtil.clientLog.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.defaultLog.error("data change error:" + t.getMessage(), t.getCause());
}
}
DataChangeTask(String groupKey) {
this(groupKey, false, null);
}
DataChangeTask(String groupKey, boolean isBeta, List<String> betaIps) {
this(groupKey, isBeta, betaIps, null);
}
DataChangeTask(String groupKey, boolean isBeta, List<String> betaIps, String tag) {
this.groupKey = groupKey;
this.isBeta = isBeta;
this.betaIps = betaIps;
this.tag = tag;
}
final String groupKey;
final long changeTime = System.currentTimeMillis();
final boolean isBeta;
final List<String> betaIps;
final String tag;
}
allSubs 是所有订阅了文件变更事件的应用客户端。然后挨个检查,每个应用是不是关心这个文件(由namespace、groupId、dataId 共同决定)是否发生了改变。
如果是 beta 发布,并且订阅的 ip 不在 beta 发布的 ip 集合里,则直接跳过。从这里也可以看出什么是 beta 发布,beta 发布只针对部分 ip 地址的应用。
tag 发布也类似。
然后就是删除客户端的订阅关系,因为是客户端长轮询,客户端在收到服务器的响应之后就会立即再次向服务器端发起请求的,所以可以放心的删。不删除的话,这个集合会越来越大,撑爆了内存。
再接着看是怎么给客户端响应的 sendResponse():
void sendResponse(List<String> changedGroups) {
/**
* 取消超时任务
*/
if (null != asyncTimeoutFuture) {
asyncTimeoutFuture.cancel(false);
}
generateResponse(changedGroups);
}
如果有了异步超时任务,就把这个任务取消,可以看一下 nacos 原理之为什么修改了配置文件后应用端会立刻生效-服务端篇1 篇一。
异步任务主要目的是为了保持连接,又不用让客户端频繁的访问服务器,超时时间到了之后会给客户端返回你关心的文件没有改变的消息。
现在文件发生了变化,应该立即给客户端应用返回,告诉它,你关心的那些文件发生了变化,你来拉取吧。
generateResponse() 方法:
void generateResponse(List<String> changedGroups) {
if (null == changedGroups) {
/**
* 告诉容器发送HTTP响应
*/
asyncContext.complete();
return;
}
HttpServletResponse response = (HttpServletResponse)asyncContext.getResponse();
try {
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 se) {
pullLog.error(se.toString(), se);
asyncContext.complete();
}
}
主要返回了 namespace(可选)、groupId、dataId 拼起来的字符串。这三者就能代表一个文件。告诉客户端应用他关心的文件发生了变化,然后客户端应用会根据 namespace、groupId、dataId 这三者主动来服务器端拉取文件的内容。
至此,服务器端的两篇分析都讲完了。涉及到的代码挺多的,我挑重点的讲了讲,对原理感兴趣的同学可以照着我的提示单步调试来彻底的理解。
为了帮助大家理解,最后再画个图表总结一下。准备画个图,发现还挺不好画的,先把文章发布了,等下周写 nacos 客户端的时候再一起画吧。