ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SaaS多租户架构设计与实现经验分享

SaaS多租户架构设计与实现经验分享

1. 多租户功能的核心价值与设计考量

在SaaS领域摸爬滚打多年,我深刻体会到多租户架构就像一栋精装修的公寓楼——同一套基础设施服务着不同租户,但每个租户都拥有独立的门锁和私密空间。最近我们团队刚完成产品多租户功能的上线,过程中踩过的坑和积累的经验,值得与各位开发者分享。

多租户(Multi-tenancy)本质上是通过共享应用实例实现资源隔离的技术方案。与传统的单租户架构相比,它的优势主要体现在三个方面:硬件成本降低60%以上(实测数据)、运维复杂度直线下降、功能迭代可以一次性覆盖所有客户。但实现方式上,开发者通常会面临三种选择:

  • 独立数据库:每个租户使用完全隔离的物理数据库
  • 共享数据库独立Schema:同一数据库实例下的不同Schema隔离
  • 共享数据库共享Schema:通过tenant_id字段实现逻辑隔离

我们最终选择了第二种方案,主要基于以下考量:既保证了数据隔离的安全性(符合金融级客户要求),又避免了数据库连接数爆炸(实测500+租户时连接池压力仍在可控范围)。这里特别提醒:如果选择第三种方案,务必在所有SQL查询中显式添加tenant_id条件,我们曾因此出现过数据泄露事故!

2. 技术实现的关键路径

2.1 租户标识的传递与拦截

租户识别是整个系统的基石。我们采用"前端透传+后端拦截"的双保险机制:

  1. 前端在每次请求头中添加X-Tenant-ID字段
  2. API网关通过JWT解析校验租户合法性
  3. 后端通过ThreadLocal存储租户上下文
// 拦截器示例 public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId = request.getHeader("X-Tenant-ID"); if(!tenantService.validate(tenantId)) { throw new IllegalTenantException(); } TenantContext.setCurrentTenant(tenantId); return true; } @Override public void afterCompletion(...) { TenantContext.clear(); // 防止内存泄漏 } }

重要提示:务必在拦截器中清理ThreadLocal,我们曾因未及时清理导致生产环境出现租户数据串号!

2.2 数据隔离层的实现

MyBatis拦截器是实现SQL自动注入的最佳选择。以下是我们经过三次迭代后的稳定版本:

public class TenantSqlInterceptor implements Interceptor { private static final List<String> IGNORE_TABLES = Arrays.asList("public_config", "tenant_info"); @Override public Object intercept(Invocation invocation) { BoundSql boundSql = ((MappedStatement)invocation.getArgs()[0]) .getBoundSql(parameterObject); if(!shouldIgnore(boundSql.getSql())) { String newSql = "SELECT * FROM (" + boundSql.getSql() + ") tmp " + "WHERE tenant_id = '" + TenantContext.get() + "'"; resetSql(invocation, newSql); // 反射修改SQL } return invocation.proceed(); } private boolean shouldIgnore(String sql) { return IGNORE_TABLES.stream().anyMatch(sql::contains); } }

实测发现需要特别注意:

  1. 分页查询时COUNT语句也需要注入条件
  2. 连表查询要确保所有关联表都包含tenant_id
  3. 批量插入需特殊处理(我们最终采用了AOP切面方案)

2.3 缓存策略的租户隔离

Redis缓存必须实现租户隔离,我们采用两种方案并行:

  1. Key前缀隔离:tenant1:user:1001
  2. 独立数据库:配置不同db index

推荐使用Redisson的MultiTenant工具类:

// 初始化时 RedissonClient client = Redisson.create(); MultiTenantCacheManager manager = new MultiTenantCacheManager(client); // 使用时 RMap<String, Object> map = manager.getMap("user_cache"); // 自动带上前缀

3. 典型问题排查实录

3.1 租户上下文丢失问题

现象:偶尔出现查询结果包含其他租户数据 排查过程:

  1. 检查ThreadLocal清理逻辑 → 正常
  2. 发现异步任务中使用@Async未传递上下文 解决方案:
// 自定义线程池装饰器 public class TenantAwareThreadPoolTaskExecutor extends ThreadPoolTaskExecutor { @Override public void execute(Runnable task) { String tenantId = TenantContext.get(); super.execute(() -> { TenantContext.set(tenantId); try { task.run(); } finally { TenantContext.clear(); } }); } }

3.2 分布式事务难题

在Seata分布式事务中,我们发现分支事务无法获取主事务的租户上下文。最终解决方案:

  1. 自定义RootContext拦截器
  2. 在事务传播时注入tenantId参数
  3. 修改Seata的GlobalLockTemplate
# 新增seata配置项 client.tenant.id.transmission.enable=true

4. 性能优化实战记录

4.1 数据库连接池优化

多租户环境下,连接池配置需要特殊调整:

spring: datasource: hikari: maximumPoolSize: 50 → 调整为物理核心数×5 minimumIdle: 10 → 改为0(避免闲置连接) connectionTimeout: 30000 → 改为5000(快速失败)

4.2 弹性伸缩方案

通过K8s HPA实现按租户数自动扩容:

# 自定义指标采集 kubectl autoscale deployment user-service \ --cpu-percent=50 \ --min=3 --max=10 \ --custom-metrics=tenant_count=500/per-pod

5. 安全防护要点

  1. 租户数据导出必须加密(我们采用AES-256+租户独立密钥)
  2. 后台管理接口需要双重校验:
    • 平台级管理员权限
    • 目标租户的归属权限
  3. 审计日志必须记录租户上下文
@Aspect public class TenantSecurityAspect { @Before("@within(adminController)") public void checkAccess() { if(!currentUser.getTenants() .contains(TenantContext.get())) { throw new AccessDeniedException(); } } }

在实施多租户改造的过程中,最大的教训是:不要试图一步到位。我们采用灰度发布策略,先对10%的租户开放新功能,持续观察两周后再全量。期间发现的缓存穿透问题,正是通过这种渐进式方案才避免了系统性崩溃。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进