AI应用的多租户架构:一套系统服务100个企业客户
2026/11/1大约 6 分钟多租户SaaSSpring AI数据隔离Java
AI应用的多租户架构:一套系统服务100个企业客户
一、从一个客户到一百个,系统差点崩了
小王的公司做企业AI助手SaaS,2025年初只有3个客户,一切运行良好。
到了2026年,客户增长到了47个,然后噩梦开始了:
某天,A客户的AI助手居然能搜索到B客户的内部文档。
那是因为他们用的是同一个向量数据库,最初图省事没有做数据隔离。
更严重的是:A客户(金融公司)的知识库数据里有账户信息,被B客户(外部服务商)的AI检索到了。
这是一个重大的数据安全事故。他们一夜之间失去了A客户,差点被起诉。
这个故事的核心教训只有一条:SaaS AI应用从第一天就要设计多租户架构,数据隔离不是可选项,是生死线。
二、多租户隔离的三种模式
对于AI SaaS的向量数据库,推荐方案:
| 方案 | 向量DB隔离 | 关系型DB隔离 | 适用场景 |
|---|---|---|---|
| 方案A | 独立PGVector实例 | 独立PostgreSQL | 金融/医疗等高合规行业 |
| 方案B | 同一PGVector + 过滤 | Schema隔离 | 通用SaaS(推荐) |
| 方案C | 同一PGVector + 过滤 | 行级RLS | 成本敏感的小型SaaS |
三、租户上下文传播
3.1 从HTTP请求头提取租户信息
package com.laozhang.ai.multitenant;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 租户上下文过滤器
* 从HTTP请求中提取租户ID,存入ThreadLocal,在整个请求生命周期可用
*/
@Slf4j
@Component
@Order(1) // 最先执行
public class TenantContextFilter implements Filter {
private final TenantRepository tenantRepository;
private final JwtTokenParser jwtParser;
public TenantContextFilter(TenantRepository tenantRepository,
JwtTokenParser jwtParser) {
this.tenantRepository = tenantRepository;
this.jwtParser = jwtParser;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, jakarta.servlet.ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
// 方式1:从JWT Token中提取租户ID
String token = extractBearerToken(httpRequest);
String tenantId = null;
if (token != null) {
tenantId = jwtParser.extractClaim(token, "tenantId");
}
// 方式2:从自定义请求头提取(API Key场景)
if (tenantId == null) {
String apiKey = httpRequest.getHeader("X-Api-Key");
if (apiKey != null) {
tenantId = tenantRepository.findTenantIdByApiKey(apiKey);
}
}
// 方式3:从子域名提取(company1.yourapp.com)
if (tenantId == null) {
String host = httpRequest.getServerName();
tenantId = extractTenantFromSubdomain(host);
}
if (tenantId == null) {
log.warn("无法识别租户: path={}", httpRequest.getRequestURI());
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 验证租户有效性
if (!tenantRepository.isActiveTenant(tenantId)) {
log.warn("租户无效或已停用: tenantId={}", tenantId);
httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
// 存入线程上下文
TenantContext.setTenantId(tenantId);
log.debug("租户上下文已设置: tenantId={}, path={}", tenantId, httpRequest.getRequestURI());
chain.doFilter(request, response);
} finally {
// 请求结束后清理,防止线程复用时泄露
TenantContext.clear();
}
}
private String extractBearerToken(HttpServletRequest request) {
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
return auth.substring(7);
}
return null;
}
private String extractTenantFromSubdomain(String host) {
String[] parts = host.split("\\.");
if (parts.length >= 3) {
return parts[0]; // company1.yourapp.com -> company1
}
return null;
}
}3.2 租户上下文持有器
package com.laozhang.ai.multitenant;
/**
* 租户上下文持有器(ThreadLocal)
* 在整个请求生命周期内维护当前租户信息
*/
public final class TenantContext {
private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
private static final ThreadLocal<TenantConfig> TENANT_CONFIG = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
TENANT_ID.set(tenantId);
}
public static String getTenantId() {
String tenantId = TENANT_ID.get();
if (tenantId == null) {
throw new TenantContextException("当前线程没有租户上下文,请检查请求头配置");
}
return tenantId;
}
public static String getTenantIdOrNull() {
return TENANT_ID.get();
}
public static void setTenantConfig(TenantConfig config) {
TENANT_CONFIG.set(config);
}
public static TenantConfig getTenantConfig() {
return TENANT_CONFIG.get();
}
public static void clear() {
TENANT_ID.remove();
TENANT_CONFIG.remove();
}
private TenantContext() {}
}四、向量数据库的租户数据隔离
package com.laozhang.ai.multitenant.rag;
import com.laozhang.ai.multitenant.TenantContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 多租户RAG服务
* 所有向量操作都自动注入租户过滤条件
* 这是防止跨租户数据泄露的关键实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MultiTenantRagService {
private final VectorStore vectorStore;
/**
* 安全的向量检索
* 自动添加租户过滤,确保只检索当前租户的数据
*/
public List<Document> secureSimilaritySearch(String query, int topK) {
String tenantId = TenantContext.getTenantId();
log.debug("多租户向量检索: tenantId={}, query长度={}", tenantId, query.length());
// 关键:使用FilterExpression过滤租户数据
// 即使有人通过其他方式绕过,这里也会做最后一道过滤
return vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.7f)
.withFilterExpression("tenant_id == '" + sanitizeTenantId(tenantId) + "'")
);
}
/**
* 安全的文档添加
* 自动注入租户标识
*/
public void secureAddDocument(String content, Map<String, Object> metadata) {
String tenantId = TenantContext.getTenantId();
// 确保租户ID不被覆盖
Map<String, Object> secureMetadata = new java.util.HashMap<>(metadata);
secureMetadata.put("tenant_id", tenantId);
// 不允许调用者传入 tenant_id(防止覆盖注入)
if (metadata.containsKey("tenant_id")) {
log.warn("尝试自定义tenant_id被阻止: tenantId={}, attempted={}",
tenantId, metadata.get("tenant_id"));
}
Document doc = new Document(content, secureMetadata);
vectorStore.add(List.of(doc));
log.info("文档已添加到租户知识库: tenantId={}", tenantId);
}
/**
* 安全删除(只能删除当前租户的文档)
*/
public void secureDeleteDocument(String documentId) {
String tenantId = TenantContext.getTenantId();
// 先验证文档属于当前租户
boolean belongs = verifyDocumentBelongsToTenant(documentId, tenantId);
if (!belongs) {
log.error("越权删除尝试: documentId={}, tenantId={}", documentId, tenantId);
throw new SecurityException("无权删除该文档");
}
vectorStore.delete(List.of(documentId));
log.info("文档已删除: documentId={}, tenantId={}", documentId, tenantId);
}
/**
* 清理特殊字符,防止过滤器注入攻击
*/
private String sanitizeTenantId(String tenantId) {
return tenantId.replaceAll("[^a-zA-Z0-9_\\-]", "");
}
private boolean verifyDocumentBelongsToTenant(String documentId, String tenantId) {
// 实际实现:查询向量DB的metadata验证归属
return true; // 简化
}
}4.1 多租户AI服务入口
@Service
@RequiredArgsConstructor
@Slf4j
public class MultiTenantAiService {
private final ChatClient chatClient;
private final MultiTenantRagService ragService;
private final TenantConfigService tenantConfigService;
public String chat(String userQuestion) {
String tenantId = TenantContext.getTenantId();
TenantConfig config = tenantConfigService.getConfig(tenantId);
// 使用租户自定义的系统提示词(每个企业可定制AI人格)
String systemPrompt = config.getCustomSystemPrompt() != null
? config.getCustomSystemPrompt()
: "你是一个专业的AI助手。";
// 检索当前租户的知识库(自动过滤)
List<Document> relevantDocs = ragService.secureSimilaritySearch(userQuestion, 5);
String context = relevantDocs.stream()
.map(Document::getContent)
.collect(java.util.stream.Collectors.joining("\n\n---\n\n"));
log.info("多租户AI对话: tenantId={}, docsFound={}", tenantId, relevantDocs.size());
return chatClient.prompt()
.system(systemPrompt + (context.isEmpty() ? "" : "\n\n知识库内容:\n" + context))
.user(userQuestion)
.call()
.content();
}
}五、租户配额和限流
@Component
@RequiredArgsConstructor
public class TenantQuotaEnforcer {
private final RedisTemplate<String, Long> redisTemplate;
private final TenantPlanRepository planRepository;
public void checkAndConsumeQuota(String tenantId, int estimatedTokens) {
TenantPlan plan = planRepository.findByTenantId(tenantId);
String monthKey = "quota:" + tenantId + ":" + YearMonth.now();
Long usedTokens = redisTemplate.opsForValue().get(monthKey);
if (usedTokens == null) usedTokens = 0L;
if (usedTokens + estimatedTokens > plan.getMonthlyTokenLimit()) {
throw new QuotaExceededException(
"月度Token配额已用完(" + plan.getMonthlyTokenLimit() + "),请升级套餐");
}
// 消耗配额
redisTemplate.opsForValue().increment(monthKey, estimatedTokens);
redisTemplate.expire(monthKey, Duration.ofDays(35)); // 保留35天
}
}六、小王的数据安全事故后续
小王在那次事故之后,用了4周时间重构了整个多租户架构:
- 向量数据库:所有查询强制加tenant_id过滤
- 关系型数据库:使用PostgreSQL RLS(行级安全)
- API层:所有接口都经过租户上下文验证
- 审计日志:记录每一次跨租户操作尝试
重构完成后,他们上线了渗透测试,用A客户的账号尝试访问B客户的数据,100%被拦截。
现在他们的客户数量已经到了92个,没有再发生数据泄露事件。
多租户架构是SaaS AI应用的地基,地基不牢,楼建得越高越危险。
