SpringBoot多数据源实战:dynamic-datasource集成与避坑指南
发布时间:2026/10/3 20:48:28 作者:尧图编辑部 阅读量:1,286

做后端开发的兄弟大概率都遇到过这样的场景项目刚上线时一套MySQL库跑得挺欢后来订单量大了老板说要把报表拆出来于是又多了一个只读库再往后用户服务单独拆了一套库数据分析组还要连ClickHouse或者PostgreSQL。这时候如果代码里还是自己手动切换Connection到处都是DataSourceUtils.getConnection(某个dataSource)这种写法数据源一多基本就没法维护了。我这个项目正好也是典型的SpringBoot MyBatis-Plus组合需求很明确一个应用同时访问多个数据库配置要少切换要稳最好开箱即用。折腾一圈之后最终选了dynamic-datasource-spring-boot-starter整个落地过程还算顺利但中间也踩了不少坑——尤其是事务、连接池、国产数据库兼容这几个环节官方文档写得并不算细。这篇文章把整个选型思路、配置方法、运行原理和排障记录全整理出来适合正在给SpringBoot项目接入多数据源的开发者参考也适合那些已经接上了、但偶尔发现DS切换不生效的兄弟对照自查。1. 为什么需要多数据源三类典型场景与选型思路1.1 多数据源不是架构师在炫技是业务跑起来后的必然结果很多项目一开始是单库但业务增长之后单库的瓶颈很快就会出现。最常见的有三类情况第一类是读写分离主库承担写操作从库扛读流量第二类是业务分库订单、用户、商品各自落库一个服务要同时访问若干个库第三类是异构数据源并存业务数据在MySQL统计报表在PostgreSQL或者ClickHouse甚至某些报表模块需要直连数据仓库。我见过很多项目甚至还要在同一个应用里同时连MySQL、Oracle和国产数据库。这几种场景本质上都是同一个问题应用代码里不能把数据源写死需要在运行时根据上下文决定访问哪个库。如果用一个MapString, DataSource手动管理每次都要写一堆模板代码还容易漏掉连接释放出问题的时候排查成本极高。所以多数据源方案的最终目标不是单纯能把多个库连上而是配置简单、切换可靠、事务可控、碰到异常能快速定位。1.2 三种方案各有利弊手写AOP、注解封装、第三方starter多数据源实现的方案大体有三种。第一种是原始方案自己维护数据源的Map写一个RoutingDataSource继承AbstractRoutingDataSource再通过一个ThreadLocal变量动态切换。这个方案代码量不大二三十行就能搞定最简版本但真正做上生产之后你会发现自己要解决的问题还有一堆连接池管理、事务边界、多库回滚、配置刷新、监控打点。每一个问题都需要花时间填坑。第二种方案是自己写AOP切面用自定义注解标注目标数据源。这个方案比纯手动方式好一些能把切换逻辑跟业务代码解耦但本质上只是把第一种方案包装得更优雅。切面的顺序、异常处理、嵌套切换、事务传播行为都需要你自己踩一遍才知道坑在哪。第三种方案就是直接用社区成熟的starterMyBatis-Plus官方周边生态里dynamic-datasource-spring-boot-starter是使用率最高的一套。它把数据源注册、路由、注解解析、负载均衡、多源事务都做了封装生产验证的案例也足够多。1.3 为什么是dynamic-datasource开箱即用这个词不能随便用我最终选dynamic-datasource核心原因是它确实做到了“开箱即用”。首先它支持DS注解做类级和方法级切换方法级优先这个语义非常符合日常开发习惯。其次它在AbstractRoutingDataSource的基础上做了增强支持多组数据源、主从分组、负载均衡、嵌套切换甚至兼容Druid、HikariCP、BeeCp等主流连接池。第三它的DSTransactional提供了多数据源的本地事务支持虽然不保证强一致但解决了很多轻量级场景下的多库写入问题。另外一点这个库本身就是com.baomidou全家桶里的成员跟MyBatis-Plus配合得最自然。你用mybatis-plus-boot-starter做ORM再用同一组织维护的dynamic-datasource做数据源路由踩坑时能查到的资料也最多。如果你项目用的是SpringBoot 2.x直接用3.6.x版本即可如果项目已经升级到SpringBoot 3.x或JDK 17以上记得用4.x版本因为SpringBoot 3的自动配置加载机制改了旧版本很多会直接失效。2. 快速集成dynamic-datasource五分钟搭出可运行的demo2.1 引入依赖Maven坐标与版本选择第一步是加依赖。Maven坐标如下dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version4.2.0/version /dependency如果你的项目还是SpringBoot 2.x建议用3.6.x的最后一个稳定版本。这里有一个很容易踩的坑有些兄弟在SpringBoot 3的环境里引入了3.x版本运行时报NoSuchBeanDefinitionException或者干脆数据源都没有被自动装配。原因就是SpringBoot 3把自动装配的注册文件从spring.factories改成了AutoConfiguration.imports旧版本的starter没有适配这个机制。说白了不是配置写错了是starter版本跟Boot版本不匹配。还有一个细节如果你的项目原本单独引了druid-spring-boot-starter引入dynamic之后要小心Bean冲突。dynamic在开启Druid支持的情况下会自动创建Druid相关数据源如果两边同时存在容易出现数据源被覆盖或者属性不生效的情况。我的做法是只用dynamic一个starter连接池参数统一在spring.datasource.dynamic下配置不额外引Druid的独立starter。2.2 配置文件模板一个主库两个从库的完整示例以三个数据源为例master是主库slave_1和slave_2是只读从库配置如下spring: datasource: dynamic: primary: master strict: true lazy: true datasource: master: url: jdbc:mysql://192.168.10.10:3306/order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.10.11:3306/order_ro?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://192.168.10.12:3306/order_ro?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver三个参数值得特别说明。primary指定的库是默认数据源你写DS(xxx)没有匹配到任何数据源时或者方法上根本没加注解时都会走这个主库。strict建议显式配置成true否则数据源key写错了很多版本会静默回退到主库不会报错这种隐性Bug排查起来非常难受你还会以为是SQL写错了实际上是数据源根本没切过去。lazy建议配置成true启动时不会立刻把所有的数据源都初始化连接而是等第一次用到某个库时再懒加载。这样如果某个备用库临时连不上应用依然可以正常启动只有真正访问到那个库时才会报错这在多环境部署时很实用。如果你还用了Druid连接池参数是这样加的spring: datasource: dynamic: druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 test-while-idle: true test-on-borrow: false validation-query: SELECT 1 time-between-eviction-runs-millis: 600002.3 验收标准跑通一个多源CRUD就算入门了配置好之后写个简单的Service验证一下。比如我们有个OrderMapper是标准的MyBatis-PlusBaseMapperService public class OrderService { Autowired private OrderMapper orderMapper; DS(slave_1) public ListOrder listFromSlave1() { return orderMapper.selectList(null); } DS(slave_2) public ListOrder listFromSlave2() { return orderMapper.selectList(null); } public ListOrder listFromMaster() { return orderMapper.selectList(null); } }写个接口分别调用这三个方法看日志或者看数据库连接就能确认是否切库成功。如果三个方法返回的数据来自不同的库基础功能就通了。这里还要提醒一句MyBatis-Plus自带的BaseMapper自带通用CRUD在多数据源环境下照样能用这一点比手写JDBC方便太多。你不需要自己维护数据源上下文只需要关注注解该标在哪个类、哪个方法上。3. 深入理解DS切换机制注解是怎么让数据源“动”起来的3.1 AbstractRoutingDataSource是基石但还差两步Spring本身提供了一个AbstractRoutingDataSource它的核心思想很简单维护一个目标数据源集合执行数据库操作前通过determineCurrentLookupKey()决定当前线程应该用哪个key。这个key通常从ThreadLocal里取。basic版的实现就是把这个抽象类用起来但有一个很头疼的问题Spring容器在启动时会初始化这个RoutingDataSource而你的目标数据源如果不是全部配好并且可用启动阶段就可能报错这跟很多项目的多环境部署需求是冲突的。dynamic-datasource在AbstractRoutingDataSource基础上做了一套改进它的路由规则可以动态增减数据源可以懒加载同时维护了一个基于Deque的栈用来支持嵌套切换。Deque的意思是你可以在一个方法里切到库A然后在A的内部再切到库B方法结束之后会按栈的顺序自动弹回。这个栈式设计让DS注解的组合使用变得灵活但理解不到位也容易踩到“切了没生效”的坑后面会细说。3.2 DS注解的解析时机方法级优先与SpEL表达式DS注解可以放在类上也可以放在方法上。放在类上表示这个类里面的所有方法默认走某个数据源放在方法上会覆盖类上的配置。如果你在Controller里或者Service方法间调用切面会拦截加了注解的方法在方法执行前解析注解的value把对应的数据源key压入栈方法结束后再从栈里弹出恢复调用前的数据源上下文。这里有一个很多人误解的点DS的解析是AOP切面在方法执行前进行的而不是在SQL执行时才去解析。所以如果你在方法体内修改了某个参数而这个参数又是SpEL表达式依赖的入参修改后的值不会影响到已经确定的数据源。DS还支持SpEL表达式这个功能在动态切换场景里非常有用。比如你的系统是多租户的每个租户对应一个独立数据库就可以这样写DS(#tenantId) public ListOrder listByTenant(Long tenantId) { return orderMapper.selectList(null); }如果你的方法参数不止一个Spring的SpEL可以通过参数名解析但有个前提编译时需要开启-parameters参数否则只能退而用#p0、#p1这样的位置参数。举个实际例子DS(#p0.tenantId) public ListOrder listByTenant(OrderQuery query) { return orderMapper.selectList(query); }我建议在大项目里统一开启-parameters编译参数这样代码可读性更好小项目图省事的话直接使用#p0反而最稳。3.3 切换为什么“看起来失效了”线程栈与事务连接最常见的“DS不生效”问题十有八九是发生在嵌套调用或者事务方法里。先看一段典型的错误写法Transactional public void saveOrder(Order order) { orderMapper.insert(order); // 当前事务数据源是 master this.saveLog(order); // 自调用注解不生效 } DS(log_db) public void saveLog(Order order) { logMapper.insert(order); }这段代码有两个坑。第一this.saveLog()是同类内部调用不经过Spring的代理对象所以AOP切面根本不会执行DS注解直接失效。第二就算你把saveLog拆到另一个Service类里外层方法已经加了TransactionalSpring事务管理器在执行第一条SQL时就已经从master获取了数据库连接并绑定到当前线程。后续的DS(log_db)虽然把数据源上下文切到了log_db但MyBatis在执行时会优先使用当前线程事务绑定的连接——也就是master的连接log_db根本不会被访问到。要解决这个问题思路是这样的如果多个库的操作之间确实需要同时成功或失败就用后面会讲到的DSTransactional如果不需要强一致就把事务拆开控制。最忌讳的是在Transactional方法里混着用DS因为它的表现是不报错、不切换极其隐蔽。3.4 事务与多数据源的相爱相杀事务内切换为什么经常失效这里把事务和数据源切换的关系再掰开揉碎一点。Spring事务的本质是“同一个线程内复用同一个数据库连接”而多数据源切换的本质是“切换数据源key以获取不同的连接”当这两者碰在一起时先获取连接的一方决定了整个事务期间使用的数据源。不管后面的注解怎么写只要事务没提交连接就不会换。所以真正正确的姿势是把涉及不同数据源的方法拆成独立的事务边界。比如A方法不加事务分别调用B方法操作主库自身加事务和C方法操作日志库自身加事务这样每个库的事务各自提交。缺点是如果第二步失败第一步已经提交了数据会不一致。优点是简单、可靠、不会出现诡异的“切不动”问题。业务上如果容忍这种短时间不一致就选这种方式如果不容忍再考虑更强的事务方案。做技术选型不是追求炫技而是搞清楚自己的系统能接受什么程度的不一致。4. 高级玩法读写分离、多主多从与事务控制4.1 读写分离配置主从分组与负载均衡dynamic-datasource里有一个很舒服的配置主从分组。分组之后你可以用DS(slave)直接指向一组从库框架会在这一组内做负载均衡而不需要关心具体的库是slave_1还是slave_2。spring: datasource: dynamic: datasource: master: url: jdbc:mysql://192.168.10.10:3306/order username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.10.11:3306/order_ro username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://192.168.10.12:3306/order_ro username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver group: slave: - slave_1 - slave_2这个分组的负载均衡策略默认是随机你也可以自己替换实现。逻辑上凡是只读的列表查询接口统一标DS(slave)写操作不标注走默认的master。这样配置上非常清爽后续扩从库时只需要在group里加一个数据源key业务代码完全不用动。4.2 多源事务的三条路线该怎么选多数据源的事务问题是绕不开的话题。我的建议是分三层来考虑。第一层如果只是要在多个库上分别执行几条操作但可以接受“最终一致”最简单的方式就是拆方法、各自事务。第二层如果你需要多个库的本地事务一起提交回滚可以用DSTransactional。这个注解的实现机制是在多个数据源上各自开启事务提交时依次提交——注意它不是分布式事务不保证原子性如果第二个库提交失败了第一个库已经提交成功它不会回滚。所以它比较适合“大多数时候不会失败”的场景比如业务日志、操作审计这类辅助性写入。第三层如果业务真的需要强一致那就要上真正的分布式事务方案比如Seata。Seata的AT模式配合dynamic-datasource是可以用的但会带来额外的部署成本和性能开销。我见过不少项目上来就给多数据源标配Seata最后发现99%的表都难出现跨库同时操作的情况完全是在给自己加运维负担。先想清楚业务是否真的需要强一致再决定要不要上分布式事务这才是正确的选型顺序。4.3 同一个事务写多个库时你需要的不是切换而是路由有一种业务场景很有意思同一个表结构因为数据量大被水平拆到了多个库事务里需要按某个字段路由到特定库去写入。这时候DS加SpEL表达式就非常合适。比如按用户ID尾号分库DS(#userId % 10 0 ? db_user_0 : db_user_1) public void saveUser(User user, Long userId) { userMapper.insert(user); }当然SpEL表达式不建议写得太复杂否则读代码的人一眼看不懂维护成本很高。我的习惯是在Service层先算好数据源key再用方法直接指定到这个key。这种写法逻辑清晰排查问题时也方便。多数据源技术本身并不局限于数据库。实际项目里你还会把MinIO对象存储、消息队列、定时任务等组件跟多数据源组合起来用。比如定时任务扫描某个数据源中的待处理数据处理完之后把结果写到另一个库同时把对应的文件传到MinIO。这种场景下数据源切换本身只是其中一环真正需要你关注的是任务执行过程中哪些步骤失败可以被重试、哪些需要手动补偿。不要试图用一个注解解决所有一致性问题那是不现实的。5. 我踩过的坑常见问题与排查实录5.1 必须记住的十个坑我把团队里几个人踩过的坑汇总成一张速查表按出现频率排了序表现原因解决启动后Mapper扫描不到接口MapperScan没有生效或者多模块下扫描路径不对指定正确的Mapper包路径确认启动类上注解数据源key写错但没报错strict未设置为true显式配置spring.datasource.dynamic.stricttrue方法内自调用DS不生效同类内部调用不走代理把切库逻辑抽到另一个Bean里事务方法里DS不生效连接已被事务占用拆分事务或用DSTransactionalSpringBoot3下无法自动装配starter版本过低升级到4.x分页插件查数据源错乱PaginationInnerInterceptor的DbType被固定升级MyBatis-Plus版本或动态识别DbType启动失败备用库连不上数据源全部启动时初始化配置lazy: trueDruid配置不生效重复引用了druid starter只保留dynamic里Druid配置数据库驱动类不存在驱动jar包未引入检查依赖确认driver-class-name从库连接断开后不会自动恢复缺少连接池保活配置配置test-while-idle、validation-query等5.2 启动阶段驱动背锅、端口背锅、配置背锅项目刚集成多数据源时最常遇到的报错是Failed to configure a DataSource。很多新手兄弟一看这个错第一反应是去查URL、用户名密码但实际上这个错误还有一个常见原因dynamic-datasource的自动装配覆盖了SpringBoot默认的数据源装配而你配置了多个数据源却没有指定primary。primary一定不能省它告诉框架在没有DS注解时默认用哪个数据源。另外一个启动阶段的高频问题是MySQL 8的驱动类写法。老项目里很多人还写着com.mysql.jdbc.Driver在MySQL 8以上版本会直接报ClassNotFoundException正确的写法是com.mysql.cj.jdbc.Driver。这里建议直接把driver-class-name省略让连接池根据URL自动识别驱动反而更省事。还有一点如果你的备用库IP是个内网地址在本地开发时连不通记得开lazy。之前有个同事把一套配置从测试环境拷贝到本地测试环境能跑本地一启动就失败就是因为备用库的地址在本地网络不可达而旧版的lazy默认值是false启动时把每一个数据源都初始化了一遍。这个问题本身不是大问题但排查起来特别浪费时间。5.3 运行阶段切换失效、连接被回收、慢查询扎堆运行阶段最大的坑是“数据源切换失败了但业务没有报错”。我排查过最典型的一个案例A服务通过Feign调用B服务B服务的某个接口标了DS(slave)结果实际查询走的还是主库。排查了很久最后发现是Feign接口所在的类被Transactional给拦了整个请求链路一旦开启事务数据源就被锁定在主库上。这类问题通常发生在“框架封装太深、开发人员不知道底层有事务代理”的场景。连接被回收的问题也值得单独说。如果应用里有慢SQL特别是从库上的大查询会把连接池里的连接拖死最终触发连接泄漏、后续请求排队等待、接口RT飙升。排查时可以先看连接池监控再开启MyBatis-Plus的SQL日志或者p6spy定位慢SQL。多数据源环境下生产环境建议把SQL日志打到独立的日志文件里方便按数据源维度排查问题。5.4 兼容国产数据库金仓、达梦需要注意的细节国内项目里多数据源经常还会涉及国产数据库比如金仓、达梦、人大金仓等。这些库跟MySQL、PostgreSQL的兼容性参差不齐集成时要注意几点驱动类名要写对金仓的驱动是com.kingbase8.Driver达梦的驱动是dm.jdbc.driver.DmDriverURL前缀各不一样金仓是jdbc:kingbase8://host:port/db达梦是jdbc:dm://host:port/db。如果项目里同时手动配置了driver和Dialect还有可能影响MyBatis-Plus分页插件的DbType识别导致分页SQL生成错误。我的建议是先写一个独立的连接测试在引入业务代码之前把“能连上、能查询、能分页”这三个基本能力跑通。很多时候大家一上来就改Mapper结果发现是数据库驱动的问题绕了一大圈。另外国产库的驱动版本也要注意有些版本和数据库服务端的协议不兼容连上去就会出现随机断连的现象这已经超出应用层能解决的范围了通常需要和数据库厂商确认驱动版本。6. 性能与运维多数据源上线前必须检查的细节6.1 连接池参数怎么给才不拖垮主库多数据源环境下连接池参数必须按库的实际压力单独给不能一套参数走天下。主库承担所有写操作和部分读操作通常并发压力最大连接数上限可以给到20~50视QPS而定从库主要服务报表和大查询可以给到10~20但要注意从库上的慢查询会把连接占满所以查询超时参数一定要配置。HikariCP示例spring: datasource: dynamic: datasource: master: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 slave_1: hikari: maximum-pool-size: 10 minimum-idle: 3 connection-timeout: 15000 idle-timeout: 600000 max-lifetime: 1800000这里有个很重要的经验主库和从库的事务隔离级别、自动提交设置、连接超时时间都可以不一样。从库如果只读连接池参数可以更激进一点允许更短的连接超时避免报表查询拖住太长时间。6.2 监控和数据源体检清单多数据源上线后监控是必须的。如果你用的是Druid可以开启内置的监测页面如果是HikariCP需要接Micrometer或Prometheus暴露指标。我建议至少盯三个指标每个数据源的活跃连接数、等待获取连接的时间、SQL执行耗时。这三个指标能帮你快速判断是“连接池不够用”还是“SQL本身慢”。上线前我还习惯做一次“数据源体检”流程很简单确认每个数据源都能正常连通用DS注解访问每一个库的简单查询和分页查询确认主从切换后事务不受影响关闭一个备用数据源的网络确认应用其余功能依然可用模拟主库宕机确认从库读取不受影响。这套体检流程每做一个项目都跑一遍能提前暴露绝大多数配置问题。6.3 当数据源多到十个以上配置如何治理数据源的数量达到一定规模之后配置治理就成了大问题。十个数据源全部写在application.yml里文件会变得非常长而且很容易重复。我见过的项目里有把数据源配置切分成多个Profile文件的也有把配置放到Nacos、Apollo这类配置中心的。配置中心的优势很明显改连接串不用重新发版某个数据源有问题可以直接在配置中心摘除。另一个实用手段是给数据源key建立命名规范比如业务域_用途_序号order_master、order_slave_1、report_clickhouse。命名规范虽然简单但能避免很多低级的key写错问题。再说直接一点多数据源最大的风险不是技术问题而是你根本不知道一个请求最终走了哪个库。所以规范的命名、完整的日志、及时的监控这三件事比任何框架技术都重要。最后说点个人感受。我做了好几个多数据源的SpringBoot项目越来越觉得这类问题真正的难点不在于“怎么把库连上”而在于“连上之后如何保证业务在复杂场景下依然正确、可排查”。dynamic-datasource确实做到了开箱即用但开箱之后的维护工作仍然需要你对事务边界、连接池参数、数据源命名有足够清晰的认知。遇到问题不要慌多看看实际的SQL日志和连接池监控大部分坑都能定位到具体环节。希望这篇文章能让你少走一些弯路。