SpringBoot多数据源实战:AbstractRoutingDataSource与dynamic-datasource方案详解

发布时间:2026/8/26 2:47:53
SpringBoot多数据源实战:AbstractRoutingDataSource与dynamic-datasource方案详解 1. 项目背景与核心诉求最近在重构一个老的后台管理系统遇到了一个挺典型的场景业务数据存在两个独立的MySQL实例里一个存放核心交易数据另一个存放日志和报表数据。在单体SpringBoot应用里要同时操作这两个库这就是我们常说的“多数据源”需求。这活儿听起来简单不就是配两个DataSource嘛但真动手时你会发现从数据源创建、事务管理到MyBatis集成每一步都可能埋着坑。比如你可能会遇到那个经典的启动报错Failed to configure a DataSource: url attribute is not specified and no embedded datasource could be configured.或者更头疼的在事务里切换数据源失效导致数据错乱。网上搜“SpringBoot多数据源”方案一大堆但很多文章要么只给个Demo代码不讲为什么要么一上来就推荐某个第三方组件对于背后的机制和选型逻辑语焉不详。今天我就结合自己趟过的坑把SpringBoot里实现多数据源的两种主流、务实的方案给你掰扯清楚。一种是基于AbstractRoutingDataSource的手动路由这是Spring框架原生提供、最贴近原理的方案另一种是使用成熟的第三方组件比如国内开发者常用的dynamic-datasource-spring-boot-starter。我们不光要会“用”更要明白每种方案背后的设计思想、适用场景以及那些文档里不会写的“坑点”。无论你是刚接手一个遗留的多数据源项目感到一头雾水还是正在为新项目做技术选型这篇文章都能给你提供从原理到实战的完整参考。我会假设你已经有SpringBoot和MyBatis的基础所以我们会跳过基础环境搭建直接切入多数据源这个主题的核心。2. 方案一基于AbstractRoutingDataSource的手动动态路由这是最经典、最“Spring”的方式。它不依赖任何第三方库完全利用Spring框架已有的能力来构建一个动态数据源。理解了这个方案你就能掌握多数据源切换的底层原理。2.1 核心原理线程绑定的上下文与路由决策AbstractRoutingDataSource是Spring JDBC抽象层提供的一个类顾名思义它是一个“抽象的路由数据源”。它的工作模式很像一个路由器内部维护着一个包含多个目标数据源targetDataSources的映射表比如MapObject, DataSource同时持有一个默认数据源defaultTargetDataSource。它的核心方法是determineCurrentLookupKey()这个方法返回一个Lookup Key查找键路由数据源会根据这个Key去映射表中找到对应的真实数据源并使用它。那么这个Lookup Key从哪里来这就是实现动态切换的关键。通常的做法是借助ThreadLocal。ThreadLocal可以为每个线程创建一个变量的副本这个副本是线程隔离的。我们创建一个工具类常命名为DataSourceContextHolder或DynamicDataSourceContext在里面用ThreadLocal保存当前线程应该使用的数据源标识例如“master”,“slave”。当一次数据库操作开始时我们首先通过代码例如在Service层的方法上使用自定义注解或AOP切面将数据源标识设置到ThreadLocal中。随后当Spring需要获取数据库连接时会调用我们自定义的、继承了AbstractRoutingDataSource的类的determineCurrentLookupKey()方法这个方法只需从ThreadLocal中取出之前设置好的标识并返回。这样这次操作就会自动路由到正确的数据源上。为什么是ThreadLocal因为Web应用通常基于Servlet每个请求由一个独立的线程处理。将数据源标识绑定到当前线程可以完美保证在同一次请求处理链路中所有数据库操作都使用同一个数据源避免在事务中发生不可预期的切换。事务管理器如DataSourceTransactionManager也是基于同一个DataSource来管理连接的这确保了事务的一致性。2.2 完整实现步骤与代码拆解下面我们一步步来实现这个方案。假设我们有两个数据源master主库和slave从库。第一步配置多个数据源属性在application.yml中我们不再使用SpringBoot自动配置的spring.datasource而是为每个数据源定义独立的配置前缀。# 主数据源 datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/master_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: master_password hikari: pool-name: MasterHikariPool maximum-pool-size: 20 # 从数据源 slave: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3307/slave_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: slave_password hikari: pool-name: SlaveHikariPool maximum-pool-size: 15注意这里用的是jdbc-url而不是url。这是为了避免SpringBoot的自动配置机制误认为我们要配置单数据源而引发冲突这也是解决开头提到的‘url‘ attribute is not specified报错的关键一步。第二步创建数据源配置类我们需要手动将这两个数据源实例化为Spring Bean。这里会创建两个DataSource通常选用高性能的连接池如HikariCP。Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix datasource.master) public DataSource masterDataSource() { // SpringBoot 2.x 默认使用Hikari这里直接返回DataSource即可属性会自动绑定 return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } }第三步实现数据源上下文持有者这个类负责用ThreadLocal保存和获取当前线程的数据源键。public class DataSourceContextHolder { /** * 使用ThreadLocal保存数据源标识确保线程隔离 */ private static final ThreadLocalString 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 clearDataSourceKey() { CONTEXT_HOLDER.remove(); } }第四步创建动态数据源并继承AbstractRoutingDataSource这是路由机制的核心。我们将前面定义的两个DataSourceBean注入并配置给这个动态数据源。Primary // 务必加上Primary告诉Spring在多个DataSource Bean中优先使用这个 Bean(name dynamicDataSource) public DataSource dynamicDataSource(Qualifier(masterDataSource) DataSource master, Qualifier(slaveDataSource) DataSource slave) { DynamicRoutingDataSource routingDataSource new DynamicRoutingDataSource(); // 配置目标数据源映射 MapObject, Object targetDataSources new HashMap(2); targetDataSources.put(master, master); targetDataSources.put(slave, slave); routingDataSource.setTargetDataSources(targetDataSources); // 设置默认数据源当没有找到对应key时使用 routingDataSource.setDefaultTargetDataSource(master); return routingDataSource; } /** * 自定义动态数据源实现determineCurrentLookupKey方法 */ public class DynamicRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 直接从上下文持有者中获取当前数据源键 return DataSourceContextHolder.getDataSourceKey(); } }第五步配置事务管理器与MyBatis由于我们替换了默认的DataSource事务管理器和MyBatis的SqlSessionFactory也需要显式配置并指向我们自定义的dynamicDataSource。Configuration EnableTransactionManagement // 启用注解事务 public class MyBatisConfig { Autowired Qualifier(dynamicDataSource) private DataSource dynamicDataSource; Bean public PlatformTransactionManager transactionManager() { // 使用动态数据源作为事务管理器的数据源 return new DataSourceTransactionManager(dynamicDataSource); } Bean public SqlSessionFactory sqlSessionFactory() throws Exception { SqlSessionFactoryBean sessionFactory new SqlSessionFactoryBean(); sessionFactory.setDataSource(dynamicDataSource); // 设置你的MyBatis配置如mapperLocations, typeAliasesPackage等 // sessionFactory.setMapperLocations(...); return sessionFactory.getObject(); } }第六步实现切换数据源的切面AOP最后我们需要一个机制来方便地切换数据源。最优雅的方式是使用自定义注解和AOP。/** * 自定义注解用于标记方法使用的数据源 */ Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DS { String value() default master; // 默认使用主库 } /** * 数据源切面拦截DS注解在方法执行前后切换和清理数据源 */ Aspect Component Order(-1) // 确保在事务切面之前执行 public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint point, DS ds) throws Throwable { String oldKey DataSourceContextHolder.getDataSourceKey(); String newKey ds.value(); try { // 切换数据源 DataSourceContextHolder.setDataSourceKey(newKey); // 执行原方法 return point.proceed(); } finally { // 恢复原数据源 if (oldKey ! null) { DataSourceContextHolder.setDataSourceKey(oldKey); } else { // 如果之前没有设置则清理 DataSourceContextHolder.clearDataSourceKey(); } } } }现在在Service层的方法上使用DS(“slave”)注解该方法内的所有数据库操作就会自动使用从库数据源。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override DS(“master”) // 写操作用主库 public void createUser(User user) { userMapper.insert(user); } Override DS(“slave”) // 读操作用从库 public User getUserById(Long id) { return userMapper.selectById(id); } }2.3 避坑指南与实战心得事务内的数据源切换失效这是最常见的问题。如果你在同一个Transactional注解的方法内部先调用了DS(“slave”)的方法再调用DS(“master”)的方法你会发现第二个方法并没有切换到主库。原因Spring的事务管理在方法开始时就会从DataSourceTransactionManager获取一个数据库连接Connection这个连接会绑定到当前事务中。后续所有数据库操作无论你怎么切换ThreadLocal里的keyAbstractRoutingDataSource在determineCurrentLookupKey时拿到的key可能已经是过时的或者事务管理器根本不会重新获取连接。解决方案避免在同一个事务方法内切换数据源。将不同数据源的操作拆分到不同的事务方法中或者使用REQUIRES_NEW传播级别开启新事务需谨慎可能带来数据一致性问题。ThreadLocal内存泄漏DataSourceContextHolder中使用的ThreadLocal如果没有及时清理在Web容器使用线程池时线程会被复用导致上一个请求设置的数据源键污染下一个请求。解决方案务必在切面的finally块中清理如上面的clearDataSourceKey。更完善的做法是使用一个Filter或Interceptor在请求结束后统一清理。启动报错Failed to configure a DataSource如果你按照上面的配置依然遇到这个错误请检查是否在启动类上使用了SpringBootApplication(exclude {DataSourceAutoConfiguration.class})来排除SpringBoot的自动数据源配置在我们的手动配置方案中通常不需要排除因为我们手动定义的DataSourceBean会覆盖自动配置的。排除有时反而会引发其他问题。检查配置文件中的属性名是否正确。确保没有残留的spring.datasource.url等属性它们会触发自动配置。最根本的解决方法是确保Spring容器中成功创建了一个DataSource类型的Bean。我们的dynamicDataSourceBean加了Primary注解就是为了成为那个主要的DataSource。多数据源与MyBatis Mapper扫描如果你的Mapper接口分散在不同的包且希望不同的包对应不同的数据源这是一种更粗粒度的划分那么上述动态路由方案需要调整。你需要为每个数据源配置独立的SqlSessionFactory和MapperScannerConfigurer并指定各自的包路径。这比方法级别的注解切换更复杂但适用于模块化非常清晰的项目。3. 方案二使用dynamic-datasource-spring-boot-starter如果你觉得手动实现动态路由太繁琐或者项目对多数据源的需求非常复杂比如主从分离、读写分离、分库分表等那么使用一个成熟的第三方组件是更高效、更稳妥的选择。国内社区中由baomidou团队维护的dynamic-datasource-spring-boot-starter是事实上的标准。3.1 组件介绍与快速集成dynamic-datasource是一个基于SpringBoot的快速集成多数据源的启动器。它内置了类似我们手动方案的路由机制但功能更强大封装更完善开箱即用。它支持主从分离内置读写分离逻辑。多数据源支持任意数量的数据源。多种策略支持负载均衡策略选择从库。Seata分布式事务无缝集成。SPI扩展支持自定义数据源选择策略。集成步骤极其简单引入依赖在pom.xml中引入starter。dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version4.3.0/version !-- 请使用最新稳定版 -- /dependency注意它内部已经包含了HikariCP等必要依赖通常无需再单独引入连接池。修改配置文件配置格式非常直观。spring: datasource: dynamic: primary: master # 设置默认的主数据源 strict: false # 是否严格匹配数据源未匹配到时是否抛错。生产环境建议true datasource: master: # 数据源名称可以自定义 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/master_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: master_password slave_1: # 第二个数据源 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3307/slave_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: slave_password # 可以继续添加 slave_2, slave_3... # 读写分离配置示例可选 # slave: # enabled: true # 启用读写分离 # load-balance: round_robin # 从库负载均衡策略轮询看到了吗配置又变回了熟悉的spring.datasource前缀但结构变成了dynamic。它完美兼容了SpringBoot的自动配置逻辑不会产生冲突。使用DS注解切换数据源组件提供了和我们手动实现同名的DS注解全限定名是com.baomidou.dynamic.datasource.annotation.DS。Service public class OrderServiceImpl implements OrderService { DS(“master”) // 写订单用主库 public void createOrder(Order order) { ... } DS(“slave_1”) // 查订单可以用从库 public Order getOrder(Long id) { ... } // 如果不加注解默认使用 primary 指定的数据源master public ListOrder getDefaultList() { ... } }集成完毕无需任何额外的配置类、AOP切面或ThreadLocal工具类。事务管理也由组件自动处理通常无需额外配置。3.2 高级特性与配置详解除了基础使用dynamic-datasource还提供了一些高级特性来应对复杂场景。1. 读写分离与负载均衡在配置中启用slave选项并配置负载均衡策略后当你使用DS(“slave”)注解或者不指定名称用DS默认的slave逻辑时组件会自动从配置的多个从库数据源如slave_1,slave_2中根据策略如轮询round_robin、随机random选择一个来执行查询。这极大地简化了读写分离的架构实现。2. 嵌套调用与传播行为组件对Spring事务有很好的支持。在大部分情况下它能够正确处理嵌套方法调用时的数据源切换。其内部原理也是基于ThreadLocal但做了更精细的控制。例如DS(“master”) Transactional public void masterMethod() { // 操作主库 insertToMaster(); slaveMethod(); // 调用一个标记了DS(“slave”)的方法 } DS(“slave”) public void slaveMethod() { // 这里会操作从库吗不一定 }在同一个事务masterMethod中调用slaveMethod由于事务已经开启并绑定了主库连接slaveMethod上的DS注解可能会失效操作仍然发生在主库上。组件提供了DSTransactional注解来更好地管理多数据源事务但在涉及跨库数据一致性时依然推荐使用Seata这类分布式事务解决方案。3. 自定义数据源选择策略如果内置的负载均衡策略不满足需求你可以实现DynamicDataSourceStrategy接口来自定义选择逻辑。比如根据当前时间、查询SQL的类型或者某个业务参数来选择特定的从库。4. 集成MyBatis-Plus等ORM框架dynamic-datasource与MyBatis-Plus兼容性极佳通常只需正常引入MP的starter即可。如果你的Mapper位于不同的包希望不同的包默认使用不同的数据源可以在配置中指定spring: datasource: dynamic: datasource: master: # ... 配置 slave: # ... 配置 # 指定不同包的默认数据源可选高级功能 strategy: package-mapping: com.example.mapper.master: master com.example.mapper.slave: slave3.3 常见问题排查与组件选型思考Q1: 引入了starter但DS注解不生效A1: 请检查以下几点确保注解来自com.baomidou.dynamic.datasource.annotation.DS而不是其他同名注解。检查方法是否是public的。Spring AOP默认只对public方法生效。确认方法是否被同一个类内部的其他方法调用。由于Spring AOP基于代理类内部调用不会经过代理对象导致切面失效。需要通过AopContext.currentProxy()或从容器中获取Bean的方式来调用。查看启动日志确认dynamic-datasource组件已成功初始化。Q2: 事务回滚后数据源上下文是否被正确清理A2: 是的组件内部做了处理。它会将数据源Key的绑定与Spring的事务同步管理器TransactionSynchronizationManager关联在事务完成提交或回滚后自动清理对应的ThreadLocal资源一般不会出现内存泄漏。Q3: 在哪种情况下应该选手动方案哪种情况选第三方组件这是一个典型的“造轮子”还是“用轮子”的问题。选择手动实现AbstractRoutingDataSource的情况项目对依赖非常敏感希望保持极简不愿引入额外的第三方库。多数据源逻辑极其简单且固定未来几乎没有扩展可能。你需要一个纯粹的教学或理解底层原理的场景。你有强烈的定制化需求且第三方组件无法满足这种情况较少。选择dynamic-datasource-starter的情况绝大多数生产环境项目。它经过了大量项目的验证稳定性高。需要快速上线追求开发效率。有多数据源、读写分离、甚至未来向分库分表演进的需求。你不想花时间处理事务传播、连接泄漏等底层细节问题。我的个人建议是对于新的生产项目优先使用dynamic-datasource-spring-boot-starter。它节省了大量的开发和调试成本社区活跃遇到问题也容易找到解决方案。手动方案更适合作为学习原理的途径。4. 两种方案的深度对比与性能考量理解了两种实现方式后我们从多个维度进行对比以便你在实际项目中做出更合适的选择。对比维度手动实现 (AbstractRoutingDataSource)使用 dynamic-datasource-starter核心原理基于Spring原生抽象类自行实现ThreadLocal上下文和AOP切面进行路由。基于同样的原理但进行了完整封装提供了开箱即用的DS注解和丰富策略。集成复杂度高。需要编写配置类、上下文工具、AOP切面、事务管理器配置等代码量较大。极低。引入依赖修改YAML配置使用注解即可。功能特性基础。仅实现动态路由其他如读写分离、负载均衡需自行实现。丰富。内置读写分离、多种负载均衡策略、Seata集成、监控端点等。事务支持较弱。需要开发者非常小心地处理事务边界同一事务内切换容易失效易踩坑。较强。对Spring事务有较好封装提供了DSTransactional降低了使用门槛。可维护性较低。自定义代码多后续团队成员需要理解整套自定义逻辑。高。遵循社区标准有官方文档新人上手快。社区与生态无。属于项目自有代码无社区支持。活跃。由baomidou团队维护社区活跃Issue和PR响应较快与MyBatis-Plus生态融合好。适用场景学习原理、对依赖数有严格限制的超轻量级应用、有高度定制化需求且第三方组件无法满足。绝大多数需要多数据源、读写分离的SpringBoot生产项目追求稳定、高效和可维护性。关于性能的考量两种方案在性能开销上差异微乎其微。核心的路由逻辑从ThreadLocal取Key都是常数时间复杂度O(1)的操作。主要的性能瓶颈在于连接池本身HikariCP等连接池的获取、释放连接的速度。网络I/O数据库服务器的响应时间和网络延迟。不当的事务管理在事务内频繁切换数据源导致连接持有时间过长或事务范围过大。因此性能优化的重点不应放在选择哪种路由方案上而应该放在合理配置连接池参数如maximumPoolSize、minimumIdle、connectionTimeout等根据实际负载调整。优化SQL语句与数据库索引这是提升数据库操作性能的根本。严格控制事务范围使用Transactional(readOnly true)标注只读事务对于写操作尽量缩短事务持有时间。使用从库分担读压力这正是多数据源/读写分离架构的核心价值所在。5. 生产环境部署与运维注意事项无论选择哪种方案将多数据源应用部署到生产环境时都需要注意以下事项1. 配置分离与加密数据库密码等敏感信息不应明文写在application.yml中。应该使用配置中心如Nacos、Apollo或至少是application-{profile}.yml配合环境变量。对于密码建议使用Jasypt等工具进行加密。2. 连接池监控与调优多数据源意味着多个连接池。你需要监控每个连接池的活动连接数、空闲连接数、等待连接超时次数等关键指标。Spring Boot Actuator的/actuator/metrics端点或/actuator/hikaricp端点如果使用HikariCP可以提供这些数据。根据监控结果动态调整maximum-pool-size等参数避免连接池成为瓶颈。3. 数据库高可用与故障转移你的主库和从库本身应该是高可用的例如采用主从复制VIP或使用云数据库服务。在应用配置中可以考虑以下策略重试机制为数据源配置连接失败后的重试逻辑。从库降级当从库不可用时读操作能否暂时降级到主库这需要在代码或组件层面设计降级策略。dynamic-datasource支持设置strict模式非严格模式下如果指定的从库不可用可能会回退到主库取决于版本和配置需仔细测试。4. 清晰的日志与排查手段为不同的数据源操作打上不同的日志标签或MDCMapped Diagnostic Context信息这在排查问题时至关重要。例如可以在AOP切面或dynamic-datasource的插拔点处将当前使用的数据源名称记录到日志中。这样当出现数据不一致问题时可以快速定位操作发生在哪个库上。5. 完整的测试多数据源应用的测试比单数据源更复杂。你需要单元测试确保每个Service方法上的DS注解正确可以通过Mock数据源上下文来验证。集成测试在测试环境中真实连接多个数据库进行读写操作测试验证数据是否落入了预期的库。事务测试重点测试跨方法、跨Service的事务场景下数据源切换是否符合预期特别是涉及回滚的场景。故障演练模拟某个数据库节点宕机观察应用的容错和降级行为是否如预期。6. 关于Docker部署在Docker容器中部署SpringBoot多数据源应用并无特殊之处。需要注意的是数据库的连接地址应使用服务名或外部IP而非localhost。确保容器的网络能够访问所有数据库实例。另外在Kubernetes环境中可以利用ConfigMap和Secret来管理不同环境的数据源配置。最后分享一个我个人的体会在多数据源项目中“约定大于配置”和“显式优于隐式”的原则非常重要。为数据源定义清晰、一致的命名规则如ds_order_master,ds_user_slave_1在代码中使用DS注解时明确指定数据源名称避免依赖默认值。这能极大减少团队协作中的 confusion 和潜在的运行时错误。多数据源增加了系统的复杂度但通过合理的架构选型、清晰的代码规范和完备的运维监控完全可以将其管理得井井有条为系统带来显著的性能和扩展性收益。