官方基准里,同条件单 filter 的 dissect 比 Grok 快一成到两成。这个数字小到不值得为它改配置。dissect 真正值钱的地方在另一头:它不用正则引擎,回溯那条会让单条 event 从微秒跳到秒级的路径在它这里根本不存在。

顺着这个判断往下,本篇落到三件具体的事:线性扫描的每一步怎么走、dissect 的语法能覆盖到哪、以及同一个管道里 dissect 和 Grok 怎么分工。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
同一行日志,两条处理路径:

message: "2026-08-06T13:40:00 INFO pay-svc user=42 amount=99.50"

├── dissect ──── 找分隔符 ──── O(n) 线性扫描 ──── 字段
│ 无正则引擎
│ 无回溯

└── grok ─────── 展开 pattern → 正则匹配 ──── 字段
Joni 引擎(回溯式 NFA)
最坏 O(2^n) 回溯

选型依据:日志是否有固定分隔符?
有 → dissect(或 dissect + grok 混合)
无 → grok

dissect 的工作原理

dissect filter 不使用正则引擎。它把 mapping 里的 pattern 字符串解析成一个"字段标记与字面分隔符交替"的序列,然后对输入字符串做一次从左到右的线性扫描:找到下一个字面分隔符的位置,把分隔符前的内容提取为当前字段的值,推进到下一个字段标记,重复直到整个 pattern 消耗完。

1
2
3
4
5
6
7
8
9
10
11
pattern:  "%{ts}  %{level}  %{svc}  user=%{user}"
──┬── ──┬── ──┬── ─────┬─────
field " " " " literal "user="

输入: "2026-08-06T13:40:00 INFO pay-svc user=42"

步骤:
1. 找 " " → ts = "2026-08-06T13:40:00"
2. 找 " " → level = "INFO"
3. 找 " " → svc = "pay-svc"
4. 找 "user=" → 跳过字面量,再找行尾或下一分隔符 → user = "42"

整个过程没有任何回溯。每个分隔符只被查找一次,一旦找到就推进,找不到就报失败(在 tags 里加 _dissectfailure,行为与 _grokparsefailure 对称)。

复杂度是 O(n),n 是输入字符串长度。对 Grok 来说,最坏情况下同一段输入可能被正则引擎从每个字符位置重新尝试,复杂度退化到指数级。对有固定分隔符的日志,dissect 是更安全的选择,不只是"快一些",而是消除了性能的不确定性。

dissect 语法:字段标记与操作符

dissect 的字段标记有几种变体,覆盖常见的结构化提取需求:

基础字段:%{field_name},提取一段内容赋给 field_name

跳过字段:值被提取出来但不写进 event。匿名形式是 %{},具名形式是 %{?name}。两者行为一样,具名的意义有两个:让长 pattern 可读,以及给下面的间接字段提供键名来源。

追加字段:%{+field_name},把多个标记的内容合并进同一个字段。连接用的不是固定空格,而是该标记前面找到的那个分隔符;只有当这个标记前面找不到分隔符时,才退化成一个空格。同一份结构换个分隔符,结果就跟着变:

1
2
3
4
5
6
7
8
9
# 分隔符是空格
# 输入: "2026 08 06"
# pattern: "%{ts} %{+ts} %{+ts}"
# 结果: ts => "2026 08 06"

# 分隔符是连字符
# 输入: "2026-08-06"
# pattern: "%{ts}-%{+ts}-%{+ts}"
# 结果: ts => "2026-08-06" ← 拼回来的是连字符,不是空格

带顺序的追加:%{+field_name/N},N 是追加顺序编号,用于乱序标记:

1
2
3
# 输入: "13:40:00 2026-08-06"(时间在前,日期在后)
# pattern: "%{+ts/2} %{+ts/1}"
# 结果: ts => "2026-08-06 13:40:00"(按 /1 /2 顺序拼接)

间接字段:%{&field_name},用另一个字段的值作为本字段的键名,专门跟具名跳过字段配对,用来解析 key=value 对:

1
2
3
# 输入: "user=42"
# pattern: "%{?key}=%{&key}"
# 结果: user => "42" (字段名来自 ?key 提取到的 "user")

这就是具名跳过字段存在的理由:?key 提取到的 "user" 本身不进 event,只是给 &key 当键名用。追加和间接两个前缀不能组合,%{+&x} 会被理解成往 &x 这个键里追加,%{&+x} 则是往 +x 里追加,两种写法都不是想要的结果。

padding 后缀:%{field_name->},告诉 dissect 忽略该字段右侧重复出现的分隔符。这是列对齐日志的专用工具:

1
2
3
4
5
# 输入(两行列宽对齐,中间空格数不同):
# 00000043 ViewReceive machine-321
# f3000a3b Calc machine-123
# pattern: "%{id} %{function->} %{server}"
# 结果: 两行都能正确切出 id / function / server 三个字段

官方对这个后缀有一条明确要求:必须加在 padding 左边那个字段上。所以当某一列右对齐、padding 在视觉上落到它左侧时,从 dissect 看这段 padding 仍然紧跟在前一个字段后面,后缀要往前挪一格:%{id->} %{function} %{server} 对应 00000043 ViewReceive machine-321 这种输入。

连续分隔符:缺失字段还是 padding

分隔符连续出现两次以上时,dissect 要在两种解释里选一个:中间夹着一个空字段,还是这些分隔符整体是一段 padding。从插件 1.1.1 起,官方把默认解释定为缺失字段,产出空字符串而不是把多余分隔符当填充。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 用这一行建的 dissection(6 个字段)
# John Smith,Big Oaks,Wood Lane,Hambledown,Canterbury,CB34RY
# pattern: "%{name},%{addr1},%{addr2},%{addr3},%{city},%{zip}"

# 处理这一行(addr2 与 addr3 为空)
# Jane Doe,4321 Fifth Avenue,,,New York,87432
# 结果:
# name => "Jane Doe"
# addr1 => "4321 Fifth Avenue"
# addr2 => "" ← 空字符串字段,不是失败
# addr3 => ""
# city => "New York"
# zip => "87432"

CSV 类日志正需要这个默认值:列位置固定,空列必须占位,否则后面的字段会整体错位。代价是"数错空格"这类错误在 dissect 下不一定报错。真 padding 的正确工具是上一节那个 -> 后缀,不是靠默认行为兜。

dissect 的失败处理

分隔符序列在输入里找不到时,dissect 向 event 的 tags_dissectfailure,原始字段保持不变,event 继续流转,语义与 Grok 的 _grokparsefailure 对称。用 tag_on_failure 可以替换默认 tag 名:

1
2
3
4
5
6
filter {
dissect {
mapping => { "message" => "%{ts} %{level} %{svc}" }
tag_on_failure => ["_dissect_structured_failure"]
}
}

调试时要先分清落在哪一种失败上,因为两种的表现完全不同。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
2
3
4
5
6
7
8
9
10
11
12
filter {
dissect {
mapping => {
"message" => "%{log_ts} %{log_level} %{service} %{rest}"
"rest" => "user=%{user_id} amount=%{amount} status=%{tx_status}"
}
convert_datatype => {
"user_id" => "int"
"amount" => "float"
}
}
}

两条 mapping 在同一个 filter 里跑完,类型也一次收齐。整条路径上没有出现正则引擎,最坏情况的时间上界依然是线性的。

第二档:尾部需要字符类或可选匹配,dissect 切前缀加 Grok 收尾。 尾部是 Java 异常栈、个数不定的 key=value、或者带可选段的结构时,线性扫描表达不了,这时才引入正则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
filter {
# 第一步:dissect 切出固定前缀字段,速度快
dissect {
mapping => {
"message" => "%{log_ts} %{log_level} %{service} %{rest}"
}
}

# 第二步:只对 rest 字段跑 Grok,缩短正则匹配的输入长度
if [rest] {
grok {
match => {
"rest" => "user=%{NUMBER:user_id:int} amount=%{NUMBER:amount:float} status=%{WORD:status}"
}
tag_on_failure => ["_grok_rest_failure"]
}
}
}

这么拆有两条收益。传给 Grok 的字符串只是 rest 字段,比完整 message 短得多,正则匹配的输入域更小,回溯深度上限随之降低。另一条是结构化前缀由 dissect 完成,即便 Grok 对 rest 失败(产生 _grok_rest_failure),log_tslog_levelservice 已经落袋,不会因为尾部对不上而丢掉整行的结构。

第三档:整行没有可依赖的固定结构,纯 Grok。 没有稳定字面分隔符时 dissect 的前提就不成立,硬切只会产出一批错位字段,而且如前所述这种错位往往不打 tag。

最小实验:dissect vs Grok 处理同一行日志

1
2
3
4
5
6
7
8
9
10
# dissect-demo.conf
input { stdin {} }
filter {
dissect {
mapping => {
"message" => "%{log_ts} %{log_level} %{service} user=%{user_id} amount=%{amount} status=%{tx_status}"
}
}
}
output { stdout { codec => rubydebug } }
1
bin/logstash -f dissect-demo.conf

输入测试行:

1
2026-08-06T13:40:00  INFO  pay-svc  user=42  amount=99.50  status=SUCCESS

期望输出(关键字段):

1
2
3
4
5
6
7
8
9
{
"log_ts" => "2026-08-06T13:40:00",
"log_level" => "INFO",
"service" => "pay-svc",
"user_id" => "42",
"amount" => "99.50",
"tx_status" => "SUCCESS",
...
}

注意:mapping 的提取结果一律是字符串,amount"99.50" 不是浮点数。转换由 dissect 自己的 convert_datatype 完成,它是一个独立的顶层配置项,支持 intfloat,在所有 mapping 都跑完之后统一执行:

1
2
3
4
5
6
7
8
9
filter {
dissect {
mapping => { ... }
convert_datatype => {
"user_id" => "int"
"amount" => "float"
}
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
要从 message 里提取字段?

├─ 输入本身是 JSON?
│ → json filter 或 json codec,不需要 dissect/grok

├─ 输入整体是 key=value 对,个数还不固定?
│ → kv filter(比 grok 直接,也比 dissect 灵活)

└─ 是带分隔符的文本行?

├─ 分隔符是固定字面量(列对齐 padding 用 -> 吃掉)
│ ├─ 尾部也有固定分隔符
│ │ → 两级 dissect mapping + convert_datatype
│ ├─ 尾部要字符类或可选匹配
│ │ → dissect 切前缀 + grok 收尾
│ └─ 两种情况都记得用 if 条件块保护,
│ 否则 dissect 会硬套 mapping 加出错字段

└─ 分隔符形态不确定 / 完全非结构化
→ grok,检查 pattern 的回溯风险,
并把 timeout_scope 设成 event(见上一篇)

模式提炼

1
2
3
4
5
6
7
8
9
10
模式:放弃回溯,换来最坏情况可预测

- 固定分隔符 → 线性扫描,O(n),无不确定性
- 变长 pattern → 正则回溯,最好 O(n),最坏 O(2^n)
- 平均吞吐的差距只有一成到两成,差别在方差不在均值
- 结构化日志走线性算法(dissect),非结构化文本走正则(grok)并防回溯
- 混合策略:线性扫描处理确定部分,正则只处理不确定部分
→ 最坏情况被限制在"不确定部分"的长度上
- 确定性的代价:dissect 只在 pattern 完全对不上时才报错,
分隔符数错会静默产出空字段;grok 要么整体匹配要么整体失败

在其他系统里同样存在这个权衡: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 自己的顶层配置项,支持 intfloat。多接一个 mutate 不只是多写几行:mutate 的 convert 是另一个独立 filter,它和 dissect 之间可以插进条件块、字段改名、甚至另一个 dissect,任何一处让字段名变了,转换就静默错过目标;convert_datatypemapping 在同一个 filter 内、在所有 mapping 之后执行,中间没有插入点。它唯一的维护成本是 mapping 里改字段名时,convert_datatype 的键要跟着改。

练习

  1. 取几行 Spring Boot 默认格式的日志(日期 时间 级别 --- [线程] 类名 : 消息),注意它的级别字段是右对齐的,INFOWARN 前面补了不同数量的空格。写一个 dissect mapping 把时间戳、级别、线程名、类名、消息切出来,用 -> 后缀吃掉那段 padding。用 stdin/stdout 验证同一份 mapping 能同时处理 INFOWARN 两种行。

  2. 把上一题里的"消息"字段再做一次 dissect:在同一个 dissect 块里加第二条 mapping,对第一条产出的字段继续切分。确认原字段仍然保留在 event 里,然后用 convert_datatype 把其中一个数值字段转成 int。再故意把某处分隔符多写一个空格,观察结果是打了 _dissectfailure,还是静默多出了一个空字符串字段。

  3. 思考题:一行日志格式是 时间戳 级别 模块 消息,前三个字段固定用两个空格分隔,第四个字段"消息"是可变文本,可能包含 Java 异常栈(多行)。描述一个完整的处理方案:哪部分用 dissect、哪部分用 Grok、multiline codec 应该在哪一层介入,以及 _dissectfailure_grokparsefailure、"静默产出空字段"这三种异常各自如何被发现和路由。

系列导航

序号 主题
00 导读:核心对象是 event,骨架是三段管道
01 架构:JRuby、JVM 与 pipeline 的运行形态
02 event 模型:@timestamp、@metadata 与字段引用
03 codec:字节流与 event 的边界转换
04 input 插件:拉取、监听与 Beats 接入
05 Grok 的本质:命名正则加预定义 pattern
06 dissect 与结构化 filter:放弃回溯换吞吐(本篇)
07 常用 filter 组合:mutate、date、geoip 与条件
08 output 插件:Elasticsearch output 与批量写入
09 pipeline 执行模型:worker、batch 与背压
10 内存队列 vs 持久队列:可靠性的分界线
11 死信队列(DLQ):无法处理的 event 去哪
12 Multiple Pipelines 与 pipeline-to-pipeline
13 监控:Node Stats API、hot threads 与瓶颈定位
14 性能调优:JVM heap、批处理与持久队列磁盘
15 Logstash vs Beats vs Ingest Pipeline:该用谁
16 Logstash vs Fluentd vs Vector:日志管道的三种取舍
17 Logstash 的演进与 Elastic Agent 的冲击

参考资料