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 流水线实战。
