优先用轮询文件大小变化+seek定位末尾+bufio.scanner读新行:稳定跨平台,需检测inode变化应对logrotate轮转,设置scanner缓冲避免errtoolong,sleep 250ms平衡延迟与cpu。

如何用 Go 实现类似 tail -f 的实时文件追加监听
Go 本身不提供内建的“文件变更通知 + 行流式读取”一体化方案,必须组合使用 os.OpenFile 定位末尾 + bufio.Scanner 持续读新行 + fsnotify 或轮询检测新增内容。直接用 os.Stat 查大小变化最轻量、最可控,fsnotify 在某些场景(如 NFS、容器挂载卷)反而不可靠。
- 优先用「轮询大小 + seek 到 EOF 后持续读」:稳定、跨平台、不依赖 inotify/kqueue
- 避免一上来就上
fsnotify.Watcher:它只通知“文件被修改”,不告诉你改了多少字节,仍需自己做偏移管理 - 每次读前先
stat.Size()对比上次大小,若变大,file.Seek(oldSize, io.SeekStart)再读新内容 - 注意
bufio.Scanner默认单行上限 64KB,超长日志行会报scanner.ErrTooLong,需提前设置:scanner.Buffer(make([]byte, 4096), 1
为什么 fsnotify 不适合纯 tail -f 场景
fsnotify 是监听文件系统事件(如 WRITE、CHMOD),但 Linux 下追加写入(echo "x" >> file)可能触发多次 WRITE 事件,也可能合并成一次;更关键的是,它不暴露写入字节数,你无法知道该从哪开始读新数据 —— 还得靠 os.Stat 查大小来定位。
- 在 Docker 容器中挂载的 host 文件,
fsnotify常静默失效(inotify 事件不穿透) - 某些日志轮转工具(如
logrotate)会 rename + create 新文件,fsnotify会丢失原 fd,而轮询方式只要文件路径不变就能继续读 - 如果真要用
fsnotify,务必配合os.Stat校验大小,并监听fsnotify.Rename事件做文件重开逻辑
处理日志轮转(logrotate)的关键逻辑
真实生产环境,日志文件大概率被 logrotate 切走,比如 app.log → app.log.1,然后新建空 app.log。此时仅靠大小对比会误判为“文件被清空”,必须检查 inode 是否变化。
- 用
fi.Sys().(*syscall.Stat_t).Ino获取 inode(Linux/macOS),每次循环前比对是否变化 - inode 变了,说明文件被替换或轮转,需关闭旧
*os.File,重新os.Open并从开头读(或跳过已有内容) - 不要依赖文件名存在性判断,因为
logrotate的copytruncate模式会清空原文件而非 rename - 可缓存已处理的最后几行哈希(如 CRC32),在重开后跳过重复内容,但实现复杂,多数场景直接接受少量重复更简单
一个最小可行的轮询式 tail 实现要点
下面这段逻辑足够应对大多数日志监控需求,无第三方依赖,仅用标准库:
for {
fi, _ := file.Stat()
if fi.Size() > offset {
file.Seek(offset, io.SeekStart)
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 4096), 1
- sleep 时间别设太小(如 10ms),否则 CPU 空转;也别太大(如 2s),会延迟看到新日志
- 必须用
file.Seek()而非反复os.Open,否则在 Windows 下可能因文件被占用而失败 - 忽略
scanner.Err()中的io.EOF,但要警惕syscall.EBADF(文件已被删除或关闭) - 实际使用时建议封装成结构体,把
offset、inode、*os.File都作为字段管理
轮询方式看似“土”,但在文件路径固定、IO 压力可控的前提下,它比事件驱动更可预测。真正容易被忽略的,是轮转后 inode 变化检测和 seek 失败的错误分支处理 —— 这两处不补全,服务跑几天后大概率静默停止输出。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











