Quickwit 压缩调度的前沿窗口优先级缺口(GAP-009):无 Leading Edge 优先策略的成因、影响与演进方案
【免费下载链接】quickwitCloud-native OSS search engine for observability项目地址: https://gitcode.com/GitHub_Trending/qu/quickwit
本篇技术分析基于 Quickwit 仓库中的设计文档 GAP-009(docs/internals/adr/gaps/009-no-leading-edge-prioritization.md),并结合其关联的 ADR-003 时间窗口化排序压缩设计、Phase 1 Sorted Splits 设计文档 以及合并策略、合并调度器、独立压缩规划器的真实源码实现展开。
导读
在可观测性(Observability)场景下,Quickwit 的查询几乎总是命中最近的数据——仪表盘、告警、故障排查全部聚焦于"前沿窗口"(Leading Edge),即最新时间窗口中小文件(split)堆积最快、查询频率最高的区域。然而 GAP-009 指出:Quickwit 当前的压缩(compaction)机制并不对时间窗口做优先级区分,前沿窗口与冷窗口平等竞争压缩资源,导致高写入速率下最近数据查询性能下降、新数据可见性延迟。本文将从设计文档出发,用仓库源码逐层证实"无优先级"的现状,对比 Husky、Prometheus/Mimir 等行业方案,并深入剖析三种候选解决思路及其落地路径,帮助你理解如何为 Quickwit 的压缩调度引入窗口级优先级机制。
一、背景:时间窗口化压缩与"前沿窗口"概念的由来
要理解 GAP-009,首先要理解 Quickwit 压缩架构中时间窗口(Time Window)这一核心抽象。
1.1 时间窗口化压缩(ADR-003)
Quickwit 在 ADR-003: Time-Windowed Sorted Compaction 中提出了面向 Parquet 指标管线的压缩设计:所有数据被划分为固定时长、与 Unix 纪元对齐、互不重叠的时间窗口,压缩(compaction)只合并同一窗口内的 split,绝不跨窗口合并数据。窗口计算方式为:
window_start = t - (t % window_duration_seconds) window_end = window_start + window_duration_seconds窗口时长window_duration默认 15 分钟,必须能整除一小时(合法值:1m、2m、3m、4m、5m、6m、10m、12m、15m、20m、30m、60m)。这一设计带来四个直接收益:
- 限定压缩范围:每个窗口是独立的压缩单元,单次合并的数据量被窗口时长与写入速率共同约束;
- 对齐查询模式:可观测性查询总是携带时间范围谓词,查询引擎可以直接丢弃窗口范围之外的 split;
- 支撑高效保留策略:过期窗口内全部 split 可以成批删除;
- 限制写放大:旧窗口完成压缩后不再被新数据扰动。
1.2 什么是"前沿窗口"(Leading Edge)
前沿窗口指最新的一批时间窗口——也就是当前时刻正在接收写入、小 split 以最快速度累积的那些窗口。
GAP-009 明确指出其量级:在高写入速率下,每个 15 分钟窗口内会累积数十万个(hundreds of thousands of)小 split(详见 ADR-003,该文档给出的极端推算是 10 GiB/s 写入速率下约 92 万个 split/窗口)。如果压缩跟不上这种累积速度,查询最近数据时就必须 fan-out 到所有这些小 split 上,性能急剧恶化——而这恰恰是可观测性负载中最常见、最敏感的查询路径。
二、缺口定义:Quickwit 压缩为何对前沿窗口"一视同仁"
GAP-009 的核心论断可以归纳为三句话:
- Quickwit 的压缩不优先处理最新时间窗口,老旧窗口与前沿窗口在压缩资源上是公平竞争关系;
- 合并规划器(Merge Planner)把所有符合条件的窗口同等对待,按发现合并候选(merge candidate)的顺序处理,而不是按窗口新旧程度排序;
- 系统缺乏三种关键机制:
- 优先压缩新窗口而非旧窗口的机制;
- 在前沿窗口存在积压(backlog)时,退避旧窗口压缩的机制;
- 发出"某窗口需要紧急压缩"信号(例如小文件过多已影响查询)的机制。
文档特别强调了两类后果:
- 查询性能劣化:前沿窗口小 split 过多 → 每次查询必须 fan-out 到全部小 split → 最近数据查询延迟上升。由于可观测性查询压倒性地针对近期数据(仪表盘、告警、故障排查),这是最可见、影响最大的劣化。
- 新数据可见性受损:系统不优先让新写入数据尽快可查询。极端情况下,旧窗口的压缩应该让出资源,以确保新数据在 ingest-to-query 延迟 SLO(如 30 秒 p99.9)内可见。
2.1 与既有时间窗口设计的关系
值得说明的是,GAP-009 并不是说 Quickwit 完全没有时间维度处理——恰恰相反,StableLogMergePolicy 在构建合并层级时确实会按时间结束倒序(cmp_splits_by_reverse_time_end,见stable_log_merge_policy.rs第 170-178 行)排序 split。但这只是单次策略运行内部的处理顺序优化,用于让时间剪枝更高效,它不构成跨窗口的资源分配优先级:没有"前沿窗口分到更多压缩并发",没有"旧窗口压缩退避",也没有"按窗口紧急程度排序执行"。这正是 GAP-009 所指的"没有窗口优先级概念"。
三、源码证据:从实现层逐级证实"无优先级"现状
GAP-009 的"Evidence"一节断言StableLogMergePolicy没有窗口优先级概念,仅依据**成熟度(maturity)与文档数(document count)**评估合并候选。这一论断可以在当前仓库源码中得到完整印证。我们从"策略选哪些 split 合并"到"这些合并按什么顺序执行"逐层分析。
3.1 策略层:StableLogMergePolicy 只认成熟度与文档数
MergePolicytrait 定义在 quickwit/quickwit-indexing/src/merge_policy/mod.rs:
pub trait MergePolicy: Send + Sync + fmt::Debug { /// Returns the list of merge operations that should be performed. fn operations(&self, splits: &mut Vec<SplitMetadata>) -> Vec<MergeOperation>; ... fn split_maturity(&self, split_num_docs: usize, split_num_merge_ops: usize) -> SplitMaturity; }其核心方法split_maturity的逻辑(stable_log_merge_policy.rs 第 116-123 行):
fn split_maturity(&self, split_num_docs: usize, _split_num_merge_ops: usize) -> SplitMaturity { if split_num_docs >= self.split_num_docs_target { return SplitMaturity::Mature; } SplitMaturity::Immature { maturation_period: self.config.maturation_period, } }也就是说,一个 split 是否进入合并候选,取决于文档数是否达到目标(split_num_docs_target)与成熟期(maturation_period,测试中常见 48 小时)——完全没有时间窗口新旧的概念。而merge_candidate_size(第 274-297 行)判断合并候选大小时,依据的也只是max_merge_factor、merge_factor与split_num_docs_target。
GAP-009 文档本身的表述是:策略"基于成熟度和文档数评估合并候选,而非基于时间窗口的年龄或压缩对查询性能的紧迫性"。源码与之完全一致——没有任何分支依据window_start或窗口内 split 数量为某个窗口提高优先级。
3.2 调度层:合并调度器按"分裂收益/字节数"打分,而非窗口新旧
合并操作从规划器产出后,进入MergeSchedulerService(quickwit/quickwit-indexing/src/actors/merge_scheduler_service.rs)。调度器内部用一个BinaryHeap<ScheduledMerge>维护待执行合并,排序依据是order_key()(第 100-102 行):
fn order_key(&self) -> (u64, Reverse<u64>) { (self.score, std::cmp::Reverse(self.id)) }而这个score来自compute_merge_score(quickwit/quickwit-indexing/src/merge_policy/mod.rs 第 159-170 行):
pub fn compute_merge_score(num_splits: usize, total_num_bytes: u64) -> u64 { if total_num_bytes == 0 { return u64::MAX; } let delta_num_splits = num_splits.saturating_sub(1) as u64; (delta_num_splits << 48) .checked_div(total_num_bytes) .unwrap_or(1u64) }代码注释明确指出优先级含义:"一个良好的合并操作:大幅减少 split 数量、且轻量(字节少)"。即评分 = 减少的 split 数 / 总字节数——与窗口新旧、窗口内 split 堆积量、查询热度完全无关。同文件第 155-158 行的注释 "The higher, the sooner we will execute the merge operation" 说明分数越高执行越早,但分数的两个自变量都不含时间窗口维度。
此外,该调度器还通过Semaphore以merge_concurrency限制并发合并数(默认 3),但同一信号量下,等待队列的排序也只认score。
3.3 独立压缩器:按成熟时间而非窗口新旧扫描
在独立的压缩器(compactor)管线中,CompactionPlanner(quickwit/quickwit-compaction/src/planner/compaction_planner.rs)每个扫描周期(SCAN_AND_PLAN_INTERVAL = 5s)从 metastore 拉取 immature 的 published split,其查询构造为(第 196-223 行):
let query = ListSplitsQuery::for_all_indexes() .with_split_state(SplitState::Published) .retain_immature(OffsetDateTime::now_utc()) .sort_by_maturity_timestamp() .with_limit(SCAN_PAGE_SIZE) .with_excluded_split_ids(excluded_split_ids);注意sort_by_maturity_timestamp()——当存在积压时,最"紧急"的 split(即将成熟的)先被处理。这是基于成熟时间的紧迫性排序,与窗口新旧无关。代码注释也坦承:"Every tick, the planner re-scans the immature published set, sorted bymaturity_timestampASC so the most-urgent splits are processed first when a backlog exists."
随后待分配合并操作进入PendingOperations(quickwit/quickwit-compaction/src/planner/mod.rs),PendingMerge的priority_score同样由compute_merge_score(operation.splits.len(), total_num_bytes)计算(第 40-60 行),Ord实现为按priority_score降序的最大堆(第 77-89 行)。从合并候选的产生、排队、到执行排序,三个环节都没有引入window_start或窗口内 split 数量作为优先级因子。
3.4 小结:三处源码共同佐证 GAP-009
| 层级 | 代码位置 | 当前优先级依据 | 是否含窗口维度 |
|---|---|---|---|
| 合并策略 | merge_policy/stable_log_merge_policy.rs | 成熟度、文档数(split_num_docs_target) | 否 |
| 合并调度 | actors/merge_scheduler_service.rs | compute_merge_score:减少的 split 数 / 总字节 | 否 |
| 独立压缩规划 | planner/compaction_planner.rs、planner/mod.rs | 成熟时间戳(扫描)、compute_merge_score(排队) | 否 |
GAP-009 是Status: Open的缺口分析文档(Discovered: 2026-02-19),其影响评估为:Severity: High(直接作用于近期数据查询延迟)、Frequency: Constant(生产负载下持续存在)、Affected Areas: Merge planner、compaction scheduler、resource allocation。
四、为什么这个缺口在高写入速率下最致命
4.1 查询 fan-out 与 split 数成正比
可观测性系统每个查询都要打开并扫描时间范围内的全部相关 split。split 越多,I/O、元数据查找与 DataFusion 任务调度开销越大。前沿窗口的小 split 以最高速度累积(ADR-003 的极端推算:10 GiB/s 写入 + 10 MiB split 时每秒约产生 1,024 个 split,15 分钟窗口内压缩前可累积约 92 万个),如果压缩跟不上,查询最近数据时 fan-out 规模会失控。
4.2 新数据可见性(freshness)
GAP-009 明确将"新数据可见性"列为受影响项:系统不优先让新写入数据尽快可查询。在极端情况下,旧窗口的压缩应让出资源,保证新数据在 ingest-to-query 延迟 SLO(文档给出的示例为 30s p99.9)内可见。当前实现没有任何机制实现这种"让出",前沿窗口与旧窗口在同一资源池中竞争。
4.3 信号无关性
GAP-009 的"Signal Impact"指出:所有信号(logs/traces/metrics)同等受影响。前沿窗口优先级是信号无关(signal-agnostic)的——任何具有高写入速率与时间范围查询的信号都能受益。这意味着该缺口不是某个特定管线的特有问题,而是压缩调度的通用能力缺失。
五、行业现状(State of the Art):Husky 与 Prometheus/Mimir 的做法
GAP-009 对照了两个成熟系统的设计:
5.1 Husky:压缩器优先前沿窗口
- 前沿优先:压缩器优先压缩最新时间桶(recent time buckets),旧桶排在后面;
- 自动扩缩:压缩并发根据前沿窗口未压缩文件的积压量(backlog)自动扩缩——积压越多,投入的压缩资源越多。
Husky 的做法本质上是把"前沿窗口积压"作为资源分配与调度规模的核心输入信号。这也与 Phase 1: Sorted Splits for Parquet 中提到的 hinting mechanism(提示机制)一脉相承——Phase 1 设计文档第 334 行写道:"In the future, the compaction planner may benefit from a hinting mechanism similar to Husky's, where the system can signal that a particular window needs compaction (e.g., due to late-arriving data or a schema change). This would replace the current polling-based approach with event-driven compaction for specific windows."
5.2 Prometheus/Mimir:分级调度
- Head block 压缩按紧凑的固定节奏(每 2 小时)执行;
- 重叠块(overlapping blocks)的垂直压缩优先级更低。
其思路是把"必须按时完成的关键压缩"与"可以弹性延后的优化性压缩"分离开,用不同的调度节奏与优先级处理,避免低价值压缩抢占关键路径资源。
这两者的共同点在于:压缩调度必须理解数据的"时间价值"——最近的数据对查询价值最高,其物理组织状态也最需要及时维护;而这两个项目分别用"积压驱动的自动扩缩/优先"与"分级调度节奏"实现这一点。
六、候选解决方案:三种思路的深度剖析
GAP-009 提出了三个候选方案(Potential Solutions),原文均处于开放讨论状态。结合仓库现有代码结构,逐一分析其可行性与落点。
6.1 Option A:为压缩调度引入优先级队列(Priority Queue)
Assign priority based on window recency and split count. Recent windows with high split counts get compacted first. Older, already-compacted windows get lower priority.
方案要点:调度优先级 = f(窗口新旧程度, 窗口内 split 数量)。最新且 split 多的窗口先压缩;已充分压缩的旧窗口降级。
落地分析(结合源码):这是改造面最小的方案,直接复用现有的两处优先队列基础设施:
- merge_scheduler_service.rs 的
ScheduledMerge已实现Ord(按score排序),只需把score从"纯compute_merge_score"扩展为compute_merge_score + window_recency 加权; - quickwit-compaction/src/planner/mod.rs 的
PendingMerge.priority_score同理,在计算时引入window_start(越新越高分)与窗口内 split 数量。
实现上需要SplitMetadata携带window_start(这正是 ADR-003 提出的 split 元数据扩展 中的window_start: i64字段,与metrics_splits表及 Parquetkey_value_metadata双写)。注意:在 ADR-003 尚未落地前,Tantivy 管线的 split 元数据尚无window_start,Option A 若要覆盖既有 logs/traces 管线,可能需要以time_range.end近似窗口新旧。
优点:改动集中、可渐进灰度;风险:优先级计算公式需要实验校准(窗口新旧与 split 数的权重、防止旧窗口饿死)。
6.2 Option B:前沿压缩与后台压缩分离(资源切分)
Separate leading-edge compaction from background compaction. Dedicate a portion of compaction resources to the most recent N windows (e.g., last 1 hour), with remaining resources for background compaction of older windows.
方案要点:把压缩资源分成两份——一份固定配额给最近 N 个窗口(示例:最近 1 小时),另一份做旧窗口后台压缩。
落地分析:这直接对应MergeSchedulerService中的并发控制。当前实现用单一Semaphore以merge_concurrency限流(merge_scheduler_service.rs 第 191 行),Tantivy 合并与 Parquet 合并共享同一信号量(第 244-247 行注释:"Shares the same semaphore as Tantivy merges so the node doesn't exceed its merge concurrency limit")。Option B 需要把单一信号量拆分为两个(如"前沿池 70% + 后台池 30%"),或引入基于窗口的令牌桶:window_start落在最近 1 小时内的合并从高配额池取令牌,否则从低配额池取。
优点:直观地保证前沿窗口有最低资源保障,天然实现"旧窗口压缩让位";风险:配额比例需要随写入速率动态调整(否则写入飙升时前沿配额不足,写入低谷时前沿配额浪费),可能仍需结合积压信号做自适应。
6.3 Option C:事件驱动的压缩提示(Event-driven Compaction Hints)
When a window's split count exceeds a threshold (e.g., affecting query latency), emit a compaction hint that bumps that window's priority. Similar to Husky's hinting mechanism.
方案要点:当某窗口的 split 数超过阈值(例如已影响查询延迟)时,发出一个"压缩提示(hint)"把该窗口的优先级临时抬高。这类似 Phase 1 设计文档中提到的 Husky 式 hinting 机制(见 phase-1-sorted-splits.md 的"Compaction Policy"一节)。
落地分析:这是从"轮询式(polling-based)"到"事件驱动(event-driven)"的架构转变。当前CompactionPlanner每 5 秒轮询 metastore 扫描 immature splits(SCAN_AND_PLAN_INTERVAL),本质上是被动发现积压。Option C 引入主动信号:例如在 split 发布路径上统计每个window_start的活跃 split 数,超过阈值时向 planner 发送带窗口标识的 hint,planner 据此将该窗口的待合并操作在PendingOperations堆中"插队"。ADR-003 的"Merge correctness invariants"(MC-1~MC-4)表明压缩是纯物理重排,hint 机制不会破坏正确性,只需保证 hint 驱动的合并仍遵守六维兼容作用域(index_uid, source_id, partition_id, doc_mapping_uid, sort_schema, window_duration)。
优点:资源利用最精准——只有真正"生病"的窗口才被提升优先级,避免固定配额在低负载时的浪费;风险:hint 阈值(split 数 vs 查询延迟的关系)需要实验标定,且 hint 风暴(大量窗口同时超阈)需要背压保护。
6.4 三个选项并非互斥
从工程实践看,三者可以组合:Option A 提供基础的分级排序,Option B 保证前沿窗口的资源下限,Option C 提供对异常窗口的快速响应。GAP-009 将其列为平行候选,最终取舍取决于前沿窗口 split 累积速率的实测数据(见下一节 Next Steps 的测量任务)。
七、落地路径:Next Steps 与评估指标
GAP-009 给出的后续步骤(原文为 Open 状态的任务清单)为:
- 在代表性写入速率下,测量前沿窗口的 split 数量累积速率;
- 设计基于优先级的压缩调度(窗口新旧 + split 数量);
- 定义前沿窗口压缩 SLO(例如:每窗口最大 split 数、首次压缩前最大窗口年龄);
- 评估事件驱动压缩提示 vs 轮询式优先级的取舍。
其中"测量"是第一优先级——任何方案都依赖对"前沿窗口 split 累积曲线"的量化理解。ADR-003 的"Compaction Policy"一节同样强调实验先行:建议先做基线测量(每 15 分钟窗口的 split 数、单个 split 大小、窗口总数据量),再做 merge fanin 扫描(4/8/16)与目标 split 尺寸扫描(64MB/128MB/256MB/512MB)。
落地时可关注的监控指标(源自 Phase 1 设计文档 的 Monitoring 一节):split_size_bytes(压缩前后对比)、indexer_cpu_usage、compaction_duration,以及parquet_pages_scanned(反映查询阶段页级剪枝效果)。若实现 GAP-009 方案,还应新增窗口级积压指标(如 per-window_start的活跃 split 数、前沿窗口合并等待时长),作为优先级计算与 SLO 告警的数据源。
八、结论
GAP-009 揭示的是 Quickwit 压缩调度中的一个系统性能力缺失:调度只优化"合并自身的效率"(减少的 split 数 / 字节数),而不优化"合并对象的时间价值"(窗口新旧、查询热度、可见性紧迫度)。仓库源码在策略、调度、规划三个层面均证实了这一点——StableLogMergePolicy只认成熟度与文档数,compute_merge_score只认分裂收益与字节数,CompactionPlanner只按成熟时间戳扫描。
在高写入速率、查询高度集中于近期数据的可观测性负载下,这个缺口直接转化为前沿窗口小 split 堆积失控、最近数据查询延迟劣化、以及新数据可见性延迟。行业方案(Husky 的前沿优先+积压扩缩、Prometheus/Mimir 的分级调度)提供了两条可借鉴的路径,而 GAP-009 的三种候选方案(优先级队列、资源切分、事件驱动 hint)给出了具体的改造蓝图——它们与 ADR-003 的时间窗口化压缩、Phase 1 的 sorted splits 基础设施天然衔接,是 Quickwit 压缩架构从"物理效率优先"走向"查询价值优先"的关键一步。
延伸阅读
- GAP-009: No Leading Edge Prioritization(本文主体文档)
- ADR-003: Time-Windowed Sorted Compaction for Parquet(时间窗口化压缩设计,GAP-009 的直接上下文)
- Phase 1: Sorted Splits for Parquet(含 hinting mechanism 前瞻讨论)
- StableLogMergePolicy 实现(无窗口优先级的合并策略)
- MergePolicy trait 与 compute_merge_score(优先级打分的实现源头)
- MergeSchedulerService(合并执行队列的排序逻辑)
- CompactionPlanner 与 PendingMerge(独立压缩器侧的扫描与排队逻辑)
【免费下载链接】quickwitCloud-native OSS search engine for observability项目地址: https://gitcode.com/GitHub_Trending/qu/quickwit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考