Spring Boot多数据源配置实战:基于AbstractRoutingDataSource的优雅实现
1. 项目概述:为什么需要连接多个数据库?
在真实的业务开发里,单数据库架构往往撑不了多久。我经历过不少项目,初期为了图快,所有业务表都堆在一个库里。随着业务扩张,问题就来了:用户中心的读写压力把订单库拖慢;运营后台的复杂报表查询影响了核心交易;甚至因为一个非核心功能的慢SQL,导致整个应用响应延迟。这时候,把不同业务的数据拆分到独立的数据库,就成了必然选择。
Spring Boot实现多数据源连接,核心目标就是“优雅”。这里的优雅,不是指代码看起来多花哨,而是指:配置清晰、隔离彻底、切换丝滑、维护简单。你不能让订单查询一不小心跑到了日志库,也不能因为加个新数据源就得把老代码翻个底朝天。这背后涉及Spring框架对DataSource、事务管理、ORM框架(如MyBatis、JPA)的深度整合。接下来,我会拆解从设计思路到避坑实操的全过程,这套方案经过多个生产环境验证,你可以直接拿去用。
2. 核心设计思路与方案选型
面对多数据源,通常有几种路子:最原始的是手动获取DataSource,高级点用Spring的AbstractRoutingDataSource做动态路由,再或者上ShardingSphere这类中间件。对于大多数业务清晰、数据源数量固定(比如2-5个)的场景,我推荐基于AbstractRoutingDataSource配合显式注解的方案。它足够轻量,侵入性低,并且能与Spring的事务管理完美结合。
为什么选这个方案?首先,它符合Spring的“约定大于配置”哲学。我们通过自定义注解标记方法或类,框架在运行时根据注解值自动切换到对应的数据源。其次,它与@Transactional事务注解可以协同工作(这里有个大坑,后面细说),保证了业务逻辑的原子性。最后,它的性能开销极小,就是一次ThreadLocal的查找,远比代理或拦截整个SQL解析要高效。
关键设计考量点:
- 数据源隔离:每个数据源必须有自己独立的连接池配置(如HikariCP)、事务管理器、SQL会话工厂(如
SqlSessionFactory)。绝不能混用,这是数据混乱的根源。 - 路由粒度:我们控制在方法级别。通过自定义
@DS(“数据源名”)注解,可以精确控制每个DAO方法使用哪个库。比在类级别配置更灵活。 - 默认数据源:必须指定一个默认数据源。当方法没有标注
@DS,或者某些框架内部初始化逻辑时,会使用它,避免空指针异常。 - 事务集成:这是难点。Spring的事务管理通常绑定一个固定的
DataSource。我们需要定制一个支持多数据源的事务管理器,确保在带有@Transactional的方法中,即使内部调用了多个不同@DS标注的方法,事务也能正确管理(通常需要将事务传播行为设置为REQUIRES_NEW或另做处理)。
3. 详细配置与核心组件实现
下面我们一步步实现。假设我们有两个数据库:primary(主业务库)和secondary(日志/报表库)。
3.1 数据源与连接池配置
首先在application.yml中配置两个数据源的连接信息。这里以HikariCP为例,它是Spring Boot 2.x后的默认连接池,性能很好。
spring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/primary_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 secondary: jdbc-url: jdbc:mysql://localhost:3307/secondary_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 # 报表库可以配置小一点的连接池 minimum-idle: 2注意:这里用的是
jdbc-url,不是url。因为Spring Boot的自动配置可能对url有特殊处理,直接使用jdbc-url可以避免一些诡异的配置冲突问题。
接着,在Java配置类中,将这些配置绑定到具体的DataSourceBean上。
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { // Spring Boot会自动根据配置创建HikariDataSource return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties(prefix = "spring.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } }3.2 动态数据源路由核心
这是实现优雅切换的核心。我们继承AbstractRoutingDataSource,并重写determineCurrentLookupKey方法。它的作用就是:当需要获取数据库连接时,告诉我该用哪个数据源的key。
public class DynamicDataSource extends AbstractRoutingDataSource { /** * 使用ThreadLocal来保存当前线程使用的数据源键值。 * 为什么用ThreadLocal?因为Web请求通常是线程隔离的,这保证了每个请求的数据源上下文不会互相干扰。 */ private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); /** * 设置当前线程的数据源 */ public static void setDataSourceKey(String dataSourceKey) { CONTEXT_HOLDER.set(dataSourceKey); } /** * 获取当前线程的数据源 */ public static String getDataSourceKey() { return CONTEXT_HOLDER.get(); } /** * 清除当前线程的数据源,防止内存泄漏。务必在请求处理完成后清理! */ public static void clearDataSourceKey() { CONTEXT_HOLDER.remove(); } @Override protected Object determineCurrentLookupKey() { // 这个方法会被Spring在获取连接时调用 return getDataSourceKey(); } }然后,我们需要一个配置类,将多个真实的DataSource注入到DynamicDataSource中,并指定默认数据源。
@Configuration @EnableTransactionManagement // 启用事务管理 public class DynamicDataSourceConfig { @Bean public DataSource dynamicDataSource( @Qualifier("primaryDataSource") DataSource primaryDataSource, @Qualifier("secondaryDataSource") DataSource secondaryDataSource) { Map<Object, Object> targetDataSources = new HashMap<>(2); targetDataSources.put("primary", primaryDataSource); targetDataSources.put("secondary", secondaryDataSource); DynamicDataSource dataSource = new DynamicDataSource(); // 设置所有目标数据源 dataSource.setTargetDataSources(targetDataSources); // 设置默认数据源 dataSource.setDefaultTargetDataSource(primaryDataSource); // 初始化 dataSource.afterPropertiesSet(); return dataSource; } }3.3 自定义注解与切面编程
为了让使用变得简单,我们定义一个@DS注解。
@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DS { String value() default "primary"; // 默认为主数据源 }最关键的一步:创建一个切面(AOP),在方法执行前,根据@DS注解的值,动态设置数据源键。
@Aspect @Component @Order(-1) // 确保该切面在事务切面之前执行,非常重要! @Slf4j public class DynamicDataSourceAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint point, DS ds) throws Throwable { String dataSourceKey = ds.value(); // 如果当前已经设置了数据源,且不是默认的,这里可以加日志或做其他处理 if (DynamicDataSource.getDataSourceKey() != null) { log.debug("当前线程已存在数据源 [{}],将被覆盖为 [{}]", DynamicDataSource.getDataSourceKey(), dataSourceKey); } // 设置数据源 DynamicDataSource.setDataSourceKey(dataSourceKey); log.debug("设置数据源为: {}", dataSourceKey); try { // 执行原方法 return point.proceed(); } finally { // 方法执行完毕后,清理当前线程的数据源键 DynamicDataSource.clearDataSourceKey(); log.debug("清理数据源"); } } }核心要点:
@Order(-1)至关重要。Spring的事务管理也是通过AOP实现的(@Transactional)。我们必须确保数据源切换的切面在事务切面之前执行。因为事务管理器需要在获取连接之前就知道用哪个数据源。如果顺序反了,事务管理器会拿到错误(或默认)的数据源连接,导致数据错乱。
3.4 MyBatis集成配置
如果你用MyBatis,需要为每个数据源配置独立的SqlSessionFactory和MapperScannerConfigurer。这里有个技巧:将dynamicDataSource作为唯一的数据源注入,但通过配置让MyBatis的Mapper接口与特定的SqlSessionFactory绑定。
@Configuration @MapperScan(basePackages = "com.yourpackage.mapper.primary", sqlSessionFactoryRef = "primarySqlSessionFactory") public class PrimaryMyBatisConfig { @Bean public SqlSessionFactory primarySqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dynamicDataSource) throws Exception { SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); sessionFactory.setDataSource(dynamicDataSource); // 设置primary库专用的mapper.xml路径 sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/primary/*.xml")); // 其他配置,如类型别名、插件等 return sessionFactory.getObject(); } } @Configuration @MapperScan(basePackages = "com.yourpackage.mapper.secondary", sqlSessionFactoryRef = "secondarySqlSessionFactory") public class SecondaryMyBatisConfig { @Bean public SqlSessionFactory secondarySqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dynamicDataSource) throws Exception { SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); sessionFactory.setDataSource(dynamicDataSource); sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/secondary/*.xml")); return sessionFactory.getObject(); } }这样,com.yourpackage.mapper.primary下的Mapper会自动使用primarySqlSessionFactory,而这个工厂虽然也用了dynamicDataSource,但通过@DS注解在运行时切换,最终会路由到正确的物理库。这种设计实现了配置上的解耦。
3.5 多数据源事务管理(高级话题)
这是多数据源中最复杂的一环。Spring的@Transactional默认只管理一个事务管理器。如果我们有多个数据源,就需要多个PlatformTransactionManager。但一个方法上只能指定一个@Transactional。
方案一:链式事务管理器(ChainedTransactionManager)这是一种折中方案,它按照定义的顺序管理多个事务管理器。提交时按正序,回滚时按倒序。但它不是分布式事务(如XA),不能保证绝对的强一致性,属于“尽力而为”型。适用于对一致性要求不是100%严苛的跨库操作。
@Bean public PlatformTransactionManager transactionManager( @Qualifier("primaryDataSource") DataSource primaryDataSource, @Qualifier("secondaryDataSource") DataSource secondaryDataSource) { // 为每个数据源创建独立的事务管理器 DataSourceTransactionManager primaryTM = new DataSourceTransactionManager(primaryDataSource); DataSourceTransactionManager secondaryTM = new DataSourceTransactionManager(secondaryDataSource); // 使用链式事务管理器 return new ChainedTransactionManager(primaryTM, secondaryTM); }使用此方案时,一个事务方法内操作多个库,如果第二个库操作失败,第一个库的操作不会回滚。你需要根据业务容忍度来决定是否采用。
方案二:使用@Transactional+ 手动@DS切换,并配置事务传播行为更常见的做法是,将跨库操作拆分成多个方法,每个方法用@DS指定数据源,并利用@Transactional(propagation = Propagation.REQUIRES_NEW)为每个方法创建独立的新事务。这样,每个库的事务是独立的,一个失败不影响另一个。这要求业务上能接受这种中间状态。
@Service public class OrderService { @Transactional public void createOrder(Order order, Log log) { // 主库操作 saveOrder(order); try { // 新开一个事务操作日志库,即使失败,主库订单已提交 saveLogInNewTransaction(log); } catch (Exception e) { // 记录日志异常,但不影响主业务 log.error("记录操作日志失败", e); } } @DS("secondary") @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveLogInNewTransaction(Log log) { logMapper.insert(log); } @DS("primary") @Transactional(propagation = Propagation.REQUIRED) public void saveOrder(Order order) { orderMapper.insert(order); } }4. 使用示例与最佳实践
配置好后,使用就非常直观了。
@Repository public interface UserMapper { // 默认使用primary数据源(在类上标注,或使用默认值) @DS("primary") User selectById(Long id); } @Repository @DS("secondary") // 这个类下所有方法默认使用secondary库 public interface OperationLogMapper { int insert(OperationLog log); @DS("primary") // 这个方法可以单独覆盖,使用primary库 User selectUserForLog(Long userId); } @Service public class BusinessService { @Autowired private UserMapper userMapper; @Autowired private OperationLogMapper logMapper; public void doBusiness(Long userId) { // 这个方法会使用primary库 User user = userMapper.selectById(userId); // 这个方法会使用secondary库(类级别注解生效) logMapper.insert(new OperationLog(...)); // 这个方法会使用primary库(方法注解覆盖了类注解) User user2 = logMapper.selectUserForLog(userId); } }最佳实践与注意事项:
- 明确数据源职责:规划好每个数据源的用途,比如
primary用于核心交易,secondary用于日志查询,third用于风控。并在团队内形成规范,避免随意使用。 - 避免在事务中循环切换:在一个
@Transactional方法内部,尽量避免多次调用不同@DS注解的方法。这可能导致连接持有混乱,甚至死锁。如果必须,请仔细设计事务传播行为。 - 清理ThreadLocal:我们的
DynamicDataSourceAspect中使用了finally块进行清理,这很重要。如果是在Filter或Interceptor中设置数据源,更要确保异常情况下也能清理,否则会导致内存泄漏和后续请求数据源错乱。 - 测试要充分:多数据源下,务必进行单元测试和集成测试,验证数据是否真的写入了预期的库。可以写个测试,插入数据后,分别从两个库查询确认。
- 监控连接池:为每个数据源的连接池配置合理的参数(如
maximumPoolSize),并监控活跃连接数、空闲连接数等指标。不同压力的库,连接池大小应区别设置。
5. 常见问题排查与性能调优
在实际使用中,你肯定会遇到下面这些问题。
5.1 数据源切换失效,总是走到默认库
可能原因及排查步骤:
- AOP顺序问题:这是最常见的原因。确保你的
DynamicDataSourceAspect切面使用了@Order(-1)或一个较小的值,并且生效了。可以在切面方法开始和结束处打日志确认。 - 注解未生效:检查
@DS注解是否被正确扫描。确保切面的@Around表达式能匹配到你的方法。对于Spring AOP,要关注方法是否是public的(因为默认只代理public方法),是否在同一个Bean内部调用(内部调用不走代理)。 - ThreadLocal污染:在异步任务(如
@Async)或子线程中,ThreadLocal值不会自动传递。你需要手动传递数据源键,并在新线程中设置。可以考虑使用TransmittableThreadLocal(阿里开源)替代ThreadLocal来解决异步上下文传递问题。
5.2 事务与数据源切换的冲突
现象:在@Transactional方法中,@DS注解似乎没起作用,所有SQL都跑到了默认数据源。
根因:Spring事务的代理逻辑在目标方法执行前就获取了数据库连接。如果数据源切换的AOP在事务AOP之后执行,那么事务管理器获取连接时,ThreadLocal里还没有数据源键,自然就用默认数据源了。
解决方案:
- 确保切面顺序,如前所述,数据源切面 (
@Order(-1)) 必须在事务切面之前。 - 如果问题依旧,可以尝试将事务管理器也换成我们自定义的、支持动态查找数据源的那种。但更简单的做法是,将
@DS注解加到Service类或方法上,而不是Dao/Mapper上。因为事务通常在Service层开启,在进入Service方法前就确定数据源,能保证事务内连接一致。
5.3 性能问题:连接池配置不当
多数据源意味着多个连接池。配置不当很容易拖慢应用。
调优建议:
| 参数 | 建议 | 说明 |
|---|---|---|
maximumPoolSize | 核心库:10-20,非核心库:5-10 | 不是越大越好。计算公式可参考:连接数 = ((核心数 * 2) + 有效磁盘数)。监控实际使用峰值。 |
minimumIdle | 设置为maximumPoolSize的1/4到1/2 | 保持一定空闲连接,避免突发请求时新建连接的开销。 |
connectionTimeout | 30000 (30秒) | 获取连接的超时时间,不宜过短。 |
idleTimeout | 600000 (10分钟) | 空闲连接存活时间,超时后释放。 |
maxLifetime | 1800000 (30分钟) | 连接最大生命周期,强制刷新,避免数据库端连接僵死。 |
监控:启用HikariCP的JMX监控,或通过/actuator/metrics/hikaricp.connections.*端点(如果集成了Spring Boot Actuator)查看连接池状态。
5.4 在Spring Boot 2.7/3.x中与自动配置的冲突
Spring Boot的自动配置非常强大,但有时会“多管闲事”。比如,它可能会因为你定义了多个DataSourceBean而自动尝试配置一个DataSourceTransactionManager,这可能会干扰我们的动态数据源。
解决方法: 在启动类或主配置类上,排除特定的自动配置。
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, // 排除数据源自动配置 DataSourceTransactionManagerAutoConfiguration.class, // 排除事务管理器自动配置 JdbcTemplateAutoConfiguration.class // 如果需要,也排除JdbcTemplate自动配置 }) public class YourApplication { public static void main(String[] args) { SpringApplication.run(YourApplication.class, args); } }然后,完全由我们自己的配置类(如DynamicDataSourceConfig)来掌控DataSource和TransactionManager的创建。这样能获得最清晰的控制权。
5.5 与第三方Starter的兼容性
比如,你使用了knife4j-spring-boot-starter做接口文档,或者spring-boot-starter-data-redis。这些starter有时也会初始化数据源相关的东西。如果遇到奇怪的问题,可以查看它们的自动配置类,必要时进行排除。
一个更通用的原则是:对于多数据源这种比较底层的、需要精细控制的配置,尽量采用“全手动”模式,即排除相关自动配置,自己显式定义所有Bean。虽然配置量稍大,但避免了无数潜在的、难以调试的隐性冲突。
这套基于AbstractRoutingDataSource和自定义注解的方案,在我经历过的多个中大型项目中都稳定运行。它提供了足够的灵活性和清晰度。关键在于理解其原理——ThreadLocal上下文传递和AOP拦截顺序,并做好事务边界的设计。开始时可能会踩一些坑,但一旦跑通,后续扩展新的数据源(比如加一个tertiary)就会非常轻松,只需要在配置里加一组数据源定义,再增加对应的@DS(“tertiary”)注解即可。
