简介:实际项目中常会遇到一个应用连接多个数据库的场景。这份“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. 多数据源的选型逻辑:框架千千万,为什么我选 AbstractRoutingDataSource
2.1 三类主流方案的取舍对比
做多数据源的路上,摆在面前的路其实有三条。第一条是用@DS注解,典型代表是dynamic-datasource-spring-boot-starter这类开源组件,优点是省心,注解一加,切面自动处理,缺点是侵入性很强——业务代码里全是@DS("order")这种注解,换数据源时要改源码;而且这种组件在特定版本下和 Spring Boot 的自动装配有兼容问题,排查起来很玄学。第二条路是直接用 MyBatis 的多插件方案,类似 MyBatis 的 interceptor 做数据源路由,这个方案的好处是能和 Mapper 绑定,但坏处也很明显——一旦项目里混用 JdbcTemplate 和 JPA,路由就失效了,因为拦截器只盯着 MyBatis 的Executor接口。第三条路就是我最终采用的AbstractRoutingDataSource+ThreadLocal方案。
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 ThreadLocal<String> 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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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) { Map<Object, 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 请求没有设置数据源 key,SQL 却全部落到了报表库,业务数据被拆得七零八落。
原因: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) { List<String> 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没清理。自那以后,启动校验 + 日志观察成了每次落地的标配动作,也希望这份笔记能帮你避掉那些我也踩过的坑。希望帮到你。
本文还有配套的精品资源,点击获取