3、日志文件采集:tail -f 原理、Python实现实时文件读取、seek与文件指针管理
日志采集,说白了就是盯着文件看它有没有新内容。
很多新手会问:不就是读文件吗?有什么难的?
嗯,如果你只是读一个静态文件,确实不难。但日志文件是活的——它一直在追加,还会滚动(rotate)。你想想看,一个文件在不停变大,甚至文件名都变了,你怎么保证不漏掉一行数据?
这一章,我们就来彻底搞懂这件事。
3.1 tail -f 到底是怎么工作的?
先问个问题:你在终端里敲 tail -f app.log,为什么新日志一出现,屏幕就立刻显示出来?
我刚开始做运维时,以为 tail 是每秒去读一次文件。后来翻了源码才发现,根本不是这么回事。
tail -f 的核心原理其实就两步:
- 先定位到文件末尾——用
lseek()把文件指针移到文件尾部,然后从那里开始读。 - 然后进入阻塞等待——调用
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 上,可以用 pyinotify 或 watchdog 库来监听文件事件。这样文件一有写入,你的程序立刻被唤醒,不需要 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(行缓冲)。
嗯,这一章的内容就到这里。下一章我们会讲「日志轮转的处理策略」,到时候你会看到,文件滚动才是真正的噩梦——但搞懂了之后,也就那么回事。