Fluent Bit 源码笔记(二):百万个只写一次的文件
3677 字 · 约 9 分钟
Fluent Bit 源码笔记
读 in_tail 与引擎源码的四篇笔记。源码基线 v5.1.1,发布前复核至上游 master a92d414c1。
- (一)背压与投递,先不伤害宿主 · 背压与投递
- (二)百万个只写一次的文件 · 规模天花板
- (三)采集器怎么记住自己读到了哪里 · offset 存储
- (四)读取边界上的数据丢失,GBK 日志丢了 27.4% · 编码与压缩
银行一笔交易一个日志文件,证券每天落一批 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),条件只有一个:
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):
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):
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):
/*
* 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 这个功能本身,也都是同一类痕迹。
所以准确地说,他们碰到过几万,没碰到过百万。
| 量级 | fd | watch | 有效的解法 |
|---|---|---|---|
| 几万 | ulimit -n 65536 撑得住 | max_user_watches=524288 撑得住 | 调内核参数 |
| 百万 | 调参数也不可能 | 同样不可能 | 必须换模型 |
在几万这个量级上,调 sysctl 就是管用的答案,社区自然没动力去做资源池、LRU 和目录短路,这个工程判断是合理的。场景的形态也能解释:只写一次的海量小文件,是金融、证券这类传统行业的样子,这些系统很少用 Fluent Bit,社区里没人提需求,也没人来贡献。
#12441 冒出来,说明已经有用户开始碰到这个天花板了。
最后
在几万个文件以内,Fluent Bit 是够用的,到了百万级,我不建议硬上。它的长处在生态、多信号和处理管道,不在海量小文件。真要在这种场景下用,ignore_older 只看 mtime、不看读完没有这一点一定得知道,只写一次的文件被淘汰以后就再也不会被捡回来,没读完的那部分悄无声息就没了。
相关:采集器工程 · 背压与投递,先不伤害宿主(fd 耗尽的连锁停摆)