☰
多租户隔离与权限下推:企业级知识库问答系统的安全架构实践
2026/10/26 14:00:09 网站建设 项目流程

写了小十年后端,带团队做过几套 SaaS 系统,也接过不少知识库问答的需求,每次走到"多租户隔离"和"权限下推"这两个词,总能看到一波人翻车。很多人不是不会写代码,而是没想清楚边界到底该画在哪。这一章我们就拿"企业级智能问答系统"当靶子,掰开揉碎了聊聊,从租户如何识别,到权限怎么一层层压到数据库里去,顺带把我在生产环境里踩过的坑和排查套路一并交代出来。

1. 先想清楚:多租户隔离到底要解决什么问题

1.1 一个真实的需求场景

假设你是做企业知识库问答的,客户有几十家,甚至几百家。每家的文档不一样,权限结构也不一样。A公司的员工登录之后,理论上只能查A公司的内部手册,只允许经理职级以上的角色查看薪资相关制度,研发部门的人能看技术文档,销售部门的人不能碰。

这个需求写出来很轻巧,落地却是灾难现场。你辛辛苦苦把 B 公司的文档塞进共享知识库,如果隔离没做干净,A 公司的用户检索时,直接命中 B 公司的内部流程文档,这就是一次实打实的越权事故,不是 bug,是安全漏洞,是要被人写进事故报告、拿去复盘批判的那种。

智能问答系统和普通 CRUD 系统不一样的地方在于:普通系统是"用户主动打开某条记录",你还可以靠前端菜单、路由跳转来兜一层;问答系统是"用户输入一句话,系统从海量文档里自动检索"——检索是模糊的、动态的、横跨整个索引库的。只要有一条文档没过滤干净,它就有可能被召回、被拼进上下文、被大模型引用后输出给用户。所以多租户隔离在问答系统里,容错率极低,出问题的概率反而极高。

1.2 常见的三种隔离方案怎么选

业内做多租户隔离,基本就三条路,各有利弊。我直接按实际场景说结论。

第一种是独立数据库隔离。每个租户一套库,物理隔离最彻底,互不干扰,出了问题影响面也最小。代价是什么?连接数爆炸,运维成本翻倍,迁移、备份、升级全都变成乘以租户数量的活。适合那种租户数量少、单个租户数据量极大、客户对数据安全要求高的场景,比如大型银行、政务系统。

第二种是共享数据库、独立 Schema。库是同一个库,每个租户一套 Table Schema,逻辑上隔离,稍微省一点运维成本。但跨租户查询、统计报表够你喝一壶,每次都要写动态 SQL 去切 Schema,ORM 里那一堆 Model 映射也容易乱。

第三种是共享库、共享表,靠一个 tenant_id 字段做行级隔离。这也是目前绝大多数 SaaS 系统的主流做法。成本低、扩展性好、查询灵活,虽然隔离能力不如前两种"物理级"强,但只要你的 SQL 层约束得死死得,安全性完全可控。

问答系统我建议直接走第三种,理由很现实:知识库和向量库天然是海量共享资源,你没法给几百个租户各部署一套向量检索服务,那不叫安全,叫暴发户做派。共享表 + 强制的租户条件拦截,是性价比最高的方案。

很多人纠结"物理隔离更安全",我个人的判断是:对于知识问答这类场景,真正的风险点不在数据库物理隔离,而在漏加条件的查询。方案再物理,只要你代码里有一句 SQL 忘了带 tenant_id,一样越权。

2. 权限下推的本质:把条件压到最靠近数据的层级

2.1 先搞懂下推到底推的是什么

"权限下推"这四个字,听上去很玄乎,其实说的是一个很朴素的事:不要等数据都查出来了,再在内存里做权限过滤。

举个例子。A租户的知识库有100万篇文档,B租户有80万篇。如果你的代码把两个租户的文档都查出来,再在应用层用 if 判断"只返回当前租户的",会发生什么?每次问答检索,你都要把几百万篇文档从数据库拖到应用服务器,内存直接被打爆,响应延迟飙到几十秒。这还不是最致命的,致命的是这种模型下,只要有一个分布式查询节点忘了做内存过滤,数据就漏出去了。

下推的意思,就是把这个"只允许看当前租户/当前角色内容"的条件,一路向下穿透到存储层去执行。数据库能过滤,就让数据库过滤,最好连索引都命中;向量检索库能过滤,就让检索阶段直接带过滤条件,别等全向量召回之后再逐条筛。

2.2 在问答系统里,谁能看哪些内容由什么决定

我把问答系统的权限模型拆成两层,这是最容易理清的部分。

第一层是租户边界。用户从哪个租户来的,就只能在这个租户的知识域里检索。租户边界通常由一个 tenant_id 表达,它附在用户登录态上,随请求一路传递。

第二层是租户内部的文档级权限。同一个租户里,不是所有文档对所有员工开放。有的按角色,比如管理员、普通员工、HR;有的按部门,比如研发部、销售部;有的挂在文档本身的 ACL 列表上,明确写了谁能读、谁能写。这一层如果做得粗糙,最常见的表现是:检索结果里混着用户没权限看的文档,用户点进去看详情才发现被拒绝。体验差还是小事,更糟的是摘要、引用已经泄露在了搜索结果里。

所以我在设计知识库问答系统时,坚持所有文档在写入时,就把它的可见范围拍平成一串最简条件 group 或 dept 或 user_id,存进文档元数据里。检索时,不仅要带上 tenant_id,还要把当前用户的 group/dept/user_id 一起下推到检索条件里。这叫双层权限下推,租户边界做粗粒度掐断,文档权限做细粒度收紧。

2.3 为什么不能靠"控制返回结果"来兜底

你可能会说,既然我都把文档权限存了元数据,那等向量检索完之后,我在应用层再用 ACL 过滤一遍不就行了?理论上行,实操上坑很多。

先聊性能。问答系统要的就是低延迟,你召回 100 条候选文档,再用 ACL 逻辑筛掉 80 条,时间白白浪费在传输和序列化上。检索库如果能提前过滤,那 80 条压根就不用参与向量计算、语料拼接,吞吐量差异不是一个量级的。

再聊语义质量。很多问答系统会做重排(Re-rank),让语义最匹配的文档排在前面。如果你把权限过滤放在重排之后,很可能出现一种尴尬情况:语义最接近的几篇文档全是无权限的,筛完之后,剩下几篇相关度很低的文档被硬凑上来,最终答案质量极其糟糕。把权限下推到检索阶段,就是为了让候选集从源头上就是用户真正能看的,这样重排才有意义。

这个点极其重要。我有一次就是先全量召回再过滤,结果用户问了句"团建经费标准",系统把另一家租户的隐私制度排在最前面,好在还在测试阶段就被拦住了。那次之后,我把"权限过滤必须发生在召回之前"写进了团队的架构规范。

3. 落地实现:租户上下文与 SQL 强制隔离

3.1 租户上下文的传递链路

先看看一次完整的问答请求里,租户身份是怎么一步一步传下去的。

客户端带 token 请求网关,网关解析 token,确认租户身份和用户身份,把这些信息转成内部请求头,继续向后端服务转发。后端服务收到请求后,把租户信息写进一个全局上下文(ThreadLocal),这个上下文在本次请求的整个生命周期里都可读。再往下到数据访问层,我们通过 MyBatis 拦截器自动给每条 SQL 拼上租户过滤条件和权限条件。

这套链路的本质,是让租户信息从"入口处"就变成一个显式的上下文,而不是每个 Service 方法传来传去。谁漏传、谁拼错,出问题就非常直观。下面是网关层解析完 token 后,往上下文里写入数据的一个简化示例:

public class TenantContext { private static final ThreadLocal<TenantUser> HOLDER = new ThreadLocal<>(); public static void set(TenantUser user) { HOLDER.set(user); } public static TenantUser get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }

注意,这个 ThreadLocal 必须在请求结束之后清理掉。否则线程池复用的线程会把上一个租户的身份带到下一个请求里,这是典型的低概率、高危害事故。

3.2 手写一个无条件生效的租户拦截器

网上很多教程教你在 Service 层手动拼接租户条件,比如 where tenant_id = #{tenantId}。这种做法最大的问题是:拼不拼全靠开发人员的自觉。总有那么一个深夜,有人在写临时统计功能时少带一句条件,第二天线上就出越权事故,然后大家一起来查。

我不能接受这种建立在自觉之上的安全设计。所以我们的做法是:在 MyBatis 层写一个拦截器,对所有查询 SQL 做统一改写,自动注入租户条件。

核心思路是利用 MyBatis 的 Interceptor 接口,拦到 BoundSql 之后,用 JSqlParser 解析 SQL 的 Where 片段,强行加上 tenant_id 条件。具体代码不复杂,关键是拦截范围要覆盖到所有语句类型,Update 和 Delete 也一样要处理,不然删改数据时会绕过租户隔离。

这里有个比较大的忌讳:不要试图用字符串拼接去注入条件,很容易和 SQL 里已有的条件拼出语法错误。用解析器去改 AST 是更稳的路子。

写拦截器时还要留一个白名单机制。某些系统表、全局配置表,本就不属于任何租户,拦截器需要跳过。这个白名单要收敛,不能放开成默认,否则大家都会图省事把自己的表塞进白名单,隔离就又成了摆设。

分享一段关键思路的伪代码,方便你参考落地时怎么设计:

public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 1. 如果请求没有租户上下文,则放行(例如登录接口) // 2. 如果是系统配置表,则放行 // 3. 解析 SQL,在 WHERE 条件中追加 tenant_id = ? // 4. 如果有子查询/join,需要确保表都有租户条件 // 5. 把新的 SQL 写回 BoundSql return invocation.proceed(); } }

这个拦截器看起来简单,真正写起来要注意的点很多。比如子查询和 JOIN 的树形解析,比如 INSERT INTO ... SELECT 这种特殊语句,比如tenant_id字段不存在但表结构确实没这个字段的异常处理。建议你从最简单的单表查询开始用,逐步把语法树处理面扩宽,不要一上来就追求覆盖所有 SQL 形态。

3.3 复合索引与执行计划验证

权限条件注入之后,不是就完事了,你还要保证它跑得快。多租户最大的敌人永远是扫表。一个知识库表,假如有 1000 万行却横跨 50 个租户,你给某一个租户查询时如果走全表扫描,性能直接崩掉。

实践中最有价值的索引,不是单独的tenant_id索引,而是(tenant_id, updated_at)或(tenant_id, doc_id)这种复合索引。原因是:问答系统里频繁出现的查询都是"某个租户下按更新时间找增量文档""某个租户下按文档主键找详情",复合索引能把过滤条件压到一个极小的索引区间。

建索引的 SQL 大致长这样:

CREATE INDEX idx_tenant_update ON doc(tenant_id, updated_at); CREATE INDEX idx_tenant_docid ON doc(tenant_id, doc_id);

建完索引后,用 EXPLAIN 验证一下执行计划。看到 type 是 ref, rows 被压缩到几百行以内,说明下推是有效的。如果你发现 SQL 里带了租户条件还是走了全表扫描,排查顺序是:字段类型是否匹配、索引是否被函数包住、OR 条件是否破坏了索引。

3.4 租户条件下的分页与统计陷阱

最后补一个实战细节:在租户隔离的 SQL 上做分页,Order By 的字段必须和索引配合。比如ORDER BY updated_at LIMIT 10,如果只建了tenant_id单列索引,数据库会先按租户过滤出所有行,再统一排序取前 10 条,海量租户数据下效率会很差。

更隐蔽的是一个统计类 SQL 的坑:聚合函数不会自动理解租户隔离逻辑。你写一条SELECT COUNT(*) FROM doc,拦截器会把它改成SELECT COUNT(*) FROM doc WHERE tenant_id = ?,这没问题。但是如果你写的是SELECT tenant_id, COUNT(*) FROM doc GROUP BY tenant_id,你的目的是做全局运营统计,拦截器却会把这个 SQL 拼上租户条件,导致统计结果被异常限定。这种情况,白名单机制是唯一的出口,但同样的,你必须严格审批,别让运营同学把这当成了后门。

在线上的每次新 SQL 评审里,我们都会把"这条 SQL 的租户条件从哪里来"作为必问项,任何答不上来的,默认不放行。

4. 问答系统的特殊难点:文档权限与 RAG 检索

4.1 知识库的租户隔离设计

智能问答系统的底座是知识库,知识库的核心单元是文档。设计表结构时,文档表必须带上tenant_id,这是硬性的。文档的切分片段(chunk)表也必须带tenant_id,这往往被忽略。我见过不少团队只给文档表加了租户字段,chunk 表却没有,导致检索阶段一旦走向量库,租户条件根本无法下推到 chunk 级别,只能靠事后回表过滤,这又回到了前面说的错误模型。

正确做法是:文档、chunk、向量索引、检索日志,所有与内容挂钩的表,都必须有可下推的租户字段。数据写入的时候,从文档一路填充到 chunk,保证链路完整。

4.2 向量检索阶段的权限下推

这里就牵出问答系统最特殊的地方:向量库的过滤机制。

如果你用的是 pgvector、Elasticsearch、Milvus 这类能处理向量的存储,它们的元数据过滤(filter)机制是下推权限的关键。简单说,向量相似度检索跑的是 ANN 近似最近邻,你必须把租户和文档权限条件作为 filter 参数传进去,让向量检索引擎在索引的内部就完成排除。

Python 侧调用 OpenAI Embedding API 做向量化后,写入向量库的元数据大致是这样:

# 以 elasticsearch 元数据为例 metadata = { "tenant_id": tenant_id, "doc_id": doc_id, "allowed_roles": ["admin", "manager"], "allowed_depts": ["rd"], "updated_at": int(now) }

检索时,把用户当前角色和部门作为过滤条件传入:

query_filter = [ {"term": {"tenant_id": tenant_id}}, {"terms": {"allowed_roles": user_roles}}, {"terms": {"allowed_depts": user_depts}} ]

这一步做完,向量检索返回的候选集天然就是当前用户可看的。我实测过这种设计在百万级 chunk 规模下,过滤带来的额外耗时可控制在 10ms 以内,远小于事后过滤带来的 300-500ms 浪费。

需要特别留意:很多向量库的 filter 有短路机制。filter 条件写得太多、太复杂,引擎可能退化成暴力扫描。因此元数据上尽量用枚举值,不要用自由文本。

4.3 命中文档后的权限二次校验

讲道理,权限前置下推已经做了,为什么还要二次校验?因为工程上要给"意外"兜底。向量检索的召回算法受近似搜索精度影响,某些实现可能返回了不完全符合 filter 的结果,也可能 filter 配置在某个发布节点上被误改。这些情况都很极端,但极端不代表不会发生。

二次校验的做法很简单:在生成回答之前,把召回的所有 chunk 按 doc_id 分组,再用文档权限缓存去做一次本地鉴权,把任何没有权限的 chunk 摘除。这个环节不做任何复杂运算,只做集合过滤,耗时几乎可以忽略,但它是一道安全护栏,能兜住上游所有可能出现的系统失误。

二次校验还有一个额外好处:可以记录"谁在什么时间、拿什么 query、召回了哪些文档"的审计日志。企业级问答系统上线后,法务和数据安全团队几乎一定会要求你提供这种审计能力,越早设计进去越省事。

5. 避坑实录与排查思路

5.1 线上越权是怎么发生的

讲一个真实事故。我们的问答系统上线第四个月,某天突然收到客户投诉,说销售部门有人搜到了研发部的内部架构文档。排查开始后,第一直觉是权限条件拼错了,结果仔细查了代码,发现链路完全没问题。

再往下查,定位到了原因:知识库管理后台允许管理员批量导入文档,导入接口在解析 Excel 时,只给文档表写了 tenant_id,但给 chunk 表写入时,却用了一个公共工具类的默认租户值。那个工具类是给系统初始化跑数据用的,里面写死了tenant_id = 0。等于说,所有文档的租户是对的,但文档下面的 chunk 全建在了租户 0 的桶里。检索时,租户条件跑到 chunk 层就断了,结果自然乱套。

事故的教训总结成一句话:隔离设计必须贯穿整条数据链路,这一层是对的,不代表下一层是对的。任何一次数据写入,都要在入口处强制指定租户来源,绝不允许用"默认值"去填充租户字段。

5.2 异步线程丢失租户上下文

第二个高发性问题:你的问答服务里必然有异步处理——埋点上报、消息通知、文档异步切分、缓存异步刷新等。这些异步线程默认拿不到主线程的 ThreadLocal,如果你在异步里去查数据库,租户拦截器会发现上下文是空的,有些实现会直接放行,于是产生无租户条件的查询,虽然不越权,但可能串数据,或者占用公共租户的资源。

这个问题的常规解法是:提交异步任务前,把租户上下文快照提取出来,在任务执行时重新塞回 ThreadLocal。这方案不复杂,但要写入你团队的基础设施代码规范,否则每写一个异步任务都忘一次。

5.3 缓存键必须包含租户身份

还有一个很容易被忽略的坑:缓存。公司内有团队做过公共的缓存组件,key 设计成doc:前缀加 doc_id,结果不同租户间文档 ID 撞了号,导致租户 A 的用户读到了租户 B 的文档缓存。

从那个事故后,我们的约定是:所有涉及多租户数据的缓存 key,必须带租户标识,比如tenant:{id}:doc:{id}。权限上下文也一样,不同租户下同一角色的权限集合缓存要分开隔离。

5.4 排查 SQL 是否漏掉权限条件的实战套路

如果你现在要接手一套已经写好的问答系统,想看它有没有多租户隐患,最快的办法是打开慢查询日志,把所有 SQL 按 executor 的耗时排序,然后逐条检查有没有租户条件。慢查询不一定漏权限,但漏权限的 SQL 因为数据量巨大,大概率会出现在慢查询里,这是一个高效切入口。

另一个排查方式是全链路追踪。如果你有 trace 系统,把请求链路拉出来,观察每次数据库调用的参数列表。这一步能看到每条 SQL 是否都带着 tenantId 参数。

再进阶一层:写一个自动化小工具,扫描代码仓库里所有手写 XML mapper,专门找没有自动注入条件的自定义 SQL。我们团队自定义 SQL 全部走同样的租户拦截器,不允许任何手写 SQL 逃逸在外。这个规则一开始会有人不理解,觉得限制自由度,但经历一次越权事故后,所有人都老实了。

5.5 接口层的白名单要怎么写

最后给你一个实用建议:不是所有接口都要带上租户条件。登录、注册、获取验证码、系统参数查询,这些本来就不属于任何租户。问题在于,这类"租户无关"接口慢慢会变多,你要有个集中管理的地方。

我的做法是:维护一张审核严格的白名单表,这里加一个接口、一条 SQL,都要走一次评审流程。白名单之外的所有请求,默认强制要求租户上下文。这样"默认拒绝"就变成了最高优先级的安全基调,而不是"默认放行、遇到问题再加条件"。

这种基调差异,会直接影响整个系统的安全水位。凡是吃过隔离漏洞亏的团队,都应该把这块当成第一原则来抓。

6. 一点坚持下来的工程习惯

多租户隔离与权限下推,真正考验的从来不是某个算法或者某个框架,而是一条数据从入口到存储、从存储到检索、再从检索回到用户手里的完整链路里,有没有任何一个环节默认放行了数据。做问答系统的这几个月,我心里一直绷着根弦:宁可让系统在出现异常时直接报错拒绝请求,也不要让它偷偷返回了一份没权限的文档。

在工程落地上,我逐渐沉淀出几个习惯:网关层解析完身份后,立刻把租户和角色信息显式塞进 trace 日志;数据访问层的自动注入只做加法不做减法;每次发版前,用自动化脚本扫一遍增量 SQL 和增量接口,拦截任何没有明确租户声明的变更。这几件事单独拎出来都很笨,但组合在一起,就是能让系统睡个安稳觉的防线。

如果你现在正在从零搭自己的企业级问答系统,建议你先把存储层表结构重新审一遍,看看从文档到向量检索的每个环节有没有都带上租户字段。这一步走扎实了,后面的权限下推实现起来会顺很多。等这一层稳住了,你会发现多租户隔离这件事,真正磨人的不是技术,而是所有人心里的那根弦:每一次数据访问,都必须先回答一个问题——它属于谁,谁能碰它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询