Milvus 外部表新格式milvus-table:把 Milvus 快照当作可查询外部表源的完整设计解读
【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus
Milvus 推出了面向外部表(external table)的新格式milvus-table,它允许把一份由 Milvus 自身生成的集合快照(snapshot)当作外部表的数据源来创建与刷新外部集合,从而以零拷贝方式直接读取快照中 StorageV3 列组文件。本文基于仓库内的设计文档 20260526-milvus-table-external-source.md 展开,结合rootcoord、datacoord、datanode、querynodev2、storagev2/packed等模块的实现文件,系统讲解该格式的公共契约、Schema 对齐、刷新流程、物理列解析、主键模式、删除语义与失败行为。读完本文,你将理解milvus-table与 Parquet 等常规外部表格式的本质差异,掌握它的创建约束、配置格式与内部数据通路,并能在自己的部署中判断"快照外部表化"这一方案的适用边界。
一、背景:为什么"快照"需要一种新的外部表格式
Milvus 已有的外部表体系支持把 Parquet、Lance、Vortex、Iceberg 等格式的外部数据文件当作只读集合查询。这些格式的数据是相互独立的数据文件集合,外部表把用户路径下的文件范围映射成外部片段(external fragment)即可。
但 Milvus 快照(snapshot)并不是一堆独立数据文件。一份 Milvus 集合快照里包含:
- 集合的 Schema(字段定义与字段 ID);
- 段清单(segment manifests);
- 删除日志(delta logs,含 L0 覆盖层);
- 主键布隆过滤器统计(primary-key bloom-filter statistics)。
因此,若要把快照作为外部表源,目标外部集合必须在数据字段上保留源字段的身份(field ID),同时仍然以普通的 Milvus 字段名和external_field映射暴露给用户。这正是milvus-table格式要解决的核心问题,也是它与"通用文件格式外部表"在实现上分道扬镳的起点。
该设计对应 issue #45881,当前仓库中的状态为Implemented,涉及 External Table、Snapshot、StorageV3、QueryNode、DataNode 等组件,设计文档位于 20260526-milvus-table-external-source.md。
二、公共契约:external_spec、external_source与字段映射
2.1 最小化的公开接口
milvus-table格式不新增任何 Milvus-table 专有的source_collection_id、snapshot_idAPI,而是复用外部表既有契约,仅通过两个字段选择格式与数据源:
external_spec.format取值milvus-table;external_source指向具体的快照元数据 JSON 文件路径。
external_spec示例(来自设计文档 Public Contract 小节):
{ "format": "milvus-table", "extfs": { "cloud_provider": "aws", "region": "us-west-2", "access_key_id": "...", "access_key_value": "..." } }其中extfs描述访问快照所在对象存储所需的云厂商与凭据信息。external_source则必须是快照元数据 JSON 的完整对象存储路径:
s3://bucket/snapshots/{source_collection_id}/metadata/{snapshot_id}.json在代码层面,外部格式常量集中定义于 pkg/util/externalspec/external_spec.go(FormatMilvusTable,见 L36-L40),其底层解析逻辑位于 pkg/util/externalspec/specutil/spec.go:该文件维护了受支持格式白名单(Parquet、lance-table、vortex、iceberg-table、milvus-table)与ParseExternalSpec等入口,spec_test.go 中对{"format":"milvus-table"}的解析用例可以直接验证这一点。
设计上刻意不把"快照路径布局"暴露进外部表 API——快照元数据 JSON 是唯一权威产物(canonical artifact)。这样外部表 API 就无需耦合 Milvus 内部快照目录结构;若将来内部布局调整,只需保证 metadata JSON 路径不变即可。
2.2 禁止链式(chaining)
快照源必须是普通 Milvus 集合生成的快照。用另一个外部集合的快照再去创建或刷新milvus-table外部集合会被拒绝。原因很直接:若允许链式,刷新与读取路径就需要追着前一个集合的外部源和存储契约继续解析,这已超出milvus-table快照契约的职责边界(详见设计文档 Public Contract 的说明)。
2.3 目标集合的字段映射链
目标集合 Schema 使用普通字段名。对每个用户数据字段,external_field必须填源字段名:
目标字段名 -> external_field(源字段名) -> 源字段 ID创建时 RootCoord 读取源快照元数据,把源字段 ID 复制进目标字段;持久化的目标 Schema 里external_field仍然保存源字段名字符串,而不是数字形式的字段 ID。
三、端到端架构:六大组件各司其职
设计把 Milvus 特有行为尽量压进既有外部表生命周期,而不是扩大公共 API。设计文档 Implementation Overview 给出的组件分工如下:
| 组件 | 职责 |
|---|---|
| RootCoord | 创建外部集合时读取快照元数据,校验 Schema 身份,将目标数据字段 ID 对齐到源字段 ID,拒绝外部表链式 |
| DataCoord | 构建刷新任务(refresh job),预分配 ID 区间,把 DataNode 结果归类为 kept / new / manifest-only update 三类段更新 |
| DataNode | 读取 Milvus 快照探索清单(explore manifest),生成目标 StorageV3 manifest,执行目标函数,复制或翻译删除日志,并抽样估算 fake-binlog 内存大小 |
storagev2/packed | 解析快照元数据、解析源相对路径,通过 Loon FFI 导入源 manifest,对外暴露 Milvus-table manifest 辅助函数 |
| QueryNode | 加载外部 StorageV3 manifest,拆分源侧与目标侧 deltalog,按需 eager 加载真实 PK 与时间戳列 |
| Segcore | 解析物理存储列,合成虚拟 PK 系统字段,按需加载真实 PK 与源时间戳,输出字段使用take() |
其中主数据路径是零拷贝的:目标 manifest 在根据external_source解析出真实路径后,直接指向源 StorageV3 列组文件;仅当需要写入生成式函数的输出、或删除日志必须落在目标 PK 空间时,才会在目标侧写入新文件。
四、Schema 对齐与 field ID 保留(RootCoord 阶段)
4.1 为什么必须对齐字段 ID
StorageV3 的段清单使用字段 ID 字符串作为物理列名:例如源字段 ID101,其物理列就叫"101"。如果目标集合被分配了不同的字段 ID,那么读取时就会指向错误或不存在的列。因此对齐发生在最早期——集合创建时。
4.2 RootCoord 的创建流程
设计文档给出 RootCoord 在创建时的完整步骤:
- 校验
external_source与external_spec; - 解析快照元数据 JSON;
- 校验目标 Schema 与源快照 Schema 的身份一致性;
- 构建"源字段名 → 源字段 Schema"映射;
- 对每个目标用户数据字段,通过
external_field找到匹配的源字段,把源字段 ID 写入target.field_id; - 给创建请求打上
preserve_field_ids=true标记。
字段对齐逻辑在 internal/rootcoord/create_collection_task.go 中实现,配套测试见 internal/rootcoord/create_collection_task_test.go。
4.3 哪些字段属于"目标自有字段"
以下字段不映射源数据列,由目标侧自持:
- 系统字段(如 RowID 等);
- 目标虚拟主键字段(当目标没有用户主键时由系统注入);
- 目标函数输出字段(function output fields)。
值得注意的一个细节:如果源普通快照里已经存有某个函数输出字段的列,目标的一个普通字段仍可通过external_field映射到这份已存储的源列;而目标集合自己的函数输出在每次刷新时重新计算,不读取外部源数据。这一边界也在设计的 Non-Goals 中明确列出:目标函数输出字段是 target-owned,刷新时重新生成,绝不从外部源数据反读。
4.4 DDL 重放不依赖源桶
在 DDL 重放(replay)时,只要preserve_field_ids已置位,RootCoord会跳过重新读取外部快照。这保证了 Schema 一旦持久化,故障恢复就与源桶及其凭据解耦,不再需要回源校验。
五、刷新流程:DataCoord 编排与 DataNode 落盘
5.1 探索阶段
DataCoord 的刷新任务会重新校验外部源与 spec,读取快照元数据,然后在目标存储下写出探索清单(explore manifest);DataNode 按自己分配到的文件区间消费这份清单并生成目标段清单。
对milvus-table而言,DataCoord 探索时会读取快照元数据 JSON,且强制要求存在storagev2_manifest_list字段。若缺失则快速失败并给出明确错误——因为这通常意味着源快照并非来自 StorageV3 集合。这一点有直接源码佐证:在 internal/storagev2/packed/milvus_table.go 中,错误errMilvusTableStorageV2ManifestListMissing的文案明确写着:"milvus-table requires storagev2_manifest_list in snapshot metadata; create the source snapshot from a StorageV3 collection (enable common.storage.useLoonFFI=true before writing source data)"。也就是说,要产生可用于milvus-table的源快照,源集合在写数据阶段就必须启用 StorageV3 / Loon FFI 存储。
5.2 片段身份(fragment identity)
探索会把源 StorageV3 段清单转换成目标侧的外部片段,片段身份由三元组唯一标识:
source_manifest_path:start_row:end_row关键设计点是:删除日志不参与片段身份。这带来一种很有用的刷新行为——如果 L1 数据片段没变、只是快照的 L0 覆盖层变了,DataNode 会重写目标段清单,并返回既有 segment ID 作为"更新段",DataCoord 就地更新该段的ManifestPath,而不是先删后建;只有当源 L1 manifest 路径或行区间变化时,旧目标段才被作废并新建目标段。
三种情形的刷新行为归纳如下:
| L1 片段 | L0 覆盖 | 刷新行为 |
|---|---|---|
| 变化 | 任意状态 | 丢弃旧目标段并创建新目标段 |
| 未变化 | 未变化 | 目标段保持不变 |
| 未变化 | 新增 / 删除 / 变化 | 保留目标段 ID,仅重写其 manifest |
5.3 一个片段一个目标段
目前milvus-table下 DataNode 对每个刷新片段创建一个目标段,而不会把多个源片段 bin-pack 进一个目标段。这样做的理由是把"基于行偏移的虚拟 PK 与删除日志翻译"限制在单个目标 manifest 内、保证局部性;代价是源快照中 StorageV3 段很多时,会产生较多目标外部段并消耗更多预分配 ID。设计文档也指出:未来的 bin-packing 需要一个显式的"源行偏移 → 目标行偏移"映射层,才能安全换算虚拟 PK 删除。
5.4 目标段清单的构造差异
目标段清单的构造方式与通用外部文件不同:
- Parquet 等格式是从文件区间创建列组(column groups);
milvus-table则是从源段清单导入源 StorageV3 列组进目标段清单。
这保证了列数据主体零拷贝;仅当源删除需要转换或复制时,才可能写出目标自有的 deltalog。
DataNode 会把 manifest 直接写到最终的 StorageV3 insert-log 路径下,使用 DataCoord 预分配的 ID 区间。该区间被三处消耗:目标段 ID、fake-binlog 的 log ID,以及当源 deltalog 没有携带显式 LogID 时的回退 deltalog ID。
相关实现集中在 internal/datanode/external/ 目录,核心文件包括milvus_table_refresh.go、milvus_table_deltalog.go、task_update.go、function_executor.go及其同名_test.go;DataCoord 侧的任务编排在 internal/datacoord/external_collection_refresh_manager.go(配套测试external_collection_refresh_manager_test.go)。刷新任务的对外状态机(Pending / InProgress / Completed / Failed)与进度信息可参考 client/entity/external_table.go 中的RefreshExternalCollectionState与RefreshExternalCollectionJobInfo。
六、物理列解析规则:一套规则,双端实现
持久化 Schema 只存external_field(源字段名),那么"物理列到底叫什么"就需要统一裁决。设计把物理列选择集中到两处:
- Go 侧:
StorageColumnResolver; - C++ 侧:
Schema::GetPhysicalColumnName/Schema::IsExternalManifestStoredField。
规则本身取决于"读的是源数据,还是目标段清单":
| 格式 | 物理列名 |
|---|---|
| Parquet、Lance、Vortex、Iceberg | external_field(即源字段名) |
| Milvus table | 目标字段 ID 字符串(已对齐到源字段 ID) |
为什么milvus-table要用数字字段 ID 字符串?因为源 StorageV3 manifest 本就是 Milvus 写的,物理列按字段 ID 存储。而目标自有字段(虚拟 PK、目标函数输出)不属于源数据,其中函数输出在刷新后以目标数字字段 ID 落盘。
该规则在实际物理访问点统一生效,包括:
- DataNode 刷新时的字段大小抽样;
- 存储 manifest 读取器;
- QueryNode 段加载;
- Segcore 的 search / query / retrieve / take;
- DataNode 索引构建;
- 外部字段大小抽样。
设计文档特别说明:早期"把external_field改写为数字字符串"的方案被否弃,因为那样会把用户意图藏进持久化 Schema,后续维护极易出错。
七、两种主键模式
根据目标集合是否声明用户主键,milvus-table分为"真实主键"与"虚拟主键"两条数据通路。
7.1 真实主键(Real Primary Key)
若目标 Schema 含用户主键,则它必须映射到源快照的主键。真实主键列在 QueryNode 段加载时被 eager 加载,以便 retrieve / take / search 输出真实 ID。真实 PK 段的要点:
- 导入源 PK 布隆过滤器统计(或以外部统计方式读取),QueryNode 可复用常规 PK 剪枝;
- 源段 deltalog 作为外部 StorageV3 delta 路径导入目标 manifest;
- 快照 L0 覆盖层则被复制为目标自有 StorageV3 deltalog,落在目标段 base path 之下;
- 加载时 QueryNode 把"源外部 deltalog"与"目标自有 deltalog"分开:前者用外部 StorageV3 读取器解码,后者用常规目标存储配置解码;
- Segcore eager 加载源插入时间戳列——这样即使出现"先删后插(delete-before-reinsert)",快照作为外部表后仍能保持原有的可见性语义。
7.2 虚拟主键(Virtual Primary Key)
若目标集合没有用户主键,Milvus 会注入外部虚拟主键,其取值为:
virtual_pk = (target_segment_id & 0xffffffff) << 32 | row_offset即"段 ID 的低 32 位左移 32 位 + 段内行偏移"。虚拟 PK 段的要点:
- 源 PK 布隆过滤器不能复用,因为它以源 PK 为键,而非目标虚拟 PK;
- QueryNode 路由使用保守的 PK 候选;
- 刷新时,DataNode 把源段 deltalog 与快照 L0 覆盖层里的"源 PK 删除记录"全部转换成"目标虚拟 PK 删除记录"。
转换策略是先读删除键,再只扫描源主键列去匹配这些键,从而让内存占用正比于"删除键数 + 命中行数",而不是源总行数。
八、删除语义贯穿(Delete Handling)
Milvus 快照可能包含两类删除来源:
- 挂在源段 manifest 上的 deltalog;
- 快照段清单引用的 L0 delta 覆盖层。
8.1 真实 PK 目标:删除语义留在源 PK 空间
- Loon FFI 的 manifest 构造器在目标有外部主键时,从源 manifest 导入源段 deltalog;
- DataNode 把快照 L0 覆盖层复制为目标自有 StorageV3 deltalog;
- QueryNode 通过外部 StorageV3 deltalog 读取器加载源侧删除、通过常规读取器加载目标侧删除;
- Segcore 使用源插入时间戳比较删除时间戳,使删除只对插入时间早于删除时间的行生效。
8.2 虚拟 PK 目标:删除语义必须转换
- 合并快照 L0 覆盖层与源段 deltalog;
- 读取源 deltalog,收集被删除的源 PK;
- 仅对受影响片段扫描源 PK 列与时间戳列;
- 把每个匹配的源行偏移映射到目标虚拟 PK;
- 写出目标自有虚拟 PK deltalog;
- 将这些 deltalog 加入目标 StorageV3 manifest。
当源 PK 存在重复时,一个删除键会映射到所有匹配的目标行偏移;时间戳排序遵循 Milvus 既有删除语义(删除仅移除插入时间戳早于删除时间戳的行)。deltalog 路径判断相关辅助函数可在 internal/storagev2/packed/milvus_table.go 找到,例如用/_delta/识别 StorageV3 deltalog、用/delta_log/识别流式删除冲刷产生的旧版 L0 deltalog。
九、QueryNode 加载与读写路径
QueryNode 把milvus-table外部段当作带 StorageV3 manifest 的常规 sealed 外部段加载。
加载阶段的行为:
- 物理列名一律经 Schema 解析(见第六节规则);
- 真实 PK 字段 eager 加载;
- 真实 PK 的
milvus-table段额外 eager 加载源时间戳列; - 虚拟 PK 使用既有的合成虚拟 PK 列机制;
- deltalog 对外部段不再被跳过;真实 PK 段在解码前会先把源外部 delta 路径与目标自有 delta 路径分离;
- 真实 PK 的布隆过滤器统计从外部感知(external-aware)的存储中读取。
相关代码集中在 internal/querynodev2/segments/segment_loader.go 与 internal/querynodev2/segments/utils.go,并有对应_test.go覆盖外部真实 PK 布隆过滤器与 deltalog 的加载行为。
search / query / retrieve / take 阶段的行为:
- Segcore 用
Schema::GetPhysicalColumnName请求物理列:对milvus-table返回字段 ID 字符串,对其他外部格式返回external_field; - 输出字段 ID 与结果 ID 仍是目标的 Milvus 字段 ID 与目标 PK——对外用户看到的是普通外部表语义。
十、索引构建保持一致
索引构建没有单独维护一套"Schema 变更路径"。DataNode 把external_source与external_spec原样透传进BuildIndexInfo,C++ 侧索引构建读取external_spec,并采用与查询路径完全相同的物理列解析规则:
milvus-table:字段 ID 字符串;- 其他外部格式:
external_field。
由此保证索引构建与 load / search / query / take 完全同构,避免索引数据与查询数据指向不同物理列。
十一、兼容性与失败行为
设计明确列出以下失败语义(全部为 fail-fast 参数/校验错误):
milvus-table的external_source不是 JSON 路径:创建或刷新直接以参数错误失败;- 快照元数据缺少
storagev2_manifest_list:刷新快速失败(参考 internal/storagev2/packed/milvus_table.go 的报错定义,其中明确提示源须为启用 Loon FFI / StorageV3 的集合); - 源 deltalog 不是 StorageV3 的
_delta路径:刷新快速失败; - 源 deltalog 需要物化进目标 manifest,但 DataNode 无法推导或分配目标 deltalog ID:刷新失败而非写出不稳定路径;
- 目标 Schema 与源快照 Schema 不匹配:创建或刷新失败,而不是以错误的字段 ID 读数据;
- 后续刷新指向了不同 Schema 的快照:刷新在 Schema 身份校验处失败;
- 既有 Parquet、Lance、Vortex、Iceberg 外部表的
external_field物理列行为完全不变。
这些约束共同构成了一个强校验边界:milvus-table宁可快速失败,也绝不在 schema / 路径 / 删除语义不确定时静默写错数据。
十二、设计边界(Non-Goals)
为了保证主题聚焦,这里汇总该设计的明确边界——它不做以下事情:
- 不支持非 StorageV3 源集合产生的快照;
- 不根据
source_collection_id与snapshot_id反推快照路径(路径布局不是 API 契约的一部分); - 外部表不可写;
- 刷新之间不支持 Schema 演进;
- 虚拟 PK 目标不复用源 PK 布隆过滤器;
- 不支持 dynamic fields、struct 字段、分区键、聚簇键、text match、auto ID 等特性用于该外部集合;
- 不从外部源数据读取目标函数输出字段(目标函数输出为 target-owned,刷新时重算)。
十三、测试覆盖矩阵
设计文档的 Validation 小节声明了完整测试覆盖,仓库中大部分对应文件也已就位,可作为阅读源码的入口:
- RootCoord Schema 对齐测试:internal/rootcoord/create_collection_task_test.go;
- DataCoord 刷新 Schema 校验测试:internal/datacoord/external_collection_refresh_manager_test.go;
- DataNode manifest 创建、deltalog 复制与虚拟 PK deltalog 翻译测试:internal/datanode/external/milvus_table_refresh_test.go、internal/datanode/external/milvus_table_deltalog_test.go、internal/datanode/external/task_update_test.go;
- QueryNode 外部真实 PK 布隆过滤器与 deltalog 加载测试:internal/querynodev2/segments/segment_loader_test.go、internal/querynodev2/segments/utils_test.go;
- StorageV3 packed 读取器与 Milvus-table 快照元数据测试:internal/storagev2/packed/exttable_test.go、[internal/storagev2/packed/transaction_test.go] 等;
- Segcore 字段 ID 物理列解析测试(C++ 侧);
- Go 客户端 E2E 测试:覆盖
milvus-table快照的刷新、query、take、真实 PK、虚拟 PK 与删除行为。
结语
milvus-table是 Milvus 外部表家族中一个"内部格式外部化"的特例:它没有引入全新的公开 API,而是把 Milvus 集合快照这一复杂产物(Schema + StorageV3 manifest + 各类删除日志 + PK 统计)塞进了既有的external_spec/external_source/external_field契约里,靠 RootCoord 创建期字段 ID 对齐、DataCoord 刷新编排、DataNode 清单翻译与删除转换、QueryNode/Segcore 双端一致的物理列解析来支撑整条零拷贝只读链路。理解它的关键是记住三组对立:源身份 vs 目标身份(字段 ID 必须对齐、Schema 身份必须一致)、源数据 vs 目标自有数据(虚拟 PK、函数输出、目标 deltalog 均为 target-owned)、真实 PK vs 虚拟 PK(决定布隆过滤器能否复用、删除日志是导入还是翻译)。如果你的场景需要把 Milvus 集合的不可变快照快速交付给另一套查询视图,milvus-table正是为这条"快照即数据源"的路径而设计的。
【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考