最近Dify社区版1.10一发布,多租户相关的话题又热闹起来了。说起来多租户不算新概念,在SaaS领域早就被讲烂了,但一旦落到AI场景里和向量数据库、RAG知识库、AI客服这些具体业务结合,隔离怎么做、权限怎么控、性能怎么保,每件事都会变得特别具体。这篇文章我想把多租户从概念到架构再到Milvus实战代码完整走一遍:先讲清楚我们到底在防什么问题,再谈架构选型里的取舍,最后给出三套在Milvus上真正能跑的落地代码,以及我实际运维中踩过的坑。适合正在做知识库平台、SaaS后台、企业内部AI系统,或者单纯想给RAG服务加上多租户隔离的同学参考。
1. 认识多租户:先搞清楚它到底在解决什么问题
1.1 同一套系统为什么非要拆成多租户
我最早接触多租户不是在看技术文章,而是做SaaS续费系统时被现实逼出来的。公司有一套客服工单系统,十几个品牌客户共用,业务方提的需求也很直接:A品牌的数据,B品牌绝对不能看到。当时研发团队第一反应是“加个tenant_id字段不就行了”,真做下去才发现,过滤条件只是最后一步,前面还有数据库连接池要不要按租户拆、缓存要不要带上租户维度、任务队列会不会被某个大租户占满等一系列问题。
所谓多租户,本质就是一套软件服务如何同时服务多个互不信任的业务方。这里的关键词是“互不信任”而不是“多个用户”。普通用户体系里,用户A和用户B虽然彼此独立,但他们都在同一个客户的授权边界内;而多租户场景下,租户与租户之间是组织级的隔离关系,A租户的运营人员不能看到B租户的业务数据,甚至两个租户共用同一个底层模型,也不能通过检索把对方的内容捞出来。
所以侧重点很明确:多租户不是功能,而是一种资源组织方式。要么给每个租户一套独立的环境,要么大家共享底层服务但通过机制保证数据和权限不串。前者简单但是贵,后者省钱但技术复杂。绝大多数的实践案例,其实都在这两者之间找一个平衡点。
1.2 数据隔离、性能隔离、安全隔离,一个都不能少
很多人一提多租户就只想到数据隔离,其实要拆开看的话,至少要解决三个层面的隔离问题。
数据隔离是最基本的需求,也是最容易暴露事故的环节。在传统关系型数据库里,每个查询带上where tenant_id = xxx,结果集自然就分开了。到了向量检索场景,问题会更隐蔽:你在做相似度检索时,如果候选数据集是全局的,那即使最后过滤了tenant_id,也难保在召回阶段就已经把别的租户的高相关向量拉进来了。更稳妥的做法是在检索前就限定租户的候选分区,让向量搜索只在当前租户的数据范围内进行。
性能隔离是第二个容易翻车的点。最典型的场景是:早上十点,某个大客户开始批量化导入全量文档,几百个collection同时写入、建索引、flush,结果其他租户的检索延迟从20毫秒直接飙升到500毫秒,客服那边所有机器人全部超时。这不是数据库出了故障,而是资源被一个租户吃掉了。性能隔离的核心不是限制每个租户用多少CPU,而是保证单租户的突发负载不会破坏整体的服务等级协议。
安全隔离往往被忽视,但做内部AI平台的时候反而最敏感。比如集团内有四条业务线共用一个知识库系统,A业务线的小组长登录后,理论上只能看到自己团队的文档和检索记录,操作审计日志也不能暴露给其他业务线。这种场景下,单纯靠业务代码里的租户过滤就不够了,还需要在数据访问层做权限模型设计,甚至让开发人员都无法通过后门绕过。
1.3 多租户是一个“共享-隔离”的连续谱
多租户方案不是非黑即白。我习惯把隔离等级看成一个连续谱,从完全隔离到完全共享,中间有四种常见形态:
| 方案 | 隔离强度 | 资源成本 | 运维复杂 | 典型场景 |
|---|---|---|---|---|
| 独立部署实例 | 最强 | 最高 | 最高 | 大客户专有部署 |
| 独立数据库/集群 | 强 | 高 | 中 | 金融、医疗等强合规 |
| 共享数据库独立Schema | 中 | 中 | 中 | 租户多但体量可控 |
| 共享库共享表+租户ID | 弱 | 低 | 低 | 海量小租户的SaaS |
这个表格在传统数据库语境下很多人都见过,但如果放到向量数据库场景,它的对应关系就变成了:Collection per Tenant相当于独立数据表,Partition Key方案类似于共享表加租户ID字段,而RBAC权限控制则解决安全隔离那一层。后面第3章的代码实战,就是围绕这三个层次展开的。
选型的时候我建议大家记住一个判断逻辑:**隔离越强,资源池化率越低,成本越高;隔离越弱,架构越省,但出问题的概率越大。**没有绝对正确的方案,只有适合你当前业务阶段的方案。
2. 架构选型:多租户在不同层的处理方式
2.1 应用层:租户身份怎么进来、怎么往下传
多租户的起点在应用层。你首先得解决一个问题:一个请求进来,系统怎么知道它属于哪个租户?
常见的做法是在网关层或者认证中间件里解析token,从JWT的claims或者会话信息中取出tenant_id,塞进请求上下文。到了微服务架构里,这个租户ID还要通过HTTP头或者RPC元数据一路传递下去。像gRPC的metadata、HTTP的X-Tenant-ID这些约定,在很多团队里都是基础设施的一部分,每个服务启动时先从上下文中读出tenant_id,再交给数据访问层使用。
这个环节最大的坑是硬编码。我见过不止一个项目,业务代码里写死了某个测试租户的ID,或者从配置文件里读了一个default_tenant,导致所有请求都落在同一个租户的数据上。排查这类问题非常痛苦,因为代码逻辑本身没毛病,只是租户上下文传丢了。
我自己的实践是:在统一入口组件里把tenant_id解析好,封装成上下文对象,业务代码只从上下文读取,不自己解析token。如果发现某个功能没走统一入口,就说明设计上已经出问题了。另外,缓存Key和数据操作语句里的租户维度,必须由框架自动拼接,不能指望业务开发手动加。
2.2 数据层:独立库、独立Schema、共享表该怎么选
数据层是大多数团队纠结最久的地方。三种方案各有利弊:
独立库隔离性最好,恢复、迁移、备份都方便,比如某一个租户的数据量特别大,可以直接单独给他扩容。缺点是数据库连接数会被放大,每个库都要独立运维,租户一多,光建库建账号就是一笔不小的运维负担。
共享库独立Schema比独立库省一点,能共用一个数据库实例,schema之间逻辑隔离。但PostgreSQL的schema和MySQL的database在性能资源上其实共享得很彻底,一个租户的慢查询照样拖垮整个实例。
共享表加租户ID最省钱,成千上万个租户都能塞进一张表,但所有租户都挤在同一个存储引擎上,索引膨胀、锁竞争、大查询相互影响的问题会逐渐暴露。
做选型我一般会问三个问题:
- 租户数量级是多少?几百和几十万是完全不同的方案。
- 单租户数据量差异大不大?如果有一个租户的数据量超过其他所有租户之和,独立库是更省心的选择。
- 合规要求强制隔离吗?如果强制,那就不要考虑共享表了。
这三个问题一过,数据层方案基本就能定下来。
2.3 缓存层和检索层:容易被忽略的两个细节
传统多租户方案讨论到最后,往往还会忽略缓存和检索这两个层。
缓存层的问题是缓存Key必须带上租户维度。如果不带,一个租户的查询结果被另一个租户命中,那不只是数据串了,连用户画像、推荐结果、个人设置都可能串。我见过一个真实的案例:做内部工具时Redis key只用了id+query,结果A部门的人搜出来的文档其实是B部门上传的,用户还以为是产品做得不好,其实是缓存设计漏了租户维度。
检索层是更特殊的存在。在传统关系库里,一个where tenant_id = xxx就能把范围缩到当前租户;但向量检索不一样,它的核心是“在候选集里找最近的N个向量”,如果候选集是整个Collection,那过滤只能发生在召回之后,性能开销会很大。所以向量数据库的多租户落地,关键不是“怎么加过滤条件”,而是怎么让过滤条件直接决定检索的候选范围。这也是第3章会重点讲分区键的原因。
顺带说一句,很多团队把多租户做成了“所有租户共用一张大表,检索时用embedding相似度去全表捞一遍,最后再filter”。这个方案在小数据集上看不出问题,数据量一旦过百万,延迟会快速恶化,而且租户越多,互相干扰越明显。
3. Milvus多租户实战:三套代码与选型建议
3.1 为什么选Milvus落地多租户
聊完架构层面的取舍,我们把场景聚焦到向量数据库。为什么单独挑Milvus来讲?因为它是目前开源社区里成熟度最高、多租户相关功能最完整的向量数据库之一:
- 支持standalone和分布式集群两种部署,本地开发可以先用standalone模式快速验证,生产再平滑切到分布式架构;
- 2.3版本之后原生支持分区键(Partition Key),可以做物理级的数据裁剪,不是靠执行过滤条件碰运气;
- 有完整的RBAC(基于角色的访问控制)体系,适合做跨租户、跨项目的权限管控;
- pymilvus的API迭代很成熟,写起来比直接调HTTP接口舒服很多。
Milvus毕竟不是传统关系型数据库,它的多租户方案不能照搬MySQL那套成熟套路——表可以随便建几十万张,Collection如果无限膨胀,元数据管理和加载调度都会出问题。所以接下来我给的三套方案,分别对应不同的租户规模和隔离需求。
3.2 环境准备:本地起一个Milvus standalone
先准备环境。如果只是学习验证,本地用docker compose起一个Milvus standalone就够了:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.9/milvus-standalone-docker-compose.yml -O docker-compose.yml sudo docker compose up -d启动后确认端口19530可用。然后安装Python客户端:
pip install pymilvus连接Milvus:
from pymilvus import connections connections.connect( alias="default", host="localhost", port="19530" )默认情况下不开启认证,也就是不需要用户名密码就能连上。如果后续在docker-compose里配置了authorizationEnabled,需要显式带上root账号去连。还没安装Milvus的话,也可以先用Milvus Lite本地文件模式把代码逻辑跑通,再把连接串替换成正式环境,两边API基本一致,这个我实测下来很顺手。
3.3 方案一:Collection per Tenant,最粗暴但也最清晰
第一个方案,每个租户一个独立Collection。适合租户数量不多(几十到几百)、单租户数据量大、租户间数据天然需要物理隔离的场景。
先定义统一的CollectionSchema。这里有个实践要点:所有租户的Collection必须用同一套Schema和索引参数,否则后续做跨租户统计分析或者统一升级索引时会非常痛苦。
from pymilvus import ( Collection, CollectionSchema, FieldSchema, DataType, utility ) DIM = 768 # 具体看你用的embedding模型 def build_schema(): fields = [ FieldSchema(name="pk", dtype=DataType.INT64, is_primary_key=True, auto_id=True), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2048), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=DIM), ] return CollectionSchema(fields, description="knowledge base schema") def get_or_create_collection(tenant_id: str): name = f"kb_{tenant_id}" if utility.has_collection(name): return Collection(name) collection = Collection(name=name, schema=build_schema()) collection.create_index( field_name="embedding", index_params={ "index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200} } ) collection.load() return collection写入时只需要拿到对应租户的Collection对象,插入和检索都不用再关心其他租户的存在:
# 租户A写入 col_a = get_or_create_collection("tenant_a") col_a.insert([{"text": "A租户的知识文档分块", "embedding": [0.1] * DIM}]) col_a.flush() # 租户A检索 query_vec = [0.2] * DIM results = col_a.search( data=[query_vec], anns_field="embedding", param={"metric_type": "COSINE", "params": {"ef": 128}}, limit=10, output_fields=["text"], )这个方案的优点是逻辑简单,隔离最彻底,单租户数据量大时不会影响别人;缺点是Collection数量会随租户数线性增长,当租户规模达到几百上千后,Milvus的元数据管理、list/show collection耗时都会明显上升。所以方案一更适合“为大客户单独开一个知识库”的业务形态,而不是海量小租户的SaaS标准服务。
3.4 方案二:共享Collection + Partition Key,官方推荐的租户隔离方案
如果租户数量很多、单租户数据量不大,推荐用共享Collection加分区键的方式。分区键的原理是把tenant_id映射到物理分区,检索时通过表达式就能裁剪掉大部分分区,只搜索当前租户的数据。这是Milvus 2.3之后官方比较推荐的多租户做法。
建Collection时指定partition_key_field:
fields = [ FieldSchema(name="pk", dtype=DataType.INT64, is_primary_key=True, auto_id=True), FieldSchema(name="tenant_id", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2048), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=DIM), ] schema = CollectionSchema(fields, description="shared knowledge base") collection = Collection( name="kb_shared", schema=schema, partition_key_field="tenant_id" )注意一下版本差异。我在pymilvus 2.4.x里这样写没问题,有的版本要求在CollectionSchema里传partition_key_field,如果你报参数错误,检查一下当前版本的API签名。
写入时每条数据都必须带tenant_id:
data = [ {"tenant_id": "tenant_a", "text": "A租户的知识文档", "embedding": [0.1] * DIM}, {"tenant_id": "tenant_b", "text": "B租户的知识文档", "embedding": [0.2] * DIM}, ] collection.insert(data) collection.flush()检索时通过expr指定租户过滤:
query_vec = [0.15] * DIM results = collection.search( data=[query_vec], anns_field="embedding", param={"metric_type": "COSINE", "params": {"ef": 128}}, limit=10, expr='tenant_id == "tenant_a"', output_fields=["text", "tenant_id"], )这里最关键的是:expr里的tenant_id过滤和embedding相似度检索是同时下推的,不是先算出全局最近邻再过滤,所以性能比裸filter好很多。我在100万向量、单机standalone的测试环境下,带上expr的P99延迟大约在40到80毫秒,相比全量检索的20到30毫秒会有一些损失,但数据量越大,这个损失越值得,因为不这么做,每个租户的检索都在全库上跑,迟早会爆。
需要注意两个限制:一是分区键字段名的命名有规范,不能以$开头,类型建议用INT64或VARCHAR;二是分区数量有上限,租户数量达到数千级别时,需要提前做容量评估。对于绝大多数企业内部系统,这个方案已经足够。
3.5 方案三:共享Collection + RBAC,把权限模型交给Milvus
前两个方案解决了“数据是否隔离”的问题,但“谁有权限操作哪个租户的数据”还没闭环。比如一个SaaS平台,客户A的管理员登录后,理论上只能管理租户A自己的Collection和数据,不能碰其他租户。用代码判断当然能做,但更规范的做法是把权限收归到Milvus的RBAC体系里。
Milvus的RBAC核心元素是:用户(user)、角色(role)、权限(privilege)、资源(object)。流程是先创建用户,再创建角色,给角色授予指定Collection的某个权限,最后把用户绑定到角色:
from pymilvus import utility # 创建用户 utility.create_user("alice", "StrongP@ss123") # 创建角色 utility.create_role("tenant_a_role") # 给角色授予对kb_shared这个collection的检索权限 utility.grant_privilege( "tenant_a_role", "Collection", "kb_shared", "Search" ) utility.grant_privilege( "tenant_a_role", "Collection", "kb_shared", "Query" ) # 把用户加入角色 utility.add_user_to_role("alice", "tenant_a_role")等alice用自己的账号连接Milvus时,需要显式传入user和password:
connections.connect( alias="default", host="localhost", port="19530", user="alice", password="StrongP@ss123" )然后她就可以在自己的权限范围内对kb_shared做检索和查询。但这里有一个非常容易踩的误区:RBAC控制的是能不能操作某个Collection,并不能自动做到行级数据过滤。alice仍然需要在自己的检索代码里写expr='tenant_id == "tenant_a"'。RBAC和分区键是两层独立机制,前者管“能不能进这个门”,后者管“进了门只能看哪个隔间”。
实际项目中我一般这样组合:**分区键负责数据裁剪,RBAC负责身份授权,业务代码里的expr负责最后的行级约束。**三个一起用,才算是完整的租户隔离方案。
3.6 三套方案怎么选,我的判断清单
整理了这么多样本,我总结了一套选型清单,可以直接对照:
| 判断维度 | Collection per Tenant | 共享Collection + Partition Key | 共享Collection + RBAC |
|---|---|---|---|
| 租户数量 | 几十到几百 | 几百到几千 | 任意规模组合 |
| 单租户数据量 | 大 | 中小 | 中小 |
| 隔离需求 | 强物理隔离 | 逻辑隔离 | 逻辑隔离+权限管控 |
| 运维成本 | 高,Collection多 | 低 | 中 |
| 推荐场景 | 独立大客户 | SaaS标准多租户 | 平台型产品、跨团队协作 |
如果你刚开始做,又没有特殊合规要求,我建议优先考虑方案二,在这个基础上再按需增加RBAC。方案一虽然简单,但回头迁移的成本很高,我已经为这个决定付过学费了。
4. 多租户落地的常见问题与排查实录
4.1 Collection数量一旦上百,元数据压力怎么办
方案一最早遇到的问题就是Collection数量膨胀。我做过一个企业知识库,最初每个知识库一个Collection,很快就突破两三百。倒不是说Milvus立刻就不能用了,而是list collections、describe collection、dump时的响应时间肉眼可见地变慢,每次刷UI都感觉卡一下。
排查下来,根因是Milvus的元数据都存放在etcd里,Collection数量增加后,元数据操作的开销跟着涨。解决思路有三条:一是给不活跃的租户做生命周期管理,定期释放不常访问的Collection;二是能用分区键的场景就尽量从Collection per Tenant迁移到Partition Key方案;三是如果确实需要海量Collection,考虑走分布式集群模式,把元数据和查询压力分摊到多节点上。
4.2 filter下推失效,检索延迟突然飙到几百毫秒
共享Collection方案上线后,我遇到过一个典型性能事故:加了expr过滤条件后,检索延迟从30毫秒一路飙到300毫秒以上,一开始以为是数据量太大,后面才发现是tenant_id字段没建索引。
Milvus的expr过滤在没有对应索引时会退化成暴力扫描,数据量一旦过百万,这个退化非常致命。解决方法是给tenant_id字段单独建一个倒排索引:
collection.create_index( field_name="tenant_id", index_params={ "index_type": "INVERTED", "params": {} } )加完索引后,同样的检索请求延迟直接回落到50毫秒以内。这个坑在官方文档里其实有提,但很多人(包括我)都是等到延迟恶化才意识到要补索引。记住:只要你的expr里经常用到某个字段,就给这个字段建索引。
4.3 数据导入与检索之间的可见性窗口
另一个高频问题:某个租户刚上传了一批文档,马上测试检索,结果一条都搜不到。很多人第一反应是代码bug,其实是Milvus的写入可见性机制在“作怪”。
在Milvus里,insert之后的数据是先写进内存缓冲区,调用flush()之后才真正落盘并变得可检索。如果在flush之前就发起search,是查不到刚写入的数据的。我的做法是在写入流程的末尾显式调用flush:
collection.insert(data) collection.flush()同步场景下这样做最稳妥。如果是异步批量导入,可以在批量任务结束前统一flush,避免每条数据都flush导致性能浪费。多租户系统里尤其要注意,因为租户A和租户B的写入是并行的,不能假设A已经flush了。
4.4 权限模型踩坑记录
RBAC相关的坑也值得单独写一下。第一个坑是:创建用户后,该用户默认没有任何权限,连查看Collection元数据都不行。所以在创建完用户后,要立刻把需要的权限一次性授予到位,否则用户一登录就会一脸懵。
第二个坑是不同版本的内置角色名不一样。我在旧版本里见过readonly/readwrite,到了新版本又变成了observer/operator,如果代码里硬编码了内置角色名,升级Milvus版本时很容易悄悄失配。最稳妥的办法是自定义自己的角色,别依赖内置角色。
第三个坑在上面也提到过:RBAC不管行级数据隔离。你给了一个角色Search权限,不代表他只能搜自己租户的数据,他仍然可以搜整个Collection——前提是在代码里不写expr。安全设计上,行级过滤必须放在服务端接口里统一控制,不能让终端用户直接调Milvus接口,否则RBAC就形同虚设了。
4.5 性能实测与调优方向,给一组参考数据
最后给一组我在单机Milvus standalone环境下跑过的参考数据,环境是16核CPU、64G内存、100万条768维向量、HNSW索引:
| 检索方式 | P99延迟 | 备注 |
|---|---|---|
| 全量检索,无expr | 20-30ms | 基线 |
| 带tenant_id过滤,字段无索引 | 150-300ms | 字段扫描开销 |
| 带tenant_id过滤,字段有倒排索引 | 40-80ms | 推荐方案 |
调优方向主要有三个:
- 每个租户的数据尽量按batch插入,10条一插和1000条一插的性能差很远,尤其是还涉及flush时;
- 索引参数不是越大越好,HNSW里的efConstruction大大会拖慢写入,M太大会增加内存占用。做多租户共享Collection时,建议用统一参数,优先保证检索稳定;
- 冷热租户要区分对待,活跃租户的Collection或分区保持loaded状态,低频租户可以先release掉,需要检索时再临时load,这个策略在租户多的时候很省内存。
# 批量插入示例 batch_data = [ {"tenant_id": "tenant_a", "text": text, "embedding": embedding} for text, embedding in zip(chunk_texts, embeddings) ] collection.insert(batch_data) collection.flush()我做知识库平台时,第一版无脑用了Collection per Tenant,等一个客户拆出十几个知识库后,Collection数量开始失控,后来整体切到了Partition Key加RBAC的组合方案,才把租户规模和运维成本同时稳住。如果你也在做多租户方案,我的建议是先拿一个最小样本把三种方案都跑一遍,用真实请求测一下过滤后的延迟,再做决定。另外有个小技巧:把用户身份的Tenant ID在链路入口统一解析,后续所有数据访问只依赖这个上下文,能省掉大量不该有的返工。