先给结论,赶时间看这段就够了:
ConfigChangeListener.checkAndRefresh()上的@Scheduled被注释掉了 ------"30 秒自动刷新"从来没生效过,只能手动调/api/config/refresh- 但示例类的注释里白纸黑字写着"30秒内自动刷新",文档和实现对不上,这个误导性比 bug 本身更坑
- 真正决定"改了能不能生效"的是刷新边界 :只有
@RefreshScope的 Bean 会重建,普通单例里的@Value永远不变ConfigRefreshEvent是个死类 ------定义了、写了getChangedProperties(),但发布它的那行代码也是注释状态- 数据库配置源用
addFirst注册,优先级高于 application.yml,库里配错了本地救不回来- 启动阶段要单独配一套
config.datasource,跟主数据源是两套
运维在配置中心改了个开关,问我:"不是说 30 秒自动生效吗?我都等五分钟了。"
我说你调一下刷新接口。他调完就生效了。
我当时以为是偶发。后来翻源码才发现------自动刷新这个功能,压根就没在跑。
不是配置没开,不是线程池满了,是那条定时任务上的 @Scheduled 注解,被注释掉了。
java
//@Scheduled(fixedDelayString = "${forge.config.refresh-interval:30000}")
public void checkAndRefresh() {
boolean refreshed = configRefresher.refresh();
}
而这个模块的示例类 AppConfigExample,注释里是这么写的:
java
/**
* 使用说明:
* 3. 数据库中修改配置后,30秒内自动刷新(或调用刷新接口立即生效)
*/
文档说有,代码里没有。 这种不一致比功能缺失更害人------你会一直以为它在工作,直到某天线上有个开关死活不生效,才开始怀疑人生。
一、整体结构:两套体系叠在一起
这个模块 2379 行,其实是两件事叠在一个 jar 里:
arduino
forge-starter-config
│
├── config/ 【体系 A:配置中心】
│ ├── entity/SysConfigGroup.java 配置分组
│ ├── service/ConfigManagerService.java 配置读写
│ ├── service/ConfigCenterService.java 配置中心门面
│ ├── service/ConfigSyncService.java 配置同步
│ ├── controller/ConfigManageController.java
│ ├── converter/ConfigConverter.java JSON 拍平
│ └── security/CryptoConfigSanitizer.java 密钥脱敏
│
└── property/ 【体系 B:动态刷新】
├── DbPropertySourcePostProcessor.java EnvironmentPostProcessor,启动期注入
├── DbConfigLoader.java 两表合并加载
├── DbPropertySource.java 自定义 PropertySource
├── refresh/ConfigRefresher.java 刷新主控
├── refresh/ConfigChangeListener.java ← 坑在这里
├── scope/RefreshScopeImpl.java 自定义 Scope
├── event/ConfigRefreshEvent.java ← 死类
├── controller/ConfigRefreshController.java
└── config/PropertyRefreshAutoConfiguration.java
数据流向:
scss
启动期:
EnvironmentPostProcessor
→ 读 config.datasource(独立数据源配置)
→ 建一个临时 DataSource
→ DbConfigLoader.load() 查两张表、内存合并
→ environment.getPropertySources().addFirst(dbPropertySource) ← 优先级最高
运行期:
配置中心改了值 → (理想中)30 秒后 ConfigChangeListener 触发
→ ConfigRefresher.refresh()
→ ① 重新查库 ② diff ③ 更新 PropertySource
→ ④ refreshScope.refreshAll() ⑤ 发事件
→ (现实)必须手动 POST /api/config/refresh
二、坑 1:自动刷新的注解是注释状态(最直接的原因)
ConfigChangeListener 全貌:
java
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "forge.config", name = "auto-refresh", havingValue = "true", matchIfMissing = true)
public class ConfigChangeListener {
private final ConfigRefresher configRefresher;
/**
* 定时刷新配置
* 默认每30秒检查一次
*/
//@Scheduled(fixedDelayString = "${forge.config.refresh-interval:30000}")
public void checkAndRefresh() {
//log.debug("开始检查配置变更...");
boolean refreshed = configRefresher.refresh();
if (refreshed) {
log.info("检测到配置变更,已自动刷新");
}
}
}
三个细节:
@Scheduled注释掉了 → 方法永远不会被调度- 方法体里的第一行
log.debug也注释掉了 → 说明这不是临时调试,是整体停用 - 类上的
@ConditionalOnProperty(auto-refresh, matchIfMissing = true)还在 → 配置开关显示为开启状态,给人"功能在工作"的错觉
更讽刺的是 PropertyRefreshAutoConfiguration 上还挂着 @EnableScheduling:
java
@Configuration
@EnableScheduling
public class PropertyRefreshAutoConfiguration { ... }
调度开关开了,但没有一个可调度的任务。
为什么会被注释掉
我不知道当时的具体原因,但可以推测------checkAndRefresh() 每 30 秒就要查一遍全表并做 JSON 拍平,这个开销不小。而且它只比对 Map 是否相等,没有任何版本号或更新时间戳的轻量判断:
java
Map<String, String> oldProperties = new HashMap<>(dbPropertySource.getSource());
if (oldProperties.equals(newProperties)) {
return false;
}
注释掉大概是为了先止血。但注释掉之后没有同步更新文档,于是留下了这个陷阱。
怎么改
方案一:把注解放回来,但先解决开销问题------加个版本号字段,只有版本号变了才全量加载:
sql
SELECT MAX(update_time) FROM sys_config WHERE config_type = 'Y'
方案二(更推荐):别用轮询,用事件驱动。配置中心改完直接发一条消息(Redis Pub/Sub 或 MQ),各实例收到就刷新。轮询天然有延迟和开销,事件驱动几乎是零成本。
方案三:如果确实不想自动刷新,就把文档和示例注释改掉,别让人以为有。
三、坑 2:刷新边界------到底哪些 Bean 会变
就算你手动调了刷新接口,也不是所有配置都会变。这个边界必须搞清楚。
先看刷新动作做了什么:
java
// ConfigRefresher.refresh()
Map<String, String> newProperties = loadPropertiesFromDb(); // 1. 重新查库
DbPropertySource dbPropertySource = getDbPropertySource();
Map<String, String> oldProperties = new HashMap<>(dbPropertySource.getSource());
if (oldProperties.equals(newProperties)) {
return false; // 2. 没变化就跳过
}
dbPropertySource.getSource().clear();
dbPropertySource.getSource().putAll(newProperties); // 3. 更新 PropertySource
refreshScope.refreshAll(); // 4. 清 refresh scope 缓存
第 4 步的实现就一行:
java
public void refreshAll() {
destructionCallbacks.values().forEach(Runnable::run);
destructionCallbacks.clear();
scopedObjects.clear();
}
它只清空了缓存。 下次通过代理访问这个 Bean 时,ObjectFactory.getObject() 重新创建实例,走完整的 Bean 生命周期,@ConfigurationProperties 重新绑定一次。
所以边界是这样的:
| 用法 | 会不会刷新 | 原因 |
|---|---|---|
@RefreshScope + @ConfigurationProperties |
✅ 会 | Bean 被销毁重建,重新绑定 |
普通单例 + @ConfigurationProperties(无 @RefreshScope) |
❌ 不会 | 不在 refresh scope,缓存里根本没有它 |
普通单例 + @Value("${xxx}") 字段注入 |
❌ 不会 | 值在 Bean 创建时就固化了 |
单例里 @Autowired 一个 @RefreshScope Bean |
✅ 会 | 注入的是代理,每次访问都重新解析 |
最后一行是这个方案能工作的关键,得看自定义注解怎么写的:
java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Scope(scopeName = "refresh", proxyMode = ScopedProxyMode.TARGET_CLASS)
@Documented
public @interface RefreshScope {
}
proxyMode = ScopedProxyMode.TARGET_CLASS ------ 这一条写对了。
它的意思是:注入到其他 Bean 时,注入的不是真实对象,而是一个代理。每次调用代理方法时,代理才去 refresh scope 里拿当前的真实对象。所以缓存清空后,下一次方法调用就拿到新对象了。
对比一下 Spring 原生 @Scope 的默认行为------它的 proxyMode 默认是 ScopedProxyMode.DEFAULT,在没有额外配置时等同于不创建代理 。如果是那样,注入的就是裸对象,刷新时旧实例被清掉,但持有它的单例还攥着旧引用,刷新完全失效,而且不报错。
这是自己实现 RefreshScope 时最容易踩的坑,这个模块绕过去了。
判断你的配置会不会刷新
一句话:看你拿配置的方式。
java
// ❌ 这样永远不变
@Service
public class OrderService {
@Value("${order.timeout}")
private int timeout;
}
// ✅ 这样会变
@Component
@RefreshScope
@ConfigurationProperties(prefix = "order")
@Data
public class OrderConfig {
private int timeout;
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderConfig config; // 注入的是代理
public void check() {
int timeout = config.getTimeout(); // 每次都是最新的
}
}
四、坑 3:ConfigRefreshEvent 是个死类
ConfigRefreshEvent 写得很完整:
java
public class ConfigRefreshEvent extends ApplicationEvent {
private final Map<String, String> oldProperties;
private final Map<String, String> newProperties;
public Map<String, String> getChangedProperties() {
Map<String, String> changed = new java.util.HashMap<>();
newProperties.forEach((key, newValue) -> {
String oldValue = oldProperties.get(key);
if (!newValue.equals(oldValue)) {
changed.put(key, newValue);
}
});
return changed;
}
}
有新旧快照、有变更项提取。看起来你可以在任何地方监听它:
java
@EventListener
public void onConfigRefresh(ConfigRefreshEvent event) {
// 配置变了,做点什么
}
但没有任何地方发布过它。 ConfigRefresher.refresh() 里这一行是注释状态:
java
// 6. 发布配置变更事件
//publishRefreshEvent(oldProperties, newProperties);
而 publishRefreshEvent 方法本身还在,也没被别处调用:
java
private void publishRefreshEvent(Map<String, String> oldProperties, Map<String, String> newProperties) {
ConfigRefreshEvent event = new ConfigRefreshEvent(this, oldProperties, newProperties);
applicationContext.publishEvent(event);
}
同样的情况还有 publishEnvironmentChangeEvent------为了兼容 Spring Cloud 准备的,方法体整个被注释掉了:
java
private void publishEnvironmentChangeEvent(Map<String, String> oldProperties, Map<String, String> newProperties) {
// Set<String> changedKeys = getChangedKeys(oldProperties, newProperties);
// if (!changedKeys.isEmpty()) {
// EnvironmentChangeEvent event = new EnvironmentChangeEvent(changedKeys);
// applicationContext.publishEvent(event);
// }
}
后果 :你想在配置变更时做点副作用(比如重建连接池、清本地缓存),没有任何钩子可用 。只能自己改 ConfigRefresher 把那行注释打开。
五、坑 4:数据库配置优先级高于本地配置文件
DbPropertySourcePostProcessor 里这一行:
java
environment.getPropertySources().addFirst(dbPropertySource);
addFirst = 最高优先级。
这意味着数据库里的配置会覆盖 application.yml、application-prod.yml、环境变量、命令行参数------全都被它压住。
好处很明确:线上改配置不用重新打包。
风险也很明确:如果库里被写进了一条错误配置,你在本地、在启动参数里怎么改都救不回来,只能去数据库删掉那条记录。
而且这个模块在 DbConfigLoader 里做了一层密钥过滤------唯一的例外:
java
private static void putIfPresent(Map<String, String> result, Object keyObj, Object valueObj) {
if (keyObj != null && valueObj != null) {
String key = keyObj.toString();
if (CryptoDeploymentSecretPolicy.isDeploymentSecretPropertyKey(key)) {
log.warn("已忽略数据库中的部署级 crypto 密钥配置: {}", key);
return; // ← 密钥不允许从数据库覆盖
}
result.put(key, valueObj.toString());
}
}
数据库不能覆盖部署级加密密钥。 这个设计是对的------密钥如果也能从库里改,那拿到数据库写权限就等于拿到解密能力,加密就形同虚设了。
建议
生产上加一条约束:数据库配置源只放"业务开关",不放"基础设施配置"(数据源地址、端口、密钥路径这类)。后者一旦被库里的值覆盖,排查起来非常痛苦------你盯着 yml 文件看了半天,完全想不到真值来自数据库。
六、坑 5:启动期要单独配一套数据源
DbPropertySourcePostProcessor 是 EnvironmentPostProcessor,在 Spring 容器启动之前执行。这时候主数据源还没创建,所以它自己造了一个:
java
ConfigDbProperties configDbProperties = new ConfigDbProperties();
Binder.get(environment).bind("config.datasource", Bindable.ofInstance(configDbProperties));
if (configDbProperties.getUrl() == null || configDbProperties.getUsername() == null
|| configDbProperties.getPassword() == null) {
return; // ← 没配就静默跳过,不报错
}
DataSource dataSource = DataSourceBuilder.create()
.url(configDbProperties.getUrl())
.username(configDbProperties.getUsername())
.password(configDbProperties.getPassword())
.driverClassName(configDbProperties.getDriverClassName())
.build();
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
三个要注意的点:
1. 这是第二套数据源配置。 你的 yml 里会有两组:
yaml
spring:
datasource: # 主数据源
url: jdbc:mysql://.../forge
config:
datasource: # 配置库数据源(通常指向同一个库)
url: jdbc:mysql://.../forge
username: root
password: xxx
大多数人会指向同一个库,那就是同样的连接信息写两遍 。漏配第二套不会报错------看上面那段 return,静默跳过,然后你会奇怪为什么数据库配置一个都没加载。
2. 启动期的 DataSource 不受容器管理。 没有连接池参数、没有健康检查、用完也不关闭。好在它只查两次(启动一次,之后刷新走容器里的 JdbcTemplate),实际影响有限。
3. 加载失败会直接抛异常阻断启动:
java
try {
DbPropertySource dbPropertySource = new DbPropertySource(DbConfigLoader.load(jdbcTemplate));
environment.getPropertySources().addFirst(dbPropertySource);
} catch (Exception e) {
throw new RuntimeException(e);
}
数据库连不上 → 应用起不来。这个取舍可以理解(配置都拿不到,起来也没意义),但要知道有这个依赖。
七、它做得对的地方
1. 两张表合并 + 分组覆盖,且降级不阻断
DbConfigLoader 同时兼容两张表:
java
result.putAll(loadSysConfig(jdbcTemplate)); // 基础层:sys_config 散配置
result.putAll(loadConfigGroups(jdbcTemplate)); // 覆盖层:sys_config_group JSON 拍平
addCamelCaseVariants(result); // 追加驼峰变体
三层设计都有降级:
sys_config查失败 → 尝试旧表config_properties→ 再失败才记 errorsys_config_group表不存在 →log.warn跳过,不影响基础层- 单个分组 JSON 解析失败 → 跳过该分组,其他分组照常加载
"表不存在就跳过"这个细节很关键------旧库升级上来时不会因为这个模块直接起不来。
2. 自动兼容两种 key 风格
java
private static String convertToCamelCase(String key) {
// max-login-attempts -> maxLoginAttempts
}
数据库里存 max-login-attempts,@ConfigurationProperties 绑定 maxLoginAttempts,两边都能匹配上。省掉了大量"为什么我配了不生效"的排查。
3. 刷新是 synchronized + 先 diff
java
public synchronized boolean refresh() {
...
if (oldProperties.equals(newProperties)) {
return false;
}
串行执行避免并发刷新互相踩,diff 避免无意义的全量重建。
4. 刷新端点有权限校验
java
@PostMapping("/refresh")
public Map<String, Object> refresh() {
SessionHelper.assertAdmin("只有超级管理员可以刷新系统配置");
...
}
不过要注意------这个 Controller 的条件是:
java
@ConditionalOnProperty(prefix = "forge.config", name = "enable-refresh-endpoint",
havingValue = "true", matchIfMissing = true)
matchIfMissing = true,默认开启。 生产环境建议显式关掉或加网关层限制。
八、接入实战:4 步让配置真的能刷新
第 1 步:配数据源
yaml
config:
datasource:
url: jdbc:mysql://localhost:3306/forge?useUnicode=true&characterEncoding=utf8
username: root
password: your-password
driver-class-name: com.mysql.cj.jdbc.Driver
别忘了这一套,没配的话整个模块静默失效。
第 2 步:定义配置类
java
@Component
@RefreshScope // ← 必须加,否则不刷新
@ConfigurationProperties(prefix = "order")
@Data
public class OrderConfig {
private Integer timeout = 30;
private Boolean autoConfirm = false;
private List<String> notifyChannels = new ArrayList<>();
}
第 3 步:注入使用
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderConfig config; // 注入的是代理,每次访问都拿最新值
public void handleTimeout() {
int timeout = config.getTimeout();
...
}
}
⚠️ 别把配置值缓存成局部变量或字段:
java
// ❌ 这样刷新后还是旧值
private final int timeout;
public OrderService(OrderConfig config) {
this.timeout = config.getTimeout();
}
第 4 步:触发刷新
因为这个模块的定时任务是停用的,你需要自己选一种触发方式:
bash
# 方式 A:手动调接口
curl -X POST http://localhost:8580/api/config/refresh \
-H "Authorization: Bearer <admin-token>"
java
// 方式 B:自己补一个定时任务(推荐,把注解加回来)
@Component
@RequiredArgsConstructor
public class ConfigAutoRefreshTask {
private final ConfigRefresher refresher;
@Scheduled(fixedDelayString = "${forge.config.refresh-interval:30000}")
public void refresh() {
refresher.refresh();
}
}
java
// 方式 C:配置中心改完直接调(最及时)
@Service
@RequiredArgsConstructor
public class ConfigCenterServiceExt {
private final ConfigRefresher refresher;
public void updateAndBroadcast(String groupCode, String json) {
// 1. 写库
// 2. 通知所有实例(Redis Pub/Sub / MQ / 或循环调各实例的刷新接口)
}
}
多实例部署时方式 B 和 C 都要保证每个实例都被触发------这个模块的刷新是单机的,改完库只有调用刷新接口的那台机器会更新。
九、可带走的 7 条诀窍
-
自己实现 RefreshScope 时,
proxyMode必须显式写TARGET_CLASS。 Spring 原生@Scope默认是ScopedProxyMode.DEFAULT(等同于不代理),那样注入的是裸对象,刷新后持有者还攥着旧引用------不报错,就是不生效。 -
@Value注入到单例字段的配置,任何刷新机制都救不了。 想动态就得走@ConfigurationProperties+ refresh scope。 -
"文档说有"和"代码里有"是两件事。 用框架能力前先去源码里确认那个注解是不是注释状态------尤其是定时任务、监听器这类"看不见摸不着"的东西。
-
数据库配置源别用
addFirst无差别覆盖,或者至少约定:库里只放业务开关,基础设施配置留在配置文件里。否则排查时会怀疑人生。 -
密钥类配置必须排除在数据库配置之外,否则拿到库写权限就等于拿到解密能力。
-
配置刷新用事件驱动,别用轮询。 轮询要么有延迟(30 秒对开关类配置太久了),要么有开销(全表扫描 + JSON 拍平)。改完发条消息,几乎是零成本。
-
多实例部署时,"改了数据库"不等于"所有实例都更新了"。 每个实例有自己的 PropertySource 副本,必须各自触发刷新。
最后
这个模块 2379 行,方案本身是对的 ------proxyMode = TARGET_CLASS 这个最容易错的地方它没错,两表合并、降级跳过、密钥过滤这些工程细节也都想到了。
问题出在收尾 :定时任务停用了但文档没改、事件类写好了但发布被注释、@EnableScheduling 开着却没有任务。这些都是"做了一半"的痕迹。
一句话总结:骨架搭对了,收尾没做完。
代码在这:
- Gitee:
https://gitee.com/ForgeLab/forge-admin - GitHub:
https://github.com/yaomindong1996/forge-admin
如果你的项目里也有"改了配置要重启才生效"的说法,点个赞吧 ------下次我拆 forge-starter-cache(Redis 缓存 + Redisson 分布式锁),那个模块的锁续期有个更隐蔽的问题。
评论区聊聊:你们的配置刷新是轮询还是事件驱动?有没有遇到过"文档说有、代码里其实没有"的功能------我赌这种坑每个人都踩过一次。