问题是这么来的
最近在做商品库存预警的配置管理,我们用的是 Nacos 做配置中心。按理说很简单的事情,配置类上加个 @RefreshScope,Nacos 里改完配置发布,应用就能自动刷新,对吧?
一开始确实也是这么干的:
java
@RefreshScope
@ConfigurationProperties(prefix = "inventory")
public class InventoryAlertConfig {
private String notifyEmail; // 这个能刷新
private int defaultThreshold; // 这个也能刷新
// 但是这个就不行了
private Map<String, Map<String, AlertRule>> warehouses;
}
notifyEmail 和 defaultThreshold 这种简单属性改完就生效,但是 warehouses 这个复杂的嵌套 Map 怎么都刷不上。当时在 Nacos 里改了配置,发布了,日志里也看到 EnvironmentChangeEvent 事件触发了,但就是新配置不生效。
先看看正常情况下的刷新流程
我们先理解一下 @RefreshScope 到底是怎么工作的。
整个过程其实就是把旧 Bean 扔掉,然后重新创建一个新的。Spring 会用 Binder 把最新的配置绑定到新 Bean 上。
Spring 的刷新机制其实挺有意思的
这里稍微展开说说 @RefreshScope 背后的机制,理解了这个,后面遇到问题就好排查了。
@RefreshScope 本质上是一个特殊的 Bean 作用域,和我们常见的 @Singleton、@Prototype 是一个层级的东西。它的特殊之处在于,这个作用域下的 Bean 可以在运行时被"刷新"。
Spring 用了一个挺巧妙的设计:它不是配置一变就立刻去刷新所有 Bean,而是先把它们标记为"脏数据"(dirty),等到下次真正用到这个 Bean 的时候,才去重新创建。这样做的好处是避免了大量无用的 Bean 重建,毕竟有些配置类可能根本就不会被用到。
但是这个机制有个前提:Spring 得知道怎么重新创建这个 Bean。对于简单的配置类,这很容易:
java
// Spring 能轻松处理这种
@RefreshScope
@ConfigurationProperties(prefix = "inventory")
public class SimpleConfig {
private String email; // 调用 setEmail(String)
private int threshold; // 调用 setThreshold(int)
private boolean enabled; // 调用 setEnabled(boolean)
}
Spring 的 Binder 会从 Environment 里读取 inventory.email、inventory.threshold 这些配置,然后调用对应的 setter 方法。这个过程很直接。
但是遇到复杂泛型就麻烦了:
java
// 这个就比较头疼
private Map<String, Map<String, AlertRule>> warehouses;
public void setWarehouses(Map<String, Map<String, AlertRule>> warehouses) {
this.warehouses = warehouses;
}
Java 的泛型在运行时会被擦除,Binder 拿到这个 setter 方法的时候,它看到的参数类型其实是 Map,而不是 Map<String, Map<String, AlertRule>>。所以它不知道这个 Map 里第二层还有一个 Map,更不知道最里面装的是 AlertRule 对象。
ConfigurationProperties 的绑定过程
我们再深入一点,看看 @ConfigurationProperties 是怎么工作的:
对于简单类型,Binder 只需要调用内置的 Converter 把字符串转成对应类型就行了。但对于复杂类型,它需要递归地处理:
- 先知道外层是个 Map
- 再知道 Map 的 key 是什么类型
- 然后知道 Map 的 value 是什么类型
- 如果 value 还是个 Map,继续递归
这整个过程依赖于完整的泛型类型信息,而这个信息在运行时通常是拿不到的。
问题出在哪?
后来我们加了日志,发现问题出在 Binder 这一步。对于简单类型,Binder 能正确识别:
这里涉及到 Java 的泛型擦除。我们定义的是 Map<String, Map<String, AlertRule>>,但运行时这个类型信息就没了,变成了 Map。Binder 不知道这个 Map 里到底装的是什么,所以就不知道怎么正确绑定。
特别是我们这种三层嵌套结构:
- 外层 Map 的 key 是仓库编码(String)
- 外层 Map 的 value 又是一个 Map
- 内层 Map 的 key 是商品类目(String)
- 内层 Map 的 value 是自定义的 AlertRule 对象
这种复杂结构对 Spring 的自动绑定来说确实有点难度。
我们的解决办法
既然自动刷新靠不住,那就手动来。我们监听配置变更事件,然后显式地用 Binder 重新绑定:
java
@EventListener(EnvironmentChangeEvent.class)
public void onInventoryConfigChanged(EnvironmentChangeEvent event) {
// 先判断是不是我们关心的配置
boolean related = event.getKeys().stream()
.anyMatch(k -> k.startsWith("inventory.warehouses"));
if (related) {
log.info("[InventoryConfig] 检测到配置刷新: {}", event.getKeys());
// 重点来了:显式告诉 Binder 完整的类型信息
ResolvableType keyType = ResolvableType.forClass(String.class);
ResolvableType alertRuleType = ResolvableType.forClass(AlertRule.class);
ResolvableType innerMapType = ResolvableType.forClassWithGenerics(
Map.class, keyType, alertRuleType);
ResolvableType mapType = ResolvableType.forClassWithGenerics(
Map.class, keyType, innerMapType);
Bindable<Map<String, Map<String, AlertRule>>> bindable =
Bindable.of(mapType);
// 手动绑定
Map<String, Map<String, AlertRule>> newWarehouses =
Binder.get(environment)
.bind("inventory.warehouses", bindable)
.orElseGet(HashMap::new);
this.warehouses = newWarehouses;
log.info("[InventoryConfig] warehouses 已更新,仓库数量: {}", newWarehouses.size());
}
}
这里的关键是用 ResolvableType 把泛型的完整类型信息都告诉 Binder,这样它就知道该怎么正确解析配置了。
完整的配置类长这样:
java
@Component
@RefreshScope
@ConfigurationProperties(prefix = "inventory")
public class InventoryAlertConfig implements EnvironmentAware, InitializingBean {
private Environment environment;
private Map<String, Map<String, AlertRule>> warehouses = new HashMap<>();
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
// getter/setter
public Map<String, Map<String, AlertRule>> getWarehouses() {
return warehouses;
}
public void setWarehouses(Map<String, Map<String, AlertRule>> warehouses) {
this.warehouses = warehouses;
}
@EventListener(EnvironmentChangeEvent.class)
public void onInventoryConfigChanged(EnvironmentChangeEvent event) {
// 上面的刷新逻辑
}
}
为什么要这么麻烦?
有人可能会问,为啥不直接把 warehouses 拍平成简单结构?确实可以这么干,但我们的场景是这样的:
yaml
inventory:
warehouses:
WH001: # 华东仓
electronics:
threshold: 100
urgentLevel: HIGH
food:
threshold: 500
urgentLevel: MEDIUM
WH002: # 华南仓
electronics:
threshold: 50
urgentLevel: MEDIUM
clothing:
threshold: 200
urgentLevel: LOW
每个仓库有多个商品类目,每个类目有不同的预警规则。这种层级结构用嵌套 Map 表达起来最直观,维护起来也方便。如果拍平成一维,key 会变成 inventory.warehouses.WH001.electronics.threshold 这种,反而不好管理。
整个刷新流程是这样的
几个要注意的地方
第一个是 @RefreshScope 还是要保留的,虽然它对复杂 Map 不起作用,但对其他简单属性还是有用的。而且保留它也不碍事。
第二个是要实现 EnvironmentAware 接口来拿到 Environment 实例,不然没法调用 Binder.get(environment)。
第三个是监听的事件类型要选对。EnvironmentChangeEvent 是 Nacos 配置变更时会触发的,RefreshScopeRefreshedEvent 是 Bean 刷新时触发的,我们要的是前者。
第四个是日志一定要加,生产环境配置刷新这种事情,必须有迹可查。我们当时就是靠日志发现配置确实变了,但 Bean 里的值没变。
还有个小细节
我当时在测试的时候发现,如果配置类里既有简单属性又有复杂 Map,最好把它们分开处理。简单属性让 @RefreshScope 自动刷新就行,复杂 Map 用 @EventListener 手动刷新。
不过这块我还没研究透,不知道是不是 Spring Cloud 的版本问题。我们用的是 Spring Cloud 2021.x,可能新版本对复杂泛型的支持更好了,这个没深入验证。
类型绑定的细节
这里再详细说说 ResolvableType 是怎么工作的:
ResolvableType 实际上是在运行时重建了泛型的完整类型信息,这样 Binder 就知道:
- 外层 Map 的 key 是 String
- 外层 Map 的 value 是另一个 Map
- 内层 Map 的 key 也是 String
- 内层 Map 的 value 是 AlertRule 对象
有了这些信息,Binder 就能正确地把 YAML/Properties 配置转换成对应的 Java 对象了。
写在最后
这个问题其实挺常见的,特别是现在微服务架构里,配置都是动态管理的。如果你的配置结构比较复杂,又要支持热更新,基本都会遇到类似的坑。
我们这个方案在生产环境跑了几个月了,还挺稳定的。每次在 Nacos 里调整库存预警规则,都能立即生效,也没出过什么幺蛾子。
不过说实话,这个 ResolvableType 的写法还是有点繁琐的,特别是嵌套层级深了以后。如果你有更优雅的方案,欢迎交流。
对了,还有一点忘了说。如果你的配置类是在其他 Bean 里通过 @Autowired 注入使用的,那个 Bean 最好也加上 @RefreshScope,不然它持有的可能还是旧的配置实例。这个我们也踩过坑,后来统一加上就好了。
关于 Spring 刷新机制的一些补充
最后再说几个我们踩过的坑,都跟 Spring 的刷新机制有关:
第一个是关于 Bean 的生命周期。 加了 @RefreshScope 的 Bean,每次刷新都会经历完整的生命周期:@PostConstruct 会重新执行,InitializingBean.afterPropertiesSet() 也会重新调用。如果你在这些初始化方法里做了一些重量级操作(比如建立数据库连接、初始化线程池),每次配置刷新都会重复执行,这可能不是你想要的。
java
@RefreshScope
@ConfigurationProperties(prefix = "inventory")
public class InventoryAlertConfig {
@PostConstruct
public void init() {
// 这个方法每次刷新都会被调用!
// 如果这里有重量级操作,需要注意
log.info("配置初始化...");
}
}
第二个是关于多实例的问题。 @RefreshScope 创建的是代理对象,每次调用方法时都会检查 Bean 是否 dirty。如果你在代码里频繁调用配置对象的方法,这个检查开销可能会积累起来。我们有个服务在高峰期 QPS 能到几千,后来发现配置读取成了小瓶颈,最后是在业务代码里加了一层缓存才解决的。
第三个是关于事件的顺序。 Nacos 配置变更会触发多个事件:
EnvironmentChangeEvent 和 RefreshScopeRefreshedEvent 的触发顺序是不保证的,如果你在两个事件的监听器里都做了操作,可能会遇到时序问题。我们当时就因为这个,同一个配置被处理了两次,日志里一堆重复的初始化记录。
后来统一改成只监听 EnvironmentChangeEvent,问题就消失了。
第四个是关于配置的传播。 如果你的应用用了 @Async 异步方法,或者有自己创建的线程,这些地方拿到的配置对象可能不是最新的。因为 @RefreshScope 的代理只在 Spring 容器管理的调用链路上生效,一旦脱离了 Spring 的管理,代理就不起作用了。
java
@Service
public class AlertService {
@Autowired
private InventoryAlertConfig config;
@Async
public void sendAlert() {
// 这里的 config 可能是旧的!
// 因为异步线程不在 Spring 的代理链路上
String email = config.getNotifyEmail();
}
}
解决办法是在异步方法里重新获取 Bean,或者在调用异步方法之前把需要的配置值传进去。