2547 字
约 8 分钟
16
S22-X09 · 权限与数据隔离:多租户场景下的 AI 应用

单租户跑通、接第二个客户就出事,几乎都栽在同一处:**检索链路里的租户过滤写法,是从请求参数里取的。**开发时只有一份数据,tenantId 传什么都不影响结果,上线后才发现"过滤条件漏写"不会报错,只会把别人的文档答给用户。

多租户 AI 应用的隔离不是一道开关,是三层各管一件事的约束。


1. 三个隔离层级

层级 隔离对象 泄露后果 主要手段
数据 文档、向量、对话历史、缓存 直接泄露他人内容,最严重 索引层过滤 + 鉴权上下文注入
模型配额 并发、token、调用次数、费用 一个租户打满、全站降级 按租户限流与计费,独立 key
提示资产 系统提示、工具定义、自定义模板 提示污染、越权调用工具 资产归属与作用域隔离

三层里最容易被忽略的是第三层:租户可以自定义提示,如果这份自定义提示被写进共享的模板表,它就可能覆盖到别人的请求上。


2. 向量库隔离的三种方案

方案 结构 优点 代价
每租户独立 collection tenant_{id} 一个集合 隔离最硬,删除租户=删集合 集合数随租户线性增长,跨租户统计难
共享 collection + 元数据过滤 单集合,每条向量带 tenant_id 管理简单,扩容平滑 过滤漏写就是数据泄露,依赖调用方纪律
独立实例 / 独立库 每租户一个库或集群 故障与性能都隔离 成本最高,只适合大客户

实际项目的分界线通常是:中小客户走共享集合加过滤,大客户走独立集合或独立库。取舍依据不是技术偏好,而是"漏一条会不会变成事故"。


3. 检索越权的真实风险:先过滤,再检索

两种写法的差别是安全与性能的分水岭:

// 错:先按语义检索 topK,再在应用层过滤
List<Document> hits = vectorStore.similaritySearch(req);      // 全库 topK
List<Document> mine = hits.stream()
        .filter(d -> d.getMetadata().get("tenantId").equals(ctx.tenantId()))
        .toList();                                            // 可能剩 0 条

// 对:租户条件进入检索请求本身
SearchRequest scoped = SearchRequest.builder()
        .query(req.text())
        .topK(req.topK())
        .filterExpression("tenant_id == '" + ctx.tenantId() + "'")   // 索引层过滤
        .build();
List<Document> hits = vectorStore.similaritySearch(scoped);

先检索再过滤有两个问题:topK 被别人的数据占满,你的结果可能一条不剩;而且过滤逻辑一旦写错(大小写、类型不一致、忘记加分支),泄露就是静默的。

过滤表达式不要拼字符串。Spring AI 的 FilterExpressionBuilder 提供了类型安全的构造方式,按当前版本核对可用 API 后优先用它。

FilterExpression tenantFilter(TenantContext ctx) {
    return new FilterExpressionBuilder()
            .eq("tenant_id", ctx.tenantId())
            .build();
}

4. 租户 id 只能来自鉴权上下文

这是整篇最重要的一条:**tenantId 绝不从请求体、查询参数或请求头里直接取。**它必须来自已经验证过的身份凭证(token 里的 claim、会话绑定、网关注入并签名的头)。

public final class TenantContext {

    private static final ThreadLocal<Tenant> CURRENT = new ThreadLocal<>();

    /** 只允许鉴权层调用,业务代码禁止 set */
    static void bind(Authentication auth) {
        String tenantId = ((JwtAuthenticationToken) auth).getTokenAttributes()
                .get("tenant_id").toString();      // 来自已验证的 claim
        CURRENT.set(new Tenant(tenantId, roles(auth)));
    }

    public static Tenant current() {
        Tenant t = CURRENT.get();
        if (t == null) throw new IllegalStateException("tenant not bound");
        return t;                                   // 缺失即失败,绝不回落到默认租户
    }
}

两个细节:缺失就抛异常,不要回落到"默认租户"——默认值是多租户系统里最经典的越权源;请求结束必须 remove(),线程池复用会把上一个请求的租户带进下一个。


5. 模型侧隔离:限流、计费、key

维度 做法 为什么必须按租户
并发 每租户信号量或 Redis 计数器 一个租户的批量任务会挤掉所有人
速率 每租户 QPS / TPM 配额 需要按套餐区分
计费 token 用量按租户累加 月末结算与超额提醒
凭证 大客户用租户专用 API key 便于撤权与成本归户
# application.yaml:配额按套餐分层,具体数值按自己的成本模型核定
ai:
  quota:
    default:
      max-concurrent: 2
      tokens-per-minute: 20000
      tokens-per-month: 2000000
    tiers:
      pro:
        max-concurrent: 8
        tokens-per-minute: 100000
        tokens-per-month: 20000000

限流的桶必须带租户维度。全局桶会让"某租户刷爆"变成"全站 429",按租户桶则只影响它自己。


6. 提示与工具资产的可见性

租户自定义提示不能污染全局,规则就三条:**资产有归属(scope: global | tenant)、读取时按作用域解析、写入时校验归属。**优先级是"租户私有覆盖全局默认",反向不成立。

PromptAsset resolve(String key, TenantContext ctx) {
    return repo.findByKeyAndScope(key, "tenant", ctx.tenantId())
            .or(() -> repo.findByKeyAndScope(key, "global", null))   // 只降级到全局
            .orElseThrow(() -> new IllegalStateException("no prompt: " + key));
}

工具定义同理:租户只能看到自己被授权的工具集合,工具执行前还要再校验一次——提示层不可信,执行层才是边界。


7. 缓存 key 必须带租户维度

缓存 key 里必须有的维度
检索结果缓存 租户、查询、检索参数、索引版本
生成结果缓存 租户、提示版本、模型版本、输入摘要
权限/配置缓存 租户、用户、版本

漏掉租户维度就是"缓存穿透租户":A 的答案被 B 直接命中。这类事故在压测和单租户环境里永远发现不了。

String cacheKey(Tenant t, ChatRequest req) {
    return String.join(":",
            "ai.answer", t.id(), req.promptVersion(), req.modelVersion(),
            sha256(req.normalizedInput()));      // 租户在前,任何维度变更都换 key
}

8. 删租户:一条完整的清理链路

环节 清理动作 遗漏后果
向量 按 tenant_id 批量删除或删集合 残留文档仍可能被检索到
原始文档 对象存储前缀删除 + 元数据表删行 存储成本与合规风险
历史会话 会话表与消息表按租户删除 用户仍能看到旧会话
缓存 按租户前缀批量失效 旧答案继续命中
日志/追踪 标记为已删除或按保留策略过期 审计与隐私冲突
凭证 吊销租户 API key 已撤销租户仍能调用
-- 先软删,再由异步任务分批硬删,避免长事务锁表
UPDATE documents SET deleted_at = now() WHERE tenant_id = :tid AND deleted_at IS NULL;
DELETE FROM messages WHERE tenant_id = :tid AND created_at < :cutoff LIMIT 5000;

清理必须是异步分批 + 可重入的:一次删几十万条向量会在事务里锁死,中途失败还得能续上。


9. 审计:谁在何时问了什么

CREATE TABLE ai_audit (
    id            BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id     VARCHAR(32)  NOT NULL,
    user_id       VARCHAR(64)  NOT NULL,
    request_id    VARCHAR(64)  NOT NULL,
    question_hash CHAR(64)     NOT NULL,
    doc_ids       JSON         NOT NULL,   -- 命中的文档 id
    model         VARCHAR(64)  NOT NULL,
    prompt_ver    VARCHAR(32)  NOT NULL,
    created_at    DATETIME(3)  NOT NULL,
    KEY idx_tenant_time (tenant_id, created_at),
    KEY idx_request (request_id)
) ENGINE=InnoDB;

审计表要回答三件事:谁问的、问了什么(或其摘要)、答案依据了哪些文档。审计库与观测库分开存,保留策略不同、访问权限也不同。


10. 排错

现象 原因 处理
B 租户检索到 A 的文档 过滤写在应用层或条件漏写 过滤下推到索引层,用类型安全构造器
偶发越权,无法复现 TenantContext 用 ThreadLocal 没清理 请求结束时 remove(),或用请求作用域 bean
某租户刷爆全站 限流用全局桶 改为按租户桶,配额分层
删除租户后仍有旧答案 缓存 key 缺租户维度或没失效 key 带租户前缀,删除时按前缀批量失效
日志里混着多个租户的记录 日志未带租户字段 MDC 注入租户,检索按租户过滤
租户自定义提示影响他人 写入未校验 scope 写入强制归属,读取只允许降级到全局
计费与实际不符 只记总数,没按租户打点 token 计数打租户标签,日结对账

11. 边界

场景 更适合的手段
同一公司内不同部门 用户级 RBAC,不必上物理隔离
强监管行业要求物理隔离 独立实例或独立库,共享集合不满足
只做内部系统 单租户简化实现,过度设计成本更高
跨租户的数据分析 走脱敏聚合管道,不给业务账号直连

一句话:过滤下推到索引层、租户来自鉴权上下文、缓存带租户前缀——这三条守住,多租户 AI 应用就不会出现最贵的那个 bug。


12. 自测

  1. 为什么"先检索 topK 再在应用层过滤"既慢又不安全?
  2. TenantContext 缺失时回落到默认租户,会带来什么后果?
  3. 共享集合方案里,防止越权的最后一道防线在哪一层?
  4. 缓存 key 漏掉租户维度,为什么在单租户环境测不出来?
  5. 删除租户时为什么要异步分批,而不是一条 DELETE 全清?
  6. 审计表至少要能回答哪三个问题?
S22-X09 · 权限与数据隔离:多租户场景下的 AI 应用
https://www.clxhxhhr.top/posts/5741/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。