SpringBoot多数据源实战:动态路由、事务与连接池避坑指南
我做过不少带读写分离、多租户、分库分表需求的SpringBoot项目发现很多人在多数据源这块踩坑踩得莫名其妙。今天不聊那种“复制两个DataSource然后配两个SqlSessionFactory”的笨办法而是把真正在工程里落地、能扛住生产压力的多数据源方案拆开揉碎讲一遍从方案选型到动态切换原理再到事务、连接池这些容易翻车的细节全部用实际代码和经验说话。1. 多数据源方案选型与核心思路拆解1.1 为什么会有多数据源需求先说个最常见的场景公司早期一套系统打天下订单、用户、商品、日志全在同一个MySQL库里。业务跑到一定规模DBA就会来找你——主库写入压力太大把查询拆出去或者合规要求日志数据保留180天不能跟业务库混在一起又或者你接了个第三方系统对方只给你只读库的访问权限。这时候“多数据源”就不是锦上添花而是硬需求。SpringBoot默认只配置一个DataSource要让多个数据源共存、还能按需切换看起来简单做起来有一堆坑。常见的实现思路有三种第一种是分包方式每个数据源配一套独立的SqlSessionFactoryMapper接口按包名隔离。第二种是用第三方框架比如MyBatis-Plus的Dynamic-Datasource、ShardingSphere。第三种是自己基于Spring自带的AbstractRoutingDataSource做动态切换。我自己在中小型项目里最常用的是第三种原因很简单不引入额外的重量级依赖基于Spring原生机制出问题排查起来思路清晰而且能完全掌控切换逻辑。1.2 三种实现方案的优势与硬伤分包方式其实最不推荐在业务代码里大范围使用。虽然它配置上最直观——每个数据源一套配置类、一套Mapper包——但代价是业务代码里你要在Service层明确指定调哪个Mapper换数据源等于改调用代码侵入性太强。而且Mapperscan扫描多个包时SpringBoot自动配置经常跟你打架你得想尽办法把自动配置排除掉维护成本立刻上来了。MyBatis-Plus的Dynamic-Datasource这种框架级方案确实好用注解一加就能切库事务和切换顺序的问题它内部都处理了。但对不依赖MyBatis-Plus的项目强行引入它多少有点引入不必要的复杂度。而且框架封装度高遇到框架本身没覆盖的边界情况你得去翻它的源码才能知道问题出在哪。AbstractRoutingDataSource这个方案的核心价值在于它把数据源路由逻辑抽象成了一个“中转器”。我们往Spring容器里注册一个自定义的DataSource它内部维护一个数据源Map每次调用getConnection()时根据当前线程的上下文信息决定到底用哪个真实数据源。这个思路清晰切换容器的核心机制注意细节完全够用。1.3 核心设计思路一个中转路由一个上下文我先画个思路草图给你看。整体结构其实就三块路由数据源继承AbstractRoutingDataSource重写determineCurrentLookupKey()方法返回当前线程保存的数据源标识数据源上下文用一个ThreadLocal保存当前线程应该用哪个数据源切换切面通过AOP或者手动调用在进入Service方法前把数据源标识写进ThreadLocal方法结束后清理这个设计的好处从底层就决定了它的可靠性连接从哪个数据源来、SQL发到哪个库去都是在你调用getConnection()的时候才定下来的。也就是说只要在业务方法执行期间ThreadLocal里的值是对的连接就一定是连到对的库。2. 核心细节解析与实操要点2.1 环境准备与依赖版本适配在动手写代码之前我建议先把依赖版本框定清楚。不同SpringBoot版本在自动配置类上的行为有差异直接照抄网上教程容易翻车。这里给一套我实测过很多次的组合JDK 1.8或更高SpringBoot 2.x建议1.8SpringBoot 3.x需要17Maven 3.6MySQL 8.x5.7也兼容SpringBoot 2.3.x~2.7.x这个区间内代码几乎无差异pom.xml里必加的依赖只有两个基础的spring-boot-starter-web或其他业务starter和spring-boot-starter-jdbc。数据库驱动、连接池、ORM框架按你自己项目的实际情况来。这里插一句连接池不需要单独引入HikariCP依赖SpringBoot 2.x默认用的就是HikariCP直接用即可。2.2 多数据源基础配置在application.yml中构造两个基础数据源。多数据源的配置项和单数据源完全一致注意url、username、password分清楚就行spring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/business_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 30000 connection-timeout: 30000 pool-name: PrimaryHikariPool secondary: jdbc-url: jdbc:mysql://localhost:3306/log_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 3 maximum-pool-size: 10 idle-timeout: 30000 connection-timeout: 30000 pool-name: SecondaryHikariPool注意这里用了jdbc-url而不是url。在SpringBoot 2.x中如果你把HikariCP配成第二数据源必须用jdbc-url否则HikariCP会报“无法解析url”之类的异常。这个坑非常隐蔽我第一次踩的时候查了半小时才发现问题。另外提醒一点这两个配置节点建议放在自定义的前缀下比如app.datasource.primary和app.datasource.secondary不要直接写在spring.datasource.primary下面。因为spring.datasource.*默认会被SpringBoot的自动配置扫到容易引发“只需要一个数据源但莫名多出来一个”的问题。代码里用ConfigurationProperties指定前缀读取即可。2.3 数据源标识枚举与上下文实现这一步很关键定义数据源枚举和上下文。枚举的含义是让代码里的每一个数据源都有一个明确的代号避免到处写魔法字符串。正常的多数据源项目里就两个库直接写两个枚举项足够多了的话继续往下加public enum DataSourceType { PRIMARY, SECONDARY }上下文类的核心是ThreadLocal。为什么必须用ThreadLocal因为在一个请求到达后SpringMVC默认是一个线程处理的后续的Service层、Mapper层调用都是在同一个线程栈里。用ThreadLocal保存当前数据源标识在这个线程内部随处可读请求处理完再把值清理掉不会串到下一个请求去。public class DataSourceContextHolder { private static final ThreadLocalDataSourceType CONTEXT new ThreadLocal(); public static void setDataSource(DataSourceType type) { CONTEXT.set(type); } public static DataSourceType getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }这里我一开始用的new ThreadLocal()后来线上环境遇到一次诡异问题某些线程池复用的场景下线程执行完任务后ThreadLocal没有清干净导致下一个任务拿到上一个任务残留的数据源标识。排查半天才发现是清理逻辑漏了。所以remove()不只是代码规范问题是生产环境必须做的事。2.4 动态路由数据源的实现接着是路由数据源继承AbstractRoutingDataSource并重写determineCurrentLookupKey()。Spring在每次获取连接时都会调用这个方法把它返回的值当作key去内部的targetDataSources里查对应的真实数据源public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultDataSource, MapObject, Object targetDataSources) { super.setDefaultTargetDataSource(defaultDataSource); super.setTargetDataSources(targetDataSources); } Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }这里有一个隐藏的关键点setTargetDataSources()之后必须调用afterPropertiesSet()否则内部的数据源Map不会初始化运行时会报IllegalStateException。如果你用的是构造器注入再加Bean注册的传统方式Spring容器启动时会帮你调一次afterPropertiesSet()问题不大。如果你是手动new DynamicDataSource()然后自己塞数据源千万记得手动调一次。2.5 数据源配置类注册然后写配置类把上面这些组装起来Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix app.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix app.datasource.secondary) public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } Bean Primary public DynamicDataSource dynamicDataSource( Qualifier(primaryDataSource) DataSource primary, Qualifier(secondaryDataSource) DataSource secondary) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DataSourceType.PRIMARY, primary); targetDataSources.put(DataSourceType.SECONDARY, secondary); return new DynamicDataSource(primary, targetDataSources); } }这里必须注意Primary注解一定标在DynamicDataSource这个Bean上不能标在primaryDataSource上。因为Spring容器里现在有多个DataSource类型的BeanSpringBoot自动配置会挑一个作为主数据源注入到JdbcTemplate、MyBatis这些组件里去。如果不加Primary启动时会直接报NoUniqueBeanDefinitionException错标到物理数据源上虽然能启动但路由功能实际是失效的——所有请求都走固定那个库。2.6 动态切换的实现策略理论上讲到这一步已经可以在业务代码里手动切换了DataSourceContextHolder.setDataSource(DataSourceType.SECONDARY); // 执行查询 DataSourceContextHolder.clear();但手写太容易漏清理了。我更推荐用自定义注解加AOP切面把切换逻辑从业务代码里剥离出来调用方只需要加一个DS注解即可。首先定义一个注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { DataSourceType value() default DataSourceType.PRIMARY; }然后写切面Aspect Component public class DataSourceAspect { Around(annotation(ds) || within(ds)) public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DataSourceType original DataSourceContextHolder.getDataSource(); try { DataSourceContextHolder.setDataSource(ds.value()); return joinPoint.proceed(); } finally { DataSourceContextHolder.setDataSource(original); } } }这个切面比最简单的“设置完直接clear”要稳妥。在设计里如果方法内部又调了另一个带DS注解的方法内层数据源会覆盖外层设置等内层执行完恢复成外层的数据源标识不会造成上下文丢失。而直接clear会把外层的数据源标识也干掉后续代码如果有切换需求就会错乱。很多时候又需要恢复原值多留这个“保存原值”的逻辑没坏处。如果用的是SpringBoot 2.x切面依赖的spring-boot-starter-aop要单独引入。我见过不少项目忘了加这个依赖结果切面完全没生效注解加了跟没加一样数据源切换静默失败。这个问题后面排障部分还会细说。3. 实操过程与核心环节实现3.1 MyBatis或JdbcTemplate如何与动态数据源配合ORM框架配置这步很灵活。最简单的方式是完全不额外配置让SpringBoot自动配置去找容器里的DataSource。因为我们已经把DynamicDataSource标记为Primary了MyBatis和JdbcTemplate自动注入的就是这个中转数据源SQL执行时它会自动路由。在MyBatis场景下如果用的是mybatis-spring-boot-starter基础配置写好后直接启动连SqlSessionFactory都不需要手动声明。写代码的时候操作业务库的Mapper和操作日志库的Mapper可以放在同一个SQL文件里也可以分开目录不影响路由。JdbcTemplate同理注入后直接使用底层拿到的连接就是动态路由过的。有些项目里用了JdbcTemplate还想要事务那是后面第3.3节要特别讲的内容因为路由事务是最大的坑点。这里给一个配套的MyBatis配置参考mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entitymapper-locations不用区分数据源路径因为路由是基于运行时上下文而非编译期扫描来决定的一个扫描路径完全够用。3.2 一个完整的业务切换样例假设现在有个需求从主库查用户信息从日志库查操作日志。我建一个ServiceService public class OrderService { private final UserMapper userMapper; private final LogMapper logMapper; public OrderService(UserMapper userMapper, LogMapper logMapper) { this.userMapper userMapper; this.logMapper logMapper; } DS(DataSourceType.PRIMARY) public User getUserDetail(Long userId) { return userMapper.selectById(userId); } DS(DataSourceType.SECONDARY) public ListOperateLog listLogs(Long userId) { return logMapper.selectByUserId(userId); } }在Controller里调用RestController public class DemoController { private final OrderService orderService; public DemoController(OrderService orderService) { this.orderService orderService; } GetMapping(/user/{id}) public Result getUserWithLogs(PathVariable Long id) { User user orderService.getUserDetail(id); ListOperateLog logs orderService.listLogs(id); return Result.ok(user, logs); } }这里因为Controller调用了两个不同数据源上的方法每次方法进入时切面都会设置对应的数据源执行完又恢复成空或原值所以两次查询各自路由到正确的库。上面这段代码看起来简单但它为什么能跑通依赖的就是AOP切面在方法进入前设置、退出后恢复这个时序逻辑。如果这两个查询是在同一个方法里连着执行的务必要注意切面的粒度。比如把两个查询写到一个DS(DataSourceType.PRIMARY)注解的方法里日志库那个查询就会打到主库去报“表不存在”的错误。这种问题最坑的是它不一定报错——如果你两个库的表结构一样数据写错了才是最麻烦的。3.3 事务与多数据源冲突解析事务这块是多数据源方案里最容易出问题的地方我把常见约束和解决方案分开说。先理解Spring事务的机制Transactional是基于AOP的它会在目标方法执行前从容器中拿一个DataSource通过DataSource获取Connection然后开启事务。关键就在这里事务一旦开启整个事务期间用的都是同一个Connection路由逻辑只有在首次获取连接时执行一次。如果你在事务方法内部去切换数据源即使ThreadLocal的值变了事务管理器手里的Connection还是老的那个切了跟没切一样。具体症状是第一种在Transactional方法里调用带DS注解的另一个方法数据源切换不生效SQL还是打到之前那个库。第二种两个库都参与了事务操作但没有跨库事务的保障要么不报错但数据写得不一致要么报错但只回滚一个库。我自己在项目里的对策是这样首先是约束不在同一段事务代码里操作两个数据源。业务上还能够接受“日志写入独立提交”这种设计的话就把日志库的调用放到事务方法外面或者把日志表的写入做成独立事务。其次是如果你确实需要在同一个Service方法里操作两个库建议把主数据源的操作放在事务方法里从库的操作放到不带事务的方法里通过编程式事务或独立Service传播机制来控制。代码层面还有一个终极解法继承AbstractRoutingDataSource配合TransactionAwareDataSourceProxy。这个类是Spring提供的一个代理DataSource它能感知当前线程是否存在活跃事务并在事务存在时返回同一个绑定连接。同时需要在事务管理器里使用路由数据源的代理。但这个方案复杂度比较高一般中小型项目用不上真到了需要这一步更建议直接用ShardingSphere或者考虑改库表设计。3.4 配置与启动的完整验证流程配置完所有类可以直接启动项目做一轮验证。我习惯按这个顺序走第一步验证启动无异常。启动日志里如果看到Spring自动配置的DataSource被我们的DynamicDataSource替换掉说明Primary生效了。界面上观察HikariCP的启动日志两个连接池会各自等待初始化。第二步验证主库路由。不调用任何带DS的方法默认情况下determineCurrentLookupKey()返回的入路由数据源里的默认数据源SQL会打到主库。可以在主库执行一条查询加入日志的方式验证或者干脆故意在从库不建这张表看看是否报表不存在的错。第三步验证从库路由。用带DS(DataSourceType.SECONDARY)的方法查一次日志库在日志库执行SHOW PROCESSLIST看是否有来自应用侧的连接。整个验证过程中我强烈建议把数据源名称、当前线程名打出来logging: level: com.zaxxer.hikari: INFO com.example.demo: DEBUG这样排查问题时有迹可循。我已经不止一次遇到“切到从库但没生效”的问题最后一看日志SQL全在主库执行了原因无非是切面没生效或者事务把连接绑死了。4. 常见问题与排查技巧实录4.1 循环依赖和数据源冲突问题启动的时候报The dependencies of some of the beans in the application context form a cycle这类错误通常是动态数据源和SpringBoot自动配置之间互相依赖导致的。解决方法是关闭DataSource自动配置或者在配置类上做排除。在SpringBoot 2.x中比较优雅的方式是SpringBootApplication(exclude DataSourceAutoConfiguration.class)排除了自动配置后我们注册的DynamicDataSource就是容器里唯一的DataSourceJdbcTemplate和MyBatis都会直接用这个路由数据源。这是我在多数据源项目里最推荐的方式简单粗暴能杜绝大量“自动帮你配了个数据源”的意外。4.2 切面不生效的排查前面提到过忘了引入spring-boot-starter-aop会导致切面不生效。实际表现是项目能正常启动所有接口也能访问但该切到从库的查询全打到主库去了控制台没有任何提示。排查方法很简单在切面around方法里加一行日志看有没有输出。没有输出说明切面根本没被Spring管理要么是依赖缺失要么是切面类没有被扫描到。确认依赖加了、类路径对再看EnableAspectJAutoProxy是否需要手动开启——SpringBoot一般在有AOP starter时会自动开启但如果你用了自定义配置覆盖需要检查一下。4.3 动态切换失效与连接池耗尽问题动态切换失效最常见的原因是代码里漏调clear()导致ThreadLocal残留。之前有个线上问题一直困扰我某个定时任务在跑的时候会周期性出现“偶尔连错库”。后来排查发现那个任务用了线程池线程执行完后ThreadLocal没清理复用时把上一次的库标识带到了下一次任务。所以在一切手动设置的地方务必要在finally块里清理最好统一收到切面里处理不裸写在业务代码里。连接池耗尽的症状是接口偶发卡顿、报Connection is not available, request timed out after 30000ms。多数据源场景下每个连接池是独立的你需要分别监控。造成耗尽的常见原因是某条慢SQL长期占用连接或者某个数据源的maximum-pool-size配得太小不够业务并发用。建议把主库的池子开大一点从库的池子按需中等配置不要整齐划一地设置相同值。4.4 MyBatis缓存层面的隐患MyBatis的一级缓存是SqlSession级别的二级缓存是Mapper级别的。在动态数据源场景下如果开启了二级缓存同一个Mapper接口的查询结果会被缓存下来首次是从A库查的缓存了第二次切到B库查同一个Mapper可能命中的是A库的缓存结果。这不是切换逻辑的问题是缓存粒度跟不上数据源路由粒度导致的。解决方式要么关闭二级缓存默认是不开的要么把命名空间按数据源拆开或者在XML里设置useCachefalse。4.5 动态数据源可靠性的进一步思考纸面实现跑通容易生产环境可靠性是另一回事。我把这套方案在项目里压了一段时间之后有几个体会特别深。第一路由逻辑越简单越好。别在determineCurrentLookupKey()里做复杂计算、查表、判断。它会在每次获取连接时调用频率极高任何额外开销都会被放大。我的经验是它只返回ThreadLocal的值其他逻辑提前在切面里算好。第二数据源数量要克制。路由Map里塞了三五个数据源的场景代码层面没问题但运维监控、日志排查的复杂度会成倍上升。宁可在应用层做分组隔离也别在单个应用里塞太多库。第三连接池的监控指标一定要接。HikariCP本身有HikariPoolMXBean可以暴露活性连接数、挂起任务数通过SpringBoot Actuator配合PrometheusGrafana能直观看到每个数据源的池子占用情况。多数据源项目没有监控几乎等于蒙着眼睛开车。第四要考虑数据库账号权限的隔离。既然有了多数据源建议主库用读写账号从库用只读账号。应用层面切错了还有数据库兜底挡一下能减少因为路由混乱导致的脏数据。5. 个人经验总结多数据源背后的工程思维这套多数据源方案本质上是用一个“上下文标识”把数据源选择权从底层连接获取逻辑中解耦出来。谁设置上下文、何时清理上下文就成了整个方案的灵魂。用注解加AOP的方式能把这个灵魂问题集中在代码结构上解决而不是散落在业务逻辑里。踩过几次坑之后我的习惯是新建一个多数据源模块时先花半小时理清楚哪些操作走主库、哪些走从库、有没有跨库事务需求再动手写代码。方案层面想透了编码只是执行。最后再分享一个排查技巧遇到诡异的数据源路由问题时别急着猜先看一眼HikariCP的SHOW PROCESSLIST再查日志里实际执行的SQL一步就能定位是切面问题、连接池复用问题还是事务绑定问题。这个思路能帮你省下大量的排查时间也是我在多数据源项目里收获最大的实战经验。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →