首页 / 资讯中心 / 文章详情

Spring Boot多数据源实战:连接归属、装配与动态路由

Spring Boot多数据源实战:连接归属、装配与动态路由 ★ FEATURED ARTICLE
简介这份基于 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它负责创建 SqlSessionSqlSession 再向 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 到两套 JdbcTemplate3.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 MapString, Object probe() { MapString, 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 类型的 BeanSpring 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 ThreadLocalString 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() { MapObject, 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 方法了。希望这篇文章帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站