安全获取文件修改时间需先用os.Stat并检查os.IsNotExist错误,再对ModTime截断到秒级;比较时间应设误差窗口而非直接After或Equal;遍历时直接用Walk传入的FileInfo避免重复stat;同步时用OpenFile+O_CREATE/O_TRUNC替代先Stat再操作以防TOCTOU竞态。

如何用 os.Stat 安全获取文件修改时间
直接调用 os.Stat 获取 ModTime() 是最常见做法,但容易忽略两个关键点:一是文件可能被并发删除或重命名,导致 stat 返回 os.ErrNotExist;二是某些文件系统(如 NFS、FAT32)或挂载选项(noatime)下,ModTime 可能不精确或滞后。实际同步逻辑里必须显式检查错误,不能假设返回值一定有效。
推荐写法:
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// 文件已不存在,按“已删除”处理
return nil, ErrFileGone
}
return nil, err
}
modTime := fi.ModTime().Truncate(time.Second) // 截断到秒级,规避纳秒级抖动- 始终用
os.IsNotExist判断而非字符串匹配错误信息 - 对时间做
Truncate(常用time.Second),避免因纳秒差异误判“修改过” - 注意:Windows 下 FAT32 默认只保留 2 秒精度,NTFS 通常支持 100ns,但跨平台同步建议统一截断
对比两个文件的修改时间该用 After 还是 Equal
判断“源文件是否更新”时,不能只看 t1.After(t2)。如果目标文件时间戳因时钟漂移、手动修改或挂载参数(如 relatime)被回拨,After 会漏同步;而直接用 Equal 又无法处理“同秒内多次修改”的场景。
立即学习“go语言免费学习笔记(深入)”;
更稳妥的做法是引入“可接受的时间误差窗口”:
const timeSkew = 2 * time.Second
if srcMod.After(dstMod.Add(timeSkew)) || dstMod.After(srcMod.Add(timeSkew)) {
// 任一方向超出误差窗口,视为需同步
return true
}
- 误差窗口设为 2–5 秒较合理:既覆盖常见时钟不同步,又不会把真实修改淹没
- 不要用
==或Equal直接比较time.Time,浮点误差和时区都可能导致意外结果 - 若业务要求强一致性(如日志归档),应配合校验和(
sha256)而非仅依赖时间戳
遍历目录时如何避免重复 stat 和竞态
用 filepath.Walk 遍历时,每进一个文件都调一次 os.Stat 效率低,且在两次 stat 之间文件可能被删/改,导致状态不一致。正确做法是在 walk 的 FileInfo 回调里直接使用传入的 os.FileInfo——它已由 walk 内部 stat 过,且含完整时间信息。
示例:
err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil // 跳过目录、symlink 等
}
modTime := info.ModTime().Truncate(time.Second)
// 后续直接用 modTime,不再 Stat
return syncIfNewer(path, modTime)
})
- 传给
Walk的回调函数中,info是可靠且已缓存的,无需再Stat - 但注意:这个
info不包含 symlink 目标文件的 ModTime,遇到符号链接需额外os.Stat(并检查os.IsSymlink) - 若需跳过某些路径(如
.git),应在 walk 前预过滤,而非在回调里return filepath.SkipDir—— 后者仍会触发父目录 stat
同步前要不要先检查目标文件是否存在
很多实现习惯先 os.Stat 目标路径判断是否存在,再决定是 copy 还是 overwrite。这引入了 TOCTOU(Time-of-Check to Time-of-Use)竞态:检查后、copy 前目标文件可能被创建或删除。
更安全的方式是直接 os.OpenFile 以 os.O_CREATE | os.O_WRONLY | os.O_TRUNC 打开,并捕获 os.IsNotExist 错误来区分“首次创建”和“覆盖”:
f, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
if os.IsNotExist(err) {
// 父目录不存在,需 mkdir -p
if merr := os.MkdirAll(filepath.Dir(dstPath), 0755); merr != nil {
return merr
}
f, err = os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
}
if err != nil {
return err
}
}
- 一次 open 就能同时处理“文件存在/不存在”“目录存在/不存在”两种情况
- 注意
O_TRUNC会清空已有内容,若需原子替换(防止读取中途损坏),应写临时文件再os.Rename - rename 在同分区是原子的,跨分区则 fallback 到 copy + remove,此时需再次校验时间戳是否变更
时间戳同步看似简单,真正落地时最常翻车的不是逻辑,而是对 os.Stat 返回值的假设、对时钟精度的忽视、以及没意识到 filepath.Walk 已经帮你做了 stat。


















