3、日志文件采集:tail -f 原理、Python实现实时文件读取、seek与文件指针管理

日志采集,说白了就是盯着文件看它有没有新内容。

很多新手会问:不就是读文件吗?有什么难的?

嗯,如果你只是读一个静态文件,确实不难。但日志文件是活的——它一直在追加,还会滚动(rotate)。你想想看,一个文件在不停变大,甚至文件名都变了,你怎么保证不漏掉一行数据?

这一章,我们就来彻底搞懂这件事。

3.1 tail -f 到底是怎么工作的?

先问个问题:你在终端里敲 tail -f app.log,为什么新日志一出现,屏幕就立刻显示出来?

我刚开始做运维时,以为 tail 是每秒去读一次文件。后来翻了源码才发现,根本不是这么回事。

tail -f 的核心原理其实就两步:

  1. 先定位到文件末尾——用 lseek() 把文件指针移到文件尾部,然后从那里开始读。
  2. 然后进入阻塞等待——调用 inotify(Linux)或 kqueue(macOS)监听文件变化。文件有写入时,内核会主动通知 tail 进程。

说白了,它不是「轮询」,而是「被通知」。这才是高效的关键。

核心要点:

  • tail -f 不是每秒读一次,而是等内核告诉它「文件变了」
  • 它只读新增的部分,不会从头读整个文件
  • 文件被删除或重命名时,tail -f 会报错退出(除非用 -F 参数)

我记得有一次线上排查问题,同事用 tail -f 盯着日志,结果日志文件被 logrotate 切走了,tail 直接退出了。他愣了半天没反应过来。嗯,这就是没搞懂原理的代价。

3.2 Python 实现实时文件读取

好,原理搞清楚了。那用 Python 怎么实现?

很多人第一反应是:

with open('app.log', 'r') as f:
    for line in f:
        print(line)

这能实时吗?不能。因为 for line in f 会读到文件末尾就停住了。新来的日志它根本不知道。

正确的做法是:手动管理文件指针

来看一个最基础的版本:

import time

def follow(filepath):
    with open(filepath, 'r') as f:
        # 先跳到文件末尾
        f.seek(0, 2)
        
        while True:
            line = f.readline()
            if line:
                print(line.strip())
            else:
                # 没有新行,等一会儿再试
                time.sleep(0.1)

这段代码虽然简单,但已经抓住了核心:seek(0, 2) 跳到末尾,然后不断读新行。

不过,它有个问题——time.sleep(0.1) 是轮询,不是真正的「被通知」。如果日志量很大,0.1 秒的延迟可能不够;如果日志很少,CPU 又白白浪费了。

我的建议:

在 Linux 上,可以用 pyinotifywatchdog 库来监听文件事件。这样文件一有写入,你的程序立刻被唤醒,不需要 sleep。

我个人习惯用 watchdog,API 更友好,跨平台也支持。

来看一个用 watchdog 的版本:

import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

class LogHandler(FileSystemEventHandler):
    def __init__(self, filepath):
        self.filepath = filepath
        self.f = open(filepath, 'r')
        self.f.seek(0, 2)  # 跳到末尾
    
    def on_modified(self, event):
        if event.src_path == self.filepath:
            for line in self.f:
                print(line.strip())

if __name__ == '__main__':
    handler = LogHandler('app.log')
    observer = Observer()
    observer.schedule(handler, '.', recursive=False)
    observer.start()
    
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

这个版本就优雅多了。文件被修改时,on_modified 被触发,然后读取所有新行。

3.3 seek 与文件指针管理

说到文件指针,这是最容易踩坑的地方。

我见过不少新手写的采集程序,跑着跑着就丢数据了。为什么?因为文件指针乱了。

先搞清楚几个概念:

方法 作用 常见用法
f.tell() 返回当前指针位置(字节偏移量) 记录上次读到哪了
f.seek(offset, whence) 移动指针到指定位置 跳到文件末尾、跳到某一行
f.readline() 读一行,指针移到下一行开头 逐行读取

seek 的第二个参数 whence 有三种取值:

  • 0:从文件开头算起(默认)
  • 1:从当前位置算起
  • 2:从文件末尾算起

所以 f.seek(0, 2) 的意思就是「跳到文件末尾,偏移 0 字节」。

注意:

文件指针是按字节算的,不是按行。如果你用 seek 跳到一个字节的中间位置,再读行可能会读到乱码。尤其是 UTF-8 编码的文件,一个中文字符占 3 个字节,跳错位置就麻烦了。

我曾经踩过这个坑——采集一个中文日志文件,指针没对齐,结果读出来的行全是乱码。排查了半天才发现是 seek 的位置不对。

那怎么避免?我的做法是:只从行首开始读,只记录行尾的位置

来看一个带指针管理的完整示例:

import os
import time

class LogFollower:
    def __init__(self, filepath):
        self.filepath = filepath
        self.f = None
        self.position = 0
        self._open_file()
    
    def _open_file(self):
        """打开文件,并跳到上次记录的位置"""
        self.f = open(self.filepath, 'r')
        if self.position > 0:
            self.f.seek(self.position)
        else:
            # 第一次打开,跳到末尾
            self.f.seek(0, 2)
            self.position = self.f.tell()
    
    def read_new_lines(self):
        """读取所有新行"""
        lines = []
        for line in self.f:
            lines.append(line.strip())
        # 更新指针位置
        self.position = self.f.tell()
        return lines
    
    def check_rotation(self):
        """检查文件是否被滚动(rotate)"""
        # 如果文件不存在了,或者 inode 变了,说明被滚动了
        if not os.path.exists(self.filepath):
            return True
        # 简单起见,这里只检查文件是否存在
        # 生产环境建议用 os.stat 比较 inode
        return False
    
    def close(self):
        if self.f:
            self.f.close()

# 使用示例
follower = LogFollower('app.log')
try:
    while True:
        if follower.check_rotation():
            print("文件被滚动,重新打开...")
            follower.close()
            follower._open_file()
        
        new_lines = follower.read_new_lines()
        for line in new_lines:
            print(f"[新日志] {line}")
        
        time.sleep(0.5)
except KeyboardInterrupt:
    follower.close()

这个版本做了几件关键的事:

  • 记录 position,即使程序重启也能从断点续读
  • 检测文件滚动,自动重新打开
  • 只读新增内容,不重复读旧数据

生产环境建议:

position 存到磁盘上(比如一个 .pos 文件)。这样即使程序崩溃重启,也能从上次的位置继续读,不会丢数据。

我做过的一个采集系统,每天处理几十 GB 的日志,就是靠这个机制保证「零丢失」的。

3.4 避坑指南

最后,分享几个我踩过的坑:

  • 文件编码问题:日志文件可能是 GBK、UTF-8 甚至混合编码。打开文件时一定要指定 encoding 参数,否则读出来全是乱码。
  • 大文件 seek 性能:如果文件有几十 GB,seek(0, 2) 本身很快(O(1)),但如果你从头读到尾,那就慢了。记住:只读新增部分。
  • 文件被删除后重新创建:logrotate 经常干这事。你的文件句柄还指向旧文件,但旧文件已经被删了。需要用 os.stat 检查 inode 是否变化。
  • 缓冲区问题:Python 默认有缓冲区,可能日志已经写入了文件,但你的程序读不到。用 open(..., buffering=0) 可以关掉缓冲区,但会影响性能。折中方案是设置 buffering=1(行缓冲)。

嗯,这一章的内容就到这里。下一章我们会讲「日志轮转的处理策略」,到时候你会看到,文件滚动才是真正的噩梦——但搞懂了之后,也就那么回事。