blob: d253c8a294341864d870f7762dfde4dc07e311f6 [file] [view]
# Window Function 非流式窗口 Spill 开发方案
> **核心结论:**第一阶段只为“必须等到整个 partition 结束后才能产生结果”的窗口计算提供
> spill。能够在输入过程中持续产出结果、且工作集大小不随 partition 总行数增长的流式窗口,
> 不进入 spill 路径,继续使用现有内存执行器。
## 1. 背景
当前 Analytic Operator 会保留原始输入 Block,同时把窗口函数参数、`PARTITION BY`、
`ORDER BY` 以及 RANGE 边界表达式的结果追加到累计 Column 中。对于非流式窗口,只有发现
partition 结束后才能开始计算和输出,因此当前 partition 的数据不能提前释放。
当以下任一场景形成超大 partition 时,内存占用会随 partition 大小线性增长,并可能触发
OOM:
- 没有 `PARTITION BY`,全部输入属于同一个 partition;
- `PARTITION BY` 数据严重倾斜;
- frame 覆盖整个 partition,例如
`ROWS/RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`;
- `ntile`、`percent_rank`、`cume_dist` 等函数需要先获得 partition 行数或 peer group
完整信息。
现有 Analytic Operator 没有实现 `revocable_mem_size()` 和 `revoke_memory()`,也没有设置
`_spillable`。因此 `enable_spill` 打开后,内存仲裁器仍然不能回收 Analytic Operator 占用的
partition 数据。
Doris 已具备可复用的 spill 基础设施:
- `SpillFile` / `SpillFileWriter` / `SpillFileReader` 支持 Block 序列化、分片文件、顺序读取和
按 Block 索引 seek;
- Pipeline Operator 已有 revocable memory 和 revoke 回调;
- Sort、Aggregation、Hash Join 已提供 spill 的 Profile、文件生命周期和错误处理范例;
- `enable_spill`、`enable_force_spill`、`min_revocable_mem` 和
`spill_buffer_size_bytes` 可以直接复用。
## 2. 术语和范围
### 2.1 流式与非流式的定义
本方案不直接以当前实现中的 `_streaming_mode` 布尔值作为 spill 判定条件,而使用更明确的
执行属性:
- **流式窗口:**在 partition 尚未结束时即可持续输出,并且所需历史数据由固定 frame、
固定 look-ahead 或常量状态界定,不随 partition 总行数增长。
- **非流式窗口:**在 partition 结束前不能产生当前 partition 的正确结果,必须保留或重放
partition 数据。
当前 `_streaming_mode` 更接近“是否可以在 partition 结束前输出”。它不必然代表严格的常量
内存,例如很大的 `ROWS N FOLLOWING` 或跨越大量行的 peer group 仍可能保留较多数据。这类
问题属于流式执行器的工作集优化,不纳入第一阶段 window spill。
### 2.2 第一阶段范围
第一阶段仅处理满足以下全部条件的 Analytic Node:
1. `enable_spill = true`;
2. 窗口执行需要等待完整 partition;
3. Node 中每个窗口函数都声明了明确的 spill capability;
4. spill 算法能够在有限内存内通过一次顺序收集和一次重放完成,并保持现有结果语义。
首期支持矩阵:
| 类别 | 首期函数/Frame | 执行方法 |
| --- | --- | --- |
| 整个 partition 的归约 | `sum`/`sum0`、`count`、定长状态的 `min`/`max`、`avg`,Frame 为 `UNBOUNDED PRECEDING ... UNBOUNDED FOLLOWING` | Sink 收集时按原始顺序得到最终状态,重放时向每行填充同一结果 |
| 依赖 partition 行数 | `ntile` | partition seal 时已知总行数,重放时按全局行号计算 bucket |
| 依赖 partition 行数和 peer group | `percent_rank`、`cume_dist` | Sink 收集时生成可流式读取的 peer group 元数据,重放时计算结果 |
同一个 Analytic Node 中存在任一不支持的函数时,首期整个 Node 回退到现有内存路径,不进行
部分函数 spill,避免一份 partition 同时由两套生命周期管理。
### 2.3 非目标
第一阶段明确不处理:
- 已经可以稳定流式执行的窗口;
- 任意 RANGE offset frame;
- `lead`、`lag`、`first_value`、`last_value`、`nth_value` 的通用 spill;
- Java/Python UDAF 和没有声明 spill capability 的聚合函数;
- 通过序列化并 merge 多个部分聚合状态来实现通用窗口 spill;
- 需要从 Arena 持续分配替换值的变长 `min`/`max` 状态;
- 对一个 partition 做并行归约或改变输入行的累计顺序;
- 将整个已 spill partition 一次性重新加载到内存。
这些限制必须反映在 Profile 中。遇到不支持的函数时不能宣称窗口已经获得 spill 保护。
## 3. 设计目标
### 3.1 功能目标
- 超大非流式 partition 的输入数据可以被回收并落盘;
- spill 前后结果与现有内存执行逐行一致;
- 支持在 partition 中途收到 revoke 请求,后续数据直接追加到同一个逻辑 spill 文件;
- 支持一个输入 Block 内包含多个 partition 边界;
- Source 按原始顺序、按 Block 输出,不把完整 partition 恢复到内存;
- spill 文件在正常完成、失败、取消和 Local State 提前关闭时都能回收。
### 3.2 资源目标
对于已经 spill 的 partition,峰值内存只由以下有界工作集构成:
- 一个写入 staging buffer;
- 一个读取 Block;
- 一个输出 Block;
- 有界窗口状态;
- 有界 peer group 元数据 buffer。
内存上界不能再与 partition 总行数相关。`revocable_mem_size()` 只能上报 revoke 后确实能够释放
的字节,不能把不可回收的函数状态或正在被 Source 使用的 Block 计入其中。
### 3.3 兼容性目标
- `enable_spill = false` 时完全保留现有执行路径;
- 流式窗口完全保留现有执行路径;
- 不支持 spill 的非流式函数保持现有行为;
- 不新增 FE 规划语义,不改变排序、partition 分布和窗口结果类型;
- Block 边界允许变化,但行顺序、行数、值、NULL 语义和浮点结果必须保持一致。
## 4. 总体架构
### 4.1 执行路径选择
Analytic Operator 在初始化完成后构造一组不可变的 spill capability 信息。该信息包含:
- 窗口是否需要完整 partition;
- 每个函数的 spill strategy;
- 窗口参数的标量值;
- 结果类型和 nullable 转换信息。
执行路径选择如下:
```text
enable_spill = false -> 现有 Analytic 路径
窗口可流式执行 -> 现有 Analytic 路径
非流式,但任一函数不支持 spill -> 现有 Analytic 路径
非流式,且全部函数支持 spill -> Partition Store + Spill Evaluator 路径
```
不要继续扩大 `PARTITION_FUNCTION_SET` 这类按函数名维护的分支。应在聚合函数接口中增加默认
为“不支持”的 capability,由具体函数或 nullable adapter 显式声明,避免 Operator 根据字符串
推断函数语义。
建议的 capability 形态:
```cpp
enum class WindowSpillStrategy {
UNSUPPORTED,
PARTITION_REDUCE,
NTILE,
PEER_GROUP,
};
```
capability 声明函数的 spill 执行策略。`NTILE` 是专用策略:要求一个正整数常量桶数,并使用
partition 行数与行位置执行 NTILE 分桶公式,不代表所有依赖 partition 行数的函数都能复用。
最终是否可 spill 还必须结合 frame 类型判断。例如 `sum` 只在首期支持整个 partition 的 frame,
不因此获得任意滑动 frame 的 spill 能力。
Nereids 会把没有 `ORDER BY` 的 `OVER` 子句(例如 `sum(v) OVER (PARTITION BY k)`)改写为
`RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`。此时 partition 内所有行互为 peer,
现有执行器也必须等到 partition 结束才能输出,因此 BE 在没有 order by 表达式时把该 frame
视为整个 partition frame;带 `ORDER BY` 时它仍是流式窗口。
### 4.2 Sink 和 Source 职责
```mermaid
flowchart LR
U[上游已按 partition/order 排序的 Block] --> S[Analytic Sink]
S --> M[表达式物化、边界检测和有界状态更新]
M --> P[AnalyticSpillBatchStore]
P -->|内存未超限| I[In-memory Pages]
P -->|revoke 或主动阈值| W[SpillFileWriter]
I --> Q[Sealed Batch Queue]
W --> Q
Q --> R[Analytic Source]
R --> B[Replay: 逐 Block 生成结果]
B --> D[下游 Operator]
```
**Sink 负责:**
- 只计算一次窗口参数、partition key 和 order key;
- 检测 partition 边界;
- 按原始行顺序更新有界归约状态,并生成 peer group end 元数据;
- 将原始输出列写入 `AnalyticSpillBatchStore`;
- 响应内存 revoke,将当前 partition 的内存页写入 spill 文件并真实释放容量;
- 一个输入 Block 处理完后,把其中结束的所有 partition 合成一个 batch,关闭 writer 并发布
不可变的 batch descriptor(`AnalyticSpillBatch`)。
**Source 负责:**
- 按队列顺序获取 sealed batch;
- 从 descriptor 读取每个 partition 的边界、最终归约值和 peer group 元数据;
- 按原始顺序重放输入;
- 每次 `get_block()` 最多向下游返回一个输出 Block;
- 在 partition 消费完成后释放内存页、reader 和 `SpillFileSPtr`。
Sink 在每次 `sink()` 调用中只顺序处理当前输入 Block,不会因超大 partition 长时间占用一次
调度;Source 负责按需重放,可以直接利用下游拉取形成反压,不需要 Sink 一次性产生整个
partition 的结果 Block,也避免现有按 Block 数量缓存输出时无法精确控制字节数的问题。
### 4.3 Shared State
`AnalyticSharedState` 在 spill 路径中增加:
- 一个按输入顺序排列的 sealed batch descriptor 队列;
- 队列锁和 Sink/Source dependency;
- Sink EOS 状态;
- Sink/Source 共用的 spill execution mode 标记。
执行错误继续通过 Operator 的 `Status` 和 pipeline cancellation 传播,不在 Shared State 中维护
第二套错误状态。队列只允许一个 Sink 调用产生的批次滞留,因此首版也不额外维护队列字节数。
首期只允许一个 `sink()` 调用产生的 batch 处于队列中。一个 `sink()` 调用最多发布一个
batch:该输入 Block 内结束的所有 partition(包括从前面 Block 延续过来的 partition)合并成一个
descriptor,因此高基数小 partition 不会产生逐 partition 的 descriptor、聚合状态重建和 1 行
输出 Block。batch 的内存总量受单个输入 Block 和主动阈值约束。Source 清空队列后才唤醒
Sink。已经 spill 的 descriptor 仅持有文件句柄,不保留完整输入数据。
descriptor 发布后不可修改。Sink 必须先关闭 `SpillFileWriter`,再将 descriptor 放入队列;
Source 不得读取仍有 active writer 的 `SpillFile`。
## 5. AnalyticSpillBatchStore
### 5.1 原始 Block 与旁路状态
存储 Block 只保留上游原始输出列:
```text
[原始输出列]
```
窗口参数、partition key 和 order key 在当前输入 Block 上临时物化。partition key 只用于
Sink 检测边界;order key 只用于增量检测 peer group;`ntile` 的常量参数仅在首个非空输入上
求值,每个函数保存一个标量,并随每个 batch 传递一次,不按 partition 重复保存。
临时列不会进入 spill Block,也不会泄漏给下游。
### 5.2 partition 边界
上游已经保证 partition/order 排序。Sink 复用现有 Column 比较语义检测相邻行是否属于同一
partition,并保留上一批最后一行的 key 作为跨 Block 比较基准。
一个输入 Block 可以被切分成多个 row range:
1. 以 Block 内最后一个 partition 的起始行为界,把前面的 row range 一次性追加到当前 Store;
2. 按 partition 依次更新归约状态和 peer group 元数据,每个 partition 结束时把结果和行边界
追加到 batch 元数据,并 `reset` 聚合状态;
3. 若本 Block 内有 partition 结束,seal 整个 batch 并创建新的 Store;
4. Block 内最后一个 partition 作为 open partition 追加到 Store。整个 Block 属于同一个
partition 且不需要按字节切分时直接接管该 Block,不复制。
因此 Store 中已结束的 partition 只在处理单个输入 Block 期间存在;revoke 时 Store 只包含
open partition。
切分必须保持 NULL、字符串、Decimal、复杂类型和 nullable wrapper 的现有比较语义。不能为
spill 重新实现一套相等判断。
### 5.3 内存和磁盘状态
Store 的状态机:
```mermaid
stateDiagram-v2
[*] --> InMemory
InMemory --> InMemory: append page
InMemory --> Spilling: revoke / proactive threshold
Spilling --> Spilling: flush staging pages and append new pages
InMemory --> SealedMemory: partition end
Spilling --> SealedSpill: partition end and writer close
SealedMemory --> Consuming: Source dequeues
SealedSpill --> Consuming: Source dequeues
Consuming --> Finished: replay complete
Finished --> [*]
```
一个已 spill partition 首期对应一个逻辑 `SpillFile`。`SpillFileWriter` 可以在物理文件达到
阈值后自动生成多个 part,因此不会产生单个无限大的物理文件。writer 在 partition 中途
revoke 后保持打开;后续输入按 `spill_buffer_size_bytes` 聚合成小批次继续写入,在 partition
结束时统一 close。小 Block 先合并进写缓冲区,达到 `spill_buffer_size_bytes` 才序列化写盘;
本身已达到该大小的 Block 先刷出缓冲区再直接写入,以保持行序。revoke 和 seal 时会强制刷出
缓冲区,缓冲区计入 revocable memory。落盘后不再按主动阈值重复触发 spill,否则每次追加都会
刷出缓冲区。由于读回的 Block 可能远大于 `batch_size`,Source 重放时按 `batch_size` 切片输出。
一个 batch 对应一个逻辑 `SpillFile`,其中可以包含多个 partition。超过主动阈值、被内存
仲裁器 revoke,或 seal 时仍达到 `spill_analytic_sink_mem_limit_bytes` 的 batch 会落盘,避免发布后失去
revoke 入口。小 partition 随所在输入 Block 的 batch 一起发布,不会单独产生 spill 文件。
## 6. Spill 触发和内存回收
### 6.1 Operator 接口
仅 spill-eligible 的非流式路径设置 `_spillable = true`,并实现:
- `revocable_mem_size()`:返回当前 partition 内存页和可释放 staging page 的实际 allocated
bytes;低于 `spill_min_revocable_mem()` 时返回 0;
- `revoke_memory()`:创建 writer、写出当前所有页、清空页容器并释放 Column capacity;
- `get_reserve_mem_size()`:包含下一批表达式物化、Block 切分和 spill 序列化所需的峰值临时
内存。
`revoke_memory()` 返回成功后,相关 revocable bytes 必须接近 0。只把计数器减为 0、但保留
Column capacity 或仍被共享指针持有,不算完成内存回收。
### 6.2 主动阈值
建议新增与 Sort/Aggregation 对齐的查询选项:
```text
spill_analytic_sink_mem_limit_bytes
```
默认值 64 MB,并限制在 1 MB 到 4 GB。行为如下:
- 第一次 spill 前,内存仲裁器可以通过 `revoke_memory()` 触发 spill;
- 当前 partition 超过主动阈值时,Sink 主动触发 spill,避免在无全局压力时仍无限增长;
- partition 已经进入 spill 状态后,staging bytes 达到 `spill_buffer_size_bytes` 即写盘;
- `enable_force_spill` 用于测试强制覆盖 spill 分支,不新增测试专用用户语义。
### 6.3 峰值内存模型
假设主动阈值为 `L`、spill buffer 为 `B`、执行 Block 最大内存为 `R`,一个并行实例的主要
上界为:
```text
未 spill 且 Sink/Source 并行:约 2 * L + O(R)
已 spill:约 2 * B + O(R + bounded function state)
```
实际实现需要用 MemTracker/Profile 验证,而不能只根据容器的逻辑行数推算。若单个上游
Block 已大于 `B`,写入前应按 row range 切成接近 `B` 的子 Block,避免序列化时同时保留超大
Block、PBlock 和压缩 buffer。
## 7. 分函数执行算法
### 7.1 整个 partition 的归约
适用:`sum`/`sum0`、`count`、定长状态的 `min`/`max`、`avg`,且 frame 为整个 partition。
执行过程:
1. Sink 为每个函数维护一个跨 Block 的聚合状态;
2. 每批按原始行顺序更新状态,同时将原始输出列写入 partition store;
3. partition seal 时取得最终值并写入不可变 descriptor;
4. Source 重放原始列,并将最终值填充到该 Block 的每一行。
不能把 partition 划分为多个部分状态后再 merge。特别是浮点 `sum`/`avg`,merge 会改变加法
顺序和舍入结果。首期必须保持与现有 `add_range_single_place()` 相同的逐行累计顺序,并为
浮点、Decimal 和 nullable 输入增加精确结果对照测试。
函数只有在满足以下条件时才能声明 `PARTITION_REDUCE`:
- 状态大小有界,或状态自身另有经过验证的 spill 实现;
- 分 Block 顺序更新与当前整列更新语义完全一致;
- 重复读取最终状态不会破坏状态;
- NULL 和空 partition 行为与现有窗口实现一致。
`collect_list`、精确 distinct、可能随输入增长的 percentile 状态等不能因为支持
serialize/merge 就自动加入该能力。
变长 `min`/`max` 的现有单值状态在每次出现更优值时从 Arena 分配新空间,旧空间在 partition
结束前不能回收,因此首版不声明该能力;后续只有在实现状态 compact/rebase 后才能开放。
### 7.2 `ntile`
partition seal 时 descriptor 已记录总行数 `N`。Source 重放时维护从 0 开始的全局行号,按
现有大 bucket/小 bucket 公式计算结果。bucket 参数必须沿用现有常量参数检查和 NULL 语义,
不能在 spill 路径增加新的隐式转换。
FE 保证 bucket 参数在翻译执行计划前已折叠成正整数字面量,因此同一函数在所有 partition
中的 bucket 数相同。Sink 用 `_spill_ntile_bucket_counts` 缓存桶数,descriptor 用
`ntile_bucket_counts` 按函数索引保存一维数组,Source 不再按 partition 索引读取参数;
参数载荷由 `O(函数数 × partition 数)` 降为 `O(函数数)`。
`ntile` 不需要额外预扫描输入内容;若 descriptor 已有准确行数,可以直接进入 Replay。
### 7.3 `percent_rank` 和 `cume_dist`
两个函数同时依赖 partition 总行数和完整 peer group。为避免一个超大 peer group 本身造成
内存增长,不能在 Source 中缓存整个 group。
Sink 在当前输入 Block 的 partition 范围内自适应查找 order key 边界,并生成紧凑的
peer group 元数据。大组使用完整 key 的二分查找,范围首尾 key 相同时直接跳过该范围;
观察到高基数的小组后切换到线性扫描。partition 边界检测复用同一查找逻辑,统计只计入
已完成的完整分组,不将跨 Block 的片段误算为多个小组。查找到当前范围末尾不代表 peer
已经结束;跨 Block 继续比较上一块末尾 key,最后一个 peer 由 partition 结束操作封口。
```text
PeerGroupEnd = end_row
```
元数据先在一个有界 buffer 中累计,达到 `spill_buffer_size_bytes` 后写入辅助 `SpillFile`。
Replay 阶段同时顺序读取输入 Block 和 `PeerGroupEnd`,当前 group 的 start 由上一 group 的 end
推导:
- `percent_rank = (rank - 1) / (partition_rows - 1)`,单行 partition 返回 0;
- `cume_dist = peer_group_end / partition_rows`;
- 一个 group 跨越多个输入 Block 时复用同一组计算结果,不缓存 group 的所有原始行。
如果一个 Node 同时包含 `percent_rank` 和 `cume_dist`,两者共享同一份 peer group 元数据。
### 7.4 后续函数
第二阶段可增加:
- 整个 partition frame 下的 `first_value`、`last_value`、`nth_value`:预扫描定位目标值,重放
时填充;必须覆盖 `IGNORE NULLS`、负 offset 和 nullable 默认值;
- 需要固定 look-ahead 的函数:优先完善流式有界 ring buffer,而不是引入 partition spill;
- 通用 UDAF:只有在定义了状态大小、序列化、merge 顺序以及 bit-exact 要求后才能支持。
## 8. Source 协作式执行与反压
归约和 peer group 检测随 Sink 输入 Block 增量完成,因此 Source 不需要在一次 `get_block()`
中同步预扫描完整文件。Source 使用以下状态:
```text
WAIT_PARTITION -> OPEN_READER -> REPLAY -> FINISH_PARTITION
```
- Sink 每次调用只处理当前输入 Block;
- REPLAY 每次最多返回一个输出 Block;
- Source 取走 descriptor 后通知 Sink 可以继续写入队列;
- descriptor 队列已满时 Sink dependency 进入 blocked;
- Sink EOS 且队列和当前 Source partition 都为空时,Source 才返回最终 EOS。
该模型同时解决 CPU 调度公平性、输出反压和结果 Block 大量堆积的问题,也避免了同一
partition 的二次预扫描 I/O。
Source 的 `get_reserve_mem_size()` 只为下一次 `get_block()` 真正会执行的磁盘读取预留内存:
打开一个已落盘文件的 reader 会分配该文件最大序列化记录的读缓冲,读取一条记录会反序列化出
一个新 Block,两者都约为 `spill_buffer_size_bytes`。即将打开下一个 batch 时,Source 在队列锁下
查看队首 batch:数据文件和 peer group sidecar 文件各预留两个 spill buffer,完全在内存中的
batch 不预留,避免在没有可 revoke 任务的情况下因为无用的预留而被 workload group 暂停。重放
途中,只有在下一次调用会读取新的数据记录(当前 Block 已切片输出完)或新的 peer group 记录
(下一个切片含有超过已读最后一个 group end 的行)时,才各预留一个 spill buffer。
## 9. 正确性、错误和生命周期
### 9.1 正确性不变量
- 每个输入行只属于一个 batch descriptor 中的一个 partition;
- descriptor 的 row count 等于其所有 Block 的行数之和,也等于最后一个 partition end;
- peer group end 使用 batch 内行号,并且每个 partition end 同时是一个 peer group end;
- SpillFile 中 Block 顺序与上游输入顺序一致;
- 任何结果输出前,当前 partition 的有界状态和 peer group 元数据已经封存;
- 输出只保留原始列并追加窗口结果,隐藏列不得泄漏到下游;
- 所有函数结果列行数必须与输出 Block 行数一致;
- nullable 包装和 `_change_to_nullable_flags` 与现有路径一致;
- 浮点归约不改变输入累计顺序;
- 一个 Analytic Node 内的多个函数共享同一 partition 行号和边界。
### 9.2 错误处理
- spill 目录容量不足、序列化失败、写入失败、读取失败和元数据损坏直接返回错误;
- 已经开始 spill 后不能静默回退到内存路径;
- 内部状态不变量使用 `DORIS_CHECK`/`DCHECK`,I/O 和用户数据错误使用 `Status`;
- Source 或 Sink 任一侧失败后必须唤醒另一侧,避免 dependency 永久阻塞;
- cancellation 在写盘循环、Sink 增量计算循环和重放循环中都要检查。
### 9.3 文件生命周期
- active writer 只由 Sink Store 持有;
- sealed descriptor 持有 `SpillFileSPtr`;
- Source 完成 partition 后释放最后一个引用,由 `SpillFile` 负责 GC;
- Shared State close 时清空尚未消费的 descriptor;
- writer close 失败时仍需释放引用,并依靠 QueryContext 的 spill 目录清理兜底;
- fault injection 测试必须验证失败和取消后没有遗留 query spill 目录。
## 10. Profile 和可观测性
复用通用 spill counters:
- `SpillWriteFileTime`
- `SpillWriteSerializeBlockTime`
- `SpillWriteBlockCount`
- `SpillWriteBlockBytes`
- `SpillWriteRows`
- `SpillWriteFileBytes`
- `SpillReadFileTime`
- `SpillReadDeserializeBlockTime`
- `SpillReadBlockCount`
- `SpillReadRows`
Analytic 额外增加:
| Counter/Info | 含义 |
| --- | --- |
| `WindowSpillMode` | `Disabled`、`Streaming`、`Unsupported`、`Eligible`、`Spilled` |
| `WindowSpillUnsupportedReason` | frame 或首个不支持函数的原因 |
| `SpilledPartitions` | 实际落盘的 partition 数 |
| `InMemoryPartitions` | 未落盘、直接交给 Source 的 partition 数 |
| `MaxPartitionRows` | 最大 partition 行数 |
| `PeakPartitionBufferedBytes` | Sink 当前 partition 的内存峰值 |
| `PartitionReplayTime` | Source 重放和结果生成耗时 |
| `PeerGroupMetadataBytes` | peer group 辅助元数据字节数 |
测试和线上诊断必须同时观察 `SpillWriteRows/Bytes` 与 Operator 内存峰值。仅出现 spill counter,
但内存没有实际下降,不能视为功能完成。
## 11. 代码改动规划
### 11.1 BE Operator
主要修改:
- `be/src/exec/operator/analytic_sink_operator.h/.cpp`
- 选择 spill execution mode;
- 物化临时表达式列并检测 partition 边界;
- 实现 revocable memory、主动 spill 和 descriptor 发布。
- `be/src/exec/operator/analytic_source_operator.h/.cpp`
- 增加 partition reader/REPLAY 状态;
- 按需读取内存页或 SpillFile;
- 逐 Block 构造结果。
- `be/src/exec/pipeline/dependency.h`
- 为 `AnalyticSharedState` 增加 sealed batch 队列和生命周期状态。
建议新增独立组件,避免继续扩大 Sink Local State:
- `be/src/exec/operator/analytic_spill.h/.cpp`
- partition page、descriptor、writer 和 memory accounting。
现有流式和不支持 spill 的分支仍调用当前 `_add_input_block()` / `_execute_impl()`,避免首期
重写稳定路径。
### 11.2 Aggregate Function capability
修改:
- `be/src/exprs/aggregate/aggregate_function.h`
- 增加默认 `UNSUPPORTED` 的 window spill capability;
- `sum`、`count`、`min/max`、`avg` 和 window function 实现文件
- 显式声明支持的 strategy;
- nullable aggregate adapter
- 仅在 nested function capability 和 wrapper 语义都满足时转发能力。
capability 接口属于 hub header 变更。实现时应优先使用前置声明或轻量 enum,运行
`check-build-hygiene.sh`,并检查 include closure/reach budget,不能为方便直接引入重量级
spill/operator header。
### 11.3 Query option
如果采用独立主动阈值,需要修改:
- `gensrc/thrift/PaloInternalService.thrift`
- `fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java`
- `be/src/runtime/runtime_state.h`
变量命名和 clamp 规则与现有 sort/aggregation spill sink memory limit 保持一致。
## 12. 分阶段开发计划
### 阶段 A:基础抽象和不落盘等价性
1. 增加函数 capability 和 Operator 侧 strategy 信息;
2. 实现窗口表达式的临时物化和旁路状态;
3. 实现 `AnalyticSpillBatchStore` 的纯内存模式和跨 Block partition 切分;
4. 实现 Sink 有界状态收集和 Source REPLAY,但暂不启用磁盘写入;
5. 用现有内存路径做 differential test,确认结果和行顺序一致。
完成标准:支持矩阵内所有 case 在新路径和旧路径逐行一致,且流式/不支持函数没有进入新
路径。
### 阶段 B:Spill 存储和内存回收
1. 接入 `SpillFileWriter/Reader`;
2. 实现 `revocable_mem_size()`、`revoke_memory()` 和主动阈值;
3. 支持 partition 中途 spill、后续直写和 partition seal;
4. 实现 descriptor 队列、dependency、EOS、取消和错误传播;
5. 增加 Profile 和 fault injection。
完成标准:强制 spill 后结果一致,revoke 后实际 tracked memory 明显下降,超大单 partition 的
峰值内存不再随行数增长。
### 阶段 C:partition-size 和 peer-group 函数
1. 实现 `ntile` 的直接 Replay;
2. 实现 peer group 增量检测和有界元数据 spill;
3. 实现 `percent_rank`、`cume_dist`;
4. 覆盖 peer group 跨 Block、单个超大 peer group 和 NULL order key。
完成标准:peer group 元数据本身也不会造成 OOM,多个相关函数共享一次增量检测结果。
### 阶段 D:扩展与优化
按收益和风险评估后再进行:
- `first_value`、`last_value`、`nth_value`;
- 多 partition spill run,减少逻辑文件数量;
- 表达式结果去重和更精确的 byte-based batch sizing;
- peer group 元数据编码压缩;
- 对流式大 look-ahead/大 peer group 使用独立的有界 buffer 方案。
## 13. 测试方案
### 13.1 BE Unit Test
在 `be/test/exec/operator/analytic_sink_operator_test.cpp` 基础上补充,必要时新增
`analytic_spill_operator_test.cpp`:
- 单个超大 partition,中途一次和多次 revoke;
- 无 `PARTITION BY`;
- 一个 Block 内多个 partition;
- partition 边界跨 Block;
- 高基数小 partition 和严重倾斜 partition;
- 内存 partition 与 spill partition 交替;
- nullable、全 NULL、String、Decimal 和宽列;
- 浮点 `sum/avg` 与旧路径 bit-exact 对比;
- 多个函数混合以及一个不支持函数导致整体回退;
- `ntile` 的 bucket 数大于、等于、小于 partition 行数;
- `percent_rank/cume_dist` 的单行 partition、全部同 key、每行不同 key、peer group 跨 Block;
- revoke 后 `revocable_mem_size()` 和 MemTracker 实际下降;
- Source 每次调用最多返回一个 Block,Sink 跨输入 Block 保持有界状态;
- 写入、close、读取、反序列化和 GC fault injection;
- cancellation、early close 和 descriptor 尚未消费时的文件清理。
### 13.2 Regression Test
在 `regression-test/suites/spill_p0` 下增加独立 suite:
- 同一组 SQL 分别以 spill 关闭和强制 spill 执行,使用 `order_qt` 生成确定结果;
- 整个 partition frame 的聚合窗口;
- `ntile`、`percent_rank`、`cume_dist`;
- 无 partition、大 partition、多个 partition 和倾斜数据;
- 多窗口函数同 Node;
- 验证流式窗口结果不变且没有产生 window spill 文件;
- 验证不支持函数使用旧路径,并在 Profile 中给出原因。
预期输出必须由回归脚本生成,不手写 `.out`。
### 13.3 压力和性能验证
- 固定单行宽度,逐步把 partition 从 10 万行扩大到 1 亿行;
- 记录 Operator peak memory、Query peak memory、spill bytes、spill throughput 和总耗时;
- 证明 spill 模式峰值内存趋于稳定,而不是仅延后 OOM;
- 对小 partition 比较 `enable_spill=false/true`,确认 capability 检查和新路径没有不可接受的
固定开销;
- 对浮点归约比较原始二进制结果,不使用 epsilon 掩盖舍入差异。
## 14. 验证命令
实现阶段至少执行:
```shell
build-support/clang-format.sh
build-support/check-format.sh
build-support/check-build-hygiene.sh
./build.sh --be
./run-be-ut.sh --run Analytic
./run-regression-test.sh -d spill_p0 -s window_function_spill
build-support/run-clang-tidy.sh
git diff --check
```
具体 BE UT filter 以最终测试 binary/test suite 名称为准。涉及 Header、include 或新增测试源文件
时,必须在编译前先运行 build hygiene 检查。
## 15. 风险和决策点
| 风险 | 处理原则 |
| --- | --- |
| 通用聚合状态可能随 partition 增长 | capability 默认关闭,只允许证明状态有界的函数加入 |
| 浮点 partial merge 改变结果 | 不做 partial merge,保持原始行顺序单状态累计 |
| peer group 元数据数量达到 O(N) | 元数据也使用有界 buffer 和 SpillFile |
| 超大 partition 计算长时间占用线程 | 计算随上游 Block 增量完成,每次 `sink()` 不跨越当前 Block |
| 单个输入 Block 过大导致序列化峰值 | 写入前按目标字节切分 row range,并计入 reserve memory |
| 输出和 Sink 并发导致多份 partition 驻留 | descriptor 按输入 Block 批次反压,采用 byte-based accounting |
| spill 文件过多 | 按输入 Block 合并小 partition 为 batch,一个 batch 最多一个 spill 文件 |
| 新路径与旧路径 NULL/类型语义不同 | 复用现有函数实现和比较逻辑,强制 differential test |
| unsupported fallback 仍可能 OOM | Profile 明确原因,不把 fallback 误报为已支持 spill |
## 16. 验收标准
第一阶段可以合入的最低条件:
1. `enable_spill=false`、流式窗口和 unsupported 窗口保持原执行路径;
2. 支持矩阵中的窗口在强制 spill 和不 spill 时结果逐行一致;
3. 一个远大于内存阈值的单 partition 查询能够完成,不再因保留完整 partition OOM;
4. 实测峰值内存受配置阈值和 spill buffer 控制,不随 partition 行数线性增长;
5. `SpillWriteRows/Bytes`、`SpilledPartitions` 和内存计数互相一致;
6. 中途 revoke、多次 revoke、取消和 I/O 失败均不会死锁或遗留 spill 文件;
7. 浮点结果 bit-exact,NULL、Decimal、定长 `min/max` 和跨 Block peer group 测试通过;
8. BE build、focused UT、spill regression、format、build hygiene 和 clang-tidy 全部通过。