一、现象: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恢复";
    }
}

现在你可以把这个项目跑起来,做几个测试:

  1. 启动服务:会从Nacos拉取配置,同时在项目根目录生成nacos-cache.json
  2. 访问/mock/nacos-down模拟Nacos宕机,再访问/config/db-url,会发现还是能拿到配置;
  3. 访问/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配置中心宕机后服务还能正常运行,本质是服务做了本地缓存和容灾的设计,这套设计的核心逻辑是“分层缓存+按需切换”:内存缓存是第一选择,磁盘缓存是备用,配置中心可用时用最新配置,不可用时用本地缓存。 从咱们的示例到生产级方案,这套设计的思路是通用的,核心是平衡可用性和配置一致性:既要保证配置中心挂了服务不挂,也要保证配置更新时服务能及时同步。只要设计的时候注意缓存的隔离、安全、一致性问题,就能把这套设计落地到生产环境,成为服务高可用的一道重要防线。