
Opik 后端 ClickHouse 查询性能验证实战把这条 SQL 看着很贵变成 reviewer 可决策的数值【免费下载链接】comet-llmDebug, evaluate, and monitor your LLM applications, RAG systems, and agentic workflows with comprehensive tracing, automated evaluations, and production-ready dashboards.项目地址: https://gitcode.com/GitHub_Trending/co/comet-llm导读本指南来自 comet-llm 仓库Opik为开发者沉淀的查询性能验证方法论用于在合并任何 DAO 查询改动之前先量化这条查询在生产规模数据上到底要付出什么代价——通过只读方式访问真实环境直接测量或复活 Testcontainers 实例并把数据集外推到 20k / 500k / 1M 实体规模。阅读本文后你将掌握一套可复现的测量、等价性验证、证据采集与报告流程能够为每个查询子句给出带测量数据的change改/ keep留/ callers call调用方定夺裁决并知道如何用EXPLAIN、system.query_log等 ClickHouse 原生证据支撑结论。一、方法论的总原则测量永不推测整套流程的目标只有一个把这条查询在规模化之后会很贵的直觉变成 reviewer 可以直接行动的数字。每条结论都必须绑定一个测量值——如果结论为假这个数字就会不同。产出物是对每个子句的裁决change / keep / callers call并附上对应测量同时把失败结果也写下来避免后人重复踩坑。具体而言遵循七条铁律测量永不推测Measure, never infer每个论断都需要一个数字支撑且该数字在论断为假时会发生变化。等价性闸门先行Equivalence gate first在确保某个变体与它要替换的查询在所有被测数据形态下返回相同结果之前它的任何成本数字都不可引用。只要查询定义了顺序——任何带ORDER BY的查询以及任何分页查询顺序决定哪些行落在页面上——比较就必须包含顺序只有当结果真正没有定义顺序时才允许无顺序比较。一个更快但回答的是另一个问题的查询不是优化。等价性只存在于候选查询与它自己的变体之间候选查询相对main常常就是要改变结果的main只是成本参照不是结果参照。每次运行采集完整画面延迟、峰值内存、CPU 时间以及扫描量parts、granules、marks、rows read。前三项是系统真正付出的成本扫描数字是解释为什么前三项变了的证据。一个变体可能读的行更少但内存、CPU 更贵、墙钟时间不变——所以没有任何单一数字能独立做决策。至少两种数据形态Two data shapes minimum排名会随数据密度和倾斜度翻转在一种形态上的胜利只是假设。每个变体只改一个变量One variable per variant包括直接删除某个子句来检验它是否配得上它付出的成本。≥5 次运行报告 p50、p90、p95 和 min小于波动区间的差异不是差异尾部分位数tail正是轮询型端点最容易感知疼痛的地方。尾部分位数的可信度取决于运行次数——如果你要基于 p90/p95 做决策就多跑几次而不是止步于 5 次。写下你没测到的东西Write down what you could not measure配额、无法构造的数据形态、缓存状态。这些 caveat 界定了结论的适用范围。二、要验证、不要轻信的假设这类工作的典型失败模式是自信地相信一个引擎根本没实现的机制。以下假设必须逐一用探针验证被引用 N 次的 CTE 可能被求值 N 次。命名 CTE 不会被物化。探针方法同样的查询引用 1 次对比引用 2 次比较 marks 与峰值内存。计划节点数不等于求值次数。IN (SELECT …)的集合经常被急切构建在计划里只显示为一个字面量如x in 35297-element set而没有读取节点——所以数ReadFromMergeTree节点会低估工作量一个主导子查询可能完全不可见。EXPLAIN会执行标量子查询和集合子查询以解析索引条件它既不免费也不能当作计时代理。读的行更少 ≠ 更快而且剪枝集合本身也消耗内存。存在索引 ≠ 使用了索引——要看读取的 granule 数而不是看 schema。在一种形态上有效的剪枝在另一种形态上可能是死重包括那些用合成数据 benchmark 出来的剪枝。容器数据 ≠ 生产形态parts 数、每个 key 的版本数、基数、数组密度都不同。只读角色本身会改变实验行读取上限row-read caps会中止长查询受限 profile 可能拒绝你的测量所需的设置。三、完整工作循环七步拿到可引用数字第 1 步界定问题Frame it明确是哪个端点、哪些调用点call sites。一个常量查询同时服务列表页和按 id 查询时行为可能完全不同因为绑定参数不同。还要记录调用频率、查询受什么约束CPU、内存、扫描量。第 2 步渲染每个调用点的 SQLRender the SQL从候选分支和main分支分别渲染出每个调用点实际执行的 SQL。这一步的完整方法论包括如何把 DAO 里的 Java text block 渲染成可执行 SQL、两个必须排除的失败模式、以及一个仓库级隐患见下文第四节方法论文档见 .agents/skills/query-performance/rendering.md。第 3 步测量前先盘点查询Inventory the query读一遍查询并写下来驱动该查询的实体entity每个 CTE 和子查询以及各自被引用的次数哪些子句是剪枝prune删掉它不影响结果vs 承重load-bearing子句每个去重dedup、集合构建set build、聚合aggregation和连接join。这个盘点清单告诉你哪些探针值得跑——没有它你测的是你以为的查询而不是你实际拥有的查询。第 4 步获取环境Get an environment优先只读访问真实环境否则复活 Testcontainers 并外推规模。详见第五节方法论文档见 .agents/skills/query-performance/environments.md。第 5 步先打基线Baseline both候选分支和main各跑 ≥5 次每个调用点都捕获计划同时记录数据形态。两者之差就是这次改动的成本它本身就是一条发现——而且往往是最重要的一条。第 6 步定位成本Locate the cost见第六节instrumentation。单独测量可疑子查询同时测量端到端。两者之间的差距本身就是发现提示重复求值、集合被多次构建、意外 join 顺序等结构性问题。第 7 步按清单逐个变量击破Work the inventory, one variable at a time对第 3 步清单中的每个子句问它花了多少成本配不配具体动作剪枝子句→ 做消融ablation直接删除被重复引用的子查询→ 做引用计数探针reference-count probe去重与聚合步骤→ 尝试替代写法昂贵的谓词→ 尝试更便宜的写法。对每个变体先声明你预期会动的指标过等价性闸门在两种数据形态和每个调用点上测量然后快速决定留还是丢——无论成败都保留数字包括失败的。四、把 DAO 的 SQL 渲染成可运行形式Opik 后端的 DAO 查询位于 apps/opik-backend/src/main/java/com/comet/opik/domain/它们是 Java text block携带两类不能直接执行的语法StringTemplate 指令if(x)…endif、filters等r2dbc 命名参数:workspace_id。所以要拿到可运行 SQL必须在 Java 之外渲染——为一个调用点解析掉指令、代入参数。两个硬性要求每个调用点一份渲染一个常量同时服务列表页和按 id 查询时绑定的参数不同可能一个被优化了、另一个反而退化。绑定取值必须来自 Java 代码里TemplateUtils.newST(…)附近的bind(/ifPresent(… bind …)不能靠猜main分支渲染同一份常量这样基线就是当前生产查询本身。两个必须先排除的失败模式残留的:param它会静默变成另一条查询——所以要大声失败fail loudly而不是假装没问题用错误的参数集渲染了某个调用点。另外做重构验证时对比渲染结果而不是对比源码一个只改注释或改格式的编辑在剥离注释行后必须产生逐字节相同的查询。仓库级隐患只有--的行这是一个真实的坑查询内部某一行如果只有--会让ClickHouseParameterizedQuery停止替换后续的:params于是真实端点会以Code: 62 Syntax error … :workspace_id失败——尽管你的渲染看起来完全正常。渲染通过不等于端点可用务必警惕。渲染与模板设计的呼应渲染方法论与 DAO 侧的查询构建规范是一体的在 .agents/skills/opik-backend/clickhouse.md 中明确规定查询文本必须在 DAO 之间通过提取模板共享而不是给调用方留%s槽位——因为谓词是否落在spans/traces主键上做剪枝这件事必须在模板声明处可见而拼接进来的字符串会隐藏剪枝是否还发生。模板示例WHERE workspace_id :workspace_id if(project_id) AND project_id :project_id endif if(project_ids) AND project_id IN :project_ids endif// 按项目调用方如 ProjectMetricsDAO template.add(project_id, true); statement.bind(project_id, projectId.toString()); // 按工作区调用方如 WorkspaceMetricsDAO template.add(project_ids, true); statement.bind(project_ids, projectIds.toArray(new UUID[0]));五、获取能回答问题的环境查询的成本是它运行在其上的数据的属性。首选对真实环境的只读访问真实的 part 数、倾斜度、基数否则复活 Testcontainers——这是测量还不存在的规模的唯一方式。每一条数字都要记录用的是哪种环境。方案一真实环境只读只读就是只读不允许INSERT、OPTIMIZE、SYSTEM命令、清缓存。如果测量需要这些操作它就属于容器。刻意选择数据形态选查询所驱动实体的最大实例外加一个密度不同的实例警惕行读取/内存配额在配额处中止的查询等于没被测量如果基线也在配额处中止那本身就是发现脱敏写报告时不要把 ids、hostnames、数据库名和角色名写进去形态用规模数字描述。方案二复活 Testcontainers 实例后端集成测试已经构建了一个 schema 正确的 ClickHouse 并灌入了真实 fixture 数据但它随 JVM 一起消亡。复活流程如下让容器活下来。ClickHouseContainerUtils.java 的静态块里注册了一个 shutdown hook停止所有容器并关闭网络——把它注释掉。容器复用已经在代码里请求了withReuse(true)但还需要在~/.testcontainers.properties中设置testcontainers.reuse.enabletrue。两步都仅限本地提交前必须还原 Java 文件。运行能触达该查询的最窄测试——每次mvn调用只跑一个会迁移 ClickHouse 的测试类否则第二个迁移会以REPLICA_ALREADY_EXISTS失败详见 .agents/skills/opik-backend/testing.md。它的 fixture 就是你的分布样本如果没有测试覆盖这条查询就写一个最小的。连接到存活容器在映射出的原生端口上每次运行都会变用户default数据库opik对应ClickHouseContainerUtils.DATABASE_NAME。这里的 admin 权限能解锁真实环境拒绝的操作日志 flush、缓存 drop、停止 merge、INSERT。外推到 20k / 500k / 1M 个驱动实体按问题选择布局要跨规模对比计划形态把每个规模放进各自的 workspace id各阶梯共存一份渲染可以指向任意一个要跨规模的增长曲线给每个规模独立的 rig独立 database 或容器。原因是 workspace id 隔离不了的东西它能分隔结果、能让前缀剪枝生效但共存的规模仍共享 parts、分区和 merge 历史所以物理数字是在同时装着所有规模的表上测出来的绝对 parts 和 granule 总数只在同一个 seeded 状态内可比。参数要从 fixtures 推导而不是凭空发明且每个规模只 seed 一次容器跨运行复用重复 seed 不会覆盖任何东西——在确定性 id 下它只是给每个 id 追加一行。这行变成什么取决于去重步骤按整个 key分组的结果而不只是 id重复完整 key → 查询看到另一个版本膨胀你原本锁定的去重深度key 的其它维度变化 → 得到不同的 key膨胀实体数。两者都是漂移需要不同修法第 5 步的数量检查正是用来区分它们的。重跑测试是另一种危害fixture helper 通常每次运行都新建 workspace 和新 id所以重跑给共享表增加体积和 parts而不是给已有 key 加版本——两种都会扭曲测量只有第一种扭曲去重深度要知道你在看哪种。某个规模需要重建时seed 进新的 workspace id而不是重复执行且不要清查询表——外推依据的 fixtures 就住在那里。先验数据再验形态。先确认 fixture 符合预期——实体数、每个逻辑 key 的行数、活跃 part 数——因为漂移的 fixture 是不可见的它之后的每个数字都是错的。数原始存储行raw stored rows并限定在你 seed 的 workspace 内去重后的计数对重复 seed 产生的额外版本恰好是盲的会把漂移的 fixture 验成干净的。行数和版本深度是精确的part 数不是后台 merge 和插入顺序会移动它——测量时按住 merge或者把 part 数当作数量级而非等值来读。然后对比容器与真实环境的EXPLAIN indexes 1确认剪枝行为一致同样的条件到达索引、同样的 key 前缀被命中、同样的 skip index 被触发或被忽略。绝对 granule/mark 数在不同数据量下不会一致所以要比行为不要比比例。剪枝行为不同 seed 错了修好再继续。外推必须保留的属性以下每一项都会移动执行计划缺一不可fanout每个实体的子行数每个逻辑 key 的行数——去重步骤为它买单part 数用作剪枝的列的基数倾斜度——保证最坏情况的实体存在数组与字符串密度相对于主键的插入顺序。id 必须唯一且理想情况下可复现这样某个规模可以在新的 workspace id 里从零重建。六、采集证据读什么、每个工具回答什么先让运行可比给每次运行打标签以便之后从system.query_log聚合不同变体的运行log_comment就是挂钩关闭结果缓存容器里才能 drop caches暖对暖比较比较 CPU 效率时固定线程数——墙钟时间无法区分更少的工作和更多的并行端点做负载均衡的话跨副本聚合否则不同 host 的波动会淹没效应。EXPLAIN家族工具回答什么关键观察点EXPLAIN indexes 1剪枝裁决第一个要看的东西每个读取选中的 parts/granules 对表总数的比例比值 剪枝质量、哪些条件到达了索引、搜索算法——binary search表示命中主键前缀generic exclusion search表示没有——以及哪些 skip index 跑了一个没消除任何 granule 的 skip index 是死重而不是保护EXPLAIN PLAN actions 1每个步骤实际做什么filter/aggregation/join 的相对位置成本不在读取而在读取之后做什么时使用EXPLAIN PIPELINE执行形态节点多路并发以及流在哪里收窄到单线程并发瓶颈EXPLAIN ESTIMATE引擎自己的行/mark 估计与真实发生值对比EXPLAIN QUERY TREE/SYNTAX分析器如何重写查询CTE、谓词被怎样处理当重写结果与你假设不符时system.query_log每次运行的成本记录duration跨运行聚合为 p50/p90/p95 加 min——尾部是轮询端点感知的痛点、峰值内存一等公民不是脚注、rows read、ProfileEvents。值得记住名字的事件SelectedParts/SelectedRanges/SelectedMarks剪枝证据user / system CPU time任何非零的External*事件发生 spill 意味着内存压力改变了算法修法是内存而不是墙钟时间。其余事件在某个数字需要解释时逐个排查。执行器日志与追踪Executor logssend_logs_level trace按顺序显示真正运行了什么——key 条件、选中的 parts 和 marks、集合创建、聚合方法、每个阶段的行进出。这是暴露急切构建的集合和重复求值的地方而计划会隐藏它们。system.text_log有同样的行但在容器外通常被禁用。system.trace_log当成本来自逐行表达式工作array lambdas、JSON、字符串与 UUID 转换、哈希时定位 CPU 去向。需要查询 profiler 和 introspection 开启所以通常只有容器里有。system.parts、parts_columns、data_skipping_indices存储侧——扫描 part 数背后的 part 总数、读成本背后的列大小、以及存在哪些索引这与是否被使用不是一回事。五类探针每个探针隔离一个变量产出数字而不是观点等价性闸门Equivalence gate——引用任何变体的成本之前确保它和基线返回相同结果相同行、相同内容跨所有列做无序比较且在每个被测形态上都做。隔离可疑项Isolate the suspect——单独测可疑子查询 端到端测。差距大 结构性问题重复求值、集合被多次构建、意外 join 顺序。引用计数探针Reference-count probe——同一查询子查询被引用 1 次 vs 2 次。marks、rows、峰值内存翻倍 被求值两次无论计划显示什么。消融Ablation——删除一个子句。结果不变 → 它是剪枝测量决定它在这种形态上配不配结果变了 → 它是承重的不可交易。形态与调用点扫描Shape and call-site sweeps——同一变体跨数据形态、跨渲染后的调用点。排名翻转就是发现不是麻烦。症状 → 怀疑方向速查表观察看什么Granules 读取 ≈ 表总数条件不在主键前缀上、key 周围有非单调表达式或根本没有剪枝扫描了很多 parts分区剪枝或 part 数本身merge、插入批处理Marks 高、返回行数低缺失或无效的 skip index谓词应用得太晚行数正常、内存高集合或 join 构建侧、宽GROUP BY/DISTINCT投影、limit 前的排序非零External*内存压力下的 spillCPU ≫ 墙钟时间、行数不多逐行表达式成本行数相同、墙钟时间不同集合构建、join 构建顺序、线程竞争——固定线程重测隔离子查询便宜、端到端贵重复求值或按引用物化去重/聚合主导每个逻辑 key 的行数运行间的波动 变体间的差异还没有结论——多跑、报告波动七、裁决改动留还是丢把四个测量维度并排放置——延迟p50、p90、p95、min、峰值内存、CPU 时间、扫描/读取量parts、granules、marks、rows——覆盖每个形态和每个调用点从全集决策保留Stays在调用点所约束的至少一个维度上改善且其它维度在所有被测形态和调用点上都没有实质回退丢弃Drops任一维度实质回退而调用点关心的没有任何改善——包括扫描量变小的情况。用更少扫描换来更多内存/CPU 是一笔交易而在共享集群上内存通常是更稀缺的资源调用方定夺Callers call维度真正冲突时延迟更好、内存更差或排名在形态/调用点之间翻转时。把两组数字都摆出来、点名这笔交易而不是默默替调用方选择不改No change所有差异都在运行间波动之内——保留更简单的形式并说明该变体被测过、没有差别。让扫描数字完成它们的职责它们应当解释你测到的延迟、CPU 和内存。如果解释不了——扫描持平但内存翻倍或行数下降但 CPU 上升——你还没找到机制裁决尚未就绪。八、报告没有 before/after 表格的裁决只是观点无论你在审 PR 还是开 PR每条论断都必须配一张before/after 表格——每个变体或每次修订一行每个测量维度一列variantp50 msp90 msp95 msCPU mspeak MiBpartsgranules (marks)rows readmain……………………candidate (before)……………………with the change (after)……………………规则扫描列不可省略它们是让其它三列可解释的关键要把剪枝证据带进表格而不是只带总量每个调用点、每种形态各一张表表格注明形态实体数、运行次数先摆改动本身相对main的成本按调用点百分之几的调参收益压不过端点实质变贵如果这个成本不可接受就直接说并点名真正能推动它的杠杆然后按子句逐个汇报change替换方案、原因、before → after 表、keep试了什么、被哪个数字否掉、callers call两个选项、点名的交易最后是等价性证据和 caveats不要收录你没测过的优化不要收录你没验证过的机制。九、与 opik-backend 的 ClickHouse 约定联动这套性能验证方法论与后端 DAO 侧的既定模式互为表里测量出的数字只有结合这些约定才有意义去重是常态多数表用 ReplacingMergeTree 建模更新即插入多版本行共存到后台 merge。查询侧有两种去重写法——LIMIT 1 BY id读列少、未合并版本多时占优与FINAL表合并良好、结果集大时占优详见 .agents/skills/opik-backend/clickhouse.md。测量时要注意LIMIT 1 BY下可变列如status的过滤必须放在去重之后的外层查询否则会读到过期/幽灵行只有不可变列workspace_id、project_id、id、created_at和单调列last_updated_at可以在去重前过滤。Skip index 与 FINAL默认截至 CH 25.3FINAL会忽略 skip index需要SETTINGS use_skip_indexes_if_final1大表避免裸FINAL用单调列 minmax 索引做时间限定time-bounded FINAL。这正是存在索引 ≠ 使用了索引的仓库级实例。两阶段分页 延迟宽列大型 traces/spans 查询用轻量page_idsCTE 只分页 id 排序键page_wide再按 id 边界 LIMIT 1 BY id回读整行宽文本列input/output/metadata用EXCEPT延迟。分页预过滤必须携带排序键否则自定义排序会翻错页——测量这类查询时要格外检查排序键是否被剪枝/去重正确传递。测试护栏排序/分页/字段排除类的 SQL 改动必须覆盖整页内容断言、自定义/动态sort_fields、排序 × 排除组合且 spans 和 traces 都要测见 .agents/skills/opik-backend/testing.md 的 Sorting / Pagination / Field-Exclusion SQL Changes 一节。每次mvn调用只跑一个会迁移 ClickHouse 的测试类两个同类测试类在同一 reactor 中运行会导致第二个迁移以REPLICA_ALREADY_EXISTS失败——这既影响测试隔离也是复活容器做性能实验时的硬约束。十、把方法论固化到日常这套流程.agents/skills/query-performance/SKILL.md适用于三种典型场景建议作为合并门禁的常规动作DAO 查询发生改动时——无论改动多小先渲染、盘点、打基线端点变慢时——用症状 → 怀疑方向速查表快速定位是剪枝失效、part 数膨胀、集合/join 构建还是逐行表达式成本reviewer 问这条查询在规模化下要多少钱时——直接给出带 before/after 表格的裁决而不是我觉得还行。配套文档的完整集合位于仓库的 .agents/skills/query-performance/含 rendering.md、environments.md、instrumentation.md 三份子文档与后端约定文档 .agents/skills/opik-backend/clickhouse.md 和 .agents/skills/opik-backend/testing.md 配套阅读。记住一句话总结任何没有数字的裁决都是观点任何没有等价性闸门的数字都不可引用任何没有扫描列的表格都无法解释。【免费下载链接】comet-llmDebug, evaluate, and monitor your LLM applications, RAG systems, and agentic workflows with comprehensive tracing, automated evaluations, and production-ready dashboards.项目地址: https://gitcode.com/GitHub_Trending/co/comet-llm创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考