在第 5 章中,我们探索了 Milvus 为高可扩展性而设计的分层架构。从本章开始,我们将把关注点从静态组件转向动态数据流,从写入和读取两个视角探索数据在 Milvus 内部的完整生命周期。本章将聚焦数据写入过程。我们首先会揭开 Milvus 内部核心数据组织单元,也就是 segment,并澄清 streaming data 和 historical data 之间的关键区别。接着,我们会详细说明一个 segment 的完整生命周期管理过程:从诞生、增长、整合到最终消亡。最后,我们会从开发者视角出发,跟踪 insert、delete 和 upsert 这些 data manipulation language(DML)请求在 Milvus 内部的完整旅程,帮助你深入理解 Milvus 写路径的工作方式。
本章将覆盖以下主要主题:
- 数据组织的核心:segment
- segment 的生命周期
- DML 请求背后的旅程
- Channel checkpoints
让我们从 Milvus 内部最基础的数据组织形式开始。
技术要求
本章使用的代码可以在我们的 GitHub 仓库中找到:
https://github.com/PacktPublishing/The-Architecture-Handbook-for-Milvus-Vector-Database
要运行这些代码示例,你需要一个 Python >= 3.8 的环境,可以从这里下载:
https://www.python.org/downloads/
你应该使用 ch5 目录中的 requirements.txt 文件安装 ch5 里的所有依赖。
数据组织的核心:segment
在 Milvus 中,一个 collection 内的所有数据最终都会被组织成大量 segments。Segment 是 Milvus 内部数据组织和管理的最小单元。Cluster 中绝大多数服务,例如 data nodes 和 query nodes,都是在 segment 层级处理数据。Segments 在 Milvus 性能中发挥关键作用,因为它们支持并行处理、可扩展存储和高效索引,使其成为 Milvus 内部组织向量数据的核心结构。因此,要深入理解 Milvus 的数据管理机制,我们必须首先理解这个核心概念。
要理解 segment,可以从三个相互关联的维度来观察:它的数据状态、内部结构,以及它在系统中扮演的角色。
数据状态:streaming 与 historical
首先,基于数据在生命周期中所处阶段,Milvus 会将 collection 内的数据在逻辑上分为两种状态:streaming data 和 historical data。
Streaming data 通常指 Milvus 中实时、最近插入的数据。它主要由 growing segments 和 anonymous deletes 组成。值得注意的是,数据的物理存储位置并不是区分 streaming data 和 historical data 的最终边界。
一个 growing segment 的一部分数据可能仍然位于 message queue 的 vChannel 中,而另一部分可能已经持久化到 object storage。delete records 也是如此。
相对而言,historical data 表示系统中已经被整合完成的那部分数据。在写路径语境下,任何不再处于 growing state 的 segment,都可以被认为是 historical data。这些 segments 中的 entities 是不可变的,并且是构建 indexes、执行 queries 和进行 data compaction 的主要对象。Milvus 整个写路径的核心目标之一,就是高效地将 streaming data 转换为结构化 historical data。简单来说,streaming data 表示实时进入的数据,而 historical data 表示稳定、已存储并已索引的数据。这样的分离使 Milvus 能够同时支持高速数据摄取和高效的大规模向量搜索。
现在,我们来看 Milvus 中 segment 的结构。
内部结构:segment 的文件组成
在内部,segment 并不是一个单独文件,而是一组不同类型文件的逻辑集合,这些文件协同工作,用于完整描述一部分数据的状态,如图 6.1 所示。
图 6.1:Segment 文件结构
Segment 文件结构大致可以分为四个部分:
Binlogs
Binlogs 构成 segment 的核心,用 columnar storage format 存储该 segment 包含的所有 entities。为了高效读写,segment 内的数据会被拆分成多个 data chunks。每个 chunk 包含多行数据,并按列组织;每一列都是一个单独的 binlog file。
图 6.2:包含 M 个 chunks 的 segment
每个 binlog file 都遵循标准化 event format:
4-byte magic number:这是一个固定标识符(0xfffabc),标记每个 binlog file 的开头,使 Milvus 能够快速验证一个文件确实是有效 binlog。
包含 segment metadata 的 descriptor event:该 event 存储数据的必要信息,包括 collectionID、partition ID、segment ID、field ID、data type 和 timestamp range。它相当于文件头,提供关于该 binlog 包含什么数据,以及读取文件时应如何解释这些数据的上下文。
包含实际 data payload 的 insert Event,采用 Parquet 格式:该 event 使用 Apache Parquet format 存储实际数据行。Parquet 是一种 columnar storage format,提供高效压缩和编码。insert event 包含 timestamp 信息,并封装 Parquet 编码数据,使 Milvus 能够高效存储和检索大量 vector 与 scalar data。
Statistics logs,也就是 stats logs
Stats logs 用于 query optimization 和 data filtering。它们使 Milvus 能够快速判断一个 segment 是否可能包含相关数据,而不需要扫描整个 segment。这主要包括基于 primary key 计算出的 bloom filters。Bloom filter 是一种空间效率很高的概率型数据结构,用于测试某个元素是否属于某个集合。它可能产生 false positives,也就是声称元素存在但实际上不存在,但永远不会产生 false negatives,也就是说,如果它声称元素不存在,那它一定不存在。在 Milvus segments 中,bloom filters 基于 primary key field 计算,包含 bloom filter 以及 primary key 的最小值和最大值。
其目的在于:通过使 Milvus 能够在 search operations 中快速过滤无关 segments,从而提升 query efficiency。
Stats logs 被广泛用于定位哪些 segments 包含需要被删除的 primary keys。这种查找能力对于读路径上的标准 delete operations,以及写路径中的后台 data compaction 都非常重要;详情见第 8 章。
Index files
Index files 存储可建立索引 fields 生成的 index data,例如 vector fields。Query nodes 会加载这些 files,以大幅加速 ANN search。其目的在于:支持快速 vector similarity search,并显著降低 search latency。它与 binlogs 使用相同的 binlog format,不同之处在于,event data 中存储的是 indexed data,而 insert binlog 存储的是 data payload。Index 类型很多,包括 vector indexes 和 scalar indexes,我们将在第 9 章详细讨论。
Delta logs
Delta logs 也称为 delete logs,专门用于存储 delete records。Delete record 是一个 JSON 字符串,包含 primary key 和 timestamp,表示在该 timestamp 之前插入的、具有相同 primary key 的 entity 被删除。其目的在于:通过 timestamp 支持记录的 logical deletion,同时维持数据一致性。Delta logs 的使用会在第 6 章和第 7 章的 delete process 中详细说明。
Segment levels
最后,为了实现更精细的数据管理,特别是为了优化 compaction 和 delete operations 的效率,Milvus 引入了 segment levels 的概念,将 segments 分为三个不同 level:
Level zero(L0)segment:这是一个特殊 level,专门用于存储 anonymous delete records。L0 Segment 是 channel level segment,只存储 delta logs。当 delete request 到来时,Milvus 无法立即知道被删除的 primary keys 分布在哪些 segments 中。这些 deleted records 会被组织成属于 L0 segment 的 delta logs。
Level one(L1)segment:这是最标准的 segment level。前面讨论的 growing segments 就是 L1 segments。一个 L1 segment 可以存储 entity data,也可以记录针对自身的 delete entries。L1 segments 之间的数据分布通常是随机的。
Level two(L2)segment:这是优化后的 L1 segment。多个 L1 segments 通过一种称为 clustering compaction 的操作合并后,会生成 L2 segment。L2 Segment 内的数据不再随机分布,而是按照特定规则重新组织,例如基于 vector similarity。这种有序组织可以在特定查询场景中显著提升检索效率。
通过上述分析,我们理解了 segment 如何从 streaming 到 historical 发生动态演进,如何由多种文件组成内部结构,以及如何在系统中扮演不同角色。有了 segment 的整体图景后,我们就可以在下一节更好理解它的完整生命周期。
Segment 的生命周期
本节将沿着一个 segment 的完整生命旅程,从动态视角观察它如何被 Milvus 创建、管理,并最终回收。
这个旅程可以概括为一条清晰的状态转换路径:
Growing -> Sealed -> Flushed -> Dropped
图 6.3:Segment 生命周期管理
下面详细探索 segment 生命周期的各个阶段。
阶段一:growing state
当你向 collection 插入一些新的 entities 时,Milvus 会将这些 entities 分配给 growing segments。由于 shards 和 partitions 都代表数据的物理划分,一个拥有 m 个 shards 和 n 个 partitions 的 collection,在任意给定时刻最多可以包含 m*n 个 growing segments,也就是每个 shard-partition 组合对应一个 growing segment。
新的 growing segment 会在两种条件下被创建:当所有已有 growing segments 都达到最大容量时,或者当某个 shard-partition pair 还不存在 growing segment 时。DataCoord 会监控 segment states,并在需要时决定分配新的 segment。新创建的 segment 一创建就进入 growing state。处于 growing state 的 segments 是 mutable 的,这意味着它们可以持续接收进入的 entity data。Growing segments 是 streaming data pipeline 的一部分,也就是说,它们在数据最终定型之前处理实时数据摄取。虽然这个状态下的数据尚未 sealed 或 indexed,但仍然可以被搜索。Milvus 会确保 queries 同时考虑 growing segments 和 sealed segments,从而使新插入数据可以被立即检索到。
这个阶段会持续到 segment 达到预定义大小阈值,或发生 flush operation。一旦发生这种情况,segment 会进入生命周期的下一阶段,在那里它会被 sealed,并准备进行持久化存储和 indexing。
阶段二:sealed state
Growing segment 不会无限增长。当满足某些条件时,DataCoord 会自动 seal 一个 growing segment,将其状态从 growing 改为 sealed。这个转换意味着该 segment 的数据写入入口正式关闭,它不再接受新的 entities。
触发 sealing 的条件多种多样,目标是在数据写入实时性、内存使用效率和文件管理开销之间取得平衡:
Size threshold:当 growing segment 的大小超过预定义容量限制时触发。该限制由两个配置计算得出:
dataCoord.segment.maxSize * dataCoord.segment.sealProportion
Time threshold:当 growing segment 在 growing state 中停留时间过长时,即使大小尚未达到阈值,也可能触发。
System pressure:当某个 shard 中积累了大量 streaming data 时,Milvus 可能会提前 seal segment。这主要是为了缓解 query path 中相关组件,也就是 Delegator 的内存压力,其详细机制将在第 7 章讨论。
Sealed state 是一个短暂的中间状态。此时,segment 的部分数据可能仍然在 message queue 中,尚未完全处理。因此,它还不能被视为一个可以仅从 object storage 中独立恢复的完整 historical data segment。它就像一个等待最终处理的包裹,等待 DataNode 完成所有相关 sync 工作。Sealed segment 是不可变的,不能再写入新数据。
阶段三:flushed state
从 sealed 到 flushed 的转换,是 segment 生命周期中的关键步骤。它标志着一个 segment 正式从动态 streaming data 转变为静态、内容已整合的 historical data。这个过程的核心是 flush。
Flush operation 本质上是确保某个 sealed segment 在 message queue 中的所有相关数据都已经被 DataNode 消费,并成功持久化到 object storage。只有当 DataCoord 确认这个过程彻底完成后,才会正式将该 segment 的状态更新为 flushed。
一旦成为 flushed segment,它就进入生命周期中最长、最稳定的成熟阶段。它的所有 entity data 都已完整保存在 object storage 上,并且内容不可变。作为有效 historical data 单元,它可以参与各种后台任务,例如:
- 被 QueryNode 加载,用于提供查询服务
- 为其构建持久化 indexes
- 参与 compaction tasks,与其他 segments 合并,以优化存储和性能,这将在第 8 章详细说明
阶段四:dropped state
Segment 的旅程会在被 dropped 时结束。Segment 可以通过两种主要方式进入该状态:
Active deletion:当用户执行 drop collection 或 drop partition 操作时,其中包含的所有 segments,无论当前状态如何,都会被 DataCoord 标记为 Dropped。
Passive merging:当一个或多个 Flushed Segments 参与 Compaction task 时,旧 segments 会被标记为 Dropped,因为 Compaction 会生成一个全新的 segment,用于保存它们合并后的数据。
处于 dropped state 的 segments 是不可变的。它们不能在写路径中被选择用于 indexing 或 compaction。不过,dropped segments 中的数据仍然可用。它们仍然可以被加载、在读路径中参与 balancing,并继续服务 queries。Dropped state 只是一个逻辑标记;segment 的物理文件尚未删除。它像等待清理的逻辑垃圾,在某些情况下,数据甚至仍然可查询。直到后台 garbage collection(GC)流程根据这个标记,彻底移除其所有相关物理文件、indexes 和 metadata 后,这个 segment 的生命周期才算真正结束。
虽然 segment 的生命周期在大多数情况下由 Milvus 自动管理,但 Milvus 也提供了一个重要 API:flush(),允许用户主动干预这个过程:
c.flush(collection_name = "test_collection")
调用 flush() 方法会触发一个同步操作,强制 collection 中所有当前 growing segments 完成 Growing → Sealed → Flushed 过程。该操作的意义在于为用户提供确定性保证。当 flush() 调用成功返回后,该调用之前写入的所有数据都已经安全、完整地持久化到 object storage,形成一个不可变的 historical data snapshot。这对于需要在数据写入和后续操作之间,例如查询或备份,建立清晰时间点的应用场景至关重要。
理解了 segment 的生命周期之后,现在可以看看 DML requests 是如何执行的。
DML 请求背后的旅程
理解 segments 的静态结构和动态生命周期之后,本节从三类 DML requests 开始:insert、delete 和 upsert,并沿着 Milvus 写路径,追踪数据如何从 SDK 一路到达持久化在 object storage(例如 S3)中的 segment binlogs。我们从最基础的 insert operation 开始。
Insert request
以下面这个简单 insert 为例,我们重点关注三个问题:
- insert 如何被路由到正确的 shard 和 vChannel?
- 数据何时变得可查询?
- 数据何时被转换为 segment binlog,并持久化到 object storage?
使用第 5 章中的同一数据集,先加载数据集,创建 collection,并向其中插入四个 entities:
# Create a schema object for the Milvus collection
# A schema defines the structure of the data that will be stored
# including fields names, data types, vector dimensions, and primary keys
schema = MilvusClient.create_schema()
# Add a primary key field named "id"
# datatype=VARCHAR means the ID will be stored as a string
# max_length=64 limits the maximum allowed characters for the ID
# is_primary=True indicates this field uniquely identifies each entity in the collection
schema.add_field(
field_name="id", datatype=DataType.VARCHAR,
max_length=64, is_primary=True)
# Add a field to store the movie title
# VARCHAR is used because titles are text values
# max_length=512 allows relatively long movie titles
schema.add_field(
field_name="title", datatype=DataType.VARCHAR, max_length=512)
# Add a field to store the movie plot/description
# max_length=65536 allows storing very long text descriptions
# This is useful for storing full movie summaries
schema.add_field(
field_name="plot", datatype=DataType.VARCHAR, max_length=65535)
# Add a field to store the movie genre
# Only one genre is stored here (first genre from dataset)
# VARCHAR with max_length=64 is sufficient for genre names
schema.add_field(
field_name="genre", datatype=DataType.VARCHAR, max_length=64)
# Add a vector field named "embedding"
# datatype=FLOAT_VECTOR means this field stores vector embeddings
# dim=1536 indicates each vector contains 1536 floating point values
# This dimension matches the embedding model used for the dataset
schema.add_field(
field_name="embedding", datatype=DataType.FLOAT_VECTOR, dim=1536)
# Create a new collection in Milvus named "ch6_movies"
# The collection will store movie information along with vector embeddings
# Milvus will internally create partitions, segments, and storage structures
client.create_collection(collection_name="ch6_movies", schema=schema)
# -------------------------------
# Load Dataset
# -------------------------------
# Load the dataset from HuggingFace
# "MongoDB/embedded_movies" contains movie metadata and embeddings
# split ="train" loads the training portion of the dataset
# streaming=True enables streaming mode meaning the dataset is read row-by-row
# instead of downloading the entire dataset into memory
dataset = load_dataset(
"MongoDB/embedded_movies", split="train", streaming=True)
# -------------------------------
# Prepare Entities for Insertion
# -------------------------------
# Initialize an empty list to store processed rows
# These rows will later be inserted into Milvus
rows = []
# Iterate over the dataset using itertools.islice
# This allows use to read only a limited number of records from the streaming dataset
for row in itertools.islice(dataset, 10):
# Stop once we collect 4 valid rows
# This avoids inserting too many records during testing
if len(rows) >= 4:
break
# Check if the current row contains both embedding and genre information
# Some dataset entries might have missing values
# We skip those entries to prevent insertion errors
if row.get("plot_embedding") and row.get("genres"):
# Append a properly structured entity dictionary
# Each key must match the field names defined in the schema
rows.append({
# Use dataset _id as the primary key
"id": row["_id"],
# Movie title
"title": row["title"],
# Movie plot/description
# row.get("plot", "") ensures an empty string if plot is missing
"plot": row.get("plot", ""),
# Select the first genre from the genres list
"genre": row["genres"][0],
# Vector embedding representing the movie plot
# This vector will be used for similarity search
"embedding": row["plot_embedding"],
})
# -------------------------------
# Insert Data into Milvus
# -------------------------------
# Insert the prepared rows into the Milvus collection
# data must be prepared as a list of entity dictionaries
insert_res = client.insert(collection_name="ch6_movies", data=rows)
# Print the insertion result
# This usually includes information such as number of rows inserted
print(f"Insert result: {insert_res}")
在 Milvus 内部,这批数据会经历三个主要阶段:
Synchronous phase(Proxy) :Proxy 接收请求、解析请求并将其路由到对应 vChannel,向 RootCoord 请求 timestamp,向 DataCoord 请求或扩展 growing segment,并为这次写入分配 SegmentID,最后将带有 timestamp 和 SegmentID 标注的数据写入 message queue。该阶段确保请求在进入内部 pipeline 之前是有效且结构正确的。验证完成后,Proxy 会把请求转发到下一阶段。
WAL phase(message queue) :在 write-ahead log(WAL)阶段,数据会被写入 Kafka、Pulsar 或 RocksMQ 等 message queue 系统。这充当一个持久化日志,在所有操作被处理前记录它们。WAL 确保 durability 和 fault tolerance:如果任何组件失败,系统可以从日志中恢复操作而不丢失数据。一旦 insert message 被成功追加到底层 log service,Proxy 就会向 client 返回成功。同时,QueryNodes 会订阅同一个 vChannel,并且只要消费到该 message,就可以开始查询这批数据。
Asynchronous phase(DataNode) :在异步阶段,DataNodes 按 vChannel 消费 insert messages,按照 SegmentID 聚合进内存 writer buffers,并在满足特定条件后,将 buffered data 转换成 binlog files 写入 object storage,从而完成从 streaming data 到 historical data 的转换。这种异步处理使 Milvus 能够在保持系统可扩展性的同时处理高摄取吞吐。
现在,我们详细查看第一个阶段,理解从 client 发起请求到请求被安全入队之间到底发生了什么。
同步处理:API 调用期间
这一部分发生在 insert() 方法调用和其成功返回之间的短暂时间内。其核心目标是确定数据的目标 segment,并可靠地将其写入 message queue,如图 6.4 所示。
图 6.4:Insert 同步处理概览
同步处理阶段包含四个关键步骤:
Proxy 接收与预处理
Proxy 解析传入请求,并基于 collection metadata,例如 shards 数量,以及每行的 primary key,对 primary key(PK)计算 hash。它使用这个 hash 值决定该 row 属于哪个 shard。每个 shard 对应一个 vChannel,Proxy 会据此将 rows 分组到各自目标 vChannels。下游 DataNodes 和 QueryNodes 会按 vChannel 订阅并消费数据。此时,数据已经按照目标 vChannels 完成批处理和分组。
获取 timestamp
为了保证全局顺序和一致性,Milvus 依赖 RootCoord 提供的全局 timestamp service,见第 5 章。Proxy 会向 RootCoord 请求一个单调递增、全局唯一的 timestamp。这个 timestamp 标记本次写入的逻辑时间。Proxy 会将该 timestamp 附加到待插入的 row batch 上,使其成为 timestamped insert records。
Segment 空间分配
接下来,系统需要决定这些 rows 最终属于哪个 segment。Proxy 会向 DataCoord 发送请求,指定目标 collection、partition、shard / vChannel,以及本次 insert 的行数。DataCoord 随后会根据该 shard 当前 segment state 作出决策:
- 如果存在一个已有 writable growing segment,且剩余空间足够,它会在该 segment 中为本次 insert 预留一个连续范围。
- 如果不存在合适 growing segment,或者已有 segment 没有足够空间,它会立即创建一个新的 growing segment,并为这次写入预留空间。
随后,DataCoord 会将为这批 rows 分配的 SegmentIDs 返回给 Proxy。此时,所有即将被写入的 rows 都已经绑定到具体 SegmentID。
写入 message queue 并返回响应
拿到 timestamp 和 SegmentID 后,Proxy 会构造 insert messages,并将它们生产到 message queue 中对应 vChannels。当底层 log service,例如 Pulsar 或 Kafka,确认 messages 已经成功追加后,Proxy 会认为该写入已经被系统持久接受,向 client 返回成功响应,insert() 调用完成。
此时,数据已经持久化在 message queue 中,即使 Proxy 或 DataNode 失败也不会丢失。在这个时间点,insert() 方法已经执行完成。对用户而言,这意味着数据已被 Milvus 接收并持久化到 message queue。由于 QueryNode 也订阅 vChannel,这些数据很快会变得可见并可查询,具体可见性取决于 consistency level 设置。不过,确保数据持久性和长期存储,还需要后台继续执行后续步骤。
异步处理:API 返回之后
API 返回后,写路径会从前台同步处理转入后台异步处理,如图 6.5 所示。
图 6.5:Insert 异步处理概览
此时,DataNodes 接管,将 message queue 中的 streaming data 转换成 segment binlogs,并持久化到 object storage。下面探索该过程涉及的步骤。
DataNode vChannel 消费与 buffering
DataNode 消费 vChannel,并聚合到 writer buffers 中。每个 vChannel 都恰好由一个 DataNode instance 负责消费。DataNode 订阅对应 vChannel,并按 timestamp order 消费 InsertMsgs。对于每条 message,它会解析 SegmentID,并将属于同一 growing segment 的 rows 缓存在内存 writer buffer 中。此时,数据存在于两个地方:message queue log 和 DataNode 的 in-memory buffers。
触发 sync:将 buffered data 转换为 binlogs
当满足某些条件时,writer buffer 会触发 sync operation,将内存中的 insert data flush 到 object storage。典型触发条件包括:
单个 buffer size 超过阈值:例如,当某个 segment 的 writer buffer 增长超过 16 MB 时,DataNode 会主动触发 sync,防止 buffer 无限制增长,并将当前 buffer flush 到磁盘,形成 binlog files。
DataNode 总内存达到 high watermark:系统会监控每个 DataNode 的整体内存使用情况。当使用量超过配置阈值时,DataNode 会选择一些 segments 执行 sync,以释放内存。
周期性后台 sync:为了防止低写入负载下数据长时间滞留在内存中,后台任务会周期性强制执行 sync,使数据能在有界时间内持久化到 object storage。
一旦触发 sync,DataNode 会清空该 segment 的 writer buffer,为每个 field 构建 binlog 和 statistics files,将这些 files 写入 object storage,最后通知 DataCoord 更新对应 segment metadata。此时,原先只存在于 message queue 中的那部分 streaming insert data,已经被转换为 object storage 中的 binlog files。
可见性与持久化时间点
将同步和异步两个阶段放在一起,可以总结一次 insert request 生命周期中的关键时刻:
API return time:条件是 insert messages 已经成功写入 message queue。在这一刻,数据可靠存储于 MQ 中。订阅同一 vChannel 的 QueryNodes 原则上可以很快消费这些 messages,并基于它们提供查询服务,具体可见性取决于所选 consistency level 以及各 QueryNode 的处理进度。
Sync completion time:条件是 DataNode 已将 buffered data 转换为 binlogs,并成功写入 object storage。在这一刻,数据已成为 segment 的一部分,并以 binlog files 形式持久化。随后,QueryNodes 可以使用这些 on-disk binlogs 构建 indexes 或加载 historical data,进一步提升查询性能和稳定性。
通过这种同步和异步处理组合,Milvus 将一次 insert request 拆成两个阶段:快速的 “ingest into the message queue” 阶段,以及后台的 “batch flush to object storage” 阶段。这一设计保持前台写入延迟较低,同时仍然完成从 streaming data 到 historical data 的完整演进。
接下来,我们看看 delete request。
Delete request
与 insert 相比,Milvus 中的 delete operation 走的是不同路径。Milvus 使用 soft delete 机制:delete request 不会立即物理删除数据。相反,它会先记录一条 “delete record” 请求,之后在 compaction 过程中,这些 delete records 才会与实际 data segments 合并,从而完成物理清理。
下面示例展示了 Milvus Python SDK 中两种常见 delete 风格:按 primary keys 删除,以及按 filter expression 删除。虽然 delete() 方法会在请求被持久化到 WAL 后返回,也就是同步阶段,但实际数据持久化会在后台发生,也就是异步阶段。
ids_to_delete = rows[0]['id']
client.delete(collection_name="ch6_movies", ids=[ids_to_delete])
client.delete(
collection_name="ch6_movies", filter=f"id < {ids_to_delete}")
和 insert 一样,delete 的处理也分为同步和异步阶段。
同步处理:API 调用期间
同步阶段发生在 delete() 调用开始到成功返回之间。它的主要目标是确定需要删除的 primary keys 集合,并可靠地将对应 delete records 写入 message queue。大部分基础设施与 insert 共享,如图 6.6 所示。
图 6.6:同步 delete 流程概览
下面看该过程涉及的步骤。
Proxy 接收并解析请求
Proxy 从 client 接收到 delete request 后,首先解析其内容。有两种情况:
按 PK list 删除,例如 ids=[1]:请求显式指定要删除的 primary keys 集合,因此 Proxy 可以在后续步骤中直接使用这个 PK list。
按 filter expression 删除,例如 filter="year > 1999":请求包含一个 Boolean filter expression。Proxy 必须发起一次 internal query,解析并执行该 filter,获取满足条件的 PK 集合。后续 delete 本质上仍然只是对这个 PK 集合执行删除。
获取 timestamp 并写入 delete messages
一旦 Proxy 确定了要删除的 PK set,它会为这些 PKs 请求 timestamp,并构造 delete messages,也就是 DeleteMsg。和 insert 一样,它会对 PKs 做 hash,将 delete messages 路由到对应 vChannels,并生产到 MQ。当 message queue 确认 delete messages 已经持久追加到 log 后,Proxy 向 client 返回成功,delete() 调用完成。
这里有一个关键细节:delete messages 是 “anonymous” 的。与 insert 不同,DeleteMsg 只包含 PKs 和 timestamp;它此时并不知道这些 PKs 属于哪些具体 segments。哪些 segments 实际包含这些 PKs,会在后续异步阶段中,基于 segment metadata 和 segment-level bloom filters 确定。
基于 expression 的 delete 不是原子操作
对于类似 filter="year > 1999" 的 delete,其流程本质上是:
- Proxy 运行一次 internal query,得到 PK list。
- 然后它基于该 list 发送 delete message。
这两个步骤之间存在一个时间窗口。如果在这个窗口内插入了新的 rows,且这些 rows 也满足 year > 1999,它们的 PKs 不在原始 list 中,因此不会受到这次 delete request 影响。换句话说,filter-based delete 只对 “query time 已经存在的 entities 集合” 应用一次;它不保证删除之后插入、但同样匹配 filter 的 entities。
异步处理:API 返回之后
API 返回后,delete processing 进入异步阶段,并由 DataNodes 接管。该阶段的核心目标是接收这些 anonymous delete records,并将它们持久化为一种特殊的 L0 segment,见图 6.7。
图 6.7:异步 delete 流程概览
下面详细看该过程涉及的步骤。
消费并 buffer 到 L0 segment
DataNode 从 vChannel 中消费这些 anonymous delete messages。由于它无法立即确定这些 delete records 属于哪个 segment,DataNode 会统一将它们缓存到一个特殊内存 buffer 中,该 buffer 对应一个 L0 segment。
Sync L0 segment
当 L0 segment 的 buffer 积累到一定大小,或被 channel checkpoint 触发时,DataNode 会将这些 anonymous delete information sync 到 object storage,形成一个持久化的 flushed L0 segment。
那么,delete 何时生效?物理清理又如何完成?
从用户视角看,delete 的语义有两层:
Logical deletion(visibility) :一旦 DeleteMsg 被写入 message queue,并被 QueryNode 消费和应用,后续 queries 会使用 PK 和 delete timestamp 信息过滤掉已删除 entities。当 QueryNode 处理了相关 delete records 后,这些 entities 会从查询结果中消失,也就是被逻辑删除。
Physical deletion(via compaction) :L0 segment 只是 delete records 的持久化载体;它本身不会移除任何数据。之后,后台 compaction tasks 会处理这部分内容,细节将在 compaction 章节中描述。
综合来看,Milvus 中的 delete 是一个多阶段过程:
- 同步阶段:确定要删除的 PKs,并将 delete records 写入 vChannel。
- 异步阶段(DataNode) :将 anonymous delete records 聚合进 L0 segment,并 sync 到 object storage。
- 后台 compaction:将 L0 中的 delete records 与具体 data segments 合并,最终完成物理清理。
在整条路径中,delete 和 insert 共享同一个 message channel,也就是 vChannel,但它们在 DataNode 以及后续阶段走不同 data paths。Insert data 会直接进入 growing segments 的 writer buffers;而 delete records 会先被收集到 L0 segment 的 writer buffer 中,并且只会通过之后的 compaction 间接影响 data segments。
Upsert request
在 Milvus 中,upsert 并不是一个独立的内部 DML 类型;它是建立在已有 delete 和 insert 之上的复合操作。概念上,一个 upsert 会被拆解为两步:先 delete,再 insert;这两步共同提供一种便捷方式,实现 eventual replacement semantics。目标语义很直接:如果给定 PK 的 row 已经存在,它的 fields 应被新值替换;如果不存在,则插入一条新 row。
下面代码展示如何向 insert 部分创建的同一个 collection "ch6_movies" 中 upsert entities:
upsert_entities = []
# Update existing entity (row 2)
if len(rows) > 2:
upsert_entities.append({
"id": rows[2]['id'],
"title": "Updated Title",
"plot": "Updated plot content",
"genre": rows[2]['genre'],
"embedding": rows[2]['embedding']
})
# Insert new entities (rows 4-5)
for i in range(4, min(6, len(rows))):
upsert_entities.append({
"id": rows[i]['id'],
"title": rows[i]['title'],
"plot": rows[i]['plot'],
"genre": rows[i]['genre'],
"embedding": rows[i]['embedding']
})
upsert_res = client.upsert(
collection_name="ch6_movies", data=upsert_entities)
print(f"Upsert result: {upsert_res}")
当 upsert request 到达 Proxy 时,Proxy 首先会从 request payload 中提取所有 PKs,并为这个 PK set 构建一条内部 delete request;随后,它会基于原始 upsert data 构建 insert request。为了保持同一 PK 的旧版本和新版本之间的顺序,Proxy 会向 RootCoord 请求一个全局唯一 timestamp,并将该 timestamp 同时分配给内部 delete 和内部 insert。
这两条 messages 随后会按顺序写入对应 vChannel:先 delete,再 insert。下游 DataNodes 和 QueryNodes 不需要对 upsert 做任何特殊处理;它们只需按照前面几节描述的方式处理一条普通 delete message 和一条普通 insert message。对于给定 PK,在相同 timestamp 和确定 message order 下,Milvus 确保最终视图只保留由 insert 引入的新版本。
总结来说,upsert 完全复用了 delete 和 insert 的写入与恢复路径:在写路径上,两个操作共享同一个 vChannel 和同一个 timestamp;在 DataNode 中,delete 部分仍然进入 L0 segment,并通过后续 compaction 影响 data segments,而 insert 部分则作为 binlogs 写入对应 growing segments。
理解了 insert、delete 和 upsert 的行为后,我们现在可以沿着写路径追踪一行数据的完整生命周期:从被接收,到被标记,再到被持久化。接下来的问题是:面对故障、重启和负载迁移时,Milvus 如何精确恢复某个 channel 上的处理位置,并保持读写 segment 视图一致?为了回答这个问题,我们引入本章剩余部分的核心概念:channel checkpoint。
Channel checkpoints
作为一个读写分离的分布式架构,Milvus 从设计上必须解决两个内在核心挑战。
第一个挑战是组件故障恢复。DataNodes 负责处理写入,shard delegators 负责服务实时查询,二者都会持续从 message queue 消费数据。当其中某个 node 崩溃并重启,或者由于 load balancing 接管一个新的 vChannel 时,它必须精确知道从哪里恢复处理。这个起点不能太早,否则系统会浪费资源重新处理大量已持久化数据;也不能太晚,否则会漏掉尚未 sync 到 object storage 的数据。如果每次恢复都必须从 log 中很久以前的位置开始 replay,恢复时间会变得不可接受,对系统造成的负载也会过高。
第二个挑战是读写分离架构的本质要求:读写路径同步。如果读路径只依赖消费 streaming data 来提供查询服务,那么所有数据都会长期以 growing segment 形式留在内存中。虽然这种形式非常适合快速摄取,但从查询性能、内存占用和冷启动成本角度看,并不适合生产 workload。写路径的意义就在于持续 flush 这些 growing segments、构建 indexes,并运行 compaction,逐步将它们演化为 flushed segments 或更大的 merged segments,形成更好的 layout 和 query efficiency。为了让读路径真正受益于这些工作,它必须能可靠观察写路径进度,并在合适时机用更高质量 segments 替换自己的 segments,使自身数据视图逐步收敛到写路径最新、最优状态。为了统一解决这两个核心挑战,Milvus 在写路径上引入了最关键可靠性机制之一:channel checkpoint。
Channel checkpoint 的核心思想,是在每个 vChannel 的数据流中建立一个持续前进的 safe watermark。这个 watermark 本质上是一个被持久记录的 timestamp。它向整个系统提供一个清晰保证:所有 timestamp 小于或等于 checkpoint timestamp 的 streaming data,都已经完整、可靠地持久化到 object storage。只要这个条件成立,系统中任何组件在面对 recovery、replay 或 ownership transfer 时,都可以把 checkpoint 作为可信参考点。
对于故障恢复和 load balancing,checkpoint 的作用是立竿见影的。当新的 subscriber 需要开始消费一个 vChannel 时,无论是因为 node 重启,还是因为 cluster 的 load balancing 策略接管了新任务,它都不再需要从 message queue 的开头开始,沿着 log 一路追赶到当前进度。相反,它只需从 DataCoord 获取该 channel 的最新 checkpoint。Checkpoint timestamp 随后定义它必须开始主动处理的精确位置:所有小于等于这个 timestamp 的 historical data,都可以安全地直接从 object storage 重建。图 6.8 显示,T_1 到 T_k 的 messages 都已经持久化到 object storage,只有之后的数据需要从 message queue replay。这大幅提升了系统可用性和弹性。
同时,checkpoint 也是同步读写状态的基础。通过周期性观察 checkpoint 如何前进,并将其与自己已经加载的 segments 进行比较,读路径可以准确推断写路径已经完成了什么。例如,当 checkpoint 超过某个 growing segment 的时间范围时,读路径就知道该 segment 已经 flushed,并且现在可以作为一个可安全加载的 flushed segment 使用。当 checkpoint 确认一组旧 segments 已经 compacted 成一个新 segment 时,读路径会 unload 旧 segments,并切换到新 segment。通过反复应用这种比较和替换,读路径可以持续将自身内存视图演进为更优数据布局,从而实现高查询性能。这个过程背后的详细机制将在第 7 章深入讨论。
图 6.8:vChannel 时间线概览,按 timestamp 排序
因此,channel checkpoint 机制并不只是技术细节;它是 Milvus 为解决其分布式、读写分离架构中的 fault tolerance 和 read-write synchronization 两大核心挑战而设计的基石。通过建立一个持续前进且绝对可靠的 safe watermark,它赋予系统内部组件快速恢复能力,并使读路径能够与写路径的持续优化保持同步。正是这一机制,确保整条写路径不仅高效,也足够稳健和有韧性,完全能够在生产环境中可靠运行。
小结
本章探索了 Milvus 写路径中复杂而精细的内部世界,完整描绘了数据从 API 调用到持久化记录的旅程。我们的探索从数据组织的基石 segment 开始,并基于其状态、结构和角色建立了一个三维认知模型。随后,我们跟随 segment 走过从诞生(growing)到消亡(dropped)的完整生命周期,理解 Milvus 如何通过自动化管理机制将 streaming data 转换为 historical data。在此基础上,我们从开发者视角分析了 insert、delete 和 upsert 等 DML requests 背后的流动过程,观察 proxy、coordinators 和 data nodes 等组件如何紧密协作。最后,我们揭开了保证整条写路径可靠性的核心机制,也就是 Channel Checkpoint,并理解它在故障恢复、资源管理和读写状态同步中的关键作用。
通过本章,我们不仅学会了如何向 Milvus 写入数据,更重要的是洞察了数据在内部如何被组织和管理,为下一章探索 Milvus 的高性能读取能力打下了坚实基础。