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