将百万条相似日志归并为数百个模板的关键算法,替换变量后得到稳定 ID,是后续计数、告警、异常检测的基础。
日志模板是日志语句中可变部分被占位符替换后的恒定部分。如下三行
Received block blk_7382 of size 67108864 from /10.251.42.9
Received block blk_1029 of size 67108864 from /10.251.31.5
Received block blk_5561 of size 33554432 from /10.250.14.224
实际上是同一个模板 Received block <*> of size <*> from <*>,对应三条参数列表。这种归约是一切后续分析的前提。如果同一个事件从不出现两次完全相同的写法,你就无法统计它的出现次数;只有先给被计数的对象一个身份,才能说出"这条消息整周每分钟出现 40 次,而在 03:12 出现了 4000 次"。模板提取为每条日志语句赋予了一个稳定的 ID,无需开发者主动输出结构化数据。
朴素做法——按编辑距离对原始字符串做聚类——在日志量级下对行数呈平方复杂度,根本无法使用。另一个朴素替代——为每条消息写一个正则表达式——在下次部署加入新消息(而你毫不知情)之前还能用。这个领域的算法所做的,就是在单次流式遍历中、以有限内存、在无需 schema 的前提下,得到模板身份。
Drain 由 Pinjia He、Jieming Zhu、Zibin Zheng 和 Michael Lyu 在 ICWS 2017 年发表,是大多数日志工具使用或借鉴的算法。它的核心思想是:你不需要将新行与所有已知模板逐一比对——而是需要一棵廉价的树先把候选集缩小到 handful,然后再做相似度比较。请阅读原始论文 He et al., "Drain: An Online Log Parsing Approach with Fixed Depth Tree";维护中的 Python 实现是 logpai/Drain3。
一行日志经历四个阶段。第一,遮罩(masking):一小套领域正则把那些已知一定是变量的值——IP 地址、十六进制 ID、数字、路径——改写为具名占位符。这不是算法的捷径,而是必要步骤,因为如果第一个 token 位置出现变量,会导致每次出现都走到树的不同分支。
第二,长度层。分词后的消息按 token 数量分流,所以一条五 token 的消息永远不会与一条十一 token 的消息做比较。第三,token 层:树按第一个 token、第二个 token……依次向下延伸固定的层数(由 depth 参数决定)。固定深度就是全部 trick——树不会退化成又深又不平衡的结构,因此无论存在多少模板,查找都是常数次跳转。
第四,叶节点。每个叶节点持有一组日志组,每组有一个模板和一个计数。新行与每组的模板通过简单的位置一致性打分:用行与模板在相同 token 位置上的一致数量,除以 token 总数。如果最高分达到相似度阈值 sim_th,行就加入该组,并且将所有不一致的位置在存储的模板中改写为通配符 <*>。如果没有组超过阈值,行就成为一个新组,其模板就是它自身。正是最后这一步使得模板集合会收敛:最初是具体的,随着更多样本到来逐渐侵蚀为恒定部分。
以开头三行为例,数字和 IP 被遮罩后:
line 1 Received block ID of size NUM from IP
leaf empty -> new group
template := Received block ID of size NUM from IP (count 1)
line 2 Received block ID of size NUM from IP
vs template: 8 of 8 positions agree -> sim = 1.00 >= 0.4
matches; template unchanged (count 2)
line 3 Received block ID of size NUM from IP
identical after masking (count 3)
这里遮罩做了大部分工作,这很符合实际。现在加入一条遮罩无法完全抹平的行:
line 4 Received block ID of size NUM from datanode IP
9 tokens -> different length bucket -> new group
line 5 PacketResponder NUM for block ID terminating
first token differs -> different subtree -> new group
再来看相似度计算发挥价值的场景。假设两条行到达同一叶节点,分别是 Deleting block ID file /path 和 Deleting block ID dir /path。五条 token,四个一致,所以 sim = 0.80。超过 0.4,两者合并,存储的模板变为 Deleting block ID <*> /path。解析器从两个样本中发现了一个没有任何遮罩知道的变量位置。这正是你购买的行为,但也是阈值设置不当时会出问题的行为。
sim_th —— Drain3 默认 0.4。最具影响力的旋钮。设得太低,不相关的消息会合并:0.2 时,一条五 token 的消息只需要一个匹配位置就能加入某组,最终得到一个几乎全通配符、毫无意义的模板。设得太高,模板会碎片化,同一条日志语句被跟踪为六个 ID,所有频率信号都被分割到六个方向。碎片化是更安全的失败——你可以事后合并,但无法拆分。
depth —— 默认 4,最小 3。树在到达叶节点之前按前导 token 分支的层数。越深意味着每个叶节点的候选越少、匹配越快,但也意味着前导 token 的任何变化都会把本该是一组的拆开。以组件名开头、动词居次的日志格式适合浅树;以变量开头的格式需要更好的遮罩,而不是更多深度。
max_children —— 默认 100。内部节点分支数的上限。当一个节点满时,进一步的不同 token 会被路由到一个全通配符子节点,而不是让树无限生长。这是内存保证,意味着如果前导位置有一个未遮罩的高基 token,会降低匹配质量,而不会耗尽 RAM。
Drain3 还暴露了 max_clusters(默认无限制),设为非无限值时会把模板存储变成 LRU 缓存。在长时间运行的流上如果模板更迭频繁,不设上限就是一个伪装成功能的慢速内存泄漏。
多行消息。 Java 堆栈跟踪是一个逻辑事件、四十条物理行。按行逐条输入,解析器会从每种不同的堆栈中挖掘出四十个模板,模板数量爆炸。多行拼接必须在解析之前完成,通常做法是把不以时间戳开头的行视为续行。
长度不固定的变量。 长度层假设模板有固定的 token 数量。如果一条消息插入了列表——evicted peers [a, b, c]——每次出现的 token 数量不同,从而产生不同长度的列表对应不同模板。要在解析前把带括号的列表遮罩为单个 token。
跨部署的模板漂移。 开发者把 "Received block" 改成 "Received chunk",所有基于旧 ID 建立的计数序列归零,同时冒出一个全新的 ID。任何读取这些计数的异常检测器都会报警,它并没有错——确实有东西变了——但这不是任何人希望被叫醒处理的事件。这就是为什么模板存储值得与产生它们的构建一起持久化和版本化管理。
自由文本用户内容。 如果一条消息嵌入了搜索查询或第三方库的异常信息,自然语言会出现在恒定位置上。算法本身无法将这种情况与正常变化的模板区分开来,而这正是实际中模板数量失控最常见的来源。按 token 数量设上限并截断。
拿到模板之后,自然的后续步骤包括:将行分组以便分类、在进入索引前合并重复行、以及在日志异常检测中对模板频率建模。大多数文献评估用的基准数据集——HDFS、BGL、Thunderbird 及其他十三个——都发布在 loghub 上。
Clustering Log Messages for Faster Triage
Deduplicating Noisy Log Lines Before Indexing
Log Anomaly Detection With Machine Learning