Streaming Node 解决 提交与实时可搜(第 5 篇);Query Node 解决 历史段检索(第 7 篇)。重型、可延迟的工作——向量/标量索引构建与 历史数据整理(compaction)——落在 Data Node,由 Coordinator 调度(第 4 篇)。一个常见的排障误区是「写入之后能搜到,说明这批数据已经进入正常服务状态」——本文要说明的恰恰是:能搜到 和 搜得又快又准 之间,隔着 Data Node 这条离线队列。
本文是「向量检索引擎」系列第 10 篇(共 19 篇)。→ 系列目录
版本锚定:Milvus 2.6.x Architecture / Data Processing(A 级)。compaction 动机的历史叙述引用官方博客(B 级)。
一、Data Node 在四层中的位置
官方定义:Data Node 负责历史数据的 离线处理,例如 compaction 与 index building。它是存算分离下的无状态 Worker:听 Coordinator 指令,读写对象存储,不永久持有可变用户状态。
与另外两类 Worker 的分工:
| Worker | 在线性 | 典型工作 |
|---|---|---|
| Streaming Node | 在线写 + Growing 查询 | WAL、flush、Delegator |
| Query Node | 在线历史查询 | 加载 Sealed、段级 search |
| Data Node | 离线 | 建索、compaction |
Architecture 插入流的末步:Data Node 在 Sealed 上建索引并写入对象存储后,Query Node 加载新索引并替换对应增长数据视图——中间由 Coordinator handoff 缝合(第 3 篇)。
和 Query Node 一样,Data Node 也是无状态 Worker:它不持有跨任务的可变状态,一次建索/compaction 任务失败可以直接重新调度到任意存活的 Data Node 上重跑,而不需要「恢复」某个特定节点上的中间状态。这也是为什么扩容 Data Node 池能直接提升建索并发——不像 Query Node 扩容还要考虑 data view 重新分配的过程,新加入的 Data Node 只需要从 Coordinator 的任务队列里领任务即可开始工作。
二、Index build:每段一份索引
Data Processing · Index building(A 级要点):
- 为避免数据一更新就全局重建,Collection 再分为 segment,每段自有索引。
- 可为向量字段、标量字段、主键字段建索引。
- 输入与输出都经对象存储:Data Node 加载段日志快照 → 反序列化 → 建索 → 序列化 → 写回对象存储。
- 向量建索是计算与内存密集型,依赖 SIMD;官方强调弹性对成本的重要性。
不同字段类型的建索资源画像并不相同,规划 Data Node 容量时不能只按「一种任务」估算:
| 建索对象 | 资源特征 | 与本系列的衔接 |
|---|---|---|
| 向量字段 | 计算/内存密集,依赖 SIMD,部分类型可用 GPU | Knowhere VecIndex(第 8 篇) |
| 标量字段 | 相对轻量:Bloom、哈希、树、倒排等经典结构 | 官方列举,本篇不展开实现 |
| 主键字段 | 通常与标量索引同类,但要求唯一性约束 | 影响 upsert/delete 的定位效率(第 13 篇) |
这与 Knowhere「对 segment 内数据 train 并 build」(第 8 篇)直接对接:Data Node 是调度与 I/O 壳,Knowhere 是向量索引内核。容量规划时把向量建索单独拎出来看,因为它是这张表里唯一可能需要 GPU、且耗时对数据分布最敏感的一项。
flowchart LR
coord["Coordinator schedule"]
dn["Data Node"]
obj["Object storage"]
know["Knowhere Train/Build"]
qn["Query Node load"]
coord --> dn
obj -->|"load snapshot"| dn
dn --> know
know -->|"write index"| obj
coord -->|"handoff"| qn
obj --> qn
2.1 新鲜度与「有数据无好索引」
flush 已使数据进入 Sealed 并可能被查询路径看到,但
ANN
索引对象可能仍在构建队列中。此时执行可能退化为更慢的路径或临时索引策略(具体行为随版本与
queryNode.segcore.interimIndex.*
等配置变化,见官方 Query Node 配置;第 7
篇提及)。排障「写入后能搜但很慢 / 召回不稳」应同时看:
- 建索任务是否堆积(Data Node / Coordinator);
- Query Node 是否已加载对应索引(handoff / data view)。
这两个检查点对应两次独立的「交付」:Data Node 把索引写回对象存储是第一次交付(索引已存在),Coordinator 完成 handoff 并让 Query Node 加载是第二次交付(索引已生效)。只确认了第一次交付就断言「索引已经建好可以搜了」,会漏掉两次交付之间的延迟窗口。
三、Compaction:清理与合并
3.1 动机(历史官方叙述)
官方博客 How to Compact Data in Milvus?(2022,B 级)将 compaction 概括为:合并小段、清理逻辑删除,降低存储占用;由协调组件触发、Data Node 执行。并区分:
- binlog compaction:段内 insert/delta 类日志整理,使已删实体不再占用有效日志内容;
- segment compaction:多个 Sealed 小段合并成更大段。
| 维度 | binlog compaction | segment compaction |
|---|---|---|
| 作用范围 | 单个段内部 | 多个 Sealed 小段之间 |
| 主要收益 | 回收该段内软删占用的日志空间 | 减少段数、降低对象 API 与归并扇出 |
| 触发时机 | 该段删除比例达到一定水平 | 段数或段大小达到调度阈值 |
| 与 handoff 的关系 | 完成后同样触发 handoff(第 3.2 节) | 完成后同样触发 handoff(第 3.2 节) |
两者共享同一个 Data Node 任务队列与调度器;binlog compaction 更「本地」,segment compaction 更「全局」,但排障时都要回到第四节的资源池模型去看——不必特意区分「是哪种 compaction 慢」,先看队列本身是否堆积。
这与列存 merge、LSM compaction 同属「不可变文件 + 后台整理」家族(对照 columnar-engine、rocksdb),但整理目标还包括 软删空间回收 与 段数可控(段数影响第 9 篇归并扇出与第 6 篇对象 API 次数)。这类「前台可变结构 + 后台不可变文件整理」的分工,经典源头是 O’Neil et al. 提出的 Log-Structured Merge-Tree(The Log-Structured Merge-Tree (LSM-Tree), Acta Informatica 33(4), 1996):论文关心的是磁盘随机写代价,用分层归并把随机写变成顺序写;Milvus segment compaction 面对的磁盘代价已经被对象存储的按请求计费取代,但「延迟整理、批量重写换取更低平均代价」的结构完全一致。leveled 还是 tiered 的写放大/读放大权衡(Dayan & Idreos, Dostoevsky: Are Your LSM Key-Value Stores Optimized?, SIGMOD 2017)在 Milvus 侧没有对应的公开分层策略描述——本篇不假设 Milvus 的 segment compaction 采用了这两种策略中的某一种,只指出这是同一类问题的姊妹领域。
LSM 社区量化这类后台整理代价时常用写放大(Write Amplification,WA):
\[ WA = \frac{\text{compaction 过程中实际重写的字节数}}{\text{用户逻辑写入的字节数}} \]
leveled compaction 倾向于更低的空间占用与更好的读性能,但每层合并都要重写数据,WA 偏高;tiered(在 Milvus 语境接近 segment compaction 直接合并小段)倾向于减少重写次数,WA 偏低,但读时要扫更多未合并的小文件,付出的是读放大(Read Amplification,RA)。Milvus 官方文档没有公开给出 segment compaction 的 WA/RA 数值或权衡策略,本篇不代入具体数字,只借这个量化框架说明:「多合并几次段」和「少合并、多存几个小段」是同一枚硬币的两面,选择哪一面依赖对象存储的读写代价比,而不是「compaction 越多越好」。
3.2 与 handoff 的衔接(A 级)
Data Processing:当 Growing flush 成 Sealed,或 Data Node 完成 compaction 后,Coordinator 发起 handoff,重新分布 Sealed 到 Query Node,并释放冗余。
因此 compaction 不是「只在桶里改文件」:它会驱动 查询视图迁移。compaction 高峰期可能与查询加载争抢对象存储带宽与 Node CPU。
3.3 实现提示(不替代源码阅读)
Data Node compaction 实现中可见对
SegmentBinlogs 的计划驱动合并、可选 merge-sort
等分支(源码路径随 release 变化)。本系列不绑定某一 commit
的函数名表;升级时以当前
internal/datanode/compactor
与官方发布说明为准。
四、资源与调度:离线队列如何拖累在线 SLO
Coordinator「Historical Data Management」把 compaction 与建索当作可分发任务。工程上常见张力:
| 张力 | 表现 |
|---|---|
| 建索慢于写入 | 索引堆积;查询走劣路径或等待 |
| compaction 落后 | 小段爆炸;删除空间不回收;对象文件数上升 |
| 与查询争抢 | Query Node 加载与 Data Node 拉取同一桶前缀 |
| GPU 建索与 CPU 查询混部 | 若共享物理机,资源争用从对象存储带宽扩展到显存/PCIe |
| 多 Collection 共享 Data Node 池 | 一个 Collection 的批量导入可能挤占其它 Collection 的建索窗口 |
容量规划应把 Data Node 当成 独立池:不能假设「加 Query Node」自动消化建索队列。这个池的产出能力由三个因素共同决定——单任务耗时(数据量、维度、索引类型)、并发任务数上限(内存/CPU/GPU 配额)、任务排队策略(是否有优先级)。只调其中一个因素而忽略另外两个,观察到的效果往往不稳定。
把这条张力画成因果链,更容易在监控里定位「新鲜度变差」到底卡在哪一环——建索速率一旦低于 flush 产生新 Sealed 段的速率,队列深度会持续增长,而查询侧只能退化到更慢的兜底路径,用户感知到的是「新数据搜得到但慢、或者召回看起来不稳」:
flowchart TB
flush["Growing flush 成 Sealed(持续产生)"]
queue["Data Node 建索 / compaction 队列"]
backlog["队列深度持续增长<br/>(建索速率 < flush 速率)"]
interim["Query Node 退化到<br/>interim index / 更慢兜底路径"]
stale["用户感知:新数据搜得慢或召回不稳"]
flush --> queue
queue -->|"enqueue 快于 drain"| backlog
backlog --> interim
interim --> stale
backlog -.->|"compaction 任务也排在同一队列"| queue
图中的自环(虚线)是容易被忽视的一点:compaction 本身也要占用同一个 Data Node 任务队列。当建索已经堆积时,如果继续把 compaction 任务往里塞,只会让队列更深——这也是为什么第四节强调 Data Node 该当独立资源池看待,建索与 compaction 之间还需要一层优先级调度,而不是先来先服务。
五、最小故事:一次索引堆积如何被误诊成「Knowhere 变慢」
设想一次批量导入:短时间内产生了 50 个新 Sealed
segment,而 Data Node 建索的处理速率是每分钟 5
个。运维在导入完成 10
分钟后开始压测查询,观察到部分查询延迟明显偏高、部分召回结果和预期不一致,第一反应是「怀疑
HNSW 的 ef 参数设小了」或「怀疑 Knowhere 哪个
SIMD 路径没启用」。
按第二、四节的模型重新拆解这次现象:10 分钟内 Data Node
大概只能处理完 50 个任务里的一小部分,意味着这时仍有大量新
Sealed segment
还没有向量索引。查询命中这些段时,要么走暴力/临时路径(延迟高),要么召回口径和已建好索引的段不一致(表现为「召回不稳」)。这次现象的根因是
建索队列还没消化完这批导入,而不是索引参数或
SIMD 配置的问题——正确的排障动作是查 Data Node
任务队列深度与建索任务耗时,而不是先去改
ef。
同一个场景换一个变量,结论会不同:如果这批导入分 20 次、每次间隔 5 分钟小批量写入,而不是一次性写完,Data Node 的建索速率(每分钟 5 个)足以在下一批到达前消化掉上一批,队列深度不会持续累积,也就不会出现上面的现象。这说明索引堆积不是「数据量大」本身导致的,而是 写入速率的峰值 超过了建索处理速率——同样的总数据量,写入节奏不同,是否触发堆积可能完全不同。容量规划时应该按峰值写入速率对齐 Data Node 吞吐,而不是按日均写入量估算。
六、延迟归因:从症状定位到 Data Node 哪一环
第 7、9 篇分别给过 Segcore/Knowhere 层面与跨节点归并层面的延迟归因表;离线数据面的症状对应关系不同,容易被误判成在线路径的问题:
| 症状 | 更可能对应 | 先检查什么 |
|---|---|---|
| 批量导入后一段时间内召回不稳、延迟偏高,随后恢复正常 | 建索队列堆积(第五节故事) | Data Node 任务队列深度、建索任务平均耗时 |
| 小文件数、对象存储 API 调用量持续上升 | compaction 落后 | segment compaction 任务积压、触发阈值是否合适 |
| Data Node 与 Query Node 同时抢占对象存储带宽,两侧都变慢 | 建索/compaction 与查询加载争用同一对象存储 | 是否命中同一批段的桶前缀、带宽限额配置 |
| 只有极少数 Collection 表现异常,其它正常 | 该 Collection 的段划分策略不合理 | segment 数是否偏多(第四节「段数 ≈ 索引任务数」)、是否频繁触发小段 flush |
| GPU 建索任务排队但 GPU 利用率看起来不高 | 调度未按硬件类型区分任务队列 | 是否所有建索任务(含不需要 GPU 的)都挤在同一队列里排队 |
| 调大/调小 compaction 触发阈值后现象没有变化 | binlog 与 segment compaction 互相挤占(3.1 节) | 是否只调了一种 compaction 的参数,另一种仍占用队列窗口 |
| 对象存储侧确认索引已写入,但查询仍走旧路径 | 两次交付之间的窗口期(2.1 节) | Coordinator handoff 状态、Query Node data view 是否已更新 |
这张表的落点是:离线队列的问题会以「在线查询变慢/不稳」的方式暴露出来,但根因往往不在 Query Node 或 Knowhere,而在 Data Node 这一层——排障顺序应该先看队列深度,再看具体的索引参数。
这也解释了为什么第 7、9 篇的延迟归因表都把「是否命中冷段加载 / 是否与批量写入窗口重叠」列为检查项:那两张表覆盖的是在线路径,本表覆盖的是离线路径,三者拼起来才是完整的排障地图。
七、常见误解
误解一:compaction 只是后台清理磁盘空间,不会影响查询。 第三节 3.2 已明确:compaction 完成后 Coordinator 会发起 handoff,重新分布 Sealed 到 Query Node——compaction 直接驱动查询视图迁移,高峰期还会与查询加载争抢对象存储带宽和节点 CPU。
误解二:只要 flush 完成,segment 立刻就能用上向量索引搜索。 flush 只代表数据进入 Sealed 并可能被查询路径看到;向量索引的构建是 Data Node 队列里的异步任务,可能滞后。第五节的最小故事就是这类「有数据、无好索引」现象被误诊的典型场景。
误解三:查询变慢或堆积时,加 Query Node 就能缓解。 Query Node 解决的是「加载和检索」的并发与内存容量,不解决「建索/compaction 处理速率」的问题——后者是 Data Node 独立资源池的产出能力。批量导入后的索引堆积只能靠扩容 Data Node、调整调度优先级或放慢写入速率来缓解。
误解四:段越小、compaction 越频繁,系统整体表现总是更好。 第三节的写放大讨论已经说明:更频繁的 compaction 意味着更多重写字节数,即更高的 WA;段变小还会让「段数 ≈ 索引任务数」这条关系(第四节)更早触发建索风暴。压缩策略是权衡,不是「越勤快越好」。
误解五:binlog compaction 和 segment compaction 是两套互相独立的机制,可以分别单独调优。 两者共享同一个 Data Node 任务队列(3.1 节表格),调大其中一种的触发频率会占用另一种的调度窗口。孤立地调其中一个参数,观察到的效果可能来自另一个机制被挤占,而不是这个参数本身生效。
误解六:监控看到「索引对象已写入对象存储」,就可以认为这批数据已经在用新索引搜索。 第二节 2.1 已拆开两次交付:索引写回对象存储只是第一次交付,Query Node 完成 handoff 加载才是第二次交付。两次交付之间的窗口期,查询可能仍在用旧视图或兜底路径,只看对象存储侧的写入确认会得出「已生效」的错误结论。
八、学术谱系、工程间隙、开放问题
8.1 谱系
| 工作负载 | 经典机制 | Milvus 落点 |
|---|---|---|
| 不可变文件整理 | O’Neil et al., LSM-Tree, Acta Informatica 1996 | Data Node compaction |
| leveled vs tiered 权衡 | Dayan & Idreos, Dostoevsky, SIGMOD 2017 | 暂无公开分层策略描述;本篇不代入 |
| 写放大量化 | \(WA\) 公式(第三节) | Milvus 未公开等价量化框架 |
| 批量构建索引 | 离线 ANN 构建流水线 | 每 Sealed segment 建 Knowhere 索引 |
| 视图切换 | 快照发布 / handoff | Coordinator handoff |
| 无状态离线 Worker | 批处理任务重调度(不依赖节点本地状态) | Data Node 失败任务可直接重新调度(第一节) |
Wang et al. (SIGMOD 2021) 强调动态更新与查询并存;2.6 用 Streaming/Query/Data 三分把「在线可搜」与「离线重活」拆开,避免在查询节点上同步跑满 SIMD 建索。这个三分本身也回答了 LSM 论文里没有覆盖的一个问题:O’Neil et al. 的原始模型只区分「内存 MemTable」与「磁盘 SST」两层,没有「向量索引构建」这种计算密集、需要独立硬件资源池的后台任务类型——Milvus 把 Data Node 单独拆出来,某种程度上是给 LSM 式后台整理再加一层计算密集子任务的工程扩展。
8.2 工程间隙
- 博客中的触发阈值、
MaxSegmentSize倍数等是历史叙述;以当前版本配置与监控为准,勿把 2022 默认值当 2.6 真理。 - 无状态 Worker 的「失败任务可直接重调度」假设简化了容错逻辑,但没有解决「同一批任务反复失败」时的根因诊断——重试次数上限、告警阈值仍需要运维显式配置。
- 「每段一索引」简化全局重建,但使 段数 ≈ 索引任务数;盲目调小段大小会制造建索风暴。
- 对象存储按请求计费时,compaction 重写放大的是 钱与 API,不只是 CPU。
- LSM 社区围绕 leveled/tiered 的 WA(写放大)/RA(读放大)权衡有成熟的量化模型(Dayan & Idreos 等后续工作);Milvus 的 segment/binlog compaction 目前没有公开的等价量化框架——运维者调 compaction 触发阈值更多依赖经验试错,而不是像 LSM 调参那样有明确的 WA/RA 曲线可查。
- 「索引写回对象存储」与「Query Node 完成加载」是两次独立交付(第二节 2.1);监控如果只覆盖前者,会把「索引其实还没生效」误报成「已完成」。
8.3 开放问题
- 建索优先级如何相对 compaction、相对查询加载自动调度?
- GPU 建索与 CPU 查询并存时的集群分区策略(第 8、18 篇)?
- 多向量字段导致单段多索引时,部分索引成功部分失败的对外语义?
- 能否借鉴 LSM 分层调参的思路,给 segment compaction 补一套可观测的放大系数(重写字节数 / 有效数据字节数),让容量规划从「经验阈值」变成「可核算的曲线」?
- binlog compaction 与 segment compaction 共享同一队列时,是否需要像现代 LSM 实现那样区分优先级(例如高删除率的段优先做 binlog compaction),而不是简单的先来先服务?
- 标量索引(Bloom、哈希、树、倒排)建索资源需求远小于向量索引,是否值得在调度层单独拆出一条轻量队列,避免标量索引任务被向量建索任务长期阻塞?
九、小结
三句话小结
- Data Node 是离线臂膀:从对象存储拉 Sealed 快照,经 Knowhere 写回索引,并做 compaction 控制段数与删除空间;Coordinator handoff 把结果交给 Query Node。
- flush 完成不等于索引就位——建索是异步队列任务,批量导入后的「查询变慢/召回不稳」经常是索引堆积(取决于写入峰值速率是否超过建索处理速率),而不是参数或 SIMD 配置的问题。
- Data Node 应当被当成独立资源池规划:加 Query Node 解决不了建索/compaction 处理速率不足的问题。
在线 SLO 往往死在
离线队列与对象存储争用,而不是又一个
ef
参数——排障顺序应该是「先看队列深度和两次交付状态,再看索引参数」。
下一批进入过滤与一致性语义(第 11–14 篇);对照与选型见第 15–18 篇。
排障时把「延迟落在哪一层」的问题按顺序过一遍——Segcore/Knowhere(第 7、8 篇)、跨节点归并(第 9 篇)、离线队列(本篇)——往往比直接猜参数更快找到根因。
参考资料
核心论文
- O’Neil, P., Cheng, E., Gawlick, D., O’Neil, E., The Log-Structured Merge-Tree (LSM-Tree), Acta Informatica 33(4), 1996(不可变文件 + 后台整理的经典源头)。
- Dayan, N., Idreos, S., Dostoevsky: Are Your LSM Key-Value Stores Optimized?, SIGMOD 2017(leveled vs tiered 的 WA/RA 权衡;本篇仅作姊妹领域对照,不代入 Milvus 具体实现)。
- Wang et al., Milvus: A Purpose-Built Vector Data Management System, SIGMOD 2021。
文档
- Milvus Documentation v2.6.x, Architecture Overview。
- Milvus Documentation v2.6.x, Storage/Computing Disaggregation(Data Node 职责)。
- Milvus Documentation v2.6.x, Data Processing(index building、handoff 与 compaction 触发叙述)。
- How to Compact Data in Milvus?, Milvus Blog, 2022(binlog/segment compaction;B 级)。
- 第 3 篇 Segment 状态机、第 4 篇 Proxy 与 Coordinator。
- 第 6 篇 对象存储布局、第 8 篇 Knowhere。
- 第 13 篇 Delete·Upsert·TTL、系列 index。
返回 系列目录 | 上一篇:分布式 search 归并 | 下一篇:混合过滤
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【向量检索引擎】Collection · Partition · Segment · Channel:Growing 到 Sealed 的状态机
用最小故事钉住 Milvus 2.6.x 数据模型:Collection/Partition、vchannel/pchannel 与 Streaming Node 绑定,Growing/Sealed、flush 与 handoff 状态机,并纠正「一个 Collection 一张大图」等常见误解。
【向量检索引擎】Query Node 与 Segcore:段级 search 如何执行
按 Milvus 2.6.x Data Processing 与 Architecture 拆解 Query Node 对 Sealed 的加载与段级检索,说明 Streaming Node 上 Growing 路径与 Query Delegator 如何拼成一次 search,用最小故事与常见误解钉住 Segcore 与 Knowhere 的层次边界。
【向量检索引擎】Delete · Upsert · TTL:软删生命周期与覆盖写的两条路径
按 2.6.x Delete/Upsert/TTL 文档拆解软删 bitset 从逻辑不可见到 compaction 物理回收的完整生命周期,用官方 override/merge 内部步骤的时序图区分两种 upsert,并与 FreshDiskANN 的图索引删除模型对照,说明 Milvus 用「整段重建」而非「增量合并」处理删除。
【向量检索引擎】副本、负载与故障恢复:读缓存式副本与 WAL 单所有者
按官方 In-Memory Replica 与 Streaming Service 文档拆解副本组、shard leader、Proxy 缓存 failover 与 WAL Wait for Ready;用多副本拓扑图与迁移时序图说明 Milvus 的读副本更接近只读缓存池而非共识复制,并与 Chain Replication、PacificA 对照。