
写这篇东西之前我先把话说在前头多数据源这件事看起来就是配置两个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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver user: url: jdbc:mysql://localhost:3306/user_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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.usersqlSessionFactoryRef换成userSqlSessionFactoryMapper 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。所以切换数据源这个动作在代码层面其实就变成了选择哪个MapperService 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()每次调用时都需要知道“当前线程应该用哪个数据源”这就需要线程级别的上下文存储。最常见的做法就是ThreadLocalpublic class DataSourceContextHolder { private static final ThreadLocalString 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, MapObject, 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.xAbstractRoutingDataSource有了一个新的构造器public AbstractRoutingDataSource(DataSource defaultTargetDataSource, MapObject, 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容器里有两个候选BeanSpring不知道选谁。解决办法是在动态数据源这个总入口上标注PrimaryBean(dynamicDataSource) Primary public DataSource dynamicDataSource() { MapObject, 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通过MDCMapped 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 MapString, Object health() { MapString, Object result new HashMap(); // 从DynamicDataSource中拿到resolvedDataSources反射查询或者直接在这里维护一份目标数据源的引用 // 逐个执行SELECT 1记录耗时和状态 return result; } }这里我说一句实在话多数据源的路由本身不是一个“难上天”的技术真正的难点永远在工程化的外围——事务怎么兜底、监控怎么做、团队怎么维护。先把这些理清楚你再去看那些花里胡哨的框架会轻松很多。我用这套思路已经成功落地过两三个生产项目目前最稳定的版本就是“动态路由 明确事务边界 日志标路由”的组合这里也分享给正在折腾多数据源的各位。