KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · Milvus 架构与一致性 — keel 龙骨

前三章都在单机内存里做。这一章换一个量级:把向量检索做成一个分布式服务时,会多出哪些层、依赖哪些外部存储、一致性要付什么代价。 本章的证据性质和其他章不同。 本机实验台没有装上 pymilvus(importlib.util.find_spec('pymilvus') 返回 None),milvus-lite 虽然装了但没有客户端库可用,所以本章没有任何本机跑出来的 Milvus 输出。下面关于架构、一致性、参数的内容全部来自 Milv

前三章都在单机内存里做。这一章换一个量级:把向量检索做成一个分布式服务时,会多出哪些层、依赖哪些外部存储、一致性要付什么代价。

本章的证据性质和其他章不同。 本机实验台没有装上 pymilvus(importlib.util.find_spec('pymilvus') 返回 None),milvus-lite 虽然装了但没有客户端库可用,所以本章没有任何本机跑出来的 Milvus 输出。下面关于架构、一致性、参数的内容全部来自 Milvus 官方文档,检索日期 2026-10-05,来源见本章末尾。凡是和本地 faiss / pgvector 实验有重叠的判断,我会单独标注是本地实测还是文档陈述,不混着说。

现场

你在单机上用 faiss 把索引调好了,QPS 也够。现在数据涨到 1 亿条,单机内存装不下,而且要求「写入后多数副本可见」并且「滚动升级不停机」。这时候你需要的是一个分布式向量库:检索逻辑还是 IVF/HNSW 那套,但上面多了分片、副本、元数据存储、消息日志、对象存储这些层。Milvus 是这套架构的一个代表。

写入链路

Milvus 把一次 insert 拆成好几跳,跨了进程和存储边界。

flowchart TD
    C["客户端 insert"] --> PX["Proxy (Access Layer, 无状态)<br/>校验请求、按路由转发"]
    PX --> SN["Streaming Node (Worker)<br/>写入 growing segment"]
    SN --> WAL[("WAL Storage<br/>Kafka / Pulsar / Woodpecker<br/>先落日志再算提交")]
    SN --> GROW["growing segment<br/>内存态, 立即可被检索"]
    GROW -->|"段达到容量"| SEAL["转为 sealed segment"]
    SEAL --> DN["Data Node (Worker)<br/>compaction + 建索引"]
    DN --> OBJ[("Object Storage<br/>MinIO / S3: 索引与数据文件")]
    OBJ --> QN["Query Node (Worker)<br/>加载 sealed 索引"]
    SN -.->|"WAL 写失败"| ERR["写入不可提交, 该批数据不可见"]
    DN -.->|"索引尚未建完"| SLOW["sealed 段暂无索引<br/>检索退化为更重的扫描"]

    style C fill:#e3f2fd,color:#0d3b66
    style PX fill:#e3f2fd,color:#0d3b66
    style SN fill:#f1f8e9,color:#33691e
    style WAL fill:#fff3e0,color:#8a4b00
    style GROW fill:#e8f5e9,color:#1b5e20
    style SEAL fill:#e8f5e9,color:#1b5e20
    style DN fill:#f1f8e9,color:#33691e
    style OBJ fill:#fff3e0,color:#8a4b00
    style QN fill:#f1f8e9,color:#33691e
    style ERR fill:#ffebee,color:#b71c1c
    style SLOW fill:#ffebee,color:#b71c1c

分步读(编号与图一致):

  1. Proxy 校验请求,然后转发给 Streaming Node。Proxy 是无状态的(文档原话:stateless proxies),可以用 Nginx、K8s Ingress 之类做负载均衡,扩缩容不动数据。
  2. Streaming Node 承接写入。文档把它描述为「分片级的 mini-brain」,除了写,还负责 growing 数据的查询、生成查询计划、把数据从 growing 转成 sealed。
  3. WAL 落盘。写入在提交前先记进 WAL。WAL 的实现在文档里是 Kafka、Pulsar 或 Woodpecker(Woodpecker 是文档描述的零磁盘云原生方案,直接写对象存储)。这一跳是持久化和恢复能力的来源。
  4. growing segment:数据进了内存态的段,立即可被检索。这是 Milvus 写入后能马上查到的物理原因(配合下面的一致性级别)。
  5. 转 sealed:段写满后封存。封存后的数据是不可变的,交给 Data Node 做 compaction 和建索引。
  6. Data Node → Object Storage:历史数据的离线处理在这里发生,索引文件落到对象存储(MinIO / S3 / Azure Blob)。
  7. Query Node 加载 sealed 索引,之后 sealed 数据的检索由它承担。

本地对照:Milvus 的「growing / sealed segment」和单机 faiss 完全不同。faiss 里加一条向量,索引就更新了,没有段的概念。pgvector 是单机事务,写完即查,也没有段。段的引入是为了让「写入」和「建索引」解耦——写入快、建索引慢,两件事不能互相等。

检索链路

一次 search 同样跨多个组件,还要做多级归并。

flowchart TD
    C["客户端 search"] --> PX["Proxy: 路由 + 最终归并"]
    PX --> SN["Streaming Node<br/>(分片级查询协调)"]
    SN --> GROW["growing segment<br/>本地执行检索"]
    SN --> QN["Query Node<br/>sealed segment 检索"]
    QN --> R1["Query Node 内多段归并"]
    GROW --> R2["Streaming Node 归并<br/>growing + Query Node 结果"]
    R1 --> R2
    R2 --> PX
    PX --> FIN["Proxy 归并所有 Streaming Node 结果<br/>→ top-k 返回客户端"]
    QN -.->|"GuaranteeTs 未达成"| WAIT["等待数据可见, 延迟上升"]

    style C fill:#e3f2fd,color:#0d3b66
    style PX fill:#e3f2fd,color:#0d3b66
    style SN fill:#f1f8e9,color:#33691e
    style GROW fill:#e8f5e9,color:#1b5e20
    style QN fill:#f1f8e9,color:#33691e
    style R1 fill:#e8f5e9,color:#1b5e20
    style R2 fill:#e8f5e9,color:#1b5e20
    style FIN fill:#e8f5e9,color:#1b5e20
    style WAIT fill:#ffebee,color:#b71c1c

关键在归并层级:Query Node 段内归并 → Streaming Node 归并 growing 与 Query Node 结果 → Proxy 归并所有 Streaming Node 结果。为什么三层?因为一次查询要覆盖的段分布在不同的 Query Node 上,每层各归并一次,最终 Proxy 拿到全局 top-k。这跟单机 faiss「一次 search 直接给结果」是两回事——分布式检索的延迟里,归并和网络占了相当一部分。

四层职责

层 组件 有状态吗 职责
Access Layer Proxy 无状态 校验请求、路由、归并最终结果
Coordinator 单个 active — DDL/DCL、TSO 与时间戳、streaming 服务管理、Query Node 拓扑与负载均衡、把 compaction/建索引派给 Data Node
Worker Nodes Streaming / Query / Data Node 无状态(存储计算分离) Streaming 管 growing 与分片一致性;Query 查 sealed;Data 做 compaction 与建索引
Storage etcd / WAL / Object Storage 有状态 元数据、日志、索引与数据文件

注意 Coordinator 文档里明确写了「同一时刻整个集群只有一个 active」。这是理解 Milvus 故障域的关键:Coordinator 抖动会影响 DDL 和调度,但不直接挡查询路径(查询走 Proxy → Streaming/Query Node)。

存储依赖

Milvus 不自己存元数据和文件,它依赖三个外部系统:

这带来一个和单机 faiss 完全不同的运维面:你的向量库可用性上限,等于 etcd、对象存储、WAL 三者里最弱的那个。etcd 抖、对象存储慢、WAL 堆积,都会以「向量检索变慢或不可用」的形式暴露出来,但根因不在向量层。

一致性级别

一致性在单机 faiss 里根本不存在——数据在内存里,写完就在。pgvector 是单机事务,同一会话写完立即可见。Milvus 是分布式,写入和可见之间要选一个级别。文档给了四档(默认 bounded staleness):

级别 保证 代价 文档给的适用场景
Strong 读最新版本 延迟最高 功能测试、对一致性要求极高的场景(如在线支付账单)
Bounded staleness(默认) 允许一定时间窗口内不一致,窗口外全局一致 中等 推荐系统等能接受偶发不可见、但对延迟敏感的场景
Session 同一会话内写后立即可见 较低 同一会话内强一致的场景(如删书后刷新页面不应再搜到)
Eventually 不保证读写顺序,最终收敛 最低 对一致性要求低、追求极快检索(如商品评论/评分检索)

机制是文档里的 GuaranteeTs:请求会带一个时间戳,Query Node 等到该时间戳之前的数据都可见才执行检索。Strong 把 GuaranteeTs 设为最新系统时间戳;Bounded staleness 设得略小;Session 用客户端最近一次写入的时间戳;Eventually 设很小、跳过检查。文档用 PACELC 定理来概括这组权衡——强一致换高延迟,弱一致换快检索。

工程含义:「一致性级别」本质上是「你愿意等多久才看到最新数据」。一个常被忽略的细节——Session 级别用的是「客户端最近一次写入的时间戳」,所以它能保证的只是你自己写的数据你自己能查到,不保证别人写的你能查到。多租户场景里把 Session 当成「全局强一致」会踩坑。

概念边界

主动破坏(文档陈述,本机跑不了)

本机没有 Milvus,以下两条是文档规定下的推理,不是实测:把一致性级别设为 Eventually 后写入、立即检索,按照 GuaranteeTs 的定义(设很小、跳过检查),这次检索可能看不到刚写的向量;设为 Strong 则能看到,但请求会等到最新时间戳前的数据全部可见,延迟上升。这两条正对你的业务能不能接受「写完马上查」是决定性判断——如果业务要求写后立即可见,至少要用 Session,且要清楚它只保证同一会话。

生产边界

动手

本章没有本机实验,但可以做两件不依赖 Milvus 的事:

  1. 拿本章的两张链路图,对照你正在用(或打算用)的向量库文档,标出它的写入路径在哪一步落盘、检索路径在哪一层归并。如果文档找不到对应步骤,那本身就是需要向供应方确认的风险点。
  2. 在单机 pgvector 上做对照:同一个会话 INSERT 后立刻 SELECT,验证「单机事务里写后立即可见」是默认行为,理解为什么分布式库里这需要靠一致性级别去换。

自测

  1. 为什么 Milvus 要在写入路径里引入 WAL,而不是直接写对象存储?WAL 挡掉了什么故障?
  2. 一次 search 的归并为什么要做三层(Query Node → Streaming Node → Proxy),而不是让 Proxy 直接找所有 Query Node?
  3. 「Bounded staleness」是默认级别。它和 Session 的差别是什么?各自适合哪类接口?
  4. 为什么说「你的向量库可用性上限等于 etcd、对象存储、WAL 里最弱的那个」?
  5. 本章没有任何本机实测。在课程里区分「文档陈述」和「本地实测」为什么重要?举一个两者结论看起来相同、实际风险不同的例子。

本章为 Milvus 官方文档陈述,检索日期 2026-10-05,来源:
milvus.io/docs/architecture_overview.md、milvus.io/docs/consistency.md、
milvus.io/docs/index.md、milvus.io/docs/diskann.md。本机未运行 Milvus,
与第 0003、0506 章的本地 faiss / pgvector 实测结论分开陈述。

↓ 下一步:05 章 · 过滤与混合检索 —— 分布式层的过滤为什么是另一个问题。

进入 keel 阅读