必须监听父目录而非单个文件路径,因fsnotify底层不支持监听普通文件;需结合rename和write事件处理原子写入,递归监听需手动遍历注册子目录,且须及时消费events通道防丢事件。

直接监听单个文件路径(如 "config.yaml")几乎必然失效,必须监听父目录并手动过滤事件;否则编辑器原子写入、日志轮转、临时文件等场景下,fsnotify 会静默丢事件。
为什么 watcher.Add("file.log") 总是没反应
因为 fsnotify 底层依赖操作系统原生机制(inotify/kqueue/ReadDirectoryChangesW),这些机制本身就不支持监听普通文件路径——只接受目录。传入文件路径时,Add() 可能返回 invalid argument 或静默失败,且不报错。
- 必须改用
watcher.Add(filepath.Dir("file.log")),即监听其父目录 -
event.Name是相对路径(如"file.log"),不是绝对路径,别用strings.Contains(event.Name, "file.log") - 若路径含符号链接,先调
filepath.EvalSymlinks()解析真实路径再取Dir,否则可能监听到链接自身 - 监听前务必
os.Stat()检查路径存在且可读,Add()失败不会自动重试或提示
如何正确响应编辑器保存和日志轮转
VS Code、vim、logrotate 等工具基本都走原子写入:先写 file.log.tmp 或 file.log.1,再 rename 覆盖原文件。这导致原 inode 失效,仅监听 Write 会漏事件或误处理临时文件。
- 必须同时检查
event.Op & fsnotify.Write != 0和event.Op & fsnotify.Rename != 0,不要用== -
Rename事件代表“保存完成”,应优先响应;收到后立即os.Stat(event.Name)确认文件存在且大小 > 0 - 过滤临时文件:跳过
filepath.Base(event.Name)以~、.swp、.tmp、.new结尾的事件 - Linux 下大文件写入中途就可能触发
Write,不能靠它判断“写完”;Rename才是原子信号
递归监听子目录和新增目录的实操要点
fsnotify 不支持 recursive: true,所谓“监听整个目录树”是常见误解。你调 watcher.Add("configs/") 只监听该目录自身的元数据变更(比如被 mv 或 chmod),对其下 configs/db/config.yaml 的修改完全无感。
- 启动时全量注册:用
filepath.WalkDir("configs/", ...)遍历,对每个entry.IsDir()为true的路径调watcher.Add(path) - 运行时动态响应:监听到
fsnotify.Create且event.Name是目录时,立刻watcher.Add(event.Name) - 加
sync.Mutex保护Add()调用,避免多 goroutine 并发注册 panic - 跳过符号链接:
if entry.Type()&os.ModeSymlink != 0,否则可能权限失败或无限递归
避免事件丢失、fd 泄露和 goroutine 卡死
watcher.Events 是无缓冲 channel,不及时消费会阻塞内部读取逻辑,最终丢事件;watcher.Close() 不清空已排队事件,错误通道未读也会卡死整个 watcher。
- 必须用 goroutine 消费
watcher.Events和watcher.Errors,不能在主流程里同步读 - 推荐带缓冲:
make(chan fsnotify.Event, 100),防短时爆发压垮上报逻辑 - 关闭前确保消费 goroutine 已退出;可用
select { case 控制生命周期 -
watcher.Close()必须显式调用,否则 Linux 下会泄漏 inotify 句柄,进程越久越容易 hittoo many open files - 大批量监控时,Linux 需提前调大
/proc/sys/fs/inotify/max_user_watches,否则报no space left on device
最易被忽略的是:收到事件后不做 os.Stat() 校验就直接处理——你可能正在解析一个刚被 rm 掉的路径,或打开一个零字节的临时文件。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











