
🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 163 篇,Milvus 最佳实战「2026」系列第 18 篇
大家好,欢迎来到 术哥无界 | ShugeX | 运维有术。
我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者!
Talk is cheap, let's explore。无界探索,有术而行。

翻 Milvus 3.0 源码的时候,有一行注释引起了我的注意。
在 data_coord.proto 第 1074 行附近,有句平平无奇的话:When storage_version >= 3, binlog paths must be resolved from manifest_path。
意思是 V3 之后,binlog 路径不能再靠字符串拼接了,必须从 manifest 文件里解析。
这句话看着温和,实际是一刀切断过去。Milvus 从 2.x 到 3.0,存储层经历了一次范式级别的重写——从逐 binlog 文件模型,转向了类似 Apache Iceberg 的 manifest 表格式。新的存储引擎有个独立的名字:Loon。
这篇文章聊的就是这层地基。Milvus 3.0 的 External Collection、Spark 零拷贝读取、TEXT 大文本支持,全都是建在 Loon 这块地基上的上层建筑。如果存储层不换,这些功能根本无从谈起。
先看一段非常直白的常量定义。在 internal/storage/rw.go 第 41-48 行:
const (
StorageV1 int64 = 0 // milvus 2.0 legacy binlog format
StorageV2 int64 = 2 // milvus-storage packed writer binlog format (parquet)
StorageV3 int64 = 3 // loon manifest format
)三个版本号,注意 V2 的值是 2 不是 1。编号 1 缺失了——说明中间存在过一个被废弃的版本。从命名也能看出三代演进的主线:
Milvus 2.0 的遗留格式。每个 segment 的每一列各写一个 binlog 文件,路径靠约定拼接。数据格式是 Milvus 自定义的二进制 layout,不是任何标准列式格式。
解决的问题:早期够用,实现简单。
留下的债:每列一个文件意味着一个小 segment 有几十个文件。对象存储上几十万个碎片文件,元数据管理压力很大。更要命的是没有任何事务语义——写入过程中 crash 了,你不知道哪些文件是完整的、哪些是半写的。
引入了 milvus-storage 子项目,用 Parquet 替代自定义 binlog 格式,并且把同一个 segment 的多个列打包写在一起(packed writer)。
文件数量大幅减少,Parquet 作为标准列式格式,生态兼容性和压缩率都比自定义格式好。
但债还在。Parquet 打包写入是在 Go 层做的,元数据——哪些文件属于哪个 segment、segment 的版本快照状态——仍然散落在 DataCoord 的内部数据结构里,没有持久化成一个自描述的表格式。
换句话说,V2 只是把数据文件从 binlog 换成了 Parquet,但元数据模型还是 V1 的思路——靠 DataCoord 记着。
这是范式转换的核心。V3 引入了一套完整的表格式设计:manifest 文件作为元数据锚点,Avro 编码,版本化递增,支持事务、列组、delta log、index、stats。
V3 解决的不只是文件格式问题,而是把表的元数据从 DataCoord 的内存里搬到了存储层自身。一个目录扫一眼 manifest 就知道:这个表有哪些列组、每个列组是什么格式、有哪些 delta log、挂了哪些索引。

图 1:V1 逐 binlog 碎片化 → V2 Parquet 打包 → V3 Manifest 自描述表格式,每代解决上代的债务
设计文档 docs/design-docs/design_docs/20260226-manifest-format.md 是理解 V3 的核心入口。这份文档的 Status 标记是 Implemented,作者 @tedxu。
先看目录布局:
base_dir/
├── _metadata/
│ └── manifest-{version}.avro # Avro 编码,版本号从 1 开始递增
├── _data/
│ └── {group_id}_{uuid}.{format} # Parquet, Vortex, lance, binary
├── _delta/ # 删除日志
├── _stats/ # Bloom filter, BM25 等
└── _index/ # HNSW, IVF, bitmap 等这个布局和 Apache Iceberg 的设计思路高度相似。Iceberg 用 manifest list 和 manifest file 组成快照树,每个快照记录表在某个时间点的完整状态。Loon 做了类似的抽象,但更简化——一个 manifest 文件就是一个 segment 的完整状态。
Manifest 文件的二进制头是一个 Magic Number:0x4D494C56,ASCII 是 MILV。后面跟着一个 int32 的版本字段,当前支持 1 和 2。
版本 1 包含 column_groups + delta_logs + stats,版本 2 新增了 indexes 字段。
这里有个值得注意的兼容性设计:v1 manifest 可以被 v2 代码读取(indexes 视为空),但前向不兼容——超过 MANIFEST_VERSION 的版本会直接报错。
上线新功能时,旧代码至少不会崩,只是看不到新字段。
有意思的是,manifest 的二进制格式是 C++/Rust 层处理的,但 Go 层(Milvus 主进程)只需要知道 manifest 在哪。所以在 ffi_common.go 第 329-383 行,Go 层用一个极简的 JSON 结构来编码 manifest 路径信息:
type ManifestJSON struct {
ManifestVersion int64 `json:"ver"`
BasePath string `json:"base_path"`
}格式就是 {"ver":<int>,"base_path":"<string>"}。Go 不需要理解 manifest 内部的列组、delta log 这些细节,它只负责把路径传给 C++ 的 loon 引擎去解析。这也是 FFI 边界设计的一个核心原则:Go 管协调,Rust 管干活。
有了 manifest 版本化,自然就需要并发控制。Loon 用的是乐观事务模型——读的时候记住版本号,提交的时候检查有没有冲突。
设计文档第 204-267 行定义了三种 Resolver(冲突处理策略):
Resolver | 行为 |
|---|---|
| 如果 |
| 对 |
| 对 |
在 manifest_commit.go 第 106-111 行,CommitManifestUpdates 整体使用 OVERWRITE 语义。源码注释解释了原因,翻译过来大意是:
stats 更新依赖按 key 覆盖的语义(
loon_transaction_update_stat会用相同 key 替换已有条目),而 column-group 追加和 delta-log 添加的 key 天然不冲突。
换句话说,三种写入操作的 key 空间正交,所以用 Overwrite 语义既安全又简单。stats 需要按 key 覆盖更新(text index、JSON key、BM25 等统计信息),而列组和 delta log 是纯追加操作。
一个容易被忽略的细节:对某列组执行 AppendFiles 会自动丢弃该列上的索引。原因很简单——追加了新数据后,旧索引不再覆盖完整数据集,留着会误导查询。
这种自动淘汰机制意味着 manifest 不只是被动记录状态,还带了一屽数据完整性约束——追加数据时主动让旧索引失效。
事务冲突时的重试上限由 commonCfg.ManifestTransactionRetryLimit 控制,默认 10 次(transaction.go 第 42-51 行)。注释里说明了一个典型场景:多个 stats 任务(text index、JSON key、BM25)可以并发写同一个 segment 的 manifest。乐观事务冲突时就重读最新版本再重新应用。
列组(Column Group)是 V3 存储格式区别于普通 Parquet 表的关键概念。源码在 internal/storagecommon/column_group_splitter.go 和 split_policy.go。
ColumnGroup 结构体里有个 V3 专属的 Format 字段:
type ColumnGroup struct {
GroupID typeutil.UniqueID
Columns []int // column indices
Fields []int64
Format string // Only used for loon writer.
}这个 Format 字段意味着每个列组可以声明自己的文件格式——parquet、vortex、lance 或 binary。混合存储就是从这里来的。
DefaultPolicies() 按顺序执行四个拆分策略,顺序敏感:
第一步:SystemColumnPolicy(可配置)
拆分系统列(PK、partitionKey、clusteringKey)。由 common.storagev2.splitSystemColumn 控制开关,可选包含哪些系统列。
第二步:AvgSizePolicy(可配置,阈值默认 1024 字节)
大字段单独成组。阈值由 common.storagev2.splitAvgSizeThreshold 控制。超过平均大小的列被拆出来,避免拖累同组其他列的读取效率。
第三步:SelectedDataTypePolicy(永远开启,不可关闭)
这条规则没有开关——所有向量类型列和 TEXT 类型列,各自独占一个列组。源码逻辑很直白:
func (p *selectedDataTypePolicy) Split(currentSplit *currentSplit) *currentSplit {
currentSplit.Range(func(idx int, field *schemapb.FieldSchema) {
if IsVectorDataType(field.DataType) || field.DataType == schemapb.DataType_Text {
currentSplit.SplitFields(field.GetFieldID(),
[]int64{field.GetFieldID()}, []int{idx})
}
})
return currentSplit
}覆盖的向量类型包括 BinaryVector、Float16Vector、BFloat16Vector、Int8Vector、FloatVector、SparseFloatVector、ArrayOfVector。
为什么要单独拆?因为向量列和 TEXT 列的数据量大,访问模式也和标量列不同。标量列通常做过滤(WHERE age > 18),向量列做相似度搜索。混在一个 Parquet 文件里,查标量的时候也得把向量列的数据拉过来,白白浪费 I/O。
第四步:RemanentShortPolicy(永远开启)
剩余的小字段合并为一组,默认 GroupID 为 0。
拆分的核心逻辑是按访问模式隔离。PK 和 partition key 经常被点查,向量列经常被全扫描做相似度计算,TEXT 列需要走倒排索引。把它们放在不同的文件里,查询时只需要拉取相关列组,不用扫全表。

图 2:四步拆分按顺序执行——系统列先拆、大字段隔离、向量/TEXT 独占、剩余合并,核心逻辑是按访问模式隔离
Milvus 主进程是 Go,存储引擎 Loon 是 Rust(通过 C ABI 暴露)。两者的协作边界在 internal/storagev2/packed/ 目录下,大约 20 个非测试源文件。
核心设计原则是:Go 负责调度和协调,Rust 负责所有实际的 I/O 和编码。
Packed Writer(普通列组写入):
loon_writer_new → loon_writer_write → loon_writer_close。Close 返回一个 ColumnGroups 的 C 内存指针,Go 层读出来之后用于后续的 manifest 提交。
Segment Writer(处理 TEXT LOB):
比 Packed Writer 多了一步 flush,且函数名前缀不同:loon_segment_writer_new → loon_segment_writer_write → loon_segment_writer_flush → loon_segment_writer_close。Close 返回 SegmentOutput,里面包含 column_groups 和 lob_files 两部分。
Transaction(manifest 提交):
loon_transaction_begin → 一系列 append_files / add_column_group / add_delta_log / add_lob_file → loon_transaction_commit。这个链路是 V3 事务系统的 Go 侧入口。
Reader:
NewPackedFFIReaderWithManifest → GetFFIReaderStream。读取时也需要传 manifest 路径,由 Rust 侧解析出具体的列组文件。
FFI 层有一段坦诚的 TODO 注释(ffi_common.go 第 27-37 行):
// ErrLoonTransient marks any failure surfaced by the loon FFI layer. Today
// milvus-storage does not expose structured error codes, so callers cannot
// distinguish a recoverable concurrent-transaction conflict from a hard IO
// error.
//
// TODO(storage v3): once milvus-storage exposes explicit error codes, narrow
// this sentinel to only the concurrent-transaction case (FailResolver) and
// let other errors propagate immediately as retry.Unrecoverable.
var ErrLoonTransient = errors.New("loon FFI transient error")翻译过来:当前所有 loon FFI 失败都被当成可重试的瞬时错误,因为 milvus-storage 还没有暴露结构化的错误码。这意味着一次磁盘满的错误也会被重试 10 次,每次都失败。这属于明确标记的技术债,等 Rust 侧补齐错误码后会收窄。
V3 在 TEXT 大文本存储上花了不少功夫。设计文档 docs/design-docs/design_docs/20260407-text_lob_storage.md(作者 @zhagnlu)详细记录了方案选择,包括几个被拒绝的方案。
Milvus 3.0 引入了 TEXT 类型字段,用于支持全文搜索。但 TEXT 字段的数据量可以很大(长文档、代码片段、对话记录),直接用 Parquet 存有两个问题:
V3 的方案是 inline + LOB 混合存储。每个 TEXT 值前面有 1 字节的 flag 前缀:
0x00 inline:短文本直接内联在列组文件里,变长存储。不需要额外 I/O。0x01 LOB ref:长文本用 24 字节引用替代(flag 1B + padding 3B + file_id:UUID 16B + row_offset:int32 4B)。LOB 文件本身不在 segment 的 basePath 下,而在 partition 级别:
{root}/insert_log/{collection}/{partition}/
├── {segment}/
│ ├── _metadata/manifest-{version}.avro
│ └── _data/cg{0,1}_xxx.parquet # cg1 = TEXT 引用 binlog
└── lobs/ # 分区级,跨 segment 共享
└── {field_id}/_data/{file_id}.vx # Vortex 格式LOB 文件放在 partition 级别而不是 segment 级别,这意味着多个 segment 可以引用同一个 LOB 文件。
在 compaction 时,如果某些 segment 只是重新组合而没有修改 TEXT 数据,那么 LOB 字节根本不需要移动——只需要在 manifest 里改一下引用关系就行,纯元数据操作,代价可以忽略。
Compaction 时怎么处理 LOB 文件,取决于数据的有效比例。设计文档定义了 hole_ratio(空洞率)概念——被删除的 LOB 引用占总引用的比例。
场景 | 策略 | 原因 |
|---|---|---|
| REUSE_ALL | 空洞少,直接复用引用,LOB 不动 |
| REWRITE_ALL | 空洞多,重写,生成密集 LOB 文件 |
Clustering compaction | REWRITE_ALL | 聚类需要重排,LOB 必须跟着 |
Mix compaction with split | REWRITE_ALL | 1→N 拆分,必须重写 |
Sort compaction | REUSE_ALL | 排序不改内容,只改顺序 |
L0 delete compaction | SKIP | 纯删除操作,不碰 LOB |
这套策略的核心思想是:能不搬就不搬。字节搬移是开销很大的操作,元数据操作几乎免费。
设计文档里有几个被明确否决的方案,对理解设计取舍很有帮助:
方案一:用 etcd 管理 file_id 分配。被拒绝,因为这会强制每个 C++ writer 绕道 Go 来获取 ID。LOB 写入路径是 Rust 侧的,不应该引入对 Go etcd 的依赖。
方案二:完全去掉 inline 路径。被拒绝,因为短字符串(几十字节的 tag、label)如果也走 Vortex 文件,每次读取都要多一次随机 I/O,得不偿失。
方案三:把 LOB 文件放在 segment basePath 内。被拒绝,因为这会 kill compaction reuse——segment 合并后旧 basePath 被删,LOB 引用就断了。
方案四:用 Parquet 做 LOB 文件。被拒绝,因为 Parquet 的长字符串压缩不如 Vortex,随机访问也不如 Vortex 的 split index(O(log N))。
Vortex 是 milvus-storage 仓库里的一个向量专用列式格式。相比 Parquet,它有两个优势:
配置项 dataNode.storage.format 可以在 parquet 和 vortex 之间切换,默认是 parquet。但在 TEXT LOB 场景下,LOB 文件固定使用 Vortex 格式(扩展名 .vx)。

图 3:短文本 inline 内联、长文本 LOB ref 引用,LOB 文件放分区级跨 segment 共享,compaction 时纯元数据操作
存储格式换了,已有的 V2 数据怎么办?Milvus 的答案是:让 compaction 干这个活。
源码在 internal/datacoord/compaction_policy_storage_version.go。有一个专门的 storageVersionUpgradePolicy,它的 targetVersion() 逻辑很简单:
func (policy *storageVersionUpgradePolicy) targetVersion() int64 {
targetVersion := storage.StorageV2
if paramtable.Get().CommonCfg.UseLoonFFI.GetAsBool() {
targetVersion = storage.StorageV3
}
return targetVersion
}总开关 common.storage.useLoonFFI(默认 false,从 2.6.7 版本引入)控制是否升级到 V3。打开开关后,后续 compaction 产出的新 segment 自动是 V3 格式。
迁移不是无脑跑的,有三个保护层:
TEXT 保护:如果目标版本低于 V3 且集合包含 TEXT 字段,拒绝降级。这是硬约束——TEXT 字段只有 V3 支持,降级会丢数据。
速率限制:StorageVersionCompactionRateLimitTokens=3,每 120 秒最多跑 3 个版本升级 compaction。防止后台迁移把集群 I/O 吃满。
会话版本门控:StorageVersionCompactionSessionVersionRequirement 控制最小 QueryNode 版本要求。如果集群里有旧版本节点,不启动迁移——避免新格式 segment 被旧节点读到。
External Collection 豁免:外部 Collection 的数据不在 Milvus 自己的存储里,不参与版本迁移。
新写入的 segment 怎么决定格式?在 write_buffer.go 第 1314-1343 行,根据 useLoonFFI 开关决定新 segment 的 storage_version。开关打开后,新 segment 天然就是 V3。
这意味着迁移是渐进的:打开开关 → 新写入走 V3 → 旧 V2 数据在 compaction 时逐步转为 V3。不需要停机,不需要批量迁移脚本。
前面聊了 V3 的设计,但 V3 目前还有几块没补完。
前面提到的 ErrLoonTransient 问题。当前所有 loon FFI 失败都被当成可重试,磁盘满、权限错误也会被重试 10 次。这在生产环境中意味着每次不可恢复的错误会浪费约 10 次 I/O。milvus-storage 暴露结构化错误码后这个问题才能解决。
设计文档对 Vortex 的描述只停留在压缩更好、随机访问更快的层面。Vortex 的内部编码方式、与 Lance 的 head-to-head 性能对比数据,当前官方资料中未发现相关依据。milvus-storage 仓库的 README 只提到 Format Layer 支持 Vortex,没有格式规范文档。想深入评估 Vortex 是否适合替换 Parquet,目前只能自己跑 benchmark。
20260407-text_lob_storage.md 的 Status 标记是 Draft,Released 标注为 3.0.0。说明设计已落地实现,但文档还没正式定稿。一些细节(比如 hole_ratio 阈值是否可配置)可能还有变化空间。
GitHub 上 milvus-storage 仓库截至 2026 年 7 月有 44 stars、43 forks、487 commits。相比 Milvus 主仓库(44,000+ stars),这个数字说明存储引擎的开发基本是 Zilliz 内部团队驱动的。外部贡献者参与门槛较高——你需要同时理解 Arrow 列式存储、向量检索负载特征和 Milvus 的架构约束,三样缺一样都很难提交有意义的 PR。
Milvus 3.0 的 External Collection 能做到零拷贝查询外部 Iceberg 表,Spark 能直接读 Milvus 的 Collection 做批量分析,TEXT 字段能支持全文搜索——这些功能全部建立在 Loon 这套 manifest 表格式上。
没有 manifest,External Collection 的 Virtual Segment 就没有地方记录指向 lakehouse data splits 的引用。列组拆分一旦去掉,向量列和标量列混在一起,Spark 读 Milvus 数据就得拉一堆没用的向量列。而 Vortex LOB 如果不存在,TEXT 字段只能塞进 Parquet,全文搜索的随机访问性能就上不去。
V3 不是一个孤立的存储优化。它是 Milvus 从一个向量数据库向 Vector Lakebase 演进的地基工程。理解了这块地基,后面理解 3.0 的上层建筑会顺畅很多。
后续文章会拆开聊 Compaction 怎么在 V3 上跑、Segment 生命周期管理如何适配 manifest 版本化、以及索引格式 V3 的设计。这篇先打个地基。
说明:本文内容基于 Milvus 3.0 源码(github.com/milvus-io/milvus,
internal/storage/rw.go、internal/storagev2/packed/、internal/storagecommon/、internal/datacoord/compaction_policy_storage_version.go、pkg/util/typeutil/schema.go、pkg/util/paramtable/component_param.go)和官方设计文档(docs/design-docs/design_docs/20260226-manifest-format.md、20260407-text_lob_storage.md、20260209-scalar-index-unified-format.md)分析整理而成,源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证。文中的配置模板和参数建议仅供参考,实际效果请以你的业务数据和环境测试结果为准。如果有实际使用经验,欢迎在评论区分享交流。
好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。