☰
Spring Boot多数据源实战:连接归属、装配与动态路由
2026/10/11 2:23:03 网站建设 项目流程

简介:这份基于 Spring Boot 与 JdbcTemplate 的多数据源示例工程,面向需要同时整合多个数据库的中大型 Java 服务端开发者,核心解决了应用中多个数据源如何拆分配置、如何创建独立 JdbcTemplate 实例、以及如何在服务层通过 @Qualifier 按需注入并使用对应数据源的问题,适用于多库读写分离、业务分库或与第三方系统数据交互等实战场景,适合具备一定 Spring Boot 基础、希望掌握多数据源管理思路的中高级开发人员。压缩包共 116 个文件,以 XML、Java、properties 配置为主,涵盖 16 个 Java 源文件、15 个 class 文件,另含 Spring Boot 配置、Maven 相关文件与说明文档,整体仅 66KB,轻量便于快速学习。示例以 primary 与 secondary 双数据源为主线,配置目录结构清晰,包含 DataSourceConfig 配置类、UserService 中分别注入两个 JdbcTemplate 的查询示例,并附有 springboot_jpa_moreDB 子模块,为同时使用 JdbcTemplate 与 Spring Data JPA 处理多数据源提供了可复用的代码模板。已有 304 人学习下载,适合作为搭建多库联调环境、梳理数据源管理思路或扩展为多租户系统的参考起点。

1. 先说结论:多数据源坑不在配置,在连接归属

某电商项目上线一个月后凌晨告警:主库连接池被打满,从库却在旁边空转。查到最后,问题不在 SQL,也不在连接池大小,而在 Spring Boot 下 JDBC 多数据源的切换逻辑——事务一开启,连接就被钉死在了主库上。所谓多数据源,就是在一个应用里同时维护多个 DataSource,按业务归属或读写权重把请求分到不同数据库。它适合三类场景:读写分离、库表拆分前的过渡、一个服务要对接多个独立业务库。这篇按我实际落地过的路径写:先讲清楚连接归属的机制,再给一套能直接抄的 yml 和 Java 配置,最后是四条带现场现象的排错记录。

2. 动手前先立理论:三样东西绑定了谁,才决定数据源选型

2.1 DataSource、连接池、SqlSessionFactory:谁在依赖谁

多数据源之所以让不少人翻车,是因为没分清三样组件的关系:DataSource、连接池、SqlSessionFactory(或 JdbcTemplate)。

DataSource 不是连接本身,而是生产连接的工厂。它内部维护一个连接池,调用DataSource.getConnection()时从池里取一个连接给你,用完再还回去。JdbcTemplate 做的事情很直接:拿一个 DataSource,每次执行 SQL 时getConnection(),执行完close()把连接还给池子。MyBatis 的 SqlSessionFactory 也依赖 DataSource,它负责创建 SqlSession,SqlSession 再向 DataSource 要连接。事务管理器 DataSourceTransactionManager 同样依赖 DataSource,而且它在开启事务的那一刻就会从这个 DataSource 拿走一个连接,绑定到当前线程的事务资源上。

这四者(DataSource、JdbcTemplate、SqlSessionFactory、事务管理器)各自依赖 DataSource 的方式不同,决定了拆多数据源的复杂度。Spring Boot 的自动配置默认只认一个 DataSource,然后把所有组件都绑到它身上。容器里一旦出现两个 DataSource,自动装配就不知道给谁了,要么报错,要么全靠你手动指路。

所以多数据源拆装配的本质不是“配置两个 yml 段”,而是让每个组件都拿到自己该拿的那个 DataSource,并且让它们在合适的时机切换。这个认知后面所有排错都用得上。

2.2 方案选型:多套配置、动态路由、第三方封装怎么挑

实现多数据源,主流有三条路,选型错了后面就是无穷无尽的返工。

方案核心做法适合场景主要缺点
多套组件装配每个库一套 DataSource + JdbcTemplate / SqlSessionFactory + 事务管理器库职责完全独立(订单库、报表库)代码要显式区分模板,Mapper 要分包
AbstractRoutingDataSource 动态路由用 ThreadLocal 记录当前数据源 key,运行时决定从哪个库取连接读写分离、按租户分库事务开启后切换受限
第三方动态数据源封装用注解声明数据源,框架内部完成了路由和切面团队不想碰底层,工期紧张出问题要翻框架源码,黑匣子比较深

我的选型逻辑很简单:如果两个库的业务职责完全独立,比如一个订单库、一个报表库,就用方案一,两个 JdbcTemplate 分开注入,代码意图最清楚,不掺杂路由逻辑。如果是同一个业务库的主从副本,也就是读写分离,用方案二,因为主从库结构一模一样,只是分担读和写。方案三适合团队对原理还不熟但又要快速上生产的情况,不是不能用,只是出了问题调试成本高,建议预留一条能退回方案一的路。

另外要提醒一句:不要一上来就上第三方封装。先自己用方案一或方案二跑通一次,哪怕只是 demo,你对“连接归属”的体感会比读十遍文档都强。

2.3 边界提醒:跨库事务和分库分表,不是多数据源该管的事

多数据源解决的是“连接去哪取”的问题,它不解决“跨库一致性”。比如一次写操作要同时更新订单库和报表库,两个 DataSource 都配好了,但事务管理器只能绑定其中一个,另一个库的写入是独立提交的,一旦中途失败,数据就对不上了。这种场景需要分布式事务方案,不是多数据源这个层面的东西。

分库分表同理。按用户维度拆表、按订单号取模路由、全局主键生成、扩容迁移,这些应该交给独立的分库分表中间件,而不是在 Service 层手动拼表名。多数据源只是让你连到不同的库,SQL 层面的路由和改写不是它的职责。

还有一个容易被忽略的点:两个库共用一个连接池配置会造成混淆。HikariCP 的maximum-pool-size等参数不会因为你配了两个数据源就自动翻倍,每个 DataSource 各自持有独立的池。如果你在主库连接串下配置了很大的池,从库没配,从库会走默认值,压力一来先挂的就是它。

3. 用 Spring Boot + JDBC 把双数据源跑通:从 yml 到两套 JdbcTemplate

3.1 先写 yml:两个 DataSource 的独立配置与 HikariCP 参数

先给一份最小可跑的配置。这里用的是jdbc-url而不是url,这个细节很多人踩过坑,原因是spring.datasource.url是 Spring Boot 自动配置 DataSourceProperties 认识的属性,而jdbc-url是 DataSourceBuilder 创建数据源时用的属性。在单数据源时两者都能用,多数据源时混着写容易把主库连接串解析成从库的,所以统一用jdbc-url最稳。

spring: datasource: primary: jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db username: order_rw password: change_me driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 secondary: jdbc-url: jdbc:mysql://192.168.1.11:3306/report_db username: report_ro password: change_me driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000

配置字段说明:

  • primary和secondary这两个前缀是自定义的,后面 Java 配置里用@ConfigurationProperties去对应。
  • maximum-pool-size是池内最大连接数,主库给了 20,从库给了 10,从库连接数少是因为报表查询并发低,但单条慢查询占用连接时间更长,后面避坑章节会细说。
  • connection-timeout是客户端等待连接的最长时间,超过 30 秒直接抛异常,避免接口无限等下去。
  • idle-timeout是空闲连接回收时间,10 分钟是 HikariCP 的推荐下限。

3.2 装配主数据源:@Primary 缺失会有什么后果

Java 侧第一步是把两个 DataSource 定义成 Bean。主数据源必须加@Primary,否则 Spring 容器里有两个 DataSource 类型的 Bean 时,自动配置无法判断该用哪个,启动会直接报expected single matching bean but found 2。

@Configuration public class PrimaryDataSourceConfig { @Bean(name = "primaryDataSource") @Primary @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Bean public JdbcTemplate primaryJdbcTemplate(@Qualifier("primaryDataSource") DataSource dataSource) { return new JdbcTemplate(dataSource); } }

这里两个细节容易出问题。第一,@ConfigurationProperties(prefix = "spring.datasource.primary")会把 yml 里以该前缀开头的配置项自动绑定到生成的 DataSource 上,前缀写错会导致连接池参数全部不生效。第二,注入 JdbcTemplate 时一定要用@Qualifier("primaryDataSource")指明数据源,不然 Spring 会尝试按类型注入,遇到两个 DataSource 又分不清谁是谁。

@Primary的另一个作用是兜底。就算某个组件忘记写@Qualifier,它也会优先注入主数据源,不会因为歧义直接启动失败。所以习惯上把“业务最核心、被依赖最多”的库作为主库最稳妥。

3.3 第二数据源与 JdbcTemplate 绑定:queryTimeout 记得设

第二数据源不设@Primary,用它自己的名字注册即可。JdbcTemplate 也要单独建一个实例,不能复用主库的。

@Configuration public class SecondaryDataSourceConfig { @Bean(name = "secondaryDataSource") @ConfigurationProperties(prefix = "spring.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } @Bean public JdbcTemplate secondaryJdbcTemplate(@Qualifier("secondaryDataSource") DataSource dataSource) { JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); jdbcTemplate.setQueryTimeout(3); return jdbcTemplate; } }

setQueryTimeout(3)是给从库加的硬保护。从库通常是报表库或分析库,很容易混进没走过索引的慢查询。如果不在模板层设超时,一条跑 30 秒的 SQL 会把从库连接池占满,后续所有读请求全部排队。设成 3 秒,超时直接抛异常,慢查询会被及时掐断。

但要注意,这个设置只对当前 JdbcTemplate 实例生效。如果有人绕开模板直接注入 DataSource 写原生 JDBC 代码,还是拦不住。所以在团队里最好约定:访问从库一律走secondaryJdbcTemplate。

到这一步,你已经能在一个 Service 里分别注入primaryJdbcTemplate和secondaryJdbcTemplate,手动完成两个库的读写。这种“手动模式”看着笨,但它是最容易理解、也最容易排错的多数据源形态。

3.4 切换到 MyBatis 场景:两个 SqlSessionFactory 的装配方式

项目用了 MyBatis 的话,前面的 JdbcTemplate 装配要换成 SqlSessionFactory。核心差异是:MyBatis 的 Mapper 接口、XML 映射文件、SqlSessionFactory 三者必须绑定同一个数据源,任何一个环节串了,运行时就报一堆“找不到 SQL”的怪错。

@Configuration @MapperScan(basePackages = "com.example.mapper.primary", sqlSessionFactoryRef = "primarySqlSessionFactory") public class MybatisPrimaryConfig { @Bean(name = "primarySqlSessionFactory") public SqlSessionFactory primarySqlSessionFactory( @Qualifier("primaryDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); factoryBean.setMapperLocations( resolver.getResources("classpath:mapper/primary/*.xml")); return factoryBean.getObject(); } @Bean(name = "primaryTransactionManager") public DataSourceTransactionManager primaryTransactionManager( @Qualifier("primaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

关键点:

  • @MapperScan的sqlSessionFactoryRef必须显式指定,否则默认找sqlSessionFactory这个名字的 Bean,而按类型查找时会撞上两个 SqlSessionFactory 实例。
  • mapperLocations按目录分开放,主库 XML 放mapper/primary/,从库 XML 放mapper/secondary/,路径写错启动不报错,调用时才会报 Invalid bound statement。
  • 事务管理器也要拆成两个,并且名字对上。@Transactional注解不指定transactionManager时,Spring 会用默认的事务管理器,默认的那个只绑了主数据源,意味着你的从库操作即使开事务也不会被管理。

副库的配置和主库结构一致,只是把前缀、Bean 名、MapperScan 的包路径换成 secondary 对应的一套。这块没有捷径,四个东西(DataSource、SqlSessionFactory、MapperScan、TransactionManager)必须一一对应。

写完以后怎么确认真的切通了?写一个排查用的探针接口最快:

@RestController public class DataSourceProbeController { private final JdbcTemplate primaryJdbcTemplate; private final JdbcTemplate secondaryJdbcTemplate; public DataSourceProbeController( @Qualifier("primaryJdbcTemplate") JdbcTemplate primaryJdbcTemplate, @Qualifier("secondaryJdbcTemplate") JdbcTemplate secondaryJdbcTemplate) { this.primaryJdbcTemplate = primaryJdbcTemplate; this.secondaryJdbcTemplate = secondaryJdbcTemplate; } @GetMapping("/probe/datasource") public Map<String, Object> probe() { Map<String, Object> result = new LinkedHashMap<>(); result.put("primaryDb", primaryJdbcTemplate.queryForObject("select database()", String.class)); result.put("secondaryDb", secondaryJdbcTemplate.queryForObject("select database()", String.class)); return result; } }

请求这个接口,返回里primaryDb和secondaryDb的库名不一致,说明两个数据源都拿到了自己的连接,装配没问题。

4. 避坑:切换不生效的四个典型现场与处理办法

4.1 坑一:事务一开,数据源切换就失灵

现象:在无事务方法里把数据源切到从库,查询正常;一旦 Service 方法加上@Transactional,切换立刻失效,SQL 全部打到主库,从库完全没流量。

原因:事务管理器在进入方法时就从当前数据源拿了一个连接,绑定到线程事务资源里。后续 JdbcTemplate 执行时,Spring 会优先从线程绑定的 Connection 里取,而不是重新走DataSource.getConnection()。数据源路由发生在取连接那一步,但事务已经提前把连接拿在手里了,所以路由代码就算执行了,也影响不到已经绑定的连接。

解决:读库操作不要加事务,查询本来就不需要事务;如果读和写在一个业务里必须保证一致性,就把这个业务按库拆到不同 Service 方法,或者引入真正的分布式事务方案。不要试图在事务内部用代码去“解绑”连接,那是在跟 Spring 的事务同步机制较劲,大概率会把连接泄漏到线程池里。

4.2 坑二:没标 @Primary,启动直接让你“二选一”

现象:启动日志报expected single matching bean but found 2: primaryDataSource, secondaryDataSource,或者提示Consider marking one of the beans as @Primary。

原因:容器里有两个 DataSource 类型的 Bean,Spring Boot 的自动配置按类型注入时无法决定选谁。单数据源时根本不会走到这个判断,所以很多人第一次拆多数据源都会撞上。

解决:在主数据源的@Bean方法上添加@Primary,同时注意给注入处写@Qualifier。这里有个小习惯:@Primary只管类型注入时的优先选择,如果代码里明确写了@Qualifier("secondaryDataSource"),Spring 不会因为@Primary就忽略你的指定。所以最稳的做法是两者都写上,靠@Primary兜底,靠@Qualifier指路。

4.3 坑三:连接池参数照抄,接口慢得像玄学

现象:接口偶尔从 30ms 变成 3s,日志里出现Connection is not available, request timed out after 30000ms,而且挂的总是从库接口。

原因:连接池参数配置不合理。从库maximum-pool-size只配了 2,一条没有走索引的报表查询把这两个连接全占住,后面所有读请求都排队等待。排队时间一长,就出现连接获取超时。这跟应用线程数没关系,是数据库连接资源被慢性阻塞了。

解决:按接口的实际并发和单条 SQL 耗时评估连接池大小,不要照抄。没有压测数据时,我一般先按“峰值 QPS × 单 SQL 平均耗时 / 可用连接数”粗算,再留 30% 余量。同时给从库 JdbcTemplate 设queryTimeout,把慢查询挡在最外层,别让它占着连接浪费别人时间。监控上重点关注 HikariCP 的 active 和 wait 指标,连接池快满时这两个指标会先有反应。

4.4 坑四:Mapper 只认主库,从库报 Invalid bound statement

现象:启动完全正常,一调用从库的 Mapper 就报Invalid bound statement (not found),或者直接报从库表不存在的 SQL 异常。

原因:@MapperScan把主库和从库的 Mapper 接口全交给了同一个 SqlSessionFactory,而这个工厂只配了主库数据源和主库的 XML 路径。从库接口没有对应的 SQL 绑定,调用自然失败。

解决:包路径按库拆开。主库接口放com.example.mapper.primary,从库接口放com.example.mapper.secondary,两个@MapperScan分别绑定各自的sqlSessionFactoryRef。XML 路径也对应拆到mapper/primary/和mapper/secondary/。凡是“启动没事、一调用就找不到 SQL”的报错,九成是 MapperScan 和 SqlSessionFactory 的绑定关系串了。

5. 进阶:用 AbstractRoutingDataSource + AOP 做动态切库和读写分离

5.1 动态路由的最小实现:ThreadLocal + determineCurrentLookupKey

手动切换数据源在业务代码里写@Qualifier,代码侵入性太强。常见做法是用 Spring 提供的AbstractRoutingDataSource,让路由在取连接时自动发生。

public class DynamicDataSource extends AbstractRoutingDataSource { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setKey(String key) { CONTEXT.set(key); } public static void clearKey() { CONTEXT.remove(); } @Override protected Object determineCurrentLookupKey() { return CONTEXT.get(); } }

AbstractRoutingDataSource内部维护一个targetDataSources映射,determineCurrentLookupKey()返回 key,框架据此选中目标数据源。ThreadLocal 保证每个线程的 key 互不干扰,这是动态路由最常见的实现方式。

装配时把主库和从库都塞进路由源里:

@Bean public DataSource routingDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("primary", primaryDataSource()); targetDataSources.put("secondary", secondaryDataSource()); DynamicDataSource routingDataSource = new DynamicDataSource(); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(primaryDataSource()); return routingDataSource; }

setDefaultTargetDataSource很重要,路由 key 为空或找不到对应数据源时,会走默认源。把它设为主库,等于给没有特殊标注的代码提供了安全底座。

5.2 AOP 托管切换与读写分离的收尾习惯

路由源有了,还得解决“谁来决定 key”的问题。在业务代码里到处写DynamicDataSource.setKey()会沦陷,我一般用注解加 AOP 统一托管。

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default "primary"; }
@Aspect @Component public class DataSourceAspect { @Around("@annotation(ds)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DynamicDataSource.setKey(ds.value()); try { return joinPoint.proceed(); } finally { DynamicDataSource.clearKey(); } } }

切面在方法执行前设置 key,执行后在 finally 里清掉。用 finally 而不是方法末尾,是因为异常发生时也必须清除 ThreadLocal,否则线程池复用线程时,上一个请求的数据源 key 会被下一个请求读到,这种偶发性错误很难排查。

还要强调一件事:动态路由方案里,事务边界依然优先于切库逻辑。方法加了@Transactional,事务管理器在 AOP 切库之前就拿走了连接,路由就失效了。所以动态路由一般配合“读方法不开启事务”的约定使用,这也是读写分离应用里最常见的事务边界划分方式。

我现在的习惯是:凡是新项目,只要预判读请求量会明显超过写请求,就先把动态数据源这一层搭好。数据源是基础设施,基础设施晚一天加,业务代码就多一天返工。等主从延迟、连接池水位这些问题暴露了再回头补,要动的就不是一个配置类,而是一堆 Service 方法了。希望这篇文章帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询