直接轮询 config 文件不可靠,因其浪费 cpu、易漏改且无法区分内容变更与 mtime 更新;应使用 fsnotify 监听 write/rename 事件,配合防抖、原子重载、错误降级及观察者模式实现可靠热更新。

为什么直接轮询 config 文件不可靠
Go 程序里硬编码读取 config.yaml 后就不管了,配置改了得重启服务——这在微服务或 CLI 工具里非常反直觉。用户期望“改完保存就生效”,但轮询(比如每 5 秒 os.Stat)既浪费 CPU,又可能漏掉快速连续的修改,还无法区分是内容变更还是仅 mtime 更新。
用 fsnotify 监听文件变化再重载
真正轻量且可靠的方式是让操作系统通知你:文件被写入完成。核心依赖 fsnotify 库,它封装了 inotify(Linux)、kqueue(macOS)、ReadDirectoryChangesW(Windows)等底层机制。
实操建议:
- 监听时用
fsnotify.Write事件,**不是**Create或Chmod——编辑器保存通常触发的是 Write - 务必加防抖:同一文件短时间内多次 Write 很常见(如 vim 的 swap 写入、IDE 的临时覆盖),建议用
time.AfterFunc延迟 100ms 再执行重载 - 重载逻辑必须原子:先解析新内容到临时结构体,成功后再原子替换全局
config变量,避免中间状态 panic - 别忘了监听
fsnotify.Remove和fsnotify.Rename——文件被 mv 替换时会触发这些,否则监听会丢失
watcher, _ := fsnotify.NewWatcher()
watcher.Add("config.yaml")
go func() {
for event := range watcher.Events {
if event.Op&fsnotify.Write == fsnotify.Write {
// 启动防抖 timer
}
}
}()
用 channel + interface 解耦观察者注册与通知
配置变更后,不希望每个业务模块都去监听 fsnotify,而是统一由配置中心分发。关键在于定义一个通用通知接口:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
实操建议:
- 定义
type ConfigObserver interface { OnConfigChange(newCfg *Config) },所有关心配置的模块实现它 - 用
map[*ConfigObserver]struct{}存注册表,避免重复注册;用sync.RWMutex保护读写 - 通知时遍历 map 调用
OnConfigChange,**但不要阻塞主 reload 流程**——每个 observer 应自行决定是否异步处理,否则一个慢 observer 会拖垮整个配置更新 - 提供
Unobserve()方法,便于测试清理或模块卸载时注销
注意 YAML/JSON 解析失败时的降级策略
用户手误改错格式,导致 yaml.Unmarshal 失败,这时不能让程序 panic 或卡死,更不能回退到旧配置(因为旧配置可能已被用户主动弃用)。
实操建议:
- 解析失败时记录完整错误和原始内容(
log.Printf("invalid config: %v, raw: %s", err, string(data))),方便排查 - 保留上一次成功加载的
*Config实例,继续使用——这是最安全的默认行为 - 可选:暴露一个
Config.IsValid() bool方法,让业务层自行判断是否启用新功能(比如某字段从 string 改成 int,旧版配置下该功能应静默禁用) - 避免在
init()或main()早期就启动 watcher——此时 logger 可能未初始化,错误日志会丢失
复杂点在于:文件系统事件、解析、通知三者不在同一 goroutine,容易出现竞态;最容易被忽略的是编辑器保存行为的多样性——VS Code 默认用原子写(先写 tmp 再 rename),而 nano 是直接覆盖,这两者触发的 fsnotify 事件类型完全不同,必须同时处理 Write 和 Rename 才能覆盖全场景。










