fsnotify不能直接当同步器用,因其仅通知“变化”而不校验内容、不保证顺序、不处理编辑器原子写入导致的多事件/重命名/临时文件问题,需配合去抖、比对、原子替换和持久化决策才能可靠同步。

直接用 fsnotify 监听文件变化,再配 os.Copy 硬拷贝,90% 的场景会丢数据、重复同步或同步到半截文件——它只告诉你“变了”,不告诉你“变对了没”“要不要同步”“冲突怎么判”。真正的增量同步必须补足比对、去抖、原子替换和持久化决策这四块。
为什么 fsnotify 不能直接当同步器用
fsnotify 是起点,不是终点。它发出来的 fsnotify.Write 事件不带内容、不校验哈希、不保证顺序,且在编辑器保存时可能连发多次(比如 vim 先写 swap、再写主文件、再删 swap),若不做处理就会触发三轮无效同步。
- 多个连续
Write事件需用时间窗口去抖(如 200ms 内只取最后一次) -
Rename在 Linux 和 macOS 行为不一致:Linux 常拆成Remove+Create,macOS 可能只发一个Rename,得靠os.SameFile或路径状态缓存来识别真实重命名 -
Remove事件不能立刻删目标端——得先查源端是否真没了(防止网络延迟导致事件乱序),否则可能误删刚同步成功的文件
怎么判断“这个文件真的需要同步”
仅看事件类型或修改时间(ModTime())不可靠:FAT32 时间戳精度只有 2 秒,touch 可篡改时间,硬链接共享 ModTime 却内容不同。必须分层校验:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 先用
os.Stat比大小和ModTime,快速过滤明显未变的文件 - 对小文件(sha256.Sum256;大文件读前 4KB + 后 4KB + 总大小拼接哈希(跳过中间不变部分)
- 用
os.SameFile(srcInfo, dstInfo)排除硬链接或同一文件自拷贝 - 单独处理符号链接:
os.Readlink获取原路径,按策略决定是同步链接本身还是链接指向的内容
如何安全完成一次原子同步
同步不是“删旧建新”,而是“写新+换名”,否则中断会导致目标端残留损坏文件。
- 写入临时路径(如
dst/.file.txt.tmp),成功后再os.Rename替换原文件——该操作在同文件系统下是原子的 - 跨文件系统时
os.Rename会返回syscall.EXDEV,此时必须退化为os.Copy+os.Remove,但这两步非原子,需加失败回滚逻辑(如写完后校验哈希,不匹配则删临时文件) - 删除操作要双检:收到
Remove事件后,先os.Stat源路径确认不存在,再删目标;若源还存在,说明是误报或延迟事件 - 所有写操作建议加
sync.Mutex或sync.RWMutex,避免多事件并发写同一文件
增量备份的 checkpoint 怎么持久化才不丢数据
checkpoint 不是“同步完就记”,而是“目标端事务提交后才记”。文件系统没有事务,所以得靠写入后立即 os.Sync() + 校验哈希成功,才算真正落盘。
- 每次成功同步一个文件后,把它的路径 + 哈希 + 修改时间写入本地
checkpoint.json(用os.O_CREATE | os.O_WRONLY | os.O_SYNC打开) - 重启时读
checkpoint.json,跳过已同步且哈希一致的文件;对哈希不一致的,强制重同步 - 不要用内存变量存 checkpoint——进程崩溃就全丢;也不要只写日志不
Sync()——断电可能只写了一半 - 若用
go-rsync库,它自带断点续传和差异块校验,但注意其rsync.Options.CheckpointPath必须设为可写路径,且每次同步后手动调Save()
最易被忽略的是“删除同步”的决策时机和“跨文件系统 rename 失败后的降级路径”——这两处不显眼,但线上出问题时几乎必现,且日志很难定位。动手前先用 strace -e trace=rename,copy_file_range 跑一遍测试流,比读十遍文档管用。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










