引擎级查询路由:EXPLAIN ROUTE PLAN

英文版:English

为什么判定必须发生在执行之前

迁移把 serving 和分析收敛到同一套 Apache Doris 平台之后,重查询占比不到 1%,但一条被误放进共享轻查询池的重查询足以拖垮整个池子。事后弹性救不了这个场景:计算池扩容是分钟级操作,内存打爆是秒级事件。路由判定唯一安全的位置是 plan time,即任何 backend 碰到数据之前。

第一个问题是:原生 Doris 暴露的信号够不够。我搭了一条闭环可行性流水线(SQL 输入,解析 EXPLAIN 输出并分类,再真实执行作为 ground truth),套件 52 条 SQL,代表性做了三角验证:真实的约 1,900 列 schema、平台真实生产 SQL 模板、业务查询 taxonomy。结果:原生 EXPLAIN 路由准确率 45%,heavy-miss 率 27%,最坏一条漏判查询跑了 188 秒、吃掉 800 MB,约等于单台 backend 内存的 10%。

两个测量结果决定了设计方向。同一条 SQL 模板换参数(时间窗从 1 小时到 36 天扫过一遍),EXPLAIN 输出逐字节相同,而真实内存差 23 倍、真实耗时差 6 倍。另一条刻意打在最高基数键上的点查真实执行 32ms,EXPLAIN 却预测全表扫描。这个负载的本质是固定模板加参数摆动,成本随参数摆动 50 到 2,000 倍,静态 hint 在结构上无解:任何基于查询文本打标签的方案,看到的是同样的字节,对应的却是天差地别的成本。

怎么做:把优化器已经算出的数吐出来

这是 signal problem,不是 rule problem。Doris 的 CBO 在生成物理计划时本来就算出了每个算子的 cardinality、selectivity、平均行宽、scan bytes、sort/agg 状态大小,只是把它们折进一个不透明的 cost 数字后丢弃了。第一个改动在 Apache Doris fork 上完成:EXPLAIN ESTIMATE PLAN,一个只读 visitor(PlanEstimateCollector)遍历已定稿的物理计划,把这些算子级估算以结构化 JSON 输出。diff 是纯加法:零删除,不改 cost model 和统计模块(PR 形态 15 个文件,+1,310 行)。首个单测返回 estimated_filter_selectivity = 0.9867,恰好等于 4933/5000,证明这是暴露既有数学,不是新建估算逻辑。

EXPLAIN ROUTE PLAN 等于这份估算再加一步:把 15 条规则的 Python 参考实现 port 成 Java 分类器,返回 verdict JSON(label、confidence、目标 compute group、目标 workload group、transport),耗时 6-8ms,与数据量无关(13M 行和 102M 行都是约 8ms,因为估算读的是 CBO 缓存统计,零 I/O)。接入点严格沿前一个 feature 的缝走:ANTLR lexer 加一个 ROUTE token,parser grammar 的 planType 加一个 alternative,ExplainCommand 加一个 enum case,一处 token 到 enum 的映射,planner 的 explain 路径加一个分支,外加两个新类。全部约 21 个路由阈值声明为 @ConfField(mutable=true),通过 ADMIN SET FRONTEND CONFIG 热调,默认值等于 Python 常量,调阈值永远不需要 rebuild。

在生产规模的 preprod 集群上,canonical 套件的 heavy recall 从 7% 到 100%(15/15),light precision 到 86%;六条最重的真实生产查询(最大 76 GiB、约 30 亿行扫描)6/6 路由正确,零漏判。分类器被允许不完美:漏判的重查询会被运行时内存硬限拦住并升级重跑,那是另一层机制,见总篇 case study。

flowchart LR Q["SQL"] --> FE["Doris FE<br/>EXPLAIN ROUTE PLAN · 6–8 毫秒"] FE -->|light| L["serving 池<br/>固定槽位内存硬限"] FE -->|heavy| H["弹性重查询池<br/>spot · 0→N"] L -.->|"MEM_LIMIT_EXCEEDED 约 3 秒"| E["自动升级"] E -.-> H

重新 benchmark 时发现的两个成本模型盲区

第二轮 benchmark 抓到的两处误判,本质是同一种 bug:估算不是「偏了一点」,而是结构性看不见。

被过滤器夹住的 window 查询被当成全表扫。 一条带窗口函数的点查——WHERE user_id = X ... ROW_NUMBER() OVER (PARTITION BY user_id) LIMIT 1000——被判成 heavy。CPU 阻塞规则读的是原始的最大叶子扫描基数,于是某个用户的约 84 万行看起来像一个全表窗口,而实际上过滤器已经把窗口的输入夹住了,这条查询真实成本是 0.8 秒 CPU、峰值内存 12-28MB。修复是在 WINDOW 与 PARTITIONTOPN 算子上吐出 input_cardinality,CPU 规则改用这个信号,字段缺失时退回原来的基数。真正的全表窗口仍然路由 heavy。

宽表 SELECT * 被字节模型算成便宜。 输出体量按「行数 × 平均行宽」建模,会严重低估宽读的成本,因为宽读的代价不是字节数,而是从对象存储冷启动打开上千个列文件。生产规模实测:3,727 列的 SELECT * 冷跑 253 秒。修复是加一个 output_column_count——直接取 plan.getOutput().size(),这是结构事实而不是估算——再加一条可热更的阈值线(默认 200 列),把宽结果集判为 heavy,理由标 output_materialization,并把传输切到 Arrow Flight。两处修复都向后兼容:新字段不存在时规则退回原行为。

「为什么不直接改 SQL?」 这是个公道的质疑,而回答它正好就是这个项目的论点。3,700 列的表上写 SELECT * 当然是坏实践,我们也确实这么告知调用方。但一个平台不能把自己的安全性押在「每个调用方都守纪律」上,尤其是现在——越来越多的查询由产品功能和 AI agent 生成,而不是人手写、再经过 review。而且抛开任何人的 SQL 风格:一个把 253 秒的查询标成 light 的估算器,本身就是错的,这是成本模型的缺陷,它同样会给下一个陌生形态定错价。改 SQL 消灭一条坏查询,改分类器消灭的是「任何坏查询都可能落进那个按 20 毫秒点查规格建的池子」这一整类失效。前者是补救,后者是性质。

同一条推理后来推动了 v3 的简化:依赖 CBO 字节估算的规则被砍掉,换成结构信号——算子形态、输出列数、被夹住的输入——因为一个可信为事实的信号,胜过一个更好看但不可信的数字。

难点

跨语言 parity。 15 条规则原本是 Python 实现,port 后要跑在 FE 热路径的 Java 里。两份独立的策略实现一定会静默 drift,一个阈值 off-by-one 就会把重查询送进轻查询池。解法是把 Python 当可执行规范而不是文档:用 197 条真实估算 JSON 组成 golden corpus,同时喂两边,断言 label 逐条一致,在任何集群和镜像存在之前 197/197 全过。最脆弱的表面是容错 accessor 语义:null、-1、“unknown” 在两种语言里必须映射到同一个三态,"字段缺失"绝不能和"值为零"混为一谈,跨语言 port 最容易静默出错的正是这里。

build 成本倒逼验证分层。 QEMU 模拟 amd64 的 FE 全量 build,成功一次要 43 分 55 秒。这个约束逼出了分层验证策略:便宜且高信号的验证放最前(parity harness 用 standalone javac 对着编译产物 jar 跑,零 build 成本),昂贵且低信号的验证放最后(全量 build、镜像、集群冒烟)。之后原生 arm64 编译把 build 压到约 3 分钟,overlay Dockerfile 把镜像推送从 747 MB 压到 43 MB、3.8 秒。加上阈值热调,"改一个调参值"从 44 分钟 rebuild 变成一条 SQL。

parity 网兜不住的 bug。 parity 证明的是 Java 等于 Python,不证明规范本身正确。上线后暴露了一个 LIMIT 盲区:计划里任何位置出现 LIMIT 都会压制"扫描过大"的 heavy 信号,但 LIMIT 只约束 hash aggregation 这类 blocking 算子的输出,不约束底层扫描量。这个 bug 靠把分类器 verdict 与真实生产行为交叉验证抓到,修法是结构性的(blocking 算子不再豁免,纯 scan 加 limit 仍豁免),并作为新 fixture 反哺进 golden corpus。规范级 bug 需要生产行为交叉验证,corpus 负责让它不再复发。

判断什么不该 upstream。 Leadership 最初希望把分类器和估算机制一起贡献给上游。逐行读完分类器源码后,列出七个具体的业务耦合点:workload group 锚定的内存阈值、5,000 万行 heavy 判定线这类校准常量、recall 优先的风险姿态、公司特有的查询形状、私有 fork 字段。反驳用的是第一个 PR 自己的设计原则:engine emits data, caller decides policy,把 policy 塞进上游会自相矛盾。最终结果:EXPLAIN ESTIMATE PLAN 以干净的 8-commit PR 形态对着 synthetic base branch 准备 upstream,diff 恰好就是 feature 本身;分类器留在内部归档,不提议 merge。这个论证改变了决策。

Takeaways

  • 动引擎之前先量化缺口。真正承重的数字是 27% 的 heavy-miss 率(安全问题),不是 45% 准确率这个头条数字。
  • 优先暴露既有内部数学,而不是新建估算。零删除的 diff 本身就是风险论证。
  • 判断什么不该贡献也是贡献的一部分。mechanism 属于上游,校准过的 policy 不属于。