深入 Logstash 06 - dissect 与结构化 filter:放弃回溯换吞吐
官方基准里,同条件单 filter 的 dissect 比 Grok 快一成到两成。这个数字小到不值得为它改配置。dissect 真正值钱的地方在另一头:它不用正则引擎,回溯那条会让单条 event 从微秒跳到秒级的路径在它这里根本不存在。
顺着这个判断往下,本篇落到三件具体的事:线性扫描的每一步怎么走、dissect 的语法能覆盖到哪、以及同一个管道里 dissect 和 Grok 怎么分工。
1 | |
dissect 的工作原理
dissect filter 不使用正则引擎。它把 mapping 里的 pattern 字符串解析成一个"字段标记与字面分隔符交替"的序列,然后对输入字符串做一次从左到右的线性扫描:找到下一个字面分隔符的位置,把分隔符前的内容提取为当前字段的值,推进到下一个字段标记,重复直到整个 pattern 消耗完。
1 | |
整个过程没有任何回溯。每个分隔符只被查找一次,一旦找到就推进,找不到就报失败(在 tags 里加 _dissectfailure,行为与 _grokparsefailure 对称)。
复杂度是 O(n),n 是输入字符串长度。对 Grok 来说,最坏情况下同一段输入可能被正则引擎从每个字符位置重新尝试,复杂度退化到指数级。对有固定分隔符的日志,dissect 是更安全的选择,不只是"快一些",而是消除了性能的不确定性。
dissect 语法:字段标记与操作符
dissect 的字段标记有几种变体,覆盖常见的结构化提取需求:
基础字段:%{field_name},提取一段内容赋给 field_name。
跳过字段:值被提取出来但不写进 event。匿名形式是 %{},具名形式是 %{?name}。两者行为一样,具名的意义有两个:让长 pattern 可读,以及给下面的间接字段提供键名来源。
追加字段:%{+field_name},把多个标记的内容合并进同一个字段。连接用的不是固定空格,而是该标记前面找到的那个分隔符;只有当这个标记前面找不到分隔符时,才退化成一个空格。同一份结构换个分隔符,结果就跟着变:
1 | |
带顺序的追加:%{+field_name/N},N 是追加顺序编号,用于乱序标记:
1 | |
间接字段:%{&field_name},用另一个字段的值作为本字段的键名,专门跟具名跳过字段配对,用来解析 key=value 对:
1 | |
这就是具名跳过字段存在的理由:?key 提取到的 "user" 本身不进 event,只是给 &key 当键名用。追加和间接两个前缀不能组合,%{+&x} 会被理解成往 &x 这个键里追加,%{&+x} 则是往 +x 里追加,两种写法都不是想要的结果。
padding 后缀:%{field_name->},告诉 dissect 忽略该字段右侧重复出现的分隔符。这是列对齐日志的专用工具:
1 | |
官方对这个后缀有一条明确要求:必须加在 padding 左边那个字段上。所以当某一列右对齐、padding 在视觉上落到它左侧时,从 dissect 看这段 padding 仍然紧跟在前一个字段后面,后缀要往前挪一格:%{id->} %{function} %{server} 对应 00000043 ViewReceive machine-321 这种输入。
连续分隔符:缺失字段还是 padding
分隔符连续出现两次以上时,dissect 要在两种解释里选一个:中间夹着一个空字段,还是这些分隔符整体是一段 padding。从插件 1.1.1 起,官方把默认解释定为缺失字段,产出空字符串而不是把多余分隔符当填充。
1 | |
CSV 类日志正需要这个默认值:列位置固定,空列必须占位,否则后面的字段会整体错位。代价是"数错空格"这类错误在 dissect 下不一定报错。真 padding 的正确工具是上一节那个 -> 后缀,不是靠默认行为兜。
dissect 的失败处理
分隔符序列在输入里找不到时,dissect 向 event 的 tags 加 _dissectfailure,原始字段保持不变,event 继续流转,语义与 Grok 的 _grokparsefailure 对称。用 tag_on_failure 可以替换默认 tag 名:
1 | |
调试时要先分清落在哪一种失败上,因为两种的表现完全不同。pattern 里的分隔符在输入里彻底找不到,是显式失败,_dissectfailure 会出现,按 tag 一查就能定位。而分隔符数量对不上(pattern 写了两个空格、实际只有一个,或者实际是 tab),往往是静默的:多出来的那一次分隔符被当成缺失字段,dissect 照样"成功",只是产出一堆空字符串字段和错位的值。所以看到某个字段莫名是空串时,第一件事是把实际 message 复制出来,逐个分隔符数一遍,而不是去查有没有打 tag。
官方还有一条配套建议:把 dissect 放在 if 条件块里,先确认 event 的目标字段确实是这个结构再切。缺少条件保护时,dissect 会硬着头皮往上套 mapping,可能加出一批字段而它们并不是想要的东西。Grok 在这一点上不同,它要么整体匹配要么整体失败。
可变尾部的三档处理
很多日志格式是"前半段结构化(固定分隔符)、后半段可变"。可变到什么程度决定了走哪一档,不是一律 dissect 切前缀、Grok 收尾。
第一档:尾部仍有固定分隔符,两级 dissect。 mapping 支持对上一次 dissect 产出的字段继续 dissect,且原字段保留。本文示例的尾部 user=42 amount=99.50 status=SUCCESS 恰好落在这一档——= 和双空格都是字面量,从头到尾用不着正则:
1 | |
两条 mapping 在同一个 filter 里跑完,类型也一次收齐。整条路径上没有出现正则引擎,最坏情况的时间上界依然是线性的。
第二档:尾部需要字符类或可选匹配,dissect 切前缀加 Grok 收尾。 尾部是 Java 异常栈、个数不定的 key=value、或者带可选段的结构时,线性扫描表达不了,这时才引入正则:
1 | |
这么拆有两条收益。传给 Grok 的字符串只是 rest 字段,比完整 message 短得多,正则匹配的输入域更小,回溯深度上限随之降低。另一条是结构化前缀由 dissect 完成,即便 Grok 对 rest 失败(产生 _grok_rest_failure),log_ts、log_level、service 已经落袋,不会因为尾部对不上而丢掉整行的结构。
第三档:整行没有可依赖的固定结构,纯 Grok。 没有稳定字面分隔符时 dissect 的前提就不成立,硬切只会产出一批错位字段,而且如前所述这种错位往往不打 tag。
最小实验:dissect vs Grok 处理同一行日志
1 | |
1 | |
输入测试行:
1 | |
期望输出(关键字段):
1 | |
注意:mapping 的提取结果一律是字符串,amount 是 "99.50" 不是浮点数。转换由 dissect 自己的 convert_datatype 完成,它是一个独立的顶层配置项,支持 int 和 float,在所有 mapping 都跑完之后统一执行:
1 | |
convert_datatype 不依赖 mapping,可以单独使用——对已经由别的 filter 产出的字符串字段做转换时,写一个只有 convert_datatype 的 dissect 块就够了。
所以类型声明这件事上,dissect 和 Grok 的差别在位置而不在有无:Grok 把它内联在 pattern 里(%{NUMBER:amount:float}),转换和提取写在一起、也绑在一起;dissect 把它拆到 convert_datatype,转换和提取解耦,改类型不用碰 pattern,pattern 改了也不用重新排列类型声明的位置。字段多的时候后一种更好维护,字段少的时候前一种更紧凑。
把实验结果对应到内部对象:dissect filter 在初始化阶段把 mapping 的 pattern 字符串编译成一个"分隔符序列 + 字段槽"的内部结构,不涉及正则编译。每条 event 到达时,对目标字段做一次线性扫描,把各槽的内容直接写入 event 字段 map。
性能对比的实际边界
Elastic 那篇 dissect 发布博文给了两组基准,量级差得很远,引用时容易串。
第一组是同条件下各个 filter 单独跑的吞吐(events per second,6 workers):
| filter | 1 worker | 6 workers |
|---|---|---|
| Noop(空 filter 基线) | 32031 | 86881 |
| Dissect | 16396 | 52493 |
| Grok | 14255 | 46882 |
| Grok + CSV | 5003 | 12895 |
Dissect 对 Grok 是 1.1 到 1.25 倍,不同 worker 数下略有浮动。这一组才是"同一件事,两种实现"的公平对照。
第二组换成了一个真实场景:把 Palo Alto 防火墙 syslog 的处理,从 Grok 加 CSV 两级组合替换为一个 dissect。这里每条 event 的耗时从 77.55 微秒降到 19.05 微秒(6 workers),大约四倍。三到四倍这个数字流传最广,但它的前提是替换掉两个 filter 而不是一个,而且原文在给出这组数据之后紧跟了一句 “with less certainty”。
拆到第三组数据这个差距的来源就清楚了:同一份数据上,CSV 单独跑要 60.43 微秒,Grok 单独跑只要 21.33 微秒。原文自己的结论是,Grok + CSV 组合的低性能主要该由 CSV 负责,而 Grok 在这份数据上配置得当、表现良好。
把这三组放在一起,dissect 的价值就落在了别处。Noop 基线本身就要 11.51 微秒,Grok 的 21.33 微秒里有相当一部分是正则机器的固定开销,不是回溯造成的。也就是说在非病态 pattern 上,Grok 慢的那点主要是常数,而常数差一成两成不值得为它重写配置。dissect 消除的是最坏情况的不确定性:没有回溯,就没有那条从微秒跳到秒级的尾部路径。
顺着同一个角度,Grok 在多数场合够用的原因也清楚了:
- 日志行很短(几十字节)时,正则引擎的固定开销本身就小,绝对时间差不大。
- pattern 很简单(如只有
%{WORD:f1} %{WORD:f2})时,引擎能一次走通,没有回溯可言。 - 瓶颈在 output(比如 ES 写入)而不在 filter 时,filter 吞吐翻倍也不改变整体吞吐。
dissect 的硬性限制在语法层:分隔符必须是字面字符串,不能是"任意空白"这类字符类模式。重复出现的字面分隔符(列对齐留下的 padding)不在限制范围内,-> 后缀专为它而设,用不着先拿 gsub 把多个空格压成一个。真正处理不了的是分隔符形态不确定,比如同一个位置可能是空格也可能是 tab。
选型决策树
1 | |
模式提炼
1 | |
在其他系统里同样存在这个权衡:ClickHouse 的 extractAllGroupsHorizontal 走正则,splitByString 走线性;Flink 的 split 算子走线性,CEP 的模式匹配走 NFA;Vector 的 parse_key_value 走线性,parse_regex 走正则。选型逻辑是同一套。
工程迁移表
| Logstash 概念 | Fluentd 对应 | Vector 对应 | 通用 ETL 对应 |
|---|---|---|---|
| dissect(线性切分) | parser with regexp disabled / csv parser |
parse_key_value / split |
split(delimiter) |
| grok(正则切分) | regexp parser with named captures |
parse_regex |
正则提取函数 |
dissect %{+field} 追加 |
手动 record_transformer 拼接 |
merge transform |
concat 字段 |
dissect %{?} / %{&} 键值对 |
kv parser |
parse_key_value |
key=value 解析器 |
dissect %{field->} 吃 padding |
无对应,靠正则 \s+ |
切分后再 strip_whitespace |
定宽列解析 |
dissect convert_datatype |
parser 的 types 参数 |
VRL 的 to_int / to_float |
独立的 cast 阶段 |
| 两级 dissect / dissect + grok 混合 | 多级 parser chain | transform pipeline | 多步 ETL |
_dissectfailure tag |
emit_invalid_record_to_error |
dropped metric |
错误记录路由 |
常见误解
误解一:“dissect 是 Grok 的简化版,功能弱”。有三样东西 Grok 里根本没有:%{+field} 能把散落的片段拼回一个字段并用 /N 重排拼接顺序;%{?x} 配 %{&x} 能拿数据里的值当字段名;convert_datatype 能把类型声明从 pattern 里拆出来。反过来 Grok 有字符类和可选匹配。两者覆盖的是不同的输入特征。
误解二:“只要用了 dissect,就不需要 Grok 了”。dissect 做不到两件事:字符类匹配("一个或多个数字"这种)和可选部分(某段内容可有可无)。这两件事恰好是有变体的日志格式的常态,所以实践中两者通常同时待在一个管道里。注意"分隔符重复出现"不在这份清单上,那是 -> 后缀负责的事,不需要 Grok 也不需要 gsub 预处理。
误解三:“dissect 不支持类型转换,必须在后面接一个 mutate”。convert_datatype 是 dissect 自己的顶层配置项,支持 int 和 float。多接一个 mutate 不只是多写几行:mutate 的 convert 是另一个独立 filter,它和 dissect 之间可以插进条件块、字段改名、甚至另一个 dissect,任何一处让字段名变了,转换就静默错过目标;convert_datatype 与 mapping 在同一个 filter 内、在所有 mapping 之后执行,中间没有插入点。它唯一的维护成本是 mapping 里改字段名时,convert_datatype 的键要跟着改。
练习
-
取几行 Spring Boot 默认格式的日志(
日期 时间 级别 --- [线程] 类名 : 消息),注意它的级别字段是右对齐的,INFO和WARN前面补了不同数量的空格。写一个 dissect mapping 把时间戳、级别、线程名、类名、消息切出来,用->后缀吃掉那段 padding。用 stdin/stdout 验证同一份 mapping 能同时处理INFO和WARN两种行。 -
把上一题里的"消息"字段再做一次 dissect:在同一个 dissect 块里加第二条 mapping,对第一条产出的字段继续切分。确认原字段仍然保留在 event 里,然后用
convert_datatype把其中一个数值字段转成int。再故意把某处分隔符多写一个空格,观察结果是打了_dissectfailure,还是静默多出了一个空字符串字段。 -
思考题:一行日志格式是
时间戳 级别 模块 消息,前三个字段固定用两个空格分隔,第四个字段"消息"是可变文本,可能包含 Java 异常栈(多行)。描述一个完整的处理方案:哪部分用 dissect、哪部分用 Grok、multiline codec 应该在哪一层介入,以及_dissectfailure、_grokparsefailure、"静默产出空字段"这三种异常各自如何被发现和路由。
系列导航
参考资料
- Logstash dissect filter 文档:https://www.elastic.co/guide/en/logstash/current/plugins-filters-dissect.html(mapping 语法、操作符、tag_on_failure)
- Logstash Grok filter 文档:https://www.elastic.co/guide/en/logstash/current/plugins-filters-grok.html(对比参考)
- Elastic 官方 dissect vs grok 性能对比博文:https://www.elastic.co/blog/logstash-dude-wheres-my-chainsaw-i-need-to-dissect-my-logs(基准测试数据来源)
- Logstash mutate filter 文档:https://www.elastic.co/guide/en/logstash/current/plugins-filters-mutate.html(convert 类型转换)
- Logstash kv filter 文档:https://www.elastic.co/guide/en/logstash/current/plugins-filters-kv.html(key=value 解析的专用 filter)
- Vector parse_key_value transform:https://vector.dev/docs/reference/vrl/functions/#parse_key_value(工程迁移表 Vector 对应)
