单段上的 Knowhere Query(第 8 篇)只产生
局部候选。用户一次 search
要的是 Collection 级 Top-\(k\)(或范围查询结果)。很多人调参时只盯着
ef /
nprobe,却忽略了「一致性级别」这个同样能左右延迟的旋钮——把分布式系统当成单机索引调,是排查尾延迟时最常见的误判来源之一。本文钉住官方查询流中的
多级归并树,以及可见性水位如何让「更强一致性」变成「更长等待」。
本文是「向量检索引擎」系列第 9 篇(共 19 篇)。→ 系列目录
版本锚定:Milvus 2.6.x Data Processing、Architecture Overview、Streaming Service、Timestamp、Consistency Level。
一、一次 search 的官方路径
Data Processing · Data query:
- Collection 拆成多 segment;Streaming Node 持 Growing,Query Node 持 Sealed。
- Proxy 向 相关 shard 的 Streaming Node 并发发起请求。
- 每个 Streaming Node:搜本地 Growing;向相关 Query Node 取历史结果;聚合成 shard 结果。
- Proxy 收集各 shard 结果,合并为最终结果返回。
Architecture 的 search 示例流补充 reduce 层次:
- Query Node:跨其加载的多个 Sealed segment reduce;
- Streaming Node:归并本地 Growing 与各 Query Node 结果;
- Proxy:归并所有 Streaming Node(shard)结果。
flowchart TB
proxy["Proxy final reduce"]
sn0["Streaming Node shard0<br/>Delegator"]
sn1["Streaming Node shard1<br/>Delegator"]
qnA["Query Node A<br/>segment reduce"]
qnB["Query Node B<br/>segment reduce"]
proxy --> sn0
proxy --> sn1
sn0 --> qnA
sn0 --> qnB
sn1 --> qnA
上面的 Mermaid 图把两个 shard 各自的 Query Node 画成独立分支,容易让人误以为「一个 Query Node 只服务一个 shard」。实际拓扑更灵活:Query Node 加载的 Sealed segment 与 shard 边界无关,同一个 Query Node 完全可能同时持有 shard0 与 shard1 的部分历史段。下面这张图把「同一 Query Node 被多个 shard 的 Delegator 共享」画出来,更接近生产拓扑:
图中每一条边代表一次候选结果的网络传输,边越多意味着一次
search 触发的 RPC
越多——这也是为什么第七节的延迟归因表会把「shard 数、每 shard
的 Query Node 数」列为需要先核对的拓扑参数。
这与经典 MPP「Coordinator 下发、叶子执行、根上聚合」同构——这套「算子树上每一层都能并行、根节点收拢结果」的模型可以追到 Graefe 提出的 Exchange 算子(Graefe, Encapsulation of Parallelism in the Volcano Query Processing System, SIGMOD 1990):Exchange 把并行度封装成一个独立算子,插在任意查询树的边上,使上层算子不必知道下层跑在几个进程/节点上。Milvus 的三级 reduce 树可以看成这套思想在 ANN 段检索 场景下的具体化:叶子执行的是段级向量距离计算 + 标量约束,不是 SQL 算子(可对读 query-engine 的 Exchange 直觉,勿混实现——Milvus 的 reduce 层级是按 Streaming/Query Node 物理拓扑固定的三层,不是像通用查询优化器那样按 cost model 动态插入 Exchange)。
二、Query Delegator:shard 内的拼装点
Streaming Service:Query Delegator 驻留在 Streaming Node,负责 单 shard 增量查询:
- 生成查询计划;
- 转发到相关 Query Node;
- 聚合结果;
- 广播 Delete 到其它 Query Node。
多副本时,除与 WAL 共存的 Delegator 外,还可有额外 Delegator 实例(第 5、14 篇)。对归并而言: shard 级正确性与计划在 Delegator;跨 shard 正确性在 Proxy。
| 角色 | 数量 | 失效影响 |
|---|---|---|
| 主 Delegator(与 WAL 共存) | 每 shard 固定一个 | 失效需重新选出/接管,期间该 shard 增量查询受阻 |
| 额外 Delegator 副本 | 按副本配置可有多个 | 可分担查询计划生成与结果聚合的 CPU 压力,互不共享内存状态 |
这张表提醒一点:Delegator 的「副本」分担的是计算压力,不是数据本身的多份存储——Growing 数据仍然只在承载 WAL 的那个 Streaming Node 上,额外 Delegator 副本要访问 Growing 数据时也要跨节点请求,第 14 篇会展开这条路径上的故障恢复细节。
三、Top-\(k\) 归并的接口直觉
设全局要求返回 \(k\) 个最近邻。若有 \(s\) 个 shard,朴素策略是:
- 每个 shard(或每个 segment)取不少于 \(k\) 的局部候选(常取 Top-\(k\),在过滤场景可能需要更多候选——第 11 篇);
- 上一层按距离/内积评分合并,再截断到 \(k\)。
数学上,若距离可比较且各分区覆盖全集的不交并,则 各分区 Top-\(k\) 的并集再取 Top-\(k\) 可得正确全局 Top-\(k\):设分区 \(P_1, \dots, P_s\) 两两不交且 \(\bigcup_i P_i\) 覆盖全部候选集合,\(\mathrm{TopK}(S, k)\) 表示集合 \(S\) 上按距离排序的前 \(k\) 个元素,则
\[ \mathrm{TopK}\Big(\bigcup_{i=1}^{s} P_i,\; k\Big) \subseteq \mathrm{TopK}\Big(\bigcup_{i=1}^{s} \mathrm{TopK}(P_i, k),\; k\Big) \]
且右式在此条件下与左式相等——因为全局第 \(j \le k\) 名必然落在某个 \(P_i\) 的局部前 \(k\) 名之内(若它排在某分区局部第 \(k+1\) 名之后,该分区内至少有 \(k\) 个元素比它更接近查询点,加上其它分区的候选,它不可能进入全局前 \(k\),与假设矛盾)。
用一个小例子把这条推导落地:设 \(k = 2\),有两个 segment \(P_1,
P_2\),按距离从近到远排列的候选分别是 \(P_1 = [1.0, 1.2, 3.0]\)、\(P_2 = [1.1, 2.5,
2.6]\)(数值是illustrative 的距离,越小越近)。\(P_1\) 的局部 Top-2 是 \(\{1.0, 1.2\}\),\(P_2\) 的局部 Top-2 是 \(\{1.1, 2.5\}\);合并两个局部
Top-2 再取全局 Top-2,得到 \(\{1.0,
1.1\}\)。这与直接把全部候选 \(\{1.0, 1.2, 3.0, 1.1, 2.5,
2.6\}\) 排序取前 2 名的结果完全一致——因为 \(P_1\) 里排第 3 的
3.0 和 \(P_2\)
里排第 3 的 2.6 都不可能进全局前
2:它们所在分区内已经各有 2 个更近的候选。这正是上面不等式在
\(k=2, s=2\)
时的具体实例。
过滤、一致性裁剪、软删 bitset 会破坏「局部 Top-\(k\) 足够」的假设——一旦某个分区先应用过滤再排序,局部前 \(k\) 名可能来自过滤前候选量不足的子集,「局部前 \(k\) 之外一定进不了全局前 \(k\)」的论证前提被破坏,这是混合检索篇的核心工程点,本篇先假定「候选集已按可见性过滤」。
Proxy 侧的最终 reduce 还包含官方所说的 post-process(Access Layer):例如跨 shard 去重、格式整理。超大并发时 Proxy 成为归并热点(第 4、17 篇)。
四、GuaranteeTs:一致性如何变成等待
Consistency Level:存算分离下,执行节点可能尚未看见全部最新流式更新;Milvus 用时间戳与 GuaranteeTs 约束搜索范围。用户多通过一致性级别间接设置 GuaranteeTs(Strong / Bounded / Session / Eventually;默认 Bounded)。
Timestamp 文档用 Service_timestamp
与 Guarantee_timestamp(及可选
Graceful_time)比较:
| 关系 | 行为 |
|---|---|
| Service(+ Graceful)已追上 Guarantee | 可立即执行 search/query |
| 尚未追上 | 推迟 请求,直到水位满足 |
四级的直观含义(官方 Consistency):
| 级别 | GuaranteeTs 直觉 |
|---|---|
| Strong | 用最新时间戳;执行侧等到 ServiceTime 满足 |
| Bounded(默认) | GuaranteeTs 早于最新,容忍有界陈旧 |
| Session | 以该客户端插入到达的时间点为 GuaranteeTs |
| Eventually | GuaranteeTs 极小,尽快在已有 batch 视图上执行 |
四级之间没有「一档比另一档全面更好」的关系:Strong 换来的是可预期的新鲜度上界,代价是尾延迟对写入速率敏感;Eventually 换来的是稳定的低延迟,代价是新鲜度完全依赖执行侧自然追赶的速度。选择哪一档取决于业务能不能接受「查询结果可能看不到最近几百毫秒的写入」,而不是「哪个听起来更强」。
把「比较水位、决定放行还是等待」画成时序图,比一句话「推迟请求」更容易在排障时对上日志里的等待环节:
sequenceDiagram
participant C as Client
participant Px as Proxy
participant SN as Streaming Node(Delegator)
C->>Px: search(consistency_level)
Px->>Px: 按一致性级别解出 GuaranteeTs
Px->>SN: search(plan, GuaranteeTs)
loop 直到水位满足
SN->>SN: 比较 ServiceTime(+GracefulTime) 与 GuaranteeTs
alt ServiceTime 已追上 GuaranteeTs
SN->>SN: 放行,进入段级 search
else 尚未追上
SN->>SN: 等待 / 重试比较
end
end
SN-->>Px: shard 结果
Px-->>C: 最终结果
第 4 篇已强调:2.6 的 Growing/Sealed 分流后,文档示意图里的「QueryNode 收流」应映射到 参与本次查询的执行路径整体水位。归并树每一层都只能合并 自己可见集合上的候选;水位不足时不是「少归并几段」,而是 整次请求等待或在放宽一致性下接受更旧视图。
用一条时间轴把这几个时间戳的关系摆一遍(数值是教学用的示意刻度,不代表任何真实系统的实测毫秒数):假设某次写入在逻辑时刻
\(t = 100\) 被 TSO
打上时间戳并提交 WAL;此时某 Streaming Node 的
Service_timestamp 还停在 \(t =
96\)(还没消费到这条写入)。若这次
search 用 Strong
一致性,Guarantee_timestamp
会取当前最新时间戳附近的值(约 \(t
= 100\)),执行侧必须等
Service_timestamp 追到 \(\ge 100\) 才放行;若用
Eventually,Guarantee_timestamp
可能取一个远小于 100
的值(例如已经稳定追上的历史水位),执行侧立刻放行,但这次查询看不到
\(t = 100\)
那条写入。Graceful_time
的作用是给这条比较再加一点容忍余量,避免因为极小的时钟/消费抖动而频繁进入等待分支——具体余量大小是运维可调参数,本篇不代入固定数字。
五、与「单机 FAISS search」的差别清单
| 单机 ANN 库 | Milvus 分布式 search |
|---|---|
| 一次索引结构上的 search | 多 Growing + 多 Sealed 的候选树 |
| 无跨节点时钟 | GuaranteeTs / ServiceTime / 一致性级别 |
| 无 shard | Proxy 跨 shard reduce |
| 删除常直接改结构 | bitset 软删 + 后续 compaction(第 8、13 篇) |
| 调参对象只有索引本身 | 索引参数 + 一致性级别 + shard/副本拓扑,三者独立影响延迟 |
| 失败模式是进程崩溃 | 节点故障、加载失败、水位卡住等分布式失败模式(第 14、17 篇) |
调参只改 ef / nprobe
却忽略一致性级别与 handoff
状态,是把分布式系统当成单索引用。
六、最小故事:把 Strong 一致性打开之后,延迟涨在哪一步
设想一个 2-shard
Collection,压测脚本刚以较高速率写入了一批数据,随后立刻发起一次
search:
- 若一致性级别是 Eventually:GuaranteeTs 取得很小,Streaming Node 几乎立刻通过第四节时序图里的水位比较,直接进入段级 search;这次查询可能读不到刚写入的那一批数据,但延迟表现接近「没有一致性开销」。
- 若一致性级别是 Strong:GuaranteeTs
取当前最新时间戳,Streaming Node 必须等到自己的
Service_timestamp(可能还要加上Graceful_time)追上这个刚刚产生的写入时间戳才能放行。如果这批写入还在 WAL 提交与 Growing 追加的路径上排队,这次search就会卡在「等待水位」这一步,而不是卡在 Knowhere 的向量距离计算上。
同一个查询计划、同一批 segment、同一组
ef/nprobe
参数,仅仅切换一致性级别,就能让延迟表现从「正常」变成「明显变慢」——这也是为什么排查尾延迟时,第一步该看的是
一致性级别与水位等待时长,而不是先怀疑
Knowhere 或索引类型选错了。
七、延迟归因:多级 reduce 里先看哪一层
第 7 篇给过 Segcore/Knowhere 层面的延迟归因表;跨节点归并还有一层需要单独定位:
| 症状 | 更可能对应 | 先检查什么 |
|---|---|---|
| 只在 Strong 一致性下变慢,Bounded/Eventually 正常 | GuaranteeTs 等待(第四节) | ServiceTime 追赶最新写入水位所需时间 |
| 请求量不大也偏慢,且各 shard 表现不一致 | shard 倾斜 | 各 vchannel 的段数/数据量是否均衡 |
top_k 调大后延迟不成比例地飙升 |
归并放大(第五节工程间隙) | 每一层 reduce 的候选列表长度、Proxy 内存 |
| 结果集看起来合理但延迟毛刺集中在特定时间窗口 | Streaming Node 或 Query Node 一侧资源争抢 | 是否与批量写入、compaction 窗口重叠(第 10 篇) |
| 多个看似无关的 shard 同时变慢 | 被共享引用的 Query Node 过载(第一节拓扑图) | 该 Query Node 承载的段是否被多个 shard 的 Delegator 同时访问 |
| 结果长期缺失刚写入的数据,且一致性级别已设为 Strong | GuaranteeTs 解析或时间戳传递异常 | Proxy 是否正确解出了 GuaranteeTs,而不是退化成极小值 |
| 副本数增加后延迟没有明显改善 | 计算层复制到瓶颈上限(第二节表格) | Growing 数据持有节点本身是否已经饱和 |
这张表的作用是把「延迟变高」这一个笼统症状,拆回到本文划出的三级归并树上——大多数「调一致性级别没用」的困惑,根源其实是没有先确认延迟到底卡在哪一级 reduce。
八、常见误解
误解一:把一致性级别设为
Strong,只是让结果「更准」,不会拖慢查询。
第六节的最小故事已经说明:Strong 意味着执行侧必须等到
Service_timestamp
追上请求发起时刻的最新写入水位,这段等待是真实的排队时间,不是「准确率提升的隐性代价」这种虚化的说法,而是可以在监控里看到的一段等待窗口。
误解二:Top-\(k\) 归并只是把各分区结果拼起来再排序,和过滤、软删无关。 第三节的推导依赖「各分区先各自算出局部 Top-\(k\),再合并」这一前提在过滤/删除介入之前成立;一旦过滤先于排序应用,局部 Top-\(k\) 可能不足以代表全局候选,归并出的结果会系统性偏离真实 Top-\(k\)(第 11 篇的核心工程点)。
误解三:多级 reduce
只是「多几次网络往返」的开销,和请求的 \(k\) 大小无关。
$k$
越大,每一层收到的候选列表越长,序列化、反序列化与内存占用都随之放大;Proxy
作为最终 reduce
点,往往是这条放大链的峰值所在(第五节工程间隙)。
误解四:一个 Query Node 只服务一个 shard,排查跨 shard 问题不需要看它。 第一节的拓扑图已经说明 Query Node 加载的 Sealed segment 与 shard 边界无关,同一个 Query Node 可能同时被多个 shard 的 Delegator 引用。某个 Query Node 过载会同时拖慢看起来毫不相关的多个 shard——排障时不能只按 shard 分组去看资源指标,还要看具体是哪几个 Query Node 被共享引用。
误解五:给 shard 加更多 Delegator 副本,就能线性提升这个 shard 的实时查询上限。 第二节的表格已经说明副本分担的是查询计划生成与结果聚合的计算压力,Growing 数据本身仍然只在承载 WAL 的那一个 Streaming Node 上;副本数增加到一定程度后,瓶颈会转移到那个唯一的数据持有节点,而不是继续线性提升。
九、学术谱系、工程间隙、开放问题
9.1 谱系
| 主题 | 对照 |
|---|---|
| MPP 聚合 | Graefe, Encapsulation of Parallelism in the Volcano Query Processing System, SIGMOD 1990 — Exchange 算子把并行度从算子树中解耦 |
| 有界陈旧 | Bailis, Venkataraman, Franklin, Hellerstein, Stoica, Probabilistically Bounded Staleness for Practical Partial Quorums, VLDB 2012 — 用概率模型量化「多久之后大概率读到最新写入」 |
| 会话一致性命名源头 | Terry, Demers, Petersen, Spreitzer, Theimer, Welch, Session Guarantees for Weakly Consistent Replicated Data, PDIS 1994 — 定义 read-your-writes / monotonic reads 等会话级保证 |
| ANN 候选归并 | 多索引/多分区 Top-\(k\) 合并的正确性条件(第三节推导) |
| 副本与计算/存储分离 | 多副本 Delegator 只复制计算、不复制 Growing 存储(第二节表格) |
这条谱系里要小心区分「命名借用」与「算法等价」:Milvus 的 Session 一致性级别在直觉上对应 Terry et al. 定义的会话保证(同一客户端能看到自己写入的效果),但官方文档并未声明实现了该论文完整的四种会话保证;Milvus 的 Bounded 是一个运维可调的陈旧窗口,而 Bailis et al. 的 PBS 给出的是 概率化 的陈旧界——「多少百分之的请求会在 t 毫秒内读到新写入」,Milvus 官方文档没有给出等价的概率保证。这个区分本身就是一个值得写清的工程间隙。
9.2 工程间隙
- Strong 一致性下的尾延迟往往来自 等待水位,而非 Knowhere 变慢——第六节已用最小故事说明。
- shard 倾斜(某一 vchannel 数据远多于其它)会让 Proxy 等待最慢 shard——与 Kafka 分区倾斜同类。
- 多级 reduce 放大序列化与 RPC;候选
$k$过大时内存峰值在 Proxy。 - 官方 Bounded 级别只给「容忍有界陈旧」的定性描述,没有像 Bailis et al. PBS 论文那样给出概率化的陈旧-延迟权衡曲线;容量规划者如果直接套用论文的概率公式去预测 Milvus 的陈旧窗口,会缺乏依据——两者只是同一问题(有界陈旧)的不同解法层次(学术量化模型 vs 工程可调旋钮)。
- 多副本 Delegator 分担计算但不分担 Growing 数据存储(第二节表格),意味着副本数增加并不线性提升该 shard 的实时查询吞吐上限——真正的上限仍受承载 WAL 的那个 Streaming Node 约束。
9.3 开放问题
- 过滤下最优「局部候选倍数」如何自适应(第 11 篇)?
- 是否应在 Streaming Node 层做近似预聚合以减 Proxy 带宽?
- Session 一致性在多客户端写入同一 Collection 时的产品语义边界——是否退化为只保证单连接内的顺序,而不覆盖 Terry et al. 论文里定义的跨会话因果一致性?
- 能否给 Bounded 级别补一个类似 PBS 的概率化陈旧模型,让运维者用 SLO 反推该设多大的陈旧窗口,而不是凭经验试参数?
- 当同一个 Query Node 被多个 shard 共享引用时,Coordinator 的负载均衡目标应该按「单节点吞吐」还是按「跨 shard 尾延迟」优化——两者在段分布不均时可能给出不同的调度结论。
- 多副本 Delegator 若要真正提升实时查询吞吐上限,是否需要在 Streaming Node 侧也引入类似 Query Node 的多副本 Growing 数据复制,而不只是复制计算层?
十、小结
三句话小结
- 一次 Milvus
search是段内、节点内、shard 间三级 reduce 树的归并结果,其并行结构可类比 Volcano 查询引擎里的 Exchange 算子,但层级是按物理拓扑固定的,不是查询优化器动态插入的。 - GuaranteeTs 与 ServiceTime 的比较把「一致性级别」翻译成「是否需要等待」,Strong 级别的尾延迟真实来源往往是这段等待,而不是索引查询本身变慢。
- 局部 Top-\(k\) 归并成全局 Top-\(k\) 的正确性依赖「先排序、后过滤」的前提,过滤或软删介入排序之前会打破这个前提,是混合检索必须单独处理的工程点。
下一篇进入离线数据面:Data Node:compaction 与 index build,那里会说明「有数据」与「有好索引」之间还隔着一层离线队列。
参考资料
核心论文
- Graefe, G., Encapsulation of Parallelism in the Volcano Query Processing System, SIGMOD 1990(Exchange 算子;MPP 归并树的经典源头)。
- Bailis, P., Venkataraman, S., Franklin, M. J., Hellerstein, J. M., Stoica, I., Probabilistically Bounded Staleness for Practical Partial Quorums, VLDB 2012(有界陈旧的概率化模型,与 Milvus Bounded 级别对照)。
- Terry, D. B., Demers, A. J., Petersen, K., Spreitzer, M., Theimer, M., Welch, B. B., Session Guarantees for Weakly Consistent Replicated Data, PDIS 1994(会话一致性定义,与 Milvus Session 级别命名对照)。
文档
- Milvus Documentation v2.6.x, Data Processing(data query)。
- Milvus Documentation v2.6.x, Architecture Overview(search 多级 reduce)。
- Milvus Documentation v2.6.x, Streaming Service(Query Delegator、多副本)。
- Milvus Documentation v2.6.x, Timestamp。
- Milvus Documentation v2.6.x, Consistency Level。
- 第 4、5、7、8 篇、系列 index。
- 第 14 篇 副本与故障恢复(多副本 Delegator 展开)。
返回 系列目录 | 上一篇:对象存储布局 | 下一篇:Data Node compaction
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【向量检索引擎】一致性模型:四级 GuaranteeTs 与 PACELC 的延迟账
按官方 Consistency Level 与 Timestamp 文档拆解 Strong/Bounded/Session/Eventually 如何映射到 GuaranteeTs,用最小故事、四级时间轴与 Strong 等待时序图说明「一致性」在 Milvus 里首先是一笔延迟账;对照 Abadi PACELC 定理与 Bailis PBS,说明 Bounded 是定性旋钮而非概率保证。
【向量检索引擎】Proxy 与 Coordinator:接入面、TSO 与集群大脑
以一次 collection 生命周期里的 DDL/DML/DQL 请求为线索,拆解 Milvus 2.6.x 无状态 Proxy 的路由与 MPP 归并,单活跃 Coordinator 的 TSO/timetick 全序机制与任务调度,并对照 Spanner/TiDB 的时间戳设计说明工程取舍。
【向量检索引擎】Streaming Node 与 Woodpecker WAL:实时可搜的日志层
以一条 insert 从 SDK 到「立刻能被搜到」的最小故事为线索,拆解 Milvus 2.6.x Streaming Service 三件套、Message/TSO 写顺序、Woodpecker 零本地盘 WAL 的 MemoryBuffer/QuorumBuffer 模式,并标明官方吞吐数字的引用边界。
【向量检索引擎】Query Node 与 Segcore:段级 search 如何执行
按 Milvus 2.6.x Data Processing 与 Architecture 拆解 Query Node 对 Sealed 的加载与段级检索,说明 Streaming Node 上 Growing 路径与 Query Delegator 如何拼成一次 search,用最小故事与常见误解钉住 Segcore 与 Knowhere 的层次边界。