@posthog: https://x.com/posthog/status/2062595534381326421
摘要
PostHog 使用基于 Karpathy 的 autoresearch 的 AI 代理,发现其 ClickHouse 查询引擎中存在一个三年之久的漏洞,该漏洞阻止了时间戳过滤器正确使用主键。修复后,性能提升了 11%,扫描的粒度减少了 62%。
查看缓存全文
缓存时间: 2026/06/05 09:11
Karpathy 的 AutoResearch 在我们的查询引擎中发现了一个存在三年的 Bug(并将性能提升了 11%)
几周前,在里斯本的一次团队外出活动中,我们将一个 AI 代理指向了我们的查询引擎,输入了生产环境中的慢查询,然后让它运行了一整夜。
到了第二天早上,它已经发现了一件令人尴尬的事:近三年来,所有带有时间戳过滤器的查询都没有正确使用 ClickHouse 的主键。修复方法 使得 ClickHouse 在基准查询中需要扫描的颗粒数减少了 62%,并且让查询本身明显地更快了。
这篇文章将介绍我们使用的设置、Bug 本身,以及我们正在构建的、让这种分析自动运行的功能。
什么是 AutoResearch?
这个总体想法并非我们的原创。Andrej Karpathy 在 2026 年 3 月将其打包并命名:给一个 AI 代理提供一个虽小但真实的系统、一个基准和一个预算,让它循环运行;提出一个更改,运行基准测试,保留有帮助的内容,丢弃无用的内容。
Karpathy 让它针对一个深度为 12 的 nanochat 训练运行了两天,找到了大约 20 个能改善验证损失的更改,其中一些更改还迁移到了更大的模型上。这种形式并不新鲜(DeepMind 的 FunSearch(2023 年)和 Sakana 的 AI Scientist(2024 年)是更早的例子),但 Karpathy 的仓库小而具体,足以激励你在一个下午内构建出自己的版本。
对我们来说,有趣的是二阶效应:代理不会带着代码库中积累的偏见。对我们而言,toTimeZone() 包装一直都存在。那种你已经视而不见的代码。代理没有先验知识。它会运行每项诊断,阅读周围的 ClickHouse 和 PostHog 源代码以获取上下文,并以同样怀疑的态度对待存在了三年的表达式和昨天刚写的那行代码。
在黑客马拉松中为 ClickHouse 设置 AutoResearch
每年,我们都会在公司外出活动中举办黑客马拉松。现在的 PostHog(会话回放、数据仓库、日志等)很多都是这样开始的。在里斯本,为分析平台和查询性能团队举办的一次较小规模的联合外出活动中,我们的黑客马拉松项目就是做 Karpathy 做的事情,但针对的是 ClickHouse 查询性能。
我们使用的技术栈:
-
pi:由 Mario Zechner 构建的一个小型终端编码代理。它可以与你指定的任何 LLM 对话,提供一个小的 SDK,并且代码足够小,你可以阅读整个代码库。
-
pi-autoresearch:由 davebcn87 构建的一个社区扩展,将 Karpathy 的循环接入 pi。你给它一个目标、一个基线、一个基准测试命令和一个目标指标。它会迭代,提交每个候选方案,运行基准测试,并保持一个日志,以便运行在上下文重置后仍然有效。
-
一个战役编排合约,我们在 pi-autoresearch 之上编写了它。当单个 ClickHouse 查询有数百个可能的改写时,基本的“尝试某事,测量,保留或丢弃”循环过于松散。如果没有结构,代理可能会在查询的某个角落纠结直至放弃;有了它,你会得到一个更接近人类实际调查方式的东西。我们将每个调查结构化为四个部分:
-
一个战役,包含一个慢查询和一个 git 分支。
-
这被分解为通道,即与疑似瓶颈相关的优化方向:谓词排序、JSON 解析、时区处理、主键使用等。当通道不再产生信号时可以暂停,当它们实际上是两个想法时可以拆分,或者当来自不同通道的胜利可以组合时进行合并。
-
每个通道内有一个具体的、可测试的假设。
-
每个假设内有一个实验,包含一次运行、基准测试和结论。代理必须在每个实验之后进行明确的反思,而不是让循环只是登山式地爬坡。
-
一个一次性的 ClickHouse 测试集群:这保持了高迭代速度和可预测的基准测试数字。数据形状与生产环境相同,但经过匿名化处理,并在专用于代理的较便宜硬件上运行。在开发者的笔记本电脑上运行,对于有用的内循环来说太慢了;在生产环境上运行,则意味着要与噪声邻居抗争,并冒着干扰客户查询的风险。
范围缩小也很有帮助。当目标查询超时时,代理会将范围减半(30 天、14 天、7 天、3 天、1 天),直到它在 1 到 10 秒内完成,然后针对这个缩小的版本进行优化。这个窗口足够短,可以快速迭代,但又足够长,使得索引和分区效果仍然重要。当前最佳候选方案会定期在完整范围内重新测试;一旦它在完整范围内完成,战役就会“毕业”并回到原始查询继续。
在黑客马拉松期间,我们手动输入了过去曾抱怨过的慢查询,以及我们在 system.query_log 中手动找到的慢查询(我们现在正在自动化这个过程)。
发现我们静默损坏的主键
ClickHouse 之所以快,是因为它可以跳过不必要的工作。我们的 events 表是 PARTITION BY toYYYYMM(timestamp),主键是 (team_id, toDate(timestamp), event, …)。一个带有时间戳边界的格式良好的查询应该能让 ClickHouse 丢弃整个月的数据,然后在其必须查看的月份内直接跳到正确的周。
但这并没有发生。
当我们在 2023 年 4 月为 HogQL 添加了按团队的时区支持时,我们做了理所当然的事情,将每个对 timestamp 的引用包装在 toTimeZone(timestamp, team_tz) 中,以便显示的日期是正确的。但我们没有意识到的是 ClickHouse 查询规划器无法看透 toTimeZone()。这意味着它无法从 toTimeZone(timestamp, tz) >= ‘2024-03-01’ 推导出 toYYYYMM(timestamp) 的边界,因此分区裁剪失效了。同样,也无法推导出 toDate(timestamp) 的边界,因此主键被用于 team_id 和 event,但止步于此。
这件事之所以没有让我们感到警觉,是因为 ClickHouse 在 timestamp 上还有一个 MinMax 跳过索引。MinMax 索引存储每“颗粒”(默认 8192 行)中某列的最小值和最大值。当你将 toTimeZone(timestamp, tz) 与一个常量进行比较时,ClickHouse 仍然可以针对每个颗粒的 min/max 进行评估,并跳过那些范围不重叠的颗粒。这比分区分区裁剪弱得多,但它有效,所以查询并没有灾难性地慢,只是比应有的速度慢了一些。
这就是那种永远隐藏起来的 Bug。它很慢,但并非“让人惊慌”的慢。每个查询都受到影响,所以没有人能将其与一个“好”的版本进行 A/B 比较。而确凿的证据存在于 EXPLAIN PLAN indexes=1, json=1 的输出中,除非有人已经怀疑某事,否则没人会运行这个命令。
在其中一个通道中,autoresearch 循环运行了 EXPLAIN。它注意到 Partition: Condition=‘true’(即没有裁剪),所以尝试了两件事:
-
添加带有裸时间戳边界的 indexHint()。
-
重写比较,使得字段一侧是裸的,而常量携带时区。
第二种方法获胜了,而且优势明显,这就是我们发布的:
语义是相同的,因为 toTimeZone() 只改变显示元数据:底层的时间戳没有变化。规划器现在看到了一个裸的 timestamp,可以正常工作了。
在针对生产环境中一个真实团队的 7 天漏斗查询上(load_balancing=‘in_order’,这样每个变体都命中同一分片,各运行五次,取中间三次的修剪均值):
速度提升在日期范围较短的查询上最为明显,因为这时分区裁剪最有效。在 7 天范围内,如果规划器配合,你可以丢弃大部分分区。更宽的范围无论如何都必须查看更多分区,因此相对收益会缩小:一个 90 天的查询仍然更快,但不会快 37%。颗粒减少在每个范围上都是真实的;只是当绝对上需要扫描更多颗粒时,它转化为更小的墙上时钟改善。
这个 Bug 自从时区更改上线以来就一直存在。大约三年了。
下一步:无需黑客马拉松来完成这项工作
在外出活动期间,我们是手动将慢查询输入给代理的。这无法扩展。我们现在正在构建的流程更接近你真正想要的:
-
从 system.query_log 中提取慢查询。 执行此操作的编排器位于 products/query_performance_ai/orchestrator/slow_queries.py。
-
为每个候选查询启动一个沙箱,使用与我们运行 PostHog Code 相同的沙箱,PostHog Code 是我们的编码代理和产品编辑器(目前处于测试阶段)。
-
在每个沙箱中运行 pi-autoresearch,每个沙箱都有自己的基准测试目标和预算。
-
让一个 LLM 对建议进行去重,并为每个存活的创意启动一个 PostHog Code 会话。 不同的沙箱通常会得出相同的想法,因此 LLM 在分派之前会合并这些想法。PostHog Code 针对真实代码库编写实际更改,包括测试和基准测试。
-
将生成的 PR 发布到我们的团队 Slack 频道,以便人工审查和合并。
如果这能奏效,“代码库中的某些查询没有正确使用主键”将成为系统在我们都睡着时自动发现的事情,而不是需要三年时间和一次团队外出活动才能揭露的事情。一旦结果真实可靠,我们会写下二阶结果。
与此同时,这个方案并不限于慢查询。如果你的系统中有一个你一直默默忍受的指标(速度、内存、成本、准确性、错误率,任何可以量化的事物),构建一个你可以廉价运行并且不介意对其粗暴使用的测试框架,将一个代理指向它,然后看看它返回什么。
作者:Robbie Coomber,PostHog 全栈工程师
相似文章
我重写了PostHog的SQL解析器,速度提升70倍,而几乎没看代码
PostHog的工程师使用了多次长时间运行的Claude Code会话,重写了他们的SQL解析器,相比之前基于ANTLR的解析器实现了70倍的加速,而他们自己几乎没看代码。
毫秒必争
这篇博客文章详细介绍了渐进式优化,以将ClickHouse查询延迟从超过85秒降低至亚秒级,包括对分区键的修改、连接消除和激进合并。
Making Postgres 300x faster for analytics: batching, operator fusion, and SIMD
pgrust 0.2 rebuilds the Postgres query engine with batching, operator fusion, and SIMD, achieving 300x faster analytical queries and 30% faster OLTP than Postgres.
@_avichawla: Cloudflare如何在不离开Postgres的情况下将查询时间缩短35倍:他们的Postgres表达到数十亿行,而每次时间范围查询开始变慢。
Cloudflare使用TimescaleDB(Tiger Cloud)在数十亿行的Postgres表上将查询性能提升了35倍,并借助Claude Code和Tiger CLI构建了一个实时地震仪表板。
@Greptime: GreptimeDB 的扁平格式查询现在可以在任何列上预过滤——标签、字段、时间戳——而不仅仅是主键。而…
GreptimeDB 的扁平格式查询现在支持在任何列(标签、字段、时间戳)上预过滤,而不仅仅是主键,性能提升高达 4.5 倍。此外,mito2 存储引擎移除了其遗留扫描路径,清理了约 1800 行代码。