拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot多租户数据隔离:独立库与共享Schema双模式落地实践

简介本资源是一套基于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模式同一个数据库实例下每个租户拥有独立的schemaPostgreSQL/MySQL中表现为独立数据库名Oracle中表现为独立schema逻辑隔离。共享数据库共享Schema模式所有租户共用一个schema通过tenant_id字段区分。标题里提到的是前两种的组合这一点非常务实。因为真正生产环境里没有任何一家SaaS公司会只用一种模式打天下。大租户要独立库中小租户要低成本共享双模式混合才能平衡成本和隔离性。1.2 独立数据库模式的优势与代价独立数据库模式的优点不用多说数据隔离最彻底一个租户的慢查询、死锁、磁盘占用不会拖垮其他租户备份恢复可以按租户独立操作出问题时排查范围小运维边界清晰。但代价也很具体数据库实例数量爆炸。100个租户要建100个库或实例连接数、监控、备份任务都是线性增长。成本居高不下。云数据库按实例收费独立实例的固定开销远高于共享。结构变更要push到所有库。每次上线新版本要对所有租户库执行迁移脚本漏掉一个就是事故。数据量的天花板取决于实例资源。单个租户库的读写能力受限于它所在的实例规格做不了弹性伸缩。所以独立库模式更适合数据安全要求高、业务规模大、能承担更高费用的头部租户比如银行、保险、大型连锁企业。1.3 共享数据库独立Schema模式的优势与代价共享数据库独立Schema模式在同一个MySQL实例里建多个databaseMySQL里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里最常用的就是ThreadLocalpublic class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); private static final ThreadLocalTenantType 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 过滤器/拦截器实现租户解析推荐用OncePerRequestFilterServlet过滤器来做租户解析因为它在进入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、ThreadPoolTaskExecutorThreadLocal就会丢失子线程拿不到租户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()方法返回一个keySpring就会根据这个key从预置的targetDataSources中查找对应的DataSource。这个抽象类内部维护了一个MapObject, DataSource当determineCurrentLookupKey()返回的key发生变化时连接就自动切到对应的数据源。关键点在于在Spring事务管理里拿到连接的时刻就决定了数据源所以切库动作必须发生在事务开启之前或事务边界之内但连接获取之前。3.2 租户数据源注册表的自研设计由于我们是双模式混合数据源不能单纯按租户ID注册还要兼顾模式。我在项目里维护了一个租户数据源注册表核心结构如下public class TenantDataSourceRegistry { // key: tenantId, value: DataSource private final MapString, DataSource tenantDataSources new ConcurrentHashMap(); // 共享库实例名 - 数据源 private final MapString, DataSource sharedInstanceDataSources new ConcurrentHashMap(); // 租户ID - 租户路由信息模式 目标库/实例名 private final MapString, 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 ?useSSLfalseserverTimezoneAsia/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调低到5minIdle调到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. 查询所有独立库模式的租户 ListTenantInfo 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模式所有租户共用一套迁移脚本逐个租户库迁移 ListTenantInfo sharedTenants tenantMapper.selectByType(TenantType.SHARED_SCHEMA); MapString, 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共享Schematenant_${租户ID}tenant_10023另外因为连接串里直接带了database名租户ID必须做合法性校验只允许字母、数字、下划线。否则有人传入恶意字符串拼进JDBC连接串轻则连接失败重则引起SQL注入。我在过滤器里对X-Tenant-Id做了正则校验不合法直接返回400。还有一点要给新人提个醒租户初始化和数据源注册的时机要放到事务外。如果放在事务里CREATE DATABASE这类DDL会隐式提交和事务的其他操作混在一起会产生难以排查的锁问题。我的经验是所有DDL操作都放在事务外用完连接马上释放。5. 实战中高频踩坑与完整排查链路5.1 坑一事务注解导致数据源切换失效这是最经典的问题。现象是业务代码里明明在Service方法开头设置了租户ID动态数据源也正确返回了对应的DataSource但数据库查询仍然报Table doesnt exist查的还是默认库的表。排查过程先查TenantContext.getTenantId()有没有值——正常。在determineTargetDataSource()里加日志打印返回的DataSource的JdbcUrl——发现切换到独立库时JdbcUrl是正确的。进一步在DAO层打印当前连接的类名和URL——发现连接还是默认库的。最终定位Service方法上有TransactionalSpring在进入方法时就通过默认数据源获取了连接并绑定到线程后续动态数据源虽然返回了新DataSource但Spring事务管理根本不会再重新获取连接。解决方案将Transactional移到更内层的方法上确保进入事务前租户上下文已准备好。如果必须外层开事务用TransactionSynchronizationManager.bindResource()手动把连接绑定到当前线程让事务认识新数据源。最省事的做法把需要切库的操作拆分到无事务的Service方法里底层DAO各自带短事务。5.2 坑二动态数据源在启动阶段被提前绑定Spring启动时如果PostConstruct方法里有数据库操作而TenantContext还没有租户ID动态数据源就会退回默认数据源。默认数据源如果没有配置启动直接失败配置了系统库就会误把系统库当成业务库操作。排查时看到determineCurrentLookupKey()返回null而日志显示在启动阶段就要去检查那些启动即执行的定时任务、健康检查、缓存预热代码。修复就是初始化代码里不要走业务数据源。如果是系统级操作显式注入系统库的数据源如果是租户级操作要在代码里先设置租户ID再执行通常在ApplicationRunner里执行。5.3 坑三连接池资源泄漏导致数据库连接被耗尽共享Schema模式下每个租户独立连接池如果某个租户并发量激增它的连接池会占满共享实例的所有连接。HikariCP默认maximumPoolSize10300个租户同时满负荷压力全在数据库实例上最终整个实例挂掉。排查的时候连不上数据库查看监控发现连接数飙升看到是某个租户的连接池把实例打爆。修复方案给每个租户连接池设置上限比如按租户等级设置maximumPoolSize上限为5或8。共享实例层面做总连接数熔断监控连接数达到阈值的80%就报警超出阈值拒绝新租户扩容。把SQL耗时长的租户隔离到独立库让重流量租户不再占用共享资源。5.4 坑四缓存中的租户上下文串台用ThreadLocal存租户上下文如果请求处理完没有清理连接池中的线程被下一个请求复用就会读到上一个租户的ID。这种问题极其隐蔽因为它不会立刻报错只会在数据上串味。排查手段在Filter的finally块中加日志打印线程ID和租户ID观察同一个线程在不同请求间是否残留了旧租户。修复是双管齐下在TenantContext包装类里当上一个租户ID未被清空时就设置新值打印WARN日志尽早暴露问题。在Tomcat线程池和业务线程池两个层面分别监控业务线程池用TaskDecoratorWeb层用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() { ListSharedInstanceInfo 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模式要小一点。我实践下来的参考值模式maximumPoolSizeminimumIdleconnectionTimeoutmaxLifetime独立库大租户20530000ms1800000ms独立库小租户10230000ms1800000ms共享Schema5130000ms1800000ms由于共享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正常时可以不看但事故排查时它能帮你快速缩小范围而不是把半天时间浪费在猜数据源上。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门