简介:针对Spring Boot项目在多数据源场景下的实际需求,这份示例代码演示了如何利用dynamic-datasource框架对MySQL与SQLServer进行手动切换,适合需要整合异构数据库的中级Spring Boot开发者参考。压缩包共23个文件,其中17个Java源文件覆盖核心配置、切换逻辑与业务示例,2个XML文件用于Mapper映射或扩展配置,1个YML文件集中管理数据源与框架参数,另有Git忽略文件与项目说明文档,整体体积约20KB,结构紧凑,便于逐文件研读。资源目前已有552人浏览学习,实战参考价值得到初步验证。通过阅读代码,可以掌握动态数据源的核心注解与编程式切换方式,理解多数据源下事务边界和连接管理的常见处理思路;示例中的业务场景贴近日常开发,稍作调整即可迁移到自己的项目中,有效减少从零搭建与排查切换异常的成本。
1. 多数据源切换的痛点与dynamic-datasource的选型思路
前年做报表聚合服务,业务数据在MySQL,历史归档在SQLServer,最初代码里塞了两个DataSource,每次查询前用if判断请求来自哪个租户。三个月后这个判断逻辑散落得没法维护,事务一开启就不知道连接从哪个池子里拿出来的。后来用dynamic-datasource把多数据源管理收敛掉,服务里所有数据访问都改成了按key路由。它的本质是Spring的AbstractRoutingDataSource扩展,运行时通过ThreadLocal记录当前数据源key,然后在真正请求连接时切换到对应连接的池。最关键的一点是支持运行期添加和删除数据源,这意味着新接入一个客户库不用重启应用。下面把这套方案的配置、手动切换、动态维护和典型坑位说清楚,适合正在使用SpringBoot整合多数据源,或者准备从单数据源迁移到多数据源的开发人员参照。
2. SpringBoot整合dynamic-datasource:pom依赖与数据源配置
2.1 依赖引入与版本选择
dynamic-datasource在Maven中央仓库的实际artifactId为dynamic-datasource-spring-boot-starter,配合SpringBoot使用时,需要根据当前SpringBoot版本选择对应主版本。SpringBoot 2.x项目用3.x系列,SpringBoot 3.x项目要用4.x系列,这个我一般会去中央仓库确认最新的release版本,然后显式写在pom里。
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.2</version> </dependency>这个3.5.2仅代表我自己验证时使用的版本,如果你是新建项目,建议替换成Maven仓库里的最新稳定版。除了starter本身,pom中还需要引入直接对接的两个数据库驱动,mysql库用mysql-connector-j,sqlserver库用mssql-jdbc,两者都是运行时使用,scope用默认的compile即可。
为什么不在这个starter里直接依赖驱动?因为不同数据库的驱动版本差异很大,特别是sqlserver的驱动,不同版本支持的TLS、集成登录行为都不一样,把驱动交给应用自己声明,实际上是为了保留灵活性。如果你的项目里同时驱动了MySQL和SQLServer,这个starter并不会自动帮你做类型映射,后面配置驱动类时仍然要写全限定类名。
2.2 多数据源在application.yml中的配置
我习惯把数据源配置统一放在spring.datasource.dynamic节点下,动态数据源会自动读取这个节点的datasource映射。下面是一个同时连接MySQL和SQLServer的完整示例配置。
spring: datasource: dynamic: primary: mysql strict: true datasource: mysql: url: jdbc:mysql://localhost:3306/business?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 sqlserver: url: jdbc:sqlserver://localhost:1433;DatabaseName=history username: sa password: Sa123456 driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver hikari: maximum-pool-size: 5这里有两个关键配置项,primary和strict。primary代表在没有使用任何切换注解时的默认数据源key,代码里所有不带@DS的DAO都会落到这个数据源上;strict是严格模式,当切换到一个不存在的key时,严格模式会直接在启动或调用时抛出异常,而false会静默回退到primary数据源。线上系统我建议开启strict,否则一个拼错的key会让请求打到业务库,查不到数据还不好排查。数据源内部每个库的hikari参数需要单独配置,避免一个库的连接被占满之后拖垮另一个库。
配置完成后,Spring容器中会出现一个DynamicRoutingDataSource实例,它内部持有两个真实的HikariDataSource。
2.3 数据源配置的加载顺序与边界
dynamic-datasource的加载顺序是先解析primary和strict,再逐个创建datasource下的每个子项。所以primary指定的key必须存在于datasource集合中,否则会启动失败。如果你把某个数据源配置到默认分组下面,比如用group参数配置主从组,那么primary的值应该指向组名而不是具体的库key。
下面这张表整理了我在配置时关注的几个参数:
| 配置项 | 作用 | 是否必填 | 常见误用 |
|---|---|---|---|
| spring.datasource.dynamic.primary | 默认数据源key | 是 | 写成数据库ip或url |
| spring.datasource.dynamic.strict | 严格模式下找不到数据源时抛异常 | 否 | 误设为false导致切错库不报错 |
| spring.datasource.dynamic.datasource | 具体数据源key与连接配置映射 | 是 | 两个数据源key使用相同的pool-name |
| spring.datasource.dynamic.p6spy | 开启SQL日志输出 | 否 | 生产环境开启造成日志量暴增 |
数据源key是后续手动切换使用的唯一标识,建议用业务含义而不是库地址来命名,比如report、history、master,这样代码里读起来像在描述用途,而不是暴露连接细节。另外一个边界是:每个数据源的连接池参数虽然独立,但dynamic-datasource默认会复制primary数据源的连接池参数作为未配置项的兜底,所以如果你在primary下设置了hikari参数,而sqlserver没有设置,sqlserver会继承primary的配置,这一点容易被人忽视。
3. 手动切换的两种落地方式:@DS注解与编程式路由
3.1 @DS注解实现方法级切换
最常用的手动切换方式是直接在Service方法上加@DS注解,这也是dynamic-datasource对外提供的主要入口。我把一个报表里的两个查询分别指向MySQL和SQLServer。
@Service public class ReportService { private final JdbcTemplate jdbcTemplate; public ReportService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @DS("mysql") public int countOrder() { return jdbcTemplate.queryForObject("select count(*) from order_info", Integer.class); } @DS("sqlserver") public int countHistory() { return jdbcTemplate.queryForObject("select count(*) from history_data", Integer.class); } }@DS的value就是数据源配置中的key,可以是字符串、SpEL表达式,也可以为空。为空时,如果类上配置了@DS,则使用类上的值;如果类上也没有,则回到primary。实际项目里我通常会在类级别标一个主用数据源,再在个别跨库方法上覆盖。例如一个整体走mysql的查询服务,只有一个方法要读sqlserver的历史数据,就只在这个方法上写@DS("sqlserver")。这里需要注意,@DS是基于Spring AOP拦截的,如果同一个Service中一个方法调用另一个带@DS的方法,由于调用发生在代理内部,另一个方法上的@DS不会生效,必须把两个方法分别放到不同的bean里,或者使用依赖自身引用绕开代理限制。
3.2 编程式动态切换:DynamicRoutingDataSource
注解适合在编译期就能确定数据源的场景,但更多业务场景的数据源key来自请求参数或者运行期计算,这种时候我选择用DynamicDataSourceContextHolder手动推送和弹出数据源key。
@Service public class DynamicReportService { private final JdbcTemplate jdbcTemplate; public DynamicReportService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public int countByDataSourceKey(String dsKey) { DynamicDataSourceContextHolder.push(dsKey); try { return jdbcTemplate.queryForObject("select count(*) from business_data", Integer.class); } finally { DynamicDataSourceContextHolder.poll(); } } }这段代码的核心是push和poll必须成对出现。push会把数据源key压入一个ThreadLocal栈中,后续所有获取连接的操作都会从这个key对应的数据源拿连接;poll负责清栈,如果不调用,当前线程处理完请求后key仍然残留在ThreadLocal中,下一次该线程被线程池复用时,就会错误地沿用上一个请求的数据源。jdbcTemplate本身并不知道数据源切换逻辑,它只是在获取连接时通过DataSourceUtils走到了DynamicRoutingDataSource的路由方法,而路由方法读取的正是这个ThreadLocal。传入push的dsKey必须和yaml中的数据源key完全一致,大小写敏感。
3.3 切换的粒度与事务边界
切换粒度要控制在事务边界之内,这条经验是我在实际项目里付出过代价才记住的。@DS注解和@Transactional注解同时出现时,如果事务管理器先拦截,事务创建时就已经把连接池绑定到了当前线程,后续再切换数据源就无法改变事务绑定的连接了。
// 反例:方法进入事务后,@DS已经无法让事务内连接切换到sqlserver @Transactional @DS("sqlserver") public void badWrite() { jdbcTemplate.update("insert ..."); }默认情况下,dynamic-datasource的切面order会比事务切面低,也就是说@DS会先于事务执行,所以上面的写法通常能正常工作。风险在于如果你在项目中自定义了事务切面的order,让事务抢在数据源切换之前执行,就会出现切换失效。一个更稳的写法是避免在事务方法内部频繁切库,把需要事务的部分拆成单独方法,例如先写MySQL主库,再在另一个无事务方法里切到SQLServer写入,或者直接使用动态数据源提供的@DSTransactional来协调多数据源事务。
下面这张表对比了两种切换方式在实际维护中的差异:
| 切换方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| @DS注解 | 编译期可知的数据源,读写分离 | 代码简洁,无ThreadLocal泄漏风险 | key写死在注解上,扩展性差 |
| 编程式push/poll | 运行时根据参数决定数据源,租户隔离 | 灵活性高,支持动态计算 | 忘记poll会导致线程复用串数据源 |
| 动态添加数据源+编程式 | 新接入客户库、配置中台 | 不重启应用,按需加载 | 数据源元数据需要持久化,管理复杂 |
4. 动态添加与删除数据源:从持久化配置到运行时更新
4.1 数据源信息的持久化设计
在写动态添加删除功能前,需要先把数据源配置放到底层存储中,否则重启一次应用,运行时添加的数据源就全部丢了。这里我采用最简单的方式,用一张单表保存每个数据源的连接元数据,应用启动时加载一次,运行时通过管理接口增删。
CREATE TABLE data_source_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ds_key VARCHAR(64) NOT NULL UNIQUE, url VARCHAR(500) NOT NULL, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL, driver_class VARCHAR(128) NOT NULL, status TINYINT DEFAULT 1, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT='运行时数据源配置表';这张表里ds_key是业务侧切换数据源时使用的唯一标识,status用于逻辑删除。密码字段除了连接数据库外,还用于动态添加数据源时创建连接池,生产环境至少要加密存储,解密逻辑放在独立模块里。driver_class要记录完整的驱动类名,因为动态创建数据源时无法像yaml那样自动推断驱动,尤其sqlserver的驱动类名在不同版本下会有变化。
4.2 动态添加数据源的实现
添加数据源分为两步:先根据配置创建DataSource对象,再把DataSource对象注册进DynamicRoutingDataSource。dynamic-datasource暴露了DefaultDataSourceCreator组件,专门用于把DataSourceProperty转换成真实的连接池实现。
@Component public class DataSourceManager { @Autowired private DataSource dataSource; @Autowired private DefaultDataSourceCreator dataSourceCreator; public void addDataSource(DataSourceConfigEntity entity) { DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource; if (routingDataSource.getDataSources().containsKey(entity.getDsKey())) { throw new IllegalArgumentException("数据源已存在: " + entity.getDsKey()); } DataSourceProperty property = new DataSourceProperty(); property.setPoolName(entity.getDsKey()); property.setUrl(entity.getUrl()); property.setUsername(entity.getUsername()); property.setPassword(entity.getPassword()); property.setDriverClassName(entity.getDriverClass()); DataSource realDataSource = dataSourceCreator.createDataSource(property); routingDataSource.addDataSource(entity.getDsKey(), realDataSource); } }DataSourceProperty的属性名和yaml中的数据源字段一一对应,createDataSource方法在底层根据当前classpath内的连接池类型自动选择HikariCP或Druid。这里poolName建议直接使用dsKey,这样在监控连接池时能直接对应到业务数据源。addDataSource只是把数据源放入内部ConcurrentHashMap,不会阻塞现有连接,所以这个接口可以同时处理多个数据源的注册。
4.3 动态删除数据源与连接回收
删除数据源比添加容易踩坑,因为只是remove掉map中的引用,连接池里的空闲连接并不会自动关闭,最终会变成不可回收的泄漏连接。我处理删除的固定步骤是先remove,再显式关闭真实连接池。
public void removeDataSource(String dsKey) { DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource; DataSource removed = routingDataSource.removeDataSource(dsKey); if (removed instanceof HikariDataSource) { HikariDataSource hikari = (HikariDataSource) removed; hikari.close(); } }上面的close会触发HikariCP的关闭逻辑,等待正在使用的连接归还后再释放整个连接池。如果此时池里还有活跃事务,HikariCP默认的close会中断这些连接。更稳妥的方案是不直接删,先把data_source_config表中的status置为0,然后延迟几分钟再执行remove和close,给正在执行的SQL一个自然结束的时间窗。注意删除数据源不会影响已经通过Druid或HikariCP拿到的连接,但新的连接请求会立即转到其他数据源。
在管理端做数据源操作时,还需要清楚几个API的底层行为:
| 数据源操作 | 底层行为 | 注意事项 |
|---|---|---|
| addDataSource | 放入路由表并创建连接池 | 需要校验key是否已存在 |
| removeDataSource | 从路由表移除并关闭连接池 | 防止活跃连接被强制关闭 |
| getDataSources | 返回当前所有数据源副本 | 用于查询,不能直接修改 |
4.4 运行时手动切换的服务示例
把动态添加和手动切换结合起来,可以实现一个典型的配置化切换场景。下面是一个根据请求头标注的租户代码实时切换数据源的接口示例。
@RestController @RequestMapping("/api") public class TenantController { private final DynamicReportService reportService; public TenantController(DynamicReportService reportService) { this.reportService = reportService; } @GetMapping("/count") public int count(@RequestHeader("X-DS-Key") String dsKey) { return reportService.countByDataSourceKey(dsKey); } }请求进来时X-DS-Key携带的是data_source_config表中已配置的ds_key,例如tenant_001,service层使用前面写过的push/poll逻辑完成切换。这样新加一个租户库时,只需要往配置表insert一条记录,然后调用一次addDataSource,不需要修改任何Java代码。动态添加的数据源key和yaml里配置的key混在一起,统一参与切换路由。
5. 切换失效排查与SQLServer特殊场景验证
5.1 排查点:spy、事务与连接池复用
切换不生效时,我通常按三个位置依次检查。第一个位置是事务边界,查看目标方法是否已经被Spring事务拦截,事务一旦开启,连接就固定到了第一次读取的数据源上。第二个位置是代理对象,如果方法调用发生于同类内部,或者方法不是public,AOP切面根本不会触发,@DS自然失效。第三个位置是连接池本身,如果从连接池拿到连接后再次获得同一个DataSource,实际上拿到的还是第一次路由的结果,这不是切换失败,而是线程或事务上下文没有清理干净。
一个快速验证方法是打开dynamic-datasource的spy日志,在配置里临时开启:
spring: datasource: dynamic: p6spy: true然后观察控制台输出的SQL连接URL,如果SQL对应的是mysql库但JDBC URL却指向sqlserver地址,就说明路由没有生效。
5.2 SQLServer与MySQL方言差异对切换的影响
动态数据源解决的是连接路由问题,不解决SQL方言问题。同一段SQL在MySQL和SQLServer上运行会有明显差异,比如MySQL的limit分页在SQLServer上直接报语法错误,SQLServer的top建议又是另一个写法。如果项目里两个库的表结构和查询字段都一致,你可以用一条JdbcTemplate的查询在两个库上执行,这没有任何问题。但只要SQL涉及分页、字符串拼接、日期格式化,就必须为不同库维护不同SQL,或者在代码里根据当前数据源key拼接语法。我的习惯是在每个多库业务方法里明确标注该方法的方言依赖,后续换库时能快速定位。
5.3 用Spring Boot Actuator验证数据源状态
最后给一个运行期验证思路。引入spring-boot-starter-actuator并暴露dynamic-datasource端点后,可以直接通过HTTP查看路由表里所有数据源的状态。
curl http://localhost:8080/actuator/dynamic-datasource返回结果会列出每个数据源key对应的连接池指标,包括活跃连接数、空闲连接数、挂起任务数等。要判断动态添加是否成功,可以在调用addDataSource接口后立刻访问这个端点,确认新key已经出现在Map中。对于sqlserver数据源,还需要额外检查连接是否使用了正确的jdbc url,因为很多项目会把DatabaseName和instanceName配错,导致动态添加时表面成功,第一次真正查询时才报连接失败。然后直接通过Actuator端点观察新数据源的活跃连接数变化即可完成验证。
本文还有配套的精品资源,点击获取