首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一条 delete 走过 4 个状态:拆开 Milvus 的 Segment,我发现删除才是最复杂的操作

一条 delete 走过 4 个状态:拆开 Milvus 的 Segment,我发现删除才是最复杂的操作

原创
作者头像
术哥
发布2026-07-05 09:50:12
发布2026-07-05 09:50:12
430
举报
文章被收录于专栏:运维有术运维有术

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

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

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

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

封面图
封面图

图 1:Milvus Segment 生命周期全景

说明:本文内容基于 Milvus 源码(milvus-io/milvus)和官方 design docs 分析整理,所有技术结论均标注了源码文件路径和行号,可逐项验证。文中引用的源码位置基于笔者本地仓库版本(2026-06-20 同步),随上游演进可能有变化。如果有实际使用经验或发现版本差异,欢迎在评论区交流。

你在 Milvus 里执行一句 delete,控制台秒级返回成功。看起来轻飘飘的一句话,底下其实牵动了一整套机制。

删掉的主键(PK)先被收进一个叫 L0 的中转站 segment,等系统攒够了一批再触发 compaction,把删除标记合并进真正的数据 segment 里。等查询节点把新数据加载完,老 segment 的物理文件才允许被回收。

整个过程绕不开一个核心概念 - Segment。它是 Milvus 数据的物理组织单位,也是 compaction 的操作对象。

前面那篇讲 Compaction 的文章聚焦数据怎么合并,这篇就来补另一半:合并的对象是谁,它从哪来,到哪去

翻了一遍 Milvus 源码(internal/datacoord/ 目录),我发现 Segment 远不止"一块数据"这么简单。

它有一套 L0/L1/L2 的三级分层,有 Growing→Sealed→Flushed→Dropped 的状态机,还有为删除数据专门设计的阻塞机制。

这些设计不是一开始就有的。源码里到处留着演进的痕迹:Legacy 这个枚举值、被标记 deprecatedmax_row_num 字段、v2.4 和 v2.5 行为不一致的 L2 处理逻辑。

下面按"分层是怎么来的 → L1 怎么活一辈子 → L0 为什么特殊 → L2 的尴尬定位 → 设计取舍"这条线展开。

1. 三级分层不是一天建成的

1.1 四个枚举值,藏着一段演进史

Segment 的层级定义在 pkg/proto/data_coord.proto:24-29

代码语言:protobuf
复制
enum SegmentLevel {
  Legacy = 0; // zero value for legacy logic
  L0 = 1;     // L0 segment, contains delta data for current channel
  L1 = 2;     // L1 segment, normal segment, with no extra compaction attribute
  L2 = 3;     // L2 segment, segment with extra data distribution info
}

注意第一个值 Legacy = 0。它不是给人用的,是 proto 的 zero value。老版本 Milvus 创建的 segment 根本没有 Level 这个字段,反序列化出来默认就是 0。

proto 注释(data_coord.proto:415-417)把话说得很直白:

For legacy level, it represent old segment before segment level introduced, so segments with Legacy level shall be treated as L1 segment.

意思就是:分层是后加的,老 segment 一律按 L1 处理。这一个枚举值,就是三级分层演进过程的化石。

1.2 三层各自存什么

把源码里的创建路径、初始状态、注释汇总到一张表,三层的语义就清楚了:

Level

存什么

谁创建

初始状态

源码位置

L0

delta data(删除/更新的增量),channel 级别

DataNode/StreamingNode flush deltalogs 时通过 SaveBinlogPaths 创建

直接 Flushed

meta.go:979 CreateL0Operator

L1

正常写入的数据(insert binlogs),partition 级别

SegmentManager 分配新 segment

Growing

segment_manager.go:440

L2

带"额外数据分布信息"的 segment

Clustering Compaction 产出

Flushed + IsInvisible

meta.go:2371

最反直觉的一点在 L0:它一出生就是 Flushed,根本不走 Growing→Sealed 那一套。原因后面细说。

还有一个细节 - L0 注释里的 for current channel。L0 是 channel 级别的,而 L1 是 partition 级别。

这五个字的差别,决定了整个删除回收机制的设计形态。

三级 Segment 对照图
三级 Segment 对照图

图 2:L0/L1/L2 三级 Segment 的存储范围和创建路径对比

2. L1 的一生:从 Growing 到被 GC

先看最主流的 L1 segment。它有完整的状态流转,是理解整个 Segment 生命周期的基础。

2.1 四个核心状态

SegmentState 的原始定义在 design doc 20211109-milvus_flush_collections.md:126-133

代码语言:protobuf
复制
enum SegmentState {
    SegmentStateNone = 0;
    NotExist = 1;
    Growing = 2;
    Sealed = 3;
    Flushed = 4;
    Flushing = 5;
}

但源码里实际跑的状态不止这些。从 internal/datacoord/*.go 全局 grep,真正在用的是五个:GrowingSealedFlushedDropped,外加一个 Importing

Importing 是 bulk import 专用,design doc 里没有,是后续新增的。

健康判定函数 isSegmentHealthymeta.go:2659-2664)划了条线:

代码语言:go
复制
func isSegmentHealthy(segment *SegmentInfo) bool {
    return segment != nil &&
        segment.GetState() != commonpb.SegmentState_SegmentStateNone &&
        segment.GetState() != commonpb.SegmentState_NotExist &&
        segment.GetState() != commonpb.SegmentState_Dropped
}

也就是说,只有 SegmentStateNoneNotExistDropped 算不健康,Growing、Sealed、Flushed 都是活的。

2.2 完整状态流转图

把源码里的状态转换入口拼起来,L1 segment 的一生是这样的:

代码语言:markdown
复制
[新建] → Growing
         │
         │ 触发 seal(6 种策略,见下文)
         ▼
       Sealed
         │
         │ DataNode 写完 binlog 上报
         ▼
       Flushed
         │
         │ 被 compaction 消费,原 segment 标记 Dropped
         ▼
       Dropped
         │
         │ GC 确认目标 segment 已索引/已加载
         ▼
       [物理删除 binlog 文件]

每一步的入口都能在源码里找到:

  • Growingsegment_manager.go:438openNewSegmentWithGivenSegmentID,直接 State: Growing, Level: L1
  • Sealed→Flushedservices.go:669SaveBinlogPaths,收到 req.GetFlushed() 时调 UpdateStatusOperator 改成 Flushed
  • Droppedservices.go:662,同样在 SaveBinlogPaths 里,收到 req.GetDropped() 时改 DroppedDropSegment
  • 物理删除garbage_collector.go:767recycleDroppedSegments

注意 SaveBinlogPaths 这个函数 - 它是状态转换的核心入口。一个 RPC 同时处理 Flushed 和 Dropped 两种转换。services.go:627-669 这四十来行代码,是 L1 生命线的咽喉。

L1 Segment 状态机流转图
L1 Segment 状态机流转图

图 3:L1 Segment 从 Growing 到 GC 的完整状态流转,含 6 种 seal 策略

2.3 Seal 的六种姿势

Growing 到 Sealed 这一步最有意思。SegmentManager 注册了六种 seal 策略,都在 segment_allocation_policy.go 里:

  • sealL1SegmentByCapacity:按二进制大小封口
  • sealL1SegmentByLifetime:生命周期到期
  • sealL1SegmentByBinlogFileNumber:binlog 文件数超限
  • sealL1SegmentByIdleTime:空闲超时
  • sealByBlockingL0L0 积压触发的强制 seal(重点,下节展开)
  • sealByTotalGrowingSegmentsSize:所有 growing 总体积超限,封最大的

前四种是常规的按尺寸/时间封口,第五种和 L0 删除机制强绑定,第六种是兜底。Segment 大小本身也经历过演进 - data_coord.proto:390max_row_num 字段注释明说:

deprecated, we use the binary size to control the segment size but not a estimate rows

早期是估算行数控制 segment 大小,后来改成实际二进制大小。

为什么改?因为不同向量维度、不同数据类型,同样的行数体积差太多,按行数控不准。

这种 deprecated 痕迹在源码里很常见。读源码时多留意注释,能看到设计是怎么一步步长出来的。

3. L0:删除数据的中转站

L0 是三级分层里设计最精巧、也最容易被忽略的一层。

3.1 为什么删除要单独搞一层

想明白这个问题,得先看普通数据库怎么做删除。

传统 B+ Tree 数据库,删一条数据就是打个 tombstone 标记,查询时跳过。但 Milvus 不一样 - 它的数据是按 segment 物理切分的。一个 PK 落在哪个 segment,删的时候得知道去哪打标记。

最朴素的做法是:删除消息进来,直接写到对应 segment 的 deltalogs 里。问题是 segment 是 partition 级别的,一个 collection 可能成百上千个 segment。删除消息要在它们之间路由,开销不小。

Milvus 的选择是把删除先攒到 channel 级别。proto 注释那句 for current channel 就是这个意思 - L0 不属于任何 partition,它挂在整个 DML channel 上,所有删除消息先进 L0 这个中转站

这就是 L0 一出生就是 Flushed 的原因。它没有 Growing 阶段,因为它不接收实时写入 - DataNode 把内存里的 delete buffer flush 成 deltalogs 之后,通过 SaveBinlogPaths(segLevel=L0) 直接注册成 Flushed 状态(services.go:629-630meta.go:993)。

3.2 阻塞机制:L0 攒多了会逼停 growing

L0 中转站的代价在哪?源码里有个挺巧的策略 - sealByBlockingL0segment_allocation_policy.go:225-320)。

L0 积压到阈值(BlockingL0SizeInMBBlockingL0EntryNum)时,系统会强制 seal 与 L0 时间范围重叠的 growing segment。源码注释直接画了张时间轴来解释,原文如下(segment_allocation_policy.go:251-260):

代码语言:markdown
复制
G1  [0-----------------------------
G2            [7-------------------
G3      [4-------------------------
G4			           [10--------
L0a [0-----5]
L0b          [6-------9]
L0c                     [10------20]

say L0a&L0b make total size/num exceed limit,
we shall seal G1,G2,G3 since they have overlap ts range blocking l0 compaction

意思是:假设 L0a 和 L0b 的总量超限,就要把 G1、G2、G3 全部 seal,因为它们的时间范围和 L0 重叠,会阻塞 L0 compaction。

为什么必须 seal 这些 growing?因为 L0 compaction 要把 delta 应用到该 channel 上所有早于 L0 dmlPos 的 sealed/flushed segment

Growing segment 不参与 compaction。只要它还活着、还在接收写入,L0 的删除就没办法完整应用下去 - 万一应用完之后 growing 又写进来一条同 PK 的数据呢?

所以系统宁可把正在写入的 growing 强制封口,也要保证 L0 的删除能收敛。这是用写入吞吐换删除一致性的取舍

L0 阻塞 seal 时间轴
L0 阻塞 seal 时间轴

图 4:L0 积压超阈值时,时间范围重叠的 growing segment 被强制 seal

3.3 L0 compaction 怎么挑目标

L0 自己攒够了之后,会被 L0DeleteCompaction 消费。目标 segment 的选择逻辑在 compaction_task_l0.go:305-340

代码语言:go
复制
flushedSegments := t.meta.SelectSegments(..., SegmentFilterFunc(func(info *SegmentInfo) bool {
    return ... &&
        info.GetLevel() != datapb.SegmentLevel_L0 &&
        segmentEffectiveTs(info.SegmentInfo) < taskProto.GetPos().GetTimestamp()
}))

两个条件:是 L1/L2(排除 L0 自己),且 effectiveTs < trigger pos。也就是把 L0 攒的这批删除标记,合并进所有早于 L0 触发位置的 L1/L2 segment 的 deltalogs 里。

合并完,L0 segment 自己就走向 Dropped,等 GC 回收。删除数据在 Milvus 里的完整旅程是这样:用户 delete → DML channel → DataNode 内存 → L0 deltalogs → compaction 合并进 L1/L2 → 查询时过滤

4. L2 的尴尬:一个正在被淡化的层级

L2 是三级里最尴尬的存在。

4.1 它本来要干嘛

按 proto 注释,L2 是 segment with extra data distribution info - 带额外数据分布信息的 segment。

这个额外信息来自 Clustering Compaction:把 L1 segment 按某个聚类键(比如某个标量字段的值范围)重新分布,产出的新 segment 就是 L2。

创建路径在 meta.go:2358-2381,新 segment 带 IsInvisible: true,要等 stats 和 index 建完才对查询可见。

4.2 v2.4 和 v2.5 行为不一致

L2 的尴尬,体现在源码里一段非常直白的注释(compaction_task_clustering.go:121):

代码语言:go
复制
// don't mark segment level to L2 before clustering compaction after v2.5.0

再往下翻(compaction_task_clustering.go:665-694),注释把两个版本的行为差异写得很清楚:

代码语言:markdown
复制
// v2.4:L1 ->(process)-> L2 ->(clean)-> L1
//       L2 ->(process)-> L2 ->(clean)-> L2
// ...this task must be generated by v2.4, just for compatibility

// v2.5.0+:after v2.5.0, mark the results segment as dropped

v2.4 时,Clustering Compaction 过程中会临时把 segment 标记成 L2,完成后清理回 L1(L1→L2→L1)。L2 在这里只是个中间态标记

v2.5.0 之后,过程中不再标记 L2,失败的中间结果 segment 直接标 Dropped。

但 L2 并没有被完全废弃。当前版本 Clustering 产出新 segment 时,仍然标记成 L2(meta.go:2371)。它从"中间态标记"退化成了"Clustering 产物的身份标签"。

4.3 LastLevel:为失败兜底的回滚字段

L2 还牵出一个有意思的设计 - LastLevel 字段data_coord.proto:421-424

代码语言:protobuf
复制
// use in major compaction, if compaction fail, should revert segment level to last value
SegmentLevel last_level = 23;

对应的实现在 meta.go:1089-1138,两个 Operator 配对使用:

  • UpdateSegmentLevelOperator:改 level 之前,先把旧值存进 LastLevel
  • RevertSegmentLevelOperator:compaction 失败时,把 Level 恢复成 LastLevel

这是一个典型的事务性回滚设计 - 分布式系统里 compaction 可能失败,level 改了一半得能回退。

源码里这种"为失败做准备"的细节,往往是读文档学不到的工程经验。

5. 这些设计解决了什么,又引入了什么代价

把前面几节接起来看,三级分层的设计逻辑就清楚了。

5.1 Segment 是 Compaction 的统一操作对象

四种 compaction,操作对象都落在 Segment 上:

Compaction 类型

操作的 Segment

L0DeleteCompaction

L0(输入)+ L1/L2(被应用 delta)

MixCompaction

多个 L1 segment 合并

ClusteringCompaction

L1 → L2(按聚类键重分布)

SortCompaction

单个 segment 内部按 PK 排序

通用 compaction 路径的 segment 过滤条件(compaction_util.go:108-113)能看出层级分工:

代码语言:go
复制
return isSegmentHealthy(segment) &&
    segment.GetState() == commonpb.SegmentState_Flushed &&
    segment.GetLevel() != datapb.SegmentLevel_L0 &&  // L0 有独立策略
    segment.GetLevel() != datapb.SegmentLevel_L2     // L2 有独立策略

通用路径只处理 L1 Flushed segment,L0 和 L2 各有独立的 policy。分层的好处就在这里 - 不同生命周期的数据,走不同的合并策略,互不干扰。

5.2 L0 查询时的特殊处理

L0 的特殊性还体现在查询加载阶段。handler.go:360-371 里,segment view 的分类逻辑把 L0 单独拎出来:

代码语言:go
复制
switch {
case s.GetState() == commonpb.SegmentState_Dropped:
    droppedIDs.Insert(s.GetID())
case s.GetState() == commonpb.SegmentState_Importing:
    importingIDs.Insert(s.GetID())
case s.GetLevel() == datapb.SegmentLevel_L0:
    levelZeroIDs.Insert(s.GetID())   // L0 单独归类
case s.GetState() == commonpb.SegmentState_Growing:
    growingIDs.Insert(s.GetID())
default:
    flushedIDs.Insert(s.GetID())
}

L0 被归到 L0SegmentIDs,查询节点(QueryNode)需要额外加载 L0 的 deltalogs,用来在查询结果里过滤掉已删除的 PK。

这也回应了文章开头那个现象 - 删除多了查询会变慢:L0 积压越多,查询时要扫描的 deltalogs 越多,过滤开销越大。直到 L0 compaction 把 delta 合并进 L1/L2 的 deltalogs(这部分会被索引加速),开销才会降下来。

5.3 GC 的安全距离

再说一个容易漏掉的细节 - Dropped segment 不会立即物理删除。garbage_collector.go:767-820recycleDroppedSegments 要确认两件事:

  • 该 segment 被 compaction 出的目标 segment(compactTo已经建好索引
  • 目标 segment 未被任何 QueryNode 加载

这个安全距离是为了防止一种事故:源 segment 的 binlog 删了,但目标 segment 还没建好索引或没加载完,查询直接失败。

用空间换可靠性 - 老数据多留一会儿,等新数据完全顶上再删。

5.4 顺手纠正一个猜测

调研时我一度以为 copy_segment 系列(copy_segment_meta.go 等)和负载均衡或故障恢复有关。

翻开源码注释(copy_segment_meta.go:34-51)才发现猜错了:

代码语言:markdown
复制
// This file implements the metadata management layer for copy segment jobs and tasks
// during snapshot restore operations.

它是 snapshot restore(快照恢复) 场景,跟负载均衡没关系。读源码最大的好处就是能戳破这种想当然 - 文档不会专门告诉你某个模块"不是干什么的",但源码注释会。

6. 几个值得记住的设计取舍

设计取舍对照表
设计取舍对照表

图 5:三级分层设计的取舍总览

设计选择

解决的问题

引入的代价

L0 单独一层存 delta

删除消息不用在成百上千个 segment 间路由

查询时要额外加载 L0 deltalogs 过滤,删除多了变慢

L0 直接 Flushed

中转站不需要 Growing 阶段

创建路径和 L1 完全不同,理解成本高

sealByBlockingL0 强制封口

保证 L0 删除能收敛

牺牲正在写入的 growing segment 的吞吐

LastLevel 回滚字段

Clustering 失败时 level 能恢复

多一个字段,meta 复杂度上升

GC 安全距离

防止删早了导致查询失败

老 binlog 多占存储空间

7. 演进还在继续

源码里有几个信号说明 Segment 的演进没停:

data_coord.pb.go:1330 里,SegmentIDRequest.Level 字段标记了 deprecated。对应的 AssignSegmentID RPC(data_coord.proto:39-41)也标了 option deprecated = true,被新的 AllocSegment 取代。

换句话说,Segment 分配的对外接口本身也在重构 - 旧的按 level 分配的 RPC 语义正在被新的接口替换。

结合 design docs 的时间线看(2021 年的 flush 机制、2026 年的 segment 加载管线和 PK 谓词裁剪),Segment 这个核心概念仍然在快速演进。

读 Milvus 源码最直接的感受是:它没有一上来就把设计做"对",而是边跑边改

max_row_num 从估算行数改成二进制大小,L2 从中间态标记退化成产物标签,Level 字段标记 deprecated 引入新 RPC。每一处 deprecated、每一个 v2.4 vs v2.5 的注释,都是真实工程演进的脚印。这些脚印反过来也说明一件事 - 这套系统是真的被生产环境磨过的,不是 PPT 里画出来的架构。

下一篇准备聊聊 Segment 加载管线(load-segment-pipeline),也就是这些 segment 从对象存储跑到 QueryNode 内存里 querying 的那段路。

QueryCoord 的 Target vs Distribution 模型、2.5 和 2.6 加载流程的差异,都藏在那条 design doc 里。

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

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 三级分层不是一天建成的
    • 1.1 四个枚举值,藏着一段演进史
    • 1.2 三层各自存什么
  • 2. L1 的一生:从 Growing 到被 GC
    • 2.1 四个核心状态
    • 2.2 完整状态流转图
    • 2.3 Seal 的六种姿势
  • 3. L0:删除数据的中转站
    • 3.1 为什么删除要单独搞一层
    • 3.2 阻塞机制:L0 攒多了会逼停 growing
    • 3.3 L0 compaction 怎么挑目标
  • 4. L2 的尴尬:一个正在被淡化的层级
    • 4.1 它本来要干嘛
    • 4.2 v2.4 和 v2.5 行为不一致
    • 4.3 LastLevel:为失败兜底的回滚字段
  • 5. 这些设计解决了什么,又引入了什么代价
    • 5.1 Segment 是 Compaction 的统一操作对象
    • 5.2 L0 查询时的特殊处理
    • 5.3 GC 的安全距离
    • 5.4 顺手纠正一个猜测
  • 6. 几个值得记住的设计取舍
  • 7. 演进还在继续
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档