一个容易踩的运维假设:给 Collection 配置 3 个副本,是不是意味着写入可用性也提高到了”3 个节点里坏 2 个都还能写”?答案是否定的,而且错得很有代价——某团队在一次 Streaming Node 滚动升级时,先加了两个额外副本才开始驱逐旧节点,观察到查询侧确实平滑,但那次升级窗口里的写入延迟毛刺完全没有改善,因为写入可用性从来不由 Query Node 副本数决定。前几篇默认”一段在某处加载一次”;生产环境靠内存多副本做读扩缩与可用性,靠WAL 单所有者 + 快速恢复做写可用性,靠 Coordinator handoff 做段分布均衡——这是三条独立的机制,混为一谈是这类运维事故的根源。本文按官方文档把三者的边界画清楚。
本文是「向量检索引擎」系列第 14 篇(共 19 篇)。→ 系列目录
版本锚定:Milvus 2.6.x In-Memory Replica、Streaming Service、Woodpecker、Data Processing。
一、In-Memory Replica:读扩缩与故障转移的主杠杆
In-Memory Replica:允许同一 segment 加载到多个 Query Node 的工作内存,以提升性能与可用性。
| 收益 | 官方表述 |
|---|---|
| 性能 | 用额外 CPU/内存换读吞吐;适合数据集相对不大、想堆硬件提 QPS 的场景 |
| 可用性 | Query Node 故障或繁忙时,请求可转到仍持有该段副本的空闲节点,不必先重新加载 |
1.1 关键概念与拓扑
| 概念 | 含义 |
|---|---|
| Replica group | 多个负责历史数据与副本的 Query Node 组成的组 |
| Shard replica | 同一 shard 上的 streaming replica + historical replica;组内 shard replica 数由 Collection shard 数决定 |
| Streaming replica | 同一 DML channel 上的 growing 段集合;技术上只由组内单一节点服务 |
| Historical replica | 同一 channel 的 sealed 段集合;可分布在组内多个 Query Node |
| Shard leader | 服务该 shard streaming replica 的节点 |
把这套概念铺成一张两副本、两 shard 的拓扑图:
flowchart TB
subgraph RG1["Replica Group 1"]
subgraph S1["Shard 0"]
L1["Shard leader QN-A<br/>Streaming replica (growing, 单一节点)"]
H1a["QN-A: Historical replica<br/>(sealed segments)"]
H1b["QN-B: Historical replica<br/>(sealed segments,同一批段的另一份内存拷贝)"]
end
end
subgraph RG2["Replica Group 2"]
subgraph S2["Shard 1"]
L2["Shard leader QN-C<br/>Streaming replica (growing, 单一节点)"]
H2a["QN-C: Historical replica"]
H2b["QN-D: Historical replica"]
end
end
Proxy["Proxy<br/>segment→QueryNode 缓存"]
Proxy --> H1a
Proxy --> H1b
Proxy --> H2a
Proxy --> H2b
Proxy --> L1
Proxy --> L2
注意图中的不对称:Historical replica 是真的多份内存拷贝(QN-A 和 QN-B 各自独立加载了同一批 sealed segment),而 Streaming replica 官方声明只由组内单一节点服务——growing 数据不会被复制到多个节点的内存里,副本组内其它节点要访问 growing 数据仍要跨节点请求这唯一的 shard leader。
1.2 与 2.6 Streaming 架构的术语对齐
2.6 Data Processing / Streaming Service 把 Growing 放在 Streaming Node,Sealed 放在 Query Node。In-Memory Replica 文档仍大量使用”Query Node 服务 growing”的旧表述——属于组件演进中的术语滞后。本系列采用如下对齐:
- Historical / Sealed 多副本:按 In-Memory Replica,同一 sealed 段可多 Query Node 加载——这是读扩缩与故障转移的主杠杆,对应上图的 H1a/H1b。
- Growing / 流侧多副本:Streaming Service 明确:Query Delegator 始终与 WAL 组件共存在同一个 Streaming Node;若 Collection 配置了多副本,会有额外 \(N-1\) 个 Delegator 部署在其它 Streaming Node 上。这些额外 Delegator 分担的是查询计划生成与结果聚合的计算压力,不是 growing 数据本身的存储——数据仍只在持有 WAL 的那一个节点。
读源码或监控时同时搜 replica 与
delegator,避免只认旧名词。
二、Search 路径上的缓存与 Failover
In-Memory Replica · Search 给出的完整流程:
- Proxy 缓存 segment→Query Node 映射,并周期性更新;请求到来时尽量均匀分配 sealed 段到各节点。
- Growing 侧维护 channel→节点缓存并发送请求。
- Failover:缓存可能过期;段已迁移则 Proxy 收到错误,更新缓存并改派。若更新后仍找不到段,可能因 compaction 已消除该段而忽略——这不是数据丢失,是数据已经并入了新段,旧段引用本身该被丢弃。
- 持有 DML channel 的节点可在响应中带回可靠段列表,供 Proxy 校对缓存。
- Enhancement:节点负载不均时,Proxy 可把其它节点上的活跃段请求派给同样持有该段、且更空闲的节点,缓解长尾。
这与第 4 篇”routing cache,缺则问 Coordinator”、第 9 篇多级 reduce 同一家族:正确性依赖视图最终追上,短暂不一致靠重试与缓存刷新,不是靠强一致的成员协议。
三、WAL 迁移与 Wait for Ready:写可用性的真正来源
Streaming Service 的核心约束:WAL 组件在任意时刻只允许在一个 Streaming Node 上运行,这个约束由底层 WAL 存储的fencing 机制与 Streaming Coordinator 共同保证——Streaming Coordinator 通过 etcd 做服务发现,定位可用的 Streaming Node,并负责把 WAL 绑定到具体节点上。存算分离让 WAL 可以从一个 Streaming Node 迁到另一个,迁移窗口:
- 旧节点开始拒绝部分请求;
- 新节点恢复 WAL(从对象存储/远端日志重放状态);
- 客户端进入 Wait for Ready,等待新节点的 WAL 就绪后再继续读写。
sequenceDiagram
participant C as Client
participant Old as Old Streaming Node(原 WAL owner)
participant Coord as Streaming Coordinator(etcd 服务发现)
participant New as New Streaming Node
Note over Old,Coord: 触发迁移(故障 / 负载均衡 / 滚动升级)
Coord->>Old: 撤销 WAL 绑定(fencing:旧节点之后的写将被拒绝)
C->>Old: insert / delete 请求
Old-->>C: 拒绝(该 WAL 已不归本节点所有)
Coord->>New: 绑定 WAL 到新节点
New->>New: 从远端 WAL 存储恢复日志、重建内存状态
C->>New: 重试请求(Wait for Ready 状态)
alt 新节点 WAL 尚未就绪
New-->>C: 暂不可服务,客户端继续等待
else 恢复完成
New-->>C: 就绪,正常处理 insert / delete
end
写路径可用性的关键不是”Query Node 副本数”,而是WAL 单所有者的 fencing 保证 + 恢复速度(第 5 篇 fencing)。回到开篇的运维事故:滚动升级前先加两个 Query Node 副本,改善的只是图一里 H1a/H1b 那类历史段的读扩缩与查询侧故障转移;触发升级的那个 Streaming Node 一旦被驱逐,它独占的 WAL 仍然要走完整套”拒绝—恢复—Wait for Ready”流程,副本数再多也不能让这段等待消失。
3.1 为什么一定要”拒绝旧节点”这一步:fencing 要防的是谁
图中”撤销 WAL 绑定”这一步容易被当成流程上的例行手续,但它要解决一个真实存在的正确性问题:如果旧节点在被撤销绑定之后,仍然认为自己是 WAL 的合法拥有者,会发生什么?考虑一种没有 fencing 的假想设计:Coordinator 决定把 WAL 迁到新节点,通知新节点开始接管,但旧节点此时因为网络分区或短暂假死,没有及时收到”你已经不是 owner 了”的消息,仍在处理排队中的写请求——这类”自己不知道已经被替换、还在继续对外提供服务的旧节点”,在分布式系统文献里通常称为 zombie(僵尸节点)。如果旧节点的写和新节点的写交错落进同一份底层存储,WAL 的顺序性就被破坏了。
Fencing 解决这个问题的思路很直接:给每一次”绑定”分配一个单调递增的令牌(fencing token),底层存储只接受携带当前最新令牌的写请求;旧节点在被替换后,它手上的令牌已经过期,即便它自己没意识到,它的写请求也会被存储层直接拒绝。Martin Kleppmann 在讨论分布式锁设计时系统化了这个模式(How to do distributed locking,2016,作者当时正在写《Designing Data-Intensive Applications》一书,该模式后来被收入该书讨论 leader 选举一节;本篇引用其作为方法论说明,非会议论文,B 级来源):单纯”认为自己是 leader”不能保证安全,必须让存储系统本身依据令牌大小拒绝旧 leader 的写,安全性才落在存储层而不是节点自己的判断上。图中”旧节点开始拒绝部分请求”这一步描述的正是这个机制在 Milvus 里的体现——不是旧节点”主动配合退出”,而是它的写请求从这一刻起在存储层就通不过。
3.2 Woodpecker QuorumBuffer:一次写要等几个确认
第七节会展开 Woodpecker 与经典复制协议的对照,这里先把”quorum 写确认”这件事具体化成一个可推演的例子。假设 QuorumBuffer 部署了 3 个副本节点,写操作需要至少 2 个副本确认才算成功(多数派):
sequenceDiagram
participant SN as Streaming Node(WAL owner)
participant R1 as Quorum 副本 1
participant R2 as Quorum 副本 2
participant R3 as Quorum 副本 3
SN->>R1: 写入 entry(序号 N)
SN->>R2: 写入 entry(序号 N)
SN->>R3: 写入 entry(序号 N)
R1-->>SN: ack
R2-->>SN: ack
Note over SN: 已收到 2/3 确认,达到多数派
SN->>SN: 向客户端返回写入成功
R3-->>SN: ack(迟到,不影响已经返回的结果)
这个例子里,即便 R3 因为网络延迟或短暂故障没有及时确认,只要 R1、R2 确认了,写入就已经安全——单个副本故障不会拖慢写路径,也不会丢数据(另外两份还在)。但如果同时有 2 个副本不可用(例如 R2、R3 同时故障),剩下 R1 一个确认无法达到多数派,这次写入就必须等待,直到至少一个故障副本恢复——这与第一节 Query Node 副本”至少一个加载成功即可服务”的宽松要求完全不同:quorum 写确认的可用性下限是”多数派存活”,不是”至少一个存活”,两层复制的容错门槛本来就不一样,不能用同一个直觉去类比。
四、Handoff 与负载均衡
Data Processing:flush 或 compaction 后,Coordinator handoff,在 Query Node 间分配 Sealed,平衡内存、CPU、segment 数,释放冗余。
| 事件 | 典型动作 |
|---|---|
| Query Node 宕机 | 有历史段内存副本则可改派(图一 H1a→H1b 这类切换);否则需重新从对象存储加载(延迟尖刺,第 6、7 篇) |
| 新节点加入 | handoff / balance 迁入段 |
| compaction | 旧段消失,Proxy 缓存必须能识别并忽略(第二节 Failover 步骤 3) |
| Streaming Node 故障/升级 | 走第三节的 WAL 迁移 + Wait for Ready,与 Query Node 侧的 handoff 是两条独立路径 |
五、代价:副本不是免费 QPS
| 代价 | 说明 |
|---|---|
| 内存 | 同段多份向量/索引驻留(图一的 H1a、H1b 各占一份内存) |
| 对象 GET | 多节点重复加载放大 API 与流量(第 6 篇) |
| 删除广播 | 更多历史副本需要收到 Delete(第 13 篇) |
| 协调复杂度 | 缓存失效、错派、长尾调度 |
小数据集 + 多硬件堆 QPS 是官方点名的甜区;十亿级全量多副本可能先被内存与对象费用打死。
5.1 把”内存换 QPS”摆成一个可推演的式子
第 17 篇给出的容量公式是 \(\text{Query Node 内存} \approx \sum(\text{段向量数据}+\text{段索引}) \times \text{副本因子}\);这里用一组占位数字把它具体化,帮助建立量级直觉(数字为教学示意,不是任何实测或官方基准):设某 Collection 全部 Sealed 段的向量数据加索引合计占 \(M\) GB,副本因子从 1 加到 3:
\[ \text{副本因子}=1:\ M\text{ GB}\qquad \text{副本因子}=2:\ 2M\text{ GB}\qquad \text{副本因子}=3:\ 3M\text{ GB} \]
内存开销随副本因子线性增长,这一点没有疑问;但读吞吐是否也线性增长,取决于瓶颈在哪——如果原本单副本时 CPU(向量距离计算)已经是瓶颈,加副本能让请求分散到更多节点的 CPU 上,吞吐确实接近线性提升;但如果瓶颈其实在 Proxy 侧的归并逻辑或网络带宽,加 Query Node 副本对吞吐的边际收益会迅速下降,而内存开销依然线性增长。这解释了”副本不是免费 QPS”这句话背后的具体机制:你付出的是确定线性增长的内存代价,换回来的吞吐提升幅度取决于瓶颈位置,不是同样确定的。加副本前先确认瓶颈在 CPU 还是别处,是这笔账划不划算的前提。
六、常见误解
误解一:副本数越多,写入可用性越高。 本文开篇的运维事故就是这个误解的直接后果。第一节已经说明:即便配置了多副本,Query Delegator 的额外副本分担的是计算压力,growing 数据的唯一存储位置仍是持有 fencing 后 WAL 的那一个 Streaming Node。写入可用性的上限由第三节的”WAL 迁移 + 恢复速度”决定,不随 Query Node 或额外 Delegator 数量线性提升。
误解二:In-Memory Replica 提供的是和共识协议(Raft/Paxos/Chain Replication)等价的强一致复制保证。 第一节图一里的 Historical replica 之所以不需要共识协议来保证多份拷贝一致,是因为它们复制的对象是已经在对象存储上落地的不可变 sealed segment 快照——多个 Query Node 各自从同一份不可变数据加载,天然一致,不存在”谁的写入顺序更权威”的问题。这和第七节要讨论的 Chain Replication、PacificA 解决的问题完全不同:那两类协议要处理的是多个副本上独立发生的写操作如何排出唯一顺序,而 Milvus 的读副本没有这个负担——真正需要排序保证的写操作,已经在更早的 WAL 单所有者那一层解决了。
误解三:Proxy 缓存找不到某个段,就等同于丢数据。 第二节 Failover 步骤 3 已经引用官方说明:段在缓存更新后仍找不到,可能是因为该段已经被 compaction 消除——它的数据已经并入了新段,继续引用旧段 ID 才是错误行为,忽略旧引用是正确的处理方式,不是遗漏。真正需要报警的是”新段也找不到”这种情形。
误解四:Query Node”至少一个副本加载成功即可服务”和 Woodpecker QuorumBuffer”多数派确认才算写成功”,两者的容错门槛是同一回事,坏一个节点都不影响。 3.2 节已经用具体数字区分过这两者:Query Node 读副本只要求”至少一个存活”,坏 2 个只要剩 1 个仍能服务;Woodpecker QuorumBuffer 的写确认要求”多数派存活”,3 副本部署下坏 2 个就无法继续写入。把读路径”少数存活也能扛”的直觉套用到写路径的 quorum 确认上,会低估”同时坏 2 个副本”这种情形对写可用性的真实影响。
误解五:fencing 只是给旧节点发一个”你被替换了”的通知,旧节点收到通知后配合退出就行。 3.1 节引用 Kleppmann 的分析已经说明:fencing 的安全性不能依赖”旧节点乖乖配合”,因为旧节点完全可能因为网络分区等原因没有收到或没有及时处理这条通知(zombie 场景)。真正提供安全保证的是底层存储依据单调递增令牌拒绝旧节点的写,通知旧节点只是尽力而为的优化,不是安全性的来源。
七、学术谱系、工程间隙、开放问题
7.1 谱系:两层复制解决两个不同问题
Milvus 的”复制”其实分裂成两个在学术上分属不同谱系的机制,官方文档没有明确点出这个分裂,但从架构上可以清楚拆开:
| 层 | 解决的问题 | 学术对照 |
|---|---|---|
| WAL durability(Woodpecker QuorumBuffer 模式) | 单份日志如何在写入时容忍节点故障 | 三副本 quorum,写入需 2/3 副本确认才算成功——这与经典primary-backup 复制协议(van Renesse & Schneider, Chain Replication for Supporting High Throughput and Availability, OSDI 2004;Lin, Yang, Zhang, Zhou, PacificA: Replication in Log-Based Distributed Storage Systems, MSR-TR-2008-25)解决的是同一类问题:如何在保证强一致的前提下,让一份日志容忍部分副本故障 |
| Query Node In-Memory Replica | 同一份已经落地的不可变数据,如何多点缓存以提升读吞吐与故障转移 | 更接近只读缓存池 / CDN 边缘节点的模型,不需要处理”多个副本上独立写操作的排序”这个复制协议的核心难题 |
Woodpecker 官方架构文档说明其 QuorumBuffer 模式下,客户端把数据写入三副本 quorum,至少 2 份确认后才算写成功,再异步落盘到对象存储——这是一个真正的、需要处理写序的复制协议,形态上更接近 PacificA 的”主节点分配序列号、多数副本确认后提交”框架,而不是 Chain Replication 的严格链式转发(Woodpecker 官方文档没有公开到能确认具体是哪一种协议变体的细节,本篇不代入未证实的实现)。而 In-Memory Replica 完全不需要这套机制,因为它复制的输入本身已经是不可变、已排好序的历史快照。
把这两层混为一谈,就是本文开篇运维事故的根源:升级前加的是第二层(Query Node 副本),真正卡住写路径的是第一层(WAL 的 fencing 与恢复)。
7.2 工程间隙
- 文档术语跨 2.x→2.6 需人工对齐 Streaming/Query,In-Memory Replica 文档的示意图仍按旧术语画,容易让人误以为 growing 数据也被复制。
- “至少一个副本加载成功即可服务”缩短了上线时间,但副本未齐时容量与尾延迟仍差——这是官方 Balance 一节明确的行为,不是缺陷。
- Proxy 增强调度(第二节 Enhancement)依赖段级副本拓扑正确;拓扑抖动期(如批量 handoff 进行中)长尾可能因为调度决策基于过期拓扑而恶化。
- Woodpecker QuorumBuffer 与 MemoryBuffer 两种部署模式在可用性与延迟上有本质差异(后者是客户端内存缓冲 + 定期落盘,没有多副本确认),官方文档把两者都称为”WAL”,运维选型时必须先确认部署的是哪一种,再判断这一层的容错边界。
7.3 开放问题
- 副本因子(Query Node 侧)是否应该随 QPS 自动伸缩,官方文档只描述手工配置的 Balance 策略,自动化伸缩策略是开放的运维问题。
- 跨可用区部署时,Historical replica 之间的读延迟与第 13 篇的删除广播延迟叠加后的整体 SLA 官方文档未给出公式。
- Woodpecker QuorumBuffer 的具体复制协议(是否等价于某个已发表算法、fencing token 的具体实现)没有公开到可以精确对照 Chain Replication 或 PacificA 细节的程度——这是一个需要读源码才能回答、而不是读文档就能回答的问题。
- 与 Kubernetes 滚动升级的配合:本文开篇的事故提示,“先加 Query Node 副本再驱逐”这类运维手册如果不区分两层复制机制,容易给出误导性的操作顺序建议;是否应该在官方运维文档里显式区分”这一步改善读侧”和”这一步改善写侧”?
八、小结
三句话小结:
- Milvus 的”副本”其实是两套独立机制:Query Node 的 In-Memory Replica 复制的是不可变历史快照,用来做读扩缩与查询故障转移;WAL 层靠单所有者 fencing(可选配合 Woodpecker QuorumBuffer 的多副本确认)保证写路径的容错,两者互不代偿。
- 写入可用性的上限由 WAL 迁移与 Wait for Ready 的恢复速度决定,不随 Query Node 副本数增加而线性提升——本文开篇的运维事故正是把这两层弄混的直接代价。
- In-Memory Replica 不需要共识协议是因为它复制的输入本身已经排好序、不可变;真正需要处理写序冲突的复制协议(Chain Replication、PacificA 一类)如果存在,只会出现在 WAL durability 这一层,不会出现在 Query Node 读副本这一层。
下一批文章进入对照与收束:第 15–18 篇(Qdrant、Lance、排障、选型)会重新审视本篇划出的两层复制边界在其它系统里是怎么设计的。
参考资料
核心论文
- van Renesse, R., Schneider, F. B., Chain Replication for Supporting High Throughput and Availability, OSDI 2004。
- Lin, W., Yang, M., Zhang, L., Zhou, L., PacificA: Replication in Log-Based Distributed Storage Systems, MSR-TR-2008-25, Microsoft Research, 2008(技术报告,非会议 peer-review 论文,但被广泛作为主备复制框架的工程参照)。
文档 / 工程参考
- Milvus Documentation v2.6.x, In-Memory Replica(Replica group / shard replica / streaming vs historical replica / Search failover)。
- Milvus Documentation v2.6.x, Streaming Service(Query Delegator、fencing、WAL Lifetime、Wait for Ready)。
- Milvus Documentation, Woodpecker(MemoryBuffer / QuorumBuffer 部署模式、quorum 写确认)。
- Milvus Documentation v2.6.x, Data Processing(handoff)、Architecture Overview。
- Kleppmann, M., How to do distributed locking, martin.kleppmann.com, 2016(fencing token 模式;B 级工程分析,非会议 peer-review 论文,本篇仅引用其”安全性应落在存储层而非节点自身判断”的方法论)。
- 第 4、5、6、9、10、13 篇、系列 index。
返回 系列目录 | 上一篇:Delete · Upsert · TTL | 下一篇:Qdrant 对照
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【向量检索引擎】Collection · Partition · Segment · Channel:Growing 到 Sealed 的状态机
用最小故事钉住 Milvus 2.6.x 数据模型:Collection/Partition、vchannel/pchannel 与 Streaming Node 绑定,Growing/Sealed、flush 与 handoff 状态机,并纠正「一个 Collection 一张大图」等常见误解。
【向量检索引擎】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 的层次边界。
【向量检索引擎】Data Node:compaction 与 index build
说明 Milvus 2.6.x 中 Data Node 作为离线 Worker 如何承接 Coordinator 下发的建索引与 compaction,输入输出如何进出对象存储;用最小故事、常见误解与队列积压图说明索引堆积如何拖垮查询新鲜度与资源争用。