写这篇东西之前,我先把话说在前头:多数据源这件事,看起来就是配置两个DataSource的事,但真正能撑住复杂业务的路由方案,至少要理清楚三层问题——第一层是基础的静态配置怎么做;第二层是说到做到动态切换要依赖哪些Spring底层机制;第三层是动态路由之后,事务、连接池、拦截器这些“隐形炸弹”怎么排。这篇文章就把这条线从头到尾捋一遍,代码你拿过去改了库名就能跑。
1. 多数据源的现实场景:什么时候你需要这套方案
我见过不少同学跟我聊多数据源,第一反应就是“主从读写分离”。没错,这是最常见的场景——一个MySQL主库负责写,一个或多个从库负责读,业务代码里根据操作类型分去不同的库。但真实项目里的多数据源远不止这一种需求:
- 垂直分库后的组合查询:一个系统拆了订单库、支付库、用户库之后,服务层经常需要在一个方法里同时查两个库的数据。比如做对账单,先查订单表,再根据订单里的用户ID去用户表补信息,这俩库物理上是分开的,就得在一个业务方法里切换数据源。
- 多个业务域共用一套服务:SaaS系统特别典型。同一个SpringBoot服务,接入了多家客户的数据库,每个客户的库结构一样但数据隔离。请求进来的时候根据租户ID决定连哪个库,这不是主从关系,而是N个平级的业务库。
- 异构数据源并存:MySQL主要存业务数据,但报表、搜索类的需求可能要用Elasticsearch,老的遗留系统在Oracle里,新功能在PostgreSQL里。大部分项目是渐进式改造的,不可能一天把老库全部迁走,那服务里就得一个MySQL、一个PostgreSQL先并存,跑通了再慢慢收编。
- 和大数据组件对接:现在很多SpringBoot项目会接ClickHouse、StarRocks这类OLAP库做统计查询,平时业务走MySQL,跑统计报表的时候切到分析型数据库,这也需要多数据源能力。
还有一个特别现实的场景是数据迁移与核对——新老系统并行期间,同一个接口要同时读写两边库,然后做一致性校验。这种逻辑用单数据源根本写不出来。
所以你在简历上写“熟悉多数据源配置”,最好是真的在以上某个场景里踩过坑,因为面试官问到第三层必然会聊事务和路由的边界问题。本文就是从第一层写起,把所有细节摊开讲。
2. 最朴素的激活方式:多套SqlSessionFactory与基础切换
先从最直观的做法开始。SpringBoot面对单个数据源时会自动装配DataSource、SqlSessionFactory、SqlSessionTemplate,给你配好一个MyBatis的完整链路。但一旦你自己声明了超过一个DataSource,自动配置就会失效,因为容器里出现了多个候选者,Spring也犯难——到底该拿哪一个去生成SqlSessionFactory?
所以的基础方案思路也简单:斩断自动配置的念想,每个数据源我都声明一套完整的“DataSource + SqlSessionFactory + MapperScan”,让Spring知道哪些Mapper归哪个数据源管。
2.1 先看配置:两份数据源的连接信息怎么放
比如我在application.yml里这样写:
spring: datasource: order: url: jdbc:mysql://localhost:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver user: url: jdbc:mysql://localhost:3306/user_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver注意这里有一个细节:SpringBoot自动配置会默认读取spring.datasource.url这个前缀。我不想让它插手,所以自定义了两个前缀spring.datasource.order和spring.datasource.user,这样配置中心的自动装配逻辑就不会干扰我后面的手动声明。
2.2 核心代码:每个数据源配一套专属配置类
这是最基础也最容易写错的部分。我分别建两个配置类,以订单库的为例:
@Configuration @MapperScan( basePackages = "com.demo.mapper.order", sqlSessionFactoryRef = "orderSqlSessionFactory" ) public class OrderDataSourceConfig { @Bean("orderDataSource") @ConfigurationProperties(prefix = "spring.datasource.order") public DataSource orderDataSource() { return DataSourceBuilder.create().build(); } @Bean("orderSqlSessionFactory") public SqlSessionFactory orderSqlSessionFactory( @Qualifier("orderDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); // 订单库的Mapper映射文件单独放一个目录 factoryBean.setMapperLocations(resolver.getResources("classpath:mapper/order/*.xml")); // 这里可以配置驼峰映射等公共属性 org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); } }用户库的配置类结构一模一样,只是basePackages换成com.demo.mapper.user,sqlSessionFactoryRef换成userSqlSessionFactory,Mapper XML目录换成classpath:mapper/user/*.xml。
这里必须强调的是:Mapper接口的包路径绝对不能有交集。比如订单Mapper放在com.demo.mapper.order,用户Mapper放在com.demo.mapper.user。如果两个@MapperScan扫描了同一个包,后面的BeanDefinition会被覆盖,出现各种Mapper找不到的诡异报错,排查起来相当酸爽。
2.3 手工切库:从Service层指定Mapper实现
配置完成之后,业务代码里怎么用?说到底,这一套做法的本质是用不同包下面的Mapper接口,天然绑定到不同的SqlSessionFactory。所以切换数据源这个动作,在代码层面其实就变成了选择哪个Mapper:
@Service public class OrderQueryService { // 订单库的Mapper,走orderSqlSessionFactory private final OrderMapper orderMapper; // 用户库的Mapper,走userSqlSessionFactory private final UserMapper userMapper; public OrderDetail getOrderWithUser(Long orderId) { OrderDO order = orderMapper.selectByPrimaryKey(orderId); // 不感知数据源切换,因为UserMapper天生就是连用户库的 UserDO user = userMapper.selectByPrimaryKey(order.getUserId()); OrderDetail detail = new OrderDetail(); detail.setOrder(order); detail.setUser(user); return detail; } }你看,这种“包级别隔离”方案的好处是代码简单、逻辑清晰,一个Mapper只属于一个数据源,没有运行时切换这回事。很多老项目就是从这个方案开始的——不需要写任何切面,不需要ThreadLocal,每个数据源都是独立的一组Bean。
这套方案适合什么场景呢?数据源之间结构完全不同,业务边界清晰的时候。比如订单模块只碰订单表所在的库,用户模块只碰用户库,问题不大。但它有很明显的短板,我下一章展开讲。
3. 手工切换的坑:循环依赖、事务失效与代码侵入
如果业务逻辑只是“查完订单库再查用户库”,那第二章的方案是没问题的。但是一旦你遇到“同一个Mapper、同一个Service方法,根据条件决定连哪个库”的需求,这套方案就尴尬了。真实业务里这种需求特别常见:同一张表,平台侧数据在A库,某个大客户的定制化版本在B库,两个库表结构几乎一样,业务代码却要各写一套Mapper接口——代码量直接翻倍。
我当时做一个多租户项目就碰到了这个问题。租户A和租户B用的是同一套表结构,区别只是物理库不同,最开始就是用两套Mapper去写,结果写着写着发现,所有CRUD都是重复的,只因为包路径不一样就得复制一遍。这显然不是长久之计。
3.1 第一个坑:循环依赖
有人说,那我配两个数据源,然后在Service里按需@Autowired指定使用哪个SqlSessionTemplate,不就能动态切换了吗?试过之后你会发现天坑一个接一个。举个例子,如果你在配置类里把两个数据源互相注入来做动态选择,SpringBoot 2.6以后默认不允许循环依赖,启动直接报错The dependencies of some of the beans in the application context form a cycle。解决办法要么在类上加@Lazy,要么手动放开循环依赖开关,但这两个操作都是治标不治本,属于把问题往后拖。
3.2 第二个坑:干脆利落的事务失效
手工切换最隐蔽的坑是事务。有些同学这样写:在Service方法上标注@Transactional,方法内部根据条件从两个SqlSessionTemplate里面挑一个执行操作。表面上看没问题,其实Spring的事务管理器(PlatformTransactionManager)是绑定数据源的。一个事务管理器背后只有一个数据源,你就算拿到了另一个SqlSessionTemplate,它执行的SQL也根本没有加入到当前事务里。
最后出现的现象就是:两个库的操作,一个回滚了,另一个已经提交,数据对不上;或者你期望的是“只要有一个失败,全回滚”,结果发现只有一个库回滚了,另一个库连错都没报,因为它的连接根本不在事务管理器管控范围内。线上出这种问题,只能一边排查一边补数据,非常痛苦。
3.3 第三个坑:事务阻断路由
再往深处走一步。就算你用了动态数据源,事务仍然是动态路由的头号杀手。Spring的@Transactional开启事务时,事务拦截器会从当前数据源拿一个数据库连接,并把连接绑定到ThreadLocal(确切说是DataSourceTransactionManager里的TransactionSynchronizationManager)。如果事务先开启了,后面动态路由的切面再怎么切换数据源,这次操作的连接其实还是事务开始时绑定的那一个。
结果就是你小心翼翼在方法A里先标记“去用户库”,再标记“去订单库”,真实请求却全部打在事务开始时的那个库上。这类问题不遇到一次,你很难理解为什么动态路由在非事务方法里一切正常,一进事务方法就“失灵”。
所以我一直有个观点:做多数据源,比配置Connections更重要的是先想清楚事务边界。这部分我在第六章再详细展开。
先说回动态路由本身。既然手工切换有这么多问题,业界通行的做法是换一种思路——不改变Mapper归属,而是在运行时给同一个SqlSessionFactory配置“动态的DataSource”。这就是我们常说的动态数据源路由。
4. 动态路由的核心机制:AbstractRoutingDataSource和它的三个幕后功臣
Spring框架其实早就给大家伙备好了一个抽象类:AbstractRoutingDataSource。它继承了AbstractDataSource,本身也是一个DataSource,但它的getConnection()不是直接创建连接,而是先执行determineCurrentLookupKey()方法拿到一个查询键,再用这个键到map里找到真正的目标DataSource,然后返回那个DataSource的Connection。
可以把它想象成一个会看指示牌的接线员:电话进来先问“你要找哪个库”,然后按一下对应房间的铃。这比第二章的多套SqlSessionFactory方案灵活得多——因为目标是运行时决定的,不是启动时写死的。
4.1 决定路由的Key从哪来:ThreadLocal是功臣
determineCurrentLookupKey()每次调用时都需要知道“当前线程应该用哪个数据源”,这就需要线程级别的上下文存储。最常见的做法就是ThreadLocal:
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSourceType(String dataSourceType) { CONTEXT.set(dataSourceType); } public static String getDataSourceType() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }用ThreadLocal的关键是要在finally里清掉。因为线程池里的线程是复用的,你这次请求把key设成了“用户库”,如果不清除,下一次请求复用同一个线程时,key还是“用户库”——这就是经典的数据串库事故。我在项目中明确要求:每次动态切换必须成对出现,set之后必须finally clear,谁都不许例外。
4.2 真正干活的子类:继承AbstractRoutingDataSource
路由核心类如下:
public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) { // 默认数据源,当key为空或找不到时使用 super.setDefaultTargetDataSource(defaultDataSource); // 目标数据源集合,key是路由标识,value是DataSource super.setTargetDataSources(targetDataSources); // 这个方法必须调用,否则resolvedDataSources是空的 super.afterPropertiesSet(); } @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } }这里有个很容易被忽略的点:afterPropertiesSet()。很多新手继承了AbstractRoutingDataSource之后,只调用了set方法,忘了调afterPropertiesSet(),然后启动也不报错,一执行SQL就报DataSource is not configured。原因是resolveSpecifiedLookupKey()和determineTargetDataSource()依赖的resolvedDataSources,必须通过afterPropertiesSet()从原始map里解析出来。我把初始化逻辑放在构造函数里,顺便要求DataSources配置必须构建好再注入,从根源上避开这个坑。如果你用Spring Boot的配置绑定,直接在@Bean方法里new这个类然后传入map也是可以的。
如果你是Spring Boot 3.x,AbstractRoutingDataSource有了一个新的构造器:
public AbstractRoutingDataSource(DataSource defaultTargetDataSource, Map<Object, DataSource> targetDataSources)这个构造器内部自己会处理初始化,代码可以更简洁一些。但原理完全一样,换汤不换药。
4.3 基于注解的切面:AOP是路由的触发器
路由源码通了,业务代码怎么声明“这个方法走用户库”?最优雅的做法是自定义一个注解,比如@DS("user"),然后写一个AOP切面。
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default "master"; }注意,默认值用master而不是null,这样不标注的方法不会因为key为null走到默认数据源,语义也更清晰。
切面逻辑:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(dataSource)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DS dataSource) throws Throwable { DataSourceContextHolder.setDataSourceType(dataSource.value()); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }这一段就是业界很多动态数据源方案的核心骨架。你自己动手实现一遍,再去用dynamic-datasource-spring-boot-starter这类成熟组件,就能做到心里有底,知道它每一步的背后干了什么。
4.4 为什么一定要给动态数据源加@Primary
多数据源环境下,一个经典的启动报错是:
expected single matching bean but found 2: orderDataSource,userDataSource这是因为某个地方需要注入DataSource,容器里有两个候选Bean,Spring不知道选谁。解决办法是在动态数据源这个总入口上标注@Primary:
@Bean("dynamicDataSource") @Primary public DataSource dynamicDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("order", orderDataSource()); targetDataSources.put("user", userDataSource()); return new DynamicDataSource(orderDataSource(), targetDataSources); }有了@Primary,容器在需要DataSource的时候会优先注入动态数据源。而动态数据源最终会路由到具体的库,完美的闭环。
5. 路由落地的隐藏细节:连接池、拦截器与监控
动态路由的核心机制搞清楚后,绝大多数文章就停笔了。但真实项目上线后,压着你的往往不是路由本身,而是外围配套。这一章把我在生产环境中遇到过的外围细节都写出来。
5.1 同一个连接池定义,参数必须各调各的
很多同学的第一个版本是:
spring: datasource: druid: initial-size: 5 max-active: 20只配了一份参数,然后这个配置被两个数据源共享。看起来很省事,实际上生产环境很容易出问题:用户库的写入频率高,连接池一开始就设20个连接,配置合理;但报表库一天最多几个并发,也分配20个连接,白白浪费资源。更糟糕的反过来——报表查询特别重,一个查询就要跑十几秒,连接池拿不出更多连接,直接把用户库的正常业务也拖垮了。
所以我的做法是:每个数据源都单独配一份连接池参数,互不干扰:
spring: datasource: order: druid: initial-size: 10 max-active: 30 min-idle: 10 user: druid: initial-size: 5 max-active: 10 min-idle: 5虽然配置冗余了一点,但隔离性是值得的。尤其是多租户场景,某个租户的库要是把连接池打满,其他租户不该被牵连。
5.2 MyBatis拦截器与插件重复注册
很多项目引入了MyBatis的分页插件PageHelper、自定义拦截器做数据权限过滤。如果你用的是多套SqlSessionFactory方案(第二章),每个SqlSessionFactory构建时必须分别设置插件:
@Bean("orderSqlSessionFactory") public SqlSessionFactory orderSqlSessionFactory(...) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); // 分页插件要加一次 factoryBean.setPlugins(new Interceptor[]{new PaginationInnerInterceptor(DbType.MYSQL)}); return factoryBean.getObject(); }如果是动态数据源方案就简单很多:因为所有Mapper最终都用同一个SqlSessionFactory,插件只需要在这个SqlSessionFactory上设置一次。这也是动态数据源方案在工程化上的一大优势——中间的拦截器、配置项集中管理,不用维护多套重复配置。
但要小心一个细节:插件的初始化顺序。如果你的分页插件设置在了DataSource初始化之前,插件内部需要访问DataSource的某些元数据时可能会拿不到。我遇到过PageHelper在某些版本下查不到数据库类型导致分页SQL没有正确方言,排查到最后是插件对象复用了老项目的单例。建议新起项目不要图省事直接复制老插件配置,最好在本地跑一个分页用例验证。
5.3 监控与排障:动态路由让“这个SQL到底去哪了”变难了
多数据源是个黑盒,问题排查难度加倍。比如线上有人报慢SQL,你第一件事得判断这个SQL打到哪个库去了。如果业务方法上没有清晰的注解,排查人员往往要翻代码、查ThreadLocal在哪些地方被设置过,效率很低。
我的建议是加一个业务链路里可检索的路由标记。最简单的是在路由切面里,把当前数据源key通过MDC(Mapped Diagnostic Context)埋进日志:
@Around("@annotation(dataSource)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DS dataSource) throws Throwable { String dsKey = dataSource.value(); MDC.put("dsKey", dsKey); DataSourceContextHolder.setDataSourceType(dsKey); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); MDC.remove("dsKey"); } }日志配置里把%X{dsKey}加到pattern中。以后查日志,一眼就能看到这条请求打在哪个库。另外我还会给动态数据源加一个简单的计数器或审计点,统计每个数据源的调用次数,结合监控系统做报警阈值,比黑盒状态强太多了。
6. 事务边界与分布式事务:动态数据源最难的一道坎
如果把前面的内容比作开车,那事务就是雨天路滑。没有一个清醒的头脑,车子很容易失控。
6.1 先理解Spring事务在数据源面前的运作顺序
我在第三章提过,@Transactional开启事务时,Spring会把数据库连接绑定到当前线程的事务同步器上。这背后的实际流程是:
- 进入方法,AOP事务拦截器生效,获取事务管理器。
- 事务管理器调用DataSource的getConnection()。
- 此刻动态路由的
determineCurrentLookupKey()返回key A,拿到数据源A的连接。 - 连接被放进TransactionSynchronizationManager,后续整个事务期间都复用这个连接。
- 然后才进入业务方法体。假设业务方法内部某个Mapper标注了
@DS("B"),路由切面把key设为B,但MyBatis执行SQL时,从TransactionSynchronizationManager里拿到的还是数据源A的连接——路由被事务冻结了。
这就是动态数据源和Spring事务最核心的矛盾:事务要固定一个连接,路由要动态切换连接,两者天然冲突。
我自己在实际项目中的做法是:不在同一个事务方法里做跨库切换。具体来说:
- 动态路由最安全的用法是在“非事务方法”中使用,或者在一个方法内多次调整路由,但每次调整都对应独立的事务边界。
- 如果一个方法必须开启事务,那就明确它只操作一个数据源,不要在里面切换。
- 如果确实需要同时修改两个库,不要自己拼事务,直接在Service层拆成两个事务方法:方法A操作库A,方法B操作库B,然后通过一个编排层协调。两个方法都标注
@Transactional,各自管好自己的事务边界。
但是问题来了:如果两个方法A和B,一个成功一个失败,怎么办?这就是分布式事务的范畴了。
6.2 分布式事务的思路:从两阶段提交到Seata
自己写分布式事务,我强烈建议不要自己发明轮子。两阶段提交(2PC)看起来逻辑很简单——先给所有库发prepare,全部成功后发commit,任何一个失败就rollback——但真正实现时要考虑协调者高可用、事务日志、网络分区恢复、长时间锁表等一堆问题。生产环境,老老实实用成熟的分布式事务框架,比如Seata。
Seata的AT模式(Automatic Transaction)和Spring Cloud生态整合得比较顺,核心思路是:事务发起方(TC)记录业务SQL的前后镜像(写在undo_log表里),各分支事务(RM)先本地提交,然后TC通过全局事务ID协调各分支的状态,全局提交或回滚时,利用undo_log反向补偿。你只需要引入依赖、配置几个参数,然后在方法上标注@GlobalTransactional,业务代码基本不用大改。
但要注意,AT模式对数据库有限制:必须支持事务,MySQL/PostgreSQL/Oracle都支持,但像MongoDB这种不支持标准事务的就得考虑TCC或SAGA模式。接入Seata之前,我建议先把动态路由跑通,再单独抽一个POC验证两个库的全局回滚有效性,别一上来就全量改造。
6.3 什么时候可以不引入分布式事务
不是所有跨库操作都需要分布式事务。很多业务,尤其是报表、日志、读取类,最终一致性就可以接受。比如订单系统和库存系统是两个库,下单时扣库存,如果库存扣完了订单已经入库了,完全可以通过MQ发个补偿消息去异步处理。把强一致的诉求降级为最终一致,从架构复杂度上省掉的成本是很可观的。
我见过太多项目因为“数据绝对不允许不一致”这种话,硬着头皮上了分布式事务,结果全局锁把两个库的性能都拖垮了。后来发现业务上允许延迟几秒甚至几分钟的补偿,根本不需要强一致。做架构决策之前,先去找产品和业务方把一致性要求量化清楚,这比任何技术方案都重要。
7. 真实项目里的选型建议与避坑清单
最后聊聊落地过程中的选型和经验总结。网上搜“SpringBoot多数据源”很容易直接跳到dynamic-datasource-spring-boot-starter这个组件,它的使用成本确实很低:加依赖、配多份数据源、方法上标注@DS就完事了。它的内部原理,本质上就是我第四章实现的这套动态路由骨架。所以我特别建议你先自己动手实现一遍核心部分,再决定要不要用成熟组件——不是说不让你用,而是你要用出水平,出问题的时候能定位到原理。
7.1 自己实现 vs 用成熟组件
| 对比维度 | 自己实现 | 成熟组件(如dynamic-datasource) |
|---|---|---|
| 学习成本 | 高,要理解AOP/Spring生命周期/连接管理 | 低,文档全,内网案例多 |
| 功能丰富度 | 基础路由,事务边界要自己控制 | 带Seata集成、读写分离、多组数据源 |
| 可控性 | 完全可控,想怎么扩展怎么扩展 | 受制于组件设计,二次开发要源码级别 |
| 生产稳定性 | 依赖你的代码质量 | 经过大量项目验证 |
我的建议是:个人学习、内部工具、简单场景,完全值得自己实现一版;如果是核心业务系统、团队换人频繁,用成熟组件更稳妥。你去面试的时候,能把自己实现的原理讲透,再把成熟组件的边界讲清楚,这才是真正让面试官觉得“这个人是真的干过”的地方。
7.2 避坑清单
下面这份清单是我这几年一个字一个字踩出来的,分享给大家:
- 多套Mapper扫描的包路径必须隔离。两个
@MapperScan扫描同一个包路径,要么启动失败,要么Mapper归属混乱。 - 多套SqlSessionFactory时,公共插件、配置项要各配置一份,不要以为全局配置能自动套用。
- 继承AbstractRoutingDataSource后,务必调用afterPropertiesSet()。特别是在构造器里直接初始化时,忘记这个方法会让你浪费一个小时排查“为啥找不到数据源”。
- 动态路由一定要用@Primary标记,让自动注入的数据源是动态的那个,否则会出现多处报错。
- ThreadLocal使用后必须清理。线程池复用的场景,不清理等于数据串库,而且这种故障是间歇性的,特别难定位。
- 事务和动态路由不要硬凑。一个事务方法内不切换数据源,多库操作要么拆事务方法,要么引入分布式事务框架。
- 监控日志要暴露当前路由key。日志加MDC,监控加计数,不然线上故障你连从哪个方向查都不知道。
7.3 一个务实的小技巧:先建立健康检查
承接上一节,线上环境我还会给动态数据源配置一个简单的健康检查接口。手工写一个Controller,遍历所有数据源目标,逐个执行SELECT 1,把结果返回。这样运维巡检、自动化拨测都能直观看到每个库是否可用,而不是等服务侧报错才去翻日志。这个代码不难,但价值极高。
@RestController @RequestMapping("/internal") public class DataSourceHealthController { private final DynamicDataSource dynamicDataSource; public DataSourceHealthController(DynamicDataSource dynamicDataSource) { this.dynamicDataSource = dynamicDataSource; } @GetMapping("/datasource/health") public Map<String, Object> health() { Map<String, Object> result = new HashMap<>(); // 从DynamicDataSource中拿到resolvedDataSources反射查询,或者直接在这里维护一份目标数据源的引用 // 逐个执行SELECT 1,记录耗时和状态 return result; } }这里我说一句实在话:多数据源的路由本身不是一个“难上天”的技术,真正的难点永远在工程化的外围——事务怎么兜底、监控怎么做、团队怎么维护。先把这些理清楚,你再去看那些花里胡哨的框架,会轻松很多。我用这套思路已经成功落地过两三个生产项目,目前最稳定的版本就是“动态路由 + 明确事务边界 + 日志标路由”的组合,这里也分享给正在折腾多数据源的各位。