主题
1.2 Milvus 架构解析:存算分离的云原生设计
理解架构不是炫技——是调优和排障的前提,你得知道瓶颈在哪个组件
这一节在讲什么?
上一节我们聊了 Milvus 的定位——分布式向量数据库,专为十亿级向量而设计。但"分布式"这三个字背后,Milvus 的架构到底长什么样?为什么它要这样设计?各个组件之间怎么协作?这些问题你可能觉得"我又不是 Milvus 的开发者,为什么要了解架构"——但当你遇到搜索延迟突然飙升、写入吞吐突然下降、或者索引构建卡住不动的时候,如果你不了解架构,就只能像无头苍蝇一样到处试。理解架构,你才能知道问题出在哪个组件、该怎么调。这一节我们就来拆解 Milvus 的架构设计。
存算分离:Milvus 架构的核心理念
Milvus 架构最核心的设计理念是存算分离(Disaggregated Storage)——把数据存储和查询计算分成独立的模块,各自可以独立扩缩容。为什么要这样做?因为向量数据库的存储和计算对资源的需求是完全不同的:
- 存储需要大容量、持久化、高吞吐的磁盘 I/O——向量数据动辄几百 GB 甚至 TB 级
- 计算需要大内存、高 CPU——向量搜索需要在内存中计算距离,索引也需要常驻内存
如果把存储和计算耦合在一起(像 pgvector 那样),你就不得不买一台既有大磁盘又有大内存的服务器——这种机器很贵,而且当你只需要更多存储空间时,你也得连带买更多内存,反之亦然。存算分离让你可以独立地扩展存储和计算——存储不够就加对象存储的容量(便宜),计算不够就加 QueryNode(按需),资源利用率更高。
存算分离 vs 存算耦合:
存算耦合(pgvector):
┌──────────────────────────┐
│ PostgreSQL 服务器 │
│ ┌────────┐ ┌─────────┐ │
│ │ 存储 │ │ 计算 │ │ → 必须同时升级,资源浪费
│ │ (磁盘) │ │ (内存) │ │
│ └────────┘ └─────────┘ │
└──────────────────────────┘
存算分离(Milvus):
┌──────────┐ ┌──────────┐
│ 对象存储 │ │ QueryNode│ → 独立扩缩容
│ (MinIO) │ │ (内存) │ → 存储不够加磁盘
│ 便宜 │ │ 计算不够加节点
└──────────┘ └──────────┘三大核心组件
Milvus 的架构可以分成三层:协调器层、工作节点层和存储层。每一层都有明确的职责,层与层之间通过消息队列和 RPC 通信。
Milvus 架构全景图:
┌─────────────────────────────────────────────────────────┐
│ 协调器层(大脑) │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │RootCoord │ │QueryCoord │ │DataCoord │ │IndexCoord│ │
│ │(元数据) │ │(查询调度) │ │(数据调度)│ │(索引调度)│ │
│ └───────────┘ └───────────┘ └──────────┘ └──────────┘ │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────┼────────────────────────────────┐
│ 工作节点层(手脚) │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │QueryNode │ │DataNode │ │IndexNode │ │
│ │(执行搜索) │ │(写入数据) │ │(构建索引)│ │
│ └───────────┘ └───────────┘ └──────────┘ │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────┼────────────────────────────────┐
│ 存储层(记忆) │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │ etcd │ │ MinIO/S3 │ │Pulsar/ │ │
│ │(元数据) │ │(对象存储) │ │Kafka │ │
│ │ │ │ │ │(消息队列)│ │
│ └───────────┘ └───────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘协调器层(Coordinator):大脑
协调器是 Milvus 的"大脑",负责元数据管理、任务调度和全局协调。它不直接处理数据,而是告诉工作节点"该做什么":
- RootCoord:管理元数据——Collection 的创建/删除、Schema 的变更、分区的创建。当你在 Python 里调用
create_collection()时,实际上是 RootCoord 在处理这个请求 - QueryCoord:管理查询调度——决定哪些 QueryNode 加载哪些数据、如何分配搜索任务、如何做负载均衡。当搜索延迟升高时,QueryCoord 会尝试把数据重新分配到不同的 QueryNode
- DataCoord:管理数据调度——决定数据写入哪些 Segment、何时触发 Flush 和 Compaction。它维护着"哪些数据在哪个 Segment 里"的全局视图
- IndexCoord:管理索引调度——决定何时构建索引、分配索引构建任务给 IndexNode。当你调用
create_index()时,IndexCoord 会选择一个空闲的 IndexNode 来执行
工作节点层(Worker Node):手脚
工作节点是 Milvus 的"手脚",负责实际的数据处理工作:
- QueryNode:执行搜索和查询。它从对象存储加载数据和索引到内存,然后响应搜索请求。QueryNode 是内存消耗最大的组件——所有需要搜索的数据和索引都必须在 QueryNode 的内存中
- DataNode:处理数据写入。它从消息队列消费写入请求,把数据攒成 Segment,然后写入对象存储。DataNode 是 CPU 密集型组件——它需要处理数据的序列化、压缩和持久化
- IndexNode:构建索引。它从对象存储读取原始向量数据,构建索引文件,然后把索引文件写回对象存储。索引构建是 CPU 和内存密集型操作,IndexNode 的资源消耗取决于数据量和索引类型
存储层:记忆
存储层是 Milvus 的"记忆",负责数据的持久化和通信:
- etcd:存储元数据——Collection 的 Schema、Segment 的位置信息、索引的元数据。etcd 是整个系统的"目录",如果 etcd 挂了,Milvus 就无法找到任何数据
- MinIO / S3:对象存储——存储实际的向量数据文件和索引文件。这是 Milvus 的"仓库",所有持久化数据都在这里
- Pulsar / Kafka:消息队列——缓冲写入请求,实现组件间的异步通信。当你的应用调用
insert()时,数据先写入消息队列,DataNode 再从队列消费
数据流:从写入到搜索的完整路径
理解了组件之后,让我们跟踪一条数据从写入到可搜索的完整路径:
数据从写入到搜索的完整路径:
1. 应用调用 insert()
│
▼
2. 数据写入消息队列(Pulsar/Kafka)
│
▼
3. DataNode 从消息队列消费数据
│
▼
4. DataNode 把数据攒成 Segment,写入对象存储(MinIO/S3)
│
▼
5. DataCoord 更新元数据(etcd),记录新 Segment 的位置
│
▼
6. 应用调用 create_index() → IndexCoord 分配任务给 IndexNode
│
▼
7. IndexNode 从对象存储读取原始数据,构建索引文件,写回对象存储
│
▼
8. 应用调用 load() → QueryCoord 指示 QueryNode 加载数据和索引到内存
│
▼
9. 应用调用 search() → QueryNode 在内存中执行搜索,返回结果这个流程解释了为什么 Milvus 有一些"奇怪"的行为:
- 插入后搜不到数据:因为数据还在消息队列里,DataNode 还没消费,更别说加载到 QueryNode 了。你需要
flush()强制数据持久化,或者等待自动同步 - 建索引后还是暴力搜索:因为索引文件还在对象存储里,你需要
load()把索引加载到 QueryNode 内存 - 删除后空间没释放:因为删除是逻辑删除(标记为已删除),需要
compact()合并 Segment 才能物理清理
比如,下面的程序展示了从创建 Collection 到搜索的完整流程,由于 Milvus 的存算分离架构,每一步都有明确的职责和触发条件:
python
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
# 第1步:创建 Collection(RootCoord 处理)
client.create_collection(
collection_name="documents",
dimension=768,
metric_type="COSINE"
)
# 第2步:插入数据(写入消息队列 → DataNode 消费 → 对象存储)
client.insert(
collection_name="documents",
data=[
{"id": 1, "vector": [0.1] * 768, "text": "hello world"},
{"id": 2, "vector": [0.2] * 768, "text": "goodbye world"},
]
)
# 第3步:创建索引(IndexNode 构建 → 写回对象存储)
client.create_index(
collection_name="documents",
field_name="vector",
index_params={"index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 256}}
)
# 第4步:加载到内存(QueryNode 从对象存储加载索引)
client.load_collection("documents")
# 第5步:搜索(QueryNode 在内存中执行)
results = client.search(
collection_name="documents",
data=[[0.15] * 768],
limit=2,
output_fields=["text"]
)为什么需要理解架构
你可能会觉得"我只是用 Milvus 做搜索,为什么要了解这么多架构细节"。原因很简单——调优和排障时你需要知道瓶颈在哪个组件:
| 问题现象 | 可能的瓶颈组件 | 排查方向 |
|---|---|---|
| 搜索延迟高 | QueryNode | 检查内存是否足够、索引参数是否合理 |
| 插入吞吐低 | DataNode / 消息队列 | 检查消息队列积压、DataNode 数量 |
| 索引构建慢 | IndexNode | 检查 CPU 和内存资源、数据量 |
| 数据"丢了" | DataCoord / QueryNode | 检查是否 flush、一致性级别 |
| 元数据操作慢 | RootCoord / etcd | 检查 etcd 性能 |
| 整体系统卡 | 协调器 | 检查 Collection/Partition 数量是否过多 |
比如,当你发现搜索延迟从 10ms 飙升到 500ms,如果你不了解架构,你可能会去调搜索参数(ef、nprobe),但实际上问题可能是 QueryNode 内存不够导致索引被换出了——这时候调搜索参数根本没用,你需要加 QueryNode 或者增加内存。
常见误区:Milvus Standalone 不需要理解架构
有些同学觉得"我用的是 Milvus Standalone(单容器部署),所有组件都在一个容器里,不需要理解架构"。这个想法是错误的——即使所有组件打包在一个容器里,它们的职责和协作方式并没有变。搜索慢了还是得看 QueryNode 的内存,写入慢了还是得看 DataNode 的消费速度,索引构建卡了还是得看 IndexNode 的资源。Standalone 只是把部署变简单了,并没有把架构变简单。
小结
这一节我们拆解了 Milvus 的三层架构:协调器层负责调度和管理,工作节点层负责实际的数据处理,存储层负责持久化和通信。存算分离的设计让 Milvus 可以独立扩展存储和计算,但也带来了更多的组件和更复杂的运维。理解架构的核心价值在于——当系统出问题时,你能快速定位瓶颈在哪个组件,而不是盲目地调参数。下一节我们要聊的是 Milvus 的安装和连接,从最简单的 Milvus Lite 到生产级的分布式集群。