Config与Bus:配置读取、传播与版本回滚
catalog 有 A、B 两个实例。配置库已提交 workers=12,A 返回新值,B 仍返回旧值;管理员收到 Bus 请求成功。这只能确认入口接受请求,不能确认两个实例的业务对象已经切换。配置库提交、Config Server 返回、客户端 Environment 更新、Bean 重建、业务可用,是五个需要分别观察的状态。
本页对应 U34 / #71、PRD L04。Boot 通用来源与绑定见Boot配置:外部来源、绑定与失败;Nacos 原有机制见Nacos注册发现与配置一致性。以下命令和时间线是教学用法与预期;实际运行以公开实验说明和入口生成的证据为准,不提前声明通过。
版本、职责与工程入口
查询日期为 2026-10-27。工程基线 JDK 21、Maven 3.9.9、Boot 4.0.9、Cloud 2025.1.4;Cloud BOM 管理 Config 5.0.6、Bus/Commons/Context/Stream 5.0.4,Boot 管理 Spring Kafka 4.0.8、Kafka 4.1.2。BOM、最终依赖树与实际运行是不同证据。
固定提交分别为 Config 69d5ac35b2980cd498212e304e43512b864e5995、Bus 0db5f69447c786e744bcd00a4112b44386987116、Commons db9b65a5a8553e4ec04faa4d100eeb45817a2a92。source-excerpt 的 tag 字段使用完整提交,SHA-256 对整文件计算,代码为连续原文;教学代码另行标注。
Config Server 将后端配置组织成 Environment;Config Client 将响应接入本应用属性源;Context 负责刷新与作用域;Bus 分发管理事件;Stream binder 与 Kafka/RabbitMQ 承担消息传输。这些组件不自动验证业务参数、不迁移数据,也不回滚已发生的业务效果。
| 模块 | 教学落点与边界 |
|---|---|
| config-client / starter-config | ConfigData、读取坐标、启动与失败;后者是客户端依赖入口 |
| config-server | EnvironmentRepository、Git/替代后端、表示、加密与 HTTP 安全 |
| config-monitor | webhook 到刷新事件;未引入时不能假设有 /monitor |
| config-dependencies / sample / client-tls-tests / docs | 版本、示例、上游测试与文档;存在测试模块不代表本页跑过 TLS |
| cloud-context / starter-bootstrap | Environment、重绑、RefreshScope 与显式启用的 legacy bootstrap |
| bus / starter-bus-stream | 事件、寻址、端点与通用 Stream 桥接 |
| starter-bus-kafka / starter-bus-amqp | 分别选择 Kafka 与 Rabbit;交付、消费组和离线恢复须各自审计 |
| bus-dependencies / bus-tests / docs | 版本、上游测试及文档;测试模块不是生产消息代理 |
最小完整 Git Server 与客户端
以下是独立教学工程:根 pom.xml;server/client 各有 pom.xml、Java 入口和 application.yml。两个客户端运行相同 jar,使用不同 HTTP 端口与 Bus ID。Kafka 地址由学习者的隔离代理提供;公开实验另以同 JVM 独立 Spring contexts 装配。
根 pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.9</version><relativePath/>
</parent>
<groupId>demo.u34</groupId><artifactId>config-demo</artifactId>
<version>1.0.0</version><packaging>pom</packaging>
<properties><java.version>21</java.version></properties>
<modules><module>server</module><module>client</module></modules>
<dependencyManagement><dependencies><dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId><version>2025.1.4</version>
<type>pom</type><scope>import</scope>
</dependency></dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId></dependency>
</dependencies>
</project>server/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>demo.u34</groupId><artifactId>config-demo</artifactId>
<version>1.0.0</version></parent>
<artifactId>server</artifactId>
<dependencies><dependency><groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId></dependency></dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>server/src/main/java/demo/server/Server.java:
package demo.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@SpringBootApplication
@EnableConfigServer
public class Server {
public static void main(String[] args) { SpringApplication.run(Server.class, args); }
@Bean SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(c -> c.ignoringRequestMatchers("/encrypt", "/decrypt")).build();
}
}server/src/main/resources/application.yml:
server:
address: 127.0.0.1
port: 18888
spring:
application:
name: config-demo-server
security:
user:
name: operator
password: ${DEMO_PASSWORD}
cloud:
config:
server:
git:
uri: ${CONFIG_GIT_URI}
default-label: main
clone-on-start: true
refresh-rate: 0
encrypt:
key: ${DEMO_ENCRYPT_KEY}
management:
endpoints:
web:
exposure:
include: healthBasic 只承担 loopback 教学鉴权;生产还需 HTTPS、角色授权、每应用可读范围与密钥托管。共享一个能读取所有配置坐标的口令不能称为多租户隔离。
client/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>demo.u34</groupId><artifactId>config-demo</artifactId>
<version>1.0.0</version></parent>
<artifactId>client</artifactId>
<dependencies>
<dependency><groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-kafka</artifactId></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>client/src/main/java/demo/client/Client.java:
package demo.client;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class Client {
public static void main(String[] args) { SpringApplication.run(Client.class, args); }
@Bean @RefreshScope Policy policy(Environment env) {
return new Policy(env.getRequiredProperty("demo.workers", Integer.class),
env.getRequiredProperty("demo.release"));
}
@Bean Sticky sticky(Environment env) {
return new Sticky(env.getRequiredProperty("demo.release"));
}
@Bean SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(c -> c.ignoringRequestMatchers("/actuator/refresh",
"/actuator/busrefresh", "/actuator/busrefresh/**",
"/actuator/busenv", "/actuator/busenv/**")).build();
}
public static class Policy {
private final int workers;
private final String release;
public Policy(int workers, String release) {
if (workers < 1) throw new IllegalArgumentException("workers must be positive");
this.workers = workers; this.release = release;
}
public Map<String, Object> snapshot() {
return Map.of("workers", workers, "release", release);
}
}
public record Sticky(String release) {}
@RestController
public static class Api {
private final Policy policy;
private final Sticky sticky;
public Api(Policy policy, Sticky sticky) { this.policy = policy; this.sticky = sticky; }
@GetMapping("/policy") public Map<String, Object> read() {
return Map.of("policy", policy.snapshot(), "stickyRelease", sticky.release());
}
}
}Policy 是可代理的非 final 类;不要改成 final record 后期待默认类代理仍可用。Sticky 是普通单例,故意保留启动值;单次 snapshot 从同一 Policy 目标取得相关字段,避免同请求分别跨刷新读取两代对象。
client/src/main/resources/application.yml:
server:
address: 127.0.0.1
spring:
application:
name: catalog
config:
import: configserver:http://127.0.0.1:18888
security:
user:
name: operator
password: ${DEMO_PASSWORD}
cloud:
config:
username: operator
password: ${DEMO_PASSWORD}
label: main
request-connect-timeout: 1000
request-read-timeout: 2000
bus:
destination: u34-demo-bus
trace:
enabled: true
stream:
kafka:
binder:
brokers: ${KAFKA_BROKERS}
management:
endpoints:
web:
exposure:
include: health,refresh,busrefresh,env,configprops
endpoint:
env:
show-values: never
configprops:
show-values: never超时单位为毫秒;Git refresh-rate 是秒。两实例不要配置相同 Stream group。环境变量 DEMO_PASSWORD、DEMO_ENCRYPT_KEY、CONFIG_GIT_URI、KAFKA_BROKERS 需由启动环境提供,均使用合成凭据或隔离地址;缺失应作为失败输入排查,不能用空字符串掩盖。先在自建临时工作目录创建配置库:
mkdir config-repo
cd config-repo
git init -b main
git config user.name u34-demo
git config user.email u34-demo@example.invalid
cat > catalog.properties <<'PROPERTIES'
demo.workers=8
demo.release=r1
PROPERTIES
git add catalog.properties
git commit -m '初始化合成配置'
python3 -c 'from pathlib import Path; print(Path.cwd().resolve().as_uri())'将最后一行 URI 作为 CONFIG_GIT_URI。回到教学工程目录执行 mvn package -DskipTests;确认端口空闲,分别在三个终端运行:
java -jar server/target/server-1.0.0.jar
java -jar client/target/client-1.0.0.jar --server.port=18081 --spring.cloud.bus.id=catalog:a
java -jar client/target/client-1.0.0.jar --server.port=18082 --spring.cloud.bus.id=catalog:b这些是独立启动命令,不能在一个阻塞的前台 shell 里期待三者同时启动。读取与刷新入口:
curl -fsS -u "operator:${DEMO_PASSWORD}" http://127.0.0.1:18888/catalog/default/main
curl -fsS -u "operator:${DEMO_PASSWORD}" http://127.0.0.1:18081/policy
curl -fsS -u "operator:${DEMO_PASSWORD}" -X POST http://127.0.0.1:18081/actuator/refresh
curl -fsS -u "operator:${DEMO_PASSWORD}" -X POST \
http://127.0.0.1:18081/actuator/busrefresh/catalog:b读取坐标与服务端配置顺序
Environment JSON 入口为 /{application}/{profile}/{label}。application 通常取 spring.application.name,profile 是请求的活动 profiles,label 对 Git 可为分支、tag 或 commit。客户端 spring.cloud.config.name/profile/label 可改坐标,进程名与配置名不必相同。
application.properties 提供共享基底,catalog.properties 提供应用覆盖;同一名字的 profile 特定文件覆盖普通文件。多个 profiles 后者可覆盖前者;name=catalog,shared-db 中后面的名字还可覆盖前面的同键。以响应 propertySources 顺序解释最终值,不按文件列表猜测。固定 Config Client 文档给出相应规则。
观察 name、profiles、label、version、state、propertySources:Git version 是服务器读取的 commit,state 为后端可提供的附加状态,任意后端不保证都有值。HTTP 200 不能证明配置进入客户端,也不能用 release 文本替代提交身份。
原码:Config 5.0.6 ConfigServerConfigDataLoader.doLoad 转换远程源并保存 version/state:
// result.getPropertySources() can be null if using xml
if (result.getPropertySources() != null) {
for (org.springframework.cloud.config.environment.PropertySource source : result
.getPropertySources()) {
@SuppressWarnings("unchecked")
Map<String, Object> map = translateOrigins(source.getName(),
(Map<String, Object>) source.getSource());
propertySources.add(0,
new OriginTrackedMapPropertySource("configserver:" + source.getName(), map, true));
}
}
HashMap<String, Object> map = new HashMap<>();
if (StringUtils.hasText(result.getState())) {
putValue(map, "config.client.state", result.getState());
}
if (StringUtils.hasText(result.getVersion())) {
putValue(map, "config.client.version", result.getVersion());
}
// the existence of this property source confirms a successful
// response from config server
propertySources.add(0, new MapPropertySource(CONFIG_CLIENT_PROPERTYSOURCE_NAME, map));远程列表转换为 Boot ConfigData 顺序时 add(0),不能只看到反转就断言低优先级胜出。config.client.version 可诊断本客户端读到的 revision;Policy 仍可能是旧目标,须同时报告业务快照。
ConfigData 导入位置与优先级
spring.config.import=configserver:http://... 为必需导入,加 optional: 允许取不到配置时继续。默认地址 localhost:8888;import 内 URL 优先于 spring.cloud.config.uri,改 uri 后仍访问旧地址应先查 import。ConfigData 不需要 bootstrap.yml。先 default、再活动 profile 的多次 HTTP 获取是正常解析,不表示重复 Bus 刷新。
Boot 4.0.9 的固定外部配置规则规定:导入覆盖声明该导入的文档,同一 import 列表后项优先;配置文件仍在 ConfigData 层,OS 环境、Java System、CLI 等更高层来源可覆盖。加入 profile 文档、后续文件或额外 location 后,需重新计算顺序。
冲突例:声明 import 的本地文档 workers=4,远程 workers=8,CLI --demo.workers=16。无其他源时预期 16,删除 CLI 后预期 8。Config Server HTTP 响应仍是 8,与客户端最终 16 不矛盾。应一起保存远程响应、客户端来源和 Policy 实际值。
spring.cloud.config.server.overrides 添加独立远程覆盖源;loader 将它特别标为 profile-specific:
// When we see the overrides property source name we
// should always prioritize those
// properties over everything else, even profile specific
// property sources so also
// label this property source profile specific.
if (OVERRIDES_NAME.equals(propertySourceName) || (!DEFAULT_PROFILE.equals(profile)
&& propertySourceName.matches(".*[-,]" + profile + "\\b.*"))) {
// // TODO: switch to Options.with() when implemented
options.add(Option.PROFILE_SPECIFIC);
options.add(Option.IGNORE_PROFILES);
}
}
return ConfigData.Options.of(options.toArray(new Option[0]));
});这是 ConfigData 内部排序,不把 overrides 升到命令行之上。legacy allowOverride 三开关属于 bootstrap 链,见下一节;配置系统也无法强迫应用一定通过 Environment 取值。
Context 与 bootstrap 的两代排序
Commons 5.0.4 同时保留两条把远程源接入应用的路径:现代 ConfigData 导入(上一节)与显式启用 spring.cloud.bootstrap.enabled=true(或 classpath 存在 bootstrap.yml 的 legacy 路径)时的 bootstrap 上下文。bootstrap 父 Context 先建自己的 Environment,再把远程源插入子 Context;spring.cloud.config.allowOverride/overrideNone/overrideSystemProperties 三个开关只属于这条 legacy 链,PropertySourceBootstrapConfiguration.insertPropertySources 按布尔组合选择插入位置:
PropertySourceBootstrapProperties remoteProperties = new PropertySourceBootstrapProperties();
Binder.get(environment(incoming)).bind("spring.cloud.config", Bindable.ofInstance(remoteProperties));
if (!remoteProperties.isAllowOverride()
|| (!remoteProperties.isOverrideNone() && remoteProperties.isOverrideSystemProperties())) {
for (PropertySource<?> p : reversedComposite) {
if (propertySources.contains(DECRYPTED_PROPERTY_SOURCE_NAME)) {
propertySources.addAfter(DECRYPTED_PROPERTY_SOURCE_NAME, p);
}
else {
propertySources.addFirst(p);
}
}
return;
}
if (remoteProperties.isOverrideNone()) {
for (PropertySource<?> p : composite) {
propertySources.addLast(p);
}
return;
}
if (propertySources.contains(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME)) {
if (!remoteProperties.isOverrideSystemProperties()) {
for (PropertySource<?> p : reversedComposite) {
propertySources.addAfter(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, p);
}
}
else {allowOverride=false 或“未 overrideNone 且 overrideSystemProperties=true”(后者是默认组合)把远程源插到最前,远程可覆盖一切本地源;overrideNone=true 插到末尾,远程只补充本地没有的键;allowOverride=true 且 overrideSystemProperties=false 插在系统环境之后,OS 环境变量仍赢。注意默认值本身允许远程覆盖系统属性,这是 bootstrap 时代的历史默认,不是“本地设置优先”的证明。ConfigData loader 不读取这三个开关,两条路径的顺序结论不能互相搬运。
spring.cloud.config.server.overrides 是服务端在响应里附加的独立源;EnvironmentEncryptorEnvironmentRepository.findOne 把它 addFirst 到返回 Environment:
@Override
public Environment findOne(String name, String profiles, String label, boolean includeOrigin) {
Environment environment = this.delegate.findOne(name, profiles, label, includeOrigin);
if (this.environmentEncryptors != null) {
for (EnvironmentEncryptor environmentEncryptor : environmentEncryptors) {
environment = environmentEncryptor.decrypt(environment);
}
}
if (!this.overrides.isEmpty()) {
environment.addFirst(new PropertySource("overrides", getOverridesMap(includeOrigin)));
}
return environment;
}它标为 profile-specific 参与客户端 ConfigData 排序(见上一节),最终仍不高于 CLI/OS 环境等高层来源,也不能强迫应用一定通过 Environment 取值。“服务端 overrides 强制所有实例回到某值”只在所有实例都读它、且没有更高优先级本地源时成立。
同 classpath 放置 config-server 与 config-client 工件时,Config 5.0.6 的 ConfigServerBootstrapApplicationListener 会在客户端早期默认注入 spring.cloud.config.enabled=false:
private PropertySource<?> propertySource = new MapPropertySource("configServerClient",
Collections.<String, Object>singletonMap("spring.cloud.config.enabled", "false"));
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (!environment.resolvePlaceholders("${spring.cloud.config.enabled:false}").equalsIgnoreCase("true")) {
if (!environment.getPropertySources().contains(this.propertySource.getName())) {
environment.getPropertySources().addLast(this.propertySource);
}
}
}这是防止 Server 给自己拉配置的默认退让;本票公开实验的两个客户端因此显式 --spring.cloud.config.enabled=true。普通只引 starter-config 的应用不需要也不应该照抄这一项。
Git 仓库、读取时点与本地路径
显式 default-label=main 固定默认分支。未设默认 label 时,默认 main 不存在可按 try-master-branch 规则回退 master。label 可分支、tag、commit;含斜杠 label 按 API 使用 (_) 替代斜杠,不能直接拼成额外 HTTP 路径。
Git 使用本地 checkout/clone。clone-on-start=true 将首次克隆故障提前到启动;默认懒克隆可能先启动、首次请求才失败。refresh-rate=0 默认每次请求有取更新机会,正值按秒抑制拉取,负值不再拉更新;它不使客户端定时刷新。Server 本地副本与客户端 Environment 是两层缓存。
原码:JGitEnvironmentRepository.refresh 按条件 fetch,再 checkout/merge 并返回 HEAD:
public String refresh(String label) {
Git git = null;
try {
git = createGitClient();
if (shouldPull(git)) {
FetchResult fetchStatus = fetch(git, label);
if (this.deleteUntrackedBranches && fetchStatus != null) {
deleteUntrackedLocalBranches(fetchStatus.getTrackingRefUpdates(), git);
}
}
// checkout after fetch so we can get any new branches, tags, ect.
// if nothing to update so just checkout and merge.
// Merge because remote branch could have been updated before
checkout(git, label);
tryMerge(git, label);
// always return what is currently HEAD as the version
return git.getRepository().findRef("HEAD").getObjectId().getName();
}相同 branch label 两次请求可返回不同 commit。已有有效本地副本而远端不可达时可能继续返回旧值;首次 clone、缺失 label、工作区保护失败不能依赖该回退。force-pull 处理脏工作区,delete-untracked-branches 清理已从远端删除的本地分支残留,均不是业务回滚。
多仓库/搜索路径推演:替换 Server 的 git 段,未运行远程认证或多仓库。
spring:
cloud:
config:
server:
git:
uri: ${DEFAULT_CONFIG_GIT_URI}
basedir: ${CONFIG_CHECKOUT_DIR}
search-paths: ['shared', '{application}']
clone-on-start: true
refresh-rate: 10
force-pull: false
delete-untracked-branches: true
repos:
payments:
pattern: ['payments/*']
uri: ${PAYMENTS_CONFIG_GIT_URI}
search-paths: ['{application}']payments/prod/main 选择匹配仓库,catalog/prod/main 用默认库。观察源名确认实际位置;label 不存在、认证错、basedir 不可写是独立失败输入。basedir 应隔离、可靠且不被系统清理;带口令的 URL 不应写入日志。版本行为见Git 后端。
Config 5.0.6 ScmFileUtils.assertNoSymlinkInPath 逐分量检查本地 file: 路径:
public static void assertNoSymlinkInPath(Path path) {
Path current = path.getRoot();
if (current == null) {
return;
}
for (Path component : path) {
current = current.resolve(component);
if (Files.isSymbolicLink(current)) {
throw new IllegalStateException("Path component must not be a symbolic link: " + current);
}
}
}任一分量是软链接即失败。macOS /var 常经链接到 /private/var;本地实验对自建目录使用 Files.createTempDirectory(...).toRealPath() 后生成 URI,不关闭路径保护。顶层 Cannot load environment 需继续追到 cause 中具体路径。解析自己的临时目录不代表应接受任意不可信软链接。
替代后端与组合读取
以下为完整使用路径与失败推演,未运行这些后端。复用前述 Server 入口、Web/Actuator/Security 与 Config Server 依赖;增加表内 SDK,替换仓库/profile 段,保留 HTTP 鉴权。每项配置独立使用,不把互斥 profile 示例合并。官方来源为 Config 5.0.6 后端目录及 EnvironmentRepositoryConfiguration 的 profile/class 条件。
文件、数据库与 SVN
| 后端 | 增加依赖与独立配置 | 数据、版本与失败 |
|---|---|---|
| native | 无额外 SDK;spring.profiles.active=native,spring.cloud.config.server.native.search-locations=file:${CONFIG_ROOT}/,native.add-label-locations=false | 目录存 catalog.properties;GET /catalog/default/main;没有 Git commit。忘记 file: 可读错 classpath;空目录与无权限不能当有效配置;多 Server 需可靠共享内容 |
| JDBC | spring-boot-starter-data-jdbc 与目标 JDBC driver;profile=jdbc;完整 H2 例见下 | 按 application/profile/label 查行;缺表、SQL 方言错与无匹配分别诊断 |
| Redis | spring-boot-starter-data-redis;profile=redis;spring.data.redis.host=${REDIS_HOST}、port=${REDIS_PORT}、database=${REDIS_DB}、password=${REDIS_PASSWORD} | hash application、catalog、catalog-prod;共享后应用覆盖。错 database/hash 可无配置;不能假设有 Git label 历史 |
| MongoDB | spring-boot-starter-data-mongodb;profile=mongodb;spring.mongodb.uri=${CONFIG_MONGO_URI};spring.cloud.config.server.mongodb.collection=properties、default-label=master | 文档含 application/profile/label/properties;错集合/label 无匹配,认证失败另行处理 |
| SVN | org.tmatesoft.svnkit:svnkit,Cloud BOM 管理;profile=subversion;spring.cloud.config.server.svn.uri=${SVN_URI}、username=${SVN_USER}、password=${SVN_PASSWORD}、basedir=${SVN_CHECKOUT} | label 对应 SVN 分支/目录约定,不是 Git commit;缺路径、凭据错、工作目录保护可失败 |
Redis/Mongo 老文档中的连接前缀不能照搬到 Boot 4:本基线分别为 spring.data.redis、spring.mongodb,应以 Boot 配置元数据为准。native 默认可追加 label 子目录,使用显式占位路径或 add-label-locations=false 时语义改变;不得认为 native/main 是不可变 Git revision。
JDBC 教学例增加 com.h2database:h2 runtime 依赖,在 Server 的独立配置中使用:
spring.profiles.active=jdbc
spring.datasource.url=jdbc:h2:file:${CONFIG_H2_PATH}
spring.datasource.username=sa
spring.datasource.password=${CONFIG_H2_PASSWORD}
spring.cloud.config.server.jdbc.default-label=main
spring.sql.init.mode=never数据库所有者在专用库准备:
CREATE TABLE PROPERTIES (
APPLICATION VARCHAR(100), PROFILE VARCHAR(100), LABEL VARCHAR(100),
"KEY" VARCHAR(200), "VALUE" VARCHAR(2000)
);
INSERT INTO PROPERTIES VALUES ('catalog','default','main','demo.workers','8');
INSERT INTO PROPERTIES VALUES ('catalog','default','main','demo.release','r1');GET /catalog/default/main 应匹配上面行。默认查询使用带引号 KEY/VALUE;改 MySQL 时必须处理 SQL 方言并配置查询,不把 H2 DDL 当通用迁移。Mongo 的合成文档:
{"application":"catalog","profile":"default","label":"master",
"properties":{"demo.workers":"8","demo.release":"r1"}}Mongo 对此请求 /catalog/default/master。Redis 输入为 HSET catalog demo.workers 8 demo.release r1,读取 /catalog/default。以上是隔离环境配方,不要求操作本机既有业务库;底层数据库能事务提交,也不等于客户端配置已原子生效。
Vault 与云秘密后端
Vault 增加 org.springframework.vault:spring-vault-core,保留通用 Server 配置,替换为:
spring:
profiles:
active: vault
cloud:
config:
server:
vault:
host: ${VAULT_HOST}
port: 8200
scheme: https
backend: secret
kv-version: 2
skip-ssl-validation: false在正确挂载的 KV v2 写入 secret/catalog;GET /catalog/default 带 X-Config-Token。KV v1/v2 的路径/响应形态不同,缺 kv-version=2 可失败;过期 token、无读取 policy、证书不可信须分别诊断,不用跳过 TLS 校验掩盖。非 Token 认证另配对应参数并依赖 Vault Core。Vault 默认不按 Config label 选秘密;启用 enable-label 后才能按该后端的三段路径约定读取,Git revert 不会自动回退 Vault。
下面每行是一个独立 Server 配置,前提为已配置相应 SDK 凭据/授权与数据;不是已完成云账户连通验证:
| 后端与增加依赖 | 独立选择与连接参数 | 数据契约与失败条件 |
|---|---|---|
S3;software.amazon.awssdk:s3 | profile=awss3;spring.cloud.config.server.awss3.region=us-east-1、bucket=${CONFIG_BUCKET} | 对象 catalog-default.properties,label 是目录;默认凭据链需读取权限。bucket 版本化不等于 label 自动选择 S3 versionId;错 region/对象名/IAM 可失败 |
Parameter Store;software.amazon.awssdk:ssm | profile=awsparamstore;spring.cloud.config.server.awsparamstore.region=us-east-1、prefix=/config、profile-separator=-、recursive=true、decrypt-values=true、max-results=10 | /config/catalog/demo.workers 等参数路径;默认最新值;KMS 解密需授权。max-results=11、非法前缀为错误输入 |
Secrets Manager;software.amazon.awssdk:secretsmanager | profile=awssecretsmanager;spring.cloud.config.server.aws-secretsmanager.region=us-east-1、prefix=/secret、ignore-label=false | /secret/catalog/ 中 JSON;label 可映射 staging label,按后端设 default-label。ignore-label=true 忽略 label;未移动 staging label、路径错或无权限影响读值 |
CredHub;org.springframework.credhub:spring-credhub-core | profile=credhub;spring.cloud.config.server.credhub.url=${CREDHUB_URL}、default-label=master | /catalog/default/master/toggles 等 JSON 凭据;需 mTLS 或下方 OAuth2。只给 URL 不提供认证不能当可用 |
GCP Secret Manager;com.google.cloud:google-cloud-secretmanager,权限核查加 google-api-services-cloudresourcemanager 与 google-auth-library-oauth2-http | profile=secret-manager;spring.cloud.config.server.gcp-secret-manager.project-id=${GCP_PROJECT}、application-label=application、profile-label=profile、token-mandatory=true、version=1 | Secret 标签匹配 application/profile,读最新 enabled 版本;Config label 不选秘密版本。服务端 ADC 与客户端 X-Config-Token 权限门是不同凭据;缺项目/token/IAM 可无源或失败 |
AWS SDK 可在教学父 POM 的 dependencyManagement 额外导入 software.amazon.awssdk:bom:2.39.6,Google 库使用 com.google.cloud:libraries-bom:26.71.0;这是 Config 上游构建版本,不是本页的云 SDK 运行证据。CredHub/Vault Core 由 Cloud Config BOM 管理。外部 SDK 是可选依赖,不能假设引入 config-server 后全都存在。
CredHub 采用 OAuth2 时增加 spring-boot-starter-oauth2-client,除 URL 外还须:
spring.cloud.config.server.credhub.oauth2.registration-id=credhub-reader
spring.security.oauth2.client.registration.credhub-reader.provider=uaa
spring.security.oauth2.client.registration.credhub-reader.client-id=${CREDHUB_CLIENT_ID}
spring.security.oauth2.client.registration.credhub-reader.client-secret=${CREDHUB_CLIENT_SECRET}
spring.security.oauth2.client.registration.credhub-reader.authorization-grant-type=client_credentials
spring.security.oauth2.client.provider.uaa.token-uri=${UAA_TOKEN_URI}UAA 客户端需 credhub.read。GCP ADC 可来自工作负载身份;选择 service-account 文件则在 Server 挂载受控文件。token-mandatory=false 改为依赖服务端凭据并对客户端 X-Project-ID 施加 allowed-project-ids 限制,不能关闭权限门后声称等价。秘密后端的读取/版本契约不能从“都返回 Environment”推成相同。
Composite 顺序与失败政策
复合示例仍复用完整 Server 入口:
spring:
profiles:
active: composite
cloud:
config:
server:
fail-on-composite-error: true
composite:
- type: native
search-locations: file:${OVERRIDE_ROOT}/
add-label-locations: false
- type: git
uri: ${CONFIG_GIT_URI}
default-label: main列表前项优先;独立后端多 profile 组合用各自 order,数值越小越优先;自定义 EnvironmentRepository 可实现 Ordered。各仓库的 label 必须可解析;Git main 与 JDBC master 不天然对应同一发布。
原码 CompositeEnvironmentRepository.findOne 多后端分支:
else {
for (EnvironmentRepository repo : environmentRepositories) {
try {
env.addAll(repo.findOne(application, profile, label, includeOrigin).getPropertySources());
}
catch (Exception e) {
if (failOnError) {
throw e;
}
else {
log.info("Error adding environment for " + repo);
}
}
}
}
return env;默认任一后端失败使整体失败;fail-on-composite-error=false 在此分支记录错误后继续,结果可能少一个覆盖源/秘密源,低优先级值重新生效。它不是跨后端事务。单后端另走直接调用,不能说此开关会屏蔽一切异常。推演:首源 workers=8、次源 workers=4,使首源失败后比较拒绝与回落;不能只看 HTTP 200。
表示、占位符与纯文本资源
Environment JSON 保留有序源列表及版本信息;扁平 JSON/YAML/properties 合并成单个映射,丢失来源元数据,YAML 也不保证保留仓库原排版。例:
curl -fsS -u "operator:${DEMO_PASSWORD}" http://127.0.0.1:18888/catalog/default/main
curl -fsS -u "operator:${DEMO_PASSWORD}" http://127.0.0.1:18888/main/catalog-default.properties
curl -fsS -u "operator:${DEMO_PASSWORD}" \
'http://127.0.0.1:18888/main/catalog-default.yml?resolvePlaceholders=true'
curl -fsS -u "operator:${DEMO_PASSWORD}" \
http://127.0.0.1:18888/catalog/default/main/logback.xml最后一个是纯文本资源入口 /{application}/{profile}/{label}/{path}:按同样搜索路径取首个匹配资源,不聚合全部;profile 文件如 logback-prod.xml 可优先,资源里的标准占位符按该坐标有效 Environment 解析。未解析占位符、缺资源、路径/授权不合法应分别检查。资源端点不等于允许任意服务器文件读取;授权也不能只保护 JSON 忘记文本路径。
固定依据为 替代表示与纯文本。纯文本支持 Git/SVN/native/S3 不表示 JDBC/Redis 也能直接提供任意文件;参数名相同不证明后端能力相同。
启动、失败与重试前提
必需导入(spring.config.import=configserver:)在 Config Server 不可达时使启动失败;optional:configserver: 记录警告后继续,应用以本地来源启动,此刻业务对象的配置缺省值生效。配置获取超时由 spring.cloud.config.request-connect-timeout/request-read-timeout(毫秒)约束;多个 URI 按序尝试——ConfigServerConfigDataLoader 在 HTTP 4xx/5xx 且非末位时按默认 MultipleUriStrategy.ALWAYS 换下一个 URI(404 另行处理),ResourceAccessException 在非末位时也继续。整体重试是另一层:只有 classpath 存在 spring-retry 且 spring.cloud.config.fail-fast=true 时,ConfigClientRetryBootstrapper 才用 RetryTemplate 包裹整个加载调用;spring.cloud.config.retry.* 的初始间隔、倍率与最大次数只在这两个前提同时成立时生效。缺重试依赖而把 retry.* 写满配置,是纸面重试的常见反例。
启动成功的判定要与就绪分开:ConfigData 解析完成只说明远程 Environment 已装入,不说明绑定了它的 Bean 校验通过。bootstrap 链下同名失败发生在父 Context 阶段,错误形态与 ConfigData 的 ConfigDataResourceNotFoundException 不同;排查时先确认应用走哪条链。
本地刷新、EnvironmentChangeEvent 与 RefreshScope
提交 Git 不会自动改变任何已启动客户端:拉取发生在启动解析、首次绑定或显式刷新时。POST /actuator/refresh 由 ContextRefresher.refresh() 执行三步——重跑 EnvironmentPostProcessor 重新解析远程配置、替换非标准 PropertySource、发布 EnvironmentChangeEvent,随后 RefreshScope.refreshAll() 销毁作用域内缓存实例:
public synchronized Set<String> refresh() {
Set<String> keys = refreshEnvironment();
this.scope.refreshAll();
return keys;
}
public synchronized Set<String> refreshEnvironment() {
Map<String, Object> before = getCurrentEnvironmentProperties();
updateEnvironment();
Set<String> keys = changes(before, getCurrentEnvironmentProperties()).keySet();
this.context.publishEvent(new EnvironmentChangeEvent(this.context, keys));
return keys;
}源替换是按源名整体 replace,不是逐键合并:同名源被新响应整体替换,其中消失的键随之移除;本次完全不再出现的旧源名不会被删除。RefreshScope.refreshAll 只销毁,不重建:
@ManagedOperation(description = "Dispose of the current instance of all beans "
+ "in this scope and force a refresh on next method execution.")
public void refreshAll() {
super.destroy();
this.context.publishEvent(new RefreshScopeRefreshedEvent());
}因此刷新后的行为分三类:@RefreshScope 代理在下一次方法调用才用新环境重建目标,构造器抛错延迟到那时才暴露;@ConfigurationProperties bean 由 EnvironmentChangeEvent 触发 rebinder 原地重绑;普通单例(含构造时捕获值的 record)永远持有启动值。rebinder 对可变 bean 先按无参构造取默认再重绑:
private void resetBeanToDefaults(Object bean) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
if (!hasDefaultConstructor(targetClass)) {
// Beans whose no-arg constructor (if any) is not their only constructor
// (for example constructor-bound beans, beans with required dependencies, or
// beans with an extra no-arg constructor kept only for a framework's benefit)
// cannot be instantiated to obtain trustworthy defaults, so the reset is
// skipped. The bean is still re-bound from the Environment afterwards; only
// reverting removed properties to their defaults is skipped.
if (logger.isDebugEnabled()) {
logger.debug("No default constructor for " + targetClass.getName()
+ "; skipping property reset before rebinding");
}
return;
}
Object freshInstance;
try {
freshInstance = BeanUtils.instantiateClass(targetClass);
}
catch (Exception ex) {
logger.warn("Cannot create default instance of " + targetClass.getName()
+ " for reset; skipping property reset", ex);
return;
}
resetProperties(bean, freshInstance, Collections.newSetFromMap(new IdentityHashMap<>()), 0);构造绑定且没有无参构造的 bean 跳过“恢复默认”但仍会从 Environment 重绑——所以“删键后字段一定保留旧值”与“一定重置为默认”都不成立,取决于绑定器如何处理缺失。原地重绑不保护并发读者看到中间状态,作用域销毁也不是集群范围事件。
本票公开实验观察:Git 提交 v2 后 A 的 /state 仍是 v1;A 调 refresh 后 Policy 为 v2、Sticky 仍是启动值、B 不变。非法 lab.workers=0 的 refresh 返回 200——校验在 @RefreshScope 目标构造时才执行,首次调用业务端点才 500。刷新幂等,可重复执行;不存在“刷新窗口内请求半新半旧必然一致”的保证。
Bus 寻址、唯一 id 与真实 Kafka 传播
POST /actuator/busrefresh/{destination} 由 RefreshBusEndpoint 构造 RefreshRemoteApplicationEvent 并经 StreamBridge 发到 broker;未提供 destination(无参 busRefresh() 走 getDestination(null))时目的为 **,而带空 selector 的请求会得到 ":**",一般不匹配任何正常 bus.id:
@Endpoint(id = "busrefresh") // TODO: document new id
public class RefreshBusEndpoint extends AbstractBusEndpoint {
public RefreshBusEndpoint(ApplicationEventPublisher publisher, String id, Destination.Factory destinationFactory) {
super(publisher, id, destinationFactory);
}
@WriteOperation
public void busRefreshWithDestination(@Selector(match = Match.ALL_REMAINING) String[] destinations) {
String destination = StringUtils.arrayToDelimitedString(destinations, ":");
publish(new RefreshRemoteApplicationEvent(this, getInstanceId(), getDestination(destination)));
}
@WriteOperation
public void busRefresh() {
publish(new RefreshRemoteApplicationEvent(this, getInstanceId(), getDestination(null)));
}目的串 catalog:b 由 PathDestinationFactory 补全为 catalog:b:**(至多一个冒号且未以 :** 结尾时追加),订阅端 PathServiceMatcher.isForSelf 用该 pattern 匹配自己的 bus.id,isFromSelf 抑制自己发出的事件回环:
public boolean isFromSelf(RemoteApplicationEvent event) {
String originService = event.getOriginService();
String serviceId = getBusId();
return this.matcher.match(originService, serviceId);
}
public boolean isForSelf(RemoteApplicationEvent event) {
String destinationService = event.getDestinationService();
if (destinationService == null || destinationService.trim().isEmpty()
|| this.matcher.match(destinationService, getBusId())) {
return true;
}
// Check all potential config names instead of service name
for (String configName : this.configNames) {
if (this.matcher.match(destinationService, configName)) {
return true;
}
}
return false;
}
public String getBusId() {
return this.id;每实例 spring.cloud.bus.id 必须唯一;未配置时 BusEnvironmentPostProcessor 按应用名/端口/随机值生成默认 id,有活动 profiles 时模板还会追加 profiles:
Map<String, Object> defaults = new HashMap<>();
defaults.put("spring.cloud.stream.function.bindings." + BusConstants.BUS_CONSUMER + "-in-0",
BusConstants.INPUT);
String destination = environment.getProperty(PREFIX + ".destination", BusConstants.DESTINATION);
defaults.put("spring.cloud.stream.bindings." + BusConstants.INPUT + ".destination", destination);
defaults.put("spring.cloud.stream.bindings." + BusConstants.OUTPUT + ".destination", destination);
if (!environment.containsProperty(PREFIX + ".id")) {
String unresolvedServiceId = IdUtils.getUnresolvedServiceId();
if (StringUtils.hasText(environment.getProperty("spring.profiles.active"))) {
unresolvedServiceId = IdUtils.getUnresolvedServiceIdWithActiveProfiles();
}
defaults.put(PREFIX + ".id", unresolvedServiceId);
}
addOrReplace(environment.getPropertySources(), defaults, DEFAULTS_PROPERTY_SOURCE_NAME, false);两个实例手动配成相同 id 会互相把对方事件当成自己发出而忽略,表现为“定向刷新丢了实例”。传输默认 topic 是 springCloudBus,可用 spring.cloud.bus.destination 改名;本实验用独有 topic 隔离。StreamBusBridge.send 是一次发布,投递语义与消费组行为由 Kafka binder 决定:默认匿名消费组随实例生灭,两个实例共用显式 group 会竞争分区而不是各自广播,广播要求每实例独立 group。显式新组默认 earliest、匿名新组默认 latest,已有位点与重置条件统一见 Stream 的起点、组与广播/竞争;本页保留它对 Bus 广播的影响。
// TODO: configurable mimetype?
this.streamBridge.send(BusConstants.OUTPUT, MessageBuilder.withPayload(event).build());
}消费者 BusConsumer.accept 收到事件后:本地 isForSelf 通过且非自身来源才 publish 到本地监听器;RefreshListener.onApplicationEvent 再调 ContextRefresher.refresh()。ack.enabled=true 时回复 AckRemoteApplicationEvent:
if (this.serviceMatcher.isForSelf(event) && this.publisher != null) {
if (!this.serviceMatcher.isFromSelf(event)) {
this.publisher.publishEvent(event);
}
if (this.properties.getAck().isEnabled()) {
AckRemoteApplicationEvent ack = new AckRemoteApplicationEvent(this, this.serviceMatcher.getBusId(),
destinationFactory.getDestination(this.properties.getAck().getDestinationService()),
event.getDestinationService(), event.getId(), event.getClass());
this.busBridge.ifAvailable(bridge -> bridge.send(ack));
this.publisher.publishEvent(ack);
} @Override
public void onApplicationEvent(RefreshRemoteApplicationEvent event) {
log.info("Received remote refresh request.");
if (serviceMatcher.isForSelf(event)) {
Set<String> keys = this.contextRefresher.refresh();
log.info("Keys refreshed " + keys);
}
else {
log.info("Refresh not performed, the event was targeting " + event.getDestinationService());
}
}
}本票公开实验用真实 embedded Kafka broker(KRaft 单节点、独有 topic)观察:A 发定向 busrefresh/catalog:b,B 从 v1 到 v2、A 保持 v1;随后广播 busrefresh,两端经 Kafka 传播收敛到 v3。A 不刷新是因为目的 pattern catalog:b:** 不匹配 A 的 bus.id,而不是发送被阻止;HTTP 204 只证明端点在本地发布了事件(AbstractBusEndpoint.publish 只调 ApplicationEventPublisher),桥接后的 broker 投递、订阅者刷新与 ACK 都不在响应内,成员快照或准备/提交两阶段也不存在,Bus 刷新不是原子操作。
busenv、monitor 与其他事件入口
POST /actuator/busenv?name=...&value=... 走 EnvironmentBusEndpoint,把键值对随 EnvironmentChangeRemoteApplicationEvent 传播:
// TODO: document params
public void busEnvWithDestination(String name, String value,
@Selector(match = Match.ALL_REMAINING) String[] destinations) {
Map<String, String> params = Collections.singletonMap(name, value);
String destination = StringUtils.arrayToDelimitedString(destinations, ":");
publish(new EnvironmentChangeRemoteApplicationEvent(this, getInstanceId(), getDestination(destination),
params));
}
@WriteOperation
public void busEnv(String name, String value) { // TODO: document params
Map<String, String> params = Collections.singletonMap(name, value);
publish(new EnvironmentChangeRemoteApplicationEvent(this, getInstanceId(), getDestination(null), params));
}接收端 EnvironmentChangeListener 在 INFO 日志输出键和值,再逐项写入本地 EnvironmentManager 管理的内存属性源(插入为最高优先级源)并发布 EnvironmentChangeEvent:
@Override
public void onApplicationEvent(EnvironmentChangeRemoteApplicationEvent event) {
Map<String, String> values = event.getValues();
log.info("Received remote environment change request. Keys/values to update " + values);
for (Map.Entry<String, String> entry : values.entrySet()) {
this.env.setProperty(entry.getKey(), entry.getValue());
}
@ManagedOperation
public void setProperty(String name, String value) {
if (!this.environment.getPropertySources().contains(MANAGER_PROPERTY_SOURCE)) {
synchronized (this.map) {
if (!this.environment.getPropertySources().contains(MANAGER_PROPERTY_SOURCE)) {
MapPropertySource source = new MapPropertySource(MANAGER_PROPERTY_SOURCE, this.map);
this.environment.getPropertySources().addFirst(source);
}
}
}
if (!value.equals(this.environment.getProperty(name))) {
this.map.put(name, value);
publish(new EnvironmentChangeEvent(this.publisher, Collections.singleton(name)));
}与提交 Git 相比差三点:易失(重启即失)、不进配置库历史(无 revert 路径)、不触发 RefreshScope 销毁(@RefreshScope 目标不重建)。它不是秘密分发通道——/actuator/env 的 show-values 脱敏管不到这条 INFO 日志。config-monitor 模块把 Git webhook 转成本地刷新事件再经 Bus 传播;未引入 spring-cloud-config-monitor 依赖的部署不存在 /monitor 端点,不能假设 webhook 已可用。spring.cloud.bus.trace.enabled=true 只把 ACK 与 Sent 事件发给本地监听器、不写入 /trace 存储,Bus 5.0.4 的 TraceListener 存储调用被注释,旧文档“开启 trace 后 /trace 可查”不能照抄:
public void onAck(AckRemoteApplicationEvent event) {
Map<String, Object> trace = getReceivedTrace(event);
// FIXME boot 2 this.repository.add(trace);
}
@EventListener
public void onSend(SentApplicationEvent event) {
Map<String, Object> trace = getSentTrace(event);
// FIXME boot 2 this.repository.add(trace);
}
protected Map<String, Object> getSentTrace(SentApplicationEvent event) {端点暴露与鉴权是另一层:busrefresh/busenv 属写操作,必须与 health 分别授权;未鉴权的 busrefresh 等于把“整组实例重载配置”暴露给内网任意调用方。
部分实例失联与修复责任
发送成功不等于业务生效:广播 busrefresh 返回 204 后,可能同时存在 A 已用新值、B 仍持旧值的混版窗口。本票公开实验把 B 的 Config HTTP 路径注入 503(Kafka 与 B 的 Bus 消费仍正常):事件到达 B、本地刷新执行,但重新拉取配置失败,B 的 Environment 保留旧源;在该实验 max-attempts=1、未启用 DLQ 的处理路径下,事件没有再次触发刷新,B 停在 v1 而 A 已到 v2。这是本次输入下的观察,max-attempts=1 只关闭 binder 内重试,不能泛指所有容器/事务路径都不重投;属性作用域及恢复边界统一见 Stream 的重试、DLQ 与客户端边界。
修复责任分三层:B 自身的重试只覆盖“刷新时的拉取失败”,不覆盖“失败后新事件不再到达自己”;配置库回滚修的是配置内容,不通知任何实例;只有解除故障后对 B 显式刷新(或等下一次提交再广播)才恢复一致。恢复后必须逐实例核查配置版本与业务读值,不能以 broker 已投递或发送端 204 为收敛证据。这个模型与 Nacos 的客户端定期比对/长轮询路径不同:Config/Bus 没有“实例自动对账”的默认机制,把两者互推会得到错误恢复设计。
秘密信息:静态密文不是传输保护
{cipher} 值由 Config Server 在返回 Environment 前解密:密文只存在于配置库静态形态,授权 Config HTTP 返回的是明文。解密失败默认改写为 invalid.<name>=<n/a>:
if (entry.getValue() != null && entry.getValue().toString().startsWith("{cipher}")) {
String value = entry.getValue().toString();
map.remove(key);
try {
value = value.substring("{cipher}".length());
value = encryptor
.locate(this.helper.getEncryptorKeys(name,
StringUtils.arrayToCommaDelimitedString(environment.getProfiles()), value))
.decrypt(this.helper.stripPrefix(value));
}
catch (Exception e) {
if (this.prefixInvalidProperties) {
value = "<n/a>";
name = "invalid." + name;
}
String message = "Cannot decrypt key: " + key + " (" + e.getClass() + ": " + e.getMessage()
+ ")";prefix-invalid-properties=false 会保留去前缀的原始密文值,这不是修复而是把密文当普通值下发。/encrypt、/decrypt 是 Config Server 的 REST 端点(须经鉴权),密钥通过 encrypt.key 或 keystore 提供,与密文分开保管;轮换窗口内新旧密文共存属预期,应安排重加密而非假设无缝。
本票公开实验观察(全部合成凭据):未授权请求 Config Server 配置路径、/encrypt、客户端 refresh 端点分别 401/401/401;{cipher} 密文提交进 Git 后,文件内容不含明文、无关应用(application 名不同)的响应不包含该键;授权读取返回明文属设计;客户端 /state 不含原始秘密、/actuator/env(show-values=never)只见键名;非法密文回退 invalid. 前缀且低优先级值不得静默顶替;新密文提交后刷新恢复。加密保护静态存储,不替代 TLS、管理面隔离、外部密钥托管或审计;这些生产条件本票未运行。
版本回滚:revert 产生新提交而不是还原历史
Git 后端的 version 是读取时 HEAD commit;label 可为分支、tag 或 commit。回滚的标准动作是 git revert 产生内容等于旧值的新提交,或把 label 固定到旧 commit/tag——两者都产生新的读取身份,不是把历史改回原 commit。客户端生效要再过三关:Server 拉取该提交、实例执行刷新、@RefreshScope 目标重建成功。
本票公开实验观察:发布非法 workers=0 后 refresh 200 但业务 500;revert 产生与坏提交不同的新 commit id;再次刷新后 /state 返回恢复值,config.client.version 等于 revert 新提交。非法输入的失败是懒创建——不回滚就刷新的实例持续 500,配置回滚也不补偿已执行的业务效果或已发放的外部凭据。延迟消费的事件可能携带更早的刷新意图,但拉取总是当前 HEAD,因此“按事件时间生效”不成立;最终一致靠逐实例核查,不靠事件顺序。
观测、容量与平台生命周期边界
观测分层:config.client.version/state 反映客户端最后装入的提交身份;业务快照(Policy//state)反映生效对象;两者都齐才能宣称“该实例已在版本 X”。Actuator env 端点只展示当前源,不提供实例间版本分布视图;把 health UP 当作“配置已同步”是观察缺口。配置风暴控制靠发布节奏与刷新预算:一次广播触发每个实例各自拉取 Config Server,N 实例 × M 后端 × 每次拉取的完整 Environment 构成瞬时读放大;没有内置全局速率限制,大集群应分批定向刷新。灰度与强协调是两个合理选择:Bus 广播 + 逐实例验证适合容忍窗口的配置,强一致切换要外推到“先验证后放量”的发布流程,不能指望 Bus 提供协调协议。
平台生命周期:AOT/Native 下 RefreshScope 与运行时刷新受构建期限制,CRaC 检查点恢复后 Environment 与刷新代理的行为需按版本单独验证,本票均未运行;嵌入式 Server(同 JVM 自用)与生产多副本 Config Server 的 HA、认证边界另属部署域。与 Nacos 对照:Nacos 配置中心自带服务端灰度、监听推送与历史回滚 API,其“最后有效快照”是客户端本地兜底;Config/Bus 的对应物是“本地静态文件 + 显式刷新”,两者失败语义不同,比较时必须分开机制不能互换结论。
实验读法与旧内容唯一归属
公开实验入口与边界见 Config/Bus 实验:三个独立 Spring Context(Server、A、B)、真实 loopback HTTP、真实 JGit 临时库与真实 embedded Kafka;同 JVM 共享进程生命周期,不是跨进程/跨故障域验证。六条观察行覆盖初始读取、本地刷新、Bus 定向与广播、部分失败修复、懒失败回滚与秘密边界;证据 JSON 固定在 labs/config-bus/evidence/,通过与否以证据为准。
Boot 通用属性来源、Profile 与绑定校验的唯一讲解仍在 Boot配置:外部来源、绑定与失败,本页只写 ConfigData 导入与刷新链的增量;Nacos 注册、健康与快照切换仍在 Nacos注册发现与配置一致性,本页只保留配置维度的条件对照;U32 的发现/负载均衡模型与本页的配置读取互补,config-first/discovery-first 的适配器选择与发现适配细节归 U35 的 Cloud 适配。旧 Bus/monitor 机制如与本页冲突,以本页固定 5.0.4 源码为准。
闭卷验收
本页 31 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。