bigywater.tech
微服务 · 56

配置热更新:哪些配置可以不重启生效

配置热更新到底怎么做才安全?原地改字段、换指针、加锁各会怎样?哪些配置换了也不会生效?推一份坏配置会发生什么?用信号还是轮询触发?这篇用一个 Go 程序真的跑出来。

4471 → 1754003571
8 个读取方读到半新半旧配置的次数:原地改字段,写 A 与写 B 间隔 0 到 50 µs(atomic 换指针在两种情形下都是 0)
255.286 ms
每个任务 100 ms 时,把 worker 池从 8 个缩到 2 个(Resize)的用时;配置改成 8 个但没调用 Resize,worker 数仍是 4
0 / 300
naive_no_range 加载 huge(限速写成 1000000)之后,1 s 里被拒绝的请求数,限速保护已经失效

三种换配置的写法

实测 · 本机执行输出
  写 A 与写 B 之间隔 0 µs:
  发布方式    | 读取次数(8 个读取方合计) | 读到违反不变式的次数 | 发布次数
  --------+----------------+------------+-----
  inplace | 11230556362    | 4471       | 6809
  atomic  | 11196829624    | 0          | 6668
  rwmutex | 20215188       | 0          | 5293
  写 A 与写 B 之间隔 50 µs:
  发布方式    | 读取次数(8 个读取方合计) | 读到违反不变式的次数 | 发布次数
  --------+----------------+------------+-----
  inplace | 11294085910    | 1754003571 | 6023
  atomic  | 10914950818    | 0          | 4941
  rwmutex | 18719397       | 0          | 4982
  发布方式(用 go build -race 构建,间隔 0 µs,0.5 s) | 竞态检测器报告 “WARNING: DATA RACE” 的次数
  ----------------------------------------+---------------------------------
  inplace                                 | 2                               
  atomic                                  | 0                               
  rwmutex                                 | 0                               
  不加保护地原地改字段:两次写之间没有间隔时,违反不变式的读取相对读取总数极少(各次运行的数字不一样,开发中的一次 1 s 运行里甚至一次都没有读到),但竞态检测器每次都报告了数据竞争;写入之间一旦有间隔(解析、校验都会产生),读取方就大量读到半新半旧的配置。atomic 方式从来没有读到过半新半旧的配置,读取次数与不加保护的相近;RWMutex 方式也没有,但这个紧循环里读取次数少得多(每次读都要加锁)

原地改字段会让读取方读到半新半旧的配置,竞态检测器每次都能报出来;atomic 整体换只读快照没有这个问题;RWMutex 也没有,代价是读取要加锁。

哪些配置会立刻生效

实测 · 本机执行输出
  配置项                     | 结果                                                                
  ------------------------+-------------------------------------------------------------------
  (a)请求里每次读配置(换指针之后发起的请求) | 一共 12549 个请求;换完指针之后发起的请求里仍然用旧版本的有 0 个;第一个读到新版本的请求发起于换指针之后 0.033 ms
  (b)配置里的监听地址             | 换配置之后,原来的地址还能访问(返回 true:v2);配置里的新地址:refused                       
  (c)启动时按配置起的 worker 数    | 4 个;配置改成 8 个、没有调用 Resize,500 ms 后仍是 4 个                           
  (c)带 Resize 的池:4 → 8    | 调用 Resize(8) 后 worker 数变成 8,用时 0.000 ms(显示为 0.000 表示小于 1 µs)      
  (c)带 Resize 的池:8 → 2    | 每个任务 100 ms、队列里还有任务时,从 Resize(2) 到只剩 2 个 worker 用了 255.286 ms     
  每次请求都从原子指针里读的值,换完指针之后的请求就用新值;只在启动时用过一次的值(监听地址、按配置起的 worker 数),换了配置也不会让已经在运行的东西变化,需要专门写“重新绑定 / 调整大小”的代码;缩小 worker 池要等在忙的 worker 做完手上的任务(这里的 worker 每轮在“退出信号”和“新任务”之间由 select 随机选一个)

每次请求读的值换指针后立刻生效;只在启动时用过一次的值(监听地址、worker 数)换配置不会变,需要专门写重新绑定或调整大小的代码。

坏配置

实测 · 本机执行输出
  加载方式             | 推送       | 加载结果              | 推送之前被拒绝/总请求 | 推送之后 1 s 被拒绝/总请求 | 推送后生效的限速
  -----------------+----------+-------------------+-------------+------------------+---------
  naive_ignore_err | bad_json | stored            | 199/360     | 299/299          | 0       
  naive_ignore_err | zero     | stored            | 198/359     | 300/300          | 0       
  naive_ignore_err | huge     | stored            | 199/360     | 0/300            | 1000000 
  naive_no_range   | bad_json | kept(parse error) | 199/360     | 200/300          | 100     
  naive_no_range   | zero     | stored            | 199/360     | 300/300          | 0       
  naive_no_range   | huge     | stored            | 199/360     | 0/300            | 1000000 
  validated        | bad_json | kept(rejected)    | 199/360     | 201/300          | 100     
  validated        | zero     | kept(rejected)    | 199/360     | 200/300          | 100     
  validated        | huge     | kept(rejected)    | 199/360     | 200/300          | 100     
  解析出错时存零值会让所有请求被拒绝;解析成功但值不合理(0 或大了一万倍)时,没有范围检查的加载方式会让限速失效或者把所有请求都拦下;对 bad_json,凡是解析出错就保留旧配置的加载方式(naive_no_range 和 validated)都不受影响;对取值不合理的 zero 和 huge,只有做了范围检查的 validated 保住了原来的限速

解析出错存零值、取值不合理又没有范围检查,都会让限速出问题;校验失败就保留旧配置才不受影响。

触发方式

实测 · 本机执行输出
  触发方式            | 发现/收到的次数 | 延迟 最小 / 中位 / 最大(ms)  
  ----------------+----------+----------------------
  SIGHUP          | 200/200  | 0.030 / 0.164 / 0.513
  轮询文件,间隔 100 ms  | 30/30    | 4.2 / 54.4 / 102.2   
  轮询文件,间隔 1000 ms | 19/30    | 7.3 / 409.3 / 969.3  
  信号几乎是即时的,但只能通知“同一台机器上有人发了信号”,不会带内容、也不跨机器;轮询的延迟在 0 到间隔之间,而且有间隔内多次变更被合并的可能;Nacos 之类的推送延迟见第 55 篇
ERRORS {}

信号几乎是即时的但不带内容也不跨机器;轮询延迟在 0 到间隔之间,间隔内的多次变更会被合并。

分步演示

配置热更新
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Go 是 1.27.1(darwin/arm64),源码在 vtmp/v56/main.go,实验用 Python 编排并解析它的输出;“间隔”是用忙等模拟的解析耗时,不是真实的配置解析;读取方是紧循环,所以 RWMutex 的读取次数少得多,真实的配置读取不会这么密;间隔 0 时 inplace 的违反次数各次运行不同(开发中的另一次 1 s 运行是 0,还有 7276、117883 的运行),因此只能说“很少但不为零”;缩小 worker 池的用时也因 select 随机而不同(开发中的一次运行是 550.090 ms)
    • 没有验证:真实应用的配置库(viper、Spring Cloud @RefreshScope 等)的刷新行为;Java 的 volatile 与不可变对象;热更新数据库连接池、TLS 证书(GetCertificate)、日志级别等具体组件;配置推送与应用重载之间的版本一致性(多个实例会有一段时间不一致);fsnotify 等文件监听库;跨平台的信号与文件替换语义
    京ICP备2026067502号-1京公网安备11010502063487号