3、采集层故障(一):日志丢失——文件句柄泄漏与缓冲区溢出
日志丢了,这是最让人头疼的问题之一。
你排查了半天,最后发现——日志压根没写进去。或者写了,但被冲掉了。这种时候,你连骂人的力气都没有。
我个人习惯把采集层的日志丢失问题归为两类:文件句柄泄漏和缓冲区溢出。这两兄弟,一个让你写不了,一个让你写不全。今天咱们就好好聊聊它们。
3.1 文件句柄泄漏:日志写不进去的元凶
先说说文件句柄泄漏。说白了,就是你的进程把文件句柄用光了,新日志来了,没地方写了。
为什么会这样?我遇到过最典型的场景:日志轮转(log rotation)处理不当。
核心问题:日志文件被删除或重命名后,采集进程仍然持有旧的文件句柄。新日志写入旧句柄,但旧文件已经不存在了。日志就这么凭空消失了。
嗯,这里要注意。很多同学以为日志轮转只是运维的事。其实不然。采集层必须感知到文件的变化。
3.1.1 典型场景复现
我记得有一次线上告警,说某个服务的日志量突然降为零。我上去一看,进程还在跑,日志文件也在,但就是没新内容。
查了半天,发现是 logrotate 把日志文件重命名了,但采集进程还握着旧文件的 inode。新日志全写进了那个已经不存在的文件里。
# 错误示例:采集进程未处理文件重命名
# 伪代码
while True:
fd = open("/var/log/app.log", "a")
data = read_from_source()
fd.write(data)
# 没有检测文件是否被重命名
time.sleep(1)
你看,这个循环里,fd 一直没变。如果外部把 app.log 重命名成 app.log.1,再新建一个 app.log,那这个进程写的还是 app.log.1。新日志全丢了。
3.1.2 解决方案:inode 检测与文件重新打开
解决方案其实不复杂。核心思路是:每次写入前,检查当前文件的 inode 是否发生了变化。
# 改进示例:检测 inode 变化
import os
import time
def get_inode(path):
try:
return os.stat(path).st_ino
except FileNotFoundError:
return None
current_inode = get_inode("/var/log/app.log")
fd = open("/var/log/app.log", "a")
while True:
new_inode = get_inode("/var/log/app.log")
if new_inode != current_inode:
# 文件被重命名或重建,重新打开
fd.close()
fd = open("/var/log/app.log", "a")
current_inode = new_inode
print("检测到文件变化,重新打开句柄")
data = read_from_source()
fd.write(data)
fd.flush()
time.sleep(1)
我的建议:不要只依赖文件修改时间。inode 才是唯一标识。文件被重命名后,修改时间可能不变,但 inode 一定变了。
3.2 缓冲区溢出:日志写了但没落盘
文件句柄泄漏是写不进去。缓冲区溢出呢?是写了,但没落盘。
你想想看,日志采集通常有缓冲区。缓冲区满了,新日志要么丢弃,要么覆盖旧日志。无论哪种,都是丢失。
3.2.1 缓冲区溢出的常见原因
| 原因 | 说明 | 典型场景 |
|---|---|---|
| 生产者速度 > 消费者速度 | 日志产生太快,写入磁盘太慢 | 突发流量、磁盘 I/O 瓶颈 |
| 缓冲区配置过小 | 默认缓冲区大小不满足实际需求 | 高并发服务、大量 DEBUG 日志 |
| 消费者阻塞 | 磁盘满、网络延迟导致写入阻塞 | 磁盘空间不足、远程日志服务故障 |
我曾经在一个高并发网关服务上遇到过。业务高峰期,每秒产生几十万条日志。采集进程的缓冲区只有 64KB。结果呢?缓冲区瞬间被填满,后面的日志全被丢弃了。
3.2.2 如何检测缓冲区溢出
检测其实不难。关键看几个指标:
- 丢弃计数(drop count):采集框架通常会记录丢弃的日志条数
- 缓冲区使用率:监控缓冲区水位,超过阈值就告警
- 写入延迟:如果写入延迟突然升高,很可能是缓冲区在排队
# 示例:带丢弃计数的缓冲区实现
class BoundedBuffer:
def __init__(self, max_size=1024):
self.buffer = []
self.max_size = max_size
self.dropped = 0
def put(self, item):
if len(self.buffer) >= self.max_size:
self.dropped += 1
# 可以选择丢弃最旧的,或者丢弃最新的
# 这里选择丢弃最旧的
self.buffer.pop(0)
self.buffer.append(item)
def get_drop_rate(self):
total = len(self.buffer) + self.dropped
if total == 0:
return 0.0
return self.dropped / total
注意:丢弃计数本身也可能被丢弃。如果缓冲区溢出严重,连丢弃计数都来不及记录。所以,最好在采集层之外单独监控缓冲区状态。
3.3 实战:一个完整的排查流程
好了,理论说完了。咱们来点实战的。假设你现在遇到日志丢失,该怎么查?
我个人习惯按这个顺序来:
- 先确认日志是否真的丢了——对比业务指标和日志量。比如 QPS 是 1000,但日志只有 100 条,那肯定有问题。
- 检查文件句柄——用
lsof -p <pid>看进程打开了多少文件。如果接近系统上限(ulimit -n),那就是句柄泄漏。 - 检查缓冲区——看采集进程的内存使用。如果内存持续增长,可能是缓冲区堆积。如果内存稳定但日志丢失,可能是缓冲区溢出丢弃。
- 检查磁盘——
iostat -x 1看磁盘 I/O。如果 await 很高,说明磁盘是瓶颈。 - 检查日志轮转——手动触发一次 logrotate,看采集进程是否重新打开文件。
我曾经踩过的坑:有一次排查了半天,所有指标都正常。最后发现是日志采集框架的版本有 bug——缓冲区满了之后,不是丢弃,而是直接阻塞了生产者线程。结果整个服务都 hang 住了。所以,一定要关注第三方库的已知问题。
3.4 核心知识体系
下面这张图,是我对本章内容的总结。你可以把它当作排查时的思维导图。
3.5 避坑指南
最后,分享几个我亲身踩过的坑:
- 不要相信默认配置——很多采集框架的默认缓冲区大小只有几十 KB。对于高并发服务,这远远不够。我建议至少配置到 10MB 以上。
- 文件句柄泄漏不一定是代码问题——有时候是操作系统参数限制。比如
fs.file-max设置过小。记得检查系统级限制。 - 日志丢失可能是网络问题——如果采集层是远程写入(比如通过 TCP 发送到日志中心),网络抖动也会导致丢失。这时候需要加本地缓存或重试机制。
- 监控要分层——不要只监控采集进程本身。还要监控磁盘、网络、系统资源。日志丢失往往是多个因素叠加的结果。
我曾经犯过的错:有一次我把缓冲区调得很大,以为这样就不会丢了。结果内存爆了,OOM Killer 直接把进程杀了。嗯,缓冲区不是越大越好。要结合业务流量和内存限制来配置。
好了,这一章就到这里。日志丢失是个老生常谈的问题,但每次遇到,原因可能都不一样。记住:先确认,再排查,最后修复。别一上来就改配置,那样只会越改越乱。
公众号:蓝海资料掘金营,微信deep3321