Spring AI 多租户隔离方案
本文讲解如何在 Spring AI 应用中实现多租户隔离,包括数据隔离、资源隔离、计费隔离、配置动态化以及生产级监控告警。
一、多租户架构概述
1.1 隔离级别详解
┌─────────────────────────────────────────────────────────────────────────┐
│ 多租户隔离级别(从轻到重) │
│ │
│ 级别 0: 无隔离 │
│ └── 所有租户共享一切,仅通过元数据字段区分(不推荐生产) │
│ │
│ 级别 1: 应用层隔离(逻辑隔离) │
│ ├── 共享应用实例、共享基础设施 │
│ ├── 共享 API Key(或动态切换) │
│ └── 通过租户 ID 在 VectorStore 元数据中过滤 │
│ │
│ 级别 2: 数据层隔离 │
│ ├── 每个租户独立 API Key / 独立模型配置 │
│ ├── 独立向量存储命名空间(Collection / Index / Table) │
│ ├── 独立 Embedding 模型(可选) │
│ └── 独立对话记忆(ChatMemory) │
│ │
│ 级别 3: 基础设施隔离 │
│ ├── 独立应用实例(Deployment) │
│ ├── 独立数据库实例 │
│ └── 独立资源配额(CPU/内存) │
│ │
│ 级别 4: 完全隔离(VPC / 专有云) │
│ └── 不同租户部署在不同 VPC / 云账户,物理隔离 │
└─────────────────────────────────────────────────────────────────────────┘
1.2 多租户核心需求矩阵
| 需求 | 级别0 | 级别1 | 级别2 | 级别3 | 级别4 | 实现复杂度 |
|---|---|---|---|---|---|---|
| 数据隔离 | ❌ | ✅ | ✅ | ✅ | ✅ | 低 |
| 资源配额 | ❌ | ✅ | ✅ | ✅ | ✅ | 中 |
| 性能隔离 | ❌ | ❌ | ✅ | ✅ | ✅ | 高 |
| 独立模型 | ❌ | ❌ | ✅ | ✅ | ✅ | 中 |
| 独立计费 | ❌ | ✅ | ✅ | ✅ | ✅ | 低 |
| 故障隔离 | ❌ | ❌ | ❌ | ✅ | ✅ | 高 |
典型选择:
- SaaS 初创期:级别1(逻辑隔离),快速上线。
- SaaS 成长期:级别2(数据层隔离),支持大客户独立配置。
- 企业级 / 金融场景:级别3+,物理隔离满足合规要求。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、租户上下文管理(增强)
2.1 支持多种租户传递方式
java
package com.example.ai.multitenancy.context;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.function.Supplier;
/**
* 租户上下文管理(支持多源获取)
*/
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
private static final ThreadLocal<Map<String, Object>> CONTEXT_DATA = new ThreadLocal<>();
// 可插拔的租户 ID 提供者
private static Supplier<String> tenantProvider = () -> {
// 默认从 ThreadLocal 获取
return CURRENT_TENANT.get();
};
public static void setTenantProvider(Supplier<String> provider) {
tenantProvider = provider;
}
public static String getTenantId() {
String tenant = tenantProvider.get();
if (tenant == null || tenant.isBlank()) {
tenant = "default";
}
return tenant;
}
// 手动设置(用于测试或非Web场景)
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
// 从请求头自动提取(通过拦截器)
public static String extractFromRequest() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
String header = attributes.getRequest().getHeader("X-Tenant-Id");
if (header != null && !header.isBlank()) {
return header;
}
// 也可以从 JWT / OAuth2 token 中提取
String token = attributes.getRequest().getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
// 解析 JWT 中的 tenant 字段
return parseTenantFromJwt(token.substring(7));
}
}
return null;
}
public static void clear() {
CURRENT_TENANT.remove();
CONTEXT_DATA.remove();
}
// 上下文数据(用于传递额外信息,如租户配置快照)
public static void setContextData(Map<String, Object> data) {
CONTEXT_DATA.set(data);
}
public static Map<String, Object> getContextData() {
return CONTEXT_DATA.get() != null ? CONTEXT_DATA.get() : Map.of();
}
public static <T> T getContextData(String key, Class<T> type) {
Map<String, Object> data = CONTEXT_DATA.get();
if (data == null) return null;
Object value = data.get(key);
if (type.isInstance(value)) {
return type.cast(value);
}
return null;
}
}
2.2 响应式上下文(WebFlux)
对于响应式场景,使用 Context 传递租户:
java
package com.example.ai.multitenancy.context;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;
/**
* 响应式租户上下文
*/
public class ReactiveTenantContext {
private static final String TENANT_KEY = "tenantId";
public static Mono<String> getTenantId() {
return Mono.deferContextual(ctx ->
Mono.justOrEmpty(ctx.getOrEmpty(TENANT_KEY))
.defaultIfEmpty("default")
);
}
public static Context withTenant(String tenantId) {
return Context.of(TENANT_KEY, tenantId);
}
// WebFilter 自动注入
public static class TenantWebFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String tenant = exchange.getRequest().getHeaders().getFirst("X-Tenant-Id");
if (tenant == null || tenant.isBlank()) {
tenant = "default";
}
return chain.filter(exchange)
.contextWrite(Context.of(TENANT_KEY, tenant));
}
}
}
2.3 异步任务中的租户传递
确保异步任务也能正确携带租户上下文:
java
@Configuration
public class AsyncTenantConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
// 自定义装饰器,传递租户上下文
executor.setTaskDecorator(runnable -> {
String tenantId = TenantContext.getTenantId();
Map<String, Object> data = TenantContext.getContextData();
return () -> {
try {
TenantContext.setTenantId(tenantId);
TenantContext.setContextData(data);
runnable.run();
} finally {
TenantContext.clear();
}
};
});
return executor;
}
}
三、租户感知的向量存储(增强)
3.1 支持多命名空间策略
不同的向量数据库对命名空间的支持不同,需要适配:
| 向量数据库 | 命名空间机制 | 对应实现方式 |
|---|---|---|
| PGVector | 不同的表(Table) | tableName 动态拼接租户 ID |
| Redis | 不同的 Key 前缀(Prefix) | prefix + 租户 ID |
| Qdrant | 不同的 Collection | collectionName 动态选择 |
| Milvus | 不同的 Partition 或 Collection | 租户作为 Partition Key |
| Elasticsearch | 不同的 Index | Index 名包含租户 ID |
多租户 VectorStore 工厂:
java
package com.example.ai.multitenancy.vector;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.pgvector.PgVectorStore;
import org.springframework.ai.vectorstore.redis.RedisVectorStore;
import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import io.qdrant.client.QdrantClient;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 多租户 VectorStore 工厂(按租户创建不同的存储实例)
*/
@Component
public class TenantVectorStoreFactory {
private final Map<String, VectorStore> tenantStores = new ConcurrentHashMap<>();
private final JdbcTemplate jdbcTemplate;
private final LettuceConnectionFactory redisFactory;
private final QdrantClient qdrantClient;
private final VectorStoreType storeType;
public enum VectorStoreType {
PGVECTOR, REDIS, QDRANT
}
public VectorStore getVectorStore(String tenantId) {
return tenantStores.computeIfAbsent(tenantId, this::createVectorStore);
}
private VectorStore createVectorStore(String tenantId) {
switch (storeType) {
case PGVECTOR:
return createPgVectorStore(tenantId);
case REDIS:
return createRedisVectorStore(tenantId);
case QDRANT:
return createQdrantVectorStore(tenantId);
default:
throw new IllegalArgumentException("Unsupported store type");
}
}
private VectorStore createPgVectorStore(String tenantId) {
// 每个租户使用独立的表
String tableName = "vector_docs_" + tenantId.replace("-", "_");
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
.tableName(tableName)
.dimensions(1536)
.initializeSchema(true)
.build();
}
private VectorStore createRedisVectorStore(String tenantId) {
// 每个租户使用独立的 key 前缀
String prefix = "vstore:" + tenantId + ":";
return RedisVectorStore.builder(redisFactory, embeddingModel)
.prefix(prefix)
.indexName("vector-idx-" + tenantId)
.initializeSchema(true)
.build();
}
private VectorStore createQdrantVectorStore(String tenantId) {
// 每个租户使用独立的 collection
String collectionName = "documents_" + tenantId;
return QdrantVectorStore.builder(qdrantClient, embeddingModel)
.collectionName(collectionName)
.initializeSchema(true)
.build();
}
}
3.2 带租户过滤的 VectorStore 装饰器(增强版)
java
package com.example.ai.multitenancy.vector;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpression;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 增强型多租户 VectorStore 包装器
* 支持:租户元数据注入、过滤表达式自动拼接、租户隔离级别配置
*/
@Component
public class EnhancedMultiTenantVectorStore implements VectorStore {
private final VectorStore delegate;
private final TenantIsolationLevel isolationLevel;
public EnhancedMultiTenantVectorStore(VectorStore delegate,
TenantIsolationLevel isolationLevel) {
this.delegate = delegate;
this.isolationLevel = isolationLevel;
}
public enum TenantIsolationLevel {
/**
* 逻辑隔离:通过元数据字段 tenant_id 过滤
*/
METADATA,
/**
* 物理隔离:每个租户使用独立的 Collection/Table/Index
*/
PHYSICAL
}
@Override
public void add(List<Document> documents) {
if (isolationLevel == TenantIsolationLevel.METADATA) {
// 元数据隔离:添加租户 ID 到每个文档
String tenantId = TenantContext.getTenantId();
documents.forEach(doc -> {
if (doc.getMetadata() == null) {
doc.setMetadata(new HashMap<>());
}
doc.getMetadata().put("tenant_id", tenantId);
// 还可以添加租户级别的其他元数据(如环境、区域等)
doc.getMetadata().put("tenant_env",
TenantContext.getContextData("env", String.class));
});
}
// 物理隔离模式下,delegate 已经是租户专用的实例,无需额外处理
delegate.add(documents);
}
@Override
public void delete(List<String> idList) {
delegate.delete(idList);
}
@Override
public void delete(String filterExpression) {
if (isolationLevel == TenantIsolationLevel.METADATA) {
String tenantFilter = buildTenantFilter();
String combined = combineFilters(filterExpression, tenantFilter);
delegate.delete(combined);
} else {
delegate.delete(filterExpression);
}
}
@Override
public List<Document> similaritySearch(SearchRequest request) {
SearchRequest finalRequest = request;
if (isolationLevel == TenantIsolationLevel.METADATA) {
String tenantFilter = buildTenantFilter();
String existing = request.getFilterExpression();
String combined = combineFilters(existing, tenantFilter);
finalRequest = request.mutate()
.filterExpression(combined)
.build();
}
return delegate.similaritySearch(finalRequest);
}
private String buildTenantFilter() {
String tenantId = TenantContext.getTenantId();
return "tenant_id == '" + tenantId + "'";
}
private String combineFilters(String existing, String tenantFilter) {
if (existing == null || existing.isBlank()) {
return tenantFilter;
}
// 检查 existing 是否已经包含 tenant_id 条件(避免重复)
if (existing.contains("tenant_id")) {
return existing;
}
return "(" + existing + ") && (" + tenantFilter + ")";
}
// 批量操作:支持多个租户的查询(跨租户分析场景)
public List<Document> searchAcrossTenants(String query, List<String> tenantIds) {
// 仅在特殊场景(如管理员)使用
String tenantFilter = tenantIds.stream()
.map(id -> "tenant_id == '" + id + "'")
.collect(Collectors.joining(" || "));
SearchRequest request = SearchRequest.builder()
.query(query)
.filterExpression(tenantFilter)
.build();
return delegate.similaritySearch(request);
}
}
四、租户配置管理
4.1 租户配置持久化(数据库 + 缓存)
java
package com.example.ai.multitenancy.config;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.util.concurrent.TimeUnit;
/**
* 租户配置服务(支持数据库持久化和 Redis 缓存)
*/
@Service
public class TenantConfigService {
private final JdbcTemplate jdbcTemplate;
private final RedisTemplate<String, Object> redisTemplate;
public TenantConfigService(DataSource dataSource,
RedisTemplate<String, Object> redisTemplate) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
this.redisTemplate = redisTemplate;
}
/**
* 获取租户配置(带缓存)
*/
@Cacheable(value = "tenantConfigs", key = "#tenantId", unless = "#result == null")
public TenantConfig getTenantConfig(String tenantId) {
// 先从缓存获取(Caffeine/Redis)
String cacheKey = "tenant:config:" + tenantId;
TenantConfig cached = (TenantConfig) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// 从数据库加载
TenantConfig config = loadFromDatabase(tenantId);
if (config != null) {
redisTemplate.opsForValue().set(cacheKey, config, 5, TimeUnit.MINUTES);
}
return config;
}
private TenantConfig loadFromDatabase(String tenantId) {
String sql = """
SELECT tenant_id, api_key, model_name, daily_token_quota,
monthly_token_quota, max_requests_per_minute,
max_tokens_per_request, enabled, extra_config
FROM tenant_configs WHERE tenant_id = ?
""";
return jdbcTemplate.query(sql, rs -> {
if (rs.next()) {
TenantConfig config = new TenantConfig();
config.setTenantId(rs.getString("tenant_id"));
config.setApiKey(rs.getString("api_key"));
config.setModelName(rs.getString("model_name"));
config.setDailyTokenQuota(rs.getLong("daily_token_quota"));
config.setMonthlyTokenQuota(rs.getLong("monthly_token_quota"));
config.setMaxRequestsPerMinute(rs.getInt("max_requests_per_minute"));
config.setMaxTokensPerRequest(rs.getInt("max_tokens_per_request"));
config.setEnabled(rs.getBoolean("enabled"));
config.setExtraConfig(parseExtraConfig(rs.getString("extra_config")));
return config;
}
return null;
}, tenantId);
}
/**
* 刷新租户配置(供管理端调用)
*/
public void refreshTenantConfig(String tenantId) {
String cacheKey = "tenant:config:" + tenantId;
redisTemplate.delete(cacheKey);
// 同时清理 Spring Cache
CacheManager cacheManager = ...;
cacheManager.getCache("tenantConfigs").evict(tenantId);
}
/**
* 租户配置变更监听(事件驱动)
*/
@EventListener
public void onTenantConfigChanged(TenantConfigChangedEvent event) {
refreshTenantConfig(event.getTenantId());
// 通知相关组件重新初始化租户资源
applicationEventPublisher.publishEvent(
new TenantResourceRefreshEvent(event.getTenantId())
);
}
}
4.2 租户级别的模型路由
支持为不同租户动态选择不同的 LLM 模型(GPT-4 / Claude / 国产模型等):
java
package com.example.ai.multitenancy.routing;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 租户模型路由器
*/
@Component
public class TenantModelRouter {
private final Map<String, ChatModel> modelCache = new ConcurrentHashMap<>();
private final TenantConfigService configService;
public TenantModelRouter(TenantConfigService configService) {
this.configService = configService;
}
public ChatModel getModelForTenant(String tenantId) {
return modelCache.computeIfAbsent(tenantId, this::createModel);
}
private ChatModel createModel(String tenantId) {
TenantConfig config = configService.getTenantConfig(tenantId);
if (config == null) {
throw new TenantNotFoundException(tenantId);
}
String modelName = config.getModelName();
if (modelName == null) {
modelName = "gpt-3.5-turbo"; // 默认
}
// 根据模型名称路由
if (modelName.startsWith("gpt-") || modelName.startsWith("text-davinci")) {
return createOpenAiModel(config);
} else if (modelName.startsWith("claude-")) {
return createAnthropicModel(config);
} else if (modelName.startsWith("qwen-")) {
return createQwenModel(config);
} else {
// 自定义或默认
return createDefaultModel(config);
}
}
private ChatModel createOpenAiModel(TenantConfig config) {
OpenAiApi api = OpenAiApi.builder()
.apiKey(config.getApiKey())
.build();
return OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(OpenAiChatOptions.builder()
.model(config.getModelName())
.maxTokens(config.getMaxTokensPerRequest())
.build())
.build();
}
private ChatModel createAnthropicModel(TenantConfig config) {
AnthropicApi api = AnthropicApi.builder()
.apiKey(config.getApiKey())
.build();
return AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(AnthropicChatOptions.builder()
.model(config.getModelName())
.maxTokens(config.getMaxTokensPerRequest())
.build())
.build();
}
// 其他模型...
}
五、租户级别的限流与配额
5.1 分布式限流(基于 Redis + Lua 脚本)
java
package com.example.ai.multitenancy.rate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
/**
* 分布式限流器(滑动窗口,基于 Redis Lua)
*/
@Component
public class DistributedRateLimiter {
private final RedisTemplate<String, Object> redisTemplate;
private final DefaultRedisScript<List<Long>> rateLimitScript;
private static final String LUA_SCRIPT = """
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 时间窗口(秒)
local limit = tonumber(ARGV[2]) -- 限制次数
local current_time = tonumber(ARGV[3])
-- 清理过期数据
redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window * 1000)
-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, current_time, current_time .. ':' .. math.random())
redis.call('EXPIRE', key, window + 60)
return {1, limit - count - 1}
else
return {0, 0}
end
""";
public DistributedRateLimiter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.rateLimitScript = new DefaultRedisScript<>(LUA_SCRIPT, List.class);
}
/**
* 检查并消耗配额
* @param key 限流key(如 tenantId + ":" + apiName)
* @param windowSeconds 时间窗口(秒)
* @param limit 限制次数
* @return {允许, 剩余配额}
*/
public boolean tryAcquire(String key, int windowSeconds, int limit) {
List<Long> result = redisTemplate.execute(
rateLimitScript,
List.of(key),
String.valueOf(windowSeconds),
String.valueOf(limit),
String.valueOf(Instant.now().toEpochMilli())
);
return result != null && result.get(0) == 1L;
}
}
5.2 多维度配额检查
java
@Service
public class TenantQuotaService {
private final DistributedRateLimiter rateLimiter;
private final TenantConfigService configService;
private final RedisTemplate<String, Long> redisTemplate;
/**
* 综合配额检查(涵盖多个维度)
*/
public QuotaCheckResult checkAndConsume(String tenantId,
String apiName,
int estimatedTokens) {
TenantConfig config = configService.getTenantConfig(tenantId);
if (config == null || !config.isEnabled()) {
return QuotaCheckResult.denied("租户无效或已禁用");
}
// 1. 频率限制(按 API)
String rateKey = "rate:" + tenantId + ":" + apiName;
if (!rateLimiter.tryAcquire(rateKey, 60, config.getMaxRequestsPerMinute())) {
return QuotaCheckResult.denied("请求频率超限(每分钟最多 " +
config.getMaxRequestsPerMinute() + " 次)");
}
// 2. 单次请求 Token 限制
if (estimatedTokens > config.getMaxTokensPerRequest()) {
return QuotaCheckResult.denied("单次请求 Token 数 " + estimatedTokens +
" 超过限制 " + config.getMaxTokensPerRequest());
}
// 3. 每日 Token 配额(滑动窗口)
String dailyKey = "quota:daily:" + tenantId + ":" + LocalDate.now();
Long dailyUsed = redisTemplate.opsForValue().get(dailyKey);
dailyUsed = dailyUsed != null ? dailyUsed : 0L;
if (dailyUsed + estimatedTokens > config.getDailyTokenQuota()) {
return QuotaCheckResult.denied("每日 Token 配额已用完(" +
dailyUsed + "/" + config.getDailyTokenQuota() + ")");
}
// 4. 每月 Token 配额
String monthlyKey = "quota:monthly:" + tenantId + ":" +
YearMonth.now().toString();
Long monthlyUsed = redisTemplate.opsForValue().get(monthlyKey);
monthlyUsed = monthlyUsed != null ? monthlyUsed : 0L;
if (monthlyUsed + estimatedTokens > config.getMonthlyTokenQuota()) {
return QuotaCheckResult.denied("每月 Token 配额已用完");
}
// 5. 消耗配额
redisTemplate.opsForValue().increment(dailyKey, estimatedTokens);
redisTemplate.expire(dailyKey, 2, TimeUnit.DAYS);
redisTemplate.opsForValue().increment(monthlyKey, estimatedTokens);
redisTemplate.expire(monthlyKey, 32, TimeUnit.DAYS);
return QuotaCheckResult.allowed(config.getApiKey(), config.getModelName());
}
}
六、租户感知的 ChatClient 与 RAG
6.1 完整的多租户 ChatClient 工厂
java
package com.example.ai.multitenancy.chat;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 多租户 ChatClient 工厂
* 为每个租户创建独立的 ChatClient,包含租户专属配置、Advisor 和模型
*/
@Component
public class TenantChatClientFactory {
private final Map<String, ChatClient> clientCache = new ConcurrentHashMap<>();
private final TenantModelRouter modelRouter;
private final TenantVectorStoreFactory vectorStoreFactory;
private final TenantQuotaService quotaService;
private final TenantConfigService configService;
public ChatClient getChatClient(String tenantId) {
return clientCache.computeIfAbsent(tenantId, this::buildChatClient);
}
private ChatClient buildChatClient(String tenantId) {
TenantConfig config = configService.getTenantConfig(tenantId);
ChatModel model = modelRouter.getModelForTenant(tenantId);
VectorStore vectorStore = vectorStoreFactory.getVectorStore(tenantId);
// 租户专属的 System Prompt
String systemPrompt = config.getExtraConfig() != null ?
config.getExtraConfig().getOrDefault("system_prompt",
"你是一个AI助手,请基于已知信息回答问题。") :
"你是一个AI助手,请基于已知信息回答问题。";
// 租户专属的 RAG 配置
QuestionAnswerAdvisor ragAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(config.getExtraConfig() != null ?
Integer.parseInt(config.getExtraConfig()
.getOrDefault("rag_top_k", "5")) : 5)
.similarityThreshold(config.getExtraConfig() != null ?
Double.parseDouble(config.getExtraConfig()
.getOrDefault("rag_threshold", "0.7")) : 0.7)
.filterExpression("tenant_id == '" + tenantId + "'")
.build())
.build();
return ChatClient.builder(model)
.defaultSystem(systemPrompt)
.defaultAdvisors(ragAdvisor)
// 租户专属的 ToolCallbacks 也可以在这里配置
.build();
}
/**
* 清除租户缓存(配置变更时调用)
*/
public void evict(String tenantId) {
clientCache.remove(tenantId);
modelRouter.evict(tenantId);
vectorStoreFactory.evict(tenantId);
}
}
6.2 租户级别的对话记忆隔离
java
package com.example.ai.multitenancy.memory;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.messages.Message;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 多租户对话记忆
*/
@Component
public class MultiTenantChatMemory implements ChatMemory {
// 租户ID -> 用户ID -> 消息列表
private final Map<String, Map<String, List<Message>>> tenantMemory = new ConcurrentHashMap<>();
private final int maxMessagesPerConversation = 100;
@Override
public void add(String conversationId, List<Message> messages) {
String tenantId = TenantContext.getTenantId();
String key = tenantId + ":" + conversationId;
tenantMemory.computeIfAbsent(tenantId, k -> new ConcurrentHashMap<>())
.computeIfAbsent(conversationId, k -> new ArrayList<>())
.addAll(messages);
// 限制消息数量
List<Message> list = tenantMemory.get(tenantId).get(conversationId);
if (list.size() > maxMessagesPerConversation) {
list.subList(0, list.size() - maxMessagesPerConversation).clear();
}
}
@Override
public List<Message> get(String conversationId, int lastN) {
String tenantId = TenantContext.getTenantId();
Map<String, List<Message>> userMem = tenantMemory.get(tenantId);
if (userMem == null) return List.of();
List<Message> list = userMem.get(conversationId);
if (list == null || list.isEmpty()) return List.of();
int start = Math.max(0, list.size() - lastN);
return list.subList(start, list.size());
}
@Override
public void clear(String conversationId) {
String tenantId = TenantContext.getTenantId();
Map<String, List<Message>> userMem = tenantMemory.get(tenantId);
if (userMem != null) {
userMem.remove(conversationId);
}
}
// 清理租户所有记忆(租户注销时)
public void clearAllForTenant(String tenantId) {
tenantMemory.remove(tenantId);
}
}
七、监控与审计
7.1 租户级用量统计
java
package com.example.ai.multitenancy.monitor;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 租户用量统计(内存 + 定期持久化)
*/
@Service
public class TenantUsageService {
private final MeterRegistry meterRegistry;
private final Map<String, TenantUsage> usageMap = new ConcurrentHashMap<>();
private final JdbcTemplate jdbcTemplate;
public void recordUsage(String tenantId, String modelName,
int inputTokens, int outputTokens,
long latencyMs, boolean success) {
TenantUsage usage = usageMap.computeIfAbsent(tenantId,
k -> new TenantUsage(tenantId));
usage.incrementRequests();
usage.addInputTokens(inputTokens);
usage.addOutputTokens(outputTokens);
usage.addLatency(latencyMs);
if (!success) {
usage.incrementErrors();
}
// 实时指标上报
meterRegistry.counter("ai.requests.total",
"tenant", tenantId, "model", modelName).increment();
meterRegistry.summary("ai.tokens.input", "tenant", tenantId)
.record(inputTokens);
meterRegistry.summary("ai.tokens.output", "tenant", tenantId)
.record(outputTokens);
meterRegistry.timer("ai.latency", "tenant", tenantId)
.record(Duration.ofMillis(latencyMs));
}
/**
* 每小时持久化一次用量到数据库
*/
@Scheduled(cron = "0 0 * * * ?")
public void persistUsage() {
LocalDateTime now = LocalDateTime.now();
String hour = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
for (TenantUsage usage : usageMap.values()) {
if (usage.getRequests() == 0) continue;
String sql = """
INSERT INTO tenant_usage_hourly
(tenant_id, hour, requests, input_tokens, output_tokens,
total_tokens, avg_latency, errors)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
requests = requests + VALUES(requests),
input_tokens = input_tokens + VALUES(input_tokens),
output_tokens = output_tokens + VALUES(output_tokens),
total_tokens = total_tokens + VALUES(total_tokens),
avg_latency = (avg_latency * requests + VALUES(avg_latency) * VALUES(requests))
/ (requests + VALUES(requests)),
errors = errors + VALUES(errors)
""";
jdbcTemplate.update(sql,
usage.getTenantId(),
hour,
usage.getRequests(),
usage.getInputTokens(),
usage.getOutputTokens(),
usage.getTotalTokens(),
usage.getAverageLatency(),
usage.getErrors()
);
// 重置计数器
usage.reset();
}
}
@Data
public static class TenantUsage {
private String tenantId;
private AtomicLong requests = new AtomicLong(0);
private AtomicLong inputTokens = new AtomicLong(0);
private AtomicLong outputTokens = new AtomicLong(0);
private AtomicLong totalLatency = new AtomicLong(0);
private AtomicLong errors = new AtomicLong(0);
// ...
}
}
7.2 审计日志
java
package com.example.ai.multitenancy.audit;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 审计日志过滤器
*/
@Component
public class AuditLogFilter extends OncePerRequestFilter {
private final AuditLogService auditLogService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
String tenantId = TenantContext.getTenantId();
String uri = request.getRequestURI();
String method = request.getMethod();
try {
chain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - startTime;
int status = response.getStatus();
// 异步记录审计日志(不阻塞主流程)
CompletableFuture.runAsync(() ->
auditLogService.log(new AuditEntry(
tenantId,
uri,
method,
status,
duration,
request.getRemoteAddr(),
LocalDateTime.now()
))
);
}
}
}
@Service
public class AuditLogService {
public void log(AuditEntry entry) {
// 写入数据库、Elasticsearch 或消息队列
String sql = """
INSERT INTO audit_logs
(tenant_id, uri, method, status, duration, client_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""";
jdbcTemplate.update(sql,
entry.getTenantId(),
entry.getUri(),
entry.getMethod(),
entry.getStatus(),
entry.getDuration(),
entry.getClientIp(),
entry.getCreatedAt()
);
}
}
八、租户健康检查与告警
java
@Component
public class TenantHealthIndicator implements HealthIndicator {
private final TenantConfigService configService;
private final Map<String, Health> tenantHealth = new ConcurrentHashMap<>();
@Override
public Health health() {
Health.Builder builder = Health.up();
for (String tenantId : configService.getAllTenantIds()) {
Health status = checkTenant(tenantId);
builder.withDetail("tenant_" + tenantId, status);
}
return builder.build();
}
private Health checkTenant(String tenantId) {
try {
// 检查租户配置是否存在
TenantConfig config = configService.getTenantConfig(tenantId);
if (config == null || !config.isEnabled()) {
return Health.down().withDetail("reason", "配置不存在或已禁用").build();
}
// 检查 API Key 有效性(调用轻量级 API)
boolean apiValid = validateApiKey(config.getApiKey());
if (!apiValid) {
return Health.down().withDetail("reason", "API Key 无效").build();
}
// 检查配额是否耗尽
long remaining = getRemainingDailyQuota(tenantId);
if (remaining <= 0) {
return Health.down().withDetail("reason", "日配额已耗尽").build();
}
return Health.up()
.withDetail("remaining_daily_quota", remaining)
.withDetail("model", config.getModelName())
.build();
} catch (Exception e) {
return Health.down(e).build();
}
}
@Scheduled(fixedRate = 60000) // 每分钟检查一次
public void checkQuotaAlerts() {
for (String tenantId : configService.getAllTenantIds()) {
long used = getDailyUsage(tenantId);
long quota = configService.getTenantConfig(tenantId).getDailyTokenQuota();
double usagePercent = (double) used / quota;
if (usagePercent > 0.9) {
// 发送告警(邮件、钉钉、Slack)
alertService.sendAlert(tenantId,
"日配额使用超过90%,已使用 " + used + "/" + quota);
}
}
}
}
九、Kubernetes 多租户部署
9.1 Namespace 级别隔离
在 K8s 中为每个租户创建独立的 Namespace,实现基础设施隔离:
yaml
# tenant-ns.yaml
apiVersion: v1
kind: Namespace
metadata:
name: tenant-001
---
# 每个租户独立的资源配额
apiVersion: v1
kind: ResourceQuota
metadata:
name: ai-quota
namespace: tenant-001
spec:
hard:
requests.cpu: "4"
requests.memory: "8Gi"
limits.cpu: "8"
limits.memory: "16Gi"
---
# 网络策略(隔离租户间通信)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-other-tenants
namespace: tenant-001
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
tenant: "001"
9.2 租户 Pod 自动注入环境变量
使用 Admission Webhook 自动注入租户 ID 和配置:
yaml
# 在 Pod 中注入租户信息
apiVersion: v1
kind: Pod
metadata:
labels:
tenant-id: "001"
spec:
containers:
- name: ai-app
env:
- name: TENANT_ID
valueFrom:
fieldRef:
fieldPath: metadata.labels['tenant-id']
- name: TENANT_CONFIG_PATH
value: "/etc/config/tenant-001.yaml"
9.3 租户级别的 HPA(水平自动扩缩容)
根据租户负载独立扩缩容:
yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ai-hpa-tenant-001
namespace: tenant-001
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ai-app
minReplicas: 1
maxReplicas: 5
metrics:
- type: Pods
pods:
metric:
name: requests_per_second
target:
type: AverageValue
averageValue: 50
十、总结
10.1 各隔离层次完整对比
| 层次 | 租户识别 | 数据隔离方式 | 资源隔离 | 故障隔离 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|---|---|
| 逻辑隔离 | Header | 元数据过滤 | 应用层限流 | 低 | 低 | MVP / 内部工具 |
| 命名空间隔离 | Header | 独立 Collection/Table | 独立限流配额 + 模型配置 | 中 | 中 | SaaS 多租户 |
| 模型路由隔离 | Header | 独立 Collection + 独立模型 | 独立 API Key + 模型 | 中 | 中 | 大客户定制模型 |
| K8s Namespace | Label | 独立数据库实例 | CPU/内存配额 + 网络隔离 | 高 | 高 | 企业级严格隔离 |
| 物理隔离 VPC | 独立集群 | 完全独立 | 完全独立 | 极高 | 极高 | 金融 / 合规场景 |
10.2 最佳实践清单
- 租户 ID 标准化:统一使用 UUID 或业务 ID,避免特殊字符。
- 多级缓存:租户配置使用 Caffeine(本地)+ Redis(分布式)两级缓存。
- 配额预警:设定 80%、90%、100% 三级告警阈值。
- 审计日志:记录所有 API 调用和租户操作,满足合规要求。
- 动态配置热加载:通过事件机制(如 Spring Cloud Bus)实现配置变更无需重启。
- 灰度发布:新租户可先使用默认模型,逐步迁移到专属配置。
- 定期清理:定时清理过期对话记忆和临时数据。
- 监控面板:为每个租户构建独立的 Grafana Dashboard。
- API 版本管理:租户可指定 API 版本,实现向后兼容。
- 压力隔离:使用 Resilience4j 的 Bulkhead 隔离不同租户的线程池。
10.3 常用工具与框架
| 组件 | 用途 |
|---|---|
| Spring Multi-tenancy | 基础租户支持 |
| Resilience4j | 限流、熔断、隔离 |
| Redis + Lua | 分布式限流和配额管理 |
| Micrometer + Prometheus | 租户级指标监控 |
| Kubernetes | 基础设施级别隔离 |
| Spring Cloud Bus | 配置动态刷新 |
参考资源: