刚接触向量数据库的时候,我一度以为多租户只是个"数据库层面顺手支持一下"的小功能。真正把带十来个企业客户的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多租户方案时,少走一些我已经走过的弯路。