修复 Loki 项目 OOM 问题的一次实战
最近在浏览 GitHub 的时候,Loki 项目中的一个 issue 引起了我的注意:Issue #13277。该问题描述的是在执行查询时由于语法树构建异常,导致了内存泄漏甚至 OOM(Out of Memory)的问题。作为一个热爱挑战的技术人,我决定深入一探究竟。
🔍 Loki 简介
Loki 是由 Grafana 开发的日志聚合系统,与 Prometheus 的理念类似,强调对日志的索引最小化。它支持通过 LogQL 查询语法对日志进行高效查询,广泛应用于云原生监控场景中。
💥 问题背景
在 Issue #13277 中,用户报告执行了如下类似的查询语句会引发内存异常:
ini
{job="my-job"} |= "p" |= "p"
这类查询语句在语法上是合法的,但实际执行时会在 AST(抽象语法树)中出现 子树错误地引用父树 的情况。这种错误会在执行阶段引发无限递归,最终导致程序崩溃或 OOM。
关键代码在 Loki 的 ingester.go
文件中:
-
子树在构建时重复引用了父节点:

🌳 什么是 AST(抽象语法树)?
AST(Abstract Syntax Tree) 是源代码的一种结构化表示,它以树状结构的形式,描述了程序的语法结构。
每一个节点代表了源代码中的一个语言结构(如表达式、语句、操作符等),而树的结构则体现了代码中各部分的嵌套和依赖关系。
📦 例子:LogQL 表达式
例如,LogQL 中的查询语句:
ini
{job="my-job"} |= "p" |= "q"
其对应的 AST 结构大致如下:
ini
|=
/ \
|= "q"
/ \
{job="..."} "p"
在这个例子中:
- 根节点是
|=
- 它的左子树又是一个
|=
操作 - 最底部是实际的 log selector 和匹配表达式
每一个操作符都被解析成一个节点,树的形状反映了执行的顺序。
🧩 AST 的用途
- 代码分析与编译:编译器通过 AST 理解源代码的语义。
- 代码优化:比如合并冗余节点、消除无用表达式。
- 静态检查:查找代码错误、检测重复逻辑。
- 代码转换:如代码格式化、代码生成或转换为其他语言。
- 查询执行:Loki、PromQL、SQL 等查询语言都会使用 AST 来构建执行计划。
🧠 为什么 AST 会导致 OOM?
如果构建 AST 时存在"子节点引用父节点 "的情况,会形成循环引用或"递归结构",如:
css
node
└── child
└── (back to node)
在执行或遍历这棵树时,就会进入无限递归,导致内存持续增长,最终 OOM(内存溢出)。
🧠 AST 与 Equal 判断陷阱
起初我尝试使用 reflect.DeepEqual
来判断 AST 节点之间是否相等,但很快意识到这在 Loki 这样一个对性能极为敏感的项目中是不现实的。DeepEqual
开销大,且在处理循环引用时存在风险。

因此,我需要一种轻量、高效、可控的方式来判断 AST 中是否存在 "子树引用父树" 的问题。

🔧 修复方案
我修改了语法树处理逻辑,在构建语法树时添加了一个父子结构检查。如果某个节点被自己的子节点引用,就会立即报错,避免形成循环依赖。
修复步骤如下:
- 添加 AST 节点构建时的检测逻辑,防止自引用。
- 避免构建等价但共享引用的子树结构。
- 封装简化了逻辑,使其不会影响正常性能路径。
同时,为确保逻辑的正确性,我补充了一系列单元测试,覆盖以下场景:
- 多次
|= "p"
查询; - 子树共享问题;
- 循环依赖模拟;
- 查询执行和 AST 结构解析验证。

并增加了部分单测确保不再次出现这种问题:

🧪 压测与验证
为了确保这次修复不会引入新的性能回退,我对 Loki 进行了不同查询组合下的压力测试。最终结果表明:
- 查询正确性保持不变;
- 内存占用稳定;
- 无 OOM 或崩溃现象。
✅ PR 合并
经过多轮 review 和测试后,我的修复方案被合入 Loki 主仓库:
👉 PR 地址:Fix AST cyclic reference causing OOM
✨ 总结
这次修复让我更加深入地理解了 Loki 的查询引擎实现,尤其是 AST 构建的细节。它也提醒我,在做系统底层优化时, "结构正确性" 是性能优化的前提。
希望这篇文章能给你带来一些启发。如果你也对开源项目中的性能问题感兴趣,不妨也去贡献一份力量!