4.3 热更新:配置不重启生效

线上跑着的日志管道,最怕听到的一句话是「改个配置,重启一下」。重启意味着采集偏移要保存、通道里的批次要排空、内存缓冲要重建,高峰期这几步加起来就是几分钟的数据延迟,操作手抖一下还可能丢一段。本节把「改配置不重启」做成一个可靠的常规操作,而不是靠胆量的杂技。

什么配置值得热更新

不是所有配置都配得上热更新的复杂度。先分清两档:

档位 配置 热更新
必须热更 采样率、丢弃阈值、压缩级别 值
建议热更 攒批条数、缓冲大小、刷盘周期 值
老实重启 存储路径、列族结构、通道容量 不值

判断标准是「改下去是否影响已持有的资源」:采样率只是换个数字,而通道容量涉及已经建好的队列,改了等于换管道。前者热更,后者走优雅重启。这个分档原则在别的系统也通用:限流阈值类的「策略配置」永远热更,结构性的「拓扑配置」永远重启,混在一起做就会两边都做不好。限流策略的热更手法可对照 PHP 秒杀实战 的动态限流。

## 整体手法:监听加原子替换

整体手法分三步:监听配置变化 → 校验新配置 → 原子替换旧配置。其中校验这步经常被跳过,后果是配错一个参数整个进程在运行中崩掉,比重启更糟。原子替换用 ArcSwap 这类结构,读侧永远拿到一份完整一致的配置,不会读到改一半的中间态:

use arc_swap::ArcSwap;
use notify::{Watcher, RecommendedWatcher, RecursiveMode, Event};

struct Config { sample_rate: f64, batch_size: usize, flush_ms: u64 }

let cfg: Arc<ArcSwap<Config>> = Arc::new(ArcSwap::from_pointee(load()?));

let cfg2 = cfg.clone();
let mut watcher = RecommendedWatcher::new(move |res: Result<Event, _>| {
    if let Ok(ev) = res {
        if let Ok(new) = load() {       // 1. 重新解析
            if validate(&new).is_ok() { // 2. 先校验
                cfg2.store(Arc::new(new)); // 3. 原子替换
            }
        }
    }
}, notify::Config::default())?;
watcher.watch(Path::new("/etc/logpipe/conf.toml"), RecursiveMode::NonRecursive)?;

读侧的用法对性能几乎零成本:cfg.load().sample_rate 每次拿到的是一份 Arc,无锁读,百万级 QPS 下不需要担心它成为瓶颈。

配置文件本身的写入也要原子:先写 conf.toml.tmp 再 rename 覆盖,否则监听器可能读到写一半的内容。这个手法和 2.1 里偏移文件的原子替换是完全一致的套路,只不过一个保数据一个保配置。

校验都查什么

校验不是「解析成功」就完事,要查三层:

层 检查 漏掉的后果
类型 字段类型、范围合法 越界值运行中 panic
资源 新配置是否超内存预算 OOM
相容 与当前运行状态不冲突 旧缓冲与新参数对不上

范围检查容易被写成只查上界:采样率给了负数,解析成 f64 完全合法,运行时却让采样逻辑全部走到错误分支。相容检查的例子是「-buffer 不能小于单批大小」,这种约束只在运行时才暴露,校验阶段就要提前拦下。

信号触发与文件监听的取舍

除了文件监听,传统做法还有 SIGHUP 信号触发重载。两者各有适用场景:

手法 触发方式 优点 缺点
文件监听 改文件即生效 无需登录机器 需要处理事件合并
SIGHUP kill -HUP pid 简单、可脚本化 需要登录机器
管理接口 HTTP 下发 可集中管理 多一个依赖

我们的选择是文件监听为主、SIGHUP 兜底:容器环境改配置走文件,老式机器上 kill -HUP 依然顺手。信号处理在 tokio 里要用 tokio::signal::unix::signal 收成一条流,与文件监听事件汇到同一个处理函数,避免两套逻辑分叉。汇合的方式是把两种事件都转成同一个枚举丢进一个 mpsc 通道,处理端只认事件类型不关心来源,后续要加第三种触发方式(比如管理接口)也只是多一个生产者,架构上完全平滑。

容器环境还有个额外好处:配置文件通常由配置中心或 ConfigMap 挂载进来,挂载是原子替换整个文件,监听器看到的就是完整新文件,省去了自己去抖的一半烦恼。但要注意挂载点可能是符号链接变化而不是文件内容变化,监听层级要覆盖到挂载目录而不是只盯单个文件。

应用阶段:生效不是换那么简单

换掉配置对象只是第一步,生效还要看配置关联的运行状态。比如改 write_buffer_size,RocksDB 的 option 在 DB 打开时定型,运行时改 memtable 大小只能等下一次刷盘自然轮转,不能立刻生效。这类延迟生效必须打日志说明,否则运维会以为没生效而反复改。

配置 生效时机 是否即时
采样率 下一次采样判断 即时
攒批条数 下一批攒批 即时
write_buffer_size 下一个 memtable 延迟
列族结构 不支持 需重启

踩坑提示

第一个坑是监听事件风暴:一次 cp 可能触发若干个 modify 事件,每个都重新解析配置,互相覆盖。解法是收到事件后延迟 200 毫秒去抖(debounce),合并期间只处理最后一次。第二个坑是新配置回滚的一半:校验失败时只记了日志,但配置已经不新不旧停在解析结果里;正确做法是校验失败则整个配置对象不替换、维持旧的运行。第三个坑是多消费者看到的配置不一致:热更新那一瞬间,有的任务用了新采样率、有的还在用旧的,统计曲线上出现一个小台阶。对小数配置可以接受,对强一致要求的配置(比如偏移提交位点)就不能这么玩。

第四个坑更隐蔽:配置解析失败时默认回退到「上次的好配置」,但这会让错误的配置文件悄无声息地不生效,运维改了半天以为生效了。正确姿势是解析失败必须告警出来,沉默回退是最差的选择。监控告警的接入手法可参考 Go 即时通讯实战 的配置变更通知设计,发布流程的集中化管理可对照 Kubernetes 流水线实战 的配置下发章节。配置文件原子替换、生效延迟、告警闭环,这三件套配齐,热更新才是一个能交付的功能。存储与部署侧的机器规格变更,可参考 腾讯云 AI 数据处理平台 ADP 特惠。

验证:上线前先把热更新弄坏

热更新代码自己的 bug 只能在运行中暴露,所以上线前要有一组「故意弄坏」的例行测试:并发改 100 次配置、改一个非法值、在刷盘高峰期改、把文件截断一半再写。这四种情形覆盖了热更新九成以上的线上事故。尤其是非法值这一条:校验逻辑漏掉一个字段,运行中一个空字符串就可能让解析侧 panic,整个进程带着全链路的日志一起挂。

测试用例 期望行为
并发改 100 次 最终值正确,无 panic
非法配置 拒绝替换,维持旧配,告警
高峰期改 生效但延迟指标无尖刺
文件写一半 去抖后用最后完整版本

跑通这组用例之前,热更新功能默认关掉,只走重启路径——宁可慢,不要险。

小结

热更新等于「监听 + 校验 + 原子替换 + 生效确认」四步走。文件监听为主、SIGHUP 兜底;事件去抖、校验失败不替换、延迟生效打日志。做得糙一点的功能叫「能改」,做得到位才叫「敢改」。下一节看崩了之后怎么把没落盘的日志找回来。

延伸阅读

  • 原子替换的采集侧用法,见 2.1。
  • 热更新后的监控接入,见 5.2。
  • 配置下发与回滚流水线,见 Kubernetes 流水线实战。