Spring Cloud Config vs Nacos:配置中心的迁移实战与双读过渡方案
Spring Cloud Config vs Nacos:配置中心的迁移实战与双读过渡方案
适读人群:正在使用或计划迁移配置中心的后端工程师 | 阅读时长:约24分钟 | Spring Boot 3.2 / Spring Cloud 2023.0
开篇故事
我们公司有个老项目,最早用的是Spring Cloud Config,Git仓库存配置,Config Server负责分发。这套方案用了三四年,整体是能跑,但痛点越来越明显:Config Server高可用要自己搭,Git仓库的访问权限管理麻烦,配置热更新需要手动调/actuator/refresh端点,多服务批量刷新还要引入Spring Cloud Bus……
去年决定整体迁移到Nacos,但有个现实问题:我们有将近30个微服务,不可能一次性全部迁移,必须有一个平滑过渡期,期间新旧配置中心并存,服务能同时从两边读取配置。
在这个过渡期里,我们实现了一套"双读"方案:服务同时连接Spring Cloud Config和Nacos,两边都读取配置,Nacos的配置优先级更高(方便逐步迁移),Config Server里的作为兜底。这套方案让我们在4个月内把30个服务平滑迁移完毕,零事故。今天把这套迁移方案完整写出来。
一、核心问题分析
Spring Cloud Config和Nacos Config的核心差异在于架构设计理念不同:
Spring Cloud Config:把Git仓库作为配置存储,Config Server作为代理。配置本身存在Git里(有完整的版本历史),但实时推送能力弱,需要借助Spring Cloud Bus(基于MQ)来实现广播刷新。架构上多了Config Server这一跳,增加了故障点。
Nacos Config:配置直接存储在Nacos服务端,客户端通过长轮询(HTTP)或长连接(gRPC)实时监听变更,变更即刻推送,不需要额外组件。Nacos同时承担了服务注册和配置管理两个职责,架构更简单,但也意味着Nacos的可用性变得更加关键。
迁移时最大的风险是:新旧配置格式可能不完全一致,某些在Config里用占位符引用其他属性的配置,在Nacos里需要特别处理。另外,Spring Cloud Config支持配置加密(使用RSA密钥),Nacos的配置加密需要单独方案。
二、原理深度解析
2.1 两种配置中心的架构对比
2.2 双读过渡方案架构
2.3 迁移流程时序
三、完整代码实现
3.1 Spring Cloud Config基础配置(迁移前状态)
# bootstrap.yml(旧版Config方式)
spring:
application:
name: order-service
cloud:
config:
uri: http://config-server:8888
label: main
profile: prod
# 连接失败时快速失败
fail-fast: true
retry:
max-attempts: 6
initial-interval: 10003.2 迁移到Nacos Config的依赖变更
<!-- 移除Spring Cloud Config客户端 -->
<!-- <dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency> -->
<!-- 添加Nacos Config -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Nacos Config需要bootstrap -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>3.3 Nacos Config基础配置
# bootstrap.yml(迁移到Nacos后)
spring:
application:
name: order-service
cloud:
nacos:
config:
server-addr: ${NACOS_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:prod}
group: DEFAULT_GROUP
# 主配置文件:order-service.yaml
file-extension: yaml
# 共享配置(多服务共用的配置)
shared-configs:
- data-id: common-db.yaml
group: COMMON_GROUP
refresh: true
- data-id: common-redis.yaml
group: COMMON_GROUP
refresh: true
# 扩展配置(按业务域分组的配置,优先级高于shared-configs)
extension-configs:
- data-id: payment-config.yaml
group: PAYMENT_GROUP
refresh: true3.4 双读过渡方案实现
这是迁移期间最关键的部分,通过自定义PropertySourceLocator同时读取两个配置源:
package com.laozhang.config.migration;
import org.springframework.cloud.bootstrap.config.PropertySourceLocator;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.CompositePropertySource;
import org.springframework.core.env.Environment;
import org.springframework.core.env.PropertySource;
/**
* 双读过渡配置加载器
* 同时从Nacos和Spring Cloud Config加载配置
* Nacos优先级更高,Config作为兜底
*
* 注意:这个类在迁移完成后应该删除
*/
@Order(0) // 优先级最高
public class DualReadPropertySourceLocator implements PropertySourceLocator {
private final NacosPropertySourceLocator nacosLocator;
private final ConfigServicePropertySourceLocator configLocator;
public DualReadPropertySourceLocator(
NacosPropertySourceLocator nacosLocator,
ConfigServicePropertySourceLocator configLocator
) {
this.nacosLocator = nacosLocator;
this.configLocator = configLocator;
}
@Override
public PropertySource<?> locate(Environment environment) {
CompositePropertySource composite = new CompositePropertySource("dual-read");
// 先加Nacos(优先级高)
try {
PropertySource<?> nacosSource = nacosLocator.locate(environment);
if (nacosSource != null) {
composite.addPropertySource(nacosSource);
}
} catch (Exception e) {
// Nacos不可用时,不中断启动
System.err.println("Nacos配置加载失败,将降级到Config Server: " + e.getMessage());
}
// 再加Config Server(优先级低,作为兜底)
try {
PropertySource<?> configSource = configLocator.locate(environment);
if (configSource != null) {
composite.addPropertySource(configSource);
}
} catch (Exception e) {
System.err.println("Config Server配置加载失败: " + e.getMessage());
}
return composite;
}
}3.5 配置迁移工具类
在迁移期间,需要把Spring Cloud Config的配置批量同步到Nacos:
package com.laozhang.config.migration;
import com.alibaba.nacos.api.config.ConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.yaml.snakeyaml.Yaml;
import java.util.Map;
/**
* 配置迁移工具
* 从Config Server批量读取配置并写入Nacos
*/
@Slf4j
public class ConfigMigrationTool {
private final RestTemplate restTemplate;
private final ConfigService nacosConfigService;
private final String configServerUrl;
public ConfigMigrationTool(
RestTemplate restTemplate,
ConfigService nacosConfigService,
String configServerUrl
) {
this.restTemplate = restTemplate;
this.nacosConfigService = nacosConfigService;
this.configServerUrl = configServerUrl;
}
/**
* 从Config Server读取指定服务的配置并写入Nacos
*/
public void migrateServiceConfig(String appName, String profile, String nacosGroup) {
try {
// 调用Config Server的/appName/profile端点获取配置
String configUrl = configServerUrl + "/" + appName + "/" + profile;
ResponseEntity<Map> response = restTemplate.getForEntity(configUrl, Map.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
Map<String, Object> configEnv = response.getBody();
// Config Server返回的是PropertySources格式,需要合并
Map<String, Object> mergedConfig = mergePropertySources(configEnv);
// 转成YAML格式
Yaml yaml = new Yaml();
String yamlContent = yaml.dump(mergedConfig);
// 写入Nacos
String dataId = appName + "-" + profile + ".yaml";
boolean success = nacosConfigService.publishConfig(dataId, nacosGroup, yamlContent);
if (success) {
log.info("迁移成功:{} -> Nacos DataID={}", appName, dataId);
} else {
log.error("迁移失败:{}", appName);
}
}
} catch (Exception e) {
log.error("迁移异常,appName={}", appName, e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> mergePropertySources(Map<String, Object> configEnv) {
// 从Config Server响应中提取所有属性
java.util.LinkedHashMap<String, Object> merged = new java.util.LinkedHashMap<>();
Object propertySources = configEnv.get("propertySources");
if (propertySources instanceof java.util.List) {
java.util.List<Map<String, Object>> sources = (java.util.List<Map<String, Object>>) propertySources;
// 逆序处理,低优先级先放,高优先级覆盖
for (int i = sources.size() - 1; i >= 0; i--) {
Map<String, Object> source = sources.get(i);
Object sourceProps = source.get("source");
if (sourceProps instanceof Map) {
merged.putAll((Map<String, Object>) sourceProps);
}
}
}
return merged;
}
}3.6 配置热更新的正确使用方式
package com.laozhang.config.demo;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Service;
/**
* 正确使用@RefreshScope实现配置热更新
* 当Nacos配置变更时,带@RefreshScope的Bean会被重新创建
*/
@Service
@RefreshScope
public class DynamicConfigService {
@Value("${order.max-retry-count:3}")
private int maxRetryCount;
@Value("${order.timeout-seconds:30}")
private int timeoutSeconds;
@Value("${feature.new-pricing-enabled:false}")
private boolean newPricingEnabled;
public int getMaxRetryCount() { return maxRetryCount; }
public int getTimeoutSeconds() { return timeoutSeconds; }
public boolean isNewPricingEnabled() { return newPricingEnabled; }
}package com.laozhang.config.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
/**
* 使用@ConfigurationProperties + @RefreshScope的最佳实践
* 推荐这种方式,比@Value更安全,有类型校验
*/
@Component
@RefreshScope
@ConfigurationProperties(prefix = "order")
public class OrderConfigProperties {
private int maxRetryCount = 3;
private int timeoutSeconds = 30;
private RateLimitConfig rateLimit = new RateLimitConfig();
public static class RateLimitConfig {
private int qps = 100;
private int burstCapacity = 200;
// getters and setters
public int getQps() { return qps; }
public void setQps(int qps) { this.qps = qps; }
public int getBurstCapacity() { return burstCapacity; }
public void setBurstCapacity(int burstCapacity) { this.burstCapacity = burstCapacity; }
}
// getters and setters
public int getMaxRetryCount() { return maxRetryCount; }
public void setMaxRetryCount(int maxRetryCount) { this.maxRetryCount = maxRetryCount; }
public int getTimeoutSeconds() { return timeoutSeconds; }
public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; }
public RateLimitConfig getRateLimit() { return rateLimit; }
public void setRateLimit(RateLimitConfig rateLimit) { this.rateLimit = rateLimit; }
}四、生产配置与调优
4.1 Nacos配置的优先级规则
Nacos Config加载配置时有明确的优先级顺序(从高到低):
1. ${spring.application.name}-${profile}.${file-extension}
例如:order-service-prod.yaml
2. ${spring.application.name}.${file-extension}
例如:order-service.yaml
3. extension-configs(按数组顺序,后面的优先级高于前面的)
4. shared-configs(按数组顺序,后面的优先级高于前面的)
5. 本地application.yml4.2 配置加密方案
Nacos没有内置配置加密,生产环境的密码等敏感信息建议用Jasypt加密:
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency># Nacos中存储加密后的值
spring:
datasource:
password: ENC(加密后的密文)
# 解密密钥通过环境变量注入,不存在配置文件中
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD}五、踩坑实录
坑一:迁移后某些配置莫名其妙丢失,找了半天才发现是优先级问题。
Config Server里有一个application.yml(全局共享配置),Nacos里对应的是shared-configs里的公共配置。迁移时只迁了各服务的专属配置,忘了迁全局共享配置,结果数据库连接池的一些公共参数消失了,触发了连接池默认值,性能下降。
坑二:@RefreshScope和AOP切面冲突,刷新后AOP失效。
某个Service同时有@RefreshScope和自定义AOP切面(日志切面),配置刷新后,AOP切面没有正确应用到新创建的Bean上,导致一段时间内接口调用没有日志。这是@RefreshScope底层实现(CGLIB代理的代理)和AOP切面叠加的经典问题。
解决方案是把需要热更新的配置属性单独抽取到一个专门的@RefreshScope配置类里,核心业务Service不加@RefreshScope,通过注入配置类来间接获取最新值。
坑三:双读方案中Config Server启动失败阻断了整个服务启动。
在过渡期,Config Server偶发不稳定,服务启动时连接Config Server超时,整个Spring Boot应用启动失败。但这时候Nacos已经有完整配置了,Config Server只是备份,不应该影响启动。
解决方案是配置spring.cloud.config.fail-fast=false,让Config Server连接失败时不中断启动,只打警告日志。
坑四:Nacos配置的Group区分不到位,测试环境的配置污染了生产。
用同一个Nacos集群时,如果Group命名不规范,测试环境发布配置修改时可能同时影响生产。必须严格用namespace隔离不同环境(namespace是比Group更高级别的隔离),测试和生产使用不同的namespace。
六、总结
从Spring Cloud Config迁移到Nacos Config,核心是三步:第一步,引入Nacos Config依赖,配置shared-configs处理公共配置;第二步,利用双读方案实现平滑过渡,Nacos优先、Config兜底;第三步,逐服务验证、切断Config Server连接。配置热更新用@RefreshScope,但要注意它和AOP的兼容问题。迁移完成后,整体架构会简单很多,少了Config Server这一跳,配置推送延迟也从秒级降到毫秒级。
