「客户 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 | 是(部分) | 过滤选择性高时可能退化为顺序扫描 |
| Milvus | boolean expression | 是 | 表达式语法和 SQL 不同,要单独适配 |
| Qdrant | 完整,支持嵌套 | 是 | 需要为过滤字段建 payload index,否则很慢 |
| Elasticsearch | 完整 DSL | 是 | kNN + 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.71 | 148ms | 18 GB(全库一个) |
| 按租户分区 | 0.94 | 23ms | 18 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;
计量带来的业务价值
成本计量不只是为了算账,它帮我们发现了几个之前完全不知道的问题:
- 租户间的成本差异极大。 我们 TOP 3 租户占了 61% 的成本,而他们的付费只占 34%。这个发现直接推动了定价策略调整——从「按席位收费」改成「席位 + 用量包」;
- 某些功能的成本被严重低估。 「长文档分析」这个功能单次成本 0.34 元,而我们定价时按 0.05 元估的。这个功能在两个月里亏了 4.7 万;
- 缓存的价值被量化了。 有了
cached_tokens字段之后,我们能算出缓存每个月省了 2.1 万元,这让缓存优化的优先级排到了前面; - 发现了异常使用。 有个租户的 token 消耗在两周内涨了 8 倍,查下来是他们在用我们的 API 做批量数据处理(违反协议)。如果没有计量,这个会一直白嫖下去。
隔离的性能代价
最后说说成本——不是钱的成本,是性能成本。
| 措施 | 延迟增加 | 说明 |
|---|---|---|
| JWT 验签 | +0.08ms | 本地验签,可忽略 |
| 检索过滤 | -125ms | 分区裁剪反而更快 |
| 出参校验 | +3.2ms | 查文档归属,有缓存 |
| 用量埋点 | +0.2ms | 异步写入 |
| 净变化 | -121ms |
隔离做下来,性能反而变好了。这主要归功于分区带来的检索加速。这个结果挺反直觉的——我们原本以为安全性和性能要做取舍,实际上正确的隔离设计(物理分区)两者兼得。
代价主要在运维:分区管理、计量数据管道、账单生成,这三块加起来我们投入了约 30 人日的初始建设,以及每月约 2 人日的运营。
小结
这次整改给我最大的教训,是那行「召回太少就去掉过滤」的代码。写它的人不笨,他只是在优化一个指标(召回率)的时候,完全没有意识到自己在拆安全边界。
所以我现在最看重的不是隔离技术有多强,而是让「违反隔离」这件事在流程上难以发生:静态检查规则、代码评审红线、出参兜底校验、以及那套会自动打 P1 告警的水印机制。技术手段会被绕过,流程约束不会。
成本这块则相反。我们一开始低估了它的难度,以为加个计数器就行。实际上要做到「能向客户解释清楚这笔钱怎么花的」,需要完整的明细采集和合理的分摊模型。这块建设了两个月,但它带来的价值(定价调整、异常发现、优化优先级)远超投入。
给做多租户 AI 应用的人一句建议:隔离和计量要一起做,不要先上线再补。补做计量的最大问题是历史数据缺失——你永远无法回答「上个月那个客户到底花了多少钱」。