简介本资源是一套基于SpringBoot实现多租户SaaS系统的核心技术实践方案面向Java后端开发者、云原生架构师及SaaS平台建设者聚焦解决多租户场景下数据隔离、动态数据源切换与租户识别等关键难题。方案完整覆盖独立数据库与共享数据库独立Schema两种主流隔离模式尤其深入演示了基于AbstractRoutingDataSource的动态数据源路由、请求头/URL参数驱动的租户上下文识别、多Schema事务一致性保障等高阶实现细节。压缩包共36个文件24个Java核心类、2个说明文档、2个HTML示例页、1个application.yml配置、1个jar依赖包等结构清晰含项目骨架、数据源路由器、租户拦截器、JPA多Schema适配器等关键模块总大小仅94KB轻量易读。已有72人学习下载配套的《说明文件.txt》详述搭建步骤、配置要点与典型错误排查路径代码仓库目录规范可直接导入IDE运行验证是理解SpringBoot多租户落地的优质参考范例。 说实话多租户这套东西我第一次在SpringBoot里完整落地的时候也是踩了不少坑才理顺的。很多文章要么只讲概念要么只给一段“伪动态数据源”的代码真到了“一个系统同时支持独立数据库模式和共享Schema模式”这种混合隔离需求时基本没有现成的答案。但这恰恰是SaaS平台最常见的演进路径大租户给独立资源小租户共享资源成本与隔离性兼顾。这篇我就把基于SpringBoot实现这套混合多租户架构的完整思路、核心代码、配置细节和排坑经验一次性讲透。非常适合正在做SaaS平台的架构师、后端负责人以及想搞懂多租户隔离原理的SpringBoot开发者参考。1. 多租户架构选型为什么我选“独立库 共享Schema”混合模式1.1 多租户隔离的三种经典方案先摆清楚在做任何代码之前得先明确一个事实多租户的“多”不只是用户的量级问题而是数据隔离的边界问题。常见方案就三种业内已经讨论过很多次表格对比一下更直观隔离方案数据隔离强度成本运维复杂度典型适用场景独立数据库Database per Tenant最强物理隔离最高数据库实例/库数量多高需要管理多个库的备份、迁移大客户、金融/医疗等强合规场景共享数据库独立SchemaShared Database, Separate Schema中强逻辑隔离中一个库多个Schema中需要处理Schema切换与迁移中大型租户要求数据隔离但可接受共享实例共享数据库共享表Shared Table with Tenant ID弱应用层隔离最低一张表存所有租户低但必须小心所有SQL都带租户ID中小型租户成本敏感、风险可控这里值得多说一句很多人以为共享表方案“开发最快”于是直接选它。结果后续一旦有租户要求“把我们的数据单独导出来审计”麻烦就来了。别问我怎么知道的——共享表模式下做跨租户审计数据导出逻辑至少翻一倍。1.2 为什么不用单一模式而是做混合本篇项目标题里写的是“独立数据库与共享数据库独立Schema模式”这个组合的用意其实很明显同一个SaaS平台不同租户可以落在不同的隔离级别上。打个比方就像写字楼出租整层租给大公司独立数据库单间租给创业团队独立Schema工位租给灵活办公的人共享表。写字楼还是那个写字楼但服务等级可以不同。我实际落地时用这个模型解决了一个很现实的矛盾头部客户要每年审计要求数据完全物理隔离那就给他独立库腰部客户要求比竞品更安全但预算有限那就让他走独立Schema尾部客户只要能用就行共享表兜底。所以设计上必须满足一个核心能力让“用什么隔离级别”变成租户维度的配置项而不是开发时写死。数据源路由层要能感知当前租户对应的隔离方案并动态决定走哪个数据源、切哪个Schema。这个思路也回答了标题里“动态数据源”和“租户识别”为什么是并行的核心没有动态数据源独立库模式跑不起来没有租户识别你根本不知道该路由到哪个库。2. 租户识别与上下文传递整个系统的“命门”2.1 租户识别不能只靠一个Header入口其实有三种租户识别的核心任务是在请求进入系统的第一时间明确“我是谁”。开发初期最容易犯的错误就是只在前端传一个Header就完事。真正落地时至少要考虑以下三种入口域名/子域名识别SaaS平台最常见比如tenantA.example.com、tenantB.example.com从Request的Host头直接解析租户ID。好处是用户无感知浏览器不会做跨域投诉。请求头标准传递比如X-Tenant-Id适合内部系统、开放API、服务间调用。优点是实现简单缺点是容易被伪造所以后面一定要经过鉴权校验。Token/Payload解析在JWT或OAuth2 Token里带租户字段网关或拦截器解析后写入上下文。这种方式最安全因为租户信息经过了签名和校验。我的建议是三种都支持但内部统一收敛为一个解析器。外层拦截器先判断域名再取Header最后解析Token按优先级取第一个非空值。2.2 ThreadLocal上下文为什么每个线程必须有自己的租户身份识别到租户之后要把它传递到Service、DAO层。SpringBoot默认的请求处理模型是“一个请求一个线程”所以用ThreadLocal存租户ID是最自然的做法。我见过有人把租户ID放在一个静态变量里测试时并发一上来直接串号。所以规矩必须定死租户上下文用ThreadLocal封装不允许绕过。public class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); public static void setTenant(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenant() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }为什么用ThreadLocal因为每个线程都有自己独立的一份变量副本天然隔离不需要加锁。而且Servlet容器是线程池模型如果不在请求结束后清理线程复用时会带着上一个租户的ID跑到下一个请求里后果就是数据错乱。所以清理动作必须放在finally里。配合SpringMVC的拦截器整个链路就通了Component public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId resolveTenantId(request); if (StringUtils.hasText(tenantId)) { TenantContext.setTenant(tenantId); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); } }这里有一个容易被忽略的点preHandle里如果解析不到租户ID最好不要直接返回400而是让请求继续走下去由具体的接口判断当前方法是否需要租户上下文。因为有一部分接口比如登录、获取公共配置天然不需要租户身份。2.3 跨线程传递异步场景才是最坑的ThreadLocal在同步请求里很香但一旦遇到异步就翻车。Spring的Async、CompletableFuture、MQ消费者都会开启新线程而新线程的ThreadLocal是空的。解决办法有两个方向手动传递在提交异步任务前把租户ID塞进Runnable/Callable的入参进入新线程后再重新设置ThreadLocal。使用阿里开源的TransmittableThreadLocalTTL这个库专门解决线程池场景下ThreadLocal值传递的问题用了线程池也能正确复制上下文。我实际项目里两种方案都用了简单异步任务用手动传递统一封装线程池时用TTL。这里直接给一个通用封装public class TenantRunnable implements Runnable { private final Runnable delegate; private final String tenantId; public TenantRunnable(Runnable delegate) { this.delegate delegate; this.tenantId TenantContext.getTenant(); } Override public void run() { TenantContext.setTenant(this.tenantId); try { delegate.run(); } finally { TenantContext.clear(); } } public static Runnable wrap(Runnable task) { return new TenantRunnable(task); } }跑异步任务时统一TenantRunnable.wrap(() - doSomething())比事后排查“怎么这个定时任务把租户A的数据写到了租户B的库里”要省心一百倍。3. 动态数据源核心实现两种隔离模式的统一底盘3.1 一切路由的起点AbstractRoutingDataSourceSpringBoot MyBatis做动态数据源核心就是Spring自带的AbstractRoutingDataSource。它就像一个“数据源调度器”只要你告诉它当前应该用哪个key它就从预先注册好的DataSource Map里取对应的数据源。以前很多人做多数据源是静态配置两个DataSource用DS(ds1)写在Mapper上。但多租户场景根本不能在编译期确定每个请求走哪个库所以要用运行时路由。我这里的核心设计是路由Key不只是租户ID而是“租户ID 隔离模式”的组合。比如租户tenant_a走独立库路由key是INDEPENDENT:tenant_a租户tenant_b走共享Schema路由key是SHARED_SCHEMA:tenant_b实际路由到共享数据源后Schema切换靠Connection层处理这样做的好处是路由逻辑和数据源生命周期管理完全解耦。3.2 动态数据源核心代码先定义路由数据源public class TenantRoutingDataSource extends AbstractRoutingDataSource { public TenantRoutingDataSource(MapString, DataSource targetDataSources) { super(); setTargetDataSources(new HashMap(targetDataSources)); setDefaultTargetDataSource(targetDataSources.get(DataSourceKey.DEFAULT)); afterPropertiesSet(); } Override protected Object determineCurrentLookupKey() { return TenantContext.getCurrentDataSourceKey(); } }注意determineCurrentLookupKey()返回的key决定走哪个数据源。所以我在TenantContext里不仅要存租户ID还要提供一个getCurrentDataSourceKey()由租户解析器根据当前租户的配置计算出来。再来看租户解析和注册表。我推荐用一张租户配置表或者配置文件来维护每个租户的隔离级别Component public class TenantRegistry { /** * 根据租户ID解析应该路由到哪个数据源Key */ public String resolveDataSourceKey(String tenantId) { TenantConfig config getTenantConfig(tenantId); // 从缓存或配置中心读取 if (config.getIsolationLevel() IsolationLevel.DATABASE) { return DATASOURCE_ tenantId; } else if (config.getIsolationLevel() IsolationLevel.SCHEMA) { return DATASOURCE_SHARED_SCHEMA; } return DATASOURCE_DEFAULT; } /** * 独立租户需要动态注册数据源 */ public void registerTenantDataSource(String tenantId, DataSource ds) { // 动态添加到路由表中 DynamicDataSourceManager.addDataSource(DATASOURCE_ tenantId, ds); } }动态数据源管理器可以维护一个全局静态Map方便运行时注册新租户的独立数据源public class DynamicDataSourceManager { private static final MapString, DataSource DATA_SOURCE_MAP new ConcurrentHashMap(); public static void addDataSource(String key, DataSource dataSource) { DATA_SOURCE_MAP.put(key, dataSource); // 重建Router内部targetDataSources TenantRoutingDataSource router DataSourceRouterHolder.getRouter(); router.afterPropertiesSet(); // 触发重新hash } }这一步也要注意网上很多教程只完成了AbstractRoutingDataSource的继承却没说清楚“数据源是启动时静态写死还是运行时动态注册”。实战中租户都是后台开通的不可能每开一个租户就重启一次系统所以动态注册能力是刚需。3.3 独立Schema模式下如何“切Schema”而不换数据源如果说独立库模式是“换DataSource”那共享Schema模式就是在同一个DataSource之下切换连接Connection上当前用的Schema。这里要区分数据库PostgreSQL一个database下可以建多个schema连接后执行SET search_path TO tenant_001即可切换。MySQL逻辑上 schema 就是 database做不到“一个连接里多schema”这样的优雅切换。如果硬要实现类似效果只能用多数据库连接串或同实例多库路由严格意义上它退化为“共享实例独立库”模式。SQL Server支持 schema切换方式类似PostgreSQL。所以我的建议是共享独立Schema模式优先选PostgreSQLMySQL下做这套是给自己找麻烦。在代码实现上最稳妥的是自定义MyBatis拦截器在每次SQL执行前把当前连接的search_path切到租户对应的schemaIntercepts({ Signature(type Executor.class, method prepare, args {StatementHandler.class, Connection.class, Integer.class}) }) public class SchemaSwitchInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Connection connection (Connection) invocation.getArgs()[1]; String tenantId TenantContext.getTenant(); if (StringUtils.hasText(tenantId)) { try (Statement stmt connection.createStatement()) { stmt.execute(SET search_path TO safeSchemaName(tenantId)); } } return invocation.proceed(); } }这里有一个安全细节schemaName不能直接拼接字符串必须做白名单校验防止SQL注入。比如必须匹配预设的正则^[a-zA-Z0-9_]$否则拒绝执行。schema名本来就是我们系统生成并管控的直接强校验就行。还有更优雅的做法在数据源配置里使用currentSchema/search_path参数从连接池拿到的连接天然指向目标schema。这适合租户数量固定的场景如果租户动态创建频繁还是拦截器方案更灵活。3.4 混合模式下的路由流程示意图文字概念讲多了容易绕我用一个完整的请求路径来梳理浏览器请求https://tenantA.example.com/api/order/listTenantInterceptor从Host解析出tenantATenantContext.setTenant(tenantA)Service层调用Mapper接口MyBatis执行前TenantRoutingDataSource.determineCurrentLookupKey()被调用路由key解析查租户配置表发现tenantA是独立库模式返回DATASOURCE_TENANTA拿到独立的数据源执行SQL如果此时是 tenantB配置是共享Schema模式路由key返回DATASOURCE_SHARED_SCHEMA拿到共享数据源执行SQL前schema拦截器把连接切到tenant_b的schema请求结束TenantContext.clear()这套流程里最重要的一点是路由必须是“业务无感知”的。开发人员写Mapper时不需要关心当前租户走的是独立库还是共享Schema框架层全部搞定。4. 工程化落地从表结构设计到连接池调参4.1 租户配置表与系统表设计要支撑混合模式底层必须先有一张“租户元数据表”。这张表本身放在系统默认库里不参与租户隔离。CREATE TABLE t_tenant ( tenant_id VARCHAR(64) PRIMARY KEY, tenant_name VARCHAR(128) NOT NULL, isolation_level VARCHAR(16) NOT NULL COMMENT DATABASE / SCHEMA / TABLE, db_config_id BIGINT NULL COMMENT 独立库模式下关联的数据源配置, schema_name VARCHAR(64) NULL COMMENT 共享Schema模式下的schema名, status TINYINT NOT NULL DEFAULT 1, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );独立库模式下每个租户的数据库连接信息也应该持久化。不建议把这些信息硬编码在application.yml里而是放到t_datasource_config表由后台管理界面动态维护创建租户时自动读取配置并注册数据源。schema的命名规范我也建议统一比如tenant_ 租户ID全小写不带特殊字符。这样无论是独立schema模式下的SET search_path还是后续的监控排查都能一眼看出属于哪个租户。4.2 Flyway多租户脚本迁移独立库和共享Schema分别怎么处理多租户系统最怕数据库结构升级时“有的租户升了有的没升”。这里推荐用Flyway做自动化迁移但要注意场景分支独立库模式启动时遍历所有活跃的独立库租户逐个执行Flyway迁移。共享Schema模式对共享库执行一次迁移Flyway会自动在schema下维护flyway_schema_history表多租户下要注意每个schema各有一份历史表。这里更推荐使用Flyway的schemas配置指定schema列表或者通过Java API动态配置。Configuration public class FlywayConfig { Bean public FlywayMigrationInitializer flywayForTenantSchemas( DataSource sharedSchemaDataSource, TenantRegistry tenantRegistry) { ListString schemas tenantRegistry.getActiveTenantSchemas(); Flyway flyway Flyway.configure() .dataSource(sharedSchemaDataSource) .schemas(schemas.toArray(new String[0])) .locations(classpath:db/migration/tenant) .load(); flyway.migrate(); return new FlywayMigrationInitializer(flyway, null); } }这里提示一点生产环境多个应用实例同时执行Flyway迁移会有竞争问题需要开启Flyway的锁机制默认开启并且最好把迁移任务收敛到一个独立的管理端服务上执行而不是所有工作节点都跑。4.3 连接池参数调整多数据源模式下别踩线程饥饿的坑加多数据源之后最容易忽略的是连接池参数。每个DataSource连接池默认的maximum-pool-size如果是10那么10个独立库租户就是100个连接再加上共享库的池子数据库实例的连接数压力会非常大。我自己实际调参的经验独立库模式下单个租户的数据源连接池不需要很大maximum-pool-size设为5-8就够因为每个租户的业务量相对有限。共享Schema模式下的共享数据源连接池可以大一些比如15-20因为所有共享租户共用这个池子。所有连接池必须设置connection-timeout和idle-timeout防止连接泄漏时请求永久卡死。建议开启连接池的leak-detection-thresholdHikariCP一旦检测到连接泄漏能快速定位到堆栈现场。我记得有一次生产事故就是因为某个接口开了事务没关连接池被耗尽所有租户的请求都在等连接。加上leak-detection-threshold之后瞬间就抓到了元凶。5. 事务、缓存与分布式环境下的几个大坑5.1 动态数据源与Transactional的冲突这是一个高频问题加了Transactional之后动态数据源失效。原因在于Spring的事务管理器在事务开始时就把数据库连接绑定到了当前线程上事务执行过程中MySQL连接不再走determineCurrentLookupKey()因为连接已经通过DataSourceUtils从数据源拿到并缓存了。解决思路其实不复杂——必须先切换数据源再开启事务。也就是说如果方法内部需要切换多个数据源并保持事务一致比如写租户A的库同时写租户B的库一个Transactional是搞不定的必须拆成多个事务管理器或者引入分布式事务。如果只是“同租户的单个数据源内事务”那必须保证进入Service方法前租户上下文已设置好也就是拦截器先执行Service后执行。问题就出在SpringBoot默认的拦截器执行顺序拦截器先于AOP代理所以通常没问题。但我遇到过有人把租户识别逻辑写在某个被Transactional包裹的Service方法里这就有问题了。我建议是租户上下文必须是最早初始化的最好放在Filter或拦截器而不是Service里。Debug一次线程执行顺序你就能理解为什么这个建议这么重要。5.2 独立Schema模式下MyBatis的Schema前缀问题在共享Schema模式下如果SQL里没有带schema前缀依赖连接上的search_path或currentSchema生效那当连接池复用连接时可能出现“上一次请求是租户A连接上search_path还是租户A但这一次请求是租户B”的串号风险。所以拦截器切Schema的逻辑必须每一次都执行而不是像一些人想的“连接初始化时切一次就好”。连接池里的连接是复用的不是每次getConnection都是新连接。另外如果你的ORM实体类上写了TableName但没带schema前缀那么在PostgreSQL里会优先使用连接当前的search_path一般问题不大。但如果你用MyBatis的XML写SQL强烈建议在多租户字段上不要写死schema前缀。写死会导致同一套代码无法在不同租户之间自由切换。5.3 缓存隔离Redis里不同租户的同名Key冲突很多系统引入了Redis缓存之后多租户系统又多了一个新问题租户A和租户B的商品数据Key都是product:1001结果缓存互相覆盖。我的经验是所有业务缓存的Key都要带上租户ID。不只在Redis而是在所有缓存中间件里同步执行。public static String buildTenantKey(String key) { return TenantContext.getTenant() : key; }这个统一工具方法告诉所有开发人员写缓存必须走这个入口不允许自己拼接。另外还需要考虑缓存穿透的租户隔离问题租户A查询一个不存在的数据如果没有做空值缓存每次都穿透到数据库就会给数据库造成没必要的压力。多租户系统的缓存穿透防护比单租户系统更迫切因为你面对的是N个租户的QPS。实践中我的方案是查询缓存时如果Key不存在先查数据库如果数据库也不存在设置一个较短的缓存空值比如2分钟Key同样加上租户ID。5.4 定时任务与MQ消费的租户上下文定时任务和MQ消费者是租户上下文最容易丢失的两个地方因为它们的执行线程并不在处理HttpServletRequest的线程中。对于定时任务我一般这样设计定时任务扫描的是一个“待处理任务表”每一条任务记录里本身就带了tenant_id执行到该条任务时手动调用TenantContext.setTenant(task.getTenantId())执行完立刻清理。对于MQ消费者在消息体里强制带上tenantId字段消费时先设置租户上下文再执行业务逻辑Component public class OrderMessageConsumer { RabbitListener(queues order.queue) public void onMessage(OrderMessage message) { TenantContext.setTenant(message.getTenantId()); try { orderService.handleOrder(message.getOrderId()); } finally { TenantContext.clear(); } } }这里一定要记得用try-finally因为一旦业务抛异常MQ会重投如果不清上下文线程池里下一个任务就会带着错误的租户ID执行。6. 常见问题与排查技巧实录6.1 问题速查表现象常见原因解决思路切换数据源后连接不生效还是走旧库事务已经开启连接被事务绑定拆分事务先切数据源再开启事务租户A的请求偶尔看到租户B的数据线程池复用ThreadLocal未清理或连接池复用连接未切schema强制在finally中清理TenantContext拦截器每次切Schema独立库模式下新租户数据源不生效数据源没有动态注册进路由表检查DynamicDataSourceManager是否调用afterPropertiesSet()动态数据源下MyBatis二级缓存串数据二级缓存在租户间共享多租户环境下建议关闭MyBatis二级缓存或按租户维度隔离cache实例共享Schema模式连接串库MySQL中schema与database等同误解概念换PostgreSQL或改为共享实例独立库方案异步线程拿不到租户IDThreadLocal不跨线程使用TTL或手动传递租户上下文6.2 如何定位“到底是哪个数据源”遇到多数据源问题最有效的排查手段就是在determineCurrentLookupKey()里打日志或者通过链路追踪工具记录路由Key。我常常在开发环境加一个拦截器把每次请求的租户ID、路由数据源Key、最终执行的SQL摘要输出出来logging.level.com.example.datasourceDEBUG日志效果18:23:01.225 [http-nio-8080-exec-3] DEBUG ... : [TENANT:tenantA] route to DATASOURCE_TENANTA, sql: select * from t_order where order_id ? 18:23:01.227 [http-nio-8080-exec-3] DEBUG ... : [TENANT:tenantA] execute success, cost 12ms看到这样的日志你就能快速判断“租户识别有没有生效”、“路由有没有走对、数据源有没有切换成功”。这个排查手段比看日志堆栈高效得多。6.3 性能测试时发现连接数不够多租户系统上线前一定要做连接池压力测试。因为独立库模式下每个租户的数据源都是独立的如果连接池配置过大数据库实例的连接数很容易被打满。举个例子100个独立库租户每个数据源maximum-pool-size10正常情况最多也就1000个连接。如果数据库实例限制连接数500系统必挂。建议方案每个租户数据源连接池上限动态计算比如按租户套餐等级配置整体连接数做监控超过阈值时告警共享库分组把租户拆分到多个共享库实例上而不是所有共享租户堆在一个库。6.4 MyBatis二级缓存必须谨慎多租户场景下MyBatis的二级缓存是跨namespace的如果没做租户维度隔离很容易出现“A租户查了数据B租户直接命中缓存”的严重数据泄漏。我实际项目中的决策是默认关闭MyBatis二级缓存只保留一级缓存SqlSession级别。如果需要缓存走Redis并且每个缓存Key加上租户前缀。这样缓存生命周期和数据权限都清晰可控。7. 最后分享一个小经验这套混合多租户架构一开始听起来像“造火箭”但拆解下来核心就三件事租户识别、动态路由、Schema切换。只要这三层基础打牢后续不管是加新租户、切换隔离级别还是扩展数据源都只是配置和注册的工作量。我个人的体会是动手之前先把租户配置化做进去别急着写业务代码。很多项目后来返工不是因为动态数据源写不出来而是租户上下文没有一个统一、规范的设计代码里到处散落着“通过参数传递租户ID”的临时方案最后改不动了。如果这篇文章能帮你少走一些弯路哪怕只避开一个“连接泄漏”或“缓存串号”的坑就值了。后面我还会继续拆解多租户SaaS的权限体系设计、多租户下的分布式事务方案感兴趣可以保持关注。本文还有配套的精品资源点击获取