问题是这么来的
最近在做商品库存预警的配置管理,我们用的是 Nacos 做配置中心。按理说很简单的事情,配置类上加个 @RefreshScope,Nacos 里改完配置发布,应用就能自动刷新,对吧?
一开始确实也是这么干的:
@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 到底是怎么工作的。
sequenceDiagram
participant Nacos
participant RefreshEventListener
participant RefreshScope
participant Bean
participant Binder
Nacos->>RefreshEventListener: 配置变更通知
RefreshEventListener->>RefreshScope: 触发 RefreshScopeRefreshedEvent
RefreshScope->>Bean: 销毁旧的 Bean 实例
Note over Bean: Bean 被标记为 dirty
Bean->>RefreshScope: 下次使用时请求 Bean
RefreshScope->>Binder: 重新创建并绑定配置
Binder->>Bean: 返回新的 Bean 实例
整个过程其实就是把旧 Bean 扔掉,然后重新创建一个新的。Spring 会用 Binder 把最新的配置绑定到新 Bean 上。
Spring 的刷新机制其实挺有意思的
这里稍微展开说说 @RefreshScope 背后的机制,理解了这个,后面遇到问题就好排查了。
@RefreshScope 本质上是一个特殊的 Bean 作用域,和我们常见的 @Singleton、@Prototype 是一个层级的东西。它的特殊之处在于,这个作用域下的 Bean 可以在运行时被"刷新"。
graph TB
A[配置变更事件] --> B[RefreshScope]
B --> C{标记所有Bean为dirty}
C --> D[Bean1: dirty]
C --> E[Bean2: dirty]
C --> F[Bean3: dirty]
G[业务代码调用Bean] --> H{Bean是否dirty?}
H -->|是| I[销毁旧实例]
H -->|否| J[直接返回]
I --> K[重新创建Bean]
K --> L[从Environment绑定配置]
L --> M[返回新实例]
J --> M
Spring 用了一个挺巧妙的设计:它不是配置一变就立刻去刷新所有 Bean,而是先把它们标记为"脏数据"(dirty),等到下次真正用到这个 Bean 的时候,才去重新创建。这样做的好处是避免了大量无用的 Bean 重建,毕竟有些配置类可能根本就不会被用到。
但是这个机制有个前提:Spring 得知道怎么重新创建这个 Bean。对于简单的配置类,这很容易:
// 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 方法。这个过程很直接。
但是遇到复杂泛型就麻烦了:
// 这个就比较头疼
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 是怎么工作的:
sequenceDiagram
participant Environment
participant Binder
participant Converter
participant Bean
Bean->>Binder: 请求绑定配置
Binder->>Environment: 读取 prefix 下的所有配置
Environment-->>Binder: 返回配置 Map
loop 遍历每个属性
Binder->>Binder: 找到对应的 setter 方法
Binder->>Binder: 获取参数类型
alt 简单类型 (String/int/boolean)
Binder->>Converter: 直接转换
Converter-->>Binder: 返回转换结果
else 复杂类型 (List/Map/自定义对象)
Binder->>Binder: 尝试递归绑定
Note over Binder: 这里需要完整的泛型信息
Binder->>Converter: 转换内部元素
end
Binder->>Bean: 调用 setter 方法
end
对于简单类型,Binder 只需要调用内置的 Converter 把字符串转成对应类型就行了。但对于复杂类型,它需要递归地处理:
- 先知道外层是个 Map
- 再知道 Map 的 key 是什么类型
- 然后知道 Map 的 value 是什么类型
- 如果 value 还是个 Map,继续递归
这整个过程依赖于完整的泛型类型信息,而这个信息在运行时通常是拿不到的。
问题出在哪?
后来我们加了日志,发现问题出在 Binder 这一步。对于简单类型,Binder 能正确识别:
graph TD
A[Environment 中的配置] --> B{Binder 尝试绑定}
B -->|String/int/boolean| C[成功识别类型]
B -->|Map泛型| D[类型信息丢失]
C --> E[正确绑定到属性]
D --> F[绑定失败或使用默认值]
style C fill:#90EE90
style D fill:#FFB6C6
style E fill:#90EE90
style F fill:#FFB6C6
这里涉及到 Java 的泛型擦除。我们定义的是 Map<String, Map<String, AlertRule>>,但运行时这个类型信息就没了,变成了 Map。Binder 不知道这个 Map 里到底装的是什么,所以就不知道怎么正确绑定。
特别是我们这种三层嵌套结构:
- 外层 Map 的 key 是仓库编码(String)
- 外层 Map 的 value 又是一个 Map
- 内层 Map 的 key 是商品类目(String)
- 内层 Map 的 value 是自定义的 AlertRule 对象
这种复杂结构对 Spring 的自动绑定来说确实有点难度。
我们的解决办法
既然自动刷新靠不住,那就手动来。我们监听配置变更事件,然后显式地用 Binder 重新绑定:
@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,这样它就知道该怎么正确解析配置了。
完整的配置类长这样:
@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 拍平成简单结构?确实可以这么干,但我们的场景是这样的:
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 这种,反而不好管理。
整个刷新流程是这样的
sequenceDiagram
participant Nacos
participant App
participant EventListener
participant Binder
participant Config
Nacos->>App: 推送配置变更
App->>App: 更新 Environment
App->>EventListener: 发布 EnvironmentChangeEvent
EventListener->>EventListener: 检查 key 前缀
alt 是 inventory.warehouses 相关
EventListener->>Binder: 构造 ResolvableType
EventListener->>Binder: 调用 bind() 方法
Binder->>Binder: 解析泛型类型
Binder->>Binder: 从 Environment 读取配置
Binder->>Binder: 递归绑定嵌套结构
Binder-->>EventListener: 返回绑定结果
EventListener->>Config: 更新 warehouses 字段
Config-->>App: 配置已生效
else 其他配置
EventListener->>EventListener: 忽略
end
几个要注意的地方
第一个是 @RefreshScope 还是要保留的,虽然它对复杂 Map 不起作用,但对其他简单属性还是有用的。而且保留它也不碍事。
第二个是要实现 EnvironmentAware 接口来拿到 Environment 实例,不然没法调用 Binder.get(environment)。
第三个是监听的事件类型要选对。EnvironmentChangeEvent 是 Nacos 配置变更时会触发的,RefreshScopeRefreshedEvent 是 Bean 刷新时触发的,我们要的是前者。
第四个是日志一定要加,生产环境配置刷新这种事情,必须有迹可查。我们当时就是靠日志发现配置确实变了,但 Bean 里的值没变。
还有个小细节
我当时在测试的时候发现,如果配置类里既有简单属性又有复杂 Map,最好把它们分开处理。简单属性让 @RefreshScope 自动刷新就行,复杂 Map 用 @EventListener 手动刷新。
不过这块我还没研究透,不知道是不是 Spring Cloud 的版本问题。我们用的是 Spring Cloud 2021.x,可能新版本对复杂泛型的支持更好了,这个没深入验证。
类型绑定的细节
这里再详细说说 ResolvableType 是怎么工作的:
graph LR
A[Map泛型信息] --> B[ResolvableType.forClass]
B --> C[构建类型树]
C --> D[String]
C --> E[Map<String, AlertRule>]
E --> F[String]
E --> G[AlertRule]
H[Binder] --> I{读取类型信息}
I --> C
I --> J[按类型解析配置]
J --> K[递归处理嵌套结构]
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() 也会重新调用。如果你在这些初始化方法里做了一些重量级操作(比如建立数据库连接、初始化线程池),每次配置刷新都会重复执行,这可能不是你想要的。
@RefreshScope
@ConfigurationProperties(prefix = "inventory")
public class InventoryAlertConfig {
@PostConstruct
public void init() {
// 这个方法每次刷新都会被调用!
// 如果这里有重量级操作,需要注意
log.info("配置初始化...");
}
}
第二个是关于多实例的问题。 @RefreshScope 创建的是代理对象,每次调用方法时都会检查 Bean 是否 dirty。如果你在代码里频繁调用配置对象的方法,这个检查开销可能会积累起来。我们有个服务在高峰期 QPS 能到几千,后来发现配置读取成了小瓶颈,最后是在业务代码里加了一层缓存才解决的。
第三个是关于事件的顺序。 Nacos 配置变更会触发多个事件:
sequenceDiagram
participant Nacos
participant App
participant Listeners
Nacos->>App: 推送配置变更
App->>App: 更新 Environment
par 并发触发事件
App->>Listeners: EnvironmentChangeEvent
App->>Listeners: RefreshScopeRefreshedEvent
end
Note over Listeners: 两个事件的执行顺序不确定!
EnvironmentChangeEvent 和 RefreshScopeRefreshedEvent 的触发顺序是不保证的,如果你在两个事件的监听器里都做了操作,可能会遇到时序问题。我们当时就因为这个,同一个配置被处理了两次,日志里一堆重复的初始化记录。
后来统一改成只监听 EnvironmentChangeEvent,问题就消失了。
第四个是关于配置的传播。 如果你的应用用了 @Async 异步方法,或者有自己创建的线程,这些地方拿到的配置对象可能不是最新的。因为 @RefreshScope 的代理只在 Spring 容器管理的调用链路上生效,一旦脱离了 Spring 的管理,代理就不起作用了。
@Service
public class AlertService {
@Autowired
private InventoryAlertConfig config;
@Async
public void sendAlert() {
// 这里的 config 可能是旧的!
// 因为异步线程不在 Spring 的代理链路上
String email = config.getNotifyEmail();
}
}
解决办法是在异步方法里重新获取 Bean,或者在调用异步方法之前把需要的配置值传进去。