设计一个多租户 SaaS 系统,如何实现租户数据隔离(数据库级别 / 表级别)与资源配额控制?

作为一名有着八年 Java 后端开发经验的技术人员,我参与过多个大型 SaaS 系统的架构设计。在这篇博客中,我将分享如何设计一个支持多租户的 SaaS 系统,重点探讨租户数据隔离(数据库级别 / 表级别)和资源配额控制的实现方案。

一、多租户架构概述

多租户(Multi-Tenant)是指一个软件系统同时服务多个客户(租户),每个租户拥有独立的业务空间,但共享相同的基础设施。SaaS 系统的多租户架构设计需要解决两个核心问题:

  1. 数据隔离:确保租户之间的数据互不干扰,满足安全和合规要求。
  2. 资源配额:控制每个租户使用的系统资源(如存储、API 调用次数),避免资源滥用。

二、数据隔离方案对比与实现

1. 数据隔离方案对比

常见的数据隔离方案有三种,各有优缺点:

隔离级别 实现方式 优点 缺点 适用场景
数据库级别 每个租户使用独立数据库 隔离性强,安全性高 成本高,扩展复杂 对数据隔离要求极高的场景
表级别 所有租户共享数据库,但使用独立表 隔离性较好,成本适中 表数量过多时管理复杂 租户数量中等的场景
行级别 所有租户共享表,通过租户 ID 区分 成本低,易于扩展 隔离性弱,需严格权限控制 租户数量庞大的场景

2. 数据库级别隔离实现

架构设计

diff 复制代码
+-------------------+     +-------------------+     +-------------------+
|  租户A数据库       |     |  租户B数据库       |     |  租户N数据库       |
+-------------------+     +-------------------+     +-------------------+
|  用户表           |     |  用户表           |     |  用户表           |
|  订单表           |     |  订单表           |     |  订单表           |
+-------------------+     +-------------------+     +-------------------+

核心代码实现(数据源动态切换)

typescript 复制代码
/**
 * 动态数据源路由
 */
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    
    @Override
    protected Object determineCurrentLookupKey() {
        // 从线程上下文中获取当前租户ID
        return TenantContextHolder.getTenantId();
    }
}

/**
 * 租户上下文持有者(使用ThreadLocal存储租户ID)
 */
public class TenantContextHolder {
    
    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
    
    public static void setTenantId(String tenantId) {
        CONTEXT.set(tenantId);
    }
    
    public static String getTenantId() {
        return CONTEXT.get();
    }
    
    public static void clear() {
        CONTEXT.remove();
    }
}

/**
 * 数据源配置
 */
@Configuration
public class DataSourceConfig {
    
    @Bean
    public DataSource dataSource() {
        TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
        
        // 初始化所有租户的数据源
        Map<Object, Object> targetDataSources = new HashMap<>();
        for (TenantConfig tenant : tenantConfigService.getAllTenants()) {
            targetDataSources.put(tenant.getTenantId(), 
                    createDataSource(tenant.getDbUrl(), tenant.getDbUser(), tenant.getDbPassword()));
        }
        
        routingDataSource.setDefaultTargetDataSource(defaultDataSource());
        routingDataSource.setTargetDataSources(targetDataSources);
        return routingDataSource;
    }
    
    // 其他配置方法...
}

3. 表级别隔离实现

架构设计

plaintext

diff 复制代码
+-------------------+
|  共享数据库        |
+-------------------+
|  租户A_用户表      |
|  租户A_订单表      |
|  租户B_用户表      |
|  租户B_订单表      |
+-------------------+

核心代码实现(表名动态生成)

typescript 复制代码
/**
 * 表名处理器(基于MyBatis拦截器)
 */
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TableNameInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();
        String originalSql = boundSql.getSql();
        String tenantId = TenantContextHolder.getTenantId();
        
        // 替换表名(添加租户前缀)
        String modifiedSql = replaceTableNames(originalSql, tenantId);
        
        // 通过反射修改SQL
        Field sqlField = boundSql.getClass().getDeclaredField("sql");
        sqlField.setAccessible(true);
        sqlField.set(boundSql, modifiedSql);
        
        return invocation.proceed();
    }
    
    private String replaceTableNames(String sql, String tenantId) {
        // 简单实现,实际应使用正则表达式或SQL解析器
        return sql.replaceAll("\b(user|order)\b", tenantId + "_$1");
    }
}

4. 行级别隔离实现

架构设计

plaintext

diff 复制代码
+-------------------+
|  共享数据库        |
+-------------------+
|  用户表            |
|  + tenant_id       |
|  + user_id         |
|  + username        |
|  订单表            |
|  + tenant_id       |
|  + order_id        |
|  + amount          |
+-------------------+

核心代码实现(自动注入租户 ID)

scala 复制代码
/**
 * MyBatis拦截器:自动注入租户ID
 */
@Intercepts({
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class TenantIdInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object parameter = invocation.getArgs()[1];
        String tenantId = TenantContextHolder.getTenantId();
        
        // 如果参数是实体类,自动注入tenantId
        if (parameter instanceof BaseEntity) {
            ((BaseEntity) parameter).setTenantId(tenantId);
        }
        
        return invocation.proceed();
    }
}

/**
 * JPA规范:自动添加租户ID条件
 */
public class TenantAwareJpaRepository<T, ID> extends SimpleJpaRepository<T, ID> {
    
    private final EntityManager entityManager;
    private final Class<T> domainClass;
    
    public TenantAwareJpaRepository(JpaEntityInformation<T, ?> entityInformation, 
                                  EntityManager entityManager) {
        super(entityInformation, entityManager);
        this.entityManager = entityManager;
        this.domainClass = entityInformation.getJavaType();
    }
    
    @Override
    public List<T> findAll() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<T> query = cb.createQuery(domainClass);
        Root<T> root = query.from(domainClass);
        
        // 添加租户ID条件
        query.where(cb.equal(root.get("tenantId"), TenantContextHolder.getTenantId()));
        
        return entityManager.createQuery(query).getResultList();
    }
}

三、资源配额控制方案

1. 资源配额管理模型

设计一个通用的资源配额模型,支持多种资源类型:

arduino 复制代码
/**
 * 资源配额实体
 */
@Entity
@Table(name = "tenant_quota")
public class TenantQuota {
    
    @Id
    private String tenantId;
    
    // 存储配额(MB)
    private Long storageQuota;
    
    // 已使用存储(MB)
    private Long storageUsed;
    
    // API调用次数配额
    private Long apiCallQuota;
    
    // 已使用API调用次数
    private Long apiCallsUsed;
    
    // 并发用户数配额
    private Integer concurrentUserQuota;
    
    // 上次更新时间
    private LocalDateTime lastUpdateTime;
    
    // 资源使用记录方法
    public boolean canUseStorage(long size) {
        return (storageUsed + size) <= storageQuota;
    }
    
    public boolean useStorage(long size) {
        if (!canUseStorage(size)) {
            return false;
        }
        this.storageUsed += size;
        return true;
    }
    
    // 其他资源使用方法...
}

2. 基于拦截器的配额控制实现

java 复制代码
/**
 * API调用配额拦截器
 */
public class QuotaInterceptor implements HandlerInterceptor {
    
    @Autowired
    private TenantQuotaService quotaService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) throws Exception {
        
        String tenantId = getTenantIdFromRequest(request);
        TenantQuota quota = quotaService.getQuota(tenantId);
        
        // 检查API调用配额
        if (quota.getApiCallsUsed() >= quota.getApiCallQuota()) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.getWriter().write("API调用超出配额");
            return false;
        }
        
        // 记录API调用
        quotaService.recordApiCall(tenantId);
        return true;
    }
}

3. 分布式环境下的配额控制

使用 Redis 实现分布式计数器,确保并发场景下的配额精确控制:

java 复制代码
/**
 * 基于Redis的分布式配额服务
 */
@Service
public class RedisQuotaServiceImpl implements QuotaService {
    
    @Autowired
    private RedisTemplate<String, Long> redisTemplate;
    
    private static final String QUOTA_KEY_PREFIX = "tenant:quota:";
    private static final String USAGE_KEY_PREFIX = "tenant:usage:";
    
    @Override
    public boolean checkAndConsume(String tenantId, String resourceType, long amount) {
        String quotaKey = QUOTA_KEY_PREFIX + tenantId + ":" + resourceType;
        String usageKey = USAGE_KEY_PREFIX + tenantId + ":" + resourceType;
        
        // 获取配额
        Long quota = redisTemplate.opsForValue().get(quotaKey);
        if (quota == null || quota <= 0) {
            return false;
        }
        
        // 使用Lua脚本原子性检查并消费资源
        String script = 
            "local usage = redis.call('GET', KEYS[2]) or 0 " +
            "if usage + ARGV[1] > tonumber(ARGV[2]) then " +
            "  return 0 " +
            "else " +
            "  return redis.call('INCRBY', KEYS[2], ARGV[1]) " +
            "end";
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Arrays.asList(quotaKey, usageKey),
            amount, quota);
            
        return result != null && result > 0;
    }
}

四、多租户认证与权限控制

1. 租户识别与认证

scala 复制代码
/**
 * JWT过滤器:从Token中提取租户ID
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        
        String token = extractToken(request);
        if (token != null) {
            try {
                Claims claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody();
                
                // 提取租户ID并设置到上下文中
                String tenantId = claims.get("tenantId", String.class);
                TenantContextHolder.setTenantId(tenantId);
            } catch (Exception e) {
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                return;
            }
        }
        
        filterChain.doFilter(request, response);
    }
}

2. 细粒度权限控制

使用 Spring Security 实现基于租户的权限控制:

typescript 复制代码
/**
 * 租户权限表达式
 */
public class TenantSecurityExpressionRoot extends SecurityExpressionRoot 
        implements MethodSecurityExpressionOperations {
    
    private Object filterObject;
    private Object returnObject;
    
    public TenantSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }
    
    /**
     * 判断当前用户是否属于指定租户
     */
    public boolean isTenantUser(String tenantId) {
        String currentTenantId = TenantContextHolder.getTenantId();
        return currentTenantId != null && currentTenantId.equals(tenantId);
    }
    
    // 其他权限方法...
    
    @Override
    public void setFilterObject(Object filterObject) {
        this.filterObject = filterObject;
    }
    
    @Override
    public Object getFilterObject() {
        return filterObject;
    }
    
    @Override
    public void setReturnObject(Object returnObject) {
        this.returnObject = returnObject;
    }
    
    @Override
    public Object getReturnObject() {
        return returnObject;
    }
    
    @Override
    public Object getThis() {
        return this;
    }
}

五、方案选择与最佳实践

1. 数据隔离方案选择建议

因素 数据库级别 表级别 行级别
隔离性 最高 中等 最低
成本 最高 中等 最低
扩展性 低(新增租户需创建库) 中等(新增租户需创建表) 高(共享表结构)
维护复杂度 中等
适用租户数量 少(<1000) 中(1000-10 万) 多(>10 万)

2. 资源配额控制最佳实践

  1. 分层控制:同时实现应用层和基础设施层的配额控制。
  2. 预付费机制:支持按使用量计费(Pay-as-you-go)和预付费模式。
  3. 弹性扩展:当租户资源使用接近配额时,提供升级提示。
  4. 监控与告警:实时监控资源使用情况,设置异常使用告警。

六、总结

设计一个高效、安全的多租户 SaaS 系统需要综合考虑数据隔离和资源配额控制:

  1. 数据隔离

    • 数据库级别:适合对隔离性要求极高的场景。
    • 表级别:平衡隔离性和成本的折中方案。
    • 行级别:适合租户数量庞大的场景。
  2. 资源配额控制

    • 设计通用的配额模型,支持多种资源类型。
    • 使用 Redis 实现分布式环境下的精确控制。
    • 通过拦截器和 AOP 实现透明的配额检查。
  3. 认证与权限

    • 从请求中提取租户 ID,建立上下文。

    • 基于租户 ID 实现细粒度的权限控制。

在实际项目中,建议根据租户规模、数据敏感性和预算选择合适的数据隔离方案,并通过弹性的资源配额控制机制确保系统稳定运行。通过上述方案,我们成功在多个 SaaS 项目中实现了租户数据的安全隔离和资源的合理分配,支持了从几百到数十万租户的平滑扩展。

相关推荐
林太白几秒前
Rust-连接数据库
前端·后端·rust
宁静_致远13 分钟前
React 性能优化:深入理解 useMemo 、useCallback 和 memo
前端·react.js·面试
bug菌14 分钟前
CAP定理真的是死结?业务系统到底该怎么取舍!
分布式·后端·架构
林太白24 分钟前
Rust认识安装
前端·后端·rust
掘金酱25 分钟前
🔥 稀土掘金 x Trae 夏日寻宝之旅火热进行ing:做任务赢大疆pocket3、Apple watch等丰富大礼
前端·后端·trae
xiayz28 分钟前
引入mapstruct实现类的转换
后端
Java微观世界31 分钟前
深入解析:Java中的原码、反码、补码——程序员的二进制必修课
后端
不想说话的麋鹿32 分钟前
《NestJS 实战:RBAC 系统管理模块开发 (四)》:用户绑定
前端·后端·全栈
呆呆的心1 小时前
深入剖析 JavaScript 数据类型与 Symbol 类型的独特魅力😃
前端·javascript·面试
Java水解1 小时前
JavaScript 正则表达式
javascript·后端