基于 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_dbjava
// 多数据源配置
@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.comjava
@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 |
| 数据隔离 | 物理隔离 |
✅ 最佳实践
设计原则
- 隔离优先:数据安全是多租户系统的生命线
- 性能均衡:避免单个租户影响其他租户
- 可扩展性:支持租户数量的动态增长
- 可维护性:统一升级,简化运维
安全检查清单
- [ ] 所有API都经过租户验证
- [ ] 数据库查询都包含租户过滤
- [ ] 缓存Key包含租户标识
- [ ] 文件存储按租户隔离
- [ ] 日志包含租户上下文
监控指标
| 指标 | 说明 | 告警阈值 |
|---|---|---|
| 租户请求量 | 每个租户的QPS | 超过套餐限制 |
| 租户数据量 | 存储使用 | 超过配额80% |
| 租户响应时间 | P99延迟 | > 500ms |
常见问题
Q1: 如何处理租户数据迁移?
使用数据导出/导入工具,支持增量同步,验证数据完整性。
Q2: 如何实现租户定制化?
配置化优先于代码定制,使用功能开关控制。
Q3: 如何保证租户数据安全?
加密敏感数据,定期安全审计,实施最小权限原则。
