我重写了PostHog的SQL解析器,速度提升70倍,而几乎没看代码

Hacker News Top 工具

摘要

PostHog的工程师使用了多次长时间运行的Claude Code会话,重写了他们的SQL解析器,相比之前基于ANTLR的解析器实现了70倍的加速,而他们自己几乎没看代码。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/24 19:53

# 我几乎没怎么看代码,就写了个快 70 倍的 SQL 解析器 — PostHog 来源:https://posthog.com/blog/sql-parser 在成功使用智能体通过自动研究提升查询性能后(https://posthog.com/blog/karpathy-autoresearch-query-engine-bug),我想尝试一些更有野心的事情。 我利用多个并行运行的长时间 Claude Code 会话,重写了 PostHog 的 SQL 解析器。最终结果是 1.6 万行“手写”解析器代码、5K 行工具代码、几千行测试代码,以及大约 70 倍的加速效果。 新解析器在所有实际查询中与旧解析器等效,区别仅在于极少数由邪恶捣蛋神编写的查询(有一个测试用例是 `SELECT SELECT FROM FROM WHERE WHERE AND AND`,这完全是合法的 SQL)。 以下是我的实现过程以及一路上学到的经验。 ## PostHog 为什么需要 SQL 解析器? PostHog 允许你直接用 SQL 访问数据(https://posthog.com/docs/sql)。我们会将你的 SQL 转译成原生的 ClickHouse SQL,原因是: - 我们希望你的数据呈现一个逻辑视图,与实际在数据库中的物理布局无关。 - 这样我们就可以在不破坏现有查询的前提下,修改数据库层面的内容。 - 我们还可以添加大量性能优化和访问控制。 大多数 PostHog 工具(例如产品分析、会话回放、错误追踪)的查询都是用 SQL 编写的,并且同样经过这个转译过程。但在进行转译之前,我们需要先使用解析器将 SQL 转换成 AST(抽象语法树),然后再转译为 ClickHouse SQL。 解析器是第一个接触查询的组件,这意味着它操作的是不可信的输入。下游的所有功能,如访问控制和优化,都在它生成的语法树上操作。 我们没有手写这个解析器,因为在 AI 辅助编码出现之前,解析器极难维护。没有 AI 的话,手写一个解析器可能要花几个月时间,而且即使它能大幅改善我们的 p95 响应时间,也未必值得。 因此,我们使用了 ANTLR(https://github.com/antlr/antlr4),这是一个先进的开源解析器生成器。你只需在 `.g4` 文件中(https://github.com/PostHog/posthog/blob/master/posthog/hogql/grammar/HogQLParser.g4)声明式地提供语法规则,ANTLR 就会为你生成大部分解析器代码。我们使用的是 C++ 版本,所以它本身就已经是“快速”语言了。与我们的 flags 重写(https://posthog.com/blog/even-faster-more-reliable-flags)不同,这次的速度提升并非仅仅来自于改用 Rust。 ANTLR 极其强大且灵活,但代价是它处理每个 token 时做了很多额外的工作。它会将你的语法编译成一个 ATN(https://en.wikipedia.org/wiki/Augmented_transition_network)(本质上是一个带栈的 NFA(https://en.wikipedia.org/wiki/Nondeterministic_finite_automaton)),然后通过一个通用解释器在运行时遍历这个图。这里没有手写的 `parseExpression()` 函数;所有操作都通过额外的抽象和间接层完成。 此外,ANTLR 支持任意动态向前看,因此在有多种可能的选择时,它必须同步模拟每一种解释,直到只剩下一种有效解释。它的优化做得非常好,但一个图遍历解释器永远不可能比手写的递归下降解析器快。 有了 AI,编写和维护一个手写解析器变得可行得多。但可惜的是,这并不像告诉 Claude“写一个新的 Rust 解析器,不要出错”那么简单。实际上,它犯了很多错误,不断怀疑这样的重写是否可能,并且每轮编码后就想收工。老实说,我也不知道这是否可行。 我并行测试了两种方法: 1. 一种专注于性能。我知道,如果可行,最快的解析器应该是递归下降加 Pratt 表达式循环,仅在必要时添加向前看和回溯。 2. 另一种专注于最有可能成功实现解析器的方法。它尽可能贴近 ANTLR 的行为,但将转换过程实现为显式代码,而不是通用的图遍历。 最终,两种方法的效果差不多,但直到我花了好几天时间才了解到这一点。 我的目标是让新解析器对所有实际查询与 oracle(即现有的 C++ 解析器)完全一致,并尽量接近那些人为构造的查询。拥有一个 oracle 对我开发新解析器至关重要,因为我基本上可以通过测试驱动开发:找到一些 SQL 语句,让新旧解析器输出不一致,然后修复新解析器使其一致,重复这个过程。 生成不一致的测试用例一开始很容易,因为我们在开发原始解析器时已经写了大量回归测试。当所有这些测试都通过后,事情才变得有趣起来。 ### 基于属性的测试 我之前使用 Hypothesis(https://github.com/hypothesisworks/hypothesis)发现了我们 SQL 转译器的一些 bug。Hypothesis 是一个 PBT(基于属性的测试)库。你为代码定义一个属性以及它接受的输入,它会尝试生成那些导致属性不成立的输入。 举个具体的例子,我的新解析器的属性就是它与 oracle 保持一致。输入是一条 SQL 查询。这意味着 Hypothesis 会试图找到一条 SQL 查询,使我的新解析器与 oracle 不一致。 我必须告诉 Hypothesis 如何生成有趣的 SQL,于是(在 Claude 的帮助下)我写了一个工具,根据 ANTLR 语法文件代码生成一个 SQL 生成器。我不得不承认,在编写新 SQL 解析器的同时还要为 `.g4` 文件编写一个新解析器,这让我觉得有点好笑。后来,我还添加了一个步骤,为生成的 SQL 增加额外的排列,比如交换 token 或添加括号。 ### 针对脆弱修复的提示工程 PBT 可以可靠地生成新的测试用例,我的开发循环运行良好,但 Claude 一直在做脆弱的修复。例如,它为了修复一个案例会添加一个 token 的向前看,但后来发现需要两个 token 的向前看。我经常遇到上下文窗口已满需要压缩的情况,所以我猜测它只是“忘记”了实际的语法或参考解析器是什么样子的。 这个问题可以通过一些基本的提示工程解决。我只是告诉它在编写任何修复特定差异的代码之前,立即将语法文件和相关的 C++ 源代码加载到上下文中。我花了好一阵子才想明白这一点。 ### 最大化利用资源并深入思考 此时,我希望让 CPU 始终满载运行 PBT,同时让 Claude 推理满载编写解析器,所以我写了一些工具,让 PBT 在后台持续运行,将新的失败测试用例写入文件,而不是仅仅显示出来。Claude 可以在没有其他任务时获取这些用例。 我还有另外几种生成失败测试用例的方法,比如从生产查询日志中拉取匿名查询。令人捧腹的是,最有效的方法之一就是告诉后台智能体“认真想想边界情况”。 两种并行的解析器方法共享了它们的回归测试集,因此在某个会话中发现的任何失败测试用例都会与另一个会话共享。 Hypothesis 还会帮你“缩减”测试用例,将其变成最小复现,但我无法对来自其他来源的 SQL 使用这个功能。对于这些情况,我改用 ShrinkRay(https://github.com/DRMacIver/shrinkray)。 后来,我还增加了代码覆盖率引导的测试用例生成,这能提供更好的生成 SQL 分布。有了覆盖率反馈,生成器可以判断哪些结构还没有被测试到,并倾向于生成这些结构。虽然对于在生产语料库上达到 100% 的准确率来说,这一步并非必需,但它确实帮助我找到了一些非常微妙的测试用例。 我的最终迭代循环大致如下: - 从 PBT、真实语料库、回归测试以及“认真想想边界情况”中生成新的测试失败用例 - 将缩减后的失败用例添加到不断扩充的回归测试列表中 - 仔细思考最佳修复方式,尽量选择通用解决方案,阅读参考解析器是如何处理该问题的语法和 C++ 源码 - 进行修复,并打印一段总结供人工操作员阅读 - 运行回归测试套件,确保一切通过 - 自动重新运行循环 由于新解析器速度快得多,我可以在“影子模式”下让它与现有的 C++ 解析器在生产环境中并行运行,并报告是否存在任何差异。 在与生产查询日志比较时,我只测试了大约 5 万条查询。但在影子模式下,我能够快速解析数百万条查询,且零差异。我原本计划让它运行几天,但结果如此有力,以至于几个小时后我就将生产流量切换到了新解析器(同时保留了 0.1% 的“反向影子”)。 现在,它产生的输出(AST + 源码位置)与基于 C++ ANTLR 的解析器完全相同,而性能结果(黄色部分)几乎不真实: 基准测试结果显示新解析器的性能 在生产查询上,它平均比之前的解析器快 454 倍。标题中的 70 倍来自我笔记本电脑上的基准测试,但在生产中,我们主要解析更长的 SQL 语句,这些语句没有命中解析器缓存。 这对我来说是一次更新。能够只用几天时间就构建出原本需要具备特定知识的人花几个月才能完成的东西,感觉非常强大。 虽然我没有手写任何代码,但我完全不认为这是“氛围编码”。我的 PBT 设置结合了基于语法文件的代码生成输入以及覆盖率引导的生成,这已经非常接近解析器 fuzzing 的尖端水平。 思考这对 ANTLR 这样的工具意味着什么,是件很有趣的事。我怀疑像我的这种基于 AI 的方法将成为新常态。解析器生成器将提供 oracle,然后 LLM“手写”一个更高性能的解析器,并使用 PBT/fuzzing 来确保它们匹配。 我最终得到了什么?正式来说,我的新解析器是一个“手写”的、主要采用预测性递归下降的解析器,核心是 Pratt 表达式,在特定位置通过有界、非消耗性的向前看探测来扩展 LL(2) 游标,再加上局部有序选择推测性回溯,仅用于少数需要它的决策。它完全由 Claude Opus 4.7 编写,使用 Rust 语言,于 2026 年 5 月完成。

相似文章

@posthog: https://x.com/posthog/status/2062595534381326421

X AI KOLs Timeline

PostHog 使用基于 Karpathy 的 autoresearch 的 AI 代理,发现其 ClickHouse 查询引擎中存在一个三年之久的漏洞,该漏洞阻止了时间戳过滤器正确使用主键。修复后,性能提升了 11%,扫描的粒度减少了 62%。