配置中心显示已推送,线上行为没有变
高峰期把线程池的最大线程数从 4 调到 16,配置中心显示所有实例推送成功,应用日志里也打印了「配置已更新」。排队的任务没有减少,线程数一个也没有多。
运行中修改配置,要走完三步才算生效:新值到了进程里;组件自己持有的那一份换成了新值;组件的行为跟着变了。配置中心的「已推送」和客户端的「已更新」只说明第一步。后两步发生在各个组件内部,每个组件的规则都不一样。
本文用四个常见的组件各验证一种「到了但没生效」的情况。实验见文末。
一、启动时读了一次,之后不再读
用 Spring Framework 7.0.9 搭一个最小的上下文,往 Environment 里放一个可变的属性源代替配置中心的客户端。启动后把 limit.qps 从 100 改成 500:
属性源里的 limit.qps 从 100 改成 500 之后:Environment.getProperty 读到 500
启动时注入的 @Value 字段:100
启动时用这个值算出来的对象(每次间隔的纳秒数):10000000(按 500 应为 2000000)
此后新创建的 Bean 注入到的值:500Environment 里已经是新值,这是配置中心客户端能做到的全部。@Value 是在 Bean 创建时把值复制到字段里,之后字段与属性源没有任何联系。单例 Bean 只创建一次,所以它永远是启动时的值。
更隐蔽的是第三行:启动时拿配置值算出来的东西。限流器的间隔、按大小分配好的缓冲区、根据地址建好的客户端,这些对象里已经没有「配置」了,只有当时算出来的结果。
要让这类配置在运行中生效,有三种做法:
- 每次使用时去读(
Environment.getProperty,或者配置中心客户端提供的取值接口),不缓存在字段里。适合读取成本低、每次请求都要用的开关和阈值。 - 监听变更事件,在回调里更新组件:调用组件自己的修改方法,或者重建派生对象并原子地替换引用。
- 用框架提供的刷新机制重建 Bean(例如 Spring Cloud 的刷新作用域)。它重建的是被标记的 Bean,依赖它、但自己缓存了旧值的其他对象不在其中。
不论哪一种,都意味着「这个配置项支持热更新」是需要专门写代码和测试的功能,不是接入配置中心就自动具备的。
二、值写进去了,但用不上
开头的线程池是这种情况。核心 4、最大 4、无界队列,提交 20 个会阻塞的任务:
核心 4、最大 4、无界队列,提交 20 个阻塞任务:核心 4,最大 4,实际线程 4,排队 16
把最大线程数改成 16:核心 4,最大 16,实际线程 4,排队 16setMaximumPoolSize(16) 成功了,getMaximumPoolSize() 返回 16,监听器的日志也没有骗人。但线程池只有在队列放不下的时候才会创建超过核心数的线程,无界队列永远放得下,最大线程数这个参数从来不会被用到。
要改的是核心线程数,而且有顺序:
另一个池(核心 4、最大 4)先把核心线程数改成 16:抛出 IllegalArgumentException(corePoolSize must be less than or equal to maximumPoolSize)
最大线程数已是 16,再把核心线程数改成 16:核心 16,最大 16,实际线程 16,排队 4调大时先改最大值再改核心值,调小时反过来。动态调参的代码如果固定按一个顺序设置,有一个方向会抛异常;异常被监听器吞掉的话,表现还是「推送成功,没有变化」。
调小也不是立刻生效:
任务还在执行时把核心线程数改回 4:核心 4,最大 16,实际线程 16,排队 4
任务全部结束、空闲超过保活时间之后:核心 4,最大 16,实际线程 4,排队 0正在执行任务的线程不会被打断,要等它们空闲下来才回收。
有界队列下最大线程数是有用的,但时机同样取决于队列:
核心 4、队列容量 10、已有 5 个在排队,把最大线程数从 4 改成 8:核心 4,最大 8,实际线程 4,排队 5
再提交 7 个任务(队列满了之后才会加线程):核心 4,最大 8,实际线程 6,排队 10队列的容量本身没有修改方法,只能在创建时确定。线程池各参数之间的关系,见线程池参数怎么定。
三、被另一处配置挡住了
排查问题时临时把日志级别调低,是最常见的运行时配置变更:
运行中把 root 从 INFO 调成 DEBUG:com.shop.order.OrderService 的生效级别 DEBUG,com.shop.pay.PayService 的生效级别 WARNlogger 的级别是继承的:自己没有配置时用上一级的。配置文件里单独给 com.shop.pay 配过 WARN,它就不再跟随 root。把 root 调成 DEBUG 之后,订单模块的调试日志出来了,偏偏要查的支付模块没有。
这类情况的共同点是配置有层级或优先级,改动的那一层被更具体的一层覆盖了。同样的结构出现在:环境变量覆盖配置文件、实例级配置覆盖应用级配置、代码里写死的值覆盖一切。修改之前先确认最终生效的值由哪一层决定。
四、启动之后不允许改
连接池启动后 setMaximumPoolSize(30):成功,getMaximumPoolSize = 30
连接池启动后 setJdbcUrl(新地址):抛出 IllegalStateException(The configuration of the pool is sealed once started. Use HikariConfigMXBean for runtime changes.)
连接池启动后 setPassword(新口令):成功(只影响之后新建的连接)HikariCP 把配置分成两类:最大连接数、各种超时、用户名与口令可以在运行中修改;数据库地址、驱动等在连接池启动后被封住,修改会抛异常。
这是合理的设计。换一个数据库地址不是调一个参数,已有的连接、进行中的事务都要处理,只能通过新建一个连接池并切换来完成。口令虽然能改,新口令也只用于之后新建的连接,已有的连接继续用到它们被回收为止;这对数据库口令轮换恰好是需要的行为,但要知道它不是立刻切换。
每个组件都有这样一张「哪些能热改」的表,通常写在它的文档里。把一个配置项放进配置中心之前,先查这张表。
五、怎么确认生效了
四个例子里,「推送成功」都是真的,「配置已更新」的日志也都是真的。要确认的是第三步,办法只有一个:读组件当前实际使用的值,而不是读配置。
- 线程池看
getPoolSize()和队列长度,不看getMaximumPoolSize()。 - 日志看 logger 的生效级别。
- 连接池看它的运行时指标。
- 限流、开关这类逻辑,直接观察行为:变更之后的拒绝率、走了哪个分支。
把这些值暴露成指标或一个只读的诊断接口,变更之后逐个实例核对。多实例部署时还要考虑不同实例生效的时间不一致,以及个别实例没有收到推送的情况。
路由表、限流规则这类成批的配置还有一个问题:推送内容是一个列表,客户端如果按条目逐个「新增或更新」,就表达不了删除,旧规则会一直留着;推送一个空列表时,有的实现直接忽略,规则也清不掉。更可靠的做法是把整批配置当作一个带版本号的快照:先完整解析和校验,全部通过再一次性替换当前快照;校验失败时保留上一个有效版本并告警;收到版本号比当前旧的推送(乱序、重放)就丢弃。这样「生效」也有了明确的含义:当前快照的版本号等于推送的版本号。
六、检查清单
- 每个放进配置中心的配置项,都明确标注是「启动时生效」还是「运行中生效」;后者有对应的代码和测试。
- 测试的内容是行为变了,不是值变了。
- 变更监听器里的异常不被吞掉;新值不合法时保留旧值并告警。
- 相互关联的参数按正确的顺序设置,或者一次性替换。
- 能看到每个实例当前实际生效的值。
- 不支持热更新的配置项,变更流程是滚动重启,并且在配置中心里有说明,避免有人以为改了就生效。
- 配置变更与发布一样对待:有记录、能回滚、先在一小部分实例上生效。
配套实验
- codesphere-labs/engineering/config-received-not-applied:Spring Framework 7.0.9、JDK 25.0.4.1 的
ThreadPoolExecutor、Logback 1.5.38、HikariCP 7.0.2:运行中修改属性源、线程数、日志级别、连接池参数之后,各组件实际读到的值与状态(验证记录)
参考资料
- Spring Framework 文档:Using @Value
- Spring Framework 文档:Environment Abstraction
- Java SE 25 API:ThreadPoolExecutor(核心与最大线程数、排队规则、
setCorePoolSize) - Logback 文档:Logger 的级别继承
- HikariCP 文档(可在运行中通过
HikariConfigMXBean修改的配置项) - Spring Cloud Commons 文档:Refresh Scope