news 2026/9/25 20:29:39

SpringBoot3 + JDK17 + Druid 动态多数据源实战:从踩坑到生产级优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot3 + JDK17 + Druid 动态多数据源实战:从踩坑到生产级优化

在实际企业级开发中,随着业务数据量的增长,读写分离、多库分表、冷热数据分离等需求越来越常见。本文基于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

八、方案总结

九、写在最后


一、背景与需求

我们的项目中有两个数据库:

数据源数据库用途
mastercic主库,容器信息等核心业务数据
slavegps从库,存储 GPS 历史轨迹数据(按容器 ID 分表)

面临的挑战:

  1. 同一业务方法内可能需要访问不同数据库:先查主库的容器信息,再查从库的轨迹数据。

  2. 定时任务需要切换数据源:批量落库时写入从库。

  3. 分表场景:从库中每个容器对应一张历史位置表,需要动态表名。

  4. 事务一致性:切面必须能在@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。数据源切换切面必须比事务切面先执行,否则:

  1. 事务启动时已经通过dynamicDataSource拿到了连接(此时还是默认的 master)。

  2. 进入方法后再切 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(); } }

关键点:

  1. 主库操作必须在setDataSource之前完成,否则容器信息会去从库查。

  2. finally块中一定要clearDataSource(),否则线程池复用时下一个请求可能还在用从库。

  3. 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,不同版本可能存在细节差异,请以实际项目为准。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 20:28:55

fault bad_address

bad_address() 是 Linux 内核中一个用于安全探测内核地址是否可读的辅助函数。它的核心作用是&#xff1a;在不触发内核崩溃&#xff08;Oops&#xff09;的前提下&#xff0c;检查一个给定的内核地址是否有效可读。核心机制&#xff1a;get_kernel_nofaultget_kernel_nofault(…

作者头像 李华
网站建设 2026/9/25 20:23:53

【面试题】AI相关测试面试题

1. LLM-as-a-Judge 怎么设计核心思路&#xff1a;把Judge当成一个打分模型&#xff0c;固定输入结构、明确评分维度、定义打分规则、增加校验防幻觉&#xff0c;不要让大模型自由发挥。整体结构 输入模板&#xff08;4部分&#xff09; 任务描述&#xff1a;告诉Judge它是什么角…

作者头像 李华
网站建设 2026/9/25 20:22:18

Java医院信息管理系统源码解析:HIS核心模块与二次开发实战

简介&#xff1a;这是一套基于SpringBoot、Jpa与Thymeleaf构建的Java医院信息管理系统源码&#xff0c;面向中小型医疗机构信息化建设需求&#xff0c;也适合Java学习者深入理解企业级项目开发。系统整合患者管理、医生排班、药品库存、财务管理、预约挂号、住院管理、报告管理…

作者头像 李华
网站建设 2026/9/25 20:16:54

AI 就是要取代薪酬高的岗位,程序员们怎么办?

到了2023年春天的时候, 像是GPT-4这样的人工智能技术再次在技术领域里面引爆了热议。现在的状况是, 几乎每一天都会有跟人工智或者GPT有关系的技术动态与新闻报道出来。而这些内容不断地引发着大众的关注以及讨论的声音。比如说原画师还有前端开发这样的工作岗位方面, 已经出现…

作者头像 李华
网站建设 2026/9/25 20:15:54

小红书客服系统:20核并发不抢焦,单机跑通百店零报错

小红书客服系统&#xff1a;20核并发不抢焦&#xff0c;单机跑通百店零报错 店群运营的本质不是开多少店&#xff0c;而是单店运营成本能不能压到零。小红书的自动回复与客服&#xff0c;是店群运营中最耗人力也最容易出错的环节。 店群客服是纯人力消耗战。一个店日均50条咨询…

作者头像 李华