Skip to content

Fluent Bit 源码笔记(二):百万个只写一次的文件 ​

3677 字 · 约 9 分钟

Fluent Bit 源码笔记

读 in_tail 与引擎源码的四篇笔记。源码基线 v5.1.1,发布前复核至上游 master a92d414c1。

银行一笔交易一个日志文件,证券每天落一批 dbf。这类系统的日志是只写一次的,写完就是终态,再也不会变。一个目录一天两三万个文件,保留一年,就是几百万个。

Fluent Bit 不是为这种场景设计的。可我们自己的采集器,恰恰就是冲着这个场景做的。所以我很想知道,in_tail 放到这里会在哪儿撞墙,哪些墙是架构决定的,哪些只是还没做。后面写到我们自己的做法时,它的边界问题和踩过的 bug 我也一起写出来,不然不公平。

源码基线是 v5.1.1(a31cf11a2),发布前在上游 master a92d414c1(2026-10-09)上复核过。

它的模型:长期攥着 fd 和 watch ​

in_tail 的低延迟有个前提:每个被监控的文件,都长期持有一个 fd 和一个 inotify watch。

这个设计在它的主战场上是对的。K8s 节点上是几十到几百个一直在写的滚动日志,历史文件由 kubelet 和 logrotate 去清。手里攥着 fd,文件被 rename 甚至 rm 之后,剩下的数据照样能读完,轮转不丢数据,靠的就是这个 fd。

可 fd 和 watch 都是有数的,受 ulimit -n 和 fs.inotify.max_user_watches 管着。文件一多,它就只剩一个降级手段:ignore_older 加 ignore_active_older_files,文件的 mtime 老过阈值,就关 fd、摘 watch、移出监控。这是 in_tail 唯一能主动释放资源的机制,下面两个问题都出在它身上。

淘汰的时候不看读完没有 ​

淘汰的判断在 check_purge_deleted_file() 里(plugins/in_tail/tail_file.c),条件只有一个:

c
if (ctx->ignore_older > 0 && ctx->ignore_active_older_files) {
    mtime = flb_tail_stat_mtime(&st);
    if ((ts - ctx->ignore_older) > mtime) {          /* 只看 mtime 老不老 */
        flb_tail_scan_register_aged_out_inode(...);
        flb_tail_scan_register_ignored_file_size(..., flb_tail_file_db_offset(file));
        flb_tail_file_remove(file);                  /* 关 fd、摘 watch、移出监控 */
    }
}

它压根不检查文件读完没有。

淘汰的时候 offset 是存了的,看着像是还能接着读。可要重新捡起来,条件是 mtime 变新(tail_scan_glob.c):

c
if (aged_out_inode == (uint64_t) st.st_ino) {
    mtime = flb_tail_stat_mtime(&st);
    if (mtime > 0 && (now - ctx->ignore_older) > mtime) {
        continue;                   /* mtime 还是老的 → 继续跳过 */
    }
}

只写一次的文件,mtime 是永远不会再变的:

文件写完 → mtime 冻结
  → 文件太多在排队 / 下游背压 / 文件太大,还没读完
  → ignore_older 到期 → 淘汰(offset 存了,但用不上)
  → 之后每一轮扫描:mtime 还是老的 → continue
  → 没读完的部分永久丢失,没有 error,也没有 warn

更糟的是,这两个条件天然就是一起出现的:文件多,才需要配 ignore_older 来省 fd;文件多,读取才会排队,来不及读完。

这段逻辑上游今年改过两次,4 月的 22f8800ff 和 6 月的 0264c0b8c。后一次的提交说明写得很清楚,淘汰时登记 inode 和 offset,是为了「文件的 mtime 之后被新的写入刷新时,可以从保存的位置续读」。两次都在完善「文件睡着了,醒了接着读」这条路,背后的假设是被淘汰的文件还会再被写。只写一次的文件不满足这个假设,两次修改都没管到它。

修的话,在淘汰条件里补一条「已读完」就行;退一步,淘汰一个没读完的文件时,至少打一条 warn。

淘汰记录自己又在无限长 ​

淘汰靠两张 hash 表,一张记被淘汰的 inode,一张记淘汰时的 offset。它们是这么建的(plugins/in_tail/tail_config.c):

c
ctx->ignored_file_sizes   = flb_hash_table_create(FLB_HASH_TABLE_EVICT_NONE, 1000, 0);
ctx->aged_out_file_inodes = flb_hash_table_create(FLB_HASH_TABLE_EVICT_NONE, 1000, 0);
                                                  /* 永不淘汰          1000 个桶  不限容量 */

EVICT_NONE 是永不淘汰,最后那个 0 是不限容量,1000 是桶数,不是容量。key 是完整的文件路径字符串。

拿 100 万个文件算一下:每个被淘汰的文件在两张表里各留一条,永远不释放;100 万条摊到 1000 个桶里,平均链长 1000,查找就退化成了线性扫描;每一轮 glob 扫描,每个文件都要查一次表,一轮就是 100 万乘 1000 次比较。

唯一能救 fd 的机制,到了它本该派上用场的地方,也就是文件很多的时候,先把内存和 CPU 拖垮了。而且没有指标,没有日志,表现出来只是内存在慢慢涨。

这个修起来不难。这两张表本来就是缓存的意思,换成有上限的淘汰策略(EVICT_OLDER),代价顶多是某个文件被重新打开一次;或者每个扫描周期把已经不匹配 glob 的条目清掉;再把表的大小暴露成指标。

补得了的,和补不了的 ​

把 Fluent Bit 放到百万文件的场景里,问题大致分三类。

长期持有 fd 是补不了的,这是架构上的取舍。要读完就关,就得放弃对 inode 的引用,文件被 rm 以后剩下的数据就读不到了,轮转不丢数据的基础也就没了。watch 也一样,百万个 watch 本身就撑不住。真想读完就关,只能退回轮询,延迟从毫秒级变成扫描周期级。这是鱼与熊掌,没有对错。

fd 没有上限,是补得了的,这只是实现上缺了一块。in_tail 对打开的文件数不设上限,一直撞到 ulimit -n 为止,撞上了也没有专门的 EMFILE 提示。这在现有模型里就能补:超过上限的文件先排队,不打开;用现成的 flb_tail_file_remove() 把最久不活跃的文件淘汰掉,它会关 fd、摘 watch、留着 offset;打不开的文件退避一段时间再试,别每一轮都重复报错。代价是被淘汰的文件延迟高一些,可资源受限的时候本来就该这么降级,部分文件慢一点,总好过 fd 耗尽、整条管道停摆,上一篇讲过这个连锁。

上游已经在做前两条了。在途的 PR #12441 给所有 tail input 加了一个进程级共享的 max_open_files 预算,用到 75% 告警,回收休眠文件的 fd,被延后的文件保留 offset。起因之一是 #12432:一个 Windows 用户每天产生 5000~10000 个审计日志,文件数过了 8200 左右就开始报 Too many open files,其中大部分是早就读完、只是为了审计才留在盘上的历史文件。我写这篇的时候,这个 PR 还没合进去。

介于两者之间的,是在目录这一层短路。in_tail 用 POSIX glob() 展开路径,把整个展开过程交给了 libc,也就没机会在展开的过程中插一个判断进去。一个早就全部过期的历史目录,每一轮都要被完整展开一遍。要在目录这层短路,得重写扫描层,工作量不小,但跟架构不冲突。

我们走的另一条路 ​

我们的采集器走的是另一条路:不长期持有 fd,靠轮询发现变化,fd 放在一个有上限的池子里。池子满了,新发现的文件先登记,不打开;下一轮扫描发现某个文件比上次读到的位置大了,再打开,seek 到上次的位置接着读。打不开的文件进退避期,一段时间内不再重试。

代价是延迟,变化要等下一轮扫描才能发现,做不到毫秒级。可对只写一次的文件,这个代价几乎不存在,文件出现的那一刻,内容就已经写完了。

扫描这件事在老平台上尤其贵。同一个用 C 系统调用写的扫描程序,扫 100 万文件,CentOS 7.9 的 x86 物理机上约 1.4 秒,AIX 上要 288 秒(POWER5 架构、AIX 6.1、2 核 4G、100G HDD)。两台机器的配置差得很远,这不是同等硬件下的对比,可信创和金融的老系统上,采集器要面对的往往就是后面这种机器。在这种机器上,光是「每轮都把所有文件看一遍」这个动作本身就已经撑不住了,省掉扫描对象比优化单次扫描更要紧。

这套思路的核心是文件过期:已经采完、确定不会再变的文件,打个标记,不再监控,把监控全量降成监控活跃量。我作为第一发明人,把它申请了发明专利,《一种针对海量小文件数据采集技术优化方法》(CN120541047A,2025-05 申请,2025-08 公开)。专利文本里记录的效果,是在不影响采集性能的前提下,CPU 和内存使用率下降 70% 以上。

在这个模型上,资源控制分三层,越往前越省:

层作用时机省掉了什么
路径日期模板展开路径之前路径里按日期分目录的,只展开「昨天到今天」两个目录。历史目录从来不进候选集,一次 opendir 都不做
目录过期扫过一次之后一个目录下所有文件都过期、目录本身也很久没变,就整个记下来,之后连 opendir 都不做
文件过期文件读完之后释放 fd,不再参与扫描

「昨天到今天」这个范围是必须的。零点刚过的时候,昨天的目录里可能还有刚写完、没采完的文件,只取今天就漏了。

文件过期要三个条件同时满足:自上次处理以后 mtime 没变;已经读到了文件末尾;mtime 距今超过过期阈值。跟 ignore_older 比,要紧的是中间那条,它缺的正是这个。

文件过期 ⇔  自上次处理之后 mtime 没变
         且 已经读到了文件末尾          ← ignore_older 缺的正是这一条
         且 mtime 距今超过过期阈值
维度我们的模型Fluent Bit ignore_older
判定条件三条件合取,含「已读完」只看 mtime
过期状态持久化到磁盘,重启不重扫纯内存,重启即丢,百万文件时重启就是一场资源风暴
检查频率独立定时器,默认一小时每轮清理加每次 glob 扫描都查,放大上面说的线性查找
可观测有过期文件数的指标没有

目录过期是一票否决的。进目录之前先乐观地假设它已经过期了,里面只要有一个文件没过期,或者有一个子目录没过期,就推翻;子项全部过期以后,还要再看目录自己的 mtime,目录刚增删过条目的,也不算过期。只有这么判,才敢在扫描入口直接跳过整个目录。一年 365 个日期目录,每个两三万文件,历史目录直接放过去,不用一个个文件去判断。

我们自己的问题 ​

过期标记是拿文件路径当 key 的,不是 设备号:inode。同一个路径的文件被删了又重建,inode 变了,会撞上旧的过期标记,被误跳过。这一点 Fluent Bit 反倒处理了,它扫描时会比对 inode,对不上就清掉旧记录。对文件名唯一、不复用的场景,只写一次的文件基本都是这样,没有影响,但这确实是一个边界。

持久化的过期标记一样有膨胀的风险,百万个条目落到盘上,跟上面那两张 hash 表是同一类问题,有没有回收,我们还没验证过。

路径日期模板写错了不报错。写成字面量 YYYYMMDD 而不是 %Y%m%d,路径是合法的,只是一个文件都匹配不上,采集量悄悄地变成零。这个应该在配置校验的时候就拦下来。

还有三个已经修掉的 bug,说来好笑,全出在防止资源耗尽的功能里。解析日期偏移的缓冲区少留了一个终止符,偏移写满的时候越界读栈;目录打开失败的错误处理把入参当输出缓冲区用,递归的时候覆写了上一层的栈变量;同一处错误路径提前返回,跳过了关闭目录,在一个防 fd 耗尽的功能里泄漏 fd。

上游为什么没做这些 ​

不是因为他们没碰到过大规模。代码里留着他们撞过墙的痕迹(plugins/in_tail/tail_db.c):

c
/*
 * The legacy implementation builds:
 *   DELETE ... WHERE inode NOT IN (?,?,?,...);
 * which requires one bound parameter per inode and fails when the number of
 * monitored files exceeds SQLITE_LIMIT_VARIABLE_NUMBER (commonly 32766 ...).
 */

是真有人跑到了 32766 个文件以上,这个修复是用户报告推着做的。inotify ENOSPC 的专门提示、ignore_older 这个功能本身,也都是同一类痕迹。

所以准确地说,他们碰到过几万,没碰到过百万。

量级fdwatch有效的解法
几万ulimit -n 65536 撑得住max_user_watches=524288 撑得住调内核参数
百万调参数也不可能同样不可能必须换模型

在几万这个量级上,调 sysctl 就是管用的答案,社区自然没动力去做资源池、LRU 和目录短路,这个工程判断是合理的。场景的形态也能解释:只写一次的海量小文件,是金融、证券这类传统行业的样子,这些系统很少用 Fluent Bit,社区里没人提需求,也没人来贡献。

#12441 冒出来,说明已经有用户开始碰到这个天花板了。

最后 ​

在几万个文件以内,Fluent Bit 是够用的,到了百万级,我不建议硬上。它的长处在生态、多信号和处理管道,不在海量小文件。真要在这种场景下用,ignore_older 只看 mtime、不看读完没有这一点一定得知道,只写一次的文件被淘汰以后就再也不会被捡回来,没读完的那部分悄无声息就没了。


相关:采集器工程 · 背压与投递,先不伤害宿主(fd 耗尽的连锁停摆)

内容来自一线生产环境,客户信息均已脱敏