Skip to content

Fluent Bit 源码笔记(四):读取边界上的数据丢失,GBK 日志丢了 27.4% ​

4059 字 · 约 10 分钟

Fluent Bit 源码笔记

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

给 in_tail 配上 generic.encoding=GBK,采一份 GBK 日志,2000 行只收到了 1452 行,丢了 27.4%。丢的位置精确地落在 32KB 的读取边界上,一段一段整块地没了,全程只有一条跟数据丢失八竿子打不着的 error。

同一个根因,换到 UTF-16 上是每个边界丢一行,换到 .gz 上是读到一半重启,剩下的永远读不出来。三个问题是一家子:文件里的物理位置,和数据流里的逻辑位置,被当成了一回事。

这四篇里,这一篇是我判断错得最多的。三个问题,读源码时的预测全被实测推翻过,有的比我想的严重,有的比我想的轻。

源码基线 v5.1.1(a31cf11a2),发布前在上游 master a92d414c1(2026-10-09)上复核过。这期间 src/unicode/ 只有两个不相干的改动,一个是转换失败时清空输出指针,一个是修正 UHC 映射表的上界,下面说的逻辑都没变。所有实测都是在本地 v5.1.1 全量 Debug 构建上做的。

两种位置 ​

采集一个文件,其实要同时记着两种位置。一种是物理位置,读到了文件的第几个字节,重启以后靠它 lseek 回去。一种是逻辑位置,已经处理完的数据在数据流里到了哪,也就是处理到了最后一个完整的行、最后一个完整的字符。

不转码、不解压的时候,这两个是一回事,只有一个细节要处理:每次 read() 的结尾几乎不可能刚好落在行尾,最后那半行得留在缓冲区里,等下一次读到后半截再拼上。in_tail 的普通路径就是这么做的,只把完整的行算作已消费,半行留着。

可一旦中间多了一层转换,编码转换也好,解压缩也好,物理位置和逻辑位置就分开了。这时候必须由转换层告诉调用方,它实际消费了多少源字节,调用方才知道该在缓冲区里留多少。下面三个问题,都是这一步没做对。

GBK:整块整块地丢 ​

in_tail 有两条转码路径,GBK、GB18030、ShiftJIS、UHC、Big5 走的是 generic.encoding 这一条。

读取是按字节切的,每次最多读 buf_max_size,默认 32KB,边界完全可能落在一个汉字的两个字节中间。

转换遇到不完整的字符就停下来,可报的是成功。src/unicode/flb_conv.c 里的转换循环:

c
l = flb_encoding_verifymbchar(encoding, (const char *)p_iso, len);
if (l < 0) {
    break;                       /* 尾部字符不完整 → 跳出 */
}
...
return p_utf - start_utf;        /* 返回已写出的长度,是正数 */

上层只有返回值 <= 0 的时候才当失败,所以这里走的是成功分支。转换实际消费了多少源字节,p_iso - iso,是算出来了的,就是没往上传。

再看 offset 是怎么推进的,plugins/in_tail/tail_file.c:

c
original_len = end - data;        /* 缓冲区里全部的原始字节 */
...
if (lines > 0) {
    ...
    else if (original_len > 0) {
        *bytes = original_len;    /* 转码路径:按全部原始字节算「已消费」 */
    }
    else {
        *bytes = processed_bytes; /* 普通路径:只算完整的行,这是对的 */
    }
}

普通路径用的是 processed_bytes,只累加找到了 \n 的完整行;转码路径用的是 original_len,把末尾那半行、半个字符也一起算成了已消费。转换结果随后被 free 掉,原始缓冲按这个长度清掉,末尾那一截两边都没留下。

还有个旁证。struct flb_tail_file 里有一个 parsed 字段,从名字和赋值的时机看,像是专门用来记没解析完的尾部的,可整个项目没有一个地方读它,像是重构的时候把消费端给弄丢了。

我一开始判断错了 ​

读源码的时候,我的结论是每次跨边界最多丢一个字符,offset 不会乱,影响是有界的。我甚至还估了一下丢失量:32KB 缓冲加纯中文,平均每 64KB 丢一个字。

这个结论是错的。我是这么测的:每行是 LINE%06d 加 24 个汉字,GBK 编码以后每行正好 60 字节,2000 行一共 120000 字节。行是定长的,三个 32KB 边界落在哪就能提前算出来:

边界字节偏移落在
132768第 547 行第 8 字节(ASCII 编号区)
265536第 1093 行第 16 字节(汉字中间)
398304第 1639 行第 24 字节(汉字中间)
组配置收到判定
对照组UTF-8 文件,不配编码2000 / 2000基线完整
实验组GBK + generic.encoding=GBK1452 / 2000丢 548 行(27.4%)
重复同上,再跑一次1452 / 2000完全可重复
反证组同上 + buffer_chunk_size=131072(大于整个文件)2000 / 2000零丢失,根因锁定在读取边界

收到的最大行号是 2000,不是超时没读完。

丢的位置正好落在边界上。把 chunk 换成 16KB 再测一遍,每段缺失区间的起点字节除以 chunk 大小,全是整数:

chunk缺失行行数起点 ÷ chunk
32KB54711.00
32KB1093..16395472.00
16KB274、547、820各 11.00、2.00、3.00
16KB1093..13662744.00
16KB1639..19122746.00

丢掉的行数,也正好是一个 chunk 装得下的行数,32768 / 60 ≈ 546,16384 / 60 ≈ 273。

两种丢法 ​

边界落在 ASCII 区的时候,循环正常跑完,最后半行没有 \n,被 original_len 一起算成已消费了,丢的就是被切断的那 1 行,日志里什么都没有。

边界落在汉字中间就惨了。前半个字节被跳过,下一个 chunk 从这个字的后半截开始解码,整块都错了位,换行符 0x0A 被吞进一个双字节字符里,整块一行都切不出来,丢的是整个 chunk。这时候会打一条 error,字节序列正好是半个汉字加一个换行符:

[error] [wchar] character with byte sequence 0xdd 0x0a in encoding code "13"
        has no equivalent in encoding code "1"

丢了 548 行,全程就这么一条 error,一个字没提数据丢失,更别说丢了多少。运维从日志里判断不出损失有多大,也没有指标可看。

错位只会持续一个 chunk。offset 一直按 original_len 推进,每个 chunk 的起点固定在 chunk 大小的整数倍上,下一轮自己就对齐了。所以 32KB 那组丢完 1093..1639 以后,从 1640 起又全都正常了。

我还把收到的 1452 行一行行数了汉字,全是 24 个,没有一行缺字。所以「每 64KB 丢一个字」也不对,实际是要么整行完好,要么整块消失,没有中间状态。

这么严重的问题,按理说早该有人报了,会让 generic.encoding 在 GBK 日志上基本没法用。读源码时我一度怀疑是自己漏看了哪里的补偿逻辑,实测下来是没漏。我猜是这个功能用的人还不多,generic.encoding 是比较新的功能,问题还没暴露出来。截至发布,上游没查到相关的报告。

UTF-16:比我想的轻 ​

Windows 上 SQL Server 的日志是 UTF-16LE,走的是另一条转码路径 unicode.encoding,底下用的 simdutf。

读源码的时候,我预测这条路比 GBK 严重得多,理由是这么一条链:缓冲区给字符串终止符留了 1 字节,空缓冲时读取量正好是奇数 32767,UTF-16 是 2 字节定长,奇数长度必然切在码元中间;simdutf 的封装层碰到奇数长度,直接丢掉最后一个字节,还是返回成功;offset 按全部字节推进,下一轮从奇数位置开始读,后面所有码元永久错位,\n 的 0A 00 变成 00 0A,找不到行尾,撞上 Buffer_Max_Size,整个文件停止采集。UTF-16 没有自同步性,一旦错位就再也恢复不了。我当时的结论是,SQL Server 的日志大概率在第一次跨 32KB 边界的时候就整个停采了。

实测一测,三个预测一个都没出现。每行 72 字节(UTF-16LE),2000 行,跨 4 个 32KB 边界:

组收到
对照组(UTF-8)2000 / 2000
UTF-16LE + unicode.encoding=utf-16le1996 / 2000丢 4 行(0.2%)

没乱码,没停采,最后一行也读到了,一条 error 都没有。启动日志里写着原因:

[ info] [input:tail:tail.0] adjusted buf_max_size to 32769
[ info] [input:tail:tail.0] adjusted buf_chunk_size to 32769

配了 unicode.encoding 的时候,adjust_buffer_for_2bytes_alignments() 会把缓冲调成 32769,于是读取量是 32768,偶数,码元根本不会被切开。我那条链第一步就不成立,是读源码时漏看了这个函数。

真正存在的只有一件事:丢掉的 4 行 [456, 911, 1366, 1821],每一行都正好骑在 32768 的整数倍上。被边界切断的那一行,前半截没有 \n 成不了行,后半截因为 offset 已经按 original_len 推过去了,永远读不到,跟 GBK 边界落在 ASCII 区那种丢法一模一样。

GBKUTF-16LE
缓冲对齐保护没有(变长编码,固定对齐解决不了)有
边界切破字符会,下一块整块错位不会
实测丢失27.4%(边界行 + 整块)0.2%(仅边界行)
日志1 条失真的 error没有

跟我一开始想的正好反过来,UTF-16 比 GBK 轻得多,因为它有那个对齐保护,剩下的只是两条路共有的边界行问题。

一个改法,两处都修了 ​

两处的根子是一样的:转换实际消费了多少源字节,没传回给调用方。而两处的信息都是现成的:

c
/* simdutf 封装层:截掉奇数字节后的 len 就是实际消费量 */
/* generic 转换层:p_iso - iso 就是实际消费量 */
int flb_conv_convert_to_utf8(..., size_t *consumed);

/* tail_file.c:按实际消费推进,而不是按读进来的全部字节 */
*bytes = consumed;

没凑完整的字节就留在缓冲区里,等下一轮读到后半截再拼上,这本来就是普通路径的行为,算法上没有任何改动。

我在本地照这个思路改了 tail_file.c(+32 −2),两头都验了,GBK 组 2000 / 2000,UTF-8 回归组 2000 / 2000。

这个改法有个前提:generic.encoding 覆盖的编码都得是 ASCII 安全的,多字节序列里不会出现 0x0A。GBK、Big5、ShiftJIS、UHC、GB18030 按字节范围的定义都满足,但我只实测了 GBK,其余四种还没各造一个样本去验。

gzip:读到一半重启,剩下的再也读不出来 ​

in_tail 按文件名后缀自动识别 .gz,边读边解压,没有开关。这里两种位置分得最开:压缩文件的物理字节位置,和解压后的数据位置,完全是两个数。

struct flb_tail_file 里其实两个都有,offset 是物理位置,stream_offset 是解压后的逻辑位置,注释里还写明了后者可能比文件本身还大。可持久化的时候(plugins/in_tail/tail_file.h 的 flb_tail_file_db_offset()),压缩文件走了一个单独的分支:

c
if (file->decompression_context != NULL) {
    return file->offset;          /* 压缩文件:直接存物理位置 */
}

重启以后,lseek 到压缩流中间,交给一个全新的解压上下文。gzip 是流式压缩,缺了前面的字典状态,从这儿读到的字节连 gzip 的魔数都对不上。

读源码的时候我觉得,.gz 是只写一次的,读完就不会再碰到,实际影响有限。实测又把我推翻了。用的是一个 71 MB 的 gzip,150 万行,读到一半时 SIGTERM 正常关掉:

组场景结果
实验组读到 50% 中断,重启0 行,[gzip] invalid magic bytes
再重启一次0 行,同样的错误
反证组删掉 DB,从头读1500000 / 1500000,文件本身完好
对照组非压缩文件读到 21% 中断,重启314336 + 1185664 = 1500000,零丢失零重复

最要命的是第二行。解压失败,一点数据都没消费,offset 一个字节都没动,于是每次重启都重放同一个失败,永远卡在 50%。750565 行永久丢了,自己是好不了的;唯一的人工办法是把这条 DB 记录删掉,代价是整个文件从头重采。好在这个问题至少有一条明确的 error,比前两个强点。

我的改法不如上游 ​

我本地的改法很直接,压缩文件重启时退回 0,从头解压。一测,丢失是变成 0 了,可重复了 694822 行,46.3%。说白了,我只是把最多一次换成了至少一次。

准备写 issue 之前查了下重,发现上游已经有一个在途的 PR #11269,关联 #11265。它在 DB 里加了两列:anchor 是一个合法的 gzip member 边界的物理位置,能从这儿开始解压;skip 是在这个 member 里已经消费掉的解压后字节数。重启时 lseek 到 anchor,解压以后丢掉前 skip 个字节,从断点精确地接着读。

我把 PR 分支拉下来编译,用同一个 71 MB 的文件测了一遍:

上游 master我的本地修法PR #11269
丢失750565 行(50%)00
重复无694822 行(46.3%)0
语义数据丢失至少一次恰好一次

第一轮读到第 684252 行,重启以后从第 684253 行接上,严丝合缝;已经读完的文件再重启,读 0 行,不重复采。数字也对得上,skip = 63635436,正好是 684252 行乘 93 字节。

要做到恰好一次,进度得用两个维度来描述,从哪儿开始解压,解压以后跳过多少。我的改法只有一个维度,所以只能在丢和重复之间二选一。

这事也给我提了个醒,提修复之前先去上游看看。我要是直接提了,不光是重复劳动,还会把一个更差的方案推给维护者。写这篇的时候这个 PR 还没合进去。

两个小毛病 ​

一个是截断不看字符边界。多行合并超长时的截断(src/multiline/flb_ml_group.c)和单行的 truncate_long_lines,都是纯按字节截,可能砍在汉字中间。好在输出端兜住了,JSON 输出会校验 UTF-8,非法字节走 \uXXXX 转义,下游解析不会失败。例外是不转义直接写的路径 flb_utils_write_str_raw(),用 raw 或者 msgpack 格式输出时,非法字节会原样传到下游。

另一个是一段处理 \r 的死代码。切行的时候:

c
if (len >= 2) {
    crlf = (data[len-1] == '\r');
    if (len == 1 && crlf) {       /* 外层已经保证 len >= 2,这里永远进不来 */
        ...
    }
}

主逻辑是对的,Windows 的 CRLF 日志不会在行尾留下 \r。只有配了 Skip_Empty_Lines Off 的时候,只有一个 \r 的行会把 \r 当成内容采上来,不涉及数据丢失。

上游现在的状态 ​

截至 2026-10-12,上游 master a92d414c1:

问题实测代码状态上游动态
GBK 等 generic.encoding 边界丢失丢 27.4%未变未见报告
UTF-16 等 unicode.encoding 边界丢行丢 0.2%未变未见报告
.gz 中断后无法续读丢 50%,不可自愈未变#11269(在途,实测通过)

最后 ​

配了 generic.encoding 采 GBK 日志的,在修复合进去之前,别用默认的读取缓冲。边界切在汉字中间,整个 chunk 就没了。临时的办法是把 buffer_chunk_size 调大,可这只能让它少发生,治不了根。

这一篇三个问题,我读源码的判断一个比实际轻得多,一个比实际重得多,一个方向对了程度错了。源码读得再仔细,严重性还是得靠实测说话。


相关:采集器工程

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