用 Claude 重写 SQL 解析器,性能暴涨 70 倍:程序员做的不是写代码,而是搭建验证闭环
<p data-type="paragraph">本文最初发布于 PostHog 官方博客。</p><p data-type="paragraph"></p><p data-type="paragraph">在<a href="https://posthog.com/blog/karpathy-autoresearch-query-engine-bug" title data-type="link">使用代理利用autoresearch成功提高查询性能</a>后,我想尝试一些更有野心的事情。</p><p data-type="paragraph"></p><p data-type="paragraph">我使用多个长时间运行的并行 Claude Code 会话重写了 PostHog 的 SQL 解析器。结果是 16K 行“手工”编写的解析器代码,5K 行工具代码,几千行测试代码,以及大约 70 倍的速度提升。</p><p data-type="paragraph"></p><p data-type="paragraph">在所有针对实际应用场景的查询中,新解析器均与旧解析器等效,仅在极少数由“恶作剧之神”编写的极端边缘案例中存在差异(例如,针对 SELECT SELECT FROM FROM WHERE WHERE AND AND 这类完全合法但极具迷惑性的 SQL 语句的测试用例)。</p><p data-type="paragraph"></p><p data-type="paragraph">以下是我的实现过程以及我在这个过程中学到的东西。</p><p data-type="paragraph"></p><h2 data-type="heading" data-id="heading0">为什么 PostHog 会有一个 SQL 解析器?</h2><p data-type="paragraph">PostHog 允许你直接使用 SQL 访问数据。我们将你的 SQL 转译成原始的 ClickHouse SQL,因为:</p><ul data-type="bulletedlist"><li data-type="listitem"><p data-type="paragraph">我们希望呈现一个与数据库物理布局无关的数据逻辑视图。</p></li><li data-type="listitem"><p data-type="paragraph">这让我们可以在数据库层进行变更,而不会破坏现有查询。</p></li><li data-type="listitem"><p data-type="paragraph">我们还可以添加许多性能优化和访问控制。</p></li></ul><p data-type="paragraph"></p><p data-type="paragraph">大多数 PostHog 工具(例如产品分析、会话回放、错误跟踪)都使用 SQL 编写查询,它们都经过相同的转译过程。但在进行这种转译之前,我们需要使用解析器将 SQL 转换成 AST(抽象语法树),然后再将其转译成 ClickHouse SQL。</p><p data-type="paragraph"></p><p data-type="paragraph">解析器是第一个接触查询的东西,这意味着它操作的是不受信任的输入。下游的所有东西,如访问控制和优化,操作的都是它生成的树。</p><p data-type="paragraph"></p><h2 data-type="heading" data-id="heading1">使用 ANTLR 生成我们的解析器</h2><p data-type="paragraph">我们没有手工编写这个解析器,因为至少在 AI 编码出现之前,解析器非常难以维护。没有 AI 的帮助,编写一个解析器需要花几个月的时间,即使它能显著地提高我们的 p95 响应时间,也可能不值得。</p><p data-type="paragraph"></p><p data-type="paragraph">现在,我们使用了 ANTLR,这是一个最先进的开源解析器生成器。你只要以声明式的方式在.g4 文件中提供你的语法,ANTLR 就会为你生成大部分的解析器代码。我们使用 C++版本,所以它本身已经运行在一种“快速”语言之上。与我们的功能标志重写不同,加速不仅仅来自于迁移到 Rust 语言。</p><p data-type="paragraph"></p><p data-type="paragraph">ANTLR 功能非常强大和灵活,但相应的代价是,它对访问到的每个 Token 都要进行更多的处理。它会将语法规则编译成 ATN(本质上是一种带栈的非确定性有限自动机),并在运行时让一个通用的解释器遍历这个图。这里没有手动编写的 parseExpression()方法;所有操作都是通过额外的抽象和间接层来完成的。</p><p data-type="paragraph"></p><p data-type="paragraph">此外,ANTLR 支持任意动态前瞻(arbitrary dynamic lookahead)。因此,当存在多种可能的备选路径时,它必须同步模拟每一种解释,直到仅剩一种解释有效为止。尽管其优化程度极高,但这种基于图遍历的解释器在速度上永远无法与手工编写的递归下降解析器相媲美。</p><p data-type="paragraph"></p><h2 data-type="heading" data-id="heading2">编写一个新解析器,不要犯错</h2><p data-type="paragraph">有了 AI,编写和维护手工编写的解析器变得更加可行。遗憾的是,这并不像告诉 Claude“用 Rust 编写一个新解析器,不要犯错”那么简单。实际上,它犯了很多错误,让我一直怀疑这样的重写是否可能,并且在完成每一轮编码后都想就此停下。老实说,我也不知道这是否可能。</p><p data-type="paragraph"></p><p data-type="paragraph">我同时测试了两种方法:</p><ol data-type="numberedlist"><li data-type="listitem"><p data-type="paragraph" data-number="1">一种侧重于性能。我知道,如果它有效,最快的解析器可能会是递归下降,带有 Pratt 表达式循环,只在必要时添加前瞻和回溯。</p></li><li data-type="listitem"><p data-type="paragraph" data-number="2">另一种方法则侧重于最有可能成功实现解析器的方案。它尽可能地遵循了 ANTLR 的行为,但将状态转换通过显式代码实现,而非采用通用的图遍历方式。</p></li></ol><p data-type="paragraph"></p><p data-type="paragraph">最终,这两种方法的效果差不多,但我直到为此忙碌了几天后才意识到这一点。</p><p data-type="paragraph"></p><p data-type="paragraph">我的目标是确保新解析器在所有实际查询中与 oracle(即现有的 C++ 解析器)完全一致,并在人为构造的查询中尽可能地与其接近。oracle 对我开发新解析器至关重要,因为我基本上可以通过以下方式进行测试驱动开发:找出在两个解析器之间存在差异的那些 SQL 语句,修正新解析器直到结果一致,然后重复这一过程。</p><p data-type="paragraph"></p><h2 data-type="heading" data-id="heading3">生成分歧点(多种方式)</h2><p data-type="paragraph">起初,生成分歧点或测试用例相当容易,因为我们在开发原来的解析器时已经写了很多回归测试。一旦这些测试都通过了,事情便开始变得有趣。</p><p data-type="paragraph"></p><h3 data-type="heading" data-id="heading4">基于属性的测试</h3><p data-type="paragraph">我以前使用的是 PBT(基于属性的测试)库 Hypothesis,我们在 SQL 转译器中发现过错误。你定义代码的一些属性以及它接受的输入,它会尝试生成不满足该属性的输入。</p><p data-type="paragraph"></p><p data-type="paragraph">举个具体的例子,我这个新解析器的属性与 oracle 一致。输入是一个 SQL 查询。这意味着 Hypothesis 将尝试找到一个 SQL 查询,使得我的新解析器与 oracle 的处理结果不一致。</p><p data-type="paragraph"></p><p data-type="paragraph">我必须告诉 Hypothesis 如何生成有趣的 SQL 语句,于是我(与 Claude 合作)编写了一个工具,基于 ANTLR 语法文件自动生成 SQL 生成器。我得承认,当编写一个新的 SQL 解析器竟然导致我还得为.g4 文件编写一个新的解析器时,我不禁会心一笑。后来,我还增加了一个步骤,用于在生成的 SQL 中添加额外的排列,比如交换 Token 或添加括号。</p><p data-type="paragraph"></p><h3 data-type="heading" data-id="heading5">针对脆弱修复的提示工程</h3><p data-type="paragraph">PBT 能够可靠地生成新的测试用例,我的开发循环也运行良好,但 Claude 却总是在做出脆弱的修复。例如,它会通过添加一个 Token 的前瞻来修复某个具体的问题,但随后又发现其实需要两个 Token 的前瞻。我经常遇到上下文窗口达到上限而不得不进行压缩的情况,因此我怀疑它只是“忘记”了实际的语法或参考解析器是什么样子。</p><p data-type="paragraph"></p><p data-type="paragraph">这可以通过一些基本的提示工程来解决。我只是告诉它在编写任何代码来修复特定的分歧点之前,立即将语法文件和相关的 C++源代码加载到上下文中。想通这一点,我花的时间比我愿意承认的要长。</p><p data-type="paragraph"></p><h3 data-type="heading" data-id="heading6">全力投入、绞尽脑汁</h3><p data-type="paragraph">此时,我希望在编写解析器时,既能让 CPU 始终满负荷运行 PBT,又能让 Claude 的推理任务保持满负荷状态,因此我编写了一些工具,让 PBT 能在后台持续运行,并将新失败的测试用例写入文件,而不是仅仅将其显示出来。这样,当 Claude 没有其他任务处理时,就可以调取这些测试用例。</p><p data-type="paragraph"></p><p data-type="paragraph">我还有其他几种生成失败测试用例的方法,比如从生产环境的查询日志中提取匿名查询。有趣的是,最有效的方法之一是告诉 Claude 在后台代理中“深入思考边缘情况”。</p><p data-type="paragraph"></p><p data-type="paragraph">这两个平行的解析器方法共享相同的回归套件,因此,在一个会话中发现的任何失败的测试用例都会与另一个会话共享。</p><p data-type="paragraph"></p><p data-type="paragraph">Hypothesis 还能帮你“精简”测试用例,将其转化为最简单的重现步骤,但我无法将其用于来自其他来源的 SQL 语句。对于这类情况,我改用了 ShrinkRay。</p><p data-type="paragraph"></p><p data-type="paragraph">后来,我添加了基于代码覆盖率的测试用例生成功能,这使得生成的 SQL 分布更加均衡。借助覆盖率反馈,生成器能够识别出尚未覆盖的语法结构,并有针对性地生成更多的这类用例。虽然这并非在生产数据集上达到 100%准确率的必要条件,但确实帮助我发现了一些非常微妙的测试用例。</p><p data-type="paragraph"></p><h3 data-type="heading" data-id="heading7">最后的迭代循环</h3><p data-type="paragraph">最终,我的迭代循环是这样的:</p><ul data-type="bulletedlist"><li data-type="listitem"><p data-type="paragraph">从 PBT、真实语料库、回归测试和“深入思考边缘情况”生成新的失败测试</p></li><li data-type="listitem"><p data-type="paragraph">将这些失败案例的精简版本添加到不断扩充的回归测试列表中</p></li><li data-type="listitem"><p data-type="paragraph">深入思考最佳修复方案,尽可能采用通用解决方案,并查阅语法规则和 C++源代码以了解参考解析器是如何处理该问题的</p></li><li data-type="listitem"><p data-type="paragraph">实施修复,并生成一段简要的总结供人工操作员阅读</p></li><li data-type="listitem"><p data-type="paragraph">运行回归套件,确保一切测试均通过</p></li><li data-type="listitem"><p data-type="paragraph">自动重新运行循环</p></li></ul><p data-type="paragraph"></p><p data-type="paragraph">由于新解析器的运行速度快很多,所以我可以在生产环境中以“影子模式”运行这个循环,同时继续使用现有的 C++解析器,并报告是否存在任何差异。</p><p data-type="paragraph"></p><p data-type="paragraph">与生产环境的查询日志进行对比时,我之前只测试过约 5 万个查询。在影子模式下,我能够快速测试数百万次解析,而且未发现任何偏差。我原本计划让它运行几天,但结果如此理想,以至于仅过了几个小时,我就将生产流量切换到了影子模式(同时启用了 0.1%的“反向影子”)。</p><p data-type="paragraph"></p><h2 data-type="heading" data-id="heading8">一个快 454 倍的解析器及未来展望</h2><p data-type="paragraph">现在,它生成的输出(AST+源代码位置)与基于 C++ ANTLR 的解析器完全一致,而性能结果(黄色部分)简直令人难以置信:</p><p data-type="paragraph"></p><p data-type="paragraph"></p><p data-type="paragraph">新解析器的基准测试结果</p><p data-type="paragraph"></p><p data-type="paragraph">在生产环境查询中,其速度平均比之前的解析器快了 454 倍。标题中提到的“70 倍”来自我在笔记本电脑上进行的基准测试,但在生产环境中,我们主要解析的是比较长的未命中解析器缓存的 SQL 语句。</p><p data-type="paragraph"></p><p data-type="paragraph">这对我来说是一次进步。用几天时间就完成一项专业人员可能需要花费数月才能完成的工作,这让我倍感自信。</p><p data-type="paragraph"></p><p data-type="paragraph">虽然我并没有亲手编写任何代码,但我绝不会把这称为“凭感觉编写的代码”。我的 PBT 配置基于语法文件生成输入,并采用覆盖率来引导生成过程,这在解析器模糊测试领域已经相当接近最先进水平。</p><p data-type="paragraph"></p><p data-type="paragraph">这对 ANTLR 这类工具来说意味着什么?这个问题确实很有意思。我猜测,像我这样使用基于 AI 的方法将成为一种新常态。解析器生成器将提供 oracle,然后大型语言模型(LLM)会利用 PBT/模糊测试并通过“手工”调整来构建性能更高的解析器,使其与 oracle 的处理结果相匹配。</p><p data-type="paragraph"></p><p data-type="paragraph">我最终得到了什么?从形式上讲,我的新解析器是一款“手写”的、以预测性递归下降为主体的解析器。它搭载 Pratt 表达式核心,配备一个 LL(2)游标(在特定位置通过有界且不消耗资源的前瞻探测进行扩展),并针对少数需要决策的情况,保留了局部有序选择式试探回溯能力。它完全由 Claude Opus 4.7 生成,使用 Rust 语言编写,于 2026 年 5 月开发完成。</p><p data-type="paragraph"></p><p data-type="paragraph">原文链接:<a href="https://posthog.com/blog/sql-parser" title data-type="link">https://posthog.com/blog/sql-parser</a></p>