作为一名有着八年 Java 后端开发经验的技术人员,我参与过多个大型 SaaS 系统的架构设计。在这篇博客中,我将分享如何设计一个支持多租户的 SaaS 系统,重点探讨租户数据隔离(数据库级别 / 表级别)和资源配额控制的实现方案。
一、多租户架构概述
多租户(Multi-Tenant)是指一个软件系统同时服务多个客户(租户),每个租户拥有独立的业务空间,但共享相同的基础设施。SaaS 系统的多租户架构设计需要解决两个核心问题:
- 数据隔离:确保租户之间的数据互不干扰,满足安全和合规要求。
- 资源配额:控制每个租户使用的系统资源(如存储、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. 资源配额控制最佳实践
- 分层控制:同时实现应用层和基础设施层的配额控制。
- 预付费机制:支持按使用量计费(Pay-as-you-go)和预付费模式。
- 弹性扩展:当租户资源使用接近配额时,提供升级提示。
- 监控与告警:实时监控资源使用情况,设置异常使用告警。
六、总结
设计一个高效、安全的多租户 SaaS 系统需要综合考虑数据隔离和资源配额控制:
-
数据隔离:
- 数据库级别:适合对隔离性要求极高的场景。
- 表级别:平衡隔离性和成本的折中方案。
- 行级别:适合租户数量庞大的场景。
-
资源配额控制:
- 设计通用的配额模型,支持多种资源类型。
- 使用 Redis 实现分布式环境下的精确控制。
- 通过拦截器和 AOP 实现透明的配额检查。
-
认证与权限:
-
从请求中提取租户 ID,建立上下文。
-
基于租户 ID 实现细粒度的权限控制。
-
在实际项目中,建议根据租户规模、数据敏感性和预算选择合适的数据隔离方案,并通过弹性的资源配额控制机制确保系统稳定运行。通过上述方案,我们成功在多个 SaaS 项目中实现了租户数据的安全隔离和资源的合理分配,支持了从几百到数十万租户的平滑扩展。