数据库改了配置,说好的 30 秒自动刷新根本没跑:那条 @Scheduled 是注释状态

先给结论,赶时间看这段就够了:

  1. ConfigChangeListener.checkAndRefresh() 上的 @Scheduled 被注释掉了 ------"30 秒自动刷新"从来没生效过,只能手动调 /api/config/refresh
  2. 但示例类的注释里白纸黑字写着"30秒内自动刷新",文档和实现对不上,这个误导性比 bug 本身更坑
  3. 真正决定"改了能不能生效"的是刷新边界 :只有 @RefreshScope 的 Bean 会重建,普通单例里的 @Value 永远不变
  4. ConfigRefreshEvent 是个死类 ------定义了、写了 getChangedProperties(),但发布它的那行代码也是注释状态
  5. 数据库配置源用 addFirst 注册,优先级高于 application.yml,库里配错了本地救不回来
  6. 启动阶段要单独配一套 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("检测到配置变更,已自动刷新");
        }
    }
}

三个细节:

  1. @Scheduled 注释掉了 → 方法永远不会被调度
  2. 方法体里的第一行 log.debug 也注释掉了 → 说明这不是临时调试,是整体停用
  3. 类上的 @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.ymlapplication-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:启动期要单独配一套数据源

DbPropertySourcePostProcessorEnvironmentPostProcessor在 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 → 再失败才记 error
  • sys_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 条诀窍

  1. 自己实现 RefreshScope 时,proxyMode 必须显式写 TARGET_CLASS Spring 原生 @Scope 默认是 ScopedProxyMode.DEFAULT(等同于不代理),那样注入的是裸对象,刷新后持有者还攥着旧引用------不报错,就是不生效

  2. @Value 注入到单例字段的配置,任何刷新机制都救不了。 想动态就得走 @ConfigurationProperties + refresh scope。

  3. "文档说有"和"代码里有"是两件事。 用框架能力前先去源码里确认那个注解是不是注释状态------尤其是定时任务、监听器这类"看不见摸不着"的东西。

  4. 数据库配置源别用 addFirst 无差别覆盖,或者至少约定:库里只放业务开关,基础设施配置留在配置文件里。否则排查时会怀疑人生。

  5. 密钥类配置必须排除在数据库配置之外,否则拿到库写权限就等于拿到解密能力。

  6. 配置刷新用事件驱动,别用轮询。 轮询要么有延迟(30 秒对开关类配置太久了),要么有开销(全表扫描 + JSON 拍平)。改完发条消息,几乎是零成本。

  7. 多实例部署时,"改了数据库"不等于"所有实例都更新了"。 每个实例有自己的 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 分布式锁),那个模块的锁续期有个更隐蔽的问题。

评论区聊聊:你们的配置刷新是轮询还是事件驱动?有没有遇到过"文档说有、代码里其实没有"的功能------我赌这种坑每个人都踩过一次。

相关推荐
君顾11 小时前
西安24小时自助健身房系统软件开发实战:需求分析与技术落地指南
java·开发语言·健身房
Dawson Zhu1 小时前
大模型 Agent 记忆系统五大技术路线解析与工程选型指南
人工智能·语言模型·架构·aigc·agi
右耳朵猫AI1 小时前
Rust周刊2026W38 | mold重写Rust、认证级Rust裸机、Slint 1.18发布、lint提速3133倍
后端·rust·系统编程
Dawson Zhu1 小时前
Palantir Foundry 架构深度解析:数据、本体与AI的三层协同
人工智能·语言模型·架构·aigc·agi
starzy19901 小时前
Flink Session Window 详解及代码实现:从用户会话切割到窗口合并机制
java·flink
Dawson Zhu2 小时前
Agent 响应延迟优化:从推理引擎到系统架构的四层优化模型
人工智能·语言模型·架构·aigc·agi
码流子2 小时前
智慧高速产品集落地全解析:一套方案打通车道接入、收费、管控与安全监测
大数据·人工智能·物联网·架构·系统架构