fsnotify是首选,因其基于内核事件(linux inotify/macos kqueue/windows readdirectorychangesw),低延迟、高准确率;轮询os.stat则耗cpu且易漏事件,尤其快速连续写入时。

为什么用 fsnotify 而不是轮询?
轮询 os.Stat 看修改时间不仅耗 CPU,还容易漏掉快速连续的写入(比如 echo "x" > config.yaml && echo "y" > config.yaml 两次写入可能只被检测到一次)。fsnotify 基于 inotify(Linux)、kqueue(macOS)、ReadDirectoryChangesW(Windows),是内核级事件通知,延迟低、准确率高。但要注意:它不递归监听子目录,也不保证事件顺序绝对严格(尤其在 NFS 或容器挂载场景下)。
直接上手时,别用 github.com/fsnotify/fsnotify 的裸 API 写死逻辑,而是封装成可复用的监听器:
- 监听前先
os.Stat一次,确认文件存在且可读,避免启动时 panic - 对
fsnotify.Event.Op做细粒度判断——fsnotify.Write和fsnotify.Create都可能触发重载,但fsnotify.Rename(如编辑器先写临时文件再 mv)也得处理 - 加个简单去重:记录上一次成功加载的时间戳或文件 inode+size,防止同一变更被重复触发
如何安全地重新加载配置而不中断服务?
配置重载最危险的不是读错文件,而是新旧配置切换瞬间的竞态。比如 HTTP server 正在用旧的 Timeout 字段处理请求,你却已把全局变量替换成新结构体。
推荐做法是「原子替换」+「双检查」:
- 用
sync.RWMutex保护配置变量,读操作用RLock,写操作用Lock - 每次 reload 都新建一个配置实例(比如
newConfig := &Config{...}),解析成功后再整体替换指针:config = newConfig - 关键业务逻辑里不要直接引用全局配置字段,而是先
config.RLock(),取完值立刻RUnlock(),避免锁持有过久
var (
configMu sync.RWMutex
config *Config
)
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
newCfg := &Config{}
if err := yaml.Unmarshal(data, newCfg); err != nil {
return err
}
configMu.Lock()
config = newCfg
configMu.Unlock()
return nil
}
回调函数里该不该做阻塞操作?
绝对不该。fsnotify 的事件回调是在独立 goroutine 中执行的,但它的内部 channel 容量有限(默认 1024),如果你的回调里做了耗时操作(比如调用远程 API、执行 shell 命令、或者没加超时的 http.Get),会迅速堵住 channel,后续文件事件直接丢失。
正确姿势是「立即投递、异步执行」:
- 回调里只做轻量检查和发信号,比如往一个带缓冲的
chan string发送文件路径 - 另起一个 goroutine 消费这个 channel,做真正的解析和 reload
- 加上 context 控制超时:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
顺便提醒:别在回调里 defer cancel()——这会导致 context 过早结束;应该在实际 reload 函数里调用。
常见错误:监听路径写错或权限不足
fsnotify.Watch 接收的是**目录路径**,不是文件路径。写成 watcher.Add("config.yaml") 会失败并返回 no such file or directory,因为底层需要监听整个目录来捕获 rename/mv 事件。
另外三类典型报错:
-
permission denied:目标目录不可读(Linux/macOS 上需有 +r 权限,Windows 需有 LIST_DIRECTORY) -
too many open files:系统限制了 inotify 实例数,默认常为 8192,可通过sysctl fs.inotify.max_user_watches=524288临时调高 -
invalid argument:尝试监听符号链接指向的不存在路径,或挂载点类型不支持(如某些 tmpfs 或 procfs)
调试时先用 ls -ld /path/to/dir 确认权限,再用 strace -e trace=inotify_add_watch go run main.go 看内核调用是否成功。
真正难的不是监听本身,而是 reload 后配置生效的边界——比如日志级别变了,但旧 goroutine 还在用老的 logger 实例;又比如 TLS 证书更新了,但 listener 没重启,新连接才会用新证书。这些必须按组件单独设计热更新路径,没法靠一个通用回调兜底。











