Spring AI 多租户隔离方案

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 最佳实践清单

  1. 租户 ID 标准化:统一使用 UUID 或业务 ID,避免特殊字符。
  2. 多级缓存:租户配置使用 Caffeine(本地)+ Redis(分布式)两级缓存。
  3. 配额预警:设定 80%、90%、100% 三级告警阈值。
  4. 审计日志:记录所有 API 调用和租户操作,满足合规要求。
  5. 动态配置热加载:通过事件机制(如 Spring Cloud Bus)实现配置变更无需重启。
  6. 灰度发布:新租户可先使用默认模型,逐步迁移到专属配置。
  7. 定期清理:定时清理过期对话记忆和临时数据。
  8. 监控面板:为每个租户构建独立的 Grafana Dashboard。
  9. API 版本管理:租户可指定 API 版本,实现向后兼容。
  10. 压力隔离:使用 Resilience4j 的 Bulkhead 隔离不同租户的线程池。

10.3 常用工具与框架

组件 用途
Spring Multi-tenancy 基础租户支持
Resilience4j 限流、熔断、隔离
Redis + Lua 分布式限流和配额管理
Micrometer + Prometheus 租户级指标监控
Kubernetes 基础设施级别隔离
Spring Cloud Bus 配置动态刷新

参考资源:

相关推荐
ACP广源盛139246256731 小时前
M6/M5 Pro Mac mini 端侧 AI 爆发@ACP#YLB3118 存储扩展芯片在本地 AI 服务中的机会与落地场景
大数据·网络·数据库·人工智能·嵌入式硬件·macos
不会就选b1 小时前
Linux之socket编程(七)----序列化与反序列化(1)
java·linux·服务器
Rain5091 小时前
谁动了我的 URL?——记一次微前端“灵异 Bug“的排查实录
前端·vue.js·人工智能·前端框架·bug·ai编程
秋名RG1 小时前
Java 工程师学习路线
java·学习方法
外域速览1 小时前
OpenAI Astra 跨过「高危红线」、李飞飞世界模型 Atlas 落地:AI 行业进入「安全与落地」双拐点
人工智能·安全
Mr.敦的私房菜1 小时前
【SpringEvent】Spring Boot / Spring Framework 事件大全
spring boot·spring
Mr数据杨1 小时前
莫斯科公寓价格预测实战 从 Kaggle 房价回归到可落地估值流程
人工智能·数据分析·kaggle竞赛
秋名RG1 小时前
Java 基础语法
java·开发语言
故七月1 小时前
产业观察|从 9 月行业数据看西南市场 GEO 落地现状与发展路径
大数据·人工智能