news 2026/11/2 3:26:51

Milvus存储机制深度解析:从写入链路到Compaction的完整流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Milvus存储机制深度解析:从写入链路到Compaction的完整流程

向量数据库这个圈子这两年热得发烫,但凡做过RAG、以图搜图、推荐召回或者语义检索的同学,几乎绕不开Milvus这个名字。但大多数人停留在“会调API、会建collection、会写search参数”这一层,一旦被问到“一条向量写进去之后到底经历了什么”“为什么删除的数据磁盘占用没降”“segment为什么忽大忽小”这类问题,就开始含糊其辞。我面过不少候选人,简历上写着“熟悉Milvus”,结果连Segment和Binlog的关系都讲不清楚,这就很尴尬了。

这篇内容就是想把Milvus这套存储流程从头到尾捋一遍:数据从客户端进来,经过哪些组件、落到哪些文件、什么时候刷盘、什么时候合并、删除标记怎么生效、查询又是怎么把分散的Segment拼成结果的。我会按“设计思路—核心组件—写入链路—查询链路—合并与清理—常见问题”的顺序展开,尽量把每个“为什么这么设计”讲透,而不是罗列名词。适合已经用过Milvus、想往深里钻的开发者,也适合准备面试、需要把存储机制讲明白的同学。看完你至少能做到:别人问“Milvus一条数据写进去发生了什么”,你能从Proxy一路讲到对象存储,中间不卡壳。

1. 先搞清楚Milvus存储设计的整体思路

1.1 为什么Milvus要搞“日志+列存+对象存储”这套组合

理解Milvus存储,第一步是理解它的定位:它是一个存算分离的分布式向量数据库。存算分离这四个字决定了它不可能像单机数据库那样把数据放在本地B+树上随便改。对象存储(S3、OSS、MinIO这类)的特点是吞吐高、成本低、但延迟大且不支持随机写。向量数据又有个特点:写入是追加为主,查询是批量扫描为主,单条更新极少。这两个特点一叠加,就自然导向了LSM-Tree式的追加写 + 后台合并这套架构。

所以Milvus的存储本质上是“日志结构合并”思想在向量场景的落地:所有写入先以日志形式追加(Binlog),积累到一定量后固化成一个不可变的段(Segment),后台再定期把小的段合并成大的段,删除则用标记的方式延迟生效。这套设计的好处是写入路径极短、吞吐高,代价是读路径要处理多段合并、删除过滤,以及后台合并带来的写放大。

我个人的理解是,你把这套东西类比成“先把流水账记在本子上,攒够一页就装订成册,册子太多了再合并成大厚本,划掉的内容先做记号、等重装订时再真正剔除”,基本就通了。后面所有组件,都是围绕这个流程服务的。

1.2 存算分离带来的三个关键约束

存算分离不是免费的午餐,它给存储设计带来了三个硬约束,理解这三点,后面很多“奇怪”的设计就顺理成章了。

第一,计算节点不能假设数据在本地。QueryNode加载Segment时,数据可能还在对象存储里,所以必须有缓存机制(ChunkCache)和预加载策略,否则每次查询都去拉对象存储,延迟直接爆炸。

第二,元数据必须独立管理。哪些Segment存在、分布在哪些节点、状态是什么,这些信息不能放在对象存储里(对象存储没有事务),所以Milvus用了etcd来存元数据,DataCoord负责调度,RootCoord负责整体协调。

第三,写入不能直接改已有文件。对象存储不支持原地修改,所以更新和删除都只能“追加新记录+旧记录打标记”,真正物理删除要等Compaction。这就解释了为什么你删了数据磁盘不降——不是bug,是设计。

提示:面试时如果被问“Milvus为什么删除后空间不释放”,标准答案就是“删除是逻辑标记,物理回收依赖Compaction,且Compaction有触发条件和调度延迟”,能补一句“这是LSM架构的通用取舍”就更好了。

1.3 一条数据从进入到可查的完整生命周期概览

在深入细节前,先给一个全局视角。一条向量数据的生命周期大致是:客户端SDK发起insert → Proxy接收并做路由和打包 → 写入消息队列(Pulsar/Kafka)作为DML消息 → DataNode消费消息、写入Binlog到对象存储 → DataCoord根据Binlog积累情况触发Flush,生成一个Sealed Segment → 该Segment被注册到元数据,QueryNode加载后可查 → 后台Compaction定期合并小Segment、清理删除数据 → 过期数据和Binlog由垃圾回收机制清理。

这条链路里,消息队列是写入的缓冲和解耦层,Binlog是持久化的最小单位,Segment是可查询的最小单位,Compaction是空间回收和查询优化的手段。把这四个概念的关系理清,Milvus存储你就掌握了七成。下面逐个拆。

2. 核心存储组件逐个拆解

2.1 Segment:可查询的最小单元,也是理解一切的钥匙

Segment是Milvus存储里最核心的概念,没有之一。你可以把它理解成“一批向量的集合”,它是数据在QueryNode上被加载、索引、查询的基本单位。一个Collection在物理上就是一堆Segment的集合,查询时Milvus会把请求广播到所有相关Segment上,各自算出TopK,再做全局归并。

Segment有两种状态:Growing和Sealed。Growing是正在写入的段,数据还在不断追加,通常还在内存里配合Binlog;Sealed是已经封存、不再写入的段,会被建索引、加载到QueryNode供查询。Flush的本质就是把一个Growing Segment封存成Sealed Segment。

Segment的大小是有讲究的。默认配置下,一个Segment大约积累到512MB(可通过dataCoord.segment.maxSize调整)就会触发封存。为什么是这个量级?太小会导致Segment数量爆炸,查询时要扫太多段、元数据压力大;太大则单段加载慢、合并成本高、故障恢复粒度粗。512MB是个经验平衡点,实际生产中如果内存充足可以适当调大以减少段数量,但要注意单次加载的内存峰值。

这里有个容易混淆的点:Segment和分片(Shard)不是一回事。Shard是写入层面的并行单位,一个Collection可以指定多个Shard,每个Shard对应一个虚拟通道(VChannel),写入时按主键哈希分散到不同Shard。而Segment是存储和查询层面的单位,一个Shard下会有多个Segment。理解这个区分,才能明白为什么写入并行度和查询并行度是两套独立的调节旋钮。

2.2 Binlog:持久化的最小单位,一切数据的最终归宿

Binlog是Milvus持久化到对象存储的实际文件,可以理解为“某个Segment在某类数据上的变更日志文件”。注意,Milvus的Binlog和MySQL的Binlog概念相似但用途不同——它更像是列式存储的落盘文件,按数据类型分开存放。

一个Segment落盘时会产生三类Binlog:InsertBinlog(存插入的向量和标量数据)、DeleteBinlog(存删除标记)、StatsBinlog(存统计信息,比如行数、布隆过滤器)。每类数据又是按列存的,向量列、主键列、标量列各自独立成文件。这种列存设计的好处是查询时只读需要的列,比如只查主键做去重就只读主键Binlog,不用把向量也拉下来。

Binlog文件路径是有规律的,通常在insert_log/{collectionID}/{partitionID}/{segmentID}/{fieldID}/这样的层级下,文件名带一串日志ID。这个结构设计是为了让对象存储的列举操作高效,同时方便按Segment粒度做垃圾回收。你如果去翻对象存储的bucket,看到一堆这种路径,就知道每个Segment对应哪些文件了。

注意:Binlog一旦写入就不可变,这是整个架构的基石。任何“修改”都是新增Binlog,旧Binlog靠元数据标记失效,物理删除交给GC。所以排查“数据不一致”问题时,先看元数据里的Binlog列表,再看对象存储里的实际文件,两边对不上往往是GC或Flush时序问题。

2.3 消息队列与通道:写入的缓冲带和顺序保证

Milvus的写入不是直接落对象存储的,中间隔了一层消息队列(早期用Pulsar,现在也支持Kafka)。这层的作用有三个:削峰缓冲(写入洪峰不会直接打垮存储层)、解耦(Proxy和DataNode互不感知)、顺序保证(同一通道内消息有序,保证同一主键的写入顺序)。

每个Shard对应一个VChannel,VChannel在物理上映射到消息队列的一个Topic分区。Proxy写入时按主键哈希决定进哪个VChannel,DataNode订阅这些通道消费消息。这个设计的关键在于:同一主键的写入一定落在同一通道,从而保证顺序性;不同通道之间并行消费,保证吞吐。

这里有个实操中常踩的坑:如果消息队列的Topic分区数少于Shard数,或者消费端跟不上生产端,会出现消息堆积,表现为“数据写进去了但查不到”——因为DataNode还没消费到。排查时先看消息队列的堆积量(consumer lag),再看DataNode的消费速率,基本能定位。

2.4 元数据与协调服务:谁在指挥这场交响乐

etcd存的是元数据,包括Collection的schema、Segment的列表和状态、通道的分配、节点的注册信息等。DataCoord是存储侧的调度大脑,负责Flush触发、Compaction调度、GC决策;RootCoord是全局协调者,管DDL和整体拓扑。QueryCoord负责把Sealed Segment分配到QueryNode并管理负载均衡。

这套分工的核心逻辑是“元数据与数据分离,调度与执行分离”。etcd保证元数据强一致(用Raft),对象存储存实际数据(最终一致),DataCoord做决策但不碰数据,DataNode/QueryNode执行但不做全局决策。这种分层让系统可以水平扩展——加DataNode提升写入吞吐,加QueryNode提升查询能力,各自独立。

理解这套协调机制,对排查问题极有帮助。比如“Segment一直不Flush”,你要看DataCoord的Flush触发条件(时间阈值、大小阈值、消息条数);“查询报Segment not loaded”,你要看QueryCoord的分配状态和QueryNode的加载进度。每个现象背后都对应一个协调组件的决策逻辑。

3. 写入链路:一条向量从客户端到可查的全过程

3.1 Proxy接收与打包:写入的第一站

客户端调insert,请求先到Proxy。Proxy做几件事:校验schema(字段类型、维度、主键合法性)、按主键哈希算出目标Shard和VChannel、把这一批数据打包成一条DML消息、写入消息队列。注意这里是批量打包,一次insert的多条数据打成一条消息,减少消息队列的压力。

Proxy本身是无状态的,可以水平扩展,前面挂负载均衡。它不持久化任何数据,写完消息队列就返回成功(注意:此时数据还没落对象存储,只是进了消息队列)。这个“写完队列即返回”的设计是吞吐高的关键,但也意味着消息队列的可靠性直接决定数据不丢——所以生产环境消息队列必须做持久化和副本。

实操心得:批量insert时单批不要太大,一般几千条一批比较合适。太大单条消息体积膨胀,消费和落盘都慢;太小则消息条数爆炸,元数据压力大。这个批量大小没有银弹,要结合向量维度和消息队列配置调。

3.2 DataNode消费与Binlog落盘:真正写数据的地方

DataNode订阅VChannel,消费到DML消息后,把数据按列拆开,分别追加写入对应字段的Binlog。这里的关键是内存缓冲+定期刷盘:DataNode不会每条消息都刷一次对象存储(那样延迟和成本都受不了),而是在内存里积累,达到一定条件(时间或大小)才把缓冲的Binlog刷到对象存储。

刷盘时,DataNode会把当前Growing Segment的Binlog路径、行数、时间戳等信息汇报给DataCoord。DataCoord据此判断是否触发Flush。这个“DataNode刷Binlog + DataCoord决策Flush”的分工很重要:DataNode只管写,不决定何时封存;DataCoord掌握全局,统一决策。

注意:Binlog刷盘和Segment Flush是两个不同频率的动作。Binlog可能刷了很多次,Segment还没Flush(还在Growing)。所以一个Growing Segment可能对应多批Binlog文件,Flush时才把它们“定格”成一个Sealed Segment。排查时别把这两个概念混了。

3.3 Flush触发条件与Sealed Segment生成

Flush是把Growing Segment封存成Sealed Segment的过程。触发条件主要有三个:Segment大小达到阈值(默认512MB)、Growing Segment存活时间超过阈值(默认一段时间,防止小段长期不封存)、手动调用flush接口。DataCoord综合这些条件做决策。

Flush发生时,DataCoord会通知DataNode停止向该Segment写入,把当前所有Binlog路径汇总,生成一个Sealed Segment的元数据,注册到etcd。同时该Segment进入待建索引队列,由IndexNode建索引,建完后由QueryCoord分配到QueryNode加载。整个流程走完,数据才真正“可查”。

这里有个时序细节值得注意:Flush不等于立即可查。Flush只是封存,还要建索引、加载,才能被查询命中。所以如果你写完数据立刻查不到,先确认Flush有没有触发(看DataCoord日志或Segment状态),再看索引建完没、QueryNode加载完没。这个链路任何一环卡住,都会表现为“查不到”。

3.4 手把手复现一次写入并观察Segment状态变化

光讲理论不够,我们实际走一遍。假设你有一个本地部署的Milvus(单机版即可),建一个Collection,插入一批数据,然后观察Segment状态变化。

from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType import random connections.connect(host="localhost", port="19530") fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True), FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=128), FieldSchema(name="tag", dtype=DataType.VARCHAR, max_length=64), ] schema = CollectionSchema(fields=fields) col = Collection(name="demo_seg", schema=schema) # 插入一批数据 batch = [ [i for i in range(1000)], [[random.random() for _ in range(128)] for _ in range(1000)], [f"tag_{i}" for i in range(1000)], ] col.insert(batch) col.flush() # 手动触发flush,便于观察

执行后,用col.get_query_segment_info()或管理接口查看Segment列表,你会看到至少一个Sealed Segment。如果不调flush,数据会停留在Growing状态,等DataCoord按阈值触发。这个实验建议你亲手做一遍,观察flush前后Segment状态、对象存储里的Binlog文件数量变化,比看十篇文档都管用。

4. 查询链路:分散的Segment如何拼出最终结果

4.1 查询请求的广播与归并

查询进来先到Proxy,Proxy根据Collection的Segment分布,把请求广播到持有相关Segment的所有QueryNode。每个QueryNode在本地Segment上做向量检索,算出TopK,返回给Proxy。Proxy做全局归并(多路归并取TopK),再返回客户端。

这个“分散检索+全局归并”的模式是分布式向量检索的标准做法。它的性能瓶颈往往不在单段检索,而在归并阶段和网络传输——如果Segment数量太多,每个都返回TopK,归并的数据量会很大。所以减少Segment数量(通过Compaction)对查询性能有直接帮助。

4.2 删除标记在查询时如何生效

前面说删除是逻辑标记,那查询时怎么过滤?答案在DeleteBinlog和内存里的删除位图。QueryNode加载Segment时,会把DeleteBinlog里的删除主键读进来,构建一个删除位图(或布隆过滤器)。查询时,检索出的候选结果要过一遍这个位图,命中删除标记的直接丢弃。

这里有个性能细节:删除位图是常驻内存的,删除数据越多,位图越大,内存占用越高。而且删除比例高时,检索出的候选大量被过滤,有效召回率下降,表现为“明明有数据却查不满TopK”。这就是为什么删除多的Collection要尽快Compaction——不仅回收空间,也恢复查询效率。

4.3 索引加载与ChunkCache的配合

QueryNode加载Segment时,向量数据可能不在本地,需要从对象存储拉。为了减少重复拉取,Milvus用了ChunkCache做本地缓存,把热数据留在内存或本地磁盘。索引本身也是加载的一部分——Sealed Segment建好索引后,QueryNode加载的是索引文件而非原始向量,这样检索才快。

实操中如果发现查询延迟抖动大,先看ChunkCache命中率。命中率低说明缓存不够或数据太分散,可以考虑加内存、调缓存策略,或者通过Compaction减少Segment数量让数据更集中。

5. Compaction与垃圾回收:空间回收的幕后机制

5.1 Compaction的两种类型与触发逻辑

Compaction分两类:小段合并(minor compaction)把多个小Sealed Segment合并成大Segment,减少段数量、提升查询效率;删除清理(major compaction)把带大量删除标记的Segment重写,物理剔除已删除数据、回收空间。两者都由DataCoord调度,触发条件包括段数量、删除比例、数据过期时间等。

合并的过程是:读出源Segment的数据,过滤掉删除的,写入新Segment,更新元数据指向新Segment,旧Segment标记失效等待GC。这个过程有写放大——合并期间数据被读写一遍,所以调度要控制节奏,避免合并风暴拖垮集群。

5.2 为什么删除后空间不释放:一次讲透

这是被问得最多的问题。原因链条是:删除操作只写DeleteBinlog(逻辑标记)→ 原Segment的InsertBinlog还在 → 对象存储占用不变 → 只有Compaction重写Segment时才物理剔除 → 旧Binlog被GC才真正释放空间。所以“删除后空间不降”是正常现象,要等Compaction和GC走完。

那为什么Compaction不立即触发?因为合并有成本,频繁合并会造成写放大和资源争抢,所以DataCoord按策略延迟调度。如果你确实需要立即回收,可以手动触发compaction,但生产环境要谨慎,避免影响在线查询。

5.3 GC的清理范围与安全边界

GC负责清理失效的Binlog、过期的消息、无用的元数据。它的安全边界是“只清理元数据已标记失效且超过保留期的数据”,确保不会误删还在被引用的文件。GC有延迟是刻意的——给正在进行的查询和合并留出安全窗口,防止“刚标记失效就被删、结果还有查询在读”的竞态。

排查空间问题时,顺序是:先确认删除是否生效(元数据里DeleteBinlog有没有)、再看Compaction有没有跑(DataCoord日志)、最后看GC有没有清理(对象存储文件数变化)。三步定位,基本不会错。

6. 常见问题与排查速查

6.1 写入成功但查不到数据的排查路径

这个现象最常见,排查按链路走:消息队列有没有堆积(consumer lag高说明DataNode没消费到)→DataNode有没有刷Binlog(看日志和对象存储文件)→Flush有没有触发(Segment状态是否Sealed)→索引有没有建完(IndexNode状态)→QueryNode有没有加载(QueryCoord分配状态)。五步下来,卡在哪一环一目了然。

6.2 Segment数量异常增长的成因与治理

Segment暴涨通常是Flush太频繁或Compaction没跟上。检查Flush触发阈值是否设得太小、写入是否过于分散(Shard太多导致每Shard数据少、频繁封存)、Compaction是否被禁用或资源不足。治理手段是调大Flush阈值、减少不必要的Shard、确保Compaction有足够资源运行。

6.3 查询延迟抖动的几个高频原因

延迟抖动先分内因外因。外因看ChunkCache命中率和对象存储拉取延迟;内因看Segment数量(太多导致归并慢)、删除比例(太高导致过滤多)、索引类型是否匹配场景。用监控把这几项拉出来对比,基本能锁定。

现象优先排查常见根因
写入后查不到消息队列堆积、Flush状态消费滞后或未触发Flush
删除后空间不降Compaction、GC状态逻辑删除未物理回收
查询延迟抖动ChunkCache命中率、Segment数缓存不足或段过多
Segment暴涨Flush阈值、Compaction资源封存过频或合并滞后

6.4 几个我踩过的坑和对应经验

第一个坑:早期以为flush完就能查,结果写完立刻查报空,后来才明白还要建索引和加载,中间有延迟。第二个坑:删除大量数据后盯着磁盘等空间释放,等了半天没动静,后来才知道要等Compaction。第三个坑:Shard设太多导致每Shard数据少、Segment碎,查询归并开销大,后来按数据量合理规划Shard数才稳住。

这些坑的共同点是:不理解存储链路就会误判现象。一旦你把“写入—Binlog—Flush—索引—加载—查询—Compaction—GC”这条链在脑子里串起来,任何异常都能快速定位到具体环节,而不是瞎猜。这也是我建议每个用Milvus的人都把存储流程吃透的原因——它不只是面试加分项,更是日常排障的基本功。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 3:25:08

深度学习重构语音降噪:技术脉络、关键配方与商用落地

语音降噪这行,算算也有六十多年历史了。从最早的模拟滤波器、维纳滤波,到后来的谱减法、子空间法,再到今天几乎被深度学习全面接管,这中间的转变不是简单的技术升级,而是整个治学思路的一次断代:以前我们在…

作者头像 李华
网站建设 2026/11/2 3:25:04

Kafka消息顺序性机制与分区设计解析

1. Kafka消息顺序性机制深度解析Kafka作为分布式消息系统的标杆产品,其消息顺序保证机制一直是开发者关注的焦点。标题中提到的"Kafka本身只保证单个分区内的消息是有序的"这句话,实际上揭示了Kafka在吞吐量与顺序性之间做出的经典权衡设计。我…

作者头像 李华
网站建设 2026/11/2 3:25:04

AI技术如何重塑软件测试:10大颠覆性应用

1. 项目概述:AI技术如何重塑软件测试行业作为一名在软件测试领域摸爬滚打十年的老兵,我亲眼见证了从手工测试到自动化测试的演进历程。而如今,AI技术的爆发式发展正在给这个行业带来前所未有的变革。最近半年,我系统性地研究了市面…

作者头像 李华
网站建设 2026/11/2 3:24:42

格力物流机器人技术解析与智能制造实践

1. 项目背景与战略意义格力电器作为国内家电行业的龙头企业,近年来在智能制造领域的布局引人注目。这次自主研发物流机器人的内部应用,标志着格力从传统制造向智能制造的实质性跨越。从公开资料来看,格力早在2017年就成立了智能装备公司&…

作者头像 李华
网站建设 2026/11/2 3:22:40

MATLAB R2013b 安装全攻略:从环境准备到验证跑通

简介:这份PDF资料面向需要在Windows平台搭建MATLAB R2013b开发环境的初学者与工程技术人员,针对旧版本软件安装流程繁琐、密钥与授权配置易出错等问题,提供了一份可对照执行的安装指引文档。资源包共1个文件,为PDF格式&#xff0c…

作者头像 李华
网站建设 2026/11/2 3:22:21

统一内存多模型常驻实战:128G也不够,资源调度是关键

128G统一内存,听起来是本地模型玩家的终极配置。可真跑到5个模型常驻内存的工作流时,我发现内存自由完全是相对的。我日常要同时养活代码补全、RAG嵌入、语音转写、对话主模型、待评估新模型这5个角色,最早是每个模型一个终端窗口&#xff0c…

作者头像 李华