首页 / 资讯中心 / 文章详情

多租户架构与Milvus实战:从数据隔离到RBAC权限管控

多租户架构与Milvus实战:从数据隔离到RBAC权限管控 ★ FEATURED ARTICLE
最近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只用了idquery结果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连接Milvusfrom pymilvus import connections connections.connect( aliasdefault, hostlocalhost, port19530 )默认情况下不开启认证也就是不需要用户名密码就能连上。如果后续在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(namepk, dtypeDataType.INT64, is_primary_keyTrue, auto_idTrue), FieldSchema(nametext, dtypeDataType.VARCHAR, max_length2048), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dimDIM), ] return CollectionSchema(fields, descriptionknowledge base schema) def get_or_create_collection(tenant_id: str): name fkb_{tenant_id} if utility.has_collection(name): return Collection(name) collection Collection(namename, schemabuild_schema()) collection.create_index( field_nameembedding, 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_fieldembedding, param{metric_type: COSINE, params: {ef: 128}}, limit10, output_fields[text], )这个方案的优点是逻辑简单隔离最彻底单租户数据量大时不会影响别人缺点是Collection数量会随租户数线性增长当租户规模达到几百上千后Milvus的元数据管理、list/show collection耗时都会明显上升。所以方案一更适合“为大客户单独开一个知识库”的业务形态而不是海量小租户的SaaS标准服务。3.4 方案二共享Collection Partition Key官方推荐的租户隔离方案如果租户数量很多、单租户数据量不大推荐用共享Collection加分区键的方式。分区键的原理是把tenant_id映射到物理分区检索时通过表达式就能裁剪掉大部分分区只搜索当前租户的数据。这是Milvus 2.3之后官方比较推荐的多租户做法。建Collection时指定partition_key_fieldfields [ FieldSchema(namepk, dtypeDataType.INT64, is_primary_keyTrue, auto_idTrue), FieldSchema(nametenant_id, dtypeDataType.VARCHAR, max_length64), FieldSchema(nametext, dtypeDataType.VARCHAR, max_length2048), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dimDIM), ] schema CollectionSchema(fields, descriptionshared knowledge base) collection Collection( namekb_shared, schemaschema, partition_key_fieldtenant_id )注意一下版本差异。我在pymilvus 2.4.x里这样写没问题有的版本要求在CollectionSchema里传partition_key_field如果你报参数错误检查一下当前版本的API签名。写入时每条数据都必须带tenant_iddata [ {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_fieldembedding, param{metric_type: COSINE, params: {ef: 128}}, limit10, exprtenant_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, StrongPss123) # 创建角色 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和passwordconnections.connect( aliasdefault, hostlocalhost, port19530, useralice, passwordStrongPss123 )然后她就可以在自己的权限范围内对kb_shared做检索和查询。但这里有一个非常容易踩的误区RBAC控制的是能不能操作某个Collection并不能自动做到行级数据过滤。alice仍然需要在自己的检索代码里写exprtenant_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_nametenant_id, index_params{ index_type: INVERTED, params: {} } )加完索引后同样的检索请求延迟直接回落到50毫秒以内。这个坑在官方文档里其实有提但很多人包括我都是等到延迟恶化才意识到要补索引。记住只要你的expr里经常用到某个字段就给这个字段建索引。4.3 数据导入与检索之间的可见性窗口另一个高频问题某个租户刚上传了一批文档马上测试检索结果一条都搜不到。很多人第一反应是代码bug其实是Milvus的写入可见性机制在“作怪”。在Milvus里insert之后的数据是先写进内存缓冲区调用flush()之后才真正落盘并变得可检索。如果在flush之前就发起search是查不到刚写入的数据的。我的做法是在写入流程的末尾显式调用flushcollection.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延迟备注全量检索无expr20-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在链路入口统一解析后续所有数据访问只依赖这个上下文能省掉大量不该有的返工。
阅读完成 · 觉得有帮助?
咨询建站