Spring Boot多租户动态数据源:配置入库与运行时热切换实战
去年接了一个 SaaS 化改造的活儿系统要从单数据源升级成多租户架构。当时最头疼的不是业务代码改造而是租户数据源的管理方式。客户的租户量一直在涨新租户上线不能重启服务数据源配置还得能随时调整。最后我定的方案就是标题里说的这套用 Spring Boot 实现多租户切换数据源并且把数据源连接信息存到数据库里动态读取、动态注册。这篇文章把我完整的落地过程、核心原理和踩坑记录都整理出来给正在做类似改造的后端同学一个可以直接抄作业的参考。这套方案解决的问题很具体一个应用实例同时服务多个租户每个租户独立数据库请求进来时按租户标识自动路由到对应数据源新租户的数据源配置写入数据库后无需重启即可生效。适合微服务、SaaS 后台这类对数据隔离要求比较高的场景。下面从选型开始讲一步步拆到能跑通为止。1. 多租户隔离方案选型与动态数据源的适用边界做多租户首先得选隔离模式。常见的有三种独立数据库、共享数据库独立 Schema、共享表加租户字段。这个选择直接决定了后续所有的技术方案不能拍脑袋定。独立数据库是最直观的隔离方式每个租户一套库数据的物理隔离性最强连备份恢复都能按租户单独操作。代价是连接数、资源占用会随租户数量线性增长对数据库服务的上限要求高。共享 Schema 介于两者之间一个库里建多个 Schema隔离性也不错但跨租户的迁移和备份还是麻烦。共享表加租户字段最省资源但所有租户的数据混在一起查询漏加租户条件就是事故合规审计也很难解释。我当时给客户定的方案是独立数据库模式核心原因有三点一是客户对数据隔离的合规要求非常硬租户间不允许任何串数据的情况二是租户的体量差异很大大租户数据量大适合单独库做性能调优三是未来做数据归档、按租户导出时独立库操作上最省心。1.1 为什么要把数据源信息放进数据库而不是写死在配置里最初的版本我也纠结过直接在 application.yml 里配置多数据源不就行了用静态配置确实简单但很快遇到三个痛点。第一个痛点是新增租户必须发版重启。租户的数据源信息只要写进 yml就得走 CI/CD 流程重启所有实例才能生效。SaaS 场景里开新租户是高频操作每次都要发版运维和研发都会被拖死。第二个痛点是配置容易漂移。如果多实例部署每个实例的配置文件要保证完全一致一旦有某台机器漏更新就会出现部分请求路由错误排查起来非常痛苦。第三个痛点是权限问题。数据库连接信息属于敏感配置放在代码仓库里天然不安全也不方便做定期轮换。放在数据库表里可以用加密存储配合管理后台做权限控制安全级别完全不一样。所以最终选型就是数据源信息存库应用启动时加载一次运行期间通过数据库变更触发动态注册。这样新租户开通只需要往表里插一条记录刷新缓存即可全程无感。1.2 这套方案不适合什么场景也要说清楚边界。如果你只有三五个固定数据源而且几乎不变动态化反而是过度设计。引入动态数据源会带来额外的复杂度需要管理和监控数据源生命周期需要处理配置更新的并发问题还要防连接池泄漏。另外如果租户数量特别大比如上万个租户每个租户独立数据源的模式本身就值得重新考虑。上万个连接池对应用内存和数据库服务端都是巨大压力。这种场景更适合用共享库共享表加字段隔离或者中间用代理层做连接复用而不是应用层直连每个租户库。我这套方案的适用规模大致在几十到几百个租户之间再多就要做连接池的分层管理了。2. 核心原理AbstractRoutingDataSource 如何完成路由与动态注册动态切换数据源Spring 生态里最常用的底座就是AbstractRoutingDataSource。理解它的工作机制后面写代码才不会糊。它本质上是一个代理数据源自己并不真正持有连接而是在getConnection()调用时根据当前路由 key 从目标数据源集合里取一个真实数据源来返回连接。源码里最关键的是determineCurrentLookupKey()这个抽象方法由调用方实现。方法的返回值会作为 key 去匹配目标数据源。如果没有匹配到会走determineTargetDataSource()里的默认数据源兜底逻辑。2.1 原生实现的两个局限直接用 Spring 提供的AbstractRoutingDataSource会撞上两个限制。第一个限制是目标数据源注册后不能动态新增。父类的targetDataSources和resolvedDataSources都是 private 字段只能通过构造时传入的 Map 初始化后续没有公开方法往里加新的数据源。也就是说你用原生类实现的话数据源集合在启动时锁死新租户的数据源加不进去。第二个限制是切换 key 只能靠determineCurrentLookupKey()一个方法返回如果不想在子类里写死逻辑就得自己实现一个上下文持有者把租户标识塞进 ThreadLocal再由这个方法从 ThreadLocal 里取。2.2 动态注册的关键思路反射也好核心兜底也好总之要扩展解决动态新增问题业界常规做法是不直接用原生AbstractRoutingDataSource而是自己维护一个注册表。我当时的做法是写了一个DynamicDataSource类继承AbstractRoutingDataSourcepublic class DynamicDataSource extends AbstractRoutingDataSource { private final MapObject, Object targetDataSources new ConcurrentHashMap(); private final MapObject, DataSource resolvedDataSources new ConcurrentHashMap(); private DataSource defaultDataSource; Override protected Object determineCurrentLookupKey() { return TenantContext.getCurrentTenantId(); } Override public Connection getConnection() throws SQLException { DataSource ds determineTargetDataSource(); return ds.getConnection(); } private DataSource determineTargetDataSource() { Object lookupKey determineCurrentLookupKey(); if (lookupKey null) { return defaultDataSource; } DataSource ds resolvedDataSources.get(lookupKey); if (ds null) { throw new IllegalStateException(未找到租户 [ lookupKey ] 对应的数据源); } return ds; } public synchronized void addDataSource(String tenantId, DataSource ds) { resolvedDataSources.put(tenantId, ds); targetDataSources.put(tenantId, ds); } public void removeDataSource(String tenantId) { resolvedDataSources.remove(tenantId); targetDataSources.remove(tenantId); } }这样determineCurrentLookupKey()走生命周期代码直白动态新增和删除都直接操作自己的 map。注意我重写了getConnection()而不是走父类的委托逻辑好处是完全掌控路由逻辑比如租户不存在时直接抛异常而不是默默走默认数据源导致串数据。2.3 租户标识的传递链路路由 key 只是最终一环前面还需要解决租户标识怎么进到上下文来。正常的调用链是网关或前端在请求头里带上租户 ID服务端通过拦截器或过滤器解析后写入TenantContext请求处理完清除。public class TenantContext { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setTenantId(String tenantId) { CONTEXT.set(tenantId); } public static String getCurrentTenantId() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }TenantContext的清除一定要在 finally 块里做否则线程池复用时上下文会串。这一点等会儿讲踩坑会重点说因为真的会出线上事故。3. 完整落地数据源配置入库 启动加载 切面切换原理清楚了下面给一套能直接跑的落地代码。我按配置表、加载器、数据源构造器、切面四部分来讲每一部分都标了注意事项。3.1 数据源配置表怎么设计表结构要能覆盖绝大多数场景至少包含租户 ID、数据库连接信息、状态和扩展字段CREATE TABLE tenant_datasource ( id bigint(20) NOT NULL AUTO_INCREMENT, tenant_id varchar(64) NOT NULL COMMENT 租户ID, db_type varchar(32) NOT NULL DEFAULT mysql COMMENT 数据库类型, host varchar(128) NOT NULL COMMENT 数据库地址, port int(11) NOT NULL DEFAULT 3306 COMMENT 端口, db_name varchar(128) NOT NULL COMMENT 数据库名, username varchar(128) NOT NULL COMMENT 用户名, password varchar(256) NOT NULL COMMENT 密码AES加密存储, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, extra_config text COMMENT 扩展配置(JSON), version int(11) NOT NULL DEFAULT 1 COMMENT 版本号用于热更新, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_tenant (tenant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT多租户数据源配置表;几个设计细节db_type预留数据库类型字段以后接 PostgreSQL、SQLServer 都能扩展。password不要存明文。我用的 AES 加密密钥放在配置中心不落代码库。解密逻辑在构造连接池时执行。extra_config存 JSON比如连接池大小、是否只读、连接超时时间等不同租户可以差异化配置。version字段配合热更新。修改配置时 version 自增应用轮询发现版本变化就重建连接池。3.2 启动和运行时的数据源加载器数据源初始化分两种时机。启动时全量加载运行中增量加载或更新。Component public class TenantDataSourceLoader implements ApplicationRunner { private final DynamicDataSource dynamicDataSource; private final TenantDataSourceMapper tenantDataSourceMapper; Override public void run(ApplicationArguments args) { ListTenantDataSource list tenantDataSourceMapper.selectAllEnabled(); for (TenantDataSource config : list) { DataSource ds DataSourceFactory.createDataSource(config); dynamicDataSource.addDataSource(config.getTenantId(), ds); } log.info(初始化完成共加载 {} 个租户数据源, list.size()); } }运行时更新我用了一个定时任务做轮询。每 30 秒查一次表里update_time晚于上次加载时间的记录有变化就重新创建连接池并替换。数据量不大这种实现性价比很高不需要引入消息队列。Scheduled(fixedDelay 30000) public void refresh() { ListTenantDataSource changedList tenantDataSourceMapper.selectChanged(lastRefreshTime); for (TenantDataSource config : changedList) { if (0.equals(config.getStatus())) { dynamicDataSource.removeDataSource(config.getTenantId()); DataSource old removedPoolCache.get(config.getTenantId()); if (old ! null) { closeQuietly(old); } } else { DataSource newDs DataSourceFactory.createDataSource(config); DataSource old dynamicDataSource.putDataSource(config.getTenantId(), newDs); if (old ! null) { closeQuietly(old); } } lastRefreshTime max(lastRefreshTime, config.getUpdateTime()); } }3.3 连接池构造HikariCP 的正确打开方式构造连接池我用的 HikariCPSpring Boot 2.x 以上默认自带。但注意动态创建时要手动 new HikariDataSource 而不是依赖自动配置public class DataSourceFactory { public static DataSource createDataSource(TenantDataSource config) { HikariConfig hikariConfig new HikariConfig(); hikariConfig.setJdbcUrl(buildJdbcUrl(config)); hikariConfig.setUsername(config.getUsername()); hikariConfig.setPassword(AesUtil.decrypt(config.getPassword())); hikariConfig.setDriverClassName(resolveDriver(config.getDbType())); // 连接池核心参数 hikariConfig.setMaximumPoolSize(config.getMaxPoolSize() ! null ? config.getMaxPoolSize() : 10); hikariConfig.setMinimumIdle(config.getMinIdle() ! null ? config.getMinIdle() : 2); hikariConfig.setConnectionTimeout(30000); hikariConfig.setIdleTimeout(600000); hikariConfig.setMaxLifetime(1800000); // 前缀自动加 jdbc:防止配置漏写 return new HikariDataSource(hikariConfig); } private static String buildJdbcUrl(TenantDataSource config) { return String.format(jdbc:mysql://%s:%s/%s?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai, config.getHost(), config.getPort(), config.getDbName()); } }注意几个参数含义。maximumPoolSize控制最大连接数minimumIdle是池子最少保持的空闲连接数。这个值不是越大越好租户多了以后每个连接池的 minIdle 叠加起来数据库服务端连接数会爆炸。比如 50 个租户每个 minIdle2 就是 100 个常驻连接如果没有开启 MySQL 的连接超时回收连接数会一直挂着。我实际生产配置里把 minIdle 设成了 0让空闲连接自动回收。代价是租户低峰后第一次请求会有建连延迟所以又做了预热机制这个后面讲生产化建议时细说。3.4 切换数据源的切面注解 AOP 是最舒服的用法数据源切换我选择了注解加切面的方式而不是在 Service 代码里手动调 TenantContext因为注解更直观也能避免忘记清除上下文。先定义注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface TenantDataSource { String value() default ; }再写切面Aspect Component Order(0) public class TenantDataSourceAspect { Around(annotation(tenantDataSource)) public Object switchDataSource(ProceedingJoinPoint joinPoint, TenantDataSource tenantDataSource) throws Throwable { String tenantId resolveTenantId(tenantDataSource.value()); try { TenantContext.setTenantId(tenantId); return joinPoint.proceed(); } finally { TenantContext.clear(); } } }这里Order(0)很关键必须保证这个切面的执行顺序在事务切面之前否则事务先拿到了连接再切换数据源就晚了。后面踩坑部分会专门展开。至于resolveTenantId的实现如果是加在 Controller 层接口上通常从请求头取。如果是加在 Service 方法上一般是上游已经解析好租户 ID直接从参数或上下文拿。private String resolveTenantId(String annotationValue) { if (StringUtils.hasText(annotationValue)) { return annotationValue; } // 从请求头获取租户ID ServletRequestAttributes attrs (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs ! null) { String tenantId attrs.getRequest().getHeader(X-Tenant-Id); if (StringUtils.hasText(tenantId)) { return tenantId; } } throw new IllegalStateException(无法解析租户ID); }4. 实测中最容易翻车的几个坑事务、连接池与上下文代码能跑通只是第一步。这套方案真正磨人的是各种边界情况下面这几个坑我全部在测试环境和生产环境遇过每个都值得单独拎出来讲。4.1Transactional导致的数据源切换失效这是多数据源切方案里排名第一的大坑。现象是方法加了TenantDataSource注解切换数据源也加了Transactional开启事务但查询结果还是去了默认库或者直接报表不存在。原因要从 Spring 事务的实现机制讲起。Transactional是通过 AOP 在方法执行前开启事务的它做的第一件事是从DataSourceTransactionManager里拿连接并用 ThreadLocal 把连接绑定到当前线程。如果事务开启时拿的是默认数据源的连接后面你再怎么切换数据源只要事务没提交这个线程用的连接始终是之前那个。关键就在切面执行顺序。如果事务切面的 Order 比数据源切面靠前事务先绑定连接数据源切面后面才设置 TenantContext路由核心就白搭了。解决办法有两个方向方向一是保证TenantDataSourceAspect的 Order 在事务切面之前也就是我上面代码里的Order(0)让数据源上下文在事务拿连接前就设置好。可以用Order(Ordered.HIGHEST_PRECEDENCE)来确保最优先执行。方向二是避免切数据源的方法直接开事务。把事务粒度缩小让有事务的代码统一在内部子方法里这样数据源上下文在进入事务子方法前就已经定好了。我实际项目里两种都用了主推方向一因为改动量小只用调整切面优先级。另外注意一个细节如果你在 Service 里用this.xxx()调用内部方法切面不生效这是 Spring AOP 基于代理的特性。数据源切面和事务切面一样必须通过代理对象调用才有效。我见过有人在这里卡了一下午方法明明没报错数据源就是切不过去。4.2 ThreadLocal 上下文串租户第二个大坑是异步场景下的串租户。TenantContext用的是 ThreadLocal数据天然只属于当前线程。如果提交给线程池执行子线程读不到父线程的 TenantContext但如果用的是线程复用机制上一个请求没清理干净下一个请求就会读到脏租户。这种问题只有在大流量下才偶现排查非常讨厌。我遇到的一次事故是某个导出接口把任务丢给了线程池刚好线程池里的线程之前处理过租户 A 的请求清理逻辑没走完结果租户 B 的导出任务拿着租户 A 的数据源执行直接把数据导出串了。处理方式有几层。第一层是强制清理在拦截器或切面的 finally 里调用TenantContext.clear()。第二层是跨线程传递用TransmittableThreadLocal替代普通 ThreadLocal它能在创建异步任务时自动把父线程的值拷贝给子线程。第三层是显式传递租户 ID而不是依赖上下文隐式传递比如异步任务入参里带上租户 ID任务开头手动设置 TenantContext。我建议三管齐下。框架层面更换 TransmittableThreadLocal业务层面异步任务首行设置兜底层面使用拦截器强制清理。这三层全部铺上去之后再也没有出现过串租户。4.3 动态数据源的连接池生命周期管理第三个坑是连接池只增不减。刚开始做的时候我只实现了新数据源注册没做退役机制。结果测试环境创建删除租户十几次后内存里堆了几十个 HikariDataSource每个池子都持有若干连接连接数和内存双双暴涨。连接池创建容易销毁难。销毁一个 HikariDataSource 时调用close()就能释放连接但要注意时机。我踩过的具体问题是拿到新的数据源替换旧数据源后立刻关闭旧连接池但此时可能有正在执行的 SQL 还在用旧池子的连接直接 close 会导致运行中的业务报连接中断。正确做法是给旧连接池一个宽限期。替换数据源的时候把旧连接池放缓存延迟 30 秒再关闭让正在执行的请求有足够时间完成。生产环境我是用了一个后台线程扫描延迟关闭队列到点再 close实测零中断。还有一个相关问题是连接池泄漏。如果系统里大量创建短生命周期的数据源又不关闭最终会触发Too many connections。这个问题的排查思路是监控数据源数量正常量级应该在几十个以内如果持续上涨且不下降基本可以断定有连接池泄漏。4.4 数据源配置更新时的一致性最后一个坑和数据源热更新有关。我的定时刷新任务虽然简单但存在并发风险如果同一个租户的配置在刷新过程中被修改了两次最后一次加载时版本号变了可能导致旧配置先加载、新配置后加载覆盖顺序颠倒。我的处理方式是在刷新任务里加版本校验。查出变更记录后先比较 version 和当前已加载的版本只有新版本号大于当前版本才执行替换。如果数据库记录比当前加载的还旧直接跳过。这样即使轮询频率高也不会用旧配置覆盖新配置。另外一个一致性问题是多个应用实例同时刷新。每个实例都去查变更表同一个租户会被重复替换数据库连接池。我这里用了数据库乐观锁更新任务执行前先尝试更新状态字段UPDATE ... WHERE version ?受影响行数为 0 说明已被别的实例处理跳过本次操作。5. 生产化建议预热、热更新与监控如果上面这些你都跑通了恭喜你已经到达了一个可用的版本。但要把它真正放到生产环境且稳定运行还需要做几件锦上添花且非常重要的事。5.1 连接池预热首请求不再超时动态创建数据源之后租户数据源是懒加载到池子里的。HikariDataSource初始化完成后不会立刻建立连接等第一个业务请求到来时才开始建连。在冷启动场景下建连可能需要几百毫秒如果租户库和应用的网络时延高第一个请求很可能超时。我做的预热方案是在数据源加载器里增加一个预热步骤。连接池创建完成后异步执行一个简单查询比如SELECT 1提前触发建连。这个操作放在数据源注册后立刻执行不阻塞加载过程。private void warmUp(DataSource ds) { CompletableFuture.runAsync(() - { try (Connection conn ds.getConnection(); PreparedStatement ps conn.prepareStatement(SELECT 1)) { ps.execute(); } catch (SQLException e) { log.warn(数据源预热失败, e); } }); }预热还有一个好处是能提前发现连接配置错误。密码错误、数据库不可达这些失败在预热阶段就会暴露而不是等到第一个实际业务请求才爆出来那影响面完全不同。我测试阶段遇到一次租户库 IP 变更导致批量预热报错第一时间就在日志里发现了没有引发线上故障。5.2 配置热更新的安全停机热更新虽然方便但每一次连接池替换都是一次小的服务中断窗口。如果替换逻辑本身写得不好高并发下可能产生连接获取失败。为了减少替换影响我会先创建新连接池再切换引用。也就是说先createDataSource(config)得到完全初始化好的新池子确认无误后再替换DynamicDataSource里的引用最后关闭旧池。如果创建失败就保留旧连接池不动让业务继续走旧连接这是最稳妥的回滚逻辑。public DataSource updateTenantDataSource(TenantDataSource config) { DataSource newDs DataSourceFactory.createDataSource(config); DataSource oldDs dynamicDataSource.putDataSource(config.getTenantId(), newDs); if (oldDs ! null oldDs ! newDs) { scheduledClosePool(config.getTenantId(), oldDs, 30000); } return newDs; } private void scheduledClosePool(String tenantId, DataSource oldDs, long delayMillis) { executorService.schedule(() - { try { oldDs.close(); } catch (Exception e) { log.warn(旧连接池关闭失败 tenant{}, tenantId, e); } }, delayMillis, TimeUnit.MILLISECONDS); }这里要注意scheduledClosePool的延时时间。太短会导致活跃请求中断太长会留下多余连接。我测试下来的经验值是 30 秒能覆盖绝大多数慢查询场景。对于特别重的报表类查询可以单独把租户放到延迟关闭白名单延长到 60 秒。5.3 监控指标让数据源状态可观测动态数据源最大的隐患是不可见。连接池是运行中动态创建的出了问题很难第一时间定位。我建议至少监控下面这些指标当前已注册的租户数据源总数正常应该是稳定值持续上涨说明泄漏。每个数据源的活跃连接数、空闲连接数、等待获取连接的线程数。HikariCP 的getMetricsTracker()可以拿到这些数据。数据源创建和销毁的次数用于辅助排查生命周期问题。租户配置变更失败的次数反映热更新逻辑是否稳定。实现上我用的是 Prometheus 暴露指标通过MeterBinder把 HikariDataSource 的指标注册进去。没有接监控体系的最低限度也得在日志里定期打印每个数据源的状态至少出了问题有迹可循。public class DataSourceMetrics { public Gauge register(String tenantId, DataSource ds) { if (ds instanceof HikariDataSource hikari) { return Gauge.builder(tenant.datasource.active.connections, hikari, dataSource - dataSource.getHikariPoolMXBean().getActiveConnections()) .tag(tenant, tenantId) .register(registry); } return null; } }这里我再分享一个经验不要把数据源状态只打印到日志。日志只在出事时有用平时翻看成本高。用监控面板做曲线图能提前发现连接数上涨的趋势比事后翻日志高效得多。5.4 扩展空间从 MySQL 到更多数据库类型最后说一下扩展思路。我的db_type字段刚才提过是为了将来支持更多数据库类型。目前代码里resolveDriver是一个简单的映射private static String resolveDriver(String dbType) { return switch (dbType) { case mysql - com.mysql.cj.jdbc.Driver; case postgresql - org.postgresql.Driver; case sqlserver - com.microsoft.sqlserver.jdbc.SQLServerDriver; default - throw new IllegalArgumentException(不支持的数据库类型: dbType); }; }如果你要支持更多类型只需要扩展这个映射并且在buildJdbcUrl里根据 dbType 拼接不同的 URL 格式。所有连接池创建逻辑保持统一代码改动面很小。还有一个常见的扩展点是数据源配置的读写权限。生产环境的租户数据源配置最好通过独立的管理接口维护不暴露在业务应用里。我建议把配置表放在一个单独的库中业务应用通过配置中心接口读取管理后台单独一套服务这样职责清晰也更容易做审计。纸上谈兵结束说点真实的感受整个改造做下来我最大的体会是多租户动态数据源这个方案难点不在于写出路由代码而在于理解连接和事务的生命周期。你只有把 Spring 的事务 AOP、连接池的连接获取时机、ThreadLocal 的线程归属这几件事彻底想明白才能真正把动态数据源用得又稳又高效。如果你正准备动手做类似功能我的建议是先小范围验证再推广。选两个非核心租户手动插入数据源配置跑通读写和事务场景确认无异常后再全面接入。另外从第一天就接好监控动态数据源的坑往往藏在连接池数量的曲线里早看出来早调整。最后分享一个小技巧我在本地开发环境写了一个简单的管理页面表格展示所有租户数据源的状态支持手动新增、禁用、编辑配置。这个页面看起来土但调试问题效率极高比对着数据库改记录直观多了。你可以参考这个思路给自己做一个顺手的小工具省下的精力远超做它的成本。