恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot JDBC多数据源动态切换实战:配置、路由与避坑指南
首页
资讯中心
/
Spring Boot JDBC多数据源动态切换实战:配置、路由与避坑指南
Spring Boot JDBC多数据源动态切换实战:配置、路由与避坑指南
发布时间:2026/10/11 2:21:49
简介实际项目中常会遇到一个应用连接多个数据库的场景。这份“springboot-jdbc-多数据源”资源正是一套基于Spring Boot与JdbcTemplate的双数据源示例工程面向Java开发者和需要处理跨库读写的中级程序员目的是清晰展示多套数据源的定义、装配和按需调用方式。整个压缩包共116个文件大小仅66KB文件以XML工程配置、Java源码、Class编译文件和Properties属性配置为主配合jar、说明文档及Maven相关文件导入开发环境后即可对照学习。目前已有304人浏览学习。工程整体目录结构清晰从配置类中创建主备数据源、注册各自的JdbcTemplate实例到Service层按业务场景选择不同的模板访问对应数据库形成一条完整可运行的主线同时还给出多数据源环境下与Spring Data JPA结合使用的参考思路对搭建微服务或传统单体多库应用很有帮助。1. Spring Boot JDBC 多数据源从配置漂移到动态切换的实战思路很多团队在做中台化或读写分离时都会遇到同一个尴尬项目里已经用上了 MyBatis、JPA 这类 ORM但某个老系统迁移过来的模块偏偏只吃 Spring JDBC 这一套。更麻烦的是报表库、订单库、用户库分在两个甚至三个物理库上原来单体的DataSource根本不够用。我在给某电商项目做订单中心拆分时就撞上过这堵墙一个 Spring Boot 服务要同时读写订单库和报表库两个库的事务还得保持独立不能互相干扰。Spring Boot 的spring-boot-starter-jdbc默认只给你配一个DataSource多数据源的核心思路就变成了两件事第一让 Spring 容器里同时存在多个独立的数据源实例第二在运行时根据业务场景动态选择用哪一个。Spring 自带的AbstractRoutingDataSource正是为这种“路由”场景设计的抽象类配合ThreadLocal保存当前线程的上下文就能在每次数据库操作前动态决定走哪个库。这篇文章不绕弯子直接拆解如何用 Spring Boot JDBC 方案把多数据源落到实处包括配置、切换、事务边界和那些会让人抓狂的坑。这个方案最大的价值在于不绑架你的技术栈——它不依赖 MyBatis 插件也不要求你把代码重写成 ShardingSphere纯粹用 JDBC 原生的路子就能解决问题。适合的场景很清晰中小规模项目的读写分离、报表系统与业务系统隔离、多租户分库。如果你的团队正在纠结要不要为了多数据源引入重框架这篇能帮你省下不少调研时间。2. 多数据源的选型逻辑框架千千万为什么我选 AbstractRoutingDataSource2.1 三类主流方案的取舍对比做多数据源的路上摆在面前的路其实有三条。第一条是用DS注解典型代表是dynamic-datasource-spring-boot-starter这类开源组件优点是省心注解一加切面自动处理缺点是侵入性很强——业务代码里全是DS(order)这种注解换数据源时要改源码而且这种组件在特定版本下和 Spring Boot 的自动装配有兼容问题排查起来很玄学。第二条路是直接用 MyBatis 的多插件方案类似 MyBatis 的 interceptor 做数据源路由这个方案的好处是能和 Mapper 绑定但坏处也很明显——一旦项目里混用 JdbcTemplate 和 JPA路由就失效了因为拦截器只盯着 MyBatis 的Executor接口。第三条路就是我最终采用的AbstractRoutingDataSourceThreadLocal方案。AbstractRoutingDataSource是 Spring 框架自带的抽象类代码在spring-jdbc包里不引入任何第三方依赖。它的工作原理其实就是一个带路由逻辑的DataSource代理Spring 容器里只暴露这一个代理数据源真正的物理数据源全部藏在targetDataSources这个 Map 里。每次调用getConnection()时它会先调用抽象方法determineCurrentLookupKey()把这个方法的返回值作为 key 去 Map 里找对应的物理数据源找到就返回连接找不到就走defaultTargetDataSource。我之所以更偏爱这个方案核心原因有四个一是零侵入业务代码里不需要写任何注解数据源切换逻辑全部收敛在切面或手动调用的代理里二是物理数据源的生命周期由 Spring 管理天然支持连接池的初始化和销毁三是不挑数据访问层JdbcTemplate、MyBatis、JPA 都能统一走这同一个路由入口四是调试透明你随时可以打日志看当前线程的数据源 key出问题能直接定位。代价是需要自己写一点切面代码和上下文字段但这是可控的一个文件就能搞定。2.2 动态数据源与静态多数据源的区别为什么订单中心拆分必须选动态静态多数据源的典型做法是在配置类里声明两个独立的DataSourceBean分别叫orderDataSource和reportDataSource然后在 DAO 层手动注入不同的JdbcTemplate。这个做法在只有两个数据源、切换逻辑固定时够用但一旦遇到按用户维度分库、按商户 ID 路由的场景代码就会变得非常丑陋。你需要写一堆if (userId % 2 0) jdbcTemplateA; else jdbcTemplateB;或者把JdbcTemplate塞到 Map 里手动取。动态数据源解决的正是这种运行时不确定的问题。我在做订单中心拆分时实际场景是按城市分库华东用户走华东库华南用户走华南库。这个路由规则是运行时才能定下来的静态配置没法预判。用AbstractRoutingDataSource之后determineCurrentLookupKey()直接从ThreadLocal里取当前请求上下文中的城市编码然后切成对应的库代码只维护一套JdbcTemplate逻辑全部一致。2.3 一个最小可运行的 DynamicDataSource 骨架在写完整配置之前先用一个最小骨架理解核心机制。关键的类就两个一个继承AbstractRoutingDataSource的子类一个负责保存和清理上下文的ThreadLocal。public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 核心路由逻辑从线程上下文中取出数据源标识 return DynamicDataSourceContextHolder.getDataSourceKey(); } } public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void setDataSourceKey(String key) { CONTEXT_HOLDER.set(key); } public static String getDataSourceKey() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }逻辑说明determineCurrentLookupKey()是路由的触发点Spring 每次在getConnection()时都会调它把返回的 key 拿去匹配目标数据源。这里用ThreadLocal存 key 是为了保证线程隔离——Web 请求线程处理期间设置一次后续同线程内所有 JDBC 操作都会命中同一个数据源不会串库。参数上要注意DynamicDataSourceContextHolder.clear()一定要在请求结束或切面后置逻辑里调用否则线程池复用线程时上次请求的数据源 key 会残留数据直接写错库。这一点是后续所有诡异问题的根源后面避坑章节会仔细说。3. 从零搭建 Spring Boot JDBC 多数据源配置、注册与最小切换流程3.1 基础依赖与 yml 配置主从两个库的落地姿势先说依赖这个方案不需要任何额外数据源组件。Maven 项目里只要引入spring-boot-starter-jdbc和对应的数据库驱动即可。如果你的项目里其他模块用了 ORM再用spring-boot-starter-jdbc没有任何冲突因为 ORM 底层本来也是走DataSource的。常见的做法是把连接信息写到application.yml里注意用自定义前缀避开 Spring Boot 默认的spring.datasource.*。这个前缀一旦和 Spring Boot 自动装配识别的前缀重合自动装配就会把多套配置打乱行为不可预测。我用的前缀是datasource.order和datasource.report。datasource: order: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.10.21:3306/order_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: order_user password: order_pass max-pool-size: 20 min-idle: 5 report: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.10.22:3306/report_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: report_user password: report_pass max-pool-size: 10 min-idle: 2逻辑说明jdbc-url和 Spring Boot 默认的url属性名区分开避免它的DataSourceProperties自动绑定误伤。max-pool-size和min-idle一般按照读写压力来分配主库写多连接数给大报表库查询量大但频率低连接数给小。这套配置下Spring Boot 的DataSourceAutoConfiguration不会捡到任何spring.datasource.*配置就不会自动创建单数据源物理数据源由我们自己显式创建。3.2 核心配置类注册多个物理数据源并把代理源设为唯一入口有了 yml 配置下一步就是写配置类把DataSource实例化并注册到 Spring 容器。这一步必须用ConfigurationProperties绑定前缀配合Bean创建出两个独立的HikariDataSource。Configuration public class DataSourceConfig { Bean(orderDataSource) ConfigurationProperties(prefix datasource.order) public DataSource orderDataSource() { return DataSourceBuilder.create().build(); } Bean(reportDataSource) ConfigurationProperties(prefix datasource.report) public DataSource reportDataSource() { return DataSourceBuilder.create().build(); } Bean(dynamicDataSource) Primary public DynamicDataSource dynamicDataSource( Qualifier(orderDataSource) DataSource orderDataSource, Qualifier(reportDataSource) DataSource reportDataSource) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(order, orderDataSource); targetDataSources.put(report, reportDataSource); DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(orderDataSource); return dynamicDataSource; } }逻辑说明Primary很关键Spring 容器中有多DataSourceBean如果不标记主数据源JdbcTemplate 自动注入时会因为类型不唯一而报NoUniqueBeanDefinitionException。把dynamicDataSource标记为Primary注入JdbcTemplate时默认拿到就是这个路由代理业务代码里只存在一个JdbcTemplate实例。这里把默认数据源设为order库保证在没有任何切换动作发生时所有操作落到主库。3.3 用 JdbcTemplate 跑通第一次双库查询配置注册完成后写一个最直接的测试验证机制是否跑通。这里用两个JdbcTemplate调用中间手动切换数据源。RestController public class DemoController { private final JdbcTemplate jdbcTemplate; public DemoController(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } GetMapping(/demo) public String demo() { // 第一次操作查订单库 DynamicDataSourceContextHolder.setDataSourceKey(order); Integer orderCount jdbcTemplate.queryForObject( select count(*) from t_order, Integer.class); // 切换到报表库查报表数据 DynamicDataSourceContextHolder.setDataSourceKey(report); Integer reportCount jdbcTemplate.queryForObject( select count(*) from t_report_summary, Integer.class); // 用完后清理防止线程残留 DynamicDataSourceContextHolder.clear(); return order orderCount ,report reportCount; } }逻辑说明这段代码展示了最原始的切换过程——先设置 key再执行 JDBC 操作最后清理上下文。queryForObject调用时JdbcTemplate内部从容器拿DataSource就是dynamicDataSource代理代理的getConnection()根据ThreadLocal中的 key 路由到物理库。参数上重点看setDataSourceKey的 key 值必须和配置类里targetDataSources的 key 完全一致否则路由不到目标库只能掉到默认数据源这个 Bug 极难察觉。这种手动切换只适合做验证或简单场景。真实项目中不可能在业务代码里到处写设置和清理所以下一步要给切换加上切面用自定义注解驱动自动切换。4. 动态切换落地自定义注解 AOP 切面的生产级封装4.1 自定义注解 DataSource 的设计与限定范围生产环境里数据源切换必须对业务动静最小化。我一般会做一个DataSource注解直接标注在 Service 层方法上由 AOP 在方法执行前设置上下文、方法结束后清理上下文。注解里只需要一个属性来指定目标数据源 key。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DataSource { String value() default order; }为什么加ElementType.TYPE因为有时候一个 Service 类的所有方法都走同一个数据源我可以在类级别标注一次方法级别再覆盖。这样既能减少重复代码又给特殊方法留了覆盖口。注解的策略遵守就近原则方法上的注解优先于类上的注解。这个设计在报表类 Service 里很省事——类头上标DataSource(report)个别写主库的方法再加方法注解盖掉。4.2 切面实现切点表达式与服务类的绑定细节AOP 切面的核心逻辑是拦截被注解标记的方法在方法调用前后维护ThreadLocal的上下文。切点表达式直接指向注解这样不限于任何包路径只要方法带了注解就被拦截。Aspect Component public class DataSourceAspect { Before(annotation(dataSource)) public void switchDataSource(JoinPoint joinPoint, DataSource dataSource) { DynamicDataSourceContextHolder.setDataSourceKey(dataSource.value()); } After(annotation(dataSource)) public void restoreDataSource(JoinPoint joinPoint, DataSource dataSource) { DynamicDataSourceContextHolder.clear(); } }逻辑说明Before保证方法实际执行前已经把 key 放到ThreadLocal里After用 finally 语义保证方法结束后清掉线程上下文无论方法抛不抛异常都能执行清理。这里用的是annotation(dataSource)切点Spring AOP 会把代理方法的注解实例直接绑定到切面方法的参数上不用再去反射扫描注解。要特别注意的是切面类自身必须被 Spring 管理Component不能漏否则切面不生效数据源切换会静默失败。4.3 配置切面顺序多数据源切换与事务切面的先后博弈如果方法上同时标了DataSource和Transactional切面的顺序就直接影响事务是否走对库。这是一个特别容易翻车的点。Spring 的事务管理器开启事务时必须从DataSource拿到连接而DataSourceTransactionManager默认在事务开始时调doBegin()此时会触发getConnection()相当于事务在第一时间就定了数据源。如果事务切面执行得比数据源切面早事务已经拿到默认库的连接后面你再怎么切换当前事务还是挂在默认库上。解决办法是让数据源切面优先于事务切面执行。用Order(Ordered.HIGHEST_PRECEDENCE)标注数据源切面保证Before回调先执行。Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { // 切面逻辑不变 }逻辑说明Order数值越小优先级越高。把数据源切面排在最前面Spring 在创建代理时会先触发它设置ThreadLocal随后事务切面开启事务拿连接时路由 key 已经就位连接自然从正确的物理库获取。如果项目里事务是从外部框架管理的比如基于TransactionTemplate编程式事务同样要在进入事务前先切换数据源保证连接来源正确。在实际项目中事务里的跨库操作始终是个高危动作——同一个Transactional方法内操作两个库本质上无法用本地事务保证原子性只能靠最终一致性方案兜底下面会详细展开。5. 避坑指南多数据源配置与切换的 5 个高频踩坑现场5.1 数据源配置前缀冲突Spring Boot 自动装配把你坑了现象自定义数据源的连接参数没有生效启动后项目能正常运行但连接的是localhost:3306上的默认库业务数据全部写丢。原因application.yml里用了spring.datasource.url这种标准前缀写法Spring Boot 的DataSourceAutoConfiguration在 classpath 检测到 JDBC 驱动时自动把标准前缀下的配置创建成了单数据源而你自定义的orderDataSource、reportDataSource还没进入到路由表。容器里同时存在两类数据源注入时又因为类型歧义被Primary标记强行指定导致路由代理名存实亡。解决数据源前缀一律换成自定义前缀如datasource.order。配置类上ConfigurationProperties的路径必须和 yml 保持一致。另外一个排查技巧启动时打印DataSource的全限定类名如果是HikariDataSource而非你的DynamicDataSource十有八九是自动装配抢先接管了配置。5.2 ThreadLocal 缓存残留导致的数据写串库现象一个 Tomcat 线程处理完 A 请求切换到报表库之后Tomcat 线程池复用同一个线程处理 B 请求B 请求没有设置数据源 keySQL 却全部落到了报表库业务数据被拆得七零八落。原因ThreadLocal没有在请求结束时清理。线程池里的线程是复用的上次请求设置的 key 还留在ThreadLocal里判断逻辑里也没有兜底覆盖路由就沿用了旧值。解决在After切面里必须调用DynamicDataSourceContextHolder.clear()这个方法内部执行remove()不是简单的set(null)。同时建议在路由方法里做一层兜底getDataSourceKey()返回 null 时直接走默认库。还可以再上一步过滤器在finally块强制清理保证逃过切面的异常路径也不会残留。5.3 事务边界大坑Transactional 方法内部切换失效现象一个 Service 方法标注了DataSource(report)也标注了Transactional但方法内部先查报表库再写订单库结果两次操作都走了报表库订单数据写入直接报表不存在。原因事务切面先于数据源切面执行。Spring 的事务管理器在doBegin()方法里已经通过DataSourceUtils.getConnection()拿到连接后续整个事务的生命周期内所有 JDBC 操作都复用了这条固定连接。数据源切面Before虽然设置了ThreadLocal但事务连接早就拿完了切换根本来不及。解决如 4.3 小节所述给数据源切面加Order(Ordered.HIGHEST_PRECEDENCE)。同时要在设计上规避跨库事务——两个物理库的数据一致性不能用本地事务处理正确的做法是去掉Transactional改成单库事务 消息补偿或者用TransactionTemplate分别控制两段事务。有人会尝试把事务设置为REQUIRES_NEW来强制新开事务切库这种方案可以临时解围但连接数会翻倍而且如果两个库的数据需要同时成功或失败仍然无能为力。5.4 连接池耗尽上报数据源配置的 max-pool-size 不合理现象报表服务在月底跑批量统计时大量请求超时监控里数据源连接池被打满基础连接Connection is not available, request timed out after 30000ms。原因连接池参数配置不够。报表库的max-pool-size只有 10而 AOP 切面在方法级切换数据源时每次设置 key 后都是直接从对应物理连接池取连接。批量任务一次性要拿几百个连接10 连接池自然排队超时。解决按并发峰值重新估算物理连接数报表库从 10 调整到 50。同时注意另一个细节高并发下每次切换都新建DataSource连接对象是不现实的所有物理数据源必须是容器启动时就初始化的单例不能每次路由时才创建,否则内存管理直接失控。可以用Scope(singleton)保证数据源只初始化一次。5.5 裸 JdbcTemplate 混合使用导致的连接不释放现象程序运行一段时间后数据库端Too many connections但 Spring Boot 的连接池监控显示活跃连接很少两端数据对不上。原因某些方法绕过了DynamicDataSource代理直接用new JdbcTemplate(orderDataSource)创建了独立 JdbcTemplate 实例。这些手动创建的 JdbcTemplate 使用了不同的连接获取路径连接释放依赖原始DataSource的配置一旦中途没有显式释放连接就会在物理库上悬挂。解决全项目统一注入JdbcTemplate它背后的DataSource必须是dynamicDataSource。不要在 DAO 里再手动创建 JdbcTemplate也不要在业务代码里直接注入orderDataSource。这个问题一旦发生垃圾回收很难兜底因为数据库连接是外部资源JVM 的 GC 不感知最终只能靠重启缓解属于血泪级别的大坑。6. 一个不离谱的实践习惯用启动校验 数据源体检保证切换永不失手多数据源方案的可靠性不能只靠业务层自觉我养成了一个习惯应用启动时做一次强制体检把所有数据源的路由和连通性提前验证一遍。这个做法的好处特别直接——多数据源的很多故障是配置错误导致的连接串里的库名写错、账号权限缺失、字符集不匹配这些问题如果等到业务请求进来才爆排查成本极高。写一个ApplicationRunner启动校验器在 Spring Boot 启动完成后、服务正式接流量之前对所有物理数据源做一次直连验证。Component public class DataSourceValidator implements ApplicationRunner { private final DynamicDataSource dynamicDataSource; public DataSourceValidator(DynamicDataSource dynamicDataSource) { this.dynamicDataSource dynamicDataSource; } Override public void run(ApplicationArguments args) { ListString keys Arrays.asList(order, report); for (String key : keys) { DynamicDataSourceContextHolder.setDataSourceKey(key); try (Connection conn dynamicDataSource.getConnection()) { if (!conn.isValid(3)) { throw new IllegalStateException(数据源校验失败: key); } log.info(DataSource [{}] check passed, url: {}, key, conn.getMetaData().getURL()); } catch (SQLException e) { throw new IllegalStateException(校验数据源异常: key, e); } finally { DynamicDataSourceContextHolder.clear(); } } } }这里的参数和细节我每次都要对一遍conn.isValid(3)是 JDBC 4 的 API验证连接是否有效3 秒超时会让失败快速暴露用 try-with-resources 保证连接自动归还连接池不需要手写 finally 释放。另一个重要细节校验的 key 列表必须和配置类里targetDataSources的 key 完全一致否则校验就是自欺欺人。校验器加完后我还习惯在切换切面里打一条 debug 日志把每次切换的数据源 key 和当前线程名打出来方便线上比对 SQL 到底走了哪个库。日志不要用 info 级别否则高并发下一刷屏就把日志系统打爆。再分享一个排查技巧线上觉得数据没写对库时最快的定位方式是在目标库上开 general log或者在切面里临时把日志级别调成 debug观察determineCurrentLookupKey()返回的 key 是否符合预期。数据源选没选对在路由这一步就已经决定往下查 SQL 本身没有意义。坦白说这个方案最让我忌惮的从来不是代码能不能跑通而是工程习惯能不能守住。第一次在自己的项目里看到报表数据跑到订单库、订单数据跑到报表库时我整整排查了两天才意识到原来是ThreadLocal没清理。自那以后启动校验 日志观察成了每次落地的标配动作也希望这份笔记能帮你避掉那些我也踩过的坑。希望帮到你。本文还有配套的精品资源点击获取