项目开发中,每次修改配置(比如调整接口超时时间、限流阈值)都要重启服务,不仅开发效率低,线上重启还会导致短暂不可用。
虽然Spring Cloud Config、Apollo这类配置中心能解决问题,但对于中小项目来说太重了——要部署服务,成本太高。
今天分析一个轻量级方案,基于SpringBoot原生能力实现配置热更新,不用额外依赖,代码量不到200行。
一、为什么需要“轻量级”热更新?
先说说传统配置方案的痛点
痛点1:改配置必须重启服务
开发环境中,改个日志级别都要重启服务,浪费时间;生产环境更麻烦,重启会导致流量中断,影响用户体验。
痛点2:重量级配置中心成本高
Spring Cloud Config、Apollo功能强大,但需要单独部署服务、维护元数据,小项目用不上这么复杂的功能,纯属“杀鸡用牛刀”。
痛点3:@Value注解不支持动态刷新
即使通过@ConfigurationProperties
绑定配置,默认也不会自动刷新,必须结合@RefreshScope
,但@RefreshScope
会导致Bean重建,可能引发状态丢失。
我们需要什么?
• 无需额外依赖,基于SpringBoot原生API
• 支持properties/yaml文件热更新
• 不重启服务,修改配置后自动生效
• 对业务代码侵入小,改造成本低
二、核心原理:3个关键技术点
轻量级热更新的实现依赖SpringBoot的3个原生能力,不需要引入任何第三方框架
2.1 配置文件监听:WatchService
Java NIO提供的WatchService
可以监听文件系统变化,当配置文件(如application.yml
)被修改时,能触发回调事件。
2.2 属性刷新:Environment与ConfigurationProperties
Spring的Environment
对象存储了所有配置属性,通过反射更新其内部的PropertySources
,可以实现配置值的动态替换。
同时,@ConfigurationProperties
绑定的Bean需要重新绑定属性,这一步可以通过ConfigurationPropertiesBindingPostProcessor
实现。
2.3 事件通知:ApplicationEvent
自定义一个ConfigRefreshEvent
事件,当配置更新后发布事件,业务代码可以通过@EventListener
接收通知,处理特殊逻辑(如重新初始化连接池)。
三、手把手实现:不到200行代码
结构:
3.1 第一步:监听配置文件变化
创建ConfigFileWatcher
类,使用WatchService
监听application.yml
或application.properties
的修改
import lombok.extern.slf4j.Slf4j; /** * @BelongsProject: * @BelongsPackage: com.kaigejava.common.config * @Author: kaigejava * @CreateTime: 2025-07-24 15:41 * @Description: 监听配置文件变化 * 创建ConfigFileWatcher类,使用WatchService监听application.yml或application.properties的修改 * @Version: 1.0 */ import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.util.ResourceUtils; import java.io.IOException; import java.nio.file.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @Slf4j public class ConfigFileWatcher { private final String configPath = "classpath:application.yml"; private WatchService watchService; private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ConfigRefreshHandler refreshHandler; private long lastProcessTime; private final long EVENT_DEBOUNCE_TIME = 500; public ConfigFileWatcher(ConfigRefreshHandler refreshHandler) { this.refreshHandler = refreshHandler; } @PostConstruct public void init() throws IOException { Resource resource = new FileSystemResource(ResourceUtils.getFile(configPath)); Path configDir = resource.getFile().toPath().getParent(); String fileName = resource.getFilename(); watchService = FileSystems.getDefault().newWatchService(); configDir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); executor.submit(() -> { while (true) { try { WatchKey key = watchService.take(); if (System.currentTimeMillis() - lastProcessTime < EVENT_DEBOUNCE_TIME) { continue; } for (WatchEvent<?> event : key.pollEvents()) { WatchEvent.Kind<?> kind = event.kind(); if (kind == StandardWatchEventKinds.OVERFLOW) { continue; } Path changedFile = (Path) event.context(); if (changedFile.getFileName().toString().equals(fileName)) { log.info("检测到配置文件修改:{}", fileName); refreshHandler.refresh(); } } boolean valid = key.reset(); if (!valid) break; } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); log.info("配置文件监听器启动成功,监听路径:{}", configDir); } @PreDestroy public void destroy() { executor.shutdownNow(); try { watchService.close(); } catch (IOException e) { log.error("关闭WatchService失败", e); } } }
需要注意:classpath:application.yml 这个需要根据自己项目修改成自己的。
比如有些喜欢写成:application.yaml
3.2 第二步:实现配置刷新逻辑
创建ConfigRefreshHandler
类,核心功能是更新Environment
中的属性,并通知@ConfigurationProperties
Bean刷新
import com.kaigejava.config.hotrefresh.norefreshannotation.NoRefresh; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.PropertySource; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.*; @Component @Slf4j public class ConfigRefreshHandler implements ApplicationContextAware { @Autowired private ConfigurableEnvironment environment; private ApplicationContext applicationContext; public void refresh() { try { Properties properties = loadConfigFile(); Set<String> changeKeys = updateEnvironment(properties); if (!changeKeys.isEmpty()) { rebindConfigurationProperties(); } applicationContext.publishEvent(new ConfigRefreshedEvent(this, changeKeys)); log.info("配置文件刷新完成"); } catch (Exception e) { log.error("配置文件刷新失败", e); } } private Properties loadConfigFile() throws IOException { Resource resource = new ClassPathResource("application.yml"); YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean(); yamlFactory.setResources(resource); Properties properties = yamlFactory.getObject(); if (properties == null) { throw new IOException("Failed to load configuration file"); } return properties; } private Set<String> updateEnvironment(Properties properties) { Set<String> changedKeys = new HashSet<>(); PropertySource<?> appConfig = null; for (PropertySource<?> ps : environment.getPropertySources()) { if (ps.getName().contains("application.yml")) { appConfig = ps; break; } } if (appConfig != null && appConfig instanceof MapPropertySource) { Map<String, Object> sourceMap = new HashMap<>(((MapPropertySource) appConfig).getSource()); properties.forEach((k, v) -> { String key = k.toString(); Object oldValue = sourceMap.get(key); if (!Objects.equals(oldValue, v)) { changedKeys.add(key); } sourceMap.put(key, v); }); environment.getPropertySources().replace(appConfig.getName(), new MapPropertySource(appConfig.getName(), sourceMap)); } else { // environment.getPropertySources().addFirst(new MapPropertySource("application.yml", properties)); } return changedKeys; } private void rebindConfigurationProperties() { String[] beanNames = applicationContext.getBeanNamesForAnnotation(ConfigurationProperties.class); for (String beanName : beanNames) { Object bean = applicationContext.getBean(beanName); Class<?> clazz = bean.getClass(); // 检查是否带有 @NoRefresh 注解 if (clazz.isAnnotationPresent(NoRefresh.class)) { log.info("跳过带有 @NoRefresh 注解的配置Bean:{}", beanName); continue; } // 跳过 GenConfig,避免报错 if (bean.getClass().getName().equals("com.kaigejava.generator.config.GenConfig") || bean.getClass().getName().equals("com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceWrapper")) { log.info("跳过 GenConfig 配置Bean刷新:{}", beanName); continue; } // 正常进行绑定 ConfigurationProperties annotation = clazz.getAnnotation(ConfigurationProperties.class); if (annotation != null) { String prefix = annotation.prefix(); try { Binder.get(environment).bind(prefix, Bindable.ofInstance(bean)); log.info("刷新配置Bean:{}", beanName); } catch (Exception e) { log.warn("跳过绑定失败的Bean [{}],原因:{}", beanName, e.getMessage()); } } } } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } }
需要注意:NoRefresh 这个是自定义注解.作用是哪些类可以不用
// 跳过 GenConfig,避免报错 if (bean.getClass().getName().equals("com.kaigejava.generator.config.GenConfig") || bean.getClass().getName().equals("com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceWrapper")) { log.info("跳过 GenConfig 配置Bean刷新:{}", beanName); continue; }
这段代码是如果不使用注解的话,可以使用完整路径来过滤。
ConfigRefreshedEvent
这个类代码如下:
import org.springframework.context.ApplicationEvent; import java.util.Set; /** * @BelongsProject: * @BelongsPackage: com.kaigejava.business.config.autorefresh * @Author: kaigejava * @CreateTime: 2025-07-24 16:40 * @Description: 创建ConfigRefreshHandler类,核心功能是更新Environment中的属性,并通知@ConfigurationProperties Bean刷新 * @Version: 1.0 */ /** * 自定义配置刷新事件 */ public class ConfigRefreshedEvent extends ApplicationEvent { // 存储变化的配置键(可选,方便业务判断哪些配置变了) private final Set<String> changedKeys; public ConfigRefreshedEvent(Object source, Set<String> changedKeys) { super(source); this.changedKeys = changedKeys; } // 获取变化的配置键 public Set<String> getChangedKeys() { return changedKeys; } }
3.3 第三步:注册监听器Bean
在SpringBoot配置类中注册ConfigFileWatcher
,使其随应用启动。这里使用类名:HotRefreshConfig
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.io.IOException; @Configuration public class HotRefreshConfig { @Bean public ConfigFileWatcher configFileWatcher(ConfigRefreshHandler refreshHandler) throws IOException { return new ConfigFileWatcher(refreshHandler); } }
3.4 第四步:使用@ConfigurationProperties绑定属性
创建业务配置类,用@ConfigurationProperties
绑定配置,无需额外注解即可支持热更新
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Data @Component @ConfigurationProperties(prefix = "business.config") // 绑定配置前缀 public class ReadConfigurationPropertiesAppConfig { private int timeout = 3000; // 默认超时时间3秒 private int maxRetries = 2; // 默认重试次数2次 //test-key private String testKey ; }
说明:
其中的testKey是对应配置文件的。
3.5 第五步:配置文件添加对应key
在application.yml 中添加如下配置
# 业务相关配置 business: # 业务动态配置相关 config: test-key: kaigejava test value 123
3.6 第五步:测试热更新效果
import com.kaigejava.business.config.hotrefresh.ReadConfigurationPropertiesAppConfig; import com.kaigejava.business.config.hotrefresh.ConfigRefreshedEvent; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController @Slf4j public class TestConfigController { @Autowired private ReadConfigurationPropertiesAppConfig appConfig; @GetMapping("/config") public ReadConfigurationPropertiesAppConfig getConfig() { log.info("Accessing /config, testKey = {}", appConfig.getTestKey()); return appConfig; // 返回当前配置 } // 监听配置刷新事件,可进行业务特殊处理 @EventListener(ConfigRefreshedEvent.class) public void appConfigUpdate(ConfigRefreshedEvent event) { /* event.getChangedKeys().forEach( key -> log.info("配置项 {} 发生变化", key) );*/ /*System.out.println("配置项发生变化,当前配置:" + appConfig.toString()); event.getChangedKeys().forEach( key -> System.out.println("配置项发生变化,当前配置:" + key) );*/ } }
然后通过http://localhsot:端口/config就可以看到效果了。
四、入坑须知
如果项目中开启了修改代码后自动部署。可以先注释掉。因为热部署会影响测试结果。
关闭热部署方法:
检查pom文件
查看pom文件中是否存在spring-boot-devtools 如果存在,注释掉
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> </dependency>
2.检查配置文件
如果配置文件中配置了
# 服务模块 devtools: restart: # 热部署开关 enabled: true
把true修改成false.