Administrator
发布于 2026-05-14 / 275 阅读
5

AI 应用的多租户与数据隔离设计

「客户 A 能看到客户 B 的知识库内容」

三月份的一次安全测试,外部团队提了一个问题:他们用 A 公司的账号登录后,通过构造特定的查询,让 Agent 返回了 B 公司的产品文档片段。

这个问题很严重,我们当天就成立了专项。这篇记录多租户隔离的四层设计、每一层我们实际用的方案,以及成本计量那块(这块比隔离本身更麻烦)。

问题的根因

先说清楚漏洞出在哪。我们的向量库是单一索引,所有租户的文档混在一起,靠元数据字段 tenant_id 过滤。

查询代码是这么写的:

// 问题代码:filter 拼在向量检索之后,且阈值兜底逻辑有洞
List<Document> docs = vectorStore.similaritySearch(query, 10);

return docs.stream()
    .filter(d -> tenantId.equals(d.getMetadata().get("tenant_id")))
    .limit(5)
    .toList();

问题在于:先检索 10 条再过滤,如果这 10 条里属于当前租户的只有 2 条,就只返回 2 条——这还算好的(信息不全)。真正致命的是另一段兜底代码:

// 更致命:召回太少时,去掉过滤再来一次
if (docs.size() < 2) {
    docs = vectorStore.similaritySearch(query, 5);   // 没有过滤!
}

这段是某次「优化召回率」时加的。写它的人当时的想法是「召回太少不如放开一下」,完全没意识到这是把隔离墙拆了。

这件事的教训是:召回率的优化不能触碰隔离边界,任何「放宽过滤」的代码都是安全漏洞。后来我们在代码评审里把这条列为红线,并加了静态检查。

四层隔离设计

整改之后我们建立了四层。之所以是四层而不是一层,是因为任何单层都可能被绕过,多层才能形成纵深。

层次隔离什么实现手段被绕过的后果
L1 身份租户身份可信JWT + 服务端强制解析后续全失效
L2 检索向量召回检索时过滤,不检索后过滤数据泄露
L3 存储物理或命名空间隔离按租户分库/分集合批量泄露
L4 出参最终输出出参二次校验 + 敏感词过滤单条泄露

L1:身份不能信客户端

第一条规矩:tenantId 永远不来自请求参数,只来自签名的令牌

// 反例:tenantId 从参数来,等于没有隔离
@GetMapping("/query")
public Answer query(@RequestParam String tenantId, @RequestParam String q) { ... }

// 正解:从令牌解析,且参数里出现的 tenantId 一律忽略
@GetMapping("/query")
public Answer query(@RequestParam String q, Authentication auth) {
    String tenantId = ((TenantPrincipal) auth.getPrincipal()).tenantId();
    return service.query(tenantId, q);
}

为了杜绝「顺手从参数取」的情况,我们做了个静态检查,扫描所有 Controller 和 MCP 工具方法,禁止出现名为 tenantId/tenant/orgId 的参数:

// ArchUnit 规则,跑在 CI 里
@AnalyzeClasses(packages = "com.internal.ai")
class TenantIsolationTest {

    @ArchTest
    static final ArchRule no_tenant_param =
        noMethods().that().areAnnotatedWith(McpTool.class)
            .or().areAnnotatedWith(GetMapping.class)
            .should().haveRawParameterTypesWithNameMatching(".*(?i:tenant).*")
            .because("租户 ID 必须从令牌解析,不能由调用方传入");

    @ArchTest
    static final ArchRule vector_search_must_have_filter =
        methods().that().haveNameMatching("similaritySearch")
            .should().beCalledWithFilterExpression()
            .because("向量检索必须带租户过滤,不能先检索后过滤");
}

这个检查加进 CI 之后,拦下过 2 次新的违规代码。成本低,价值高。

L2:检索时过滤,不是检索后过滤

这是技术上的核心修复。Spring AI 的 SearchRequest 支持 filterExpression,过滤下推到向量库:

SearchRequest request = SearchRequest.builder()
    .query(userQuery)
    .topK(20)
    .similarityThreshold(0.70)
    .filterExpression("tenant_id == '" + tenantId + "'")
    .build();

但这里有个注入风险:tenantId 拼进了表达式。虽然它来自令牌,理论上可信,但我们还是加了白名单校验:

private static final Pattern TENANT_PATTERN = Pattern.compile("^[A-Z0-9]{4,16}$");

SearchRequest buildRequest(String tenantId, String query, int topK) {
    if (!TENANT_PATTERN.matcher(tenantId).matches()) {
        throw new IllegalArgumentException("invalid tenant id");
    }
    return SearchRequest.builder()
        .query(query)
        .topK(topK)
        .filterExpression("tenant_id == '" + tenantId + "'")
        .build();
}

还有个必须注意的点:过滤表达式的能力因向量库而异。我们评估过的几个:

向量库元数据过滤能力是否下推
PgVector完整 SQL WHERE是(部分)过滤选择性高时可能退化为顺序扫描
Milvusboolean expression表达式语法和 SQL 不同,要单独适配
Qdrant完整,支持嵌套需要为过滤字段建 payload index,否则很慢
Elasticsearch完整 DSLkNN + filter 组合在 8.x 才成熟

我们用的是 PgVector(因为已经在用 PostgreSQL,运维成本最低)。这里有个性能坑必须提:当过滤条件选择性很高时(比如只匹配 0.1% 的文档),HNSW 索引的近似检索会退化,可能召回不全。我们的解法是把高选择性的字段做分区,见下一节。

L3:存储层分区

光靠元数据过滤,我们还是不放心——一次代码疏忽就能穿透。所以在存储层做了物理隔离。

方案选择上我们纠结了很久:

方案隔离强度成本运维复杂度适用
单表 + 元数据最低最低小租户、非敏感
数据库 Schema 隔离中等规模
独立数据库大客户、强合规
表分区(partition)中强我们选的

我们最终用 PostgreSQL 的声明式分区,按 tenant_id 做 LIST 分区:

-- 向量表按租户分区
CREATE TABLE doc_embedding (
    id          bigserial,
    tenant_id   text        NOT NULL,
    doc_id      text        NOT NULL,
    chunk_id    text        NOT NULL,
    content     text,
    embedding   vector(1536),
    created_at  timestamptz DEFAULT now(),
    PRIMARY KEY (tenant_id, id)
) PARTITION BY LIST (tenant_id);

-- 每个租户一个分区
CREATE TABLE doc_embedding_t001 PARTITION OF doc_embedding
    FOR VALUES IN ('T001');
CREATE TABLE doc_embedding_t002 PARTITION OF doc_embedding
    FOR VALUES IN ('T002');

-- 每个分区独立建 HNSW 索引
CREATE INDEX ON doc_embedding_t001
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

分区方案的好处是:每个租户有独立的 HNSW 索引,检索时 PostgreSQL 的分区裁剪直接定位到那一个分区,既保证了隔离(物理上查不到别的分区),又解决了前面说的「高选择性过滤导致召回退化」的性能问题。

实测对比(单租户 12 万条向量,全库 340 万条):

方案Recall@10查询延迟索引大小
单表 + 元数据过滤0.71148ms18 GB(全库一个)
按租户分区0.9423ms18 GB(分在各分区)

Recall 从 0.71 涨到 0.94,延迟降了 6.4 倍。这个性能提升是意外的收获——我们做分区最初只是为了安全。

代价是运维复杂度:新租户要建分区,我们写了个自动化的流程,新租户开通时自动执行 DDL。另外分区数不能太多,PostgreSQL 在几千个分区时规划器会变慢,我们的上限设在 500。

对于超大租户(> 100 万条向量)或者强合规要求的客户,我们提供独立数据库方案,但收费更高。

L4:出参二次校验

最后一层是兜底:即使前面都漏了,输出的内容也要再查一次。

@Component
public class OutputGuard {

    public Answer guard(String tenantId, Answer raw) {
        // 1. 引用的文档 ID 必须属于本租户
        for (Citation c : raw.citations()) {
            if (!docIndex.belongsTo(c.docId(), tenantId)) {
                log.error("ISOLATION BREACH: doc {} not in tenant {}",
                          c.docId(), tenantId);
                alertService.raiseP1("多租户隔离告警", c.docId());
                return Answer.refused("内容不可用");
            }
        }

        // 2. 正文里如果出现其他租户的标识串,直接拒绝
        if (containsForeignTenantMarker(raw.text(), tenantId)) {
            log.error("ISOLATION BREACH: foreign marker in output");
            alertService.raiseP1("多租户隔离告警", "output");
            return Answer.refused("内容不可用");
        }

        return raw;
    }
}

第二条检查是我们在每个文档片段里嵌入一个不可见的水印串(租户标识),如果输出里出现了别的水印,说明发生了串数据。这个方法土,但有效,而且几乎零成本。

上线四个月,这层检查触发过 3 次,全是真实的 bug(其中 2 次是新同事写的新接口漏了过滤)。没有这层兜底,这 3 次都会变成客户可见的数据泄露。

成本分摊计量

隔离做完了,接下来是成本。这块比隔离更麻烦,因为「谁花了多少钱」这个问题在 AI 场景下特别难回答。

为什么难

传统 SaaS 的成本计量很简单:按用户数、按存储、按请求数。AI 应用的成本结构完全不同:

  • 同一功能不同成本:同样是「问答」,短问题 800 token,长文档分析 30,000 token,差 37 倍;
  • 成本发生在共享资源上:向量检索、rerank 模型这些是共享的,怎么分摊到租户?
  • 离线成本归属模糊:文档索引是离线做的,但受益的是在线查询;
  • 缓存的存在:命中缓存的请求成本几乎为零,不命中的成本很高,按什么算?

我们的解法是全链路计量 + 分层分摊

全链路计量

每一次 AI 调用,无论大小,都记录一条明细。关键是包含完整的归因维度:

CREATE TABLE ai_usage_detail (
    id              bigserial PRIMARY KEY,
    ts              timestamptz    NOT NULL,
    tenant_id       text           NOT NULL,
    user_id         text,
    scene           text           NOT NULL,     -- customer_service / doc_qa / ...
    session_id      text,
    model           text           NOT NULL,
    input_tokens    int            NOT NULL,
    output_tokens   int            NOT NULL,
    cached_tokens   int            DEFAULT 0,    -- 命中 prompt cache 的部分
    embedding_calls int            DEFAULT 0,
    rerank_calls    int            DEFAULT 0,
    vector_searches int            DEFAULT 0,
    elapsed_ms      int,
    cache_hit       boolean        DEFAULT false
) PARTITION BY RANGE (ts);

-- 按月分区,保留 13 个月
CREATE TABLE ai_usage_2026_05 PARTITION OF ai_usage_detail
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

埋点用 Micrometer + 自定义 Advisor,一次搞定:

@Component
public class UsageRecordingAdvisor implements CallAdvisor {

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
        long start = System.nanoTime();
        ChatClientResponse resp = chain.nextCall(req);

        Usage usage = resp.getResult().getMetadata().getUsage();
        usageRecorder.record(UsageRecord.builder()
            .tenantId(TenantContext.currentId())
            .userId(UserContext.currentId())
            .scene(SceneContext.current())
            .model(usage.getModel())
            .inputTokens(usage.getPromptTokens())
            .outputTokens(usage.getGenerationTokens())
            .cachedTokens(usage.getCachedTokens())      // 能拿到就记
            .elapsedMs((System.nanoTime() - start) / 1_000_000)
            .build());

        return resp;
    }
}

写入是异步的(丢进队列批量写),不能阻塞主流程。我们的写入延迟要求是 < 1ms,实际是 0.2ms。数据量:日均 470 万条,压缩后约 42 GB/月。

分层分摊

有了明细,分摊规则就清楚了:

成本项分摊依据说明
LLM token 费用直接归属明细里有租户 ID,直接算
Embedding 调用直接归属同上
Rerank 调用直接归属同上
向量库按存储量 + 检索次数加权存储 60%,检索 40%
文档解析(离线)按文档数摊销一次性计入,不摊销到后续月份
GPU / 自建模型按 token 权重共享资源,按比例
平台固定成本按租户均摊或用最低消费覆盖

向量库那条是我们的重点。存 100 万条向量的租户和存 1 万条的租户,成本差 100 倍,不能均摊:

-- 月度成本分摊 SQL(简化版)
WITH storage AS (
    SELECT tenant_id,
           sum(pg_total_relation_size(partition_name)) AS bytes
    FROM tenant_partitions
    GROUP BY tenant_id
),
search AS (
    SELECT tenant_id, count(*) AS searches
    FROM ai_usage_detail
    WHERE ts >= date_trunc('month', now())
    GROUP BY tenant_id
),
llm AS (
    SELECT tenant_id,
           sum(input_tokens * 0.0008 + output_tokens * 0.002) / 1000 AS llm_cost
    FROM ai_usage_detail
    WHERE ts >= date_trunc('month', now())
    GROUP BY tenant_id
)
SELECT s.tenant_id,
       round(l.llm_cost::numeric, 2)                          AS llm,
       round((vc.total_cost * 0.6 * s.bytes / tot.bytes +
              vc.total_cost * 0.4 * sr.searches / tot.searches)::numeric, 2) AS vector
FROM storage s
JOIN search sr USING (tenant_id)
JOIN lcm l USING (tenant_id)
CROSS JOIN vector_cluster_cost vc
CROSS JOIN (SELECT sum(bytes) bytes, sum(searches) searches FROM storage JOIN search USING (tenant_id)) tot;

计量带来的业务价值

成本计量不只是为了算账,它帮我们发现了几个之前完全不知道的问题:

  1. 租户间的成本差异极大。 我们 TOP 3 租户占了 61% 的成本,而他们的付费只占 34%。这个发现直接推动了定价策略调整——从「按席位收费」改成「席位 + 用量包」;
  2. 某些功能的成本被严重低估。 「长文档分析」这个功能单次成本 0.34 元,而我们定价时按 0.05 元估的。这个功能在两个月里亏了 4.7 万;
  3. 缓存的价值被量化了。 有了 cached_tokens 字段之后,我们能算出缓存每个月省了 2.1 万元,这让缓存优化的优先级排到了前面;
  4. 发现了异常使用。 有个租户的 token 消耗在两周内涨了 8 倍,查下来是他们在用我们的 API 做批量数据处理(违反协议)。如果没有计量,这个会一直白嫖下去。

隔离的性能代价

最后说说成本——不是钱的成本,是性能成本。

措施延迟增加说明
JWT 验签+0.08ms本地验签,可忽略
检索过滤-125ms分区裁剪反而更快
出参校验+3.2ms查文档归属,有缓存
用量埋点+0.2ms异步写入
净变化-121ms

隔离做下来,性能反而变好了。这主要归功于分区带来的检索加速。这个结果挺反直觉的——我们原本以为安全性和性能要做取舍,实际上正确的隔离设计(物理分区)两者兼得。

代价主要在运维:分区管理、计量数据管道、账单生成,这三块加起来我们投入了约 30 人日的初始建设,以及每月约 2 人日的运营。

小结

这次整改给我最大的教训,是那行「召回太少就去掉过滤」的代码。写它的人不笨,他只是在优化一个指标(召回率)的时候,完全没有意识到自己在拆安全边界。

所以我现在最看重的不是隔离技术有多强,而是让「违反隔离」这件事在流程上难以发生:静态检查规则、代码评审红线、出参兜底校验、以及那套会自动打 P1 告警的水印机制。技术手段会被绕过,流程约束不会。

成本这块则相反。我们一开始低估了它的难度,以为加个计数器就行。实际上要做到「能向客户解释清楚这笔钱怎么花的」,需要完整的明细采集和合理的分摊模型。这块建设了两个月,但它带来的价值(定价调整、异常发现、优化优先级)远超投入。

给做多租户 AI 应用的人一句建议:隔离和计量要一起做,不要先上线再补。补做计量的最大问题是历史数据缺失——你永远无法回答「上个月那个客户到底花了多少钱」。

参考