一、现象:Nacos宕机后服务还能跑?到底是为什么?
很多做微服务开发的同学都遇到过这么个事儿:线上负责存配置的Nacos突然连不上了,要么是集群节点全挂,要么是网络断了连不上,按说配置中心都没了,服务应该拿不到配置、直接挂掉才对,但偏偏很多服务还能正常跑,甚至跑好几天都没出问题。 这不是Nacos有什么“隐藏外挂”,而是服务本身做了本地缓存和容灾的设计——相当于给服务配了个“备用粮”,主粮(Nacos配置)断了,先吃备用粮撑着,等主粮恢复了再换回来。接下来咱们就拆明白这背后的逻辑,还会给你看可运行的代码示例。
二、核心逻辑拆解:本地缓存怎么存?容灾怎么切?
要搞懂这个现象,得先搞清楚服务和Nacos交互的完整流程,以及本地缓存的设计逻辑。
2.1 服务和Nacos的正常交互流程
正常情况下,服务启动时会做这么几件事: 第一,主动去Nacos拉取自己需要的所有配置,比如数据库地址、接口超时时间、功能开关这些; 第二,把拉到的配置同步到自己的内存里,用的时候直接读内存,不用每次都找Nacos; 第三,和Nacos建立长连接(或者定时轮询),监听配置有没有更新——如果Nacos上的配置改了,服务能实时感知到,然后更新自己内存里的配置。 这个流程看起来没问题,但有个致命漏洞:如果Nacos挂了,服务既拉不到新配置,也感知不到配置更新,要是连本地存的配置都没有,那服务直接就没配置可用了。所以本地缓存就是补这个漏洞的。
2.2 本地缓存的设计:不止内存,还要落盘
很多同学以为本地缓存只是存在内存里,其实靠谱的本地缓存会做两层: 第一层是内存缓存:就是服务运行时存在JVM内存(或者对应语言的内存空间)里的配置,这是服务用配置的“第一选择”,速度最快; 第二层是磁盘缓存:服务拉到Nacos的配置后,会同时把配置存到本地磁盘的一个文件里,这个文件就是“备份配置库”。 为什么要存磁盘?因为如果服务重启了,内存里的配置会丢,但磁盘里的配置还在——这时候服务启动时先读磁盘缓存,再去Nacos拉新配置,就不会出现启动时找不到配置的问题。
2.3 容灾切换的触发逻辑:什么时候用本地缓存?
容灾切换不是随便切的,得有明确的触发条件,不然会乱套。常见的触发条件有三个: 第一,服务启动时,Nacos连不上:这时候服务直接读本地磁盘缓存的配置来启动; 第二,服务运行时,Nacos断连:这时候服务继续用内存里的配置,同时会尝试重连Nacos,要是重连几次都失败,就标记Nacos为不可用,持续用本地配置; 第三,Nacos配置拉取失败:比如拉取配置时超时、返回错误,服务也会自动切到本地缓存。
三、代码示例:自己实现一套简化的Nacos本地缓存与容灾逻辑
咱们用Java(Spring Boot)来做一个完整的示例,因为大部分微服务开发都用这个技术栈,代码也容易理解。
3.1 示例技术栈说明
本次示例统一使用:Spring Boot 2.7.0、Java 11、Jackson(用于配置序列化)、Lombok(简化代码)。
3.2 第一步:配置类与缓存存储
首先定义一个配置类,用来存从Nacos拉到的配置,同时实现本地缓存的读写逻辑。
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.IOException;
@Data
@Slf4j
public class ServiceConfig {
// 模拟从Nacos拉到的核心配置:数据库地址、接口超时时间、功能开关
private String dbUrl;
private int timeout;
private boolean enableFeature;
// 本地缓存文件的路径:存在项目根目录的nacos-cache.json
private static final String CACHE_FILE_PATH = "nacos-cache.json";
// 序列化工具,把配置转成JSON存到磁盘
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 把当前配置写入本地磁盘缓存
*/
public void writeToCache() {
try {
objectMapper.writeValue(new File(CACHE_FILE_PATH), this);
log.info("配置成功写入本地缓存:{}", CACHE_FILE_PATH);
} catch (IOException e) {
log.error("写入本地缓存失败", e);
}
}
/**
* 从本地磁盘缓存读取配置
* @return 读取到的配置,读失败返回null
*/
public static ServiceConfig readFromCache() {
File cacheFile = new File(CACHE_FILE_PATH);
// 先判断缓存文件是否存在
if (!cacheFile.exists()) {
log.info("本地缓存文件不存在,无法读取");
return null;
}
try {
ServiceConfig config = objectMapper.readValue(cacheFile, ServiceConfig.class);
log.info("成功从本地缓存读取配置:{}", config);
return config;
} catch (IOException e) {
log.error("读取本地缓存失败", e);
return null;
}
}
}
这段代码的逻辑很简单:定义了服务需要的配置项,然后提供了两个方法,一个把配置写成JSON存到本地,一个从本地读配置转成对象。
3.3 第二步:模拟Nacos客户端与容灾逻辑
接下来做一个模拟的Nacos客户端,实现拉取配置、监听更新、容灾切换的逻辑。
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class NacosClient {
// 内存缓存:服务运行时用的配置,默认是null
private ServiceConfig inMemoryConfig;
// 标记Nacos是否可用:初始为true
private boolean nacosAvailable = true;
/**
* 服务启动时的初始化逻辑
*/
public void init() {
log.info("服务启动,开始初始化配置...");
// 第一步:先尝试从Nacos拉取配置
ServiceConfig nacosConfig = pullConfigFromNacos();
if (nacosConfig != null) {
// 拉取成功:更新内存配置,同时写入本地缓存
inMemoryConfig = nacosConfig;
inMemoryConfig.writeToCache();
log.info("从Nacos拉取配置成功,初始化完成");
} else {
// 拉取失败:从本地缓存读配置
ServiceConfig cacheConfig = ServiceConfig.readFromCache();
if (cacheConfig != null) {
inMemoryConfig = cacheConfig;
nacosAvailable = false;
log.warn("从Nacos拉取配置失败,使用本地缓存配置启动");
} else {
// 连缓存都没有:直接抛出异常,服务启动失败
throw new RuntimeException("初始化配置失败:Nacos不可用且无本地缓存");
}
}
}
/**
* 模拟从Nacos拉取配置:这里可以改成真实的Nacos SDK调用
* 模拟场景:如果Nacos不可用,返回null
*/
private ServiceConfig pullConfigFromNacos() {
// 模拟Nacos正常的情况:返回一个配置
if (nacosAvailable) {
ServiceConfig config = new ServiceConfig();
config.setDbUrl("jdbc:mysql://nacos-db:3306/service_db");
config.setTimeout(3000);
config.setEnableFeature(true);
return config;
}
// 模拟Nacos不可用的情况:返回null
return null;
}
/**
* 定时任务:每5秒尝试重连Nacos,拉取配置
*/
@Scheduled(fixedRate = 5000)
public void syncConfig() {
if (!nacosAvailable) {
log.info("Nacos不可用,尝试重连拉取配置...");
ServiceConfig newConfig = pullConfigFromNacos();
if (newConfig != null) {
// 重连成功:更新内存配置、写入本地缓存,标记Nacos为可用
inMemoryConfig = newConfig;
inMemoryConfig.writeToCache();
nacosAvailable = true;
log.info("Nacos重连成功,配置已更新");
}
}
}
/**
* 对外提供获取配置的方法:服务用配置时都调用这个方法
*/
public ServiceConfig getConfig() {
if (inMemoryConfig == null) {
throw new RuntimeException("配置未初始化");
}
return inMemoryConfig;
}
/**
* 模拟Nacos宕机:测试用,调用这个方法会把nacosAvailable设为false
*/
public void mockNacosDown() {
nacosAvailable = false;
log.warn("模拟Nacos宕机,Nacos状态设为不可用");
}
/**
* 模拟Nacos恢复:测试用,调用这个方法会把nacosAvailable设为true
*/
public void mockNacosUp() {
nacosAvailable = true;
log.info("模拟Nacos恢复,Nacos状态设为可用");
}
}
这段代码是核心:
- 服务启动时先拉Nacos配置,拉不到就用本地缓存,连缓存都没有就启动失败;
- 定时任务每5秒检查Nacos状态,不可用就尝试重连,重连成功就更新配置;
- 对外的
getConfig()方法,服务用配置时直接调用,不用管配置是从哪来的。
3.4 第三步:服务调用配置的示例
最后写一个控制器,模拟服务用配置的场景,比如获取数据库地址、判断功能开关。
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class TestController {
// 注入NacosClient,用来获取配置
private final NacosClient nacosClient;
/**
* 测试用接口:获取当前配置的数据库地址
*/
@GetMapping("/config/db-url")
public String getDbUrl() {
ServiceConfig config = nacosClient.getConfig();
return "当前数据库地址:" + config.getDbUrl();
}
/**
* 测试用接口:判断功能开关是否开启
*/
@GetMapping("/config/feature")
public String getFeatureStatus() {
ServiceConfig config = nacosClient.getConfig();
return "功能开关状态:" + (config.isEnableFeature() ? "开启" : "关闭");
}
/**
* 测试用接口:模拟Nacos宕机
*/
@GetMapping("/mock/nacos-down")
public String mockNacosDown() {
nacosClient.mockNacosDown();
return "已模拟Nacos宕机";
}
/**
* 测试用接口:模拟Nacos恢复
*/
@GetMapping("/mock/nacos-up")
public String mockNacosUp() {
nacosClient.mockNacosUp();
return "已模拟Nacos恢复";
}
}
现在你可以把这个项目跑起来,做几个测试:
- 启动服务:会从Nacos拉取配置,同时在项目根目录生成
nacos-cache.json; - 访问
/mock/nacos-down模拟Nacos宕机,再访问/config/db-url,会发现还是能拿到配置; - 访问
/mock/nacos-up模拟Nacos恢复,等5秒后再访问配置接口,会拿到新的配置(如果拉取的配置有变化的话)。
四、设计思路的延伸:从简化版到生产级方案
咱们上面的示例是简化版,生产环境的Nacos本地缓存和容灾设计会更复杂,核心要考虑这几个点:
4.1 缓存的一致性问题
生产环境的配置可能有很多,而且不同服务的配置是隔离的,所以本地缓存的文件要做“服务隔离”——比如每个服务的缓存文件路径不一样,或者文件名里加服务名,避免多个服务的缓存互相覆盖。 另外,配置更新时要保证“原子性”:不能更新到一半就断了,导致内存里的配置和本地缓存的配置不一致。所以可以用“临时文件”的方式:先把新配置写到临时文件,写成功后再替换原来的缓存文件,避免缓存损坏。
4.2 容灾切换的“降级策略”
如果Nacos长时间不可用,本地缓存的配置可能已经过期了,这时候不能一直用旧配置,得有降级策略。比如:
- 对于“功能开关”这种配置,Nacos不可用时默认设为“关闭”,避免有风险的功能上线;
- 对于“数据库地址”这种核心配置,要是本地缓存里的地址也失效了,服务不能硬扛,得主动报错,避免造成更大的损失。
4.3 多环境的缓存隔离
很多公司会有开发、测试、生产三个环境,每个环境的Nacos配置不一样,所以本地缓存也要做环境隔离——比如缓存文件名里加环境标识,或者把缓存文件存在对应环境的目录里,避免开发环境的缓存覆盖生产环境的缓存。
五、相关技术的补充说明
和本地缓存、容灾相关的技术还有两个,得给大家说清楚:
5.1 配置加密
生产环境的配置里会有密码、密钥这种敏感信息,不能直接存在本地缓存里,不然会有安全风险。所以一般会对配置做加密:拉到Nacos的配置后,先解密再用,存到本地缓存时存加密后的内容,读的时候再解密。 比如可以用对称加密算法AES,给敏感字段加密,这样就算缓存文件被拿到,也看不到明文的敏感信息。
5.2 配置的版本管理
为了避免本地缓存的配置太旧,还可以给每个配置加版本号:Nacos上的配置更新时,版本号加1,服务拉取配置时会对比版本号,只有版本号比本地缓存的高,才会更新配置。这样就算Nacos恢复了,也不会随便把旧配置同步过来。
六、应用场景、优缺点与注意事项
6.1 应用场景
这套设计的适用场景非常广,只要是依赖配置中心的服务都能用,比如:
- 微服务架构下的业务服务,依赖Nacos/Apollo等配置中心;
- 部署在云服务器上的服务,网络波动比较频繁的场景;
- 边缘计算场景,边缘节点和中心配置中心的网络不稳定,甚至经常断连;
- 对可用性要求高的核心服务,比如支付服务、订单服务,不能因为配置中心挂了就不可用。
6.2 技术优缺点
优点很明显: 第一,提高服务的可用性:配置中心挂了,服务还能正常运行,不会出现单点故障; 第二,减少配置中心的压力:服务大部分时间用本地缓存的配置,不用每次都找配置中心; 第三,服务启动更快:如果配置中心连不上,不用等超时,直接用本地缓存启动,节省启动时间。 缺点也得知道: 第一,有配置不一致的风险:如果Nacos的配置更新了,但服务因为断连没同步到,会导致服务的配置和其他服务的配置不一致; 第二,本地缓存的安全风险:如果缓存文件被篡改,会导致服务用错误的配置运行; 第三,缓存过期的问题:如果本地缓存的配置太久没更新,可能已经不适用新的业务逻辑了。
6.3 注意事项
用这套设计的时候,有几个点必须注意: 第一,不能把本地缓存当成“永久配置”:本地缓存只是容灾用的,不能替代配置中心,配置的管理还是要统一在配置中心; 第二,敏感配置必须加密:本地缓存里不能存明文的密码、密钥,避免安全风险; 第三,要加缓存清理机制:如果服务长时间连不上配置中心,本地缓存的配置不能一直用,得主动触发服务降级,或者提示运维人员处理; 第四,要做缓存的校验:读取本地缓存时,要校验缓存的完整性、合法性,避免用损坏的或者非法的配置。
七、文章总结
Nacos配置中心宕机后服务还能正常运行,本质是服务做了本地缓存和容灾的设计,这套设计的核心逻辑是“分层缓存+按需切换”:内存缓存是第一选择,磁盘缓存是备用,配置中心可用时用最新配置,不可用时用本地缓存。 从咱们的示例到生产级方案,这套设计的思路是通用的,核心是平衡可用性和配置一致性:既要保证配置中心挂了服务不挂,也要保证配置更新时服务能及时同步。只要设计的时候注意缓存的隔离、安全、一致性问题,就能把这套设计落地到生产环境,成为服务高可用的一道重要防线。
评论
围绕“Nacos配置中心宕机后服务并没有挂掉,深入本地缓存文件加载与容灾切换机制,剖析高可用保障的底线设计方案思路”参与讨论