Chapter 02
架构:流式分布式与日志即事实源
01 钉实了 Collection / Segment / shard / 一致性这些名词——这一章解释把这些名词组织起来的那套系统:Milvus 凭什么是 4 个独立伸缩的服务 + 3 个存储后端,而不是“FAISS 套一层 REST”。读完,存算分离、四层职责、以及“一条写入在哪一刻算落定”会变成一张能在脑子里跑的图。
本章你将建立的 schema
- 存算分离:持久状态只在 etcd + 对象存储 + WAL(日志),计算节点全部无状态、可随时重建。
- 四层职责:接入层 Proxy / 协调层 MixCoord / 工作层 Streaming·Query·Data node / 存储层。
- 日志即事实源:一条写入 append 到 WAL 那一刻即算完成,WAL 是持久性边界,节点是日志的派生态。
- 写入路径与 segment 生命周期·compaction:从一次 insert 走到不可变 binlog,以及删除为何要靠 compaction 才回收。
这一章讲的是设计取舍,不是组件清单。每个机制按同一套结构推:一句话定位 → 为什么需要它 → 比文档深一层的机制 → 带边界的类比。重点放在两组对照表上——存储后端的选型、以及“写日志 vs 写节点”的取舍,是这套架构的核心,也是面试里最能问出深浅的地方。读路径与一致性机制(TSO 怎么发、guarantee timestamp 怎么卡)留到 04 章;索引内部留到 03 章。
2.1存储计算分离
Milvus 把存储和计算拆成各自独立伸缩的层,计算节点不持有任何不可丢的状态。
单机向量库(FAISS)把数据、索引、查询挤在同一块进程内存里:要扩容只能换更大的机器(纵向扩展有天花板),机器崩了内存里的东西全没。把存储和计算拆开后,加 query 节点就加查询吞吐、加 data 节点就加离线处理能力,各层按各自的瓶颈独立伸缩;任一计算节点崩了,数据一行不丢,新节点重新加载即可顶上。
比文档深一层的机制:这套设计的关键不在“分层”,而在持久状态的边界被收得极窄。整个集群里只有三处持有不可丢的状态——etcd(元数据)、对象存储(binlog 与索引文件)、WAL(写入日志)。除此之外的一切——query node 内存里加载的 sealed 段、streaming node 持有的 growing 段、proxy 缓存的拓扑——都是这三处状态的派生态:能从对象存储重新加载、能从 WAL 重放、能从 etcd 重新拉取。所以计算层“无状态”不是说它内存里没数据,而是说它内存里的数据全都能被重建,丢了不心疼。这条边界决定了故障恢复和弹性伸缩为什么对 Milvus 来说是同一件事的两面。
像一组无状态 Web 服务 + 一个共享数据库:业务进程随便重启,状态都在库里。边界在于:这里的“数据库”被进一步拆成三块各司其职的后端——元数据(etcd)、写入日志(WAL)、对象存储(binlog/索引)。不是一个全能数据库,而是三种为不同访问模式优化的存储:etcd 存小而关键、强一致的元数据;WAL 存顺序追加的写入流;对象存储存海量、不可变的大文件。把它们当成一块来理解,就会错判每一层的伸缩与故障特性。
与下一节的关系:分离只是骨架。骨架立起来之后,每一层具体由谁来干活、各自只负责什么——这就是下一节的四层职责划分。
2.2四层架构:各组件的单一职责
Milvus 自上而下是四层:接入层 Proxy、协调层 MixCoord、工作层三类 node、存储层三种后端,每个组件只担一件事。
把“接收请求”“做决策调度”“干实际计算”“持久化”这四类职责彻底分开,每一类才能按自己的负载独立扩缩、独立部署、独立失败。请求暴涨就加 Proxy,查询变重就加 Query node,离线积压就加 Data node——互不牵连。这是分层架构(自上而下、上层依赖下层、同层无状态可水平扩展)在向量库上的落地。
比文档深一层的机制:这套划分里藏着一条贯穿全章的红线——上三层(Proxy / MixCoord / 工作 node)全部无状态,真相只沉在最底层。Proxy 不存数据、MixCoord 把权威元数据写进 etcd 而非自己内存、工作 node 内存里的段都能从存储重建。更要注意 2.6 在协调层和工作层各做了一次方向相反的整合:协调层把原先 root / query / data / index 四个独立 coordinator 合并成一个 MixCoord(少了跨进程协调的复杂度);工作层则新拆出一个 streaming node(把“流式写入与 growing 段查询”从原来混在 data/query 里的职责中独立出来)。下面这张表把每个组件的职责和状态属性钉死。
| 层 / 组件 | 只负责这件事 | 有无持久状态 |
|---|---|---|
| 接入层 · Proxy | 无状态门面:校验请求、按 shard 路由写入、把各分片返回的结果做最终 reduce(跨段/跨分片合并 top-k) | 无(拓扑从 etcd / MixCoord 拉取,可缓存) |
| 协调层 · MixCoord | 集群大脑:发 TSO 全局时间戳、管 DDL/DCL、维护查询拓扑与负载均衡、调度 compaction 与建索引(2.6 合并了原 root/query/data/index 四个 coordinator) | 无(权威元数据落 etcd,自身可重启) |
| 工作层 · Streaming node | 分片级(2.6 新增):持有 WAL 读写、服务 growing 段的实时查询、做查询委派(把请求分发给持有相关段的节点) | 无(growing 段可从 WAL 重放重建) |
| 工作层 · Query node | 从对象存储加载 sealed 段(及其索引)并执行向量检索 | 无(sealed 段可从对象存储重新加载) |
| 工作层 · Data node | 离线活:flush(growing→binlog)、compaction(合并小段+清删除)、建索引 | 无(输入是 WAL/对象存储,输出回写对象存储) |
| 存储层 · 三后端 | etcd 存 schema/checkpoint/服务注册;WAL(Pulsar/Kafka 或 2.6 的 Woodpecker)存写入日志;对象存储(S3/MinIO/Azure Blob)存 binlog/索引/结果 | 持久状态唯一所在地 |
网上大量 Milvus 架构图还停在 2.x 早期:画着 RootCoord / QueryCoord / DataCoord / IndexCoord 四个独立 coordinator,且没有 streaming node。那是旧拓扑。2.6 把四个 coordinator 合并成一个 MixCoord,又新增了 streaming node。看架构图先认版本——拿旧图套 2.6 的行为,排查时会找错组件。
一个 query node 进程突然崩溃。集群里的数据会丢吗?这个节点重启(或被新节点替换)后,要从哪里把状态拿回来?
展开答案(先停 10 秒)
数据一行不丢。query node 内存里只有从对象存储加载来的 sealed 段(及其索引)——这些是派生态,权威副本一直在对象存储里。节点崩了,MixCoord 检测到拓扑变化,把这些段重新分派给其他(或新起的)query node,对象重新加载即可。
这正是“计算无状态”的实操含义:恢复一个计算节点 = 重新加载它本就该加载的段,不涉及任何数据找回。故障恢复和扩容因此是同一套机制。
与下一节的关系:表里反复出现“真相只在底层、节点是派生态”。这句话的支点是存储层里那个 WAL——它凭什么能当“事实源”,让所有节点都甘当派生态?下一节专讲这条。
2.3日志即事实源
一条写入 append 到 WAL(write-ahead log,预写日志)那一刻即视为完成——WAL 是持久性的边界,不是某个具体节点。
若“写成功”绑定在某个具体节点的内存上,那个节点崩了就要面对“到底写没写进去”的歧义。把持久性边界挪到一条独立的、顺序追加的日志上,写入与任何计算节点解耦:节点崩了,从 WAL 的 checkpoint(检查点,记录已处理到日志的哪个位置)往后重放即可恢复,不依赖崩掉的那台机器还活着。
比文档深一层的机制:每条写入按 vchannel(逻辑写入流,见 01 §1.3)的顺序、带着 TSO 时间戳,append 进 WAL。这条日志是全局唯一的、有序的事实源;集群里其余一切状态都是“把这条日志重放出来”得到的派生态——growing 段是 streaming node 重放尚未 flush 的日志尾巴、sealed 段是 data node 把一段日志固化成 binlog 的产物。所以一条写入在落 WAL 的瞬间就已经持久且不可丢,与它后来有没有进对象存储、哪个节点在服务它,全部无关。这一点是 §2.4 写入路径的地基,也是“为什么节点能随意崩、随意加”的根本原因。2.6 在这里还做了一步关键演进:把日志本身做成云原生——新组件 Woodpecker 直接把日志 append 到对象存储,零本地盘、无需再运维一套 Pulsar/Kafka 集群。
| 方案 | “写成功”意味着什么 | 为何 Milvus 不这么选 / 选它 |
|---|---|---|
| 直接写工作节点内存 | 数据进了某个节点的内存就算成功 | 否决:持久性绑死在单点,该节点崩了写入有歧义、有丢失风险;恢复要靠节点自己还活着 |
| 同步刷盘到节点本地磁盘 | 落到处理节点的本地盘才算成功 | 否决:把状态钉死在某台机器,破坏“计算无状态”,弹性伸缩与故障迁移全失效 |
| 先 append 到 WAL(事实源) | 进了独立的、有序的日志即算成功 | 选它:持久性与计算节点彻底解耦;任一节点崩了从 checkpoint 重放即恢复,节点纯派生态 |
WAL 这一层用什么实现,本身也是一组取舍。2.6 之前依赖外部消息队列(Pulsar 或 Kafka)做日志层;2.6 引入内置的 Woodpecker 把这块收进 Milvus 自己、直接架在对象存储上。两条路线的对照:
| 维度 | 外部 Pulsar / Kafka | 内置 Woodpecker(2.6) |
|---|---|---|
| 部署形态 | 需独立运维一套消息队列集群(broker、ZooKeeper 等) | 零额外集群,直接 append 到对象存储 |
| 本地磁盘 | broker 依赖本地盘做持久化 | 零本地盘(zero-disk),持久层就是对象存储 |
| 运维与成本 | 多一套有状态中间件要扩容、调优、监控 | 少一层运维面,云上成本更贴近对象存储计价 |
| 定位 | 成熟、生态广,仍受支持的选项 | 2.6 主推的云原生默认路线,专为 WAL 这一访问模式设计 |
像数据库的 WAL(redo log):先把变更顺序写进日志、确认持久,再慢慢应用到数据文件;崩溃后靠重放日志恢复。边界在于:传统数据库的 WAL 通常是单机本地文件,而 Milvus 的 WAL 是分布式、按 vchannel 分流、带全局 TSO 排序的——它同时承担了“持久性边界”和“多节点之间的有序广播通道”两个角色。Kafka 用过的话,把它想成“带数据库 redo 语义的有序 topic”更贴切。
与下一节的关系:既然落 WAL 即算持久,那一次 insert 从进 Proxy 到落 WAL、再到固化成 binlog,中间到底经过哪些手?下一节把这条路径一步步走完。
2.4写入路径:一次 insert 的全程
一次 insert 的路径:Proxy 校验并按主键哈希分流 → streaming node 分配 TSO 并 append 到 WAL(此刻已持久)→ growing 段 → flush 时 data node 固化成 binlog 落对象存储、段转 sealed。
把前三节的概念串成一条可在脑子里跑的流水线,才能判断一次写入在每个时刻的真实状态:哪一刻算持久、哪一刻才可被索引检索、节点在哪一步介入。这条路径也是 04 章读路径与一致性判定的镜像——读要“看到所有 ≤ guarantee ts 的写”,就得先搞清写是怎么带着 TSO 落进来的。
比文档深一层的机制:路径上有一个最反直觉的分界点——持久性发生在“落 WAL”那一步,而不是“落对象存储”那一步。很多人下意识以为数据要写进 S3 才算安全,实际上 append 进 WAL 的瞬间就已不可丢;后续 flush 落 binlog 只是把它从“日志尾巴上的增量”固化成“可加载、可建索引的不可变文件”,是性能与可检索性的优化,不是持久性的获得时刻。再看分流:一条 insert 先按 hash(主键) 选定 shard → 进对应 vchannel;vchannel 映射到底层 WAL 的 pchannel(物理通道),每个 pchannel 由一个 streaming node 持有读写——这就是 shard 数即写入并行度的由来(呼应 01 §1.3)。TSO 由 MixCoord 作为全局时钟统一发放,保证跨节点写入有一个全局可比较的时间序。
把每一步的状态拆开看,这条路径上有四个值得记住的时刻:
- 过 Proxy 时:仅完成校验(schema 合法性)与路由(
hash(主键)定 shard),数据还没持久,此刻进程崩了写入失败、需重试。 - append 进 WAL 时:streaming node 分配 TSO、做一致性检查、写入日志——此刻起数据不可丢,客户端可收到写入确认。
- 进 growing 段时:数据驻留 streaming node 内存、可被实时查询,但只能暴力扫描(还没建索引,呼应 01 §1.4)。
- flush 后转 sealed 时:data node 把 growing 段固化成 binlog 落对象存储、段变只读不可变(呼应 01 §1.5),随后异步建索引,自此可被 query node 加载并走向量索引检索。
“写入确认”和“可被高效检索”是两个不同时刻,中间隔着整个 growing→sealed→indexed 的异步过程。从关系库迁移过来的工程师常默认“insert 返回成功 = 立刻能高效查到”,在 Milvus 里这不成立:返回成功只保证已落 WAL(持久),可见性还要看一致性级别(04 章),高效检索还要等 sealed+indexed。
2.5Segment 生命周期与 compaction
sealed 段是不可变 binlog;删除只写 tombstone(删除标记)到 delta binlog,空间要等 compaction 才真正回收。
向量 binlog 设计成不可变,是为了让它能被任意 query node 安全地并发加载、缓存、共享,无需加锁。但不可变带来一个直接后果:数据没法原地删、原地改。删除因此退化成“追加一条删除标记”,再靠后台的 compaction 把标记和被删数据一起清掉、顺便把碎掉的小段合并——这是 01 §1.4 那张生命周期图里“只前进、靠 compaction 重走”的物理原因。
比文档深一层的机制(比 01 深一层):sealed 段的 binlog 一旦落盘就永不修改。一次删除(按主键删一行)不会去改那个 binlog,而是往该段对应的 delta binlog 追加一条 tombstone;检索时 query node 把 sealed 段的数据和 delta binlog 一起加载,在结果里过滤掉被标记删除的行——所以删除是“逻辑上立刻生效、物理上延迟回收”。被删行占的存储、以及越积越多的小段,由 compaction 统一处理:它把多个小段合并成大段、把 delta 里的删除真正落实(被删行不再写进新段),合并完旧段作废。compaction 由 MixCoord 自动触发,常见阈值如某段的 delta 超过约 20% 行数、或 delta 体积超过约 10MB(具体阈值随版本配置变化)。
| 代价 / 痛点 | 根因 | 设计回应 / 缓解 |
|---|---|---|
| 写放大 | 删除/更新不能原地改,只能追加 tombstone,数据被重复写若干次 | 用顺序追加换并发只读的简单性;compaction 阶段一次性收敛 |
| compaction 占资源 | 合并大段 + 重建索引是 CPU/IO 密集的离线活 | 放在独立的 data node 上、与查询路径隔离;MixCoord 按阈值调度,不抢在线查询资源 |
| 小段过多 → 查询扇出加重 | 每个 sealed 段各建一份索引,一次检索要在所有相关段上各跑一次再合并 | compaction 合并小段降低段数;反过来也警示:手动频繁 flush 会制造小段风暴(呼应 01 §1.5) |
和 LSM-tree(RocksDB/LevelDB)的 tombstone + compaction 几乎同构:删除先写墓碑、空间靠后台合并回收。边界在于:LSM 的 compaction 主要重排键的有序性,Milvus 的 compaction 还要重建被合并段的向量索引——多了一笔索引重算的成本。学过 RocksDB 的写放大问题,这里的取舍会很眼熟,只是又叠了一层“索引也要重来”。
对一个有 1000 万行的 collection,按主键删掉其中 100 万行。删除调用返回成功的瞬间,对象存储里 binlog 占的空间会立刻减少吗?这些被删的行还会出现在检索结果里吗?
展开答案(先停 10 秒)
空间不会立刻减少。删除只往 delta binlog 追加了 100 万条 tombstone,原 binlog 一字未动,存储占用反而短暂上升(多了 delta)。真正的回收要等 compaction 把这些段重写、被删行不再写进新段时才发生。
但被删的行不会再出现在检索结果里:query node 加载段时连 delta binlog 一起加载,在结果中把带 tombstone 的行过滤掉。所以是“逻辑立刻生效、物理延迟回收”——可见性与空间回收是两条独立的时间线。
2.6把全章串起来:一个高频写入场景
综合写入路径 + segment 状态 + 01 的 shard/partition,去推断一条数据在不同时刻的真实状态。
前五节各讲一个机制,真正的理解发生在把它们叠起来推一个具体场景的时候——什么时候持久、什么时候可被索引检索、为什么改 shard 数这么贵。这一节用一个贴近生产的场景把全章(以及 01 的 shard/segment)拧成一股。
场景:一个日志检索 collection,设了 4 个 shard,按租户 ID 做 partition key。线上持续高频写入(每秒上千条),同时偶发查询(运维偶尔来搜相似日志)。围绕“一条刚写入的数据”,把全章串起来推这三问:
| 时刻 | 它在哪 / 什么状态 | 能被检索吗 / 怎么检索 |
|---|---|---|
| 刚 append 进 WAL | 已持久(事实源),在某个 vchannel 的日志尾巴上,由一个 streaming node 持有 | 已落定不可丢;可见性取决于一致性级别(04 章) |
| 进了 growing 段 | 驻留该 streaming node 内存,可持续追加 | 能查,但只能暴力扫描(无索引),偶发查询下尚可接受 |
| flush 后 sealed + indexed | 固化成 binlog 落对象存储、不可变,索引就绪 | 由 query node 加载、走向量索引高效检索 |
顺着这张表,三个推断自然落地:
- 数据何时持久:落 WAL 那一刻(§2.3 / §2.4),不是落对象存储那一刻。高频写入下,绝大多数最新数据其实还在 growing 段、尚未 flush,但它们早已持久——崩了能从 WAL 重放。
- 何时可被索引检索:要等 growing → sealed → flushed → indexed 走完(§2.5)。所以“偶发查询查最新日志偏慢”是预期内的——最新的还在 growing 段被暴力扫。持续高频写还会不停催生新 sealed 小段,加重查询扇出,正是 compaction 要去收敛的(表 2.4)。
- 改 shard 数为何要重排:4 个 shard 绑定 4 个 vchannel→pchannel,每个 pchannel 由一个 streaming node 持有(§2.4,呼应 01 §1.3)。改 shard 数 = 改
hash(主键)到通道的映射,已写入的数据所属的日志流要全部重新分配——等于重排整条 WAL 的分区,因此 shard 数建表定死、事后难改。partition(按租户裁剪查询)则可随时加,与此正交。
本章定义清楚的 TSO、streaming/query node、MixCoord、guarantee 之前的写入序,正是 04 章读路径与一致性的输入:读要“看到所有 ≤ guarantee timestamp 的写”,靠的就是这里每条写入带着 TSO 落 WAL 的有序性。而每个 sealed 段“各建一份索引、检索时各跑一次再合并”里的索引内部,是 03 章的主线。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- Milvus 集群里,持久状态一共存在哪几处?给定这个答案,为什么说所有计算节点都是“无状态”的?
- 一次 insert 在哪一步算“已持久、不可丢”?这一步和“数据落进对象存储 binlog”是不是同一时刻?差别说明了什么?
- 2.6 相比早期 2.x,协调层和工作层各发生了一次什么方向相反的拓扑变化?各自的动机是什么?
- (设计题)若把 WAL 换成“写入直接同步刷到处理节点的本地磁盘才算成功”,存算分离架构会在哪两个能力上崩掉?为什么 append 到独立日志能同时保住这两点?
答案(先做完再展开)
- 三处:etcd(schema/checkpoint/服务注册)、对象存储(binlog/索引/结果)、WAL(写入日志)。计算节点内存里的段全是这三处的派生态——能从对象存储重新加载、从 WAL 重放、从 etcd 重拉,丢了可重建,故称无状态。
- 在 append 进 WAL 那一步即算持久。它和“落对象存储 binlog”不是同一时刻:落 WAL 是持久性边界,落 binlog 只是把日志尾巴固化成可加载、可建索引的不可变文件(性能/可检索性优化)。差别说明持久性与具体节点、与对象存储都解耦了。
- 协调层做合并:原 root/query/data/index 四个 coordinator 合成一个 MixCoord(降低跨进程协调复杂度);工作层做拆分:新增 streaming node,把流式写入与 growing 段查询从原混杂职责中独立出来(让分片级流式处理成为一等公民)。
- 会崩掉弹性伸缩和故障迁移两点:状态被钉死在某台机器的本地盘上,节点不再可随意增删、崩了数据找不回。append 到独立有序日志则把持久性从节点剥离——任一节点崩了从 checkpoint 重放即恢复,节点回到纯派生态,伸缩与迁移因此都成立。
沿着写入路径反推一次故障恢复
一个 collection 有 4 个 shard,正以每秒数千条的速度写入。某一刻,持有 shard-2 对应 pchannel 的那个 streaming node 进程崩溃,此时它内存里的 growing 段还没来得及 flush。
(a) shard-2 上“崩溃前一秒刚 append 的写入”丢了吗?依据是什么?(b) 接管的新 streaming node 要怎样把 shard-2 的 growing 段恢复出来、恢复到哪个位置为止?(c) 在这次崩溃和恢复期间,shard-0 / shard-1 / shard-3 的写入会受影响吗?为什么?
提示(卡住再展开)
(a) 想清楚“持久性边界在 WAL 还是在 growing 段内存”——崩的是持有内存段的节点,但日志在哪?(b) growing 段是“日志尾巴的派生态”,恢复 = 从某个位置开始重放 WAL;那个起点就是 checkpoint,终点是日志当前末尾。(c) 每个 shard 绑各自的 pchannel、由各自的 streaming node 持有——它们是不是共享命运?这正是把 collection 切成多 shard 在故障域上的收益。