在实际企业级开发中,随着业务数据量的增长,读写分离、多库分表、冷热数据分离等需求越来越常见。本文基于SpringBoot 3 + JDK 17 + Druid + MyBatis-Plus,手把手带你实现一套优雅的动态多数据源方案,支持注解切换和代码切换两种方式,并兼容分表场景。
目录
一、背景与需求
二、依赖准备
三、YAML 配置文件
四、核心组件实现
4.1 动态数据源路由 DynamicDataSource
4.2 数据源配置类 DataSourceConfig
4.3 @SlaveDataSource 注解
4.4 切面 DataSourceAspect
⚠️ @Order(1) 的深意
只支持 @annotation 的局限
五、两种切换方式实战
5.1 注解方式:定时任务落库
5.2 代码方式:方法内动态切换
六、分表支持:TableNameContext
七、常见坑与最佳实践
坑 1:事务内切数据源无效
坑 2:异步/线程池中数据源丢失
坑 3:clearDataSource 遗漏
坑 4:Druid 监控页面重复冲突
坑 5:SpringBoot 3 + javax vs jakarta
八、方案总结
九、写在最后
一、背景与需求
我们的项目中有两个数据库:
| 数据源 | 数据库 | 用途 |
|---|---|---|
| master | cic | 主库,容器信息等核心业务数据 |
| slave | gps | 从库,存储 GPS 历史轨迹数据(按容器 ID 分表) |
面临的挑战:
同一业务方法内可能需要访问不同数据库:先查主库的容器信息,再查从库的轨迹数据。
定时任务需要切换数据源:批量落库时写入从库。
分表场景:从库中每个容器对应一张历史位置表,需要动态表名。
事务一致性:切面必须能在
@Transactional之前执行,否则数据源切换失效。
下面进入正题。
二、依赖准备
pom.xml关键依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.x</version> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <!-- Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- AOP --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- Druid --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-3-starter</artifactId> <version>1.2.20</version> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <!-- MySQL --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> </dependencies>⚠️注意:SpringBoot 3 必须使用
druid-spring-boot-3-starter,普通的druid-spring-boot-starter会出现兼容问题。
三、YAML 配置文件
spring: datasource: # 主数据源 master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://172.16.18.53:3306/cic?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: Qynj0531! type: com.alibaba.druid.pool.DruidDataSource druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false stat-view-servlet: enabled: true url-pattern: /druid/* allow: 127.0.0.1 login-username: admin login-password: admin123 # 从数据源 slave: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://172.16.18.53:3306/gps?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: Qynj0531! type: com.alibaba.druid.pool.DruidDataSource druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false stat-view-servlet: enabled: true url-pattern: /druid/* allow: 127.0.0.1 login-username: admin login-password: admin123四、核心组件实现
4.1 动态数据源路由DynamicDataSource
继承AbstractRoutingDataSource,通过ThreadLocal保存当前线程的数据源标识:
public class DynamicDataSource extends AbstractRoutingDataSource { public static final String MASTER = "master"; public static final String SLAVE = "slave"; private static final ThreadLocal<String> contextHolder = new ThreadLocal<>(); public static void setDataSource(String dataSource) { contextHolder.set(dataSource); } public static String getDataSource() { return contextHolder.get(); } public static void clearDataSource() { contextHolder.remove(); } @Override protected Object determineCurrentLookupKey() { return getDataSource(); } }核心原理:AbstractRoutingDataSource在每次获取连接时,会调用determineCurrentLookupKey()决定用哪个数据源。返回null时使用defaultTargetDataSource。
为什么用 ThreadLocal:保证多线程并发时不会互相干扰。但要注意父子线程传递和线程池复用两种场景(后面会讲)。
4.2 数据源配置类DataSourceConfig
@Configuration @MapperScan(basePackages = "com.cetcnav.mapper", sqlSessionTemplateRef = "sqlTemplate") public class DataSourceConfig { @Bean(name = "masterDataSource") @ConfigurationProperties("spring.datasource.master") public DataSource masterDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean(name = "slaveDataSource") @ConfigurationProperties("spring.datasource.slave") public DataSource slaveDataSource() { return DruidDataSourceBuilder.create().build(); } @Primary @Bean(name = "dynamicDataSource") public DataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("slaveDataSource") DataSource slaveDataSource) { DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setDefaultTargetDataSource(masterDataSource); Map<Object, Object> dataSourceMap = new HashMap<>(2); dataSourceMap.put(DynamicDataSource.MASTER, masterDataSource); dataSourceMap.put(DynamicDataSource.SLAVE, slaveDataSource); dynamicDataSource.setTargetDataSources(dataSourceMap); return dynamicDataSource; } @Bean public DataSourceTransactionManager transactionManager( @Qualifier("dynamicDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } @Bean public SqlSessionTemplate sqlTemplate( @Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } }几个关键点:
@Primary必须加:dynamicDataSource是最终暴露给 MyBatis 使用的数据源,否则注入时会报多 Bean 冲突。@ConfigurationProperties前缀要区分:master对应spring.datasource.master,自动装载 Druid 配置。setDefaultTargetDataSource:不显式指定数据源时,默认走主库。事务管理器绑定
dynamicDataSource:保证在事务中切换数据源也生效(虽然不推荐事务内切库,容易出现跨库一致性问题)。
4.3@SlaveDataSource注解
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface SlaveDataSource { }@Target同时支持方法和类,方便类级别默认走从库。@Inherited让子类也能继承父类的注解。
4.4 切面DataSourceAspect
@Slf4j @Aspect @Order(1) // 关键:必须早于事务切面 @Component public class DataSourceAspect { @Pointcut("@annotation(com.cetcnav.annotation.SlaveDataSource)") public void slaveDataSourcePointCut() {} @Around("slaveDataSourcePointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { MethodSignature signature = (MethodSignature) point.getSignature(); SlaveDataSource slaveDataSource = signature.getMethod().getAnnotation(SlaveDataSource.class); if (Objects.nonNull(slaveDataSource)) { DynamicDataSource.setDataSource(DynamicDataSource.SLAVE); log.debug("设置数据源为: {}", DynamicDataSource.SLAVE); } try { return point.proceed(); } finally { DynamicDataSource.clearDataSource(); } } }⚠️@Order(1)的深意
Spring 事务切面默认 Order 是Integer.MAX_VALUE。数据源切换切面必须比事务切面先执行,否则:
事务启动时已经通过
dynamicDataSource拿到了连接(此时还是默认的 master)。进入方法后再切 slave,事务里的连接依然是 master,切换失败。
所以@Order(1)或任何小于Integer.MAX_VALUE的值都可以,越小越先执行。
只支持@annotation的局限
当前 Pointcut 只匹配了方法级别的注解,如果你把@SlaveDataSource打在类上,切面不会生效。推荐改写为:
@Pointcut("@annotation(com.cetcnav.annotation.SlaveDataSource) " + "|| @within(com.cetcnav.annotation.SlaveDataSource)") public void slaveDataSourcePointCut() {}@within用于匹配类级别的注解。
五、两种切换方式实战
5.1 注解方式:定时任务落库
@Override @SlaveDataSource public void batchSaveCachedData() { Iterator<Map.Entry<Long, Queue<HistoryLocation>>> iterator = locationCache.locationCache.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<Long, Queue<HistoryLocation>> entry = iterator.next(); Queue<HistoryLocation> queue = entry.getValue(); if (queue == null || queue.isEmpty()) { iterator.remove(); continue; } processQueueUntilEmpty(entry.getKey(), queue, iterator); } }只要方法上打了@SlaveDataSource,整个方法的数据库操作都会走从库,代码零侵入。
5.2 代码方式:方法内动态切换
有时候一个方法里既有主库操作又有从库操作,注解就不够用了,需要代码手动切换:
@Override public ApiResponse<Object> getLocationHistory(LocationHistoryRequestDTo request) { try { // 1. 主库:查询容器信息 ContainerInfo con = containerInfoService.getByNo(request.getNo()); if (ObjectUtil.isEmpty(con)) { return ApiResponse.error(String.format("容器【%s】不存在", request.getNo())); } // 2. 确保从库中对应分表已存在 locationCache.ensureLocationTableExists(con.getId()); // 3. 切到从库 + 指定分表后缀 DynamicDataSource.setDataSource(DynamicDataSource.SLAVE); TableNameContext.setSuffix(String.valueOf(con.getId())); // 4. 组装分页参数 Page<HistoryLocation> pageParam; if (Boolean.TRUE.equals(request.getPageFlag())) { Integer pageNum = request.getPage() != null ? request.getPage() : 1; Integer pageSize = request.getSize() != null ? request.getSize() : 10; pageParam = new Page<>(pageNum, pageSize); } else { Integer pageNum = request.getPage() != null ? request.getPage() : 1; pageParam = new Page<>(pageNum, -1); // size = -1 表示不分页 } // 5. 从库查询 IPage<HistoryLocation> data = locationMapper.selectHistoryLocationPage(pageParam, request); return ApiResponse.success(data); } catch (Exception e) { log.error("查询历史位置失败:no={}, error={}", request.getNo(), e.getMessage(), e); return ApiResponse.error("查询失败:" + e.getMessage()); } finally { // 6. 必须清理,防止线程复用污染 DynamicDataSource.clearDataSource(); TableNameContext.clear(); } }关键点:
主库操作必须在
setDataSource之前完成,否则容器信息会去从库查。finally块中一定要clearDataSource(),否则线程池复用时下一个请求可能还在用从库。TableNameContext是分表上下文(类似DynamicDataSource的思路,用 ThreadLocal 保存表名后缀)。
六、分表支持:TableNameContext
动态表名可以通过MyBatis-Plus 的DynamicTableNameInnerInterceptor实现:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); DynamicTableNameInnerInterceptor tableInterceptor = new DynamicTableNameInnerInterceptor(); tableInterceptor.setTableNameHandler((sql, tableName) -> { String suffix = TableNameContext.getSuffix(); // 只对历史位置表追加后缀,其它表保持原样 if ("history_location".equals(tableName) && suffix != null) { return tableName + "_" + suffix; } return tableName; }); interceptor.addInnerInterceptor(tableInterceptor); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }TableNameContext与DynamicDataSource类似:
public class TableNameContext { private static final ThreadLocal<String> SUFFIX = new ThreadLocal<>(); public static void setSuffix(String suffix) { SUFFIX.set(suffix); } public static String getSuffix() { return SUFFIX.get(); } public static void clear() { SUFFIX.remove(); } }这样,history_location表在 SQL 执行时会被自动改写为history_location_{containerId},业务代码无需关心物理表名。
七、常见坑与最佳实践
坑 1:事务内切数据源无效
@Transactional public void doSomething() { // 事务已经开启,绑定的是 master 连接 DynamicDataSource.setDataSource("slave"); // 这里的 SQL 依然走 master! }原因:Spring 事务会在方法执行前绑定 Connection,中途切换RoutingDataSource不会重新获取连接。
建议:不要把跨库操作放在同一个事务里。如果一定要,考虑 Atomikos / Seata 等分布式事务方案。
坑 2:异步/线程池中数据源丢失
DynamicDataSource.setDataSource("slave"); CompletableFuture.runAsync(() -> { // 这里 ThreadLocal 拿不到,回落到默认 master locationMapper.selectXxx(); });解决:使用TransmittableThreadLocal(阿里 TTL)替代ThreadLocal,或者在线程池任务开始处手动setDataSource。
坑 3:clearDataSource遗漏
try { DynamicDataSource.setDataSource("slave"); // 业务代码 } catch (Exception e) { // 忘记 finally clear }Tomcat 线程池会复用线程,下一个请求可能在不知不觉中走了从库。必须放在finally。
坑 4:Druid 监控页面重复冲突
两个数据源如果都启用了stat-view-servlet,会报url-pattern冲突。推荐只在一个数据源上启用,或显式配置不同的url-pattern。
坑 5:SpringBoot 3 +javaxvsjakarta
升级到 SpringBoot 3 后,切面、事务等自动配置的包名全部切到jakarta.*,Druid 需要选druid-spring-boot-3-starter,MyBatis-Plus 需要选mybatis-plus-spring-boot3-starter。
八、方案总结
| 特性 | 支持情况 |
|---|---|
| 注解切换数据源 | ✅@SlaveDataSource |
| 代码切换数据源 | ✅DynamicDataSource.setDataSource() |
| 分库 | ✅ master / slave 双库 |
| 分表 | ✅DynamicTableNameInnerInterceptor |
| 事务兼容 | ⚠️ 需注意切面 Order 与跨库事务 |
| 异步线程支持 | ⚠️ 需 TTL 或手动传递 |
| Druid 监控 | ✅/druid/* |
整体架构图:
┌────────────────────────────────────────────────┐ │ Business Service │ │ @SlaveDataSource / setDataSource("slave") │ └───────────────────┬────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────┐ │ DataSourceAspect (@Order(1)) │ │ ThreadLocal<DataSource> │ └───────────────────┬────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────┐ │ DynamicDataSource (AbstractRoutingDataSource)│ │ ┌────────────┐ ┌────────────┐ │ │ │ master │ │ slave │ │ │ │ DruidPool │ │ DruidPool │ │ │ └────────────┘ └────────────┘ │ └───────────────────┬────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────┐ │ MyBatis-Plus DynamicTableNameInterceptor │ │ history_location → history_location_{cid} │ └────────────────────────────────────────────────┘九、写在最后
动态多数据源看似简单,其实细节非常多:切面顺序、ThreadLocal 清理、事务边界、异步传递、分表联动,任何一环出错都可能产生线上事故。
本文的方案是经过实际项目验证的,覆盖了读写分离、定时任务切换、分表查询等典型场景。如果你的项目也有类似需求,可以直接参考落地。
如果这篇文章帮你避开了几个坑,欢迎点赞 + 收藏 + 关注,后续我会继续分享 Seata 分布式事务、ShardingSphere 分库分表等进阶内容。
📌 本文代码基于SpringBoot 3.2 + JDK 17 + Druid 1.2.20 + MyBatis-Plus 3.5.5,不同版本可能存在细节差异,请以实际项目为准。