在第 6 章中,我们详细介绍了数据写入 Milvus 的过程。本章将把关注点转向等式的另一边:如何高效地检索这些数据。
要理解读路径,我们必须回到第 5 章中的核心设计原则:读写分离。这种分离非常关键,因为它赋予读路径一种强大的双重能力。一方面,它能够在经过优化、持久化的 historical data 上提供稳定、高性能查询,并且完全不受写路径上复杂任务的影响,例如 segment 状态转换、compaction 或 index building。
另一方面,它也具备为新数据提供 near-real-time visibility 的灵活性。这是因为 QueryNodes 会订阅同样的 vChannels,并在消费后立即在内存中应用 insert / delete messages,使数据在写入后很快就可以被查询,远早于它被 flushed 或 indexed。
负责协调和执行整条复杂读路径、让它能够熟练处理 historical data 和 streaming data 的组件,正是我们前面已经见过的两个核心组件:QueryCoord 和 QueryNode。QueryCoord 充当 cluster manager,QueryNode 充当前线 worker。
本章将带你理解查询数据的过程,以及 QueryCoord 和 QueryNode 在该工作流中的作用。你还会学习 Milvus 中 query mechanism 是如何工作的。随后,我们会把关注点转向 delete 的处理,以及如何确保 reads 和 writes 之间的一致性。
本章将覆盖以下主要主题:
- Query scatter-gather pattern
- Loading and balancing
- Handling deletes
- Ensuring multi-level consistency
Query scatter-gather pattern
假设某个 collection 的数据已经成功加载到内存中,我们会在下一节详细讨论这个加载过程。当一个 query request 到来时,Milvus 如何利用其分布式架构高效处理它?答案是一种经典计算模式:scatter-gather。本节将先从鸟瞰视角介绍一次 query 的完整端到端旅程。
“read” 和 “query” 的含义
本章中,除非特别说明,我们所说的 read 或 query,指的是在 Milvus 中访问数据的一般过程,涵盖 approximate nearest-neighbor(ANN)search、scalar retrieval,以及 multi-vector hybrid search。
这个过程的核心,是将一个单一、高层级的 query request 层层拆解,也就是 scatter,拆成可以在大量 data shards 上并行执行的 micro-search tasks。然后,这些并行任务的结果会逐层 gather,最终形成对用户有意义的全局结果。
图 7.1 展示了 query scatter-gather pattern 的可视化表示。
图 7.1:Query scatter-gather pattern
首先,我们认识一下 query 中的关键参与者。
Query participants
参与 query 处理的主要角色包括 Proxy node、QueryCoord、Shard Delegator 和 QueryNode:
Proxy node:作为用户请求入口,它负责接收 queries、执行校验、解析 expressions、选择一个可用 collection replica 进行查询,并最终聚合所有并行 compute nodes 的子结果,形成最终 response 返回给 client。
QueryCoord:作为 query cluster 的中央协调器,它维护 cluster topology 和 data distribution metadata。它不参与实际计算,但会向 Proxy node 提供执行 query 所需的 routing information,例如哪个 node 负责哪个 data shard。
Shard Delegator:本质上是承担了额外协调职责的 QueryNode。除了像其他 QueryNodes 一样加载和查询 historical data segments,也就是 flushed segments,它还会被 QueryCoord 指派订阅某个特定 shard 的 vChannel。这使它能够处理实时 streaming data,并将这些数据组织为 in-memory growing segments。在 query 期间,它既负责查询自己持有的数据,包括 streaming 和 historical,也负责进一步将 query request 分发给其他持有该 shard 数据的 QueryNodes。
QueryNode:作为执行 queries 的 worker node,它会将特定 historical data segments,也就是 flushed segments,加载到内存或磁盘,并负责在这些本地数据上执行底层 search 和 computation operations。
有了这些关键参与者后,query process 会通过系统化分发 workload 启动。这就是 pattern 中的 scatter 部分:一个单一 request 被 fan out,以便并行执行。
Scatter phase:分发 workload
在 Milvus 中,一个 query 会经历两级 scatter process,以实现最大化并行度:
First-level scatter:从 Proxy 到 Delegators
当 Proxy 收到 query 时,它首先会向 QueryCoord 请求目标 collection 的 routing table。该表标识所选 replica 中每个 shard 的 Shard Delegator 由哪个 QueryNode 扮演。随后,Proxy 会将完整 query request 并行 scatter 给这些 Shard Delegators。
Second-level scatter:从 Delegators 到 QueryNodes
每个 Delegator 收到 query 后,会进一步拆解它。它会同时发起三个并行 sub-searches:
- 在自身 in-memory growing segments 上执行 search
- 在自己本地恰好持有的任何 flushed segments 上执行 search
- 将 request scatter 给所有其他持有该特定 shard 剩余 flushed segments 的 QueryNodes
经过这两级 scatter 后,一个单一 query 已经被分解成在整个 cluster 内各个 individual segments 上运行的并行 search tasks。
Gather phase
Gather phase 与 scatter phase 对称,它会自底向上整合结果,产生最终答案:
First-level gather:Shard Delegator 层级
每个 Shard Delegator 会等待所有它派发任务的 QueryNodes 返回结果。随后,它执行一次 reduction,将这些结果与自己本地 search 的结果,包括 growing 和 flushed segments 上的结果,合并起来。这会为整个 shard 生成一个单一、合并后的 top-K result。
Second-level gather:Proxy 层级
随后,Proxy 会等待接收每个 Shard Delegator 合并后的 top-K result。然后它执行最终 reduce,合并所有 shards 的结果,生成 global top-K result,并返回给 client。
例如,一个 limit = 100 的 query,会使每个 QueryNode 返回其 local top 100 results。Shard Delegator 会合并这些结果,得到该 shard 的 top 100。最后,Proxy 会合并每个 shard 的 top 100,得到 global top 100。
这种 scatter-gather pattern 赋予 Milvus 巨大的水平扩展能力。随着数据量或 query traffic 增长,你只需要增加更多 QueryNodes 或加载更多 replicas,就可以横向扩展 query processing power。不过,在极端规模下,Proxy 和 Delegator 的 gather step 可能成为瓶颈,可以通过增加 Proxy 或 QueryNodes 的 CPU 与 memory(RAM)资源来缓解。
现在我们已经从鸟瞰视角了解了整个 query journey,接下来深入这个旅程的起点,看看系统如何通过 loading 和 balancing 为 query 做准备。
Loading and balancing
要理解 load operation 的必要性,我们必须先回到 Milvus 的一个核心设计理念:为速度而构建的 in-memory computation。在 vector retrieval 领域,即使毫秒级延迟差异,也会对用户体验产生巨大影响。因此,为实现极致 query performance,Milvus 优先使用 memory 执行核心计算。
不过,考虑到生产环境中 massive datasets 和 limited memory 之间的冲突,Milvus 也提供了 disk-based indexes,例如 DiskANN,以及 memory-mapping(mmap)等高级解决方案。这些方案允许用户在 memory 和高速 SSD 之间作出取舍,以支持远超 memory 容量的数据集上的高性能查询。
然而,无论是纯内存方案,还是 memory-plus-disk 方案,都遵循一个共同前提:数据必须先从远程、低成本 object storage 拉取到本地 QueryNode,才能变得可计算。这个过程包括从远程存储下载 index 和 data files 到 QueryNode 的本地磁盘或 cache。
load_collection API 正是正式执行这一过程的 API。它就像一个开关,告诉 Milvus:“我马上要查询这个 collection,请把数据准备到 QueryNodes 上。”
这一设计让用户能够主动控制资源。你可以选择只加载需要频繁查询的 hot collections,而让不常用的 cold data 安静地留在 object storage 中。这样可以在整个 cluster 中最具成本效益地使用宝贵的 memory 或本地磁盘资源。Loading 是解锁高性能 queries 的第一步,也是 Milvus 资源管理理念的直接体现。
在查询 collection 之前,Milvus 要求你先将至少一个 replica 加载到 QueryNodes 的内存或磁盘中。
那么,当我们执行 load operation 时,具体在加载什么?答案是 replicas。下面我们理解 replica 的构成。
Replicas
Milvus 中的 replica 是一个 collection 数据的完整、可查询副本,涵盖两部分:存储在 object storage 中的不可变 historical data,以及从 message queue(MQ)中持续流入的实时数据流。
它不是一个简单的静态 snapshot,而是由两部分组成:object storage 中所有 historical segments,以及 MQ 中持续流入的 streaming data。
进一步说,一个 collection 的完整 replica,由它每个 shard 的 replica 组成。单个 shard 的 replica 数据,也来自其 historical segments 和 real-time stream。正是 MQ 的设计确保当多个 subscribers,也就是 QueryNodes,消费同一个 channel 时,它们会以完全相同的顺序收到完全相同的数据,从而保证每个 replica 维护一份精确的 streaming data 副本。
如图 7.2 所示,一个 collection 的完整 replica 由其每个 shard 的 replica 组成。
图 7.2:拥有两个 shards 的 collection replica 可视化表示
单个 shard 的完整数据集来自两个位置:
- 该 shard 的 vChannel:这个 MQ topic 保存尚未 sync 到 object storage 的最新、实时数据。
- 该 shard 的 segments:这些 object storage 中的不可变 files 保存 historical data。
MQ 的设计,见第 5 章,正是使 channel 可以作为 replica 来源的原因。当同一个 channel 的多个 subscribers 在相同 timestamp 消费数据时,它们保证会以完全相同的顺序收到完全相同的数据。这确保订阅某个 channel 的每个 QueryNode 都维护相同的 streaming data 副本。
例如,如果两个 replicas,Replica A 和 Replica B,订阅同一个 vChannel,而两个用户 User A 和 User B 同时在该 collection 上插入或查询数据,那么两个 replicas 会以相同顺序处理进入的 messages,并产生相同状态。这保证无论哪个 replica 服务 User A 或 User B 的 query,他们都能看到一致结果。
现在,我们看一下如何在 Milvus 中加载和释放 replicas。
Loading and releasing replicas
PyMilvus client 提供简单 API 来管理 loading process:
client.load_collection("test_collection")
client.load_collection("test_collection", replica_number=3)
加载 collection 时,你可以指定希望创建的 replicas 数量。为 collection 选择合适 replica 数量,是 performance、availability 和 cost 之间的关键取舍。通常应考虑以下几点:
目标 query throughput:这是扩展 replicas 最常见的原因。每个 replica 都能处理一定 queries per second(QPS)。如果单个 replica 可以处理 1,000 QPS,而应用需要 3,000 QPS,则至少需要三个 replicas。
High availability(HA) :如果应用无法容忍 downtime,至少需要两个 replicas。
Resource constraints:这是最重要的限制因素。Replicas 会消耗 memory 和 disk。三个 replicas 意味着三倍资源。
Milvus 强制要求 QueryNodes 数量必须大于你打算加载的 replicas 数量。如果尝试加载超过可用 QueryNodes 数量的 replicas,或在 standalone mode 中运行,就会报错。
请确保在加载 collection 前,已经为 collection 上的 vector field(s) 创建 index;否则 load 会失败。
加载 collection 的过程,是 Proxy、QueryCoord 和 QueryNodes 之间的协同工作。当用户的 load request 从 Proxy 转发到 QueryCoord 后,整个 loading process 会立即由 QueryCoord 组织并派发。
这个过程如下:
- 首先,QueryCoord 检索 collection 的 metadata,尤其是它包含多少 shards。
- 然后,对于用户请求的每个 replica,它会从可用池中选择一个 QueryNode,作为每个 shard 的 Shard Delegator。
- 同时,基于其 load-balancing strategy,它会将该 shard 的所有 flushed segments 的加载任务,均匀分配给所有可用 QueryNodes,包括 Delegator 自身。
- 随后,QueryCoord 向所有相关 QueryNodes 发出具体任务。此时,loading work 会在整个 query cluster 中并行启动。
- 被分配为 Shard Delegator 的 node 会立即从 latest checkpoint 开始订阅该 shard 的 vChannel,消费 real-time data,在内存中构建和维护 growing segments,并自动为其创建临时 in-memory indexes 以加速 queries。
- 与此同时,所有 QueryNodes,包括 Delegator,都会开始从 object storage 拉取分配给自己的 flushed segment data 和 indexes。这些 files 下载完成后,会交给 Milvus 的核心执行引擎 Knowhere,见第 9 章。
只有当某个 replica 的每个 shard 都有对应 Delegator 处理其实时流,并且它的所有 flushed segments 都已在各个 QueryNodes 上成功加载,QueryCoord 才会将该 replica 标记为 available,load operation 才被认为完成。
当你不再需要查询某个 collection 时,可以使用 release_collection API 释放其占用的 memory 和 disk 资源:
client.release_collection("test_collection")
该操作会从所有 QueryNodes 中卸载该 collection 相关的所有数据。Collection 将变为不可查询,直到再次加载。
加载 collection 是启用 queries 的关键第一步。然而,在一个持续写入数据、nodes 也可能变化的动态环境中,Milvus 必须持续优化数据分布,以维持高性能和韧性。这个由 QueryCoord 管理的自动过程,被称为 dynamic load balancing。
下面详细讨论它。
Dynamic load balancing
在多 node cluster 中,Milvus 会自动平衡 QueryNodes 上的数据分布,以最大化资源利用率并维持 query performance。这种 balancing 会持续发生,并由 QueryCoord 管理。它会在 channel 层面对 streaming data 做平衡,也会在 segment 层面对 historical data 做平衡。我们先看 channel balancing。
Channel balancing
作为 streaming data 的 consumer,Shard Delegator 的 CPU 和 memory 资源尤其宝贵。因此,确保订阅 vChannels 的职责在 QueryNodes 之间均匀分布,对于防止瓶颈至关重要。
当 cluster topology 发生变化时,例如 node 扩缩容、故障或升级,QueryCoord 会自动触发 vChannels 的 rebalance。这个过程严格遵循 load-before-release 原则,确保一个 subscription task 先在新 node 上成功启动,才会从旧 node 释放,从而保证服务连续性并防止 query 中断。
接下来是 segment balancing。
Segment balancing
Segment balancing 的目标更细粒度:它旨在确保每个 QueryNode 处理大致相等数量的 entity rows,而不仅仅是 segments 数量,因为 entity rows 更准确地反映真实 query workload。Balancing 会在几个关键场景中触发:
Growing 到 flushed 的转换,也就是 handoff
当写路径将 growing segment 转换为 flushed segment 时,读路径需要用这个新的、不可变的 flushed segment 替换 Shard Delegator 上的 in-memory growing segment。这个过程称为 handoff,也遵循 load-before-release 原则。它确保新的 flushed segment 在旧 growing segment 停止服务 queries 之前,已经完整加载并在 QueryNode 上 ready,从而保证无缝转换。
这个过程如图 7.3 所示:
图 7.3:Growing 到 flushed 转换的 handoff process
Handoff 过程如下:
Before handoff:Shard Delegator X 有一个 in-memory growing Segment A。QueryNode Y 尚未托管 A 的 flushed copy。
Flush:Segment A 从 growing state 转换为 flushed state,并由 DataNode 写入 object storage。
Rebalance(handoff) :新的 flushed Segment A 会在 growing segment 被释放之前加载到 QueryNode Y。最初,这个 collection 的所有 queries 都会被路由到 growing Segment A。服务这些 queries 的同时,Milvus 会将新的 flushed Segment A 加载到一个 QueryNode 上。一旦该 flushed segment 完整加载并 ready,QueryCoord 和 Delegator 会更新当前 target segments,将所有新 queries 指向它。只有在所有原本指向 growing Segment A 的 in-flight queries 完成后,原始 growing Segment A 才会被安全释放,从而保证转换无缝,不中断任何正在进行的 queries。
Flushed segment replacement,也就是 compaction
当写路径将几个小 segments 合并成一个更优的大 segment,也就是 compaction 时,读路径也需要执行同步 replacement。遵循 load-before-release 原则,QueryNodes 会先加载新 segment,再释放旧 segments,确保 queries 不被中断。
图 7.4 描绘了 flushed segment replacement process:
图 7.4:Segment replacement process
其工作方式如下:
Before:QueryNode Y 服务 flushed Segments A 和 B。这个 collection 的所有 queries 都被路由到 Segments A 和 B。
During:Segment C 不在 Before Replace 图中。这表示 flushed Segment C 是由 flushed Segments A 和 B 合并创建的,并加载到 QueryNode Y。
在 Segment C 完全加载之前,queries 仍然被路由到 Segments A 和 B。
After:一旦 C 完全加载,并且 A 和 B 上的所有 queries 都完成,A 和 B 就可以安全释放,所有 queries 会转向 C。
Node topology changes 时的 balancing
当 QueryNode 被添加、移除或失败时,QueryCoord 会在剩余 active nodes 之间重新分发它的 segments。如果只加载了一个 replica,node failure 会导致该 node 持有的数据在重新加载到其他 nodes 之前出现临时服务中断。如果有多个 replicas,或者是在计划内扩缩容和升级期间,load-before-release 方法可以确保 query availability 持续。
图 7.5 描绘了 load balancing 过程:
图 7.5:QueryNodes 的 Load balancing
其工作方式如下:
Before failure:起初,QueryNode Z 托管 flushed Segment C,Shard Delegator X 将所有相关 queries 路由到它。
During failure:表示 QueryNode Z 意外宕机。
Rebalance:QueryCoord 检测到故障并启动 rebalance process,将 flushed Segment C 重新加载到 QueryNode Y。一旦加载完成,delegator 会更新 routing table,使 queries 可以继续无缝地访问新加载的 segment。
现在,我们已经清楚理解 Milvus 中可查询数据是如何准备和组织的。不过,这张蓝图只描述了数据的增加,也就是 entities 如何被加载和分布。完整系统还必须高效处理数据减少。那么,当发出 delete 命令时,系统如何确保这些数据不会在 query 中被看到?这就是下一节要探索的核心机制:Milvus 中 delete operations 的处理。
Handling deletes
从上一节中,我们已经清楚理解 Milvus 如何准备和组织可查询数据。现在,我们面对一个更深的问题:当 delete command 发出时会发生什么?
要回答这个问题,必须回到 Milvus 的两条核心架构原则。第一,historical data 持久化在不可变 object storage 上,例如 S3,这意味着 data file 一旦生成,就不能原地修改。第二,读写分离架构决定了负责查询的组件,也就是 read path,并不拥有数据本身,也无权修改 write path 生成的原始 files。
这两条原则共同决定了一件事:处理 delete 时,读路径的职责不是物理擦除数据,而是在 query 期间高效地遮蔽这些数据。本节将深入说明 Milvus 如何通过一种优雅的 soft-delete 机制实现这一目标。这给读路径带来一个新挑战:在 query execution 期间,系统必须能够实时识别并隐藏已经被标记为 deleted 的数据。
当你发出 delete command 时,目标 entities 不会立即从存储中清除。相反,Milvus 会将它们的 primary keys(PKs)记录到与相关 segments 关联的 deltalog 中。
Deletion 可能来自许多来源。当用户发出 delete command 时,相关信息不会立即影响 data files,而是被记录为 delete marker。在读路径上,为了构建已经删除哪些数据的完整视图,QueryNodes 需要从三个不同来源收集这些 delete markers:
- 来自 vChannel:这些是最新的、实时的 anonymous delete records。和 insert records 一样,它们首先会被写入 vChannel。
- 来自 L0 segment:这些是近期 flushed deletions。正如第 6 章所学,anonymous delete operations 会被集中写入特殊的 L0 segments。
- 来自 deltalogs:对于更旧的 flushed segments,与其内部数据相关的 delete records 会存储在与每个 segment 关联的一组 deltalog files 中。
这里的关键挑战,在于处理前两个来源中的 anonymous deletes。这些 records 只包含被删除 entity 的 PK 和 timestamp,却不知道该 PK 实际属于哪个 data segment。
那么 anonymous deletions 是如何发生的?下面来看。
Delegator 的角色与 Bloom filters
Shard Delegator 是处理这些 anonymous deletions 的核心。它会消费分配给它的 vChannel 中的 real-time delete messages,从 object storage 加载相关 L0 segment data,然后把所有这些 anonymous deletion information 缓存在一个名为 delete buffer 的 in-memory structure 中。
现在的问题是:对于 buffer 中每个待删除 PK,delegator 如何快速定位它可能属于哪个 segment,而无需扫描所有 data segments?如果扫描所有 segments,计算量将是天文级的。答案是 Bloom filter。
Bloom filter 是一种空间效率高的概率型数据结构,用于测试某个元素是否属于一个集合。它并不直接存储元素,而是维护一个 bit array,并使用多个 hash functions 将每个元素映射到若干 bit positions。
插入元素时,该元素会被 k 个 hash functions 处理,对应 bit positions 被设置为 1。测试 membership 时,会计算同样的 hashes,并检查对应 bits。
Bloom filter 有以下特征:
Definitive negative:如果任何对应 bit 是 0,则该元素一定不在集合中。
Probabilistic positive:如果所有对应 bits 都是 1,则该元素可能在集合中。这种情况下存在很小的 false positive 概率,也就是 filter 报告元素存在,但它实际上从未被插入。
False positive probability 取决于 bit array 的大小、插入元素数量以及 hash functions 数量。它可以近似表示如下:
例如,如果 Bloom filter 有 10,000 bits,并使用 7 个 hash functions 存储约 1,000 个元素,false positive rate 通常低于 1%。
Milvus 会在 flush 时,基于每个 flushed segment 的所有 PKs 预生成 Bloom filter。对于内存中的 growing segments,Shard Delegator 也会动态为它们生成 Bloom filter。Query processing 期间,Shard Delegator 的工作流如下:
对于 delete buffer 中的每个 PK,delegator 会检查所有相关 segments 的 Bloom filters。虽然 Bloom filter 可能产生低概率 false positive,但它非常快,并且保证没有 false negatives。当 Bloom filter 表示可能匹配时,delegator 会将 delete operation,也就是 PK 和 timestamp,转发给负责该 segment 的 QueryNode。
这些被转发的 deletions 会在 query 期间由 Knowhere execution engine 应用,Knowhere 将在第 9 章讨论。Milvus 会将 delete records 转换为 bitmaps,标记对应 entities 已被删除。在 query execution 的最终阶段,结果会使用这些 bitmaps 过滤,从而把标记为 deleted 的 entities 从 result set 中排除。这使 deletes 能够影响正在进行的 queries,而无需物理修改底层 segment data。
通过这种机制,Milvus 实现了 soft deletion:已删除 entities 仍保留在 storage 中,但会根据其 delete timestamps 在 query time 被遮蔽。Bloom filters 通过快速缩小可能包含 deleted entities 的 segments 范围,使这一过程高效。
这个设计引出了一个重要问题:当 query 执行时,哪些 writes 和 deletes 对它可见?换句话说,query 的数据视图对应到哪个时间点?Milvus 通过其 multi-level consistency model 回答这一问题,下一节将介绍它。
Ensuring multi-level consistency
在任何分布式系统中,从数据写入到它在所有 nodes 上可读之间,都存在天然延迟。系统应选择等待数据完全同步以保证最新结果,还是不等待、直接在当前可见数据上立即执行 query,以获得最快响应?这就是 data freshness 与 query latency 之间的经典取舍。
Milvus 不采用一刀切方案。相反,它赋予用户选择 consistency level 的能力,使不同应用需求可以在 performance 和 freshness 之间取得最佳平衡。Queries 会携带一个 guarantee timestamp,系统会确保只有 timestamps 小于等于该值的数据可见。Shard Delegator 维护一个 tSafe timestamp,表示 read path 的处理进度。只有当 tSafe >= Guarantee Ts 时,query 才会执行。
四种 consistency levels 总结如下:
| Consistency level | Guarantees | Execution logic | tSafe versus Guarantee Ts |
|---|---|---|---|
| Strong | Query 会看到最新 global timestamp 之前的所有 writes。 | 等待读路径处理完所有相关数据。 | tSafe >= latest global timestamp |
| Session | Query 会看到同一 client session 中的所有 writes。 | 只等待该 client 自己的 writes。 | tSafe >= timestamp of last write in session |
| Bounded Staleness(默认) | Query 看到的数据可能略微过时,例如 5 秒。 | 当 tSafe 位于 staleness window 内即可执行。 | tSafe >= current_time - staleness_period |
| Eventual | Query 看到当前可见数据;所有 writes 最终都会出现。 | 立即执行。 | tSafe >= 0,总是为 true。 |
表 7.1:Consistency levels 与 execution timing
下面更详细讨论这些 consistency levels:
Strong consistency:最高一致性级别。它保证 queries 会看到 query 开始之前写入的所有数据。这提供最新的数据视图,但由于需要等待后台数据同步,可能产生最高延迟。
Session consistency:非常实用的级别。它保证 client 在同一 session 内可以看到自己的 writes。它提供直观的 read-your-writes 体验,而无需等待其他 clients 的数据同步。
Bounded staleness consistency:这是 Milvus 的默认 consistency level。它保证 query 看到的数据最多落后一个配置好的时间窗口,例如 5 秒。这是一个实用选项,在 performance 和 freshness 之间取得很好平衡。
Eventual consistency:最低一致性级别,提供最高性能。它不保证 data freshness,queries 会立即基于 node 当前可见数据执行。它只保证所有写入数据最终会变得可见。
接下来,我们介绍 Milvus 如何实现这些 consistency levels。
Milvus 如何确保 consistency
Milvus 使用基于 timestamp 的机制管理 consistency。其原则可以总结为:让每个 query 携带一个 timestamp requirement,然后等待系统的数据进度追上这个 requirement。
这个机制围绕两个关键 timestamps 展开:
Serviceable timestamp(tSafe) :这是由 Shard Delegator 维护并持续推进的 timestamp。它表示 read path 当前已经处理、并准备好服务的数据进度。所有 timestamps 小于或等于 tSafe 的数据,都对 queries 可见。
Guarantee timestamp(Guarantee Ts) :这是附加到每个 query request 上的 timestamp,表示这个 query 需要的数据有多新。换句话说,query result 必须反映所有 timestamp 小于或等于该 guarantee timestamp 的 write 和 delete operations。
该机制的执行如下。
当 query 到来时,Delegator 会比较这两个 timestamps。只有当 tSafe >= Guarantee Ts 时,query 才会执行。如果系统进度尚未追上 query 要求,query 就需要等待。四种 consistency levels 之间的本质差异,在于每个 query 的 Guarantee Ts 如何计算:
- 对于 strong consistency,Proxy 会使用系统 TSO RootCoord 发放的最新 global timestamp,见第 5 章。因此 query 必须等待,直到 read path 已经处理完该时间点之前的所有数据,并且
tSafe前进到超过它,从而确保绝对新鲜。 - 对于 session consistency,Proxy 会使用该特定 client session 的最后一次 write timestamp 作为 guarantee timestamp。
- 对于 bounded staleness consistency,Guarantee Ts 会计算为
current_time - staleness_period。由于tSafe通常已经超过这个略有延迟的时间点,queries 通常可以几乎无需等待就执行。 - 对于 eventual consistency,Guarantee Ts 会被设置为一个非常小的值,例如 0。由于
tSafe总是更大,query 永远不需要等待,会立即执行。
下面展示如何在 query 调用中指定所需 consistency level:
# Query with Strong Consistency: See all the latest data
results = client.query(collection_name, consistency_level="Strong")
# Query with Session Consistency: See your own recent writes
results = client.query(collection_name, consistency_level="Session")
# Query with Bounded Staleness Consistency (default): Allow minor data lag for speed
results = client.query(collection_name)
# Query with Eventual Consistency: Prioritize speed above all.
results = client.query(collection_name, consistency_level="Eventually")
选择合适级别是一个关键设计决策。当 data freshness 不可妥协时使用 Strong;交互式应用使用 Session;当 performance 是首要关注点时使用 Bounded Staleness 或 Eventual。
下表捕捉每种 consistency level 背后的机制:Strong 确保 queries 看到所有近期更新;Session 聚焦 client session;Bounded Staleness 容忍轻微延迟;Eventual 则优先速度而非新鲜度。
| Consistency level | Example applications | Trade-offs |
|---|---|---|
| Strong | 访问控制,例如人脸识别;开发 / 测试 | 最高 freshness,但 latency 最高、throughput 最低 |
| Session | 交互式个性化,例如保存文章、用户内容 | 保证单个用户的 “read-your-writes”,中等 latency |
| Bounded Staleness | 推荐引擎、近实时新闻流 | 在 performance 和轻微 stale data,例如 5 秒,之间取得平衡 |
| Eventual | 批处理分析、machine learning(ML)training、reporting | 最大 throughput 和最低 latency,但可能看到 stale data |
表 7.2:每种 consistency level 的典型用例
你可以参考以下指南:
Strong consistency 保证每个 client 都看到整个系统中已成功写入 Milvus 的绝对最新数据。它适合人脸识别这类关键访问控制场景。例如,如果管理员撤销某个用户的访问权限,该变更必须立即反映出来。下次用户尝试认证时,系统必须读取更新后的状态以拒绝访问。
它提供最高 data accuracy 和 integrity,并且从开发者视角也最容易推理,因此常用于开发或测试。不过,它也具有最高 query latency 和最低 throughput。
Session consistency 保证单个 client session 可以看到同一 session 之前的所有 writes。它为单个用户提供直观的 “read-your-writes” 体验,但不保证立即看到其他用户的 writes。它适合交互式内容个性化场景,例如当用户点赞或保存一篇文章并生成一个 vector 后,如果他们立即进入已保存文章页面,该一致性级别会确保他们刚保存的文章立即显示给自己。
这对创建良好、符合逻辑的交互式应用用户体验非常关键,同时相比 Strong consistency 有更好性能。
Bounded staleness consistency 是默认 consistency level;它保证最多看到落后 5 秒的数据。它避免等待绝对最新数据,以少量 freshness 换取更好性能。适合推荐引擎、近实时新闻流或社交媒体。对于大多数应用来说,它是 performance 与 data freshness 之间的最佳平衡。
Eventual consistency 除了数据最终会变得可见之外,不提供其他数据可见性保证。Queries 会立即在 QueryNode 当前可用数据上执行,提供最佳性能。适合批处理数据分析、reporting、ML model training 和 offline system audits 等场景。它能提供尽可能低的 query latency 和最高 throughput。
总之,Milvus 不会强制你选择单一 consistency。通过其智能 timestamp 系统,它把复杂的数据一致性问题变成一个简单选项。这赋予开发者决定权:对应用来说,到底是获取绝对最新数据更重要,还是尽可能快地获得 query speed 更重要。做出正确选择,是用 Milvus 构建优秀应用的关键部分。
小结
本章完整走过了 Milvus 的 read path。我们的旅程从 query process 的鸟瞰视角开始,也就是 scatter-gather pattern;随后深入 preparation phase,理解加载 collection replicas 的必要性,以及 dynamic load balancing 的自动化机制。接着,我们揭示了系统如何通过 Bloom filter 等设计巧妙处理 soft deletes。最后,我们探索 multi-level consistency 如何赋予用户在 data freshness 和 query performance 之间取舍的能力。下一章中,我们将讨论 Milvus 中的 compaction 和 garbage collection,以支持高效内存管理。