首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >换 embedding 不用重建库:Milvus 3.0 在线改表是怎么做到的

换 embedding 不用重建库:Milvus 3.0 在线改表是怎么做到的

原创
作者头像
术哥
发布2026-08-08 14:22:56
发布2026-08-08 14:22:56
1280
举报
文章被收录于专栏:运维有术运维有术

🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 190 篇,Milvus 最佳实战「2026」系列第 27

大家好,欢迎来到 术哥无界 | ShugeX | 运维有术

我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者

Talk is cheap, let's explore。无界探索,有术而行。

Milvus 3.0 在线改表协议信息图封面:状态机、回填、可见性、字段ID
Milvus 3.0 在线改表协议信息图封面:状态机、回填、可见性、字段ID

在 GitHub 上翻 Milvus 的 issue 和 discussion,有个需求持续了三年多:建表之后,能不能加字段、删字段

  • #20405(2023):建表后加/删非主键字段
  • #30803:换 embedding 方法就得重建 collection
  • #46896(2025):v2.6 只能加字段不能删字段,改个 max_length 都不行

这些诉求在 2.x 时代基本得不到官方回应,回复通常是"未来大版本规划"。直到 2026 年 7 月底,Milvus 3.0.0 发布,Release Notes 里白纸黑字写着一行:Flexible schema: add, backfill, and drop columns online。翻译过来就是:服务不停机,在线加列、回填、删列。

乍一看这像一句轻飘飘的发布词。但翻完设计文档和源码之后我意识到,它背后不是一条 ALTER TABLE,而是一套借鉴 Google F1 的在线 schema 演化协议。下面拆开讲:它解决了什么问题,关键逻辑怎么运作,哪些地方还留了边界。

1. 为什么不能广播一次 schema 替换

先回答那个直觉的问题:加字段,为什么不能给所有节点广播一条新 schema 就完事?

Milvus 是分布式系统,一条 DDL 要经过 Proxy、RootCoord、StreamingNode、DataNode、QueryNode、QueryCoord 一堆角色。设计文档把现状说得很直白:这些组件不可能在同一时刻切换 schema。广播总会有先后,先切到新 schema 的节点和后切到的节点,对同一批数据会给出不同的解读。

再叠加三个场景,广播方案直接失效:

一是,加性变更需要新旧数据共存。加字段时,历史 segment 里的老数据没有新字段的值,新写入的数据有。两类数据必须在新旧 schema 版本的边界上共存一段时间,一次广播替换做不到这个划分。

二是,函数输出字段需要数据回填。给既有 collection 挂一个 BM25/MinHash 函数,输出字段的值是内核算出来的,历史数据得先算完、索引建好,这个字段对读者才有意义。广播时它还不存在。

三是,破坏性变更不能伤到健康节点。删字段时,还有节点带着旧 schema 在跑查询。陈旧请求可以失败,但不能把健康的 QueryNode 打黑,更不能静默损坏数据。

Google 的 F1 数据库早就处理过同样的问题。解法是把 schema 变化表示成有序状态:每个状态带可见性约束,等所有中间条件满足再发布。Milvus 3.0 把这一套搬了过来,核心就一个词:invisible(不可见)中间态。

广播式 schema 替换与有序状态协议对比图:左边失败右边可行
广播式 schema 替换与有序状态协议对比图:左边失败右边可行

为什么不能广播一次 schema 替换:左边是直觉做法,右边是 Milvus 3.0 的做法

2. 数据先写、查询后见:invisible 中间态

Milvus 复用了已有的 schemapb.FieldState 枚举,给每个字段定义四个状态:

状态

用户可见

用户可写

含义

FieldCreating

普通字段可,函数输出不可

正在安装/回填

FieldCreated

字段完全发布

FieldDropping

拒绝

正在收尾

FieldDropped

历史墓碑,等物理删除

源码里有一个很直接的确认。internal/metastore/model/field.go 第 31 行:

代码语言:go
复制
func (f *Field) Available() bool {
	return f.State == schemapb.FieldState_FieldCreated
}

Available() 只认 FieldCreated,其余三个状态一律不可用,配套测试也把这个行为锁死了。

这就是"数据先落、查询后见"的落点:新字段以 FieldCreating 状态被安装进写入路径,写入可以写、回填可以算。但查询路径永远看不到它。等所有 gate 通过,promote 成 FieldCreated,读者才真正看见。

为什么这个顺序是对的,反过来就不行?先让读者看见字段,再等数据回来,读者会查到一半有值、一半没值的字段。索引没就绪时,查询要么报错要么性能崩。可见性必须排在数据就绪之后,这是整个协议的地基。

为了让"谁看见什么"不靠自觉,Milvus 从一份全量 schema 派生了四个不可变投影视图:Full(存储和回填用)、Write(写入校验用)、Read(只含可见字段)、Describe(管理端展示)。每个边界都显式调用视图辅助函数,避免各组件自己拼字段拼出分歧。

字段四个状态流转状态机图:创建中、已创建、删除中、已删除
字段四个状态流转状态机图:创建中、已创建、删除中、已删除

字段状态机:只有已创建状态对查询可见,其余状态一律不可见

3. 两条回填路径:外部算 vs 内核算

加字段带一个 backfill 选项,决定新字段的值从哪来。

外部回填,对应 embedding 模型升级。你换了 embedding 模型,旧向量作废,但新值是在 Milvus 外面算的。流程是:add column 时打一个 snapshot 当一致起点,离线 job 把历史数据算完写回,Milvus 只负责增量索引新列。官方原话:

An embedding-model upgrade across hundreds of millions of rows becomes a hot path with no downtime.

几亿行的模型升级,变成一条不停机的热路径。这正是社区吵了三年多的那个场景。

内部回填,对应 BM25/MinHash 函数字段。值由内核自己算:给既有 collection 挂一个函数,输出字段在旧数据上自动生成,后台 compaction 慢慢物化。用户不用跑任何离线 job。

注意一个细节:函数输出字段只允许系统代码写。用户自己提供的 BM25 输出会被拒绝。函数字段的值必须由内核统一物化,否则数据一致性就没法维持。

外部回填与内部回填两条路径对比流程图
外部回填与内部回填两条路径对比流程图

两条回填路径:换 embedding 走外部,函数字段走内部

4. 你什么时候看见新字段:admission gate

在线改表容易被误读的是 ACK 语义。两阶段协议里:

Phase 1(安装不可见字段):RootCoord 从 max_field_id + 1 分配新 ID,字段标成 FieldCreating,广播一条 AlterCollectionMessage,客户端收到 ACK。

Phase 2(发布可见字段):RootCoord 的后台 checker 慢慢等 gate 全部满足,才把字段 promote 成 FieldCreated

关键点:Phase 1 的 ACK 只代表 schema 变更持久化了,不代表查询端就绪。设计文档明确警告,ACK 不能解释成 query-side readiness。真正决定可见时机的,是后面一串 gate:

  • Backfill gate:所有需要回填的历史段都写完了
  • Index gate:可搜索字段的索引建好了
  • Query gate:已加载的 QueryNode 应用了 schema barrier,索引可加载

gate 之前还有一道 collection-level 门槛。同一 collection 只要还有字段停在 FieldCreating/FieldDropping,或者回填快照没提交,新的 DDL 就会被拒绝或排队。换句话说,一次只允许一个演化在跑,避免两个改表任务互相踩。

这一串里,有一个是 3.0 排在前面的安全修复:load/balance admission gate。schema 变化的广播窗口内,该 collection 的 load、reload、balance 全部拒绝或延迟。

这是因为修复了一个已知 race:QueryNode 可能用和 WAL 顺序不一致的 schema 快照去加载数据。这个 gate 确保每个 segment 只绑定一个 schema 版本,杜绝加载用旧 schema、执行按新 schema 的错乱。

5. 删字段:怎么防止陈旧查询打掉健康节点

删字段比加字段麻烦,因为它是破坏性变更。设计文档给了一条很务实的底线:不要求每个陈旧查询都成功,但确保三件事:没有静默损坏、没有字段 ID 复用、没有健康 QueryNode 被陈旧请求标黑。

标黑是个真问题。Milvus 有重试(retry.Do)和 proxy failover 逻辑,可以想见,一个 QueryNode 持续报错会被调度器标记,负载被转移走,集群容量跟着下降。如果删字段时旧请求在健康节点上反复报错,等于用户自己把集群搞挂了。

解法是错误分类。旧请求引用已删字段,到 segcore 层返回 FieldIDInvalid(code 2020),Go 侧把 2020 映射成输入错误(input error)。输入错误会直接中断 retry.Do 并禁用 failover。这不是系统问题,是请求自己带了一个不存在的字段,重试多少次都一样。

反过来,可重试的系统条件绝不能标成输入错误,比如 load/balance 被阻塞这种临时故障,标错了就永远无法自动恢复。

分类之外,删除还分两阶段。先标 FieldDropping,等 Proxy 缓存过期、陈旧请求 drain 完,才 finalize 成 FieldDropped 或从主 schema 移除。

正式动手前还会调一次 waitUntilSchemaDropReady(rootcoord/ddl_callbacks.go),等 load balancer 确认没有 segment 还挂在旧 schema 上。注意,这个阶段不等物理 binlog 删除,binlog 是异步 GC 的。

6. 字段 ID 不复用:被异步 GC 逼出来的约束

binlog 异步删除引出一个隐蔽的坑:如果删掉的是 ID 大的字段,下次加字段按老逻辑分配 ID,会复用刚删掉的 ID。而旧 binlog 还躺在对象存储上,新数据用同一个字段 ID 写进去,新旧数据混在一起,静默损坏。

Milvus 的做法:把 max_field_id 持久化进 collection properties,每次 drop 都把高水位写回。源码 validateMaxFieldIDEvolution(internal/util/schemautil/schema_evolution.go)校验新 schema 的 max_field_id 必须等于期望值。新字段 ID 一旦小于等于旧高水位,直接报错:new field id reuses an already allocated field id。

一句话:字段 ID 只增不减,删掉的 ID 永远不回收。这是数据安全的基本盘,也是这套协议里容易被忽略、但绝不能省的一环。

字段 ID 单调递增不复用示意图:高水位校验拦截 ID 复用
字段 ID 单调递增不复用示意图:高水位校验拦截 ID 复用

字段 ID 只增不减:删掉的 ID 永不回收

7. 边界:3.0 交付了什么,还没交付什么

把话说全,在线改表设计文档目前标注 Draft。完整的 global atomic commit、透明多版本 segment serving、完整的 Batch Update 语义都是长期目标;3.0 交付的是一条安全序列化路径:load/balance gate 加每 segment 单 schema 版本。它能确保在线改表不丢数据,但不是全集群原子切换。

另外几个用户会踩到的边界:

  • TEXT 输入限制:给既有 collection 挂 BM25/MinHash,输入必须是 VARCHAR,TEXT 不行,没法从 TEXT 回填生成输出
  • 删字段不立即释放空间:官方 FAQ 明确说,drop 之后不会立即释放存储空间,也没有固定的清理时间表
  • external collection:schema 演化依赖 refresh 完成和 segment 版本对齐,phase-2 广播只在集群本地,不跨集群复制

顺带说一句,这种"加列+回填+删列"全在线能力,在同类向量库的公开资料里并不多见,这也是 3.0 的一个差异化卖点。

回头看演进路径也很有意思:2.4 开始能加 nullable 的标量字段,2.6.18+ 支持加 nullable 向量字段,到 3.0 才补上 drop 和函数字段。在此之前删字段这个能力压根不存在,想删只能重建 collection 重新摄入。

总结

Milvus 3.0 的在线改表,本质是把 schema 变更从一次广播动作改造成一个有状态的过程:状态机流转、数据先写、索引就绪、可见性后放行。这是分布式系统逼出来的选择:组件没法同步切换,就只好让变化有序、让可见性滞后。

对使用者来说,收益是实打实的:换 embedding 不重建库,加字段不停机。至于这套协议能不能走向全局原子切换,说实话得看 3.0 落地后的社区反馈,设计文档的 Draft 标注说明团队自己也还没把话说满。

说明:本文内容基于 Milvus 3.0.0 源码(milvus-io/milvus)与官方设计文档(20260715-online-schema-evolution.md、20260413-drop-collection-field-design.md)分析整理而成,源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证。文中的配置模板和参数建议仅供参考,实际效果请以你的业务数据和环境测试结果为准。如果有实际使用经验,欢迎在评论区分享交流。

好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 为什么不能广播一次 schema 替换
  • 2. 数据先写、查询后见:invisible 中间态
  • 3. 两条回填路径:外部算 vs 内核算
  • 4. 你什么时候看见新字段:admission gate
  • 5. 删字段:怎么防止陈旧查询打掉健康节点
  • 6. 字段 ID 不复用:被异步 GC 逼出来的约束
  • 7. 边界:3.0 交付了什么,还没交付什么
  • 总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档