Skip to content

配置中心显示已推送,线上行为没有变 ​

高峰期把线程池的最大线程数从 4 调到 16,配置中心显示所有实例推送成功,应用日志里也打印了「配置已更新」。排队的任务没有减少,线程数一个也没有多。

运行中修改配置,要走完三步才算生效:新值到了进程里;组件自己持有的那一份换成了新值;组件的行为跟着变了。配置中心的「已推送」和客户端的「已更新」只说明第一步。后两步发生在各个组件内部,每个组件的规则都不一样。

本文用四个常见的组件各验证一种「到了但没生效」的情况。实验见文末。

新值到了进程里组件持有的值变了行为变了Spring:@Value 注入的字段是否否属性源已是 500,字段仍是启动时注入的 100;之后新建的 Bean 才读到 500线程池:最大线程数 4 → 16是是否getMaximumPoolSize 返回 16;队列是无界的,永远排队,实际线程仍是 4Logback:root 调成 DEBUG是是部分继承级别的 logger 变了;配置文件里单独配过级别的那个包不变HikariCP:换数据库地址是否否setJdbcUrl 抛异常:启动后配置被封住。最大连接数与口令可以改确认生效要看第三列:读组件当前实际使用的值(线程数、生效级别、连接池参数),而不是配置中心的推送记录或属性源里的值
图 1 · 实测:一次配置变更要走完三步才算生效:新值到了进程里,组件自己持有的那份也换成了新值,行为跟着变了。配置中心的「已推送」只说明第一步。四个组件各自停在不同的位置:有的启动时读一次就不再读,有的新值写进去了却被别的条件挡住,有的参数启动后根本不允许改。

一、启动时读了一次,之后不再读 ​

用 Spring Framework 7.0.9 搭一个最小的上下文,往 Environment 里放一个可变的属性源代替配置中心的客户端。启动后把 limit.qps 从 100 改成 500:

text
属性源里的 limit.qps 从 100 改成 500 之后:Environment.getProperty 读到 500
启动时注入的 @Value 字段:100
启动时用这个值算出来的对象(每次间隔的纳秒数):10000000(按 500 应为 2000000)
此后新创建的 Bean 注入到的值:500

Environment 里已经是新值,这是配置中心客户端能做到的全部。@Value 是在 Bean 创建时把值复制到字段里,之后字段与属性源没有任何联系。单例 Bean 只创建一次,所以它永远是启动时的值。

更隐蔽的是第三行:启动时拿配置值算出来的东西。限流器的间隔、按大小分配好的缓冲区、根据地址建好的客户端,这些对象里已经没有「配置」了,只有当时算出来的结果。

要让这类配置在运行中生效,有三种做法:

  • 每次使用时去读(Environment.getProperty,或者配置中心客户端提供的取值接口),不缓存在字段里。适合读取成本低、每次请求都要用的开关和阈值。
  • 监听变更事件,在回调里更新组件:调用组件自己的修改方法,或者重建派生对象并原子地替换引用。
  • 用框架提供的刷新机制重建 Bean(例如 Spring Cloud 的刷新作用域)。它重建的是被标记的 Bean,依赖它、但自己缓存了旧值的其他对象不在其中。

不论哪一种,都意味着「这个配置项支持热更新」是需要专门写代码和测试的功能,不是接入配置中心就自动具备的。

二、值写进去了,但用不上 ​

开头的线程池是这种情况。核心 4、最大 4、无界队列,提交 20 个会阻塞的任务:

text
核心 4、最大 4、无界队列,提交 20 个阻塞任务:核心 4,最大 4,实际线程 4,排队 16
把最大线程数改成 16:核心 4,最大 16,实际线程 4,排队 16

setMaximumPoolSize(16) 成功了,getMaximumPoolSize() 返回 16,监听器的日志也没有骗人。但线程池只有在队列放不下的时候才会创建超过核心数的线程,无界队列永远放得下,最大线程数这个参数从来不会被用到。

要改的是核心线程数,而且有顺序:

text
另一个池(核心 4、最大 4)先把核心线程数改成 16:抛出 IllegalArgumentException(corePoolSize must be less than or equal to maximumPoolSize)
最大线程数已是 16,再把核心线程数改成 16:核心 16,最大 16,实际线程 16,排队 4

调大时先改最大值再改核心值,调小时反过来。动态调参的代码如果固定按一个顺序设置,有一个方向会抛异常;异常被监听器吞掉的话,表现还是「推送成功,没有变化」。

调小也不是立刻生效:

text
任务还在执行时把核心线程数改回 4:核心 4,最大 16,实际线程 16,排队 4
任务全部结束、空闲超过保活时间之后:核心 4,最大 16,实际线程 4,排队 0

正在执行任务的线程不会被打断,要等它们空闲下来才回收。

有界队列下最大线程数是有用的,但时机同样取决于队列:

text
核心 4、队列容量 10、已有 5 个在排队,把最大线程数从 4 改成 8:核心 4,最大 8,实际线程 4,排队 5
再提交 7 个任务(队列满了之后才会加线程):核心 4,最大 8,实际线程 6,排队 10

队列的容量本身没有修改方法,只能在创建时确定。线程池各参数之间的关系,见线程池参数怎么定。

三、被另一处配置挡住了 ​

排查问题时临时把日志级别调低,是最常见的运行时配置变更:

text
运行中把 root 从 INFO 调成 DEBUG:com.shop.order.OrderService 的生效级别 DEBUG,com.shop.pay.PayService 的生效级别 WARN

logger 的级别是继承的:自己没有配置时用上一级的。配置文件里单独给 com.shop.pay 配过 WARN,它就不再跟随 root。把 root 调成 DEBUG 之后,订单模块的调试日志出来了,偏偏要查的支付模块没有。

这类情况的共同点是配置有层级或优先级,改动的那一层被更具体的一层覆盖了。同样的结构出现在:环境变量覆盖配置文件、实例级配置覆盖应用级配置、代码里写死的值覆盖一切。修改之前先确认最终生效的值由哪一层决定。

四、启动之后不允许改 ​

text
连接池启动后 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 的生效级别。
  • 连接池看它的运行时指标。
  • 限流、开关这类逻辑,直接观察行为:变更之后的拒绝率、走了哪个分支。

把这些值暴露成指标或一个只读的诊断接口,变更之后逐个实例核对。多实例部署时还要考虑不同实例生效的时间不一致,以及个别实例没有收到推送的情况。

路由表、限流规则这类成批的配置还有一个问题:推送内容是一个列表,客户端如果按条目逐个「新增或更新」,就表达不了删除,旧规则会一直留着;推送一个空列表时,有的实现直接忽略,规则也清不掉。更可靠的做法是把整批配置当作一个带版本号的快照:先完整解析和校验,全部通过再一次性替换当前快照;校验失败时保留上一个有效版本并告警;收到版本号比当前旧的推送(乱序、重放)就丢弃。这样「生效」也有了明确的含义:当前快照的版本号等于推送的版本号。

六、检查清单 ​

  • 每个放进配置中心的配置项,都明确标注是「启动时生效」还是「运行中生效」;后者有对应的代码和测试。
  • 测试的内容是行为变了,不是值变了。
  • 变更监听器里的异常不被吞掉;新值不合法时保留旧值并告警。
  • 相互关联的参数按正确的顺序设置,或者一次性替换。
  • 能看到每个实例当前实际生效的值。
  • 不支持热更新的配置项,变更流程是滚动重启,并且在配置中心里有说明,避免有人以为改了就生效。
  • 配置变更与发布一样对待:有记录、能回滚、先在一小部分实例上生效。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权