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

湖仓里有一张文本表,你想在 Milvus 里对它做全文检索。传统做法是先跑 BM25 或 embedding 把文本变成向量,再在湖里多算一列写回去,或者干脆导一份进 Milvus。前者污染源表,后者复制数据——两条路都违背 External Collection 的初衷。
External Collection 是 Milvus 3.0 的外部表能力:只读映射湖仓数据,主列不复制、不进 Milvus,查询时直接读源。正因为数据不搬进来,一个很自然的问题就冒出来了:主列还躺在湖里,检索字段怎么办?
Milvus 3.0 GA 把这个问题解决了,而且一次给了三样东西:函数输出字段、加法式 schema refresh、milvus-table 外部格式。这是 External Collection 系列的第 12 篇,前几篇拆过只读映射、虚拟 PK 和 Refresh 基础(上一篇:Milvus 3.0:不搬湖仓文件,怎么把查询接到仓库门口?),这篇不复述,只拆这三件事的实现和取舍。
这三项能力看起来分属不同场景,底层却围绕同一个变化:外部源数据不动,Milvus 通过 manifest 追加自己的列、版本和快照视图。下面三节分别看它如何长列、如何演进,以及如何复用自己的快照。
先看 schema 层。外部集合的字段验证把字段分成两类,规则完全相反:
字段类 | external_field | 源数据检查 | manifest 里的列名 |
|---|---|---|---|
外部输入字段 | 必须 | 是 | external_field 值 |
函数输出字段 | 禁止 | 否 | 十进制 fieldID 字符串 |
函数输出字段(BM25 sparse 向量、MinHash 签名、文本 embedding)不能声明 external_field,因为它不对应源表任何列,源表只有它的输入。普通外部字段则必须声明 external_field,否则无法映射到源列。一个 external_field 至多映射一个用户字段,函数输出字段和 external_field 冲突直接报错。
这里有个顺序问题。Proxy 创建集合时,先跑函数验证把输出字段标记为函数输出,再做外部 schema 验证。顺序反了,函数输出字段会被当成普通未映射字段,被 external_field 要求拒绝。设计文档专门提了这一点。
列命名双轨制是这套设计里容易看漏的地方。同一个 manifest 里,外部源列用 external_field 的值做列名(要匹配 Parquet 源 schema),函数输出列用十进制 fieldID 字符串做列名(匹配内部 StorageV3 约定)。C++ 侧解析列名时先按 external_field 反查,查不到再 fallback 解析数字 field ID。两种命名在同一份 manifest 里共存,靠解析顺序区分。
命名定下来之后,看数据怎么流进去。Refresh 时函数是一条流式管线:先建一份只含外部源列的 input manifest,打开只读 reader 读函数输入列,流式读 Arrow batch,执行函数,只把输出列写进新的 StorageV3 packed column group。峰值内存等于一个 Arrow batch(默认 64 MiB),跟 segment 大小无关。
这就是外部集合和内部集合的本质差别:内部集合的 function field 在写入路径上执行,数据进来先过函数再落盘;外部集合没有写入路径,函数在 refresh 时从源列读入、执行、把输出写进新的 column group,源表从头到尾没被碰过。执行顺序也有讲究:TextEmbedding → BM25 → MinHash,避免函数 planner 被预填充的 BM25 输出字段干扰。
函数执行完,输出字段就是普通 schema 字段,索引从 manifest 按 field ID 读:BM25 sparse 走 sparse inverted,MinHash binary 走 MINHASH_LSH,TextEmbedding float 走普通向量索引。但 stats 的可见性要单独看:refresh 时 DataNode 累积所有 batch 的 BM25 输出,序列化成一个 stats blob(key 是 bm25.<fieldID>),先写 stats 再 commit manifest。manifest 是可见性提交点,retry 覆盖同一确定性路径,读者只在 manifest 提交后看到 stats。
text index 的 stats 更异步:refresh 不建 text index,由 DataCoord 触发外部任务构建,QueryNode load 前需要 stats 就绪,否则报 TextIndexNotFound。
还有两条边界。BM25 输出字段不可 raw retrieve,MinHash 和 TextEmbedding 输出可以,因为 BM25 sparse 是检索专用表示,没有原始文本意义。segment reuse 规则也受函数影响:集合有函数时,只有 manifest 已含全部函数输出列的 segment 才会被复用,函数输出支持之前创建的旧 segment 会失效重建。内存估算同样分两半:外部字节从外部源字段采样,函数输出字节从输出字段 schema 估算(源文件里没有这些列),采样失败会导致 refresh 失败,避免 QueryNode 资源估算崩溃。
列从哪来解决了,下一个问题是列变多了怎么办:外部表加列,refresh 要重建整个集合吗?

不用。加法式 schema refresh 就是干这个的。
外部表加列后,不变的外部 fragment 仍然需要新 manifest column group 和新 fake binlog。如果保持旧 segment 不变,segment 就落后于当前 schema,新字段既不被 manifest 覆盖,也不被 load-time 内存估算覆盖。refresh 必须处理这个增量。
DataNode 对每个当前 segment 先做一次状态判断,结果只有三种:
判断的关键是字段覆盖:从 task schema 提取 target external fields,从 fake binlog ChildFields 读现有覆盖,缺失集用外部列名表达,因为 manifest column group 引用的是外部源列名。
patched 走 same-ID patch。所谓 same-ID,就是同一个 segment 换一份 manifest、换一个 schema version,其他什么都不动。具体流程:读现有 column groups,过滤掉已存在的列(幂等,全部已存在就返回原 manifest path),创建新 column groups,commit 新版本;然后采样新字段大小、重算内存估算、重建 fake binlog。返回的 SegmentInfo 只有 manifest path 和 schema version 变了,segment ID 原样保留。
DataCoord 侧把 patched 结果当 upsert payload 用:ID 不存在 = 新 segment,ID 已存在 = patch。patch 的校验很严格,但核心只有两条——row count 不能变(数据量变了就不叫补丁),schema version 不能回滚。真正更新的只有 ManifestPath、SchemaVersion、Binlogs、StorageVersion,segment ID、partition ID、insert channel、row count、state、level 全部保留。执行是原子的:drop 非 kept/updated 的 segment → add 新 segment → patch 现有 segment。
这里有一个我读源码时发现的取舍。设计文档描述了 job/task 级 schema-version gate(dispatch gate + apply gate,防止 refresh 期间 schema 变化导致结果过期),但源码里没有实现。task_refresh_external_collection.go 的注释写得很直白:
There is no job/task-level schema-version gate for the current additive-only refresh scope: if AddField races after this request is built, the task may finish with the older schema and skip the new field, and a later refresh will self-heal it through missing-column detection.
翻译过来就是:如果加字段操作和 refresh 请求竞态,任务可能带着旧 schema 结束、跳过新字段,但下一次 refresh 会通过 missing-column detection 自愈。当前实现靠自愈,而不是拒绝过期结果。只有 segment 级 patch 校验里有 schema version 不回滚检查。
我的判断是:这是合理取舍,不是遗漏。加法变更的容错成本低,漏一个字段,下次 refresh 补上就行,查询不会读到坏数据。drop、rename、type change 需要更强协调,那时候 gate 才会真正进来。
用户文档的口径也印证了这一点:外部集合只支持加字段,不支持 drop/rename/type change/remap external_field,只能加外部源已存在的字段,不支持加 SPARSE_FLOAT_VECTOR 和 StructArray 字段。
前两件事都在外部文件上做文章:长一列、打补丁。那 Milvus 自己的数据呢?

也能走这条路。milvus-table 是 external_spec 支持的第 5 种外部格式(前面是 parquet、lance-table、vortex、iceberg-table),external_source 指向快照 metadata JSON 路径(要求 .json 后缀)。
读取时要求快照里的 storagev2_manifest_list 非空,遍历 manifest list,每个源 StorageV3 segment manifest 变成一个 FileInfo,L0 segment 的 deltalogs 收集成 L0 overlays,挂到每个 fragment 上,这就是快照级 delete overlay。
关键约束在字段 ID 对齐。StorageV3 manifest 的物理列按 field ID 字符串存(源 field ID 101 → 物理列 101)。如果 target 集合生成不同的 field ID,数据读取会指向缺失或错误的列。所以 RootCoord 创建集合时会把每个用户数据字段的 field ID 对齐到源字段的 field ID,虚拟 PK 和函数输出字段用 target-only ID,再把 preserve_field_ids=true 写进 properties——这样 DDL replay 时不用重读快照,恢复不依赖源 bucket 和 credentials。
为什么源必须是正常 StorageV3 集合?两个原因。一是 storagev2_manifest_list 只有 StorageV3 集合的快照才有(只有 manifest path 非空的 segment 才会进这个列表);二是外部集合的快照被 RootCoord 明确拒绝,叫 external-table chaining——refresh/read 路径要追另一集合的 external source 和 storage 契约,超出 milvus-table 快照契约的范围。用户文档口径一致:源快照必须来自正常 StorageV3 Milvus 集合。
delete 处理是 milvus-table 里比较绕的部分,因为有两种 PK 模式。
真实 PK(target 有用户主键,映射源快照 PK)最简单:直接复用源 PK 的 bloom filter 做查询剪枝,源删除记录只引用不复制,QueryNode 照常解码。额外要加载源 insert timestamp 列,保持 delete-before-reinsert 的可见性。
虚拟 PK(target 无用户主键,沿用 03 期讲过的 segment_id + row_offset 拼法)就没这么省事:bloom filter 是按源 PK 建的,复用不了,DataNode 得把源 PK 的删除记录翻译成 target 虚拟 PK 的删除记录——读源删除记录、按 PK 扫出匹配行、把行号转成虚拟 PK、写进 target 自己的删除日志。代价是内存与删除条数成正比(不是总行数),换来 target 的删除语义完整。
Refresh 行为矩阵也跟 delete 有关。fragment identity = source_manifest_path:start_row:end_row,delete log 不参与 identity:L1 fragment 变 → drop 旧 target segment + 建新;fragment 不变 + overlay 不变 → keep;fragment 不变 + overlay 增/删/变 → keep segment ID + rewrite manifest。这里比较的是 deltalog identity(LogID 或路径),不是 entry 数,因为虚拟 PK 转换可能改变 target delete 数,即使源 L0 log 没变。
到这里,三件事就接成一个闭环了:内部集合 → 打快照 → milvus-table 外部源 → External Collection。批量侧 Spark 直接读快照,serving 侧 External Collection 把同一快照当外部表查,两边共享同一份 manifest-backed 视图,零复制。这就是 Release Notes 说的 batch and serving systems get a shared, manifest-backed view of the same data。


把三件事放在一起看,共同点很清楚:manifest 是可见性提交点,外部源列和 Milvus 自有列在同一 manifest 内共存。函数输出字段是在外部源之上长出一列,加法式 refresh 是给 manifest 追加 column group,milvus-table 是把快照的 manifest 直接当外部源。三件事都是围绕 manifest 的增量操作,没有一件需要复制源表。
与 03 期基础能力的边界:只读映射、虚拟 PK、Refresh 重算映射那些,03 期已经拆过,这里不重复。本篇的三件事都叠加在同一条链路上:manifest 描述外部文件。函数输出让 manifest 里出现 Milvus 自己算出来的列,加法式 refresh 让 manifest 可以演进,milvus-table 让 manifest 可以来自 Milvus 自己的快照。
社区视角补一句。GA 才两周多(调研时间 2026-08-15),还没有独立的性能评测,Elestio 的博客是目前仅有的第三方深度分析。它的实操案例是外部回填:加列 → 快照作一致起点 → 离线跑 embedding → 写回 → 增量建索引,把数亿行 embedding 模型升级从迁移周末变成热路径操作。
但这不是新需求:issue #45881 提的 lakehouse 集成(数据重复、ETL 复杂、更新延迟、运维开销,设计 Virtual Segment 只存元数据)和 discussion #35221 里 collaborator 回复的目前不支持编辑集合 schema、是规划中功能,3.0.0 都落了地。社区等了两年多的东西,这次给的是实现,不是路线图。
我倾向于把 milvus-table 看作三件事里值得多盯一眼的:它让 Milvus 的数据在批量与 serving 两个世界之间有了零复制的共享视图。至于 chaining(外部集合的快照当外部源)什么时候解禁、schema-version gate 什么时候补上,要看 drop/rename 这类破坏性 schema 变更的需求什么时候来。在那之前,加法式 refresh 的自愈设计够用。
说明:本文基于 Milvus 3.0.0 源码(zilliztech/milvus)和官方文档整理,源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证。文中涉及的函数输出字段、加法式 schema refresh、milvus-table 等机制,配置和参数请先按你的环境验证;如果你有实际使用经验,欢迎留言分享。
好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。