不能用 bufio.Scanner 读尾部日志,因其默认跳过末尾不完整行(无换行符)、内部缓存导致 OOM、指针定位不当引发乱码或 panic,且 scanner.Bytes() 返回的切片生命周期受限。

直接用 os.Seek 从末尾倒推读取,再配合手动切行逻辑,是唯一能避开内存爆炸且不依赖第三方库的可靠方式。用 bufio.Scanner 或直接 ReadString('\n') 会卡死、丢行、或 OOM。
为什么不能用 bufio.Scanner 读尾部日志
Scanner 默认行为是跳过不完整行(即末尾没换行符的那行),而日志文件最后一行几乎总是“正在写入中”——没有 \n。一旦启用 ScanLines 分割,这行就永远消失;它还会内部缓存整块数据,对 GB 级文件极易触发内存暴涨。
- 调用
scanner.Scan()前必须确保文件指针在合法起始位置,否则可能从中间字节开始解析,导致乱码或 panic -
scanner.Bytes()返回的是内部缓冲区切片,生命周期绑定 scanner,不可长期持有 - 即使设了
scanner.Buffer(make([]byte, 4096), 1,也无法解决“最后一行未闭合”这个 tail 场景本质问题
用 os.File.Seek + 手动回溯找换行符
核心是:先 Stat() 拿到文件大小,再用 Seek(offset, io.SeekEnd) 定位,逐字节往回读,直到找到足够数量的 \n(注意 Windows 下可能有 \r\n,但绝大多数日志是 Unix 风格)。
- 回溯不能无限制:设个安全上限(比如 64KB),防止遇到超长单行(如 base64 日志)卡住
- 每次读一个字节效率低,可改用
ReadAt(buf, offset)分批读,再从 buf 尾部向前扫描\n - 找到第 N 个
\n后,下一次Read()应从该偏移+1 开始,避免重复读取换行符本身 - 别忘了处理文件为空、只有一行、或全是空行的边界情况
监控日志轮转时必须检查 inode 和 size 变化
logrotate 或 truncate -s 0 后,文件路径不变但内容重置——此时你的旧 offset 还指向几 MB 前的位置,继续读只会吐出脏数据或 EOF。必须每次循环都调用 os.Stat() 对比:
fi.Size() → 文件被截断,需重置 reader 并从头或新末尾开始-
fi.Sys().(*syscall.Stat_t).Ino != lastIno(Linux/macOS)→ 文件被 rename + recreate,inode 已变,必须os.Open新句柄 - 不要依赖
fsnotify的WRITE事件:它不带字节数,且轮转时通常先REMOVE再CREATE,你得自己关联路径和 inode 才能判断是否为同一逻辑文件
用 github.com/hpcloud/tail 时 ReOpen 和 Poll 必须同时开
这个库已多年未维护,但仍是生产环境最稳的选择。关键配置不是 “开不开 Follow”,而是 ReOpen: true 和 Poll: true 的组合:
-
ReOpen: true让它在文件被轮转后尝试重新打开同名路径(注意:不是监听 inode) -
Poll: true强制使用轮询而非 inotify,规避容器内/proc/sys/fs/inotify/max_user_watches不足或 NFS 不支持的问题 -
Location: &tail.SeekInfo{Offset: 0, Whence: 2}是默认值,不用显式写;但若想跳过已有内容只读新增,就得设Offset: 0 - 它内部仍用
os.Read+ 缓存未完成行,所以不会丢最后一行——这点比手写 Scanner 安全得多
真正容易被忽略的是:轮转后新文件权限可能变化,ReOpen 失败时它不会 panic,而是静默停在旧句柄上,后续所有 t.Lines channel 都不再产出。务必定期检查 t.Err() 并做兜底重试。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











