Fluent Bit 源码笔记(四):读取边界上的数据丢失,GBK 日志丢了 27.4%
4059 字 · 约 10 分钟
Fluent Bit 源码笔记
读 in_tail 与引擎源码的四篇笔记。源码基线 v5.1.1,发布前复核至上游 master a92d414c1。
- (一)背压与投递,先不伤害宿主 · 背压与投递
- (二)百万个只写一次的文件 · 规模天花板
- (三)采集器怎么记住自己读到了哪里 · offset 存储
- (四)读取边界上的数据丢失,GBK 日志丢了 27.4% · 编码与压缩
给 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 里的转换循环:
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:
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 边界落在哪就能提前算出来:
| 边界 | 字节偏移 | 落在 |
|---|---|---|
| 1 | 32768 | 第 547 行第 8 字节(ASCII 编号区) |
| 2 | 65536 | 第 1093 行第 16 字节(汉字中间) |
| 3 | 98304 | 第 1639 行第 24 字节(汉字中间) |
| 组 | 配置 | 收到 | 判定 |
|---|---|---|---|
| 对照组 | UTF-8 文件,不配编码 | 2000 / 2000 | 基线完整 |
| 实验组 | GBK + generic.encoding=GBK | 1452 / 2000 | 丢 548 行(27.4%) |
| 重复 | 同上,再跑一次 | 1452 / 2000 | 完全可重复 |
| 反证组 | 同上 + buffer_chunk_size=131072(大于整个文件) | 2000 / 2000 | 零丢失,根因锁定在读取边界 |
收到的最大行号是 2000,不是超时没读完。
丢的位置正好落在边界上。把 chunk 换成 16KB 再测一遍,每段缺失区间的起点字节除以 chunk 大小,全是整数:
| chunk | 缺失行 | 行数 | 起点 ÷ chunk |
|---|---|---|---|
| 32KB | 547 | 1 | 1.00 |
| 32KB | 1093..1639 | 547 | 2.00 |
| 16KB | 274、547、820 | 各 1 | 1.00、2.00、3.00 |
| 16KB | 1093..1366 | 274 | 4.00 |
| 16KB | 1639..1912 | 274 | 6.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-16le | 1996 / 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 区那种丢法一模一样。
| GBK | UTF-16LE | |
|---|---|---|
| 缓冲对齐保护 | 没有(变长编码,固定对齐解决不了) | 有 |
| 边界切破字符 | 会,下一块整块错位 | 不会 |
| 实测丢失 | 27.4%(边界行 + 整块) | 0.2%(仅边界行) |
| 日志 | 1 条失真的 error | 没有 |
跟我一开始想的正好反过来,UTF-16 比 GBK 轻得多,因为它有那个对齐保护,剩下的只是两条路共有的边界行问题。
一个改法,两处都修了
两处的根子是一样的:转换实际消费了多少源字节,没传回给调用方。而两处的信息都是现成的:
/* 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()),压缩文件走了一个单独的分支:
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%) | 0 | 0 |
| 重复 | 无 | 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 的死代码。切行的时候:
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 调大,可这只能让它少发生,治不了根。
这一篇三个问题,我读源码的判断一个比实际轻得多,一个比实际重得多,一个方向对了程度错了。源码读得再仔细,严重性还是得靠实测说话。
相关:采集器工程