深入 Logstash 03 - codec:字节流与 event 的边界转换
multiline 只能是 codec,不能是 filter。这不是历史包袱留下的形状,是成帧这件事本身不可逆决定的:字节流一旦被切成离散 event 进了队列,行与行之间的先后关系就再没有载体,filter 拿到的每个 event 都是孤立的。
上一篇拆开了 event 的内部结构。这一篇往前退一步,看 event 是怎么被造出来的。顺着 codec 与 filter 的这条边界还会掉出一个大多数人记错的细节:默认的 plain codec 其实根本不按行切,按行切的是 line codec,而流式 input 在注册时会悄悄把前者换成后者。
数据流全景
1 | |
codec 的职责边界
codec 只做一件事:在字节流和 event 之间建立对应关系。具体地:
- decode 阶段(input 侧):确定"一条记录"的边界,构造
Event对象,把内容写入message字段(plain/line/multiline)或直接展开为字段(json/json_lines)。 - encode 阶段(output 侧):将
Event对象序列化为字节流,写入目标。
codec 不读写 @metadata 以外的业务字段(json codec 除外——它直接将 JSON key 映射为 event 字段),不做条件判断,不做字段变换。这些属于 filter 的职责。
分工的本质差异:filter 在 event 粒度上操作,codec 在字节/帧粒度上操作。codec 的执行时机早于 filter,此时 event 边界尚未确定。
plain codec:不做成帧
plain 是绝大多数 input 和 output 的默认 codec,但它做的事和名字给人的印象相反:它不成帧。源码注释写得很直白——“plain text with no delimiting between events”。decode 一侧,它把收到的这一块数据整个转成字符串塞进 message,产出一个 event,不管里面有几个换行符;encode 一侧,它把内容写出去,不追加任何分隔符。
1 | |
它的适用场景是传输协议自己已经带了分帧的地方,源码注释举的例子是 zeromq、rabbitmq、redis。这类 input 每次交给 codec 的本来就是一条完整消息,再切一次反而是错的。
output 侧要留意"不加分隔符"这个行为:file { codec => plain } 会把所有 event 首尾相连写进同一行。要每条一行,用 line。
line codec:按 delimiter 切
line 才是"一行 = 一个 event"那个 codec。decode 用一个 BufferedTokenizer 按 delimiter(默认 "\n")切,只有完整的一行才产出 event,残缺的尾部留在缓冲区里等后续数据;encode 时在内容后面追加 delimiter。
1 | |
delimiter 可以换成别的字符串,处理以 \r\n 或自定义分隔符成帧的协议时用得上。
成帧到底由谁做
前两节合起来会引出一个问题:既然 plain 不切行,为什么 stdin { } 什么都不配也是一行一个 event?
答案在 LogStash::Inputs::Base#fix_streaming_codecs。流式 input 在 register 阶段调它,逻辑只有几行:配的是 plain 就换成 line,配的是 json 就换成 json_lines,charset 沿用原设置。换的时候留一条 info 日志:
1 | |
stdin、tcp 这类面对连续字节流的 input 都走这条路。所以"默认 codec 是 plain"和"默认行为是按行切"两句话都成立,但中间隔着这次静默替换。替换只针对 plain 和 json 两个 codec,显式配了 line、json_lines、multiline 的一律原样保留。
file input 是另一条路径。它不调 fix_streaming_codecs,因为行切割在更下面一层就做完了:filewatch 按 file input 自己的 delimiter 参数(默认 "\n")读整行,每行单独调一次 codec.decode。此时即便 codec 是 plain,它每次拿到的也已经是一行,结果同样是一行一个 event。
责任因此是分散的:成帧可能发生在 input 插件自己的读取层(file),可能发生在被替换后的 codec(stdin、tcp),也可能发生在传输协议里(beats、kafka)。要判断一份配置到底怎么切行,先看 input 属于哪一类,再看 codec 有没有被换掉。
json codec 与 json_lines codec
json codec:将整个输入(或输入的每个"块")解析为 JSON,字段直接成为 event 的顶层字段,无需 message 中转。适合 HTTP body、整块 JSON 文档输入场景。
json_lines codec:以换行符为分隔符,每行解析一个 JSON 对象。适合 ndjson(Newline Delimited JSON)格式,这是 Logstash 自身的 HTTP input 和很多现代日志框架的输出格式。
1 | |
两者的区别:json codec 遇到多行 JSON 文档时,需要整块数据一次性到达;json_lines 每读到换行即触发解析,更适合流式场景。
json codec 还有一个容易被忽略的行为:payload 的根是 JSON 数组时,它按元素展开,一个元素产出一个 event。[{"a":1},{"a":2}] 进去出来的是两个 event,不是一个带数组字段的 event。批量上报的 HTTP API 常是这种形状,这个行为通常正是想要的,但如果预期是"一次请求一个 event"就会对不上账。
multiline codec:成帧决策的不可逆性
multiline codec 是理解"为什么 codec 不能由 filter 替代"的最佳切入点。
问题背景:Java 异常堆栈、Python traceback、多行 SQL 等日志,在物理上跨越多行,但语义上是一条完整记录。一旦按行成帧,每行产生一个 event,后续 filter 就无法把它们重新合并——独立 event 之间没有关联,而 filter 的执行单位就是单个 event。
先看一个最小例子,明确三个参数各管什么(它能跑,但下面会看到它对 Java 异常堆栈还不够):
1 | |
配置参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
pattern |
正则表达式,可以用 grok pattern | 必填 |
what |
previous(并到前一条)或 next(并到后一条) |
必填 |
negate |
false = 匹配时合并;true = 不匹配时合并 | false |
max_lines |
单条 event 最多合并的行数,超出即强制切断 | 500 |
max_bytes |
单条 event 的最大字节数,超出即强制切断 | 10 MiB |
auto_flush_interval |
缓冲区静默这么多秒后强制交出 | 无默认值 |
multiline_tag |
给合并过的 event 打上的 tag | multiline |
max_lines 的 500 在深 Java 堆栈上是会命中的。框架栈动辄几百行,一旦被切断,后半截会成为一个没有时间戳的孤立 event,看起来像日志乱了。
multiline 的两个配方
写 multiline 有两种思路:认续行,或者认新记录的开头。后者更稳,官方给的时间戳配方就是这一种:
1 | |
读法是:凡是不以时间戳开头的行,都并到前一行上去。异常首行(java.lang.NullPointerException: ...)和后面的 at 堆栈行会一起被并进带时间戳的那条日志。
认续行的写法就没这么稳。比如 pattern => "^[[:space:]]"、negate => false,只有缩进行会被合并,而异常首行通常顶格写,于是"日志行"和"异常首行 + 堆栈"会成为两个 event。下面的实验里可以把 pattern 换成缩进版跑一遍,直接看到这个差别。
auto_flush_interval 在这里几乎是必须的。multiline 的判断依据是"看到下一条新记录的开头,才知道上一条已经结束",所以最后一条记录会一直停在缓冲区里。这个参数没有默认值,不设就没有自动 flush,tail 模式下要一直等到停掉 Logstash、input 走退出时的 flush 路径,缓冲区的内容才会交出来。做实验时"最后一条 event 没出现"通常就是这个原因,而不是配置写错了。
为什么 multiline 必须是 codec,不能是 filter
这是一个架构约束,不是实现限制。
一旦字节流被 line codec 切开,每行已经成为独立的 Event 对象进入队列。队列中的 event 在语义上彼此独立,filter worker 每次取出一个 batch 处理,batch 内的 event 之间没有顺序保证(多 worker 并行时尤其如此,见第 01 篇 pipeline.ordered 一节)。即使用 filter 记录状态、尝试合并,也无法可靠地重建跨 event 的行序。
成帧(framing)必须在数据进入 event 模型之前完成。这正是 codec 存在的原因:在字节流还是连续序列的阶段,按规则切割边界,保证每个 Event 对象从诞生起就是语义完整的一条记录。
历史演进印证了这一点。Logstash 早期确实有过 multiline filter,它在多 worker 下会产生行序错乱和状态竞争,从 5.x 时代起就停止维护,8.x 起不再随发行版提供。留下来的是 codec 形态,官方对它的定性是 “the preferred tool for handling multiline events in the Logstash pipeline”,并没有被弃用。
真正的例外出现在 input 支持多来源的时候。beats input 可能同时连着几十台机器,不同 host 的行会混进同一个 codec 缓冲区,合并出来的 event 就是好几台机器的日志拼在一起。官方在这里挂了 IMPORTANT 级警告,直接说不要这样组合,理由写的是 “the mixing of streams and corrupted event data”。这种情况下成帧必须上移到 Beats 侧,用 Filebeat 自己的 multiline 配置。所以"8.x 推荐在 Filebeat 侧做 multiline"只在多 host input 上成立,不是通则;file、stdin 这类单一来源的 input,codec 仍然是首选。
rubydebug codec
rubydebug 是 output-only codec,不能用于 input decode。作用是将 event 以 Ruby inspect 格式打印到标准输出,包含所有字段的类型信息,是 pipeline 调试的标准工具。
1 | |
它靠 amazing_print 生成人类可读格式(这个 gem 早期叫 awesome_print,8.x 用的是改名后的版本)。这种格式的 I/O 开销显著高于 json_lines,输出也不是机器可解析的标准格式,所以生产环境不要把 rubydebug 当 output 目标。
可运行实验
目标:对比同一份多行 Java 异常堆栈在三种成帧规则下被切成几个 event。
准备测试数据文件 /tmp/stacktrace.log,一共 6 行:
1 | |
第一种,不做任何合并:
1 | |
1 | |
输出 6 个 event,一行一个。其中属于这次异常的是 4 行(异常首行加三行 at),它们成了 4 个互不相干的 event,谁也不知道自己属于上面那条 ERROR 日志。注意这里切行的不是 plain codec,是 file input 的读取层;plain 只是把每次收到的那一行原样装进 message。
第二种,用官方的时间戳配方:
1 | |
输出 2 个 event。第一个的 message 是前 5 行以 \n 连接的结果(ERROR 行、异常首行、三行 at),并带上 multiline tag;第二个是最后那行 INFO,它靠 auto_flush_interval => 1 才被交出来——去掉这一行重跑,第二个 event 会一直不出现,直到进程被停掉。
第三种,把配方换成认缩进的写法,其余不动:
1 | |
输出变成 3 个 event:ERROR 行单独一个、异常首行加三行 at 一个、INFO 行一个。差别只在异常首行顶格写、不匹配 ^\t,于是没能并到 ERROR 行上。同一份日志、同一个 codec,pattern 差这一点,下游拿到的就是 2 条记录还是 3 条记录。pattern 必须拿真实日志跑一遍再定,光看着像对不算。
关键对象映射
| codec 概念 | 对应 Java/JRuby 类 | 说明 |
|---|---|---|
| codec 接口 | LogStash::Codecs::Base |
所有 codec 的基类,定义 decode/encode |
| plain | LogStash::Codecs::Plain |
不成帧,整块内容 → message |
| line | LogStash::Codecs::Line |
用 FileWatch::BufferedTokenizer 按 delimiter 切 |
| json | LogStash::Codecs::JSON |
解析 JSON 并展开字段;根为数组时产出多个 event |
| json_lines | LogStash::Codecs::JSONLines |
行界 + JSON 解析,同样走 BufferedTokenizer |
| multiline | LogStash::Codecs::Multiline |
状态机,维护未完成行缓冲,内部用 grok 匹配 pattern |
| rubydebug | LogStash::Codecs::RubyDebug |
output-only,调用 amazing_print |
| codec 静默替换 | LogStash::Inputs::Base#fix_streaming_codecs |
流式 input 注册时把 plain 换成 line、json 换成 json_lines |
模式提炼
codec 是"成帧层",filter 是"变换层"。成帧不可逆:一旦字节流被切成离散 event,行序信息就丢了,filter 层无从恢复。这个约束决定了 multiline 只能落在 codec 层,或者更上游的 Beats 侧。
顺着这个约束能推出一个定位成帧责任的固定动作:从数据源往下走,找第一个把连续字节切开的地方。可能是传输协议(beats 的 lumberjack 帧、kafka 的 record 边界),可能是 input 插件自己的读取层(file 的 delimiter),也可能是 codec(stdin/tcp 上被替换成的 line)。这个位置一旦确定,它下游的所有环节就只能在它切出来的粒度上工作,之后追加多少 filter 都改不了这个粒度。
codec 的另一个隐含职责是类型系统入口:json codec 保留 JSON 里的数字和布尔类型,line 和 plain 只产生字符串。选错 codec 会让 filter 里的类型转换工作量翻倍。
工程迁移表
| Logstash codec 概念 | Kafka SerDe 对应 | 网络协议对应 | HTTP 对应 |
|---|---|---|---|
| codec(成帧+反序列化) | Deserializer(Consumer 侧) | 协议成帧(TCP framing) | Content-Type 解码 |
| plain decode | StringDeserializer | 协议已分帧,直接取整块 | text/plain 读取 |
| line decode | 无直接对应(record 边界已由 broker 保证) | 行界符分帧 | 按分隔符切分响应体 |
| json decode | JsonDeserializer | 消息边界固定 | application/json 解析 |
| multiline decode | 无直接对应(Producer 侧已完成) | 长度前缀 / 分隔符成帧 | multipart body |
| codec encode(output) | Serializer(Producer 侧) | 协议编码 | Content-Type 编码 |
| rubydebug | 无对应(调试打印) | 无对应 | 无对应 |
常见误解
误解一:“codec 只是格式化,随时可以换,和 filter 没本质区别。”
实际情况:codec 决定 event 的边界和初始字段结构;错误的 codec 选择(如对多行日志使用 plain)会使 filter 阶段的数据完全无法正确处理,无法通过增加 filter 补救。
误解二:“multiline 在 8.x 已经废弃了,别用。”
实际情况:被废弃的是 multiline filter,从 5.x 时代就停止维护、8.x 起不再随发行版提供。multiline codec 没有被弃用,官方现在对它的措辞还是 Logstash 内处理多行的首选工具。这两个组件同名不同物,搜到的旧教程往哪边说都有,看到"multiline 废弃了"这类结论先确认它讲的是 filter 还是 codec。
误解三:“json codec 和 json_lines codec 可以互换。”
实际情况:换不换出问题,取决于 input 属于哪一类。json codec 期望每一块数据是一个完整的 JSON 值,json_lines 按换行逐行解析。stdin、tcp 这类流式 input 上配 json 会被 fix_streaming_codecs 自动换成 json_lines,所以照样能工作——"json 处理 ndjson 没问题"这个印象就是从这里来的。在不做替换的 input 上(比如 http),对 ndjson 流用 json codec 才会真的解析失败或只读到第一段。反方向也有个坑:json codec 遇到根是数组的 payload 会产出 N 个 event,和 json_lines 逐行产出 N 个 event 看着像,但切分依据完全不同。
练习
-
准备一份包含多行 Python traceback 的日志文件,分别用 plain codec 和 multiline codec 处理,对比输出 event 数量和
message字段内容;调整 multiline 的pattern、negate、what三个参数,找到能把Traceback (most recent call last):到异常类型那一行完整合成一个 event 的配置。 -
用 json_lines codec 处理以下混合输入(一行合法 JSON + 一行非法 JSON),观察 Logstash 对解析失败行的处理方式(是否产生
_jsonparsefailuretag,原始内容是否保留在message中):1
2
3{"level":"info","msg":"started"}
this is not json
{"level":"error","msg":"failed"} -
在同一条 pipeline 中,input 侧用 json_lines codec,output 侧分别试用 json_lines 和 plain codec,把结果各写进一个文件,用
wc -l数行数:几个 event 在两种 codec 下落成了几行,差异来自 plain 不追加分隔符这一条。 -
分别用
stdin { codec => plain }和file { codec => plain }各跑一次,在--log.level=info下看日志里有没有Automatically switching from plain to line codec。然后把 stdin 那份改成显式codec => line,确认这条日志消失;再改成codec => json,看它被换成了哪一个 codec。
系列导航
参考资料
- codec 插件总览:https://www.elastic.co/guide/en/logstash/current/codec-plugins.html(全部内置 codec 的清单)
- multiline codec 文档:https://www.elastic.co/guide/en/logstash/current/plugins-codecs-multiline.html(
max_lines/max_bytes/auto_flush_interval/multiline_tag的默认值与必填项标注) - 多行事件处理指南:https://www.elastic.co/guide/en/logstash/current/multiline.html(“the preferred tool” 的原文、beats input 那条 IMPORTANT 级警告、Java 堆栈与时间戳两个官方配方)
- plain codec 文档:https://www.elastic.co/guide/en/logstash/current/plugins-codecs-plain.html(“no delimiting between events” 的定性)
- line codec 文档:https://www.elastic.co/guide/en/logstash/current/plugins-codecs-line.html(
delimiter参数与 encode 侧追加分隔符的行为) - json codec 文档:https://www.elastic.co/guide/en/logstash/current/plugins-codecs-json.html(与 json_lines 的取舍)
- input 插件基类源码:https://github.com/elastic/logstash/blob/main/logstash-core/lib/logstash/inputs/base.rb(
fix_streaming_codecs的完整实现,只有十几行) - file input 源码:https://github.com/logstash-plugins/logstash-input-file/blob/main/lib/logstash/inputs/file.rb(自带
delimiter参数、用IdentityMapCodec按文件隔离 codec 状态、退出时的 flush 路径) - multiline codec 源码:https://github.com/logstash-plugins/logstash-codec-multiline/blob/main/lib/logstash/codecs/multiline.rb(合并状态机与
AutoFlush) - Filebeat 多行配置:https://www.elastic.co/guide/en/beats/filebeat/current/multiline-examples.html(多 host 场景下把成帧上移到采集端的写法)
