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 双开描述符、截断偏移归零。把不重不漏的三重校验写进采集器,文件再怎么切都不慌。采集器是管道的门面,它跟丢了,后面所有环节的准确率都是零。
延伸阅读
- 多路日志的有序汇聚,见下一节 2.2。
- 偏移与 WAL 的关系,见 4.4。
- 采集与服务化部署,见 Node.js 云开发实战。
- 资源隔离与 fd 治理,见 Go 微服务实战。
