配置热更新到底怎么做才安全?原地改字段、换指针、加锁各会怎样?哪些配置换了也不会生效?推一份坏配置会发生什么?用信号还是轮询触发?这篇用一个 Go 程序真的跑出来。
写 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 到间隔之间,间隔内的多次变更会被合并。