简介:本资源是一套基于SpringBoot实现多租户SaaS系统的核心技术实践方案,面向Java后端开发者、云原生架构师及SaaS平台建设者,聚焦解决多租户场景下数据隔离、动态数据源切换与租户识别等关键难题。压缩包共36个文件,含24个Java核心类(涵盖TenantContextHolder、AbstractRoutingDataSource扩展、多租户拦截器与JPA适配逻辑)、2个关键说明文档(含部署步骤与常见错误排查)、1个application.yml配置模板、2个HTML演示页及Maven构建相关脚本与配置文件,整体仅94KB,轻量易读。已有72人学习下载,适合中高级开发者快速掌握共享数据库+独立Schema模式的落地细节。读者可直接复用动态数据源路由机制、租户上下文传递链路、跨租户事务边界控制策略,并结合说明文件理解从请求解析(如Header/子域名识别)到Schema自动切换的完整流程,具备强工程参考价值。 做SaaS平台绕不开多租户数据隔离,而SpringBoot生态里最常见的落地方式就是"独立数据库"和"共享数据库独立Schema"这两种模式。这篇博客我会把两种模式怎么选、租户识别怎么做、动态数据源怎么切、以及实际部署中那些文档里不会写的坑一次说清楚,适合正在设计多租户SaaS后台、或者准备从单租户改造为多租户的Java开发同学。
1. 多租户架构的核心诉求与两种隔离模式的选型逻辑
1.1 先从"租户到底是什么"说起
多租户(Multi-Tenancy)不是简单的"多个用户登录同一套系统",而是多个客户(租户)共享同一套应用实例,但彼此的数据完全隔离。我见过不少团队把"多用户"和"多租户"混为一谈,结果设计出来的系统只有登录层隔离,业务数据一查全串了,这是最要命的。
在一个SaaS平台里,租户可以是一个企业、一个门店、一个项目组,甚至是一个独立品牌。比如你做一个零售行业的SaaS中台,A品牌和B品牌都订阅了你的系统,A品牌录入的商品、订单、会员数据,B品牌绝对不能看到,反之亦然。这里的隔离范围是整个业务数据域,不只是用户表加个tenant_id就完事。
从隔离粒度上划分,主流方案就三种:
- 独立数据库模式:每个租户一个独立数据库实例(或独立database),物理隔离最彻底。
- 共享数据库独立Schema模式:同一个数据库实例下,每个租户拥有独立的schema(PostgreSQL/MySQL中表现为独立数据库名,Oracle中表现为独立schema),逻辑隔离。
- 共享数据库共享Schema模式:所有租户共用一个schema,通过tenant_id字段区分。
标题里提到的是前两种的组合,这一点非常务实。因为真正生产环境里,没有任何一家SaaS公司会只用一种模式打天下。大租户要独立库,中小租户要低成本共享,双模式混合才能平衡成本和隔离性。
1.2 独立数据库模式的优势与代价
独立数据库模式的优点不用多说:数据隔离最彻底,一个租户的慢查询、死锁、磁盘占用不会拖垮其他租户;备份恢复可以按租户独立操作;出问题时排查范围小,运维边界清晰。
但代价也很具体:
- 数据库实例数量爆炸。100个租户要建100个库(或实例),连接数、监控、备份任务都是线性增长。
- 成本居高不下。云数据库按实例收费,独立实例的固定开销远高于共享。
- 结构变更要push到所有库。每次上线新版本,要对所有租户库执行迁移脚本,漏掉一个就是事故。
- 数据量的天花板取决于实例资源。单个租户库的读写能力受限于它所在的实例规格,做不了弹性伸缩。
所以独立库模式更适合数据安全要求高、业务规模大、能承担更高费用的头部租户,比如银行、保险、大型连锁企业。
1.3 共享数据库独立Schema模式的优势与代价
共享数据库独立Schema模式,在同一个MySQL实例里建多个database(MySQL里database和schema等价),或者在一个PostgreSQL实例里建多个schema,每个租户一个,代码逻辑上每张表都要通过schema前缀去路由。
优点是:
- 一个实例承载大量租户,500个租户也只需要少量数据库实例,成本可控。
- 结构变更高效,很多情况下只需要对共享实例执行一次迁移脚本(要小心,建了新租户schema时得各自执行)。
- 租户之间既有隔离,又方便做跨租户的运营分析(DBA层面可以直接在实例内join多个schema)。
缺点是:
- 隔离级别是逻辑隔离,不是物理隔离。一个租户的慢SQL或锁竞争仍然可能影响同实例的其他租户。
- 单个实例的资源是瓶颈。租户数量增长到一定程度,必须拆实例,而拆实例的过程非常痛苦。
- 连接管理更复杂。每个租户schema对应一组连接串,连接池的管理比单库复杂得多。
1.4 选型决策表:到底怎么选
| 维度 | 独立数据库模式 | 共享数据库独立Schema模式 |
|---|---|---|
| 数据隔离级别 | 物理隔离 | 逻辑隔离 |
| 单租户数据容量上限 | 可扩展(受实例规格限制) | 受共享实例总资源限制 |
| 租户数量承载能力 | 较弱(实例数量成为瓶颈) | 强(一个实例可承载大量租户) |
| 运维复杂度 | 高(备份/迁移/监控按租户) | 中(集中在实例级别) |
| 单租户成本 | 高 | 低 |
| 适用客户 | 大客户、高安全要求 | 中长尾客户、成本敏感 |
| 结构变更效率 | 低(逐库执行) | 高(共享实例集中执行) |
我个人的经验是:不要把选型做成一次性决策,而是做成租户维度的一个属性。新租户入驻时,根据合同金额、数据敏感度、预估数据量,动态决定给他分到哪种模式。这就是标题里"独立数据库与共享数据库独立Schema模式"组合落地的前提——你的系统必须在运行时同时支持这两种模式的路由。
2. 租户识别链路:从HTTP请求到线程上下文的完整通道
2.1 租户信息从哪里来
要实现动态数据源切换,第一步是解决"当前请求属于哪个租户"的问题。常见的识别方式有:
- 请求头带入:前端在HTTP Header里带一个
X-Tenant-Id,后端从请求头解析。这是最简单也最通用的做法。 - JWT Token解析:登录后签发的Token中携带tenantId,每次请求解析Token取出租户信息。适合安全性要求高的场景。
- 子域名/独立域名:
tenantA.yourplatform.com自动识别租户A。适合SaaS对外提供服务的场景,但需要网关层配合做域名映射。 - 登录态存储:用户登录后把租户信息存入Session或Redis,请求时从会话中获取。
实践中我推荐请求头 + JWT双保险:X-Tenant-Id作为显式路由依据,JWT里再加一个tenantId声明用于校验一致性。这样即使前端不传Header,也能从Token里解析;如果两者都有,校验不一致直接拒绝,防止越权。
2.2 TenantContext:线程级别的租户上下文
动态数据源的核心就是在切换数据源时能拿到当前线程的租户ID,所以需要一个线程级上下文对象。Spring里最常用的就是ThreadLocal:
public class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); private static final ThreadLocal<TenantType> TENANT_TYPE = new ThreadLocal<>(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void setTenantType(TenantType tenantType) { TENANT_TYPE.set(tenantType); } public static TenantType getTenantType() { return TENANT_TYPE.get(); } public static void clear() { CURRENT_TENANT.remove(); TENANT_TYPE.remove(); } }这里我存了两个值:租户ID和租户类型。租户类型用于区分他是独立库模式还是共享Schema模式,后面动态数据源要根据这个类型决定走哪条路由逻辑。
提示:
ThreadLocal用完后一定要remove(),否则线程池复用线程时会留下上一个租户的上下文,产生数据串租户的严重事故。我会在过滤器finally块里统一调用TenantContext.clear()。
2.3 过滤器/拦截器实现租户解析
推荐用OncePerRequestFilter(Servlet过滤器)来做租户解析,因为它在进入Spring MVC之前就生效,连静态资源都能拦到,时机最早、最可靠:
@Component public class TenantIdentificationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 1. 优先从请求头获取 String tenantId = request.getHeader("X-Tenant-Id"); if (StringUtils.hasText(tenantId)) { // 2. 从JWT中校验tenantId一致性 String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { String tokenTenantId = parseTenantIdFromToken(token); if (!tenantId.equals(tokenTenantId)) { response.setStatus(HttpStatus.FORBIDDEN.value()); return; } } } else { // 3. 降级从JWT解析 String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { tenantId = parseTenantIdFromToken(token); } } if (!StringUtils.hasText(tenantId)) { throw new TenantNotFoundException("无法识别租户"); } TenantContext.setTenantId(tenantId); // 4. 根据租户ID查出租户类型(独立库 or 共享Schema) TenantType tenantType = tenantService.getTenantType(tenantId); TenantContext.setTenantType(tenantType); filterChain.doFilter(request, response); } finally { TenantContext.clear(); } } }这里有个关键点:过滤器里要查一次租户类型。我见过很多实现把租户类型也放到请求头里,然后信任前端传的值——这是安全隐患,前端可以伪造。租户类型必须由后端根据租户ID去数据库查,哪怕这个查询走了Redis缓存,也比信任前端强。
2.4 异步线程的租户上下文传递
一旦代码里出现@Async、CompletableFuture、ThreadPoolTaskExecutor,ThreadLocal就会丢失,子线程拿不到租户ID,动态数据源直接切到默认库,轻则数据错乱,重则报"默认数据源不存在"错误。
解决方案是在提交任务时显式传递上下文:
public class TenantAwareTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { String tenantId = TenantContext.getTenantId(); TenantType tenantType = TenantContext.getTenantType(); return () -> { try { TenantContext.setTenantId(tenantId); TenantContext.setTenantType(tenantType); runnable.run(); } finally { TenantContext.clear(); } }; } }配置到ThreadPoolTaskExecutor里:
@Bean(name = "asyncExecutor") public ThreadPoolTaskExecutor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(200); executor.setTaskDecorator(new TenantAwareTaskDecorator()); executor.initialize(); return executor; }注意:
TaskDecorator只在任务提交时生效,如果任务内部又嵌套提交了子任务,需要确保内层任务也走同一个Executor。我在项目里是统一封装线程池工具类,禁止直接new Thread或裸用@Async,从源头掐断上下文丢失问题。
3. 动态数据源切换的落地实现:从RoutingDataSource到双模式适配
3.1 Spring的AbstractRoutingDataSource原理
Spring框架天生支持动态数据源。我们只需要继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法,返回一个key,Spring就会根据这个key从预置的targetDataSources中查找对应的DataSource。
这个抽象类内部维护了一个Map<Object, DataSource>,当determineCurrentLookupKey()返回的key发生变化时,连接就自动切到对应的数据源。关键点在于:在Spring事务管理里,拿到连接的时刻就决定了数据源,所以切库动作必须发生在事务开启之前或事务边界之内,但连接获取之前。
3.2 租户数据源注册表的自研设计
由于我们是双模式混合,数据源不能单纯按租户ID注册,还要兼顾模式。我在项目里维护了一个租户数据源注册表,核心结构如下:
public class TenantDataSourceRegistry { // key: tenantId, value: DataSource private final Map<String, DataSource> tenantDataSources = new ConcurrentHashMap<>(); // 共享库实例名 -> 数据源 private final Map<String, DataSource> sharedInstanceDataSources = new ConcurrentHashMap<>(); // 租户ID -> 租户路由信息(模式 + 目标库/实例名) private final Map<String, TenantRouteInfo> tenantRouteMap = new ConcurrentHashMap<>(); public DataSource getDataSource(String tenantId) { TenantRouteInfo routeInfo = tenantRouteMap.get(tenantId); if (routeInfo == null) { // 从数据库加载租户路由配置 routeInfo = loadRouteInfo(tenantId); tenantRouteMap.put(tenantId, routeInfo); } if (routeInfo.getTenantType() == TenantType.INDEPENDENT_DATABASE) { // 独立库模式:直接返回该租户专属的DataSource return tenantDataSources.computeIfAbsent(tenantId, this::createIndependentDataSource); } else { // 共享Schema模式:返回共享实例DataSource,但要从当前上下文中获取schema名 return sharedInstanceDataSources.computeIfAbsent(routeInfo.getSharedInstanceKey(), this::createSharedInstanceDataSource); } } }这个注册表是动态数据源的核心组件。租户首次请求时,会从数据库加载路由信息并缓存到内存里,后续请求直接走内存查找,性能损耗可以忽略。
3.3 自定义RoutingDataSource的核心实现
有了注册表,实现动态数据源就顺理成章了:
public class TenantRoutingDataSource extends AbstractRoutingDataSource { private final TenantDataSourceRegistry registry; public TenantRoutingDataSource(TenantDataSourceRegistry registry) { this.registry = registry; // 设置默认数据源(用于系统表、租户路由表等) super.setDefaultTargetDataSource(createDefaultDataSource()); super.setTargetDataSources(new HashMap<>()); } @Override protected Object determineCurrentLookupKey() { return TenantContext.getTenantId(); } @Override protected DataSource determineTargetDataSource() { // 1. 获取当前租户ID String tenantId = TenantContext.getTenantId(); if (StringUtils.hasText(tenantId)) { // 2. 从注册表获取租户对应的DataSource DataSource dataSource = registry.getDataSource(tenantId); if (dataSource != null) { return dataSource; } } // 3. 兜底:返回默认数据源 return super.determineTargetDataSource(); } }这里要注意:重写的是determineTargetDataSource()而不是determineCurrentLookupKey()。原因在于我们的数据源并不是预先注册在targetDataSources里的,而是按需动态创建的。直接重写determineTargetDataSource()可以完全绕开Spring的静态映射,按运行时信息动态返回数据源。
3.4 共享Schema模式下的Connection初始化SQL
独立库模式直接返回租户专属DataSource就够了。但共享Schema模式还有一个灵魂操作:切换数据库。
MySQL的schema就是database,所以共享Schema模式下,一个共享实例连接池的DataSource只是把连接建立到了某个默认库,真正访问租户数据时必须执行USE tenant_a。两种做法:
- 每个租户一个独立连接池:为每个租户创建单独的DataSource,连接串里直接指定database名。缺点是连接池数量爆炸,资源浪费严重。
- 共享连接池 + 连接初始化SQL:每个连接在创建时通过
connectionInitSqls来设置动态切换。但因为连接会被多个租户复用,这种方式不安全。
我的方案是共享Schema模式下,按租户ID哈希到不同的共享实例,然后在该共享实例下为每个租户创建独立DataSource。这样可以控制连接池数量,同时连接串中直接指定了租户的database名,不需要执行USE语句,从数据库驱动层面就隔离了:
private DataSource createSharedTenantDataSource(String instanceKey, String tenantId) { // 从配置中心读取共享实例的基础连接信息 SharedInstanceConfig config = sharedInstanceConfigMap.get(instanceKey); HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl(config.getJdbcUrl() + "/" + tenantId + "?useSSL=false&serverTimezone=Asia/Shanghai"); ds.setUsername(config.getUsername()); ds.setPassword(config.getPassword()); ds.setMaximumPoolSize(config.getMaxPoolSize()); ds.setMinimumIdle(config.getMinIdle()); ds.setPoolName("shared-" + instanceKey + "-" + tenantId); return ds; }注意,这种"每个租户一个DataSource"的模式,连接池数量会随租户数线性增长。500个租户就是500个HikariCP连接池,每个连接池默认10个连接就是5000个连接,对数据库实例压力不小。我在实际线上是把maxPoolSize调低到5,minIdle调到1,再结合maximumPoolSize按租户等级做差异化配置。
3.5 动态数据源与Spring事务的协同
Spring事务的传播级别和连接绑定机制是动态数据源最大的坑。默认情况下,@Transactional方法会在事务开始时从数据源获取连接,并绑定到当前线程。如果事务过程中又切换了上游数据源,Spring拿到的是同一个事务绑定的旧连接,数据源切换不会生效。
解决思路是:事务的粒度要与租户绑定。所有业务逻辑中,获取事务时租户ID必须已经存在于TenantContext中。我的团队约定是:
- 租户过滤在Filter中完成,确保Controller进入时上下文已就绪。
@Transactional只允许在Service层使用,且Service方法内部禁止切换租户上下文。- 如果必须在一个事务里操作多个租户的数据(比如跨租户报表),单独开一个独立事务,在方法前手动保存/恢复上下文。
跨租户事务场景还有一个更极端的处理:有些租户数据量极大,一个查询就要跑几十秒,如果放任事务长连接,连接池会被占满。所以我一般在业务上做分页或者异步卸载,而不是依赖数据库事务撑大查询。
4. 双模式下的初始化、结构迁移与租户生命周期管理
4.1 新租户入驻时的自动初始化流程
一个租户从注册到真正可用,要经历:录入租户信息 -> 分配数据源模式 -> 创建数据库/schema -> 执行初始化SQL -> 注册到路由表。
我用一个状态机来管:
REGISTERED -> PROVISIONING -> PROVISIONED -> ACTIVEREGISTERED:租户基本信息已入库。PROVISIONING:正在创建数据库/schema,执行基础表结构和初始化数据。PROVISIONED:数据源创建完成,路由表已注册。ACTIVE:租户可正常访问。
创建租户库的完整逻辑放在一个TenantProvisioningService里:
public void provisionTenant(TenantInfo tenantInfo) { if (tenantInfo.getTenantType() == TenantType.INDEPENDENT_DATABASE) { // 独立库模式:在实例上创建新数据库 DataSource adminDs = getAdminDataSource(tenantInfo.getInstanceKey()); try (Connection conn = adminDs.getConnection(); Statement stmt = conn.createStatement()) { stmt.execute("CREATE DATABASE IF NOT EXISTS " + tenantInfo.getTenantId() + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); } } else { // 共享Schema模式:在共享实例上创建新database DataSource sharedDs = getSharedAdminDataSource(tenantInfo.getSharedInstanceKey()); try (Connection conn = sharedDs.getConnection(); Statement stmt = conn.createStatement()) { stmt.execute("CREATE DATABASE IF NOT EXISTS " + tenantInfo.getTenantId() + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); } } // 执行Flyway迁移 migrateTenantSchema(tenantInfo); // 注册路由 tenantDataSourceRegistry.registerTenant( tenantInfo.getTenantId(), tenantInfo.getTenantType(), buildRouteInfo(tenantInfo) ); }注意,创建数据库和管理连接需要管理员权限,所以这里用了一个独立的adminDs。生产环境建议将管理员连接串配置在独立的配置项里,并限制来源IP,避免应用层拿到过高的数据库权限。
4.2 Flyway在独立数据库模式下的迁移策略
双模式下,Flyway的迁移策略要分开处理。独立数据库模式下,每个租户库都是完全独立的,所以Flyway必须逐个租户库执行迁移。我的做法是遍历所有独立租户,依次调用Flyway.configure().dataSource(...).locations("classpath:db/migration/tenant").load().migrate()。
public void migrateAllTenants() { // 1. 查询所有独立库模式的租户 List<TenantInfo> independentTenants = tenantMapper.selectByType(TenantType.INDEPENDENT_DATABASE); for (TenantInfo tenant : independentTenants) { Flyway flyway = Flyway.configure() .dataSource(tenant.getJdbcUrl(), tenant.getUsername(), tenant.getPassword()) .locations("classpath:db/migration/tenant") .baselineOnMigrate(true) .load(); flyway.migrate(); } // 2. 共享Schema模式:所有租户共用一套迁移脚本,逐个租户库迁移 List<TenantInfo> sharedTenants = tenantMapper.selectByType(TenantType.SHARED_SCHEMA); Map<String, String> sharedInstances = buildSharedInstanceMap(); for (TenantInfo tenant : sharedTenants) { String instanceJdbcUrl = sharedInstances.get(tenant.getSharedInstanceKey()); Flyway flyway = Flyway.configure() .dataSource(instanceJdbcUrl + "/" + tenant.getTenantId(), tenant.getUsername(), tenant.getPassword()) .locations("classpath:db/migration/tenant") .baselineOnMigrate(true) .load(); flyway.migrate(); } }我强烈建议把系统公共表(如用户表、权限表、租户路由表)和租户业务表的Flyway迁移脚本分目录管理:
db/migration/common:全局表结构,比如sys_user、sys_role、sys_tenant_route。这个目录的脚本只跑在系统库上。db/migration/tenant:租户业务表结构,比如orders、product、customer。这个目录的脚本逐个租户库执行。
这样分目录,能清晰区分"系统级变更"和"租户级变更",避免公共表变更被发到每个租户库的尴尬。
4.3 共享Schema模式下租户命名与数据字典
共享Schema模式下,租户的database名不能随便取。我踩过tenant_1这种纯数字命名的坑——数据库名不能以数字开头,MySQL直接报语法错误。最后定下的命名规则是:
| 模式 | 命名规则 | 示例 |
|---|---|---|
| 独立数据库 | t_${租户编码} | t_acme |
| 共享Schema | tenant_${租户ID} | tenant_10023 |
另外,因为连接串里直接带了database名,租户ID必须做合法性校验,只允许字母、数字、下划线。否则有人传入恶意字符串拼进JDBC连接串,轻则连接失败,重则引起SQL注入。我在过滤器里对X-Tenant-Id做了正则校验,不合法直接返回400。
还有一点要给新人提个醒:租户初始化和数据源注册的时机要放到事务外。如果放在事务里,CREATE DATABASE这类DDL会隐式提交,和事务的其他操作混在一起会产生难以排查的锁问题。我的经验是所有DDL操作都放在事务外,用完连接马上释放。
5. 实战中高频踩坑与完整排查链路
5.1 坑一:事务注解导致数据源切换失效
这是最经典的问题。现象是:业务代码里明明在Service方法开头设置了租户ID,动态数据源也正确返回了对应的DataSource,但数据库查询仍然报"Table doesn't exist",查的还是默认库的表。
排查过程:
- 先查
TenantContext.getTenantId()有没有值——正常。 - 在
determineTargetDataSource()里加日志,打印返回的DataSource的JdbcUrl——发现切换到独立库时JdbcUrl是正确的。 - 进一步在DAO层打印当前连接的类名和URL——发现连接还是默认库的。
- 最终定位:Service方法上有
@Transactional,Spring在进入方法时就通过默认数据源获取了连接并绑定到线程,后续动态数据源虽然返回了新DataSource,但Spring事务管理根本不会再重新获取连接。
解决方案:
- 将
@Transactional移到更内层的方法上,确保进入事务前租户上下文已准备好。 - 如果必须外层开事务,用
TransactionSynchronizationManager.bindResource()手动把连接绑定到当前线程,让事务认识新数据源。 - 最省事的做法:把需要切库的操作拆分到无事务的Service方法里,底层DAO各自带短事务。
5.2 坑二:动态数据源在启动阶段被提前绑定
Spring启动时,如果@PostConstruct方法里有数据库操作,而TenantContext还没有租户ID,动态数据源就会退回默认数据源。默认数据源如果没有配置,启动直接失败;配置了系统库,就会误把系统库当成业务库操作。
排查时看到determineCurrentLookupKey()返回null,而日志显示在启动阶段,就要去检查那些启动即执行的定时任务、健康检查、缓存预热代码。
修复就是:初始化代码里不要走业务数据源。如果是系统级操作,显式注入系统库的数据源;如果是租户级操作,要在代码里先设置租户ID再执行,通常在ApplicationRunner里执行。
5.3 坑三:连接池资源泄漏导致数据库连接被耗尽
共享Schema模式下,每个租户独立连接池,如果某个租户并发量激增,它的连接池会占满共享实例的所有连接。HikariCP默认maximumPoolSize=10,300个租户同时满负荷,压力全在数据库实例上,最终整个实例挂掉。
排查的时候连不上数据库,查看监控发现连接数飙升,看到是某个租户的连接池把实例打爆。
修复方案:
- 给每个租户连接池设置上限,比如按租户等级设置
maximumPoolSize上限为5或8。 - 共享实例层面做总连接数熔断,监控连接数达到阈值的80%就报警,超出阈值拒绝新租户扩容。
- 把SQL耗时长的租户隔离到独立库,让重流量租户不再占用共享资源。
5.4 坑四:缓存中的租户上下文串台
用ThreadLocal存租户上下文,如果请求处理完没有清理,连接池中的线程被下一个请求复用,就会读到上一个租户的ID。这种问题极其隐蔽,因为它不会立刻报错,只会在数据上"串味"。
排查手段:在Filter的finally块中加日志打印线程ID和租户ID,观察同一个线程在不同请求间是否残留了旧租户。
修复是双管齐下:
- 在
TenantContext包装类里,当上一个租户ID未被清空时就设置新值,打印WARN日志,尽早暴露问题。 - 在Tomcat线程池和业务线程池两个层面分别监控,业务线程池用
TaskDecorator,Web层用OncePerRequestFilter的finally清理。
5.5 坑五:Flyway迁移时对共享Schema模式的全量重复执行
有次部署新版本,迁移脚本在所有共享租户库上各执行了一遍,结果脚本里的INSERT INTO重复插入数据,造成数据重复。
问题出在:迁移任务是全量遍历租户,而当时共享实例上有一批新建租户还没跑过Flyway,但它们已经存在于路由表里,导致重复执行。
解决方案:
- 在
TenantDataSourceRegistry里维护已初始化版本,租户库里查询flyway_schema_history表,只对版本落后的库执行迁移。 - 或者把Flyway迁移的执行状态同步到系统库的状态表,租户的
schema_version字段记录当前版本,迁移任务只处理版本低于目标版本的租户。
6. 动态数据源之外的全局框架设计建议
6.1 系统库与租户库分离的架构
强烈建议:系统的元数据(租户路由表、用户权限、配置项)单独放在系统库里,不要和任何租户业务库混在一起。这样做的好处是:
- 应用启动时可以先连系统库加载基础配置,不依赖任何租户数据源。
- 权限模块只需要访问系统库,和业务数据隔离,安全性可控。
- 租户数量的增删不会影响系统表。
我的系统库表设计大概是这样的:
| 表名 | 说明 |
|---|---|
sys_tenant | 租户基本信息(租户ID、名称、状态、模式类型) |
sys_tenant_route | 租户数据源路由表(租户ID、实例Key、数据库名、类型、连接配置版本) |
sys_user | 平台用户(包含租户ID外键,可跨租户管理) |
sys_role/sys_permission | 角色权限 |
sys_config | 平台全局配置 |
路由表在应用启动时会全量加载到内存,tenantId直接映射到DataSource或者路由信息。这样内存查找一次搞定,不需要每次请求都查数据库。
6.2 多实例共享库的负载分配
共享Schema模式下,不能把几百个租户都塞进一个实例。我的方案是:租户入驻时根据当前各实例的租户数量、连接池使用率、CPU和内存指标,选择一个负载最低的实例分配。
public String allocateSharedInstance() { List<SharedInstanceInfo> instances = sharedInstanceRegistry.getAll(); // 按当前租户数量和平均负载排序,取最空闲的 return instances.stream() .sorted(Comparator.comparingInt(SharedInstanceInfo::getTenantCount)) .filter(instance -> instance.getCurrentLoad() < instance.getMaxLoad()) .findFirst() .orElseThrow(() -> new NoAvailableInstanceException("所有共享实例已满")); }这是租户入驻时的静态分配。如果某个共享实例负载超标,还可以做二次分配:把一部分租户从旧实例迁移到新实例,不过跨实例的租户迁移比较重,需要停机窗口或者双写方案,我在实际项目里是放在夜间低峰期做的。
6.3 数据源连接串的配置安全管理
多人协作的项目里,数据库密码被传得满天飞,这是大忌。SpringBoot项目里我习惯的做法是:
- 本地开发用
application-dev.yml,密码走jasypt加密。 - 生产环境用配置中心(Nacos/Spring Cloud Config)下发,且加密存储。
- 数据源连接串不要硬编码在代码里,统一走注册表配置,也可以下放到Redis或数据库表里,由管理后台维护。
这里分享一个小技巧:独立库模式的租户连接池,可以用同一个实例账号+不同库名来实现,这样多租户环境下不需要为每个租户单独建数据库账号。共享Schema模式同理,一个账号访问多个库(需要在MySQL授权时指定库前缀),安全性靠应用层和数据库权限双重控制。
但如果租户要求独立账号,那就必须每个租户配一套账号密码。这种场景建议把密码也用不对称加密存储,应用侧配置私钥解密,防止明文泄露。
6.4 MyBatis拦截器做租户查询的自动过滤
除了数据源隔离,某些场景下多个租户的库物理上在同一个database(比如你不得已用共享表模式过渡),或者你要做跨租户的统计,这时候直接在SQL层加AND tenant_id = ?比较靠谱。
我写了一个MyBatis拦截器,解析SQL并自动追加租户条件:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class TenantLineInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { String tenantId = TenantContext.getTenantId(); if (!StringUtils.hasText(tenantId)) { return invocation.proceed(); } StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = statementHandler.getBoundSql(); String originalSql = boundSql.getSql(); // 这里只对包含指定表名的SQL追加租户条件 if (originalSql.contains("orders") && !originalSql.contains("tenant_id")) { // 修改SQL并设置到boundSql String newSql = originalSql + " AND tenant_id = '" + tenantId + "'"; // 通过反射替换boundSql中的sql字段 Field field = BoundSql.class.getDeclaredField("sql"); field.setAccessible(true); field.set(boundSql, newSql); } return invocation.proceed(); } }但这个拦截器要慎用,因为改写SQL容易漏掉JOIN条件、子查询、UNION等复杂情况,改错就是事故。我的建议是:优先用数据源隔离,SQL层过滤只在特定场景下用,比如你有一个共享表存平台运营数据,需要按租户过滤。
7. 性能调优与实际运行效果
7.1 租户路由缓存优化
动态数据源每次请求都查tenantRouteMap,如果这块走DB,性能会很难看。我的方案是:
- 应用启动时将
sys_tenant_route全量加载到内存,构建ConcurrentHashMap。 - 路由信息修改时,通过Redis Pub/Sub广播通知所有应用节点刷新本地缓存。
- 本地缓存失效策略设置为"永不过期 + 主动刷新",避免缓存击穿。
7.2 连接池参数的合理设置
独立库模式连接池参数可以大一点,共享Schema模式要小一点。我实践下来的参考值:
| 模式 | maximumPoolSize | minimumIdle | connectionTimeout | maxLifetime |
|---|---|---|---|---|
| 独立库(大租户) | 20 | 5 | 30000ms | 1800000ms |
| 独立库(小租户) | 10 | 2 | 30000ms | 1800000ms |
| 共享Schema | 5 | 1 | 30000ms | 1800000ms |
由于共享Schema模式下租户级DataSource数量很多,连接池参数务必保守。另外连接池的leakDetectionThreshold建议配置上,当某个租户的连接泄漏时能快速从日志里看到。
7.3 压测时注意两个性能拐点
多租户系统的性能瓶颈往往不在应用层,而在数据库连接和实例负载。压测时重点观察:
- 连接池耗尽点:当并发请求数超过所有租户连接池的总连接数时,请求会排队等待,RT陡增。此时要区分是分配问题还是实例瓶颈。
- 共享实例CPU拐点:共享Schema模式下,某几个租户的复杂报表查询会把共享实例CPU打满,其他租户的简单查询也跟着变慢。遇到这种情况,最有效的办法是把重查询租户迁移到独立库,或者对共享实例做读写分离。
我自己压过一个500租户的共享Schema模式实例,4C16G的MySQL,在并发1000的情况下,TPS稳定在3000左右,连接池配置为每个租户最大5连接,此时实例CPU已经到70%。如果继续加大并发,很快就会碰到实例CPU拐点。所以后来我把"重租户独立库 + 轻租户共享Schema"作为默认策略,线上跑得很稳。
8. 扩展:这个架构后续还能怎么演进
双模式多租户架构搭建完成之后,还有几个方向值得深入:
- 租户级限流降级:在网关层按租户ID维度做限流,防止某个大流量租户把整个平台的出口带宽和数据库资源耗尽。可以用Sentinel的
ParamFlowRule绑定租户ID参数,实现租户维度的流控。 - 数据保留与归档策略:SaaS平台要求每个租户的数据可独立归档。独立库模式天然支持,共享Schema模式下要定期把冷数据导出、清理,避免单个schema无限膨胀拖累整个实例。
- 跨租户数据分析:如果要做平台级的运营报表(比如所有租户的销售额汇总),在共享Schema模式下可以直接跨schema查询,但在独立库模式下就要走数据仓库或者统一上报通道了。设计之初就要想好这部分架构,否则后期做数据中台时要把各个库的数据同步一遍,非常头疼。
- 租户自助开通体验:把租户初始化流程封装成异步任务,新租户注册后5秒内就能进入体验环境。用消息队列削峰,避免大量租户同时注册时数据库突增负载。
在我实际维护的项目里,这套混合双模式的多租户架构已经稳定运行了三年,覆盖了300多家付费租户和几十个独立库大客户。最大的感受是:没有万能的架构,只有适合业务阶段的权衡。前期用户量小,全部共享Schema模式足够;跑出几个头部客户再逐步迁移到独立库,这个过渡路径在标题这套"独立数据库 + 共享Schema"的组合设计里是完全顺畅的——因为路由层和应用层早就为两种模式做了兼容。
最后再分享一个细节:动态数据源的日志一定要打全。线上出问题时,最大的痛苦不是不会修,而是不知道当前请求到底路由到了哪个库。我在TenantRoutingDataSource里打了INFO级别的日志:请求进来时记录tenantId和target JdbcUrl,切换失败时记录异常堆栈。这个日志在排查"某个租户的查询被路由到了别人的库"之类的玄学问题时,价值极大。平时RT正常时可以不看,但事故排查时它能帮你快速缩小范围,而不是把半天时间浪费在猜数据源上。
本文还有配套的精品资源,点击获取