2.1 日志采集:文件尾随与轮转

采集的第一步是「盯着一个日志文件读新增」,行话叫尾随(tail)。麻烦在于日志文件不是静止的:写满会被切走(轮转),切走后名字一变,采集器要么继续读旧文件、要么跟丢新文件,两边都出过经典事故。本篇把尾随与轮转讲透,让采集在任何切割下都不重不漏。

尾随的基本骨架

尾随的核心是「记住上次读到哪」。每次读文件前,先用记录的文件位置偏移定位,读完更新偏移,加文件监听(inotify 或轮询)。文件没新内容时读操作会阻塞或让出,有新内容立即醒来,这样既省 CPU 又几乎零延迟。

步骤 操作 要点
定位 seek 到记录偏移 偏移持久化
读取 读到文件尾 阻塞或让出
更新 记录新偏移 刷盘时机要注意
唤醒 有新数据继续 inotify 或轮询

骨架简单,难点全在偏移存哪:存内存,进程崩了从头或从断点重读会重复;存磁盘太频繁,开销大于读取本身。实战折中是「内存记、定时刷、按段对齐」。偏移与断点续传的套路是通用的,做采集类系统都会遇到,可对照 Go 爬虫实战 里的断点续读设计一起看。

轮转的四种姿势

轮转方式 文件怎么变 采集怎么跟
rename + 新建 旧文件改名,新建同名 记住 inode,新建文件重新打开
复制 + 截断 内容复制走,原文件清空 截断后偏移归零
按时间切 每天一个新文件名 按时间找新文件
按大小切 大小到了改名归档 同 rename

最容易坑的是复制加截断:采集器记着偏移 5000,结果文件被截断回 0,采集器 seek 到 5000 发现是文件末尾,就以为「没新数据」——其实是新数据写在 0 之后,于是完美错过。这种轮转必须靠「文件大小突然变小」来探测,然后把偏移归零。

rename 轮转的跟法

rename 加新建是最常见的姿势:日志文件 app.log 写满后改名 app.log.1,新建 app.log。正确的跟法是认 inode 不认名字:采集器打开的是文件描述符,rename 之后原描述符仍然指向旧文件,继续读它直到读完归档;同时检测到 app.log 有了新 inode,为它打开第二个描述符。两个描述符并存几分钟,老文件读完关掉。

时刻 旧描述符 新描述符 动作
轮转前 app.log 无 正常读
刚轮转 app.log.1 app.log 双开,先读旧
旧读完 关闭 app.log 只读新

漏掉「为新 inode 打开新描述符」这一步,表现就是「轮转后采集全停」——文件句柄还指向归档文件,新日志一条都没读。这是采集器最声名远扬的 bug,几乎没有新手一次写对。补一个探测示例:每隔一个探测周期对当前路径做一次 stat,比对返回的 inode 与已打开描述符的 inode,不一致就打开新描述符;旧描述符读完到 EOF 再关闭。整个逻辑加起来不过几十行,但少了任何一行上线都会出事。

// 轮转探测的骨架
let meta = std::fs::metadata(&path)?;
if meta.ino() != opened_ino {
    // 路径指向了新文件:打开第二个描述符
    let new_fd = std::fs::File::open(&path)?;
    rotates.push(new_fd);
}

轮转与偏移的协作细节

双开描述符之后,偏移要分文件记:旧文件一个偏移、新文件一个偏移,互不污染。常见错误是只记一个全局偏移,轮转后新文件从旧偏移定位,要么跳过一段、要么读到半截。另一个细节是旧文件何时关闭:读到 EOF 就关,但有些采集器会「贪心」再等几秒看有没有补写——经验上等一轮探测周期即可,等太久只是白占 fd。

场景 偏移处理 关闭时机
旧文件 独立偏移 EOF 后关
新文件 独立偏移,从 0 不关
截断 归零 不关

不重不漏的三重校验

校验 防什么 怎么做
偏移持久化 重启重复或漏 崩溃重启从偏移恢复
inode 探测 rename 跟丢 定期 stat 比对 inode
大小回退探测 截断漏读 大小变小即归零

三条合起来,外加启动时从最后已知偏移回退一小段(防偏移记在半行中间),基本做到不重不漏。若日志行天然带唯一序号或时间戳,还可以在汇聚层再做一次幂等去重,双保险。三重校验各自的造价都不高,但漏掉任何一重,故障症状都极其隐蔽——不是报错,而是悄悄少了几条日志,等你想用时才发现。

启动恢复:从哪里接着读

采集进程重启后,从哪个偏移接着读?稳妥的做法是回退一小段再开始:把持久化的偏移回退若干 KB,从那里向后扫描到第一个换行符再开始解析,容忍偏移落在半行的情形。回退的代价是少量重复,换来的是解析错位这种灾难性后果的消除。重复的部分由汇聚层去重吸收,整体仍不重不漏。

策略 重复风险 漏读风险 实现复杂度
不回退 无 高(半行错位) 低
回退定长 少量 低 中
回退 + 幂等去重 无 低 高

实战多数选「回退定长」,复杂度可控;只有日志极其金贵时才上幂等去重全套。另外提醒一句:持久化的偏移文件本身也是一份状态,它写坏了等于恢复点丢失,写入要用原子替换(先写临时文件再 rename),别省这一步。

采集层的自我保护

采集器读得快、下游处理得慢时,必须有自我保护,否则内存队列一路涨。第一道是有界通道:满了就让读暂停,下一节详述。第二道是超长行截断:遇到几 MB 一条的异常行,截断到固定长度并打标记,防止内存被单行撑爆。第三道是慢消费告警:采集了但下游迟迟不收,说明整条管道某处积压,告警出来定位。积压告警的阈值设定与消息系统的做法一致,可参考 Go 即时通讯实战 里的消息积压监控思路。三道保护把「读」这个动作从「能读多少读多少」变成「读得安全」。

保护 防的故障 实现
有界通道 内存涨爆 通道容量上限
超长行截断 单行撑爆内存 长度阈值截断
慢消费告警 积压发现晚 采集计数对处理计数

三道保护之外,还有一个容易被忽略的边界:采集器要知道自己读的是哪个进程的日志。多进程写同一个日志文件时,行可能交错(两个进程的半行拼成一行),此时按「每行一条」解析就是错的,得靠每行自带的进程标识或时间戳做归属。这个坑在 nginx access log 这类多 worker 场景很常见。

踩坑提示

最经典的坑是偏移写在半行中间:日志正在写,采集器读完几条更新偏移,恰好这个偏移落在一行还没写完的位置,重启后从半行开始解析,后面全部错位。解法是回退到上一个换行符再开始,或者只记录「已确认完整行」的偏移。另一个坑是文件描述符泄漏:轮转时旧描述符忘了关,天长日久 fd 用尽,采集进程被系统生生拒绝服务。fd 有限,且开且珍惜,关描述符和开描述符一样重要。

轮转探测的频率决定跟丢窗口的大小:探测周期越短,轮转后新文件被打开得越快,但 stat 系统调用也越频繁。实战取 1 秒到 5 秒之间,配合 inotify 事件可以做到接近实时。下表给出常用探测周期的权衡:

探测周期 跟丢窗口 系统调用开销 适用场景
0.5 秒 极短 高 日志金贵、机器空闲
1~2 秒 短 低 多数在线服务
5 秒 可接受 极低 离线分析

选完探测周期,还有一个习惯值得固化:采集器自己的状态(在读哪个文件、偏移多少、轮转了几次)也 dump 到一个状态文件里,出问题时它就是唯一的排障线索,比猜代码快十倍。

小结

尾随靠「偏移记住读到哪」,轮转靠「inode 加大小回退」双探测,rename 双开描述符、截断偏移归零。把不重不漏的三重校验写进采集器,文件再怎么切都不慌。采集器是管道的门面,它跟丢了,后面所有环节的准确率都是零。

延伸阅读