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

图 1:Milvus Compaction 的反差感——一个 API 背后藏着 7 套机制
如果你在 Milvus 里删过 100 万条向量,然后发现磁盘空间几乎没变,多半不是 bug。是数据真的还没从磁盘上抹掉。
Milvus 官方 2022 年的一篇 Blog 给过定义:compaction 是合并小段 + 清理逻辑删除的过程。听起来就一个动作。但翻一遍源码就会发现,这个动作在 pkg/proto/data_coord.proto 里被拆成了 11 个枚举值,背后挂着 6 套独立触发策略、3 种优先级排序器、多层互斥规则。
对用户来说,调一个 compact() API 就完事了;对开发者来说,整套机制跑了 7 条互不相同的回收路径。
为什么非得拆成这样?这篇文章把源码翻开来看。
说明:本文基于 Milvus 源码(
github.com/milvus-io/milvus的data_coord包)和官方文档 v3.0.x 分析整理,所有结论均可追溯至源码引用或官方 PR/Release Notes。源码分析基于笔者本地仓库版本,尚未在生产环境中完成全场景验证。文中的配置模板和参数建议仅供参考,实际效果请以你的业务数据和环境测试结果为准。如果有实际使用经验,欢迎在评论区分享交流。
源码位置:pkg/proto/data_coord.proto:670-684。
enum CompactionType {
MergeCompaction = 2;
MixCompaction = 3;
SingleCompaction = 4;
MinorCompaction = 5;
MajorCompaction = 6;
Level0DeleteCompaction = 7;
ClusteringCompaction = 8;
SortCompaction = 9;
PartitionKeySortCompaction = 10;
ClusteringPartitionKeySortCompaction = 11;
BumpSchemaVersionCompaction = 12;
}两个细节会先跳出来。
一个是编号 1 缺失。0 和 1 都没有对应类型,明显是早期 UndefinedCompaction 被废弃后留下的演进痕迹。Milvus 的 protobuf 文件从不删旧枚举值,因为旧版本的元数据还在用。
另一个更有意思:1 到 6 号全是历史类型。MergeCompaction、SingleCompaction、MinorCompaction、MajorCompaction 在新版的触发器代码里已经找不到调用方了,源码注释里还能看到一行 todo: migrate to compaction_trigger_v2。
真正活着的只有 5 种:
Level0DeleteCompaction(L0 删除回收)MixCompaction(小段合并 + 删除清理)SortCompaction(单段排序重写)ClusteringCompaction(基于聚类键重新分布)BumpSchemaVersionCompaction(schema 演进时的段重写)这里就有第一个反常识:为什么不直接做成一个 compaction + 一堆配置开关?答案藏在每种类型背后的数据病里 - 它们差得太远,硬塞到一个执行路径里,触发条件写不清楚,优先级也调不明白。
新版触发器(compaction_trigger_v2.go)注册了 6 个独立的 policy,每个挂着自己的 ticker 和触发间隔:
Policy | Ticker | 触发间隔 | 输出 CompactionType |
|---|---|---|---|
| L0Ticker |
| Level0DeleteCompaction |
| SingleTicker |
| MixCompaction + SortCompaction |
| ClusteringTicker |
| ClusteringCompaction |
| StorageVersionTicker |
| MixCompaction |
| BumpSchemaVersionTicker |
| BumpSchemaVersionCompaction |
| 仅手动触发 | — | MixCompaction |
每个 policy 都实现 Enable() / Trigger() / Name() 三个方法,独立判断自己负责的那种数据病是否到了该治的时候。

图 2:6 套 policy 各管一种数据病——删除累积、小段碎片、聚类失衡、schema 落后、存储版本旧、用户手动合并
关键路由表在 trigger_v2.go:67:
case TriggerTypeLevelZeroViewChange, TriggerTypeLevelZeroViewIDLE, TriggerTypeLevelZeroViewManual:
return datapb.CompactionType_Level0DeleteCompaction
case TriggerTypeSegmentSizeViewChange, TriggerTypeSingle, TriggerTypeForceMerge:
return datapb.CompactionType_MixCompaction
case TriggerTypeClustering:
return datapb.CompactionType_ClusteringCompaction
case TriggerTypeSort:
return datapb.CompactionType_SortCompaction
case TriggerTypeStorageVersionUpgrade:
return datapb.CompactionType_MixCompaction // 共用 mix 执行路径
case TriggerTypeBumpSchemaVersion:
return datapb.CompactionType_BumpSchemaVersionCompaction注意一个细节:StorageVersionUpgrade 没有自己的执行路径,直接复用 MixCompaction 的执行器。Milvus 团队的设计原则很清楚 - 触发条件要拆细,执行能复用就复用。
每种类型背后是一种性质完全不同的数据病:
把这六种病塞进一个 compaction,意味着每次触发都得检查六套条件,互斥规则会爆炸。拆开之后,每个 policy 独立判断,独立调度。
6 种类型里,最值得拆开看的是 Level0DeleteCompaction。它是专门负责把删除从磁盘上抹掉的机制,其他类型本质上都是在不同维度上重写 segment。
要理解 L0 为什么特殊,先得理解 Milvus 的段分级:
删除请求到来时,删除记录先写到当前 channel 的 L0 段,查询时再 apply 到 L1/L2。这种设计的好处是写入路径不被删除阻塞 - 删除只是往 L0 里追加一条日志,极快。代价是 L0 会持续累积,必须定期回收。
回收这件事,看似简单:把 L0 里的删除日志合并到对应的 L1/L2 段里,然后丢掉 L0。实际上 Milvus 给了它整整 7 处特殊待遇,下面挑 4 个最硬核的工程取舍来讲。
源码位置:compaction_queue.go。三种 prioritizer:
LevelPrioritizer: L0Delete(1) < Mix/BumpSchema(10) < Clustering(100) < 其他(1000)
MixFirstPrioritizer: Mix/BumpSchema(1) < L0Delete(10) < Clustering(100) < 其他(1000)
DefaultPrioritizer: 按 PlanID 排序(FIFO)通过 Params.DataCoordCfg.CompactionTaskPrioritizer 切换(level / mix / 默认)。默认就是 LevelPrioritizer,L0 永远排在最前面。官方 configure_datacoord.md 文档里也写得明白:level 模式下 L0 compactions first, then mix compactions, then clustering compactions。
理由很直接:删除不回收,deltalog 会一直累积,bloom filter 膨胀,查询延迟跟着飙升,写入也会被阻塞。Mix 和 Clustering 慢一点只是查询慢一点,L0 慢了是连锁灾难。
这是 PR #47154 引入的快路径,源码在 compaction_task_l0.go:100:
// 如果 plan 里只有 L0 段(没有可选的 L1/L2 目标段),直接走 fast finish
if len(plan.SegmentBinlogs) == len(t.GetTaskProto().GetInputSegments()) {
log.Info("l0CompactionTask fast finish: no target segments, directly marking L0 segments as dropped")
// 保存空输出,把 L0 段标记为 dropped,跳过 DataNode 调用
if err = t.saveSegmentMeta([]*datapb.CompactionSegment{}); err != nil { ... }
// 直接进入 meta_saved 状态
if err = t.updateAndSaveTaskMeta(setState(datapb.CompactionTaskState_meta_saved)); err != nil { ... }
return
}触发场景很具体:L0 段的删除日志影响的所有 L1/L2 段都已经被 drop 了(比如 collection 删了大量段)。这种情况下,不需要把删除 apply 到任何目标段,直接丢弃 L0 即可。省了一次 DataNode 调用,避免了无效 IO。
Milvus 2.6.16 的 release notes 也把这个修复正式收编了:Improved L0 compaction to fast-finish when no matching L1/L2 segments are found(PR #49376)。
这个最有故事感。PR #48907 的标题是 fix: self-heal compaction segment positions and add L0 force-select bypass。姊妹 PR #48910 的官方描述把问题讲得很直白:
Compaction inherits StartPosition/DmlPosition from source segments via
getMinPositionwithout recalculating from actual data. The import position bug (PR #47276) wrote wrong timestamps on imported segments, and these wrong positions persist and propagate through compaction. L0 compaction then misses L1/L2 segments due to StartPosition mismatches, causing zombie L0 segments and silent delete loss.
翻译一下:import 写错了时间戳,导致 L0 永远找不到对应的 L1/L2 段,删除日志变成僵尸段。后果是用户以为删了,其实没删 - silent delete loss,静默删除丢失。
修复策略是一个运维开关 - LevelZeroCompactionForceSelectAll。开启后强制让所有 L1/L2 段都参与 L0 compaction,绕过位置校验。源码在 compaction_l0_view.go:20:
// resolveLatestDeletePos 在 LevelZeroCompactionForceSelectAll 启用时,
// 返回 MaxUint64 时间戳,让所有 L1/L2 段都通过 startPos < taskPos 过滤
// ——用于从 import position bug 引起的错误 StartPosition 元数据中恢复
func resolveLatestDeletePos(latestL0DmlPos *msgpb.MsgPosition) *msgpb.MsgPosition {
if paramtable.Get().DataCoordCfg.LevelZeroCompactionForceSelectAll.GetAsBool() {
return &msgpb.MsgPosition{
ChannelName: channel,
Timestamp: math.MaxUint64,
}
}
return latestL0DmlPos
}平时关闭,出问题的时候手动开起来做一次性修复。注意这里还有一个被顺便修掉的潜伏 bug:mix/clustering compaction 里 DmlPosition 用的是 getMinPosition,但 DmlPosition 表示的是当前实体时间戳,应该用 max。官方在 PR 描述里明确承认了这是个 latent bug。
源码在 compaction_task_meta.go:96-101:
// Compatibility handling: for milvus ≤v2.4, since compaction task has no PreAllocatedSegmentIDs field,
// here we just mark the task as failed and wait for the compaction trigger to generate a new one.
//
// NOTE:
// - Only compaction tasks that require pre-allocated segment IDs should be marked as failed
// - Level0DeleteCompaction tasks never use PreAllocatedSegmentIDs and must be ignored here,
// otherwise unfinished L0 delete compaction tasks created before upgrade will be
// incorrectly marked as failed on reload.
if !isCompactionTaskFinished(task) &&
task.PreAllocatedSegmentIDs == nil &&
task.GetType() != datapb.CompactionType_Level0DeleteCompaction {
task.State = datapb.CompactionTaskState_failed
task.FailReason = "PreAllocatedSegmentIDs is nil"
}读这段注释能感觉到一种很谨慎的工程态度:升级前未完成的 clustering/mix 任务可以标 failed 重来,但 L0 任务不行 - 因为 L0 管的是删除数据,中断一次就可能丢日志。所以宁可让它继续跑完,也不能在升级时被杀掉。
这种取舍贯穿了整个 L0 设计:宁可多跑一次也不能丢。
如果说前 4 处特殊待遇是在讲 L0 怎么被保护,那 snapshot 豁免讲的是 L0 不能被别人挡住。
Milvus 3.0 引入了 snapshot 功能(PR #47669、#48227、#49052)。一旦某个 segment 被 snapshot 引用,它就不能被 compaction 改动 - 否则备份就会失效。这套保护接口有两个:
// 段级保护:被 snapshot 引用的段禁止 compaction
func (m *meta) isSegmentCompactionProtected(segmentID int64) bool
// collection 级阻塞:保护 snapshot 的 RefIndex 未加载完时,整个 collection 禁止 compaction
// fail-closed 设计
func (m *meta) isCollectionCompactionBlocked(collectionID int64) bool所有 v2 policy 在筛选段时都会调用这两个接口 - 源码里能 grep 到 19 处调用。
但 L0 是个例外。源码位置 meta.go:2930,函数 GetCompactableSegmentGroupByCollection 的注释写得非常直接:
// GetCompactableSegmentGroupByCollection returns sealed segments grouped by collection.
// This consumed exclusively by the L0 compaction policy, which only acts on L0 segments.
// Snapshot compaction protection targets L1/L2 segments referenced by snapshots, so it must
// NOT filter segments here: doing so would prevent L0 delete-log compaction and cause
// delta log accumulation, query latency spikes, and write stalls on collections with
// active snapshots.取舍逻辑是这样的:如果 L0 也被 snapshot 保护,那么有活跃 snapshot 的 collection 上,所有删除日志都不能回收。后果链官方注释写得清清楚楚:delta log 累积 → bloom filter 膨胀 → 查询延迟激增 → 写入停滞。
所以最终设计是:snapshot 期间允许 L0 回收(只动 L0 段,不动被引用的 L1/L2)。L0 段本身不存数据,只存删除日志,丢掉它不影响 snapshot 的数据完整性。
到这里,L0 的 7 处特殊待遇全齐了:
L0CompactionTriggerInterval 与 Mix 分开)
图 3:L0 删除回收专线——L0→L1/L2 流程 + 7 处特殊待遇
一句话总结:L0 不是普通 compaction,是系统的垃圾清运车,一旦停摆,整个系统会出问题。
你在用 Milvus 的时候如果碰过这种症状 - 删了一大堆数据,磁盘没掉,查询反而变慢 - 很可能就是 L0 这条专线的某个环节卡住了。欢迎在评论区聊聊你遇到过的场景。
讲完 L0 的特殊性,再来看整个调度层是怎么把 7 套机制装进一个执行池的。源码位置 compaction_inspector.go(763 行)。
整体结构是三队列 + 状态机:
queueTasks (优先级堆) ──► executingTasks (map) ──► cleaningTasks (map)
↑ │ │
│ ▼ ▼
Enqueue() Process() 状态推进 Clean() 清理
checkCompaction() cleanFailedTasks()任务状态机也很清楚:pipelining → executing → meta_saved → completed → cleaned,失败可以走 failed / timeout 分支。
每种类型的最大执行时长是不一样的,源码里直接写死:
var maxCompactionTaskExecutionDuration = map[datapb.CompactionType]time.Duration{
MixCompaction: 30 * time.Minute,
Level0DeleteCompaction: 30 * time.Minute,
ClusteringCompaction: 60 * time.Minute,
SortCompaction: 20 * time.Minute,
BumpSchemaVersionCompaction: 30 * time.Minute,
}Clustering 给了一个小时,因为它要做完整的全局重分布。Sort 只给 20 分钟,单段排序不应该这么久。超过时长不会直接 fail,只是打 RatedWarn 告警,留出超时容忍空间。
调度循环维护 4 个互斥集合:
l0ChannelExcludes:L0 任务占用的 channelmixChannelExcludes + mixLabelExcludes:Mix/Sort/BumpSchema 占用clusterChannelExcludes + clusterLabelExcludes:Clustering 占用互斥规则有三条:
被互斥的任务会被放回 excluded 列表,循环结束后重新 enqueue。这里的 channel 级互斥约束最硬,因为 L0 的工作模式是往一个 channel 里所有相关段写 delta,必须独占整个 channel 才能维持一致性。

图 4:调度器架构——3 种 prioritizer、5×5 互斥矩阵、差异化执行时长
讲完后台机制,回到用户视角。对用户来说,真正能感知到的只有两个 compaction 入口:ForceMerge 和 Clustering。其余几种都是系统自动跑。
ForceMerge 是 2025 年 12 月 PR #45556 引入的,标题就叫 feat: Add force merge。官方文档(milvus.io/docs/force-merge.md)的定义是:
Force Merge is designed to consolidate small and fragmented segments into fewer and larger ones to improve query performance and storage efficiency.
但翻开源码会发现,ForceMerge 本质上就是 MixCompaction 的一种变体,只是多了拓扑感知的目标大小计算。源码在 compaction_view_forcemerge.go:
// QueryNode 内存约束:用全局最小内存
qnMaxSafeSize := float64(lo.Min(lo.Values(v.topology.QueryNodeMemory))) / querynodeMemoryFactor
// DataNode 内存约束:段必须能放进最小的 DataNode
dnMaxSafeSize := float64(lo.Min(lo.Values(v.topology.DataNodeMemory))) / datanodeMemoryFactor
maxSafeSize := min(qnMaxSafeSize, dnMaxSafeSize)设计动机很现实:合并出来的段不能比最小的 QueryNode/DataNode 内存还大,否则加载不进去。Standalone 非 pooling 模式下 QueryNode 和 DataNode 共机,maxSafeSize 还要折半。
Issue #46706 里一位用户的 bug 报告也侧面证实了这个判断 - 用户原话是:ForceMerge (MixCompaction) successfully completes and merges 3 segments into 1。用户层面看到的 ForceMerge,在元数据里就是 MixCompaction。
还有一个细节很有意思 - 并行加载优化:
perShardParallelism := queryNodeCount / (numReplicas * numShards)
if perShardParallelism > 1 && targetCount < perShardParallelism {
// 提升 segment 数到 perShardParallelism,加快加载速度
targetCount = desiredCount
maxSafeSize = totalSize / float64(targetCount)
}段数太少时,加载新 collection 的并行度受限于段数。ForceMerge 会主动增加段数到 perShardParallelism,让每个 querynode 都能并行加载一段。这跟合并成更少更大的段的目标是矛盾的 - 但加载速度比段大小更重要的时候,就反过来增加段数。
Clustering 是另一个用户可见的出口,在 Milvus 2.5 正式以 Beta 形式进入用户文档。设计动机官方文档说得明白:
Milvus stores incoming entities in segments within a collection and seals a segment when it is full. As a result, entities are arbitrarily distributed across segments. This distribution requires Milvus to search multiple segments to find the nearest neighbors.
解决方案是基于 scalar field(聚类键)的值重新分布 segment,生成 PartitionStats(segment 与聚类键值的映射),查询时用聚类键值剪枝无关 segment。
官方 benchmark 数据:20M 条 LAION 768 维数据集,按 key 字段聚类后:
key==1000 精确过滤:431.41 QPS(25 倍提升)剪枝比例越高,QPS 越高。但 Clustering 当前有一个临时约束,源码里有明确注释:
// todo: remove this check after support partial clustering compaction
func (policy *clusteringCompactionPolicy) checkAllL2SegmentsContains(...)partition+channel 下的所有 L2 段必须全部参与 clustering,否则 skip。这是临时限制,未来会支持 partial clustering。
把整个 compaction 子系统的演进拼起来看,能看出 Milvus 团队的工程节奏。

图 5:双时间线对照——架构演进(拆分 policy)与 L0 修复史(踩坑与补救)
架构演进:
时间 | PR | 内容 |
|---|---|---|
2024-Q2 | #37190 | 拆分 L0 和 Mix 的触发器间隔(L0 独立) |
2024-Q3 | #39217 | L0 policy 引入 active collections 机制 |
2025-Q1 | #42562 | Sort stats task 独立为 SortCompaction |
2025-Q2 | #45556 | 新增 ForceMerge(target size compaction) |
2025-Q2 | #46990 | 新增 StorageVersionUpgrade policy |
2025-Q3 | #48808 | 新增 BumpSchemaVersionCompaction |
L0 关键修复(这一串最有意思):
时间 | PR | 问题 |
|---|---|---|
2024-Q4 | #40960 | 删除数据丢失(duplicate binlogID) |
2025-Q2 | #46436 | 设置 latestDeletePos 边界(L0 与 L1 选择的关键修复) |
2025-Q2 | #47154 | Fast finish(L0 命中 0 个 L1/L2 时直接 drop) |
2025-Q4 | #48907 | Self-heal(修复 import position bug 引起的僵尸 L0 和静默删除丢失) |
2026-Q2 | #47214 / #49122 | deltalog max count 默认值从 30 提升到 1000(缓解高删除负载下的积压) |
末尾一条值得单独说一下。Milvus 2.6.16(2026-05-14)的 release notes 明确写了:deltalog max count 默认值从 30 提升到 1000,目的是缓解高删除负载下的 compaction 积压。这从侧面说明,之前的默认值(30)对高删除场景明显不够,社区应该踩过不少坑。
整个时间线还藏着 3 处可见的渐进式迁移痕迹:
singleCompactionPolicy 注释里写着 support l2 single segment only for now, todo: move l1 single compaction heretodo: remove this check after support partial clustering compaction这些 todo 注释是先做能用的版本、再渐进优化的典型工程实践。也意味着当前架构仍在演进中 - L1 的 single compaction 迁移、partial clustering 支持都是后续会动的地方。
回到开头那个问题 - 删了 100 万条数据,磁盘为什么没缩。
答案藏在 Milvus 的整体设计取舍里:写入路径不被删除阻塞(往 L0 追加日志极快),删除回收是另一条独立的路径(L0 Delete Compaction 周期性触发)。两次 compaction 之间的窗口期,删除日志会一直堆在 L0 段里,磁盘空间自然不会立刻释放。
这件事在官方文档里其实没有专门文档说明 - 调研报告里那张覆盖度表格很能说明问题:
类型 | 官方文档覆盖度 |
|---|---|
ClusteringCompaction | 完整(user guide + benchmark) |
L0 DeleteCompaction | 极低(仅在 PR/Issue 中可见) |
MixCompaction | 极低 |
SortCompaction | 极低 |
BumpSchemaVersionCompaction | 极低 |
StorageVersionUpgrade | 极低 |
ForceMerge | 完整 |
官方文档只覆盖了用户可感知的 Clustering 和 ForceMerge,其余 5 种类型的实现细节完全藏在源码和 PR 里。
所以才会有那个反差:对用户来说只有一个 compact() API 和一个 Clustering Compaction,对开发者来说却跑着 7 套独立机制,每套对应一种不同的数据病,每套有自己的触发条件、优先级、互斥规则。
Milvus 把这件事拆得这么细,本质是因为向量库的 segment 一旦写入就被索引、被多副本加载、被 snapshot 引用,任何合并动作都伴随着复杂的并发约束。一个大而全的 compaction 既写不清楚触发条件,也调不清楚优先级。
拆细的代价是复杂度暴涨,好处是每条路径可以独立优化、独立修复。L0 那一长串 bug 修复史(#40960 → #46436 → #47154 → #48907)能看得出来 - 如果 L0 和 Mix 共用一条执行路径,这些 bug 的排查难度会指数级上升。
如果你在做向量库选型,或者运维 Milvus 时遇到过删除不释放、查询莫名变慢的问题,源码里那 7 套机制大概率能给你答案。建议收藏,下次遇到类似的磁盘异常先翻 L0 这条专线。
好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。