扇出是地板:两个针对「没人能控制的查询形态」的定向优化
英文版:English
这篇在整体里的位置
分析平台的重建分三层,而三层解决的问题性质不同。
迁移层换掉了地基:从 ClickHouse 换到存算分离的 Apache Doris。不是因为 Doris 更快 —— ClickHouse 当时还更快 —— 而是因为 ClickHouse 快的那个前提条件失效了。它的性能依赖排序键和事先对查询形态建模,而当越来越大比例的查询由 AI agent 产生时,没有人能事先知道形态。
弹性计算层在上面建了控制面:执行前评估每条查询的成本、在 plan 期判定它 heavy 还是 light、给重尾一个物理隔离的 compute group、并让这个 group 从零地板按需供给。这一层不让任何查询变快,它决定每条查询落在哪里,并让那个地方在需要时存在。
这篇文章是第三层,也是三层里唯一真正让一条查询变便宜的那层。
为什么优化必须是「定向」的
SQL 文本、负载构成、查询形态,全部是我们控制不了的输入,而且其中越来越大的一部分由产品功能和 AI agent 生成,不是人手写、再经过 review 的。在这里「让调用方写更好的 SQL」不是一个偏弱的方案 —— 它在架构上根本不存在。
所以剩下的唯一杠杆,是让调用方可能写出的那些形态本身在结构上变便宜。但这不能等于什么都优化,于是选择判据才是关键:
一种查询形态值得下手,是因为它的成本里大部分是可避免的扇出。打浪费,不打必要成本。
两种形态符合判据,值得并排写出来,因为它们浪费的地方不同:
| 形态 | 什么是浪费 | 什么不是 |
|---|---|---|
| 非分桶列上的点查 | 为了拿一行打开 566 个 tablet | 真正含那行的那 1 个 tablet |
| 几百列的宽读 | 串行读它的列文件 | 那些列文件本身 |
这就是为什么第二个不是「优化 SELECT *」。宽读之后并没有少读列,它只是不再一次一个地读。
那次重设成本模型的测量
第一反应是减少扫描行数,于是加了 bloom filter。扫描行数降了 73%,墙钟时间纹丝不动 —— 因为那个用户的行散在 2,044 个文件里。这个结果没有优化任何东西,它否掉了成本模型:货币不是扫过多少行,而是必须打开多少个独立 IO 单元、以及它们能不能重叠。
镜像的那一半从反方向撞过来:3,727 列的 SELECT * 实测冷跑 253 秒,而 planner 的字节体量估算(行数 × 平均行宽)判它 light。一个高估、一个低估,同一个病根 —— 估算器对扇出结构性失明。
而扇出有两个正交的轴,这也是为什么有两个优化而不是一个:tablet 轴可以剪掉,列轴剪不掉、但可以重叠。
轴一:剪掉 tablet 扇出
Doris 自带的每一种索引 —— zonemap、bloom filter、倒排、short-key、主键 —— 都是tablet 局部的。它们只能回答「这个值在不在你已经打开的这个 tablet 里」。当表不按被过滤的那列分桶时,点查除了打开每一个 tablet 别无选择。缺的是一个全局结构,它回答的是另一个问题,而且更早:在打开任何 tablet 之前,哪些 tablet 可能含 X。
它在 fork 上落成的形状:每个 rowset、每个被索引列一个 bloom filter,作为自校验的独立文件写在该 rowset 的 segment 旁边(对象存储上)。plan 期 FE 提取 =/IN 谓词,把探测值按与写路径逐字节相同的方式编码,然后请每个相关 BE —— 每 BE 一个 RPC,不是每 tablet 一个 —— 测它自己那些 tablet 的 bloom。全部 miss 的 tablet 被丢掉,而候选集合永远与 planner 自己的 tablet 集合求交。64 个 tablet 的 demo 表剪到 1 个(probes=1, degraded=0);不存在的值剪到 0 个。
它的代价,如实写出来
| 代价 | 量级 |
|---|---|
| 写路径 | 每个值多一次 hash 加插入,消费的是 writer 本来就在产生的列值流 —— 零额外 IO |
| 存储 | 全表规划量级 10–20 GB,而不是最初估的 6–8 GB,因为 bloom 的位数向上取到 2 的幂;4 BE 下实测每 BE 3–4 GiB |
| plan 期延迟 | 在点查预算里多一次网络往返,上限 500ms;冷 BE 上可能真付一次对象存储停顿才降级 |
| 缓存预算 | 索引块住在 file cache 的 INDEX 队列,而这个队列是从装列数据的那个队列里切出来的 —— 见下一节 |
| compaction 耦合 | 正常 compaction 通过共享 writer 路径免费重建 bloom,但ordered-rowset 快路径直接 link segment、不走 column writer,于是它的输出静默地完全没有索引,除非显式处理 |
| 生命周期 | 这些新的独立文件不在垃圾回收的枚举路径里,不修就会在对象存储上永久泄漏 |
为什么假阳性可接受,漏判永远不可接受
假阳性的代价是多扫一个 tablet:有界,而且由一个旋钮预算。漏判的代价是丢掉一个真的含数据的 tablet:无界,而且返回错误结果。这两个代价形状不同,所以设计只朝一个方向倒 —— 只多扫,绝不漏。每条失败路径都退化成「这个 tablet 必须扫」:描述符缺失、bloom 读不出、尚未 backfill、RPC 超时、或者 BE 的本地视图落后于查询钉住的快照版本。响应字段刻意叫 candidate 而不是 pruned,于是 BE 侧任何遗漏都自然表现为「多返回了候选」(安全),而不是「多剪了」(错误)。
只有一条语义路径能突破这个保证:字节编码不一致。如果谓词字面量在 planner 类型强制之后,编码方式与存储值不同,探测就会漏掉一个真实存在的值。所以任何编码不确定都意味着不剪:隐式 cast、CHAR(n) 补空格、datetime 精度、窄化转换,以及直接禁掉的 DECIMAL(scale 差异会破坏逐字节相同)。代价是真实的 —— 这些列永远吃不到收益,受影响的查询会静默失去剪枝、不报错。但这个取舍是对的:损失收益可以补救,编码错一次不能。
demo 时看不见的那个结构性代价
假阳性预算必须定义成每 tablet的,而不是每 bloom 的,因为一个 tablet 会按 rowset 累积 bloom:B 个 bloom、每个单 bloom 率 p,tablet 级的并集假阳性率是 B·p。而 B 不是一个配置旋钮 —— 它是 compaction 跟不跟得上的函数。
| B(每 tablet 的 bloom 数) | tablet 级假阳性 | 期望误扫 tablet |
|---|---|---|
| 1 | 1% | ~6 / 566 |
| 5 | 5% | ~28 / 566 |
| 50 | ~40%(并集界已饱和) | ~226 / 566 —— 剪枝实际上已经停止工作 |
所以这个索引把查询性能耦合到了 compaction 的健康度上。这是它引入的长期负担,值得主动讲出来,因为它在 demo 里看不见,在一个季度后很明显。
我们真正付出的那个代价:一块缓存,两个工作集
索引块和列数据争的是同一块本地 file cache,而这个竞争在构造上就是零和的:INDEX 队列按容量百分比配置,而装列数据的那个队列是剩余量。给一边的每一点都是从另一边拿的 —— 而另一边恰好是那个 43 秒宽读要吃的。
| 索引侧 | 列数据侧 | |
|---|---|---|
| 工作集 | 每 BE 3–4 GiB;单 BE 独占时最坏约 14 GiB | 单次宽读 441 个 1 MiB 块 |
| 常驻时的价值 | 探测 0.03 秒,而剪掉一个 tablet 省掉的是它全部的下游 IO | 每个块省一次约 97ms 往返 |
| 未命中的代价 | 每文件约 100ms,花在硬性的 500ms plan 期预算里;预算爆掉,整个剪枝被放弃、退回全扇出 | 线性变慢 |
| 失效曲线 | 悬崖 | 斜坡 |
结论来自曲线的形状,不来自偏好:INDEX 队列要按「单 BE 最坏工作集」配,不是按平均值 —— 给悬崖留余量,让斜坡去承受挤压。而且两边都必须在设定这个数之前算清楚。配小了,一次性的冷读成本会变成永久、不收敛的对象存储账单。
那次事故:「预热过一次」不等于「现在还在缓存里」
有一个 daemon 在 BE 启动时把它的索引文件预热进缓存,好让冷探测打在本地磁盘而不是对象存储上。它维护一张「BE id → 上次启动时间」的表,启动时间没变就跳过。它守住的是错的不变式。
preprod 上三天里发生的事:4 个 BE 在灌数;为了做单机查询性能测试,集群缩到单 BE,daemon 给它预热了一次 —— 这是它这辈子收到的唯一一次;两天的灌数与 compaction 产生了过去任何预热都没覆盖的新索引文件;集群扩回 4 个 BE,而三台重新加入的 BE 拿到了全新的 BE id。然后手工查了一次:
| BE | 已缓存 | 缺失 | 队列放错 |
|---|---|---|---|
| 三台重新加入的 | 各 386–405 | — | — |
| 那台从未重启的 | 0 | 313 | 73 |
三台新 BE 在 daemon 眼里是新的,于是被正常预热。原来那台保留了 id 且从未重启,于是从那唯一一次预热之后每一轮都被跳过,针对它的每一次探测都变成了一次冷的对象存储读。
放大器是容量。缓存 sizing 那一步被跳过了,INDEX 队列停在 5% 默认值;而在单 BE 那段窗口里,那一台独自持有全部 tablet 的 bloom —— 约等于它平时足迹的四倍,这个量本身就超出任何合理的 5% 预算。它把自己的预热成果挤掉了。
关于这件事,有三句话必须一起说:
- 正确性全程没受影响。 每条失败路径都退化成过扫。这是纯粹的性能与成本问题,完全符合设计。
- 它是静默的。 没有告警、没有报错、没有降级标记 —— 是手工跑脚本查出来的。常驻性从来不是一个可观测量。
- 修复顺序本身就是那个判断。 先把缓存配够,再上任何修复机制。否则一个「发现缺失就重读」的机制,在预算小于工作集的前提下,会在每一轮都重读,把一次性成本变成永久成本。
修复用「常驻性驱动的回路」替换了「进程预热过一次」,并被一条硬约束统辖:对一个完全常驻的 BE 做一次 sweep,必须发出零次对象存储 GET。 这一条要求就淘汰掉了大部分显而易见的设计。
| 被否的修复方案 | 为什么 |
|---|---|
| 每轮无条件重预热所有 BE | 违反零 GET 规则;永久的带宽浪费 |
| 让每个 BE 自己 sweep,不要协调者 | BE 不知道自己的一致性哈希归属,会漏掉因成员变化被移到它身上的 tablet —— 正是这次事故的场景 |
| 协调者走 HTTP 检查常驻性 | 每次 sweep 上百次调用,而且要复制缓存内部逻辑 |
| 把索引块 pin 住,永不驱逐 | 它把这些块从预算核算里摘了出去,于是配错容量这件事变得不可见 |
| 只做队列扩容,当成配置事故处理 | 作为第一阶段是对的,单独不够 |
而那几个计数器 —— 检查了多少、常驻多少、修复多少、失败多少 —— 是作为 metric 上线的,因为这次事故的第一条教训就是:常驻性必须在不依赖人想起来去查的前提下可观测。
那个让整个特性变成空转的 bug
在上面这一切之前,plan 期剪枝对普通查询静默地从未触发。两个独立成因,都在 planner 里:
它读的那张谓词表在那个时点上永远是空的,因为唯一填充它的代码路径是普通查询不会走的短路点查路径。而「那就在节点初始化时填一下」也修不了:翻译器会先递归进子节点 —— 在那里创建并初始化 scan node —— 之后才把谓词挂上去。这不是偶然,是「自顶向下访问、自底向上构造 fragment」的固有顺序。初始化那一刻,谓词列表按定义就是空的。
更糟的是,那个天真修法是不安全的。在初始化期剪枝会钉住它自己的快照版本,而这个版本早于查询真正读的那个版本(后者是在更晚的地方统一钉的)。版本单调,于是被剪的 rowset 集合可能是查询实际读取集合的真子集 —— 违反 superset 不变式,静默地少返回行。一个性能 bug 被修成了一个正确性 bug。
真正的修法是把剪枝移到「查询真实读版本被计算出来」的那个点,也就是整个 plan 翻译完之后。它顺带把每分区的元数据调用降到零次额外调用,把 BE 探测 RPC 从「每分区每 BE 一次」降到「每 BE 一次」。
类别比修法更重要:这是一个静默的、依赖顺序的 no-op。它编译通过、正常运行、不抛异常、不打日志、就是永远不触发。它的单测能过,是因为那个测试绕开了整条调用链,只在隔离环境里测编码逻辑。验证必须覆盖调用顺序,不只覆盖函数正确性。
轴二:把列扇出重叠掉
对宽读来说,那些列文件不是浪费 —— 它们就是工作本身。浪费的是一次一个地做。
一次这样的读,实测 baseline:43.45 秒墙钟,其中 98.6% 是远程 IO,441 次对象存储 fetch。显然的说法是「每列一次串行读」。读代码读出来是最多三次,而且有严格顺序:
| # | 读什么 | 偏移来自 |
|---|---|---|
| 1 | ordinal index page | segment footer —— 免费 |
| 2 | data page | ordinal index —— 依赖第 1 次 |
| 3 | dictionary page(字典编码列,本表占 56%) | segment footer —— 免费 |
只有第 2 次有数据依赖。 第 1 次和第 3 次的字节偏移在 segment footer 解析完之后就已经在内存里了,所以它们可以立刻发出、并行发出、不需要任何前置 IO。整个设计都是这一个事实的推论。
于是:在串行循环跑之前,先并发烘热每一列的 meta page,并提前打开每一列的缓存窗口 —— 按缓存块对齐去重,丢给本来就存在的预取线程池。串行循环本身一个字节都不改;等它走到第 N 列时,第 N 列的块已经是本地的了。
正确性是结构性论证而不是靠测试:预取任务是写进 null 目的地的 dry-run 读,它不解码、不改动任何 reader 状态;它们跑在一个只做这一个操作的池子上;队列满或任何失败都退化成「循环像今天一样同步读它」;而一个正在被拉取的块处于 downloading 状态,并发读者会等它而不是再发一次 GET。把两个烘热步骤删掉,结果必须逐位相同。
它的代价,以及它在哪里停止工作
预取池是 BE 级的,它的宽度就是节流阀:默认 worker 数下、单查询约 441 个块,并发的宽点查会排队而不是把对象存储并发翻倍。这是有意的。特性默认关闭,并且按列数和行数设了门槛,保证扫描型查询完全不受影响。两个被接受的代价:一个总开关意味着两个烘热步骤不能独立回滚;以及这个改动动了 compaction 也会走的代码。
一个看起来不重要但其实很重要的澄清:烘热步骤本身不是零 IO。它之后的那些 index load 照样阻塞 —— 只是阻塞在热缓存上。它消掉的是串行化,不是那些读。
而它在 array 列上停止工作(本表占 2.5%,刻意跳过):array 的 item page 根本无法预取,因为需要哪些 item page 取决于还没读出来的 offset。覆盖率是 97.5% 的列(按表自己的 schema 实测),而且 array 的 page 大体上还是被顺带烘到了,因为它们夹在被预取的标量列中间。
验证状态,直说
这一层的收益是推算的,不是实测的。 设计与实现完成;数字是在实测 baseline 上做的算术。它和一个可声称的结论之间还隔着两件事:
baseline 本身必须重建。 它测在一个早于本设计所扩展的那套预取子系统的版本上,所以必须在目标版本上、把开关关掉重测一次 —— 而那一次很可能更慢,因为它会暴露 iterator 初始化时的数千次串行 index load,而那恰好是这个改动要消掉的东西之一。
而我原本想要的验收指标在这个设计下不可计算。 预取走的是 dry-run 读,query id 与缓存统计都被置空,所以 profile 里的远程 IO 计数会朝零掉,而墙钟同时在掉:IO 没有消失,它只是不再被归因了。我没法用「每次 fetch 的延迟没变」来证明「我并行化了而不是搬运了工作量」。替代验收是数提交出去的预取任务数,以及比对前后写进缓存的总字节数(应当持平)。
杀掉我自己的第一版实现
预取的第一版是一条自包含的路径:自己的 stage 枚举、自己的 iterator 方法、自己的块折叠算术、自己的配置开关,摆在读循环前面。它已经提交了。两天后,我把它整个折进了本来就存在的那套预取子系统,然后删掉。
理由是机械的,不是审美的。两者在 data page 上重叠,而且各自独立推导了同一套块算术 —— 两个提交者对着同一块缓存,两套互不知情的上限,没有共享去重。而真正新的那个想法(烘热 meta page)该待的地方,独立版本到不了:它应该在既有子系统自己的逐列 index load 之前,而那个位置也顺手治好了那个子系统在宽表上最糟的行为。
值得留下的部分是当时记下来的那个反转条件:如果这件事必须发在当前已部署的版本上(那里这套子系统不存在,复用意味着跨几十个文件 backport 上千行),那么保留两条路径才是对的。这个答案完全取决于关于目标版本的一个事实 —— 而这种东西应该被写下来,不应该靠记。
被否决的方案
| 方案 | 为什么不 |
|---|---|
| 在共享元数据存储里放一份「值 → tablet」的精确倒排 | 几百 GB 加数十亿次写,压在别的系统也依赖的存储上。零假阳性确实更好;保留为可替换后端,如果哪天精确性成为硬需求 |
| 一张路由表加 tablet hint | 最强的务实替代,值得作为 baseline 去量。没选是因为 tablet id 在 split、merge、schema change、restore 之后都不稳定,而且需要双写与新鲜度窗口 |
| 每 tablet 一个可变的聚合 bloom,取代每 rowset 一个 | 会重新引入这个设计里唯一能产生错误答案的问题:那个聚合是否已经吸收了刚提交的 rowset?按 rowset 分解在构造上消灭了它 |
| 转置或 bit-sliced 的全局结构 | 同样的新鲜度问题;标为 tablet 数涨到几万时的首选演进方向 |
| FE 内存里的剪枝目录 | 精确结构塞不进堆,于是为了省一次缓存读要引入一个新的有状态服务 |
| 按被过滤列重新分桶 | 会摧毁现有每一条按用户查询所依赖的局部性,而且要重写数十亿行。不改物理布局是既定约束 |
| 把 bloom 写进 segment footer | 读它就得打开 segment 文件,这否掉了「在打开数据文件之前剪枝」的全部意义 |
Takeaways
- 扫描行数是代理指标,扇出才是地板。 行数降 73% 而墙钟零变化,是「你在优化错的量」这件事最便宜的证明。
- 当你不能约束输入时,剩下的唯一杠杆是让形态本身便宜 —— 但这时选择判据必须显式,否则「优化」会变成一个无限待办。打可避免的扇出,别碰必要成本。
- 比较代价的形状,不是它们的大小。 有界的代价可以用旋钮管理,无界的必须用架构消灭。这就是为什么多扫可以接受、漏判不行,以及为什么缓存预算要给那条悬崖而不是那条斜坡。
- 一个依赖后台维护的索引,会继承那份维护的健康度。 剪枝能力随每 tablet 累积的 bloom 数衰减,所以这个特性的价值是 compaction 跟不跟得上的函数。
- 缓存常驻性设计的典型失效方式是静默。 「预热过一次」对「现在还在缓存里」什么都没说,而且不会有任何东西 page 你。在把常驻性做成机制之前,先把它做成一个计数器。