Skip to content

基于 SaaS 的多租户架构设计

系统介绍多租户架构的设计原则、实现方案、数据隔离策略及最佳实践。

📋 目录


🎯 什么是多租户

定义

多租户(Multi-Tenancy):多个客户(租户)共享同一应用实例和基础设施,但数据和配置相互隔离。

单租户架构:
┌─────────┐  ┌─────────┐  ┌─────────┐
│ 租户A   │  │ 租户B   │  │ 租户C   │
│ 应用实例│  │ 应用实例│  │ 应用实例│
│ 数据库  │  │ 数据库  │  │ 数据库  │
└─────────┘  └─────────┘  └─────────┘

多租户架构:
        ┌──────────┐
        │ 共享应用 │
        └────┬─────┘
     ┌───────┼───────┐
  ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
  │租户A│ │租户B│ │租户C│
  └─────┘ └─────┘ └─────┘

核心特征

特征说明
资源共享多租户共享应用服务器、数据库等资源
数据隔离每个租户的数据相互隔离、互不可见
配置独立支持租户级别的配置和定制
统一升级所有租户同时升级到新版本

对比分析

维度多租户单租户
成本💰 低💰💰💰 高
隔离性⭐⭐⭐⭐⭐⭐⭐⭐
定制化受限完全自由
维护简单复杂
扩展容易需额外资源

🏗️ 架构模式选择

三种模式对比

模式成本隔离性复杂度适用场景
共享DB+共享Schema💰 最低⭐⭐⭐ 简单小型SaaS
共享DB+独立Schema💰💰 中等⭐⭐⭐⭐⭐⭐⭐ 中等中型SaaS
独立数据库💰💰💰 最高⭐⭐⭐⭐⭐⭐⭐⭐⭐ 复杂企业级

模式1:共享数据库+共享Schema

所有租户共享数据库和表,通过 tenant_id 字段区分。

sql
-- 表设计
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id BIGINT NOT NULL,
    username VARCHAR(50),
    email VARCHAR(100),
    created_at TIMESTAMP,
    INDEX idx_tenant (tenant_id),
    INDEX idx_tenant_user (tenant_id, id)
);

-- 查询必须带租户过滤
SELECT * FROM users WHERE tenant_id = 1001;
java
// 租户实体基类
@MappedSuperclass
public abstract class TenantEntity {
    @Column(name = "tenant_id", nullable = false)
    private Long tenantId;
    
    @PrePersist
    public void setTenantId() {
        this.tenantId = TenantContext.getTenantId();
    }
}

// 用户实体
@Entity
@Table(name = "users")
public class User extends TenantEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String email;
}

优点:

  • ✅ 成本最低,资源利用率最高
  • ✅ 维护简单,只需管理一个数据库
  • ✅ 扩展容易,添加租户无需额外资源

缺点:

  • ❌ 数据隔离性较弱
  • ❌ 性能可能受其他租户影响
  • ❌ 单租户数据恢复困难

适用场景: 小型SaaS、租户多数据少、成本敏感

模式2:共享数据库+独立Schema

每个租户使用独立的Schema。

sql
-- 为每个租户创建Schema
CREATE SCHEMA tenant_1001;
CREATE SCHEMA tenant_1002;

-- 租户1001的表
CREATE TABLE tenant_1001.users (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100)
);
java
// 动态Schema切换
public class SchemaRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return "tenant_" + TenantContext.getTenantId();
    }
}

@Configuration
public class DataSourceConfig {
    @Bean
    public DataSource dataSource() {
        SchemaRoutingDataSource routing = new SchemaRoutingDataSource();
        Map<Object, Object> targetDataSources = new HashMap<>();
        // 为每个租户配置Schema
        targetDataSources.put("tenant_1001", createDataSource("tenant_1001"));
        targetDataSources.put("tenant_1002", createDataSource("tenant_1002"));
        routing.setTargetDataSources(targetDataSources);
        return routing;
    }
}

优点:

  • ✅ 数据隔离性好
  • ✅ 可以为单个租户备份/恢复
  • ✅ 支持租户级别的定制

缺点:

  • ❌ Schema数量有限制
  • ❌ 维护成本增加
  • ❌ 跨租户查询复杂

适用场景: 中型SaaS、100-1000租户、需要较强隔离

模式3:独立数据库

每个租户使用完全独立的数据库实例。

yaml
# 租户数据库配置
tenants:
  - id: 1001
    database:
      host: db-tenant-1001.example.com
      port: 5432
      name: tenant_1001_db
  - id: 1002
    database:
      host: db-tenant-1002.example.com
      port: 5432
      name: tenant_1002_db
java
// 多数据源配置
@Configuration
public class MultiTenantDataSourceConfig {
    
    @Bean
    public DataSource dataSource() {
        TenantRoutingDataSource routing = new TenantRoutingDataSource();
        Map<Object, Object> dataSources = new HashMap<>();
        
        // 动态加载租户数据源
        List<Tenant> tenants = tenantRepository.findAll();
        for (Tenant tenant : tenants) {
            DataSource ds = createDataSource(tenant.getDbConfig());
            dataSources.put(tenant.getId(), ds);
        }
        
        routing.setTargetDataSources(dataSources);
        routing.afterPropertiesSet();
        return routing;
    }
    
    private DataSource createDataSource(DbConfig config) {
        HikariConfig hikari = new HikariConfig();
        hikari.setJdbcUrl(config.getJdbcUrl());
        hikari.setUsername(config.getUsername());
        hikari.setPassword(config.getPassword());
        hikari.setMaximumPoolSize(10);
        return new HikariDataSource(hikari);
    }
}

// 动态数据源路由
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getTenantId();
    }
}

优点:

  • ✅ 最高级别的数据隔离
  • ✅ 性能互不影响
  • ✅ 支持完全定制化
  • ✅ 易于迁移和备份

缺点:

  • ❌ 成本最高
  • ❌ 运维复杂度高
  • ❌ 资源利用率低

适用场景: 企业级SaaS、大客户、强合规要求


🔒 数据隔离实现

行级隔离(Row-Level Security)

使用数据库原生的行级安全策略。

sql
-- PostgreSQL 行级安全示例
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    order_no VARCHAR(50),
    amount DECIMAL(10,2),
    created_at TIMESTAMP
);

-- 启用行级安全
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 创建策略
CREATE POLICY tenant_isolation_policy ON orders
    USING (tenant_id = current_setting('app.current_tenant')::BIGINT);

-- 设置当前租户
SET app.current_tenant = '1001';

-- 查询自动过滤
SELECT * FROM orders; -- 只返回 tenant_id = 1001 的数据

ORM层隔离

通过ORM框架实现自动租户过滤。

java
// Hibernate Filter
@Entity
@Table(name = "orders")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = "long"))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order extends TenantEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNo;
    private BigDecimal amount;
}

// 启用过滤器
@Component
@Aspect
public class TenantFilterAspect {
    
    @Autowired
    private EntityManager entityManager;
    
    @Before("execution(* com.example.repository..*(..))")
    public void enableTenantFilter() {
        Session session = entityManager.unwrap(Session.class);
        Filter filter = session.enableFilter("tenantFilter");
        filter.setParameter("tenantId", TenantContext.getTenantId());
    }
}

MyBatis拦截器

java
@Intercepts({
    @Signature(type = Executor.class, method = "query",
               args = {MappedStatement.class, Object.class,
                      RowBounds.class, ResultHandler.class})
})
public class TenantInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        
        BoundSql boundSql = ms.getBoundSql(parameter);
        String sql = boundSql.getSql();
        
        // 添加租户过滤条件
        Long tenantId = TenantContext.getTenantId();
        String newSql = addTenantCondition(sql, tenantId);
        
        // 通过反射修改SQL
        Field field = boundSql.getClass().getDeclaredField("sql");
        field.setAccessible(true);
        field.set(boundSql, newSql);
        
        return invocation.proceed();
    }
    
    private String addTenantCondition(String sql, Long tenantId) {
        // 使用JSQLParser解析并添加WHERE条件
        try {
            Statement statement = CCJSqlParserUtil.parse(sql);
            if (statement instanceof Select) {
                Select select = (Select) statement;
                PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
                
                // 添加租户条件
                Expression tenantCondition = CCJSqlParserUtil
                    .parseCondExpression("tenant_id = " + tenantId);
                
                if (plainSelect.getWhere() == null) {
                    plainSelect.setWhere(tenantCondition);
                } else {
                    plainSelect.setWhere(new AndExpression(
                        plainSelect.getWhere(), tenantCondition));
                }
                return select.toString();
            }
        } catch (Exception e) {
            // 解析失败,返回原SQL
        }
        return sql;
    }
}

租户上下文管理

java
// 租户上下文 - 使用ThreadLocal存储
public class TenantContext {
    private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
    
    public static void setTenantId(Long tenantId) {
        CURRENT_TENANT.set(tenantId);
    }
    
    public static Long getTenantId() {
        return CURRENT_TENANT.get();
    }
    
    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

// 租户拦截器
@Component
public class TenantInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler) {
        // 从请求中解析租户ID
        Long tenantId = resolveTenantId(request);
        TenantContext.setTenantId(tenantId);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler, Exception ex) {
        TenantContext.clear();
    }
    
    private Long resolveTenantId(HttpServletRequest request) {
        // 优先从Header获取
        String tenantId = request.getHeader("X-Tenant-ID");
        if (tenantId != null) {
            return Long.parseLong(tenantId);
        }
        
        // 从JWT Token获取
        String token = request.getHeader("Authorization");
        if (token != null) {
            return extractTenantFromToken(token);
        }
        
        // 从子域名获取
        String host = request.getServerName();
        return extractTenantFromHost(host);
    }
}

🚦 租户识别路由

识别方式对比

方式优点缺点适用场景
子域名易识别、SEO友好需要通配符证书B2B SaaS
Path路径实现简单URL较长内部系统
Header灵活、安全需要客户端配合API服务
Token最安全需要解析JWT微服务

子域名识别

URL格式:https://{tenant}.saas.example.com
示例:https://company-a.saas.example.com
java
@Component
public class SubdomainTenantResolver implements TenantResolver {
    
    private final TenantRepository tenantRepository;
    
    @Override
    public Long resolve(HttpServletRequest request) {
        String host = request.getServerName();
        String subdomain = extractSubdomain(host);
        
        if (subdomain == null || "www".equals(subdomain)) {
            throw new TenantNotFoundException("Invalid subdomain");
        }
        
        return tenantRepository.findBySubdomain(subdomain)
            .orElseThrow(() -> new TenantNotFoundException(subdomain))
            .getId();
    }
    
    private String extractSubdomain(String host) {
        // company-a.saas.example.com -> company-a
        String[] parts = host.split("\\.");
        if (parts.length >= 3) {
            return parts[0];
        }
        return null;
    }
}

Header识别

java
@Component
public class HeaderTenantResolver implements TenantResolver {
    
    private static final String TENANT_HEADER = "X-Tenant-ID";
    
    @Override
    public Long resolve(HttpServletRequest request) {
        String tenantId = request.getHeader(TENANT_HEADER);
        
        if (tenantId == null || tenantId.isEmpty()) {
            throw new TenantNotFoundException("Missing tenant header");
        }
        
        try {
            return Long.parseLong(tenantId);
        } catch (NumberFormatException e) {
            throw new TenantNotFoundException("Invalid tenant ID format");
        }
    }
}

JWT Token识别

java
@Component
public class JwtTenantResolver implements TenantResolver {
    
    private final JwtDecoder jwtDecoder;
    
    @Override
    public Long resolve(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new TenantNotFoundException("Missing authorization header");
        }
        
        String token = authHeader.substring(7);
        Jwt jwt = jwtDecoder.decode(token);
        
        Object tenantClaim = jwt.getClaim("tenant_id");
        if (tenantClaim == null) {
            throw new TenantNotFoundException("Missing tenant claim in token");
        }
        
        return Long.parseLong(tenantClaim.toString());
    }
}

复合解析器

java
@Component
@Primary
public class CompositeTenantResolver implements TenantResolver {
    
    private final List<TenantResolver> resolvers;
    
    public CompositeTenantResolver(
            SubdomainTenantResolver subdomainResolver,
            HeaderTenantResolver headerResolver,
            JwtTenantResolver jwtResolver) {
        this.resolvers = Arrays.asList(
            headerResolver,    // 优先级最高
            jwtResolver,
            subdomainResolver  // 优先级最低
        );
    }
    
    @Override
    public Long resolve(HttpServletRequest request) {
        for (TenantResolver resolver : resolvers) {
            try {
                Long tenantId = resolver.resolve(request);
                if (tenantId != null) {
                    return tenantId;
                }
            } catch (TenantNotFoundException e) {
                // 继续尝试下一个解析器
            }
        }
        throw new TenantNotFoundException("Unable to resolve tenant");
    }
}

🔐 安全权限控制

租户级别权限模型

┌─────────────────────────────────────────────────────┐
│                    系统管理员                        │
│              (跨租户管理权限)                        │
└─────────────────────┬───────────────────────────────┘

        ┌─────────────┼─────────────┐
        ▼             ▼             ▼
┌───────────┐  ┌───────────┐  ┌───────────┐
│  租户A    │  │  租户B    │  │  租户C    │
│ ┌───────┐ │  │ ┌───────┐ │  │ ┌───────┐ │
│ │租户管理│ │  │ │租户管理│ │  │ │租户管理│ │
│ └───┬───┘ │  │ └───┬───┘ │  │ └───┬───┘ │
│ ┌───┴───┐ │  │ ┌───┴───┐ │  │ ┌───┴───┐ │
│ │普通用户│ │  │ │普通用户│ │  │ │普通用户│ │
│ └───────┘ │  │ └───────┘ │  │ └───────┘ │
└───────────┘  └───────────┘  └───────────┘

数据权限注解

java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantScope {
    boolean required() default true;
}

@Aspect
@Component
public class TenantSecurityAspect {
    @Around("@annotation(tenantScope)")
    public Object checkTenantAccess(ProceedingJoinPoint joinPoint,
                                    TenantScope tenantScope) throws Throwable {
        Long currentTenant = TenantContext.getTenantId();
        if (tenantScope.required() && currentTenant == null) {
            throw new AccessDeniedException("Tenant context required");
        }
        return joinPoint.proceed();
    }
}

资源隔离验证

java
@Service
public class ResourceAccessValidator {
    public <T extends TenantEntity> void validateAccess(T resource) {
        Long currentTenant = TenantContext.getTenantId();
        if (!resource.getTenantId().equals(currentTenant)) {
            throw new AccessDeniedException("Access denied");
        }
    }
}

⚡ 性能优化

缓存策略

java
@Component
public class TenantAwareCacheKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return TenantContext.getTenantId() + ":" + method.getName();
    }
}

连接池优化

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 20000

查询优化

sql
CREATE INDEX idx_tenant_created ON orders(tenant_id, created_at DESC);

限流配置

java
@Component
public class TenantRateLimiter {
    private final Map<Long, RateLimiter> limiters = new ConcurrentHashMap<>();
    
    public boolean tryAcquire(Long tenantId) {
        return limiters.computeIfAbsent(tenantId,
            id -> RateLimiter.create(100.0)).tryAcquire();
    }
}

💼 实战案例

案例1:电商SaaS平台

项目选择
架构模式共享数据库 + 共享Schema
租户识别子域名
数据隔离tenant_id + MyBatis拦截器

案例2:企业级CRM系统

项目选择
架构模式独立数据库
租户识别JWT Token
数据隔离物理隔离

✅ 最佳实践

设计原则

  1. 隔离优先:数据安全是多租户系统的生命线
  2. 性能均衡:避免单个租户影响其他租户
  3. 可扩展性:支持租户数量的动态增长
  4. 可维护性:统一升级,简化运维

安全检查清单

  • [ ] 所有API都经过租户验证
  • [ ] 数据库查询都包含租户过滤
  • [ ] 缓存Key包含租户标识
  • [ ] 文件存储按租户隔离
  • [ ] 日志包含租户上下文

监控指标

指标说明告警阈值
租户请求量每个租户的QPS超过套餐限制
租户数据量存储使用超过配额80%
租户响应时间P99延迟> 500ms

常见问题

Q1: 如何处理租户数据迁移?

使用数据导出/导入工具,支持增量同步,验证数据完整性。

Q2: 如何实现租户定制化?

配置化优先于代码定制,使用功能开关控制。

Q3: 如何保证租户数据安全?

加密敏感数据,定期安全审计,实施最小权限原则。


📚 参考资源

Released under the MIT License.