**核心结论:**第一阶段只为“必须等到整个 partition 结束后才能产生结果”的窗口计算提供 spill。能够在输入过程中持续产出结果、且工作集大小不随 partition 总行数增长的流式窗口, 不进入 spill 路径,继续使用现有内存执行器。
当前 Analytic Operator 会保留原始输入 Block,同时把窗口函数参数、PARTITION BY、 ORDER BY 以及 RANGE 边界表达式的结果追加到累计 Column 中。对于非流式窗口,只有发现 partition 结束后才能开始计算和输出,因此当前 partition 的数据不能提前释放。
当以下任一场景形成超大 partition 时,内存占用会随 partition 大小线性增长,并可能触发 OOM:
PARTITION BY,全部输入属于同一个 partition;PARTITION BY 数据严重倾斜;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;enable_spill、enable_force_spill、min_revocable_mem 和 spill_buffer_size_bytes 可以直接复用。本方案不直接以当前实现中的 _streaming_mode 布尔值作为 spill 判定条件,而使用更明确的 执行属性:
当前 _streaming_mode 更接近“是否可以在 partition 结束前输出”。它不必然代表严格的常量 内存,例如很大的 ROWS N FOLLOWING 或跨越大量行的 peer group 仍可能保留较多数据。这类 问题属于流式执行器的工作集优化,不纳入第一阶段 window spill。
第一阶段仅处理满足以下全部条件的 Analytic Node:
enable_spill = true;首期支持矩阵:
| 类别 | 首期函数/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 同时由两套生命周期管理。
第一阶段明确不处理:
lead、lag、first_value、last_value、nth_value 的通用 spill;min/max 状态;这些限制必须反映在 Profile 中。遇到不支持的函数时不能宣称窗口已经获得 spill 保护。
对于已经 spill 的 partition,峰值内存只由以下有界工作集构成:
内存上界不能再与 partition 总行数相关。revocable_mem_size() 只能上报 revoke 后确实能够释放 的字节,不能把不可回收的函数状态或正在被 Source 使用的 Block 计入其中。
enable_spill = false 时完全保留现有执行路径;Analytic Operator 在初始化完成后构造一组不可变的 spill capability 信息。该信息包含:
执行路径选择如下:
enable_spill = false -> 现有 Analytic 路径 窗口可流式执行 -> 现有 Analytic 路径 非流式,但任一函数不支持 spill -> 现有 Analytic 路径 非流式,且全部函数支持 spill -> Partition Store + Spill Evaluator 路径
不要继续扩大 PARTITION_FUNCTION_SET 这类按函数名维护的分支。应在聚合函数接口中增加默认 为“不支持”的 capability,由具体函数或 nullable adapter 显式声明,避免 Operator 根据字符串 推断函数语义。
建议的 capability 形态:
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 时它仍是流式窗口。
Sink 负责:
AnalyticSpillBatchStore;AnalyticSpillBatch)。Source 负责:
get_block() 最多向下游返回一个输出 Block;SpillFileSPtr。Sink 在每次 sink() 调用中只顺序处理当前输入 Block,不会因超大 partition 长时间占用一次 调度;Source 负责按需重放,可以直接利用下游拉取形成反压,不需要 Sink 一次性产生整个 partition 的结果 Block,也避免现有按 Block 数量缓存输出时无法精确控制字节数的问题。
AnalyticSharedState 在 spill 路径中增加:
执行错误继续通过 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。
存储 Block 只保留上游原始输出列:
[原始输出列]
窗口参数、partition key 和 order key 在当前输入 Block 上临时物化。partition key 只用于 Sink 检测边界;order key 只用于增量检测 peer group;ntile 的常量参数仅在首个非空输入上 求值,每个函数保存一个标量,并随每个 batch 传递一次,不按 partition 重复保存。 临时列不会进入 spill Block,也不会泄漏给下游。
上游已经保证 partition/order 排序。Sink 复用现有 Column 比较语义检测相邻行是否属于同一 partition,并保留上一批最后一行的 key 作为跨 Block 比较基准。
一个输入 Block 可以被切分成多个 row range:
reset 聚合状态;因此 Store 中已结束的 partition 只在处理单个输入 Block 期间存在;revoke 时 Store 只包含 open partition。
切分必须保持 NULL、字符串、Decimal、复杂类型和 nullable wrapper 的现有比较语义。不能为 spill 重新实现一套相等判断。
Store 的状态机:
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 文件。
仅 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 或仍被共享指针持有,不算完成内存回收。
建议新增与 Sort/Aggregation 对齐的查询选项:
spill_analytic_sink_mem_limit_bytes
默认值 64 MB,并限制在 1 MB 到 4 GB。行为如下:
revoke_memory() 触发 spill;spill_buffer_size_bytes 即写盘;enable_force_spill 用于测试强制覆盖 spill 分支,不新增测试专用用户语义。假设主动阈值为 L、spill buffer 为 B、执行 Block 最大内存为 R,一个并行实例的主要 上界为:
未 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。
适用:sum/sum0、count、定长状态的 min/max、avg,且 frame 为整个 partition。
执行过程:
不能把 partition 划分为多个部分状态后再 merge。特别是浮点 sum/avg,merge 会改变加法 顺序和舍入结果。首期必须保持与现有 add_range_single_place() 相同的逐行累计顺序,并为 浮点、Decimal 和 nullable 输入增加精确结果对照测试。
函数只有在满足以下条件时才能声明 PARTITION_REDUCE:
collect_list、精确 distinct、可能随输入增长的 percentile 状态等不能因为支持 serialize/merge 就自动加入该能力。
变长 min/max 的现有单值状态在每次出现更优值时从 Arena 分配新空间,旧空间在 partition 结束前不能回收,因此首版不声明该能力;后续只有在实现状态 compact/rebase 后才能开放。
ntilepartition 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。
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 结束操作封口。
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;如果一个 Node 同时包含 percent_rank 和 cume_dist,两者共享同一份 peer group 元数据。
第二阶段可增加:
first_value、last_value、nth_value:预扫描定位目标值,重放 时填充;必须覆盖 IGNORE NULLS、负 offset 和 nullable 默认值;归约和 peer group 检测随 Sink 输入 Block 增量完成,因此 Source 不需要在一次 get_block() 中同步预扫描完整文件。Source 使用以下状态:
WAIT_PARTITION -> OPEN_READER -> REPLAY -> FINISH_PARTITION
该模型同时解决 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。
_change_to_nullable_flags 与现有路径一致;DORIS_CHECK/DCHECK,I/O 和用户数据错误使用 Status;SpillFileSPtr;SpillFile 负责 GC;复用通用 spill counters:
SpillWriteFileTimeSpillWriteSerializeBlockTimeSpillWriteBlockCountSpillWriteBlockBytesSpillWriteRowsSpillWriteFileBytesSpillReadFileTimeSpillReadDeserializeBlockTimeSpillReadBlockCountSpillReadRowsAnalytic 额外增加:
| 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, 但内存没有实际下降,不能视为功能完成。
主要修改:
be/src/exec/operator/analytic_sink_operator.h/.cppbe/src/exec/operator/analytic_source_operator.h/.cppbe/src/exec/pipeline/dependency.hAnalyticSharedState 增加 sealed batch 队列和生命周期状态。建议新增独立组件,避免继续扩大 Sink Local State:
be/src/exec/operator/analytic_spill.h/.cpp现有流式和不支持 spill 的分支仍调用当前 _add_input_block() / _execute_impl(),避免首期 重写稳定路径。
修改:
be/src/exprs/aggregate/aggregate_function.hUNSUPPORTED 的 window spill capability;sum、count、min/max、avg 和 window function 实现文件capability 接口属于 hub header 变更。实现时应优先使用前置声明或轻量 enum,运行 check-build-hygiene.sh,并检查 include closure/reach budget,不能为方便直接引入重量级 spill/operator header。
如果采用独立主动阈值,需要修改:
gensrc/thrift/PaloInternalService.thriftfe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.javabe/src/runtime/runtime_state.h变量命名和 clamp 规则与现有 sort/aggregation spill sink memory limit 保持一致。
AnalyticSpillBatchStore 的纯内存模式和跨 Block partition 切分;完成标准:支持矩阵内所有 case 在新路径和旧路径逐行一致,且流式/不支持函数没有进入新 路径。
SpillFileWriter/Reader;revocable_mem_size()、revoke_memory() 和主动阈值;完成标准:强制 spill 后结果一致,revoke 后实际 tracked memory 明显下降,超大单 partition 的 峰值内存不再随行数增长。
ntile 的直接 Replay;percent_rank、cume_dist;完成标准:peer group 元数据本身也不会造成 OOM,多个相关函数共享一次增量检测结果。
按收益和风险评估后再进行:
first_value、last_value、nth_value;在 be/test/exec/operator/analytic_sink_operator_test.cpp 基础上补充,必要时新增 analytic_spill_operator_test.cpp:
PARTITION BY;sum/avg 与旧路径 bit-exact 对比;ntile 的 bucket 数大于、等于、小于 partition 行数;percent_rank/cume_dist 的单行 partition、全部同 key、每行不同 key、peer group 跨 Block;revocable_mem_size() 和 MemTracker 实际下降;在 regression-test/suites/spill_p0 下增加独立 suite:
order_qt 生成确定结果;ntile、percent_rank、cume_dist;预期输出必须由回归脚本生成,不手写 .out。
enable_spill=false/true,确认 capability 检查和新路径没有不可接受的 固定开销;实现阶段至少执行:
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 检查。
| 风险 | 处理原则 |
|---|---|
| 通用聚合状态可能随 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 |
第一阶段可以合入的最低条件:
enable_spill=false、流式窗口和 unsupported 窗口保持原执行路径;SpillWriteRows/Bytes、SpilledPartitions 和内存计数互相一致;min/max 和跨 Block peer group 测试通过;