土法炼钢兴趣小组的算法知识备份

【向量检索引擎】对象存储上的 Segment 布局:快照、索引与寻址代价

文章导航

分类入口
databasestorage
标签入口
#milvus#object-storage#minio#s3#segment#binlog#parquet#storage-v2#vector-engine

目录

Sealed 段「不可变且落在对象存储」是 第 3 篇 的定义;Query Node「从对象存储加载历史数据」是 第 7 篇 的前提。本文回答一个更具体的问题:如果这时候你打开 MinIO 控制台或跑一次 mc ls,能看到什么,flush/建索如何读写它,以及为何在 S3 类存储上 文件切分方式会支配延迟

本文是「向量检索引擎」系列第 6 篇(共 19 篇)。→ 系列目录

版本锚定:Milvus 2.6.x Architecture Overview / Data Processing(A 级)。对象路径命名参考 milvus-io/milvus 源码(internal/metastore/kv/datacoord/util.go 等,随 release 演进)。Storage V1→V2 布局演进引用官方工程博客(B 级),数字为原文陈述,非本机复现。


一、最小故事:flush 之后,桶里到底多了什么

假设你在本机用 Docker 跑了一个单机 Milvus,建了一个 collection,插入了一批向量,调用 flush(),再对某个字段建了索引。这时候去看对象存储桶(默认前缀 files/),会看到四类目录:

files/
├── insert_log/    # 主键、向量、标量字段的原始数据(flush 产物)
├── delta_log/      # 被删除实体的主键记录
├── stats_log/      # 段级统计信息,例如 Bloom filter
└── index_files/     # Data Node 建索引后的产物

一个常见的错误直觉是:「反正都在一个 collection 目录下,随便找一个前缀 list 一下就知道有什么」。实际的路径更细——每一层都要下钻到具体的 collection / partition / segment(乃至 field)编号,这不是随意设计,而是直接决定了「一次点查要发出多少次对象存储 API」的关键结构。下一节先把这棵路径树画出来,再解释为什么它长这样。


二、存储三件套里的对象存储

官方四层文档把持久化拆成:

组件 存什么 要求
Meta(etcd) schema 快照、消费 checkpoint;服务注册与健康检查 高可用、强一致、事务
WAL 提交前的写日志(Kafka / Pulsar / Woodpecker) 耐久、可 replay(第 5 篇)
Object storage 日志快照、标量与向量的索引文件、查询中间结果 便宜、可扩展;官方同时指出 高访问延迟、按请求计费

Milvus 默认常用 MinIO,并可部署到 AWS S3、Azure Blob 等。对象存储不是「又一块本地盘」,而是 高延迟随机打开的共享字节池——单次 GET/LIST 的延迟量级是毫秒到几十毫秒,且按请求计费。这决定了段文件布局必须减少小文件与 API 次数,而不是像本地文件系统那样「多开几个文件也无所谓」。


三、对象路径树:从 collection 到一份具体的 log 文件

结合社区讨论与源码(internal/metastore/kv/datacoord/util.go 中的 buildFieldStatslogPath 等路径构造函数,以及为 index_files 路径新增 collection 维度的源码改动 PR #49087),Milvus 的对象路径遵循「按 collection → partition → segment 逐级下钻」的统一模式:

flowchart TD
  root["bucket root<br/>minio.rootPath, default files/"]
  il["insert_log/"]
  dl["delta_log/"]
  sl["stats_log/"]
  ix["index_files/"]
  root --> il
  root --> dl
  root --> sl
  root --> ix
  il --> ilp["{collectionID}/{partitionID}/{segmentID}/{fieldID}/{logID}"]
  dl --> dlp["{collectionID}/{partitionID}/{segmentID}/{logID}"]
  sl --> slp["{collectionID}/{partitionID}/{segmentID}/{fieldID}/{logID}"]
  ix --> ixp["{collectionID}/{partitionID}/{segmentID}/{buildID}/{indexVersion}/{fileKey}"]

这棵树最直接的读法:同样一个 collection,路径层级越深、分叉越多,一次操作要触达的对象就越多insert_logstats_log 下钻到字段级,意味着一个 100 字段的 collection,仅这两类前缀下就有 200 组独立子路径;index_files 下钻到 buildID/indexVersion,意味着同一段哪怕只重建过一次索引,也会同时存在两代索引对象,直到 GC 介入才回收旧的一代。这些细节平时不需要背,但排障时用 mc ls -r 之类工具核对某个段下到底有多少文件,比空对着架构图猜测更快定位问题。

四类前缀对应四种数据:

前缀 内容 何时产生
insert_log/ 原始插入数据(主键、向量、标量字段) flush(Growing → Sealed)
delta_log/ 被删除实体的主键 delete / upsert 的删除部分(第 13 篇)
stats_log/ 段级统计信息,如 Bloom filter flush 时随数据一起生成
index_files/ 向量/标量索引产物 Data Node 建索引完成后写回(第 8、10 篇)

insert_logstats_log 都下钻到 字段(fieldID) 这一级,delta_log 不区分字段(删除只记主键),index_files 下钻到 buildID / indexVersion 这一级——因为同一段可能被反复重建索引,需要靠版本号区分新旧产物,避免 GC 误删仍在服务的旧索引。这棵路径树不是巧合的目录习惯,而是第五节要讲的「按字段拆分」布局在文件系统层面的直接体现。


四、从 Growing 到对象上的 Sealed:一次 flush 的时序

Data Processing 路径(与第 3、5 篇对齐):

  1. Streaming Node 将 WAL 提交条目切成 Growing;
  2. flush 后数据持久化到对象存储,形成 不可变 Sealed(写入 insert_log / stats_log);
  3. Data Node 从对象存储加载该段快照,建索引,再把索引写回对象存储(写入 index_files);
  4. Query Node 按 handoff 结果加载段数据与索引。
sequenceDiagram
  participant SN as Streaming Node
  participant OBJ as Object storage
  participant DN as Data Node
  participant QN as Query Node
  SN->>OBJ: flush growing segment<br/>write insert_log + stats_log
  Note over SN,OBJ: segment becomes immutable Sealed
  DN->>OBJ: load segment snapshot for index build
  DN->>DN: deserialize, build vector/scalar index
  DN->>OBJ: write back index_files
  QN->>OBJ: load segment data + index (after handoff)
  QN-->>QN: segment ready to serve historical search

因此对象存储上至少有两类读者关心的对象族:段数据快照insert_log/stats_log)与 索引文件index_files,每段可有自己的索引)。中间查询结果亦可落对象存储(Architecture Overview)——多用于大结果或特定执行路径,本篇不展开为默认热路径。


五、布局演进:按字段拆分 vs 按段整合

5.1 V1:segment 内多 binlog,一份 field 一堆小文件

较早的工程叙述与源码路径仍大量出现 binlog 一词:段内用多份二进制日志/列文件记录插入与变更,每个字段各自一套 insert_log/stats_log 文件(历史博客 How to Compact Data in Milvus?,B 级)。第三节的路径树里 {fieldID} 这一级正是这种「按字段拆分」的直接产物:一个 collection 有多少字段,insert_log 下就有多少组独立文件。

官方工程博客 Data Addressing Deep Dive: From HashMap to Milvus(Bill Chen,Milvus Blog,2026-03,B 级)给出一个可核对的思想实验:一个 100 字段、1000 段的 collection,若每字段每段平均有 5 个 binlog 文件,文件总数是

\[ N_{\text{files}} = \text{segments} \times \text{fields} \times \text{binlogs\_per\_field} = 1000 \times 100 \times 5 = 500{,}000 \]

原文进一步指出:即使一次查询只涉及 3 个字段,也要产生约 15,000 次对象存储 API 调用;按该文单次 S3 GET 约 50 ms 估算,串行化总延迟可达 750 秒,即 超过 12 分钟完成一次查询。这组数字是官方博客的思想实验结果,不是本站实测,用来说明的是「按字段拆分」在字段数与段数都增长后,文件数是二者的 乘积 而不是加和,增长速度far快于直觉。

5.2 V2:段级 Parquet,把「乘积」变成「常数倍」

同一篇博客描述的修复方式:按段整合,把同一段内的字段合并进少数 Parquet 文件,而不是按字段各自开文件:

方面 V1(按字段拆分) V2(按段整合)
文件组织 字段各自 binlog 以 segment 为单位整合
每 collection 文件数(原文表述) \(N \times \text{fields} \times \text{binlogs}\) \(N \times\) 列组数(column group)
格式 自定义 binlog Parquet(原文亦提及可支持 Lance、Vortex)
统计信息 独立 stats_log 文件 嵌入 Parquet footer
原文给出的量级感受 10,000+ 次 S3 API / 查询 约 1,000 次 S3 API / 查询,延迟从分钟级降到秒级

V2 并不是把一个 collection 的所有字段塞进一个文件,而是 按字段大小分组:小的标量字段(如主键、时间戳)合并存放,大的稠密向量字段单独存放为专用文件——这样小字段的 row group 能装下更多行,大字段仍按自己的尺寸特点布局;schema 演进时新增字段只是多开一个文件,删除字段则读时跳过,不需要重写历史数据。

这些倍数与「分钟/秒」是原文叙述,不是本站实测;读者应在自己的 MinIO/S3 上用段数 × 文件数核对实际 LIST/GET 次数,而不是直接套用博客给出的绝对数字。

5.3 读路径寻址代价:一次点查经过哪几步

同一篇博客把对象存储上的性能问题归结为一个通用公式:

\[ \text{寻址代价} \approx \text{元数据访问次数} + \text{数据访问次数} \]

WHERE id = 123 这类点查为例,V2 布局下的寻址步骤是:

flowchart LR
  q["Query: id = 123"]
  meta["Metadata lookup<br/>fetch segment list"]
  footer["Read Parquet footer<br/>row-group min/max stats"]
  prune["Column + row-group pruning<br/>id column only"]
  data["Fetch matching row group<br/>id + vector columns"]
  q --> meta --> footer --> prune --> data

关键在于 column pruning:Parquet footer 里的每个 row group 都带 min/max 统计,id = 123 若落在某个 row group 的区间内,只需要读该 row group 里 id 列(判断是否命中)与后续 vector 列(取回结果),不需要打开整份文件,也不需要像 V1 那样为每个字段单独发一次 API。V1 与 V2 在「一次点查」上的本质差别,就是把「文件数 = 段数 × 字段数 × binlog 数」压缩成「文件数 ≈ 段数 × 列组数」,从而把 API 调用次数量级往下拉。

5.4 与 2.6 写路径的关系

社区变更说明(如强制新写走 StorageV2 的 PR 叙述)表明 2.6 线将新写入统一到更新布局。本系列结论只取可核对的工程含义:

  1. 对象存储友好布局 = 减少小文件与按请求计费
  2. Query Node 加载与 Data Node 建索的输入输出都围绕「段在对象上的快照 + 索引对象」;
  3. 具体键前缀、列组切分以当时 release 的 storage 实现为准——排障时用对象浏览器/mc ls每段文件个数,比背路径模板更有用。

六、索引对象与数据对象的生命周期差

建索流程(Data Processing,A 级):

  1. Data Node 从对象存储加载待建索段快照到内存;
  2. 反序列化数据与元数据,调用索引构建(Knowhere,第 8 篇);
  3. 序列化索引,写回对象存储(index_files)。

因此可能出现:

「磁盘上有索引文件」≠「Query Node 已加载该索引」——后者还取决于 Coordinator 的 data view(第 4、10 篇)。index_files 路径里带 buildID/indexVersion,正是为了让新旧索引产物在 GC 之前能同时存在而不互相覆盖。


七、与湖仓对象布局的一句对照

lakehouse 里 Iceberg/Parquet 优化的也是对象存储上的 清单 + 文件剪枝Data Addressing Deep Dive 原文本身也把 Iceberg 的「metadata.json → manifest list → manifest → data files」四层剪枝与 Milvus 的段级 Parquet 并列,作为「对象存储上都在优化同一个寻址代价公式」的例子。原文举的例子是:面对SELECT * FROM orders WHERE date=... AND amount>...这类查询,Iceberg 用四层元数据把 1000 个数据文件的候选集剪到 3 个,整个过程只花 7 次 I/O——这与 Milvus V2 用 Parquet footer 统计信息剪掉大部分 row group,是同一个「先看统计信息、再决定读不读」的思路在两种系统里的具体实现。lakehouse/21 显示 Lance 在随机 take 上相对 Parquet 的优势。Milvus 的对象布局服务的是 ANN 段生命周期与索引文件,不是通用表格式事务。二者可共享同一对象桶,但 元数据权威不同——不要假设 Iceberg snapshot 与 Milvus segment 视图自动一致(第 16、18 篇)。


八、常见误解

8.1 「对象存储里一个 collection 就是一个大文件」

不对。即使是 V2 的段级整合布局,一个 collection 也会拆成 segments × column groups 量级的文件;V1 时代甚至是 segments × fields × binlogs。文件数从来不是 1,「整合」指的是把乘积项之一(字段拆分)收敛掉,不是把所有段合并成一个文件。

8.2 「换更贵的 SSD 型对象存储就能解决延迟问题」

不一定。Architecture Overview 明确指出对象存储的代价来自 访问延迟与按请求计费,而 V1→V2 的核心动机是 减少 API 调用次数,不是提升单次调用速度。如果小文件数量级不变,换更快的后端只能小幅缓解,真正的杠杆在文件布局本身——这也是官方博客用「12 分钟 → 秒级」而不是「换存储硬件」来描述改进的原因。

8.3 「有索引文件就代表这个段已经能被索引加速检索」

不对。索引构建(Data Node 写完 index_files)与索引加载(Query Node 从对象存储读回并替换本地视图)是两个独立步骤,中间还要经过 Coordinator 的 handoff 决策。生产上「有数据、召回却很差」的排障,往往落在这个「索引已写、尚未加载」的窗口,而不是索引算法本身有问题(第 10、17 篇)。


九、学术谱系、工程间隙、开放问题

9.1 谱系

问题 经典应对 在本篇的落点
共享存储上的小文件 合并文件、列组、清单 StorageV2/Parquet 段布局(B 级博客)
不可变数据 + 后台整理 LSM compaction、列存 merge Data Node compaction(第 10 篇)
寻址代价的通用建模 元数据分层、缓存、剪枝(HashMap → HDFS → Kafka → Iceberg 的同一族思路) Data Addressing Deep Dive 把 Milvus V1/V2 与 HDFS、Kafka、Iceberg 并列在同一寻址代价框架下讨论

把 Milvus 的 V1→V2 演进放进这条谱系里看会更清楚:HashMap 用哈希把「扫描」变成「计算」;HDFS 把整棵目录树的元数据搬进内存,用一次 RPC 换掉一次全表扫描;Kafka 用稀疏索引把「随机 I/O」变成「一次索引查找 + 一次小范围顺序读」;Milvus V2 的 Parquet footer 统计信息,做的是同一件事的向量数据库版本——用少量元数据(min/max、row group 边界)在读数据之前就排除掉大部分文件

9.2 工程间隙

9.3 开放问题

  1. 段大小、列组策略与 S3 计费模型如何联合优化?这是一个多目标权衡问题,官方文档未给出普适公式。
  2. 索引文件与数据快照分桶/分存储类(IA/Archive)时,handoff 延迟如何建模?
  3. 与 Iceberg 共用桶时,生命周期策略误删索引前缀的防护如何做到与表格式生命周期规则解耦?
  4. 当列组内某一列(例如向量列)远大于其它列时,row group 大小的选择本身是否存在与查询选择率相关的最优解,还是只能靠经验值?

十、小结

三句话小结

  1. 对象路径按 insert_log / delta_log / stats_log / index_files 下钻到 collection/partition/segment/field,路径树决定 API 次数。
  2. Storage V1 按字段拆分易小文件爆炸;V2 按段整合并靠 footer 统计做 pruning。
  3. 索引文件写完 ≠ 已服务查询——中间还有 handoff 与 Query Node 加载窗口。

下一篇把多段候选如何收成一次响应:分布式 search 归并

排障时可以把本文三条误解转成检查动作:怀疑「小文件爆炸」时,用 mc ls -r 数一数某个 collection 下 insert_log/stats_log 的实际文件数,对照段数与字段数估算是否符合 V1/V2 的量级差异;怀疑「换存储没用」时,先确认瓶颈是 API 调用次数还是单次调用带宽;怀疑「索引没生效」时,去分别确认 index_files 是否已经写出、以及 Coordinator 的 data view 是否已经把它 handoff 给了正在服务的 Query Node。


参考资料

  1. Milvus Documentation v2.6.x, Architecture Overview / Storage/Computing Disaggregation(Object storage 职责与代价)。
  2. Milvus Documentation v2.6.x, Data Processing(flush、index building 读写对象存储)。
  3. Bill Chen, A Deep Dive into Data Addressing in Storage Systems: From HashMap to HDFS, Kafka, Milvus, and Iceberg, Milvus Blog, 2026-03(Storage V1/V2、寻址代价公式、思想实验数字;B 级)。
  4. How to Compact Data in Milvus?, Milvus Blog, 2022(binlog/segment compaction 历史叙述;B 级)。
  5. milvus-io/milvus, internal/metastore/kv/datacoord/util.gobuildFieldStatslogPath 等路径构造函数);PR #49087(index_files 路径新增 collectionID 维度);GitHub Discussion #37643(社区确认 insert_log/stats_log 路径层级,作补充线索,非单独支撑结论)。
  6. 第 3、5、7 篇lakehouse/21

返回 系列目录 | 上一篇:Proxy 与 Coordinator | 下一篇:分布式 search 归并

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-12 · database / storage

【向量检索引擎】Lance / LanceDB 对照:格式还是服务

承接 lakehouse/21 的 Lance vs Parquet 实测口径,用官方 Table Format 规范拆开 fragment/manifest/version 与 Milvus Segment/WAL 的边界;对照两边索引异步构建的共性,并给出湖侧 snapshot 与在线引擎可见性水位的对齐方式。

2026-06-30 · database / storage

【数据湖与开放表格式】对象存储语义与代价

对象存储不是网络版 POSIX 文件系统。本文用 S3 官方语义钉住四件事:强一致模型的边界、LIST 随对象数线性增长的代价、没有原子 rename(只能 copy+delete)、条件写(If-None-Match/If-Match)对提交协议的意义,并讲清 multipart 与对象不可改写。


By .