线上日志天然是"模板 + 参数"的形态:一条模板反复打几千次,只有时间戳、ID、耗时在变。想把告警按"事件类型"聚合、想统计每类事件的出现频率,第一步就是把日志收敛成模板。大多数人的第一反应是两条捷径:整行去重,或者写正则把数字遮掉再去重。我在 80,000 行合成日志(40 个真实模板、8 个服务、12 个轮转文件、7.12 MB,时间戳毫秒级、参数覆盖 uuid/IP/路径/账号名等 16 种形态)上把两条捷径都跑了一遍,结果一言难尽:整行去重给出 80,000 个"模板"——每一行都自成一个模板;正则遮数字好一些,也还有 28,632 个,聚类成对召回率只有 41.71%。而一条 Drain 式的前缀树流水线,0.293 秒收敛到恰好 40 条,P/R/F1 全部 100。

整行去重死于"每行必变的字段"。 日志行首的时间戳精确到毫秒,80,000 行里有 79,990+ 个互不相同的时间戳——只要把它当正文一部分,"去重"就退化成"数行数"。实测去重后仍要存 7.47 MB,压缩率为零,模板数 80,000 对真值 40,成对召回率 0%。
正则遮数字死于"字母形态的变量"。 把 \d+ 换成 <*> 能遮住耗时、状态码、IP 八进制段,但遮不住 uuid 里的十六进制字母(11e8ec09-8c4a-... 每行字母都不同)、路径里的 hex 文件名(/data/vol2/bc844440.log)、账号名后缀(carol_6be4)。这些字段每行都在变,遮完之后同一模板仍碎成 28,632 个变体。且正则方案还有个隐性问题:它对"哪一段是变量"零学习,阈值和规则全靠人拍脑袋补。
正确姿势是让数据自己说出变量位。 Drain(LogPAIL 2017 那篇的经典算法)的思路是:同一模板的行共享大量常量 token,不同模板的行在固定前缀上分岔——所以按前缀把日志路由到解析树的叶子,叶内逐位对比,凡是不一致的位置就遮成 <*>。变量位不是猜出来的,是"差出来"的。

方案 | 簇数(真值 40) | 成对 P | 成对 R | F1 | 耗时 | 备注 |
|---|---|---|---|---|---|---|
A 整行去重 | 80,000 | 100.00 | 0.00 | 0.00 | 0.011s | "去重"后仍存 7.47 MB |
B 正则遮数字 | 28,632 | 100.00 | 41.71 | 58.87 | 0.298s | uuid/路径/账号名全漏遮 |
C Drain 式前缀树 | 40 | 100.00 | 100.00 | 100.00 | 0.293s | 参数位 201,922 个,P/R 全 100 |
C 档学出的 40 条模板总共只占 1,895 字节,约为原始日志体积的 1/3,940。抽样看学习结果,形如 <TS> api-gateway request completed <*> <*> <*> <*> INFO——模板即正则、<*> 即槽位,直接就能喂给告警聚合和频次异常检测。
消融实验把三个开关的代价钉死了:去掉时间戳归一,前缀第一位每行都不同,79,995 簇、F1 归零;前缀深度只到服务名(depth=2),同服务的 5 个模板在叶内被通配符级联污染,26 簇、精确率掉到 64.76%,哪怕加回逐位归并也只有 F1 78.55;把首内容词纳入前缀(depth=3),40 簇满分。相似度阈值扫描更有意思:0.3 和 0.45 都是满分,0.7 时召回掉到 97.39(2,536 簇),0.9 直接碎片化成 79,996 簇——阈值只管下限,把阈值调高防过合并是南辕北辙。
核心流水线不到 40 行,依赖只有标准库:
import re
from collections import defaultdict
TS_RE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\.\d{3}\] ") # 行首时间戳
def mine_templates(lines, sim_th=0.45, depth=3):
clusters, leaves, pred = [], defaultdict(list), []
for l in lines:
toks = TS_RE.sub("<TS> ", l).split() # 1. 时间戳归一 2. 分词
prefix = tuple(toks[:depth]) # 3. 前缀路由到叶子
n, best = len(toks), None
for cid in leaves[prefix][-64:]: # 只扫叶内最近 64 簇
tm = clusters[cid][0]
if len(tm) != n:
continue
m = sum(1 for a, b in zip(toks, tm) if a == b or b == "<*>")
if m / n >= sim_th: # 4. 等长 + 逐位相似度
best = cid
break # 首个达标即停,防 O(N^2)
if best is not None: # 5. 合并:差异位遮成 <*>
tm = clusters[best][0]
for j, (a, b) in enumerate(zip(toks, tm)):
if a != b and b != "<*>":
tm[j] = "<*>"
clusters[best][1] += 1
pred.append(best)
else: # 6. 新建簇
leaves[prefix].append(len(clusters))
clusters.append([list(toks), 1])
pred.append(len(clusters) - 1)
return [(" ".join(t), c) for t, c in clusters], pred
if __name__ == "__main__":
lines = open("app.log", encoding="utf-8", errors="replace").read().splitlines()
templates, pred = mine_templates(lines)
for t, c in sorted(templates, key=lambda x: -x[1])[:10]:
print(f"{c:6d} {t}")验收代码同样重要——没有真值时,用这几条自检也能拦住大部分翻车:
def sanity(templates, n_lines):
assert len(templates) <= max(200, n_lines * 0.01), "簇数虚高:八成是变量位没遮住"
for t, _ in templates:
assert "<TS>" not in t.split()[1:], "时间戳没归一,混进了模板正文"
assert "0x" not in t and "vol" not in t, "hex 形态的变量位漏遮(正则方案典型事故)"
print(f"{len(templates)} templates OK")
slow query observed、大小写成 123.45 MiB,分词后同一模板每行 token 数都不同,前缀树和参数位全部错位——聚类直接坍缩到 9 个全 <*> 的黑洞簇。真实日志里这类字段要么是单 token,要么先做归一。<*>。每条模板属于哪个服务、什么级别,是模板属性,不是行的属性。<*> 计入相似度后,一个已被遮黑的簇跟同长度的任何模板都能匹配上,越吞越黑。前缀必须路由到第一个内容词(depth=3),把不同模板物理隔进不同叶子——这一项把 F1 从 78.55 拉到 100。<TS> 不算参数;按模板固定的服务名不是参数。三处错任何一处,参数抽取指标都会"看起来很差",其实是评分类在冤枉人。<TS> 这类归一占位要选得足够冷门,别用 <date>、<*> 这类可能在正文里自然出现的串,否则自检正则和真值比对都会被污染。把下面这段直接发给 WorkBuddy,就能在任意目录复刻这条流水线(把路径换成你的日志目录即可):
# 提示词:日志模板挖掘
请用 Python 标准库写一条日志模板挖掘流水线,要求:
1. 输入:./logs/*.log,每行格式 "[HH:MM:SS.mmm] LEVEL 服务名 正文";
2. 步骤:时间戳归一为 <TS> → 按空格分词 → 前缀树路由(深度 3:归一后的时间戳、
服务名、首内容词)→ 叶内等长逐位相似度匹配(阈值 0.45,首个达标即停,
每叶最多扫 64 簇)→ 合并时差异位替换为 <*>;
3. 输出:按出现次数排序的模板清单(模板 + 次数 + 参数位数量),存到 ./out/templates.txt;
4. 自检:模板总数若超过行数的 1%,报"疑似变量位未遮住";
5. 对 80,000 行量级,整体耗时应在 2 秒内。这轮实测最反直觉的结论有两个:一是"去重"和"遮数字"这两条捷径在真实日志形态面前都不堪一击,前者 F1 为 0,后者 F1 只有 58.87;二是决定成败的不是相似度阈值,而是前缀路由的深度——把"在哪儿比较"设计对,阈值宽松到 0.3 都能满分。日志模板挖掘是告警聚合、异常检测、容量分析的地基,地基歪一寸,上层的频次统计和聚类告警就全歪了。整套实验(语料生成、三档对比、消融、阈值扫描)都是标准库可复现的,读者拿自己的日志替换输入,十分钟内可以验一遍自己的"模板"到底有几个是真的。
(数据口径:80,000 行合成日志 / 40 模板真值 / 12 个轮转文件 / 7.12 MB,全部数字来自本机单次实跑,脚本与中间结果见文内代码。)
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。