news 2026/10/9 8:50:01

Milvus多租户方案实战:用Partition Key实现数据隔离与高效检索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Milvus多租户方案实战:用Partition Key实现数据隔离与高效检索

刚接触向量数据库的时候,我一度以为多租户只是个"数据库层面顺手支持一下"的小功能。真正把带十来个企业客户的RAG服务推进生产之后才发现,多租户方案的选型能直接决定你半夜被叫起来几次。Milvus这类向量数据库也一样,看起来无非是加个tenant_id字段过滤,但要是架构没想清楚,数据隔离、查询性能、冷热资源分配全都会变成雷。

这篇文章把我在实际项目里折腾多租户的经验完整复盘一遍,会先讲清楚多租户的概念和主流架构模式,然后重点拆解Milvus上三种可实现的多租户方案,最后给出一套基于Partition Key的完整实战代码。代码不是贴上去好看的,参数怎么调、哪里是坑,我都会写明白。适合正在做SaaS化AI应用、知识库产品,或者想把Milvus从单客户改成多租户方案的同学参考。

1. 多租户到底在解决什么问题

1.1 一个故事讲清楚多租户

你可以把多租户理解成"合租"。

一套房子整租给一家人,所有空间随便用,这是单租户模式——客户和资源都是独占的,爽是真的爽,贵也是真的贵。合租则是一套房子里住好几户,客厅、厨房、卫生间共享,但每户有自己独立的房间和储物空间,谁也不能随便翻别人的东西。多租户就是软件界的合租:一套系统同时服务多个客户,每个客户在共享的基础设施上拥有独立的"房间"——也就是自己的数据、配置和访问权限。

我经历过的真实场景是这样的:产品刚上线时第一个客户很顺利,我们给他单独部署了一套Milvus服务,数据完全不互相干扰。第二个、第三个客户陆续进来,问题开始不受控——每来一个客户就起一套独立Milvus,同一台机器上跑多个实例,内存直接爆炸。备份脚本、监控面板、告警规则每个实例都得配一份,哪个实例挂了一次要挨着排查。后来忍无可忍,才决定认真设计多租户架构。

这不是某个团队独有的问题。你一旦做SaaS,就必然面对"用尽量少的资源服务尽量多的客户"和"客户之间不能互相影响"这对矛盾。多租户正是解决这对矛盾的核心手段。

1.2 为什么不用"每个客户一套系统"

有人会觉得,既然独立部署最省心,那就一直给每个客户部署一套呗,反正现在容器化很方便。这个思路在客户数量少、体量大的B端场景不是不能用,但你得想清楚它的代价。

先看这张对比表:

对比维度每客户一套系统多租户共享系统
资源成本线性增长,每增一个客户都要买机器新增客户的边际成本很低,几十上百也能扛
运维复杂度高,每套系统都要单独升级、备份、监控低,一套系统统一维护,版本一致
数据隔离性天然物理隔离,最强靠架构设计实现逻辑隔离,需要约束自己
定制化能力强,可以给单个客户改任何东西弱,改动会影响所有租户,需要抽象出配置项
扩展性横向加机器,粗暴但直接需要根据租户量级做容量规划,有一定复杂度

当客户从2个变成20个,独立部署的运维成本是指数上升的。而多租户系统的本质,就是把"每来一个客户就做一遍的事"提前变成"一次性能力"。隔离、配额、权限、监控,全部做成平台能力,后续新增租户只需要开通账号、分配额度,几分钟搞定。这也是主流SaaS产品几乎都走多租户路线的根本原因。

1.3 多租户的三大核心诉求

多租户不是"把多个客户的数据放在一个库里"这么简单。真正落地时要满足三个核心诉求:

第一,数据隔离。租户A无论如何都不能读到租户B的数据。这既是业务底线,也是合规底线。注意,数据隔离不等于物理隔离,逻辑隔离也能做到"读不到",但前提是过滤条件在所有数据访问路径上都被强制执行,一个漏洞都不能有。

第二,资源隔离。一个租户跑大量查询或者灌入海量数据时,不能让其他租户的延迟跟着劣化。完全做到资源强隔离很难,通常会用配额、优先级、分区等手段做软隔离,至少保证"互相干扰有限"。

第三,个性化配置。每个租户可能有不同的模型、不同的阈值、不同的权限结构。多租户架构要把这些差异抽象成可配置项,而不是代码里写死。否则每接入一个新客户就改一次代码,多租户就失去了意义。

2. 多租户架构设计的三种经典模式

2.1 独立数据库模式

也叫Database-per-Tenant,是最简单粗暴的模式:每个租户一个完全独立的数据库实例,或者至少一个独立的数据库。

这种模式的优点非常明显——隔离性最强,一个租户的异常查询、异常数据、甚至误删数据,都不会影响到别人。针对单个租户做备份、恢复、数据导出也很方便,甚至可以给大客户单独调优数据库参数。缺点也够呛:数据库实例多了之后,连接数、备份任务、版本升级全是运维负担,资源利用率还很低,小租户空占一个库却用不了多少资源。

在实际选型里,这种模式往往用在"租户之间合规要求非常严格、不能共享任何存储"的场景,比如金融、医疗领域的核心数据。如果你的项目属于这类,就别纠结省资源了,安全和合规优先。

2.2 共享数据库、独立Schema模式

这种模式是"同一个数据库实例,每个租户一套独立的表结构"。从数据库层面看,大家物理上坐在同一个实例里,但逻辑上是分开的Schema,类似一栋楼里每户有一套独立的单元,大门共用。

它的好处是比独立数据库省资源,数据库实例数量可控,同时还能按Schema给租户做差异化的索引、权限配置。缺点是迁移麻烦,如果一个租户的数据量太大需要单独挪走,Schema级别迁移比整库迁移还要精细;另外同一实例里某个租户写出了一条慢SQL,仍然可能拖垮整个实例上的其他租户。

这种模式比较适合中型SaaS,租户数量大概几十到几百,数据量中等,而且每个租户的表结构确实存在差异。但如果所有租户的表结构完全一样,继续用独立Schema其实是在给自己找麻烦。

2.3 共享Schema、共享表的单库模式

这是最"纯粹"的多租户模式:所有租户的数据放在同一张表里,用一个tenant_id字段区分归属。每次查询都必须带上tenant_id过滤条件,或者让数据库的行级安全策略自动追加这个条件。

这种模式的资源利用率最高,维护也最简单——永远只有一套表结构、一套索引、一套升级脚本。它的核心代价是隔离性全看"过滤条件是否无死角执行"。一旦代码里某个查询漏掉了tenant_id,就是跨租户数据泄露事故。同时,单表数据量会膨胀得很快,必须提前做好分区、索引设计,否则查询性能会全面恶化。

绝大多数SaaS产品最终都选择了这种模式,因为租户数量一旦上千,只有共享表才能撑得住。它对应的工程要求也最高:强制校验租户过滤条件、统一的数据访问层、定期做容量治理,缺一不可。

2.4 三种模式怎么选

选型维度独立数据库共享库独立Schema共享库共享表
资源利用率低中高
数据隔离强度物理级逻辑级逻辑级(依赖强制过滤)
运维复杂度高中低
单租户定制能力最强较强弱
典型租户规模个位数到几十几十到几百几百到上万
适合合规场景强合规中等合规常规SaaS

这里没有绝对的最优,只有匹配度。我个人的判断标准是:先看合规约束,再看租户规模,最后看团队运维能力。合规要求严到必须物理隔离,别节省;租户规模不大但要求灵活,选独立Schema;要做大规模SaaS,踏踏实实走共享表模式,把隔离逻辑做扎实。

3. 当"多租户"遇上向量数据库:Milvus为什么不能照搬传统套路

3.1 向量检索与关系型查询的本质差异

如果你从传统MySQL的多租户经验直接平移过来,特别容易踩坑。因为向量检索和关系型查询的底层逻辑完全不同。

MySQL里WHERE tenant_id = 10是精确匹配,走普通B+Tree索引就能快速过滤出该租户的行,查询范围天然可控。但Milvus里跑的是ANN搜索(近似最近邻检索),它的目标是"在高维向量空间里找最近的K个向量"。如果只用普通字段过滤,检索流程是先做向量计算、再过滤结果,等于把其他租户的向量也拉进来算了一遍,既浪费算力又可能让结果集被干扰。

更麻烦的是,向量索引的结构——无论是IVF还是HNSW——都是针对整个集合的向量空间建立的。如果不同租户的数据混在一起,索引会把所有租户的向量搅在一个图或一堆聚类里,查询时很难做到精准的"只在这个租户的向量子集里搜索"。所以,向量数据库做多租户,需要的不是单纯的字段过滤,而是"过滤条件下推到索引和分段(Segment)层面"的硬能力。

3.2 Milvus多租户的三种实现方案

Milvus里做多租户,主流有三种姿势,我逐个说清楚取舍。

第一种是Collection-per-Tenant,每个租户单独建一个Collection。隔离效果最好,索引、加载、查询互不干扰,还能按租户做精细的权限管理。缺点也很明显:Collection数量一多,元数据管理、资源占用都很可观,超过几十上百个Collection维护起来就开始头疼。

第二种是用Partition加Partition Key。单个Collection内部按tenant_id字段做分区,写入时Milvus通过哈希把同一个租户的数据路由到固定分区,查询时如果带上了租户过滤条件,底层的查询执行器可以只扫描对应分区。这是在"管理成本"和"隔离性能"之间最均衡的方案,也是我在生产环境里最终选定的方案。

第三种是单Collection加普通字段过滤,不建分区。实现最简单,但每次查询都是全Collection扫描,租户多了之后性能和隔离效果都堪忧。如果你只是做个Demo,可以这么干;生产环境强烈不建议。

3.3 方案选型的决策因素

我的建议是,先在纸面上回答三个问题再选方案。

问题一:租户数量级是多少。预计只有二三十个大客户,Collection-per-Tenant没毛病;要做几百上千甚至上万的租户,Collection-per-Tenant就是灾难,必须回到集合内分区的路子。

问题二:某个租户的数据量是否可能远超其他租户。如果存在明显的"超大租户",Collection-per-Tenant更有弹性,因为你可以单独为它扩容、调整索引参数;用Partition Key的话,所有租户共享同一个集合的索引和资源配额,大租户容易变成那个"把整张桌子占满的人"。

问题三:冷热租户的干扰能否接受。共享集合意味着加载到内存的Segment是整个集合的,一旦某个大租户的数据量撑爆内存,其他租户的查询延迟会跟着恶化。

可以说,Collection-per-Tenant更侧重"隔离性",Partition Key更侧重"规模与经济性"。后面我给的实战代码,就是Partition Key方案,这也是目前最通用的一条路。

4. Milvus实战:用Partition Key落地多租户

4.1 环境准备与连接

需要准备的东西不多:一个Milvus服务,以及Python的pymilvus库。我这里用的是Milvus 2.4.x版本和pymilvus 2.4.x,Partition Key功能在2.3版本开始正式可用,版本太旧建议先升级。

Milvus有两种部署形态:Standalone单机模式和分布式集群模式。小规模验证用Standalone模式就够了,它把元数据和数据都放在单机上,足以撑起几千万级别的向量规模;需要横向扩展到亿级以上,再上分布式集群。

连接的代码很简单:

from pymilvus import connections connections.connect( alias="default", host="localhost", port="19530", )

连接成功后,顺手确认一下服务版本:

from pymilvus import utility print(utility.get_server_version())

4.2 设计带租户字段的Schema

接下来要定义一个关键字段:租户ID。在Milvus里,要把这个字段变成Parititon Key,只需要在定义字段时加上is_partition_key=True。

from pymilvus import FieldSchema, CollectionSchema, DataType tenant_id_field = FieldSchema( name="tenant_id", dtype=DataType.INT64, description="租户ID", is_partition_key=True, ) doc_id_field = FieldSchema( name="doc_id", dtype=DataType.INT64, description="文档ID", ) # 假设向量维度为128,实际按你的embedding模型输出维度来 embedding_field = FieldSchema( name="embedding", dtype=DataType.FLOAT_VECTOR, dim=128, description="文档向量", ) schema = CollectionSchema( fields=[tenant_id_field, doc_id_field, embedding_field], description="多租户文档集合", enable_dynamic_field=False, )

这里的is_partition_key=True是整车核心一行。它告诉Milvus:写入数据的时候,不要只把这个字段当成普通属性,而是要在存储层按它的值做分区路由。你可以把tenant_id想象成小区地址,同一个tenant_id的向量被送到同一栋楼,查询时只搜索那栋楼,效率自然显著优于全城搜索。

另外我建议把enable_dynamic_field设为False。开启动态字段固然方便,但生产环境里它会引入不可控的元数据膨胀,也会让Schema演进变得混乱。提前定义好字段,让写入和查询都是"确定的",这是多租户系统稳定性的基础。

4.3 创建带Partition Key的集合

有了Schema,创建集合时指定分区数量,这一步特别关键:

from pymilvus import Collection collection = Collection( name="doc_tenant", schema=schema, num_partitions=16, ) print("集合创建完成:", collection.name)

注意num_partitions这个参数。它决定了集合内部会被切成多少个物理分区,Partition Key就是通过哈希把不同tenant_id映射到这些分区上的。取值范围是1到64,默认是16。我见过不少人把这个值随便设,设得不好后面查询性能会很尴尬,这一节最后我会单独展开讲怎么选。

创建完成后建索引。索引类型我用HNSW,召回和延迟平衡得比较好:

index_params = { "index_type": "HNSW", "metric_type": "IP", "params": {"M": 16, "efConstruction": 200}, } collection.create_index( field_name="embedding", index_params=index_params, ) collection.load()

这里说明一点:load()是把整个集合加载到内存。Partition Key模式下,加载的粒度是Collection级别,所以这个动作会把这个集合所有分区的索引都加载进来。如果租户总量很大而内存有限,你就要考虑按冷热拆分集合,而不是只靠Partition Key解决所有问题。

4.4 写入与检索的完整代码

写入数据时,每条记录务必带上tenant_id字段。Milvus会根据它的哈希值自动决定数据进入哪个分区。

import random # 模拟数据:给两个租户各写入几十条 for tenant in (10, 20): data = [] for i in range(50): data.append({ "tenant_id": tenant, "doc_id": i, "embedding": [random.random() for _ in range(128)], }) collection.insert(data) collection.flush()

flush()很关键。不执行flush,数据可能还停留在内存缓冲区里,后续查询或删除可能读不到最新数据。批量写入之后记得刷一下。

查询的时候,真正的重点来了——必须带上租户过滤表达式:

search_params = { "metric_type": "IP", "params": {"ef": 64}, } query_vector = [random.random() for _ in range(128)] results = collection.search( data=[query_vector], anns_field="embedding", param=search_params, limit=10, expr='tenant_id == 10', ) for hits in results: for hit in hits: print( "doc_id:", hit.entity.get("doc_id"), "tenant_id:", hit.entity.get("tenant_id"), "相似度:", hit.score, )

你可以把expr='tenant_id == 10'这个条件理解成给查询加了"只准进10号楼"的通行证。底层执行时,Milvus会通过Partition Key的路由信息,只搜索tenant_id == 10对应分区内的向量索引,而不是全集合暴力扫描。所以查询效率不会随着租户数量增长而线性劣化,这也是Partition Key相对普通字段过滤最大的优势。

这里必须反复强调一个点:Partition Key是"软隔离",不是"自动隔离"。就算字段设计得再漂亮,查询代码里漏写租户过滤表达式,Milvus还是会扫全集合,照样会串数据。实际工程中,我强烈建议封一层自己的数据访问层,所有search和query都强制注入当前登录租户的条件,禁止业务代码直接裸调Milvus接口。

4.5 参数选择:num_partitions到底设多少

很多人看到num_partitions的第一反应是"越多越好,分区多了查询可以并行嘛"。这个理解是片面的。

分区数确实是租户级隔离的"格子数",理论上多设一些可以降低每个格子里的数据量。但Milvus内部每个分区会对应若干Segment,Segment数量一多,查询时的调度开销、内存里的索引加载开销都会变大。设成64的话,通常会带来明显的资源浪费,尤其是租户数量不多的时候,大量分区空转,白白占用管理成本。

我的经验公式是这样的:先估算最大租户数,让分区数大于预计租户数即可,但不要超过16到32这个量级。比如预期一百个租户,设16个分区,哈希能较好地分散数据;预期三五百个租户,可以考虑32;再往上,或者单租户数据量巨大,就要考虑从Partition Key切换到Collection-per-Tenant这类更细粒度的方案了。

另外,num_partitions在Collection创建之后就固定了,不能随意修改。所以一定要在创建前认真做容量规划,而不是先随便设一个,等数据量大了再后悔想改——到时候只能重建集合并迁移数据,代价相当大。

5. 常见坑与排查速查

5.1 查询不强制租户过滤,等于裸奔

这是多租户落地里最危险的一个坑,没有之一。

我在代码上线的初期就犯过这个错:底层封装了一个公共查询函数,当时图省事,允许调用方不传租户ID。结果有一个报表服务调用的时候漏传了,直接全集合扫描,把一个租户的查询结果返回尴尬地送到了另一个租户的页面上。幸好当时还在内测,没有流到真实客户那里,否则就是妥妥的数据泄露事故。

后来的整改办法有三条,缺一不可:

  • 数据访问层强制要求租户ID参数,缺失直接抛异常;
  • 所有search、query、delete操作都经过同一层封装,不直接暴露裸Milvus接口;
  • 建立巡检任务,定期抽样检查自定义表达式里是否包含租户条件。

5.2 分区数量设得越大越好?小心资源反噬

前文已经说过,num_partitions要控制在合理范围。这里再补充一个真实表现:我一开始为了图"隔离"设了64个分区,但实际只有两个真实租户,结果每个分区都只有零星数据。Segment数量爆炸,查询性能不但没变快,反而因为要管理的空片段太多,延迟涨了接近一倍。

对于大多数中小规模SaaS来说,16是比较稳妥的起步值。哪怕租户数量暂时很少,固定用16也不会浪费太多资源,后续租户涨起来还能覆盖。

5.3 删除租户数据,别以为drop掉就完事了

当你要清退一个租户,直觉操作是删除该租户的数据。但用Partition Key方案时,你没法像Collection-per-Tenant那样直接drop掉整个Collection。你只能执行delete表达式:

collection.delete(expr='tenant_id == 20')

这里有个底层知识要搞清楚:Milvus的delete是逻辑删除,会把对应记录打上删除标记,真正的物理清理要等后台Compaction去合并Segment。所以在数据量很大的情况下,删除后不会立刻看到磁盘空间释放,查询如果遇到尚未compaction的陈旧segment也可能短暂出现"还能查到已删数据"的情况。

我踩过这个坑之后养成了两个习惯:一是删除前先确认expr命中了正确的租户,二是删除后配合调用collection.compact()触发合并,或者设置合理的Compaction调度窗口。

5.4 冷热租户互相影响,跨Collection拆分才是终极解

Partition Key方案里,所有租户共享同一个Collection的索引和内存。这意味着两个问题:热门租户的大量查询会把内存和CPU打满,影响冷门租户的延迟;同时,冷门租户的数据也会占着索引加载的内存,让热门租户可用内存变小。

如果你的租户之间冷热差异很明显,我建议做混合方案:默认租户留在共享的Partition Key集合里,少数大客户或高热度客户拆出来,单独创建Collection,独占索引和资源。这样既保留了共享集合的规模经济性,又给重点客户提供了稳定性的保障。

5.5 排查速查表

现象可能原因排查动作
查询结果串了租户表达式里漏写租户条件检查自定义表达式,确认包含tenant_id == xx
租户越多查询越慢num_partitions设置不合理,或索引参数过小检查Collection的num_partitions和索引参数
删除租户后空间久不释放Milvus逻辑删除未触发Compaction调低Compaction触发阈值或手动compact()
某个租户查询把全集合内存打满大租户数据量过大,与冷租户混在一起将大租户单独拆Collection
写入Performance突然变差未定期flush,Segment积累过多批量写入后及时flush,合理控制批次

最后,再分享几个实操层面的收尾建议

如果你现在正处于选型阶段,我的建议是:先别急着上Collection-per-Tenant,虽然它最"直观"。租户数量只要不是少得可怜,就用Partition Key方案起步,把所有租户放一个集合里,靠租户字段做分区。这样做的好处是代码路径单一、备份和监控都简单,出现问题时排查范围也不大。将来如果某一个大客户确实需要独立的资源环境,再把它迁移到独立的Collection里,此时你当初预留的tenant_id字段会让你少改无数代码。

还有一个很多人忽略的细节:多租户不是上线后补一补就能做好的,它应该从第一张Schema就开始设计。等到数据灌进去了再回头改造,无论是数据迁移还是代码改造,成本都会让团队非常难受。

我在生产环境里用这套思路已经稳定支撑了几十个租户,平时几乎不需要为"租户隔离"操心。希望这篇东西能让你在规划自己的Milvus多租户方案时,少走一些我已经走过的弯路。

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

SpringBoot仓储管理系统实战:从需求到部署的全流程解析

做了几年Java后端,也带过不少毕业设计项目,我太熟悉“SpringBoot 仓储管理系统”这个选题了。你搜一下“SpringBoot、仓储管理系统、智能仓库、库存管控、物料追踪系统”这几个关键词,跳出来的基本都是同一类东西:用 SpringBoot 写…

作者头像 李华
网站建设 2026/10/9 8:47:21

T3 Stack 全栈实战:从 create-t3-app 到部署,绕过那些默认配置的坑

t3code 这个代号,是我当时给一个全栈 Web 应用随手起的仓库名。t3 指的不是数字三,而是前端圈里传得很广的那套 T3 Stack:TypeScript、Tailwind CSS、tRPC,再让 Next.js 当胶水把前后端串起来。项目本身是一个内部用的小型内容管理…

作者头像 李华
网站建设 2026/10/9 8:46:28

二分查找与二分答案:C语言实现、边界处理与竞赛实战

P8088,『JROI-5』Autumn,难度普及,标签里简简单单四个字:二分查找。第一次看到这道题的人,多半觉得这就是一道套模板的水题。但带过几年算法竞赛我就明白,凡是在“普及”这个档位被反复讨论的二分题&#x…

作者头像 李华
网站建设 2026/10/9 8:46:28

二分答案实战解析:从P8088看算法竞赛中的二分查找技巧

很多刚接触算法竞赛的朋友一听到“二分查找”这四个字,脑子里浮现的往往是“在一个有序数组里找一个数”的模板题。但真上了考场,二分查找出场的方式远比这个丰富得多,尤其是当它化身为“二分答案”的时候,整道题的难度和思维量会…

作者头像 李华
网站建设 2026/10/9 8:44:50

Hookify:让Claude-code自动化定制更简单的插件管理器

Claude-code 的玩法这两年变化很快。很多人装了 npm 上的anthropic-ai/claude-code,敲几行命令让它在终端里写代码、改文件,觉得已经很顺手。但真正让 Claude-code 从一个“有点聪明的命令行助手”变成“能嵌进自己工作流里的自动化引擎”的关键&#xf…

作者头像 李华
网站建设 2026/10/9 8:44:29

十年微信聊天记录本地导出与AI分析实战:SQLite+Python全流程

别再翻烂手机找聊天记录了。我这个习惯从QQ时代延续到微信十年,中间换过三部手机,每次迁移聊天记录都像在打一场必输的仗——不是白屏闪退,就是几百个语音条变成“已过期”。最近我终于把这事彻底解决了:所有聊天记录全量导出到电…

作者头像 李华