Nacos 配置中心原理

候选人小孙在面试某互联网公司时,面试官看了他的项目经验"负责配置中心迁移",问道:

"Nacos 配置变更的时候,是推送还是拉取?"

小孙说:"推送的吧,我记得有个 watcher。"

面试官追问:"那为什么还有长轮询?"

小孙说:"呃...我不太确定..."

面试官追问:"Nacos 的命名空间和分组有什么区别?你们怎么隔离不同环境的配置?"

小孙支支吾吾。

【面试官心理】 这道题我用来区分"用过"和"理解过"Nacos 的候选人。Nacos 的长轮询机制是面试高频点,很多候选人说"推送"但说不出"服务端推送的是通知还是数据"。能讲清楚"MD5 比对 + 长轮询"组合拳的,基本都有源码阅读经验。

一、Nacos 配置核心模型 🔴

1.1 三元组:dataId + group + namespace

// Nacos 配置的定位通过三个维度确定:
// 1. dataId:配置文件的名称(相当于文件名)
// 2. group:配置分组(默认 DEFAULT_GROUP,相当于文件夹)
// 3. namespace:命名空间(相当于顶级目录)

// 三者的层级关系:
// namespace > group > dataId

// 典型场景:
// namespace: dev / test / prod
// group: order-service / user-service / payment-service
// dataId: application.yaml / db.properties / redis.properties

// 获取配置:
ConfigService configService = NacosFactory.createConfigService(serverAddr);
String content = configService.getConfig(dataId, group, timeout);

// 示例:
String dbConfig = configService.getConfig(
    "db.properties",      // dataId
    "order-service",      // group
    3000                   // timeout
);

// 等价于:
// namespace=prod 的 prod/order-service/db.properties

1.2 命名空间隔离:不同环境

// 命名空间用于隔离不同环境
// dev / test / uat / prod

// 客户端配置:
// application.yml
spring:
  cloud:
    nacos:
      config:
        namespace: prod           # 指定命名空间
        group: DEFAULT_GROUP      # 指定分组
        data-id: application.yaml # dataId(支持逗号分隔配置多个)
        file-extension: yaml       # 配置文件格式

// 或者通过 JVM 参数:
// -D nacos.config.namespace=prod
⚠️

命名空间隔离是环境隔离的关键。如果 namespace 配置错误,本地开发环境可能读到测试环境的配置,或者测试环境读到生产环境的配置。生产事故中,环境配置混淆是最常见的翻车原因之一。

1.3 分组隔离:不同服务或模块

// 分组用于在同一个 namespace 下,进一步隔离配置
// 默认分组:DEFAULT_GROUP
// 自定义分组:按业务线、按服务名、按环境维度

// 场景:同一个 namespace 下,有多个微服务的配置
// namespace = prod
// group = order-service      -> order 服务的配置
// group = user-service       -> user 服务的配置
// group = payment-service    -> payment 服务的配置
// dataId = jdbc.yaml         -> 3 份 jdbc.yaml,分别属于3个分组

// 获取特定分组的配置:
ConfigService service = NacosFactory.createConfigService("localhost:8848");
String jdbcConfig = service.getConfig("jdbc.yaml", "order-service", 3000);
// prod/order-service/jdbc.yaml

1.4 共享配置与扩展配置

// 场景:多个微服务需要共享同一份配置(比如数据库连接池配置)
// 但又想保持各自的服务级配置

// 共享配置(shared-dataids):所有服务共享
// 扩展配置(ext-config):按需引入

spring:
  cloud:
    nacos:
      config:
        # 共享配置列表(按声明顺序,后面的覆盖前面的)
        shared-dataids: |
          jdbc.properties
          redis.properties
        # 共享配置可以自动刷新
        refreshable-dataids: |
          jdbc.properties

        # 扩展配置
        ext-config:
          - data-id: db.properties
            group: COMMON_GROUP
            refresh: true

二、长轮询推送机制 🔴

2.1 为什么需要长轮询

// 短轮询(Short Polling):客户端每隔几秒轮询一次
// 问题:延迟高(最多等待一个轮询周期)、服务端压力大

// 长连接推送(Push):服务端维护每个客户端的长连接
// 问题:Nacos 是无状态服务,维护百万级长连接成本高
//       需要引入 WebSocket 网关,架构复杂度增加

// Nacos 的方案:HTTP 长轮询(Long Polling)
// 核心思路:
// 1. 客户端发起 HTTP 长轮询请求,指定超时时间(默认 30 秒)
// 2. 服务端收到请求后,检查配置是否变化
//    - 如果没变化:hold 住请求,直到超时或配置变化
//    - 如果有变化:立即返回变更通知
// 3. 客户端收到返回后,立即发起下一次长轮询
// 4. 整个过程循环往复,实现"准推送"体验

2.2 客户端长轮询源码

// Nacos 客户端的核心轮询逻辑
// 类名:NacosContextRefresher 或 ConfigHttpServlet

public class LongPolling {

    private ExecutorService executor = Executors.newSingleThreadExecutor();

    public void startLongPolling() {
        executor.submit(() -> {
            while (true) {
                doLongPolling();
            }
        });
    }

    private void doLongPolling() {
        // 关键参数:
        // - longPollTimeout: 30000 毫秒(30秒)
        // - taskExecutor: 异步执行,不阻塞

        // 发起长轮询请求
        // GET /v1/cs/configs?dataId=xxx&group=xxx&contentMD5=xxx&timeout=30000

        // MD5 比对:如果服务端配置的 MD5 和客户端传入的 MD5 相同
        // 说明配置没变化,服务端会 hold 30 秒后返回空

        // 如果 MD5 不同,说明配置变化了,服务端立即返回变更通知
    }
}

// 客户端收到变更通知后的处理:
// 1. 重新拉取最新配置
// 2. 更新本地缓存的 MD5
// 3. 触发所有监听器的回调
// 4. 立即发起下一次长轮询(不等上一个请求的响应)

2.3 服务端的 MD5 比对与变更检测

// Nacos 服务端的关键逻辑
// 类名:ConfigController 或 ConfigServletInner

public class ConfigController {

    // 长轮询接口
    @GetMapping("/listener")
    public String listener(
            @RequestParam("dataId") String dataId,
            @RequestParam("group") String group,
            @RequestParam("contentMD5") String contentMD5,
            @RequestParam("timeout") long timeout) {

        // 1. 获取配置的当前 MD5
        String currentMD5 = getConfigMD5(dataId, group);

        // 2. MD5 相同 -> 配置没变,hold 住请求
        if (currentMD5.equals(contentMD5)) {
            // 等待配置变更或超时
            // 使用 CompletableFuture 实现异步 wait
            // 默认 wait 29 秒,留 1 秒给响应
            return waitForConfigChange(dataId, group, timeout - 1000);
        }

        // 3. MD5 不同 -> 配置变了,立即返回变更通知
        // 返回值:变更的 dataId 和 group
        return formatChangeResult(dataId, group);
    }
}

// 变更通知的返回格式:
// [{"dataId":"db.properties","group":"order-service","changeInfo":"..."}]
// 客户端收到后,解析返回,立即拉取最新配置

2.4 MD5 比对的精妙设计

// MD5 比对的好处:
// 1. 节省带宽:配置没变化时,服务端只需返回一个固定字符串(不是完整配置)
// 2. 精确判断:即使配置值变了但大小没变,也能检测到
// 3. 性能优化:服务端不需要比对每个配置项的详细内容

// 典型流程:
// 1. 客户端首次启动:拉取完整配置,记录 MD5
// 2. 后续轮询:传入上次获取的 MD5
// 3. 服务端比对:
//    - MD5 相同 -> 返回空(无变化)
//    - MD5 不同 -> 返回变更通知
// 4. 客户端收到通知 -> 立即拉取最新配置 -> 更新本地 MD5
💡

Nacos 的长轮询设计非常精妙:服务端不需要主动推送数据,也不需要维护大量长连接。只要HTTP请求能被hold住,就能实现秒级的变更通知。MD5比对让服务端只需返回"是否有变化"这个布尔值,而不是完整的配置内容。

三、配置监听机制 🔴

3.1 Listener 接口

// Nacos 提供了配置监听接口
// 当配置变更时,自动触发监听器回调

// 方式1:直接使用 Listener 接口
ConfigService service = NacosFactory.createConfigService(serverAddr);
service.addListener("db.properties", "order-service", new Listener() {

    @Override
    public void receiveConfigInfo(String configInfo) {
        // 配置变更时回调
        // configInfo 是最新的配置内容
        System.out.println("配置变更: " + configInfo);

        // 重新加载配置
        reloadDatabaseConfig(configInfo);
    }

    @Override
    public Executor getExecutor() {
        // 回调使用的线程池(可选)
        return null; // 使用默认线程池
    }
});

// 方式2:Spring Cloud 集成(Nacos Starter 自动处理)
// 只需在 application.yml 配置,无需手动写监听代码
// Spring Boot 会自动创建 ConfigService 和 Listener
// 配置变更时自动刷新 @Value 注入的字段

3.2 监听器的工作原理

// 监听器的内部实现:
// 1. 客户端在本地维护一个 listenerMap
// 2. listenerMap 的结构:Map<dataId + group, List<Listener>>
// 3. 长轮询收到变更通知后,遍历对应的 Listener 列表
// 4. 逐个调用 receiveConfigInfo() 方法

// 关键代码(简化版):
public class ConfigServiceImpl implements ConfigService {

    // 本地监听器注册表
    private final Map<String, CopyOnWriteArrayList<Listener>> listenerMap =
        new ConcurrentHashMap<>();

    @Override
    public void addListener(String dataId, String group, Listener listener) {
        String key = dataId + "+" + group;
        listenerMap.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>())
            .add(listener);
    }

    // 长轮询线程收到变更通知
    private void onConfigChanged(String dataId, String group, String configInfo) {
        String key = dataId + "+" + group;
        CopyOnWriteArrayList<Listener> listeners = listenerMap.get(key);

        if (listeners != null) {
            for (Listener listener : listeners) {
                // 在独立线程池中执行,不阻塞长轮询线程
                executor.execute(() ->
                    listener.receiveConfigInfo(configInfo)
                );
            }
        }
    }
}

3.3 Spring Boot 集成示例

// application.yml
spring:
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        namespace: dev
        group: DEFAULT_GROUP
        file-extension: yaml
        refresh-enabled: true  # 开启自动刷新

// 方式1:@Value 注入(需要加 @RefreshScope)
@RefreshScope
@RestController
public class OrderController {

    @Value("${order.max-retry:3}")  // 支持默认值
    private int maxRetry;

    @GetMapping("/order/retry")
    public int getMaxRetry() {
        return maxRetry; // Nacos 配置变更后,这个值会自动刷新
    }
}

// 方式2:@ConfigurationProperties(自动刷新)
@Data
@Component
@ConfigurationProperties(prefix = "order")
public class OrderProperties {
    private int maxRetry;
    private int timeout;
    // Nacos 配置变更后,Bean 的属性会自动更新
}

// 方式3:手动刷新(复杂的初始化逻辑)
@RestController
public class ManualRefreshController {

    @NacosConfigurationListener
    public void onConfigChange(NacosConfigEvent event) {
        if ("db.properties".equals(event.getDataId())) {
            // 重新初始化数据库连接池
            reinitDataSource(event.getContent());
        }
    }
}
⚠️

@RefreshScope 虽然能自动刷新配置,但它本身有代价:每次刷新都会创建新的 Bean 实例。如果 Bean 中有昂贵的初始化逻辑(比如建立连接池),频繁的配置刷新反而会导致性能问题。建议对配置变化做过滤,只处理真正需要变化的配置。

四、多配置文件管理 🟡

4.1 为什么需要多配置文件

// 微服务的配置通常分散在多个文件中:
// 1. application.yaml:应用主配置
// 2. db.properties:数据库配置
// 3. redis.properties:缓存配置
// 4. rpc.yaml:RPC 调用配置
// 5. feature-flags.yaml:功能开关

// Nacos 的优势:
// 1. 多个配置文件可以归到不同的 group
// 2. 不同团队维护各自负责的配置
// 3. 配置变更时可以精确控制影响范围

4.2 配置优先级

// Nacos 配置的优先级(高到低):
// 1. 通过 ext-config[-n] 引入的配置(后声明的优先)
// 2. shared-dataids 中的共享配置
// 3. 主配置 data-id(application.yaml)

// 完整优先级(由高到低):
// 1. 通过 JVM 参数传入 -D spring.cloud.nacos.configOverride=xxx
// 2. 通过 bootstrap.yml 扩展配置(ext-config[-n])
// 3. 共享配置(shared-dataids)
// 4. 主配置(主 dataId)
// 5. 本地配置文件(本地优先级最低)

// 实际例子:
// spring.cloud.nacos.config.data-id: application.yaml
// spring.cloud.nacos.config.ext-config[0].data-id: db.properties
// spring.cloud.nacos.config.ext-config[0].group: DB_GROUP
// spring.cloud.nacos.config.ext-config[1].data-id: redis.properties
// spring.cloud.nacos.config.ext-config[1].group: CACHE_GROUP

// 最终生效的配置 = application.yaml 覆盖 db.properties 覆盖 redis.properties

五、Nacos vs Apollo vs Spring Cloud Config 🟡

维度NacosApolloSpring Cloud Config
推送机制HTTP 长轮询长轮询 + 消息队列Git Webhook + Spring Cloud Bus
架构复杂度低(单进程)高(4 个模块)中(需 Git + Eureka)
多环境命名空间隔离多环境独立部署profiles 隔离
灰度发布支持(权重灰度)支持(规则灰度)不支持
管理界面自带 Web UI自带 Web UI
多语言支持(HTTP 接口)支持(HTTP 接口)差(依赖 Spring)
高可用内置(无中心化)需要 MySQL HA依赖 Git 和 Eureka
配置监听原生 Listener原生 ListenerSpring 事件机制
适用场景中小型微服务大型/金融级Spring Cloud 生态

【面试官心理】 问到三大配置中心对比的候选人,说明他有技术选型的经验。我会追问:"如果你们的微服务有 100 个节点,Nacos 的长轮询会不会有问题?"能说出"需要部署多个 Nacos 节点,用 Nginx 做负载均衡"的,说明他有生产运维经验。

六、生产避坑指南 🔴

❌ 翻车点一:长轮询超时导致配置丢失

// ❌ 问题:Nacos 服务端压力大,长轮询超时
// 表现:配置变更了,但部分客户端过了很久才收到通知(甚至收不到)

// ✅ 解决方案:
// 1. 服务端增加节点,水平扩展
// 2. 客户端设置合理的超时时间
spring.cloud.nacos.config.timeout = 5000  # 5秒超时

// 3. 客户端增加重试机制
spring.cloud.nacos.config.refresh.retry = 3

❌ 翻车点二:@RefreshScope 导致 Bean 重复创建

// ❌ 问题:加了 @RefreshScope 后,每次配置变更都创建新的 Bean
// 导致原有连接未关闭,新连接又被创建
// 内存泄漏 + 连接池耗尽

@RefreshScope
@Component
public class DataSourceConfig {

    @Value("${db.url}")
    private String url;

    @Bean
    public DataSource dataSource() {
        return DruidDataSourceFactory.createDataSource(properties);
        // 每次配置变更,都会创建新的 DataSource
        // 旧的 DataSource 如果没有 close(),就会内存泄漏
    }
}

// ✅ 正确做法:不要在 @RefreshScope Bean 中创建资源
// 应该使用 @NacosConfigurationListener 手动处理
// 或者使用 SmartInitializingSingleton 在应用启动时初始化资源

❌ 翻车点三:namespace 配置错误导致环境混淆

// ❌ 危险:本地开发时,为了调试方便,把 namespace 配成了 prod
// 导致本地读取了生产配置
// 如果改了配置,会影响生产环境!

// ✅ 正确做法:
// 1. 不同环境的 namespace UUID 一定要记录在文档中
// 2. 本地开发用 dev namespace
// 3. 生产 namespace 配置要严格控制访问权限

// 获取 namespace ID 的方式:
// 在 Nacos 控制台 -> 命名空间 -> 查看 namespace ID(不是 namespace 名称)
// namespace ID 是 UUID,不是可读名称

七、面试追问链 🟡

追问一:Nacos 长轮询和 Spring Cloud OpenFeign 有什么关系?

【面试官心理】 问这个问题的面试官,通常想确认候选人对 Spring Cloud 生态的熟悉程度。

Nacos 的配置监听底层使用 HTTP 长轮询,不依赖 OpenFeign。但在 Spring Cloud 生态中,Nacos 和 OpenFeign 是两个独立的组件:

  • Nacos:提供服务发现和配置管理
  • OpenFeign:提供声明式 HTTP 客户端
  • 两者可以组合使用(Nacos 注册服务 + OpenFeign 调用服务)

追问二:Nacos 的 CP 和 AP 怎么选?

【面试官心理】 问这个问题的面试官,通常想确认候选人对分布式一致性理论的理解。Nacos 既支持 CP(一致性 + 分区容错)也支持 AP(可用性 + 分区容错),通过 nacos.distro.confignacos.distro.distro 配置切换。

// Nacos 的 CP/AP 切换:
// 1. 配置中心默认是 AP(保证可用性)
//    - 使用 Distro 协议,异步同步数据
//    - 节点间数据最终一致,但不保证强一致

// 2. 注册中心可以切换为 CP
//    - 使用 Raft 协议,强一致
//    - 注册信息在所有节点完全一致
//    - 但节点故障时,部分服务注册可能失败

// 生产建议:
// - 注册中心:用 CP(服务发现必须准确)
// - 配置中心:用 AP(配置可稍有延迟,但服务不能断)