
1. 多数据源到底解决什么问题第一次接触多数据源很多人是被业务逼出来的。单体项目跑得好好的突然有一天产品说主库要拆了订单数据放一个库用户数据放另一个库报表还要单独连一个只读库。这时候你打开application.yml发现里面只有一个spring.datasource.url整个人就愣住了。多数据源要解决的核心问题就一句话让同一个应用在运行时能同时操作多个物理隔离的数据库。注意这里说的是物理隔离不是同一个库里的多张表。它和分库分表不是一回事分库分表是同一套逻辑数据被切到多个库而多数据源是不同业务模块的数据本来就住在不同的库里。那为什么不用微服务拆开因为拆服务的成本远高于多数据源。一个中小型项目团队就三五个人为了两个库去搞服务注册发现、分布式事务、链路追踪纯属给自己找罪受。多数据源是在单体架构下用最小代价换取数据隔离能力的方案它保留了单体部署的简单性同时让数据边界清晰起来。具体来说下面这些场景基本都会用到多数据源读写分离主库负责写从库负责读通过数据源切换把查询压力分摊出去。业务垂直拆分用户库、订单库、商品库各自独立但还在同一个应用里。历史库与热库分离近期数据放热库归档数据放历史库查询时按时间路由。多租户场景每个租户一个独立库靠动态数据源在运行时切换。SpringBoot MyBatis-Plus MySQL 这套组合之所以成为多数据源的主流方案是因为 MyBatis-Plus 在 MyBatis 之上做了大量封装尤其是它提供的DynamicDataSource和DS注解把数据源切换这件事从手动管理 SqlSessionFactory降维到了加个注解。这个降维非常关键后面会详细讲。适合读这篇内容的人已经能跑通单数据源的 SpringBoot MyBatis-Plus 项目现在需要接入第二个库但不想把项目拆成微服务的开发者。如果你连单数据源都还没跑通建议先把基础打牢再来看多数据源否则排查问题时你会分不清是配置错了还是切换逻辑错了。2. 方案选型为什么是 Dynamic-Datasource多数据源的实现方式有好几种我先把它们摆出来对比一下你就明白为什么最终会落到 Dynamic-Datasource 这个组件上。2.1 几种主流实现方式的横向对比方案核心思路优点缺点手动配置多个 DataSource Bean每个库配一个 DataSource手动指定 SqlSessionFactory完全可控不依赖额外组件代码量大切换要手动事务难处理AbstractRoutingDataSource继承 Spring 提供的路由数据源用 ThreadLocal 存 keySpring 原生支持灵活需要自己写路由逻辑、AOP、注解Dynamic-Datasource第三方 starter封装了路由、注解、事务开箱即用注解切换生态成熟引入额外依赖需要理解它的规则微服务拆分每个库一个服务边界彻底清晰成本极高分布式事务复杂手动配置多个 DataSource Bean 的方式我早期项目里用过。每个库都要写一遍DataSource、SqlSessionFactory、TransactionManager三个库就是九套配置。更麻烦的是MyBatis 的 Mapper 要明确绑定到哪个 SqlSessionFactory一旦某个 Mapper 想换库改动量很大。这种方式适合库的数量固定且极少变化的场景但维护起来是真的累。AbstractRoutingDataSource 是 Spring 提供的原生方案它的原理是在determineCurrentLookupKey()方法里返回一个 keySpring 根据这个 key 从目标数据源 Map 里挑一个。这个方案本身没问题问题在于你要自己实现 ThreadLocal 上下文、自己写 AOP 切面、自己处理注解解析、自己保证事务内切换不失效。这些工作 Dynamic-Datasource 都帮你做了。Dynamic-Datasource 的本质其实就是对 AbstractRoutingDataSource 的工程化封装。它把 ThreadLocal 上下文、AOP 切面、注解解析、事务同步、数据源分组这些脏活累活全包了你只需要在配置文件里声明数据源在方法上加DS(xxx)就行。这就是选它的核心理由用成熟的轮子把精力留给业务。2.2 版本选择与依赖引入版本这块有个坑必须提前说。Dynamic-Datasource 的版本和 SpringBoot 版本有对应关系选错了启动就报错。SpringBoot 2.x 项目用dynamic-datasource-spring-boot-starter3.x 版本。SpringBoot 3.x 项目必须用dynamic-datasource-spring-boot3-starter这是专门为 SpringBoot 3 适配的。我见过有人 SpringBoot 3 项目里引了 2.x 的 starter结果启动时AutoConfiguration不生效数据源根本没被接管排查了半天才发现是版本问题。Maven 依赖这样写以 SpringBoot 3 为例dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot3-starter/artifactId version4.3.0/version /dependencyMyBatis-Plus 的依赖单独引dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency注意Dynamic-Datasource 和 MyBatis-Plus 是同一个团队维护的所以它们之间的兼容性很好。但如果你项目里用的是原生 MyBatis 而不是 MyBatis-PlusDynamic-Datasource 一样能用它接管的是 DataSource 层和 ORM 框架无关。2.3 数据源分组的概念Dynamic-Datasource 有个分组的概念这个设计很巧妙值得单独讲。假设你配了master、slave_1、slave_2三个数据源你可以把它们分成两组master组只有 master 一个。slave组包含 slave_1 和 slave_2。当你在方法上写DS(slave)时它会自动在 slave 组内做负载均衡默认是轮询策略。这个特性在读写分离场景下特别有用你不用关心具体连的是哪个从库框架帮你选。分组配置长这样spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://127.0.0.1:3306/db_master username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://127.0.0.1:3307/db_slave username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://127.0.0.1:3308/db_slave username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driverprimary: master指定默认数据源没加DS注解的方法都走 master。strict: false表示当DS指定的数据源不存在时回退到 primary而不是抛异常。生产环境建议设成true让配置错误尽早暴露。3. 核心原理DS 注解是怎么生效的很多人用DS用得很顺手但一旦切换失效就懵了。要排查问题必须理解它背后的执行链路。这一节把原理拆开讲理解了原理后面排查问题就是按图索骥。3.1 从方法调用到数据源选择的完整链路一次带DS注解的方法调用数据源切换经历了这几个步骤AOP 拦截Dynamic-Datasource 注册了一个DynamicDataSourceAnnotationAdvisor它会对标注了DS的方法或类进行拦截。解析注解拦截器DynamicDataSourceAnnotationInterceptor读取方法或类上的DS值得到目标数据源名称。压入上下文把数据源名称放进DynamicDataSourceContextHolder这个类内部用的是 ThreadLocal所以切换只对当前线程有效。路由选择当 MyBatis 执行 SQL 需要连接时会调用DynamicRoutingDataSource.getConnection()它从 ThreadLocal 里取出 key从数据源 Map 里找到对应的 DataSource。清理上下文方法执行完拦截器在 finally 块里调用clear()把 ThreadLocal 清掉防止污染下一个请求。这个链路里最关键的是第 3 步和第 5 步。ThreadLocal 保证了线程隔离finally 清理保证了不会串库。如果你发现切换失效八成是这两步出了问题。3.2 ThreadLocal 与线程池的坑ThreadLocal 的隔离性在同步调用下没问题但一旦涉及线程池就会出问题。举个典型场景你在一个DS(slave)的方法里用CompletableFuture异步执行了一段查询期望它走从库。结果发现它走了主库。原因很简单CompletableFuture默认用的是 ForkJoinPool 的线程而 ThreadLocal 是绑定线程的主线程设置的 key 不会自动传到子线程。子线程的 ThreadLocal 是空的于是回退到了 primary 数据源。解决办法有两个用DynamicDataSourceContextHolder.push()在子线程里手动设置数据源。用TransmittableThreadLocalTTL替换默认的 ThreadLocal让上下文能跨线程传递。不过 Dynamic-Datasource 默认没集成 TTL需要自己扩展。我个人的建议是异步任务里不要依赖外层的数据源上下文显式地在异步方法上加DS注解。这样最直观也最不容易出错。3.3 事务与数据源切换的冲突这是多数据源里最容易踩的坑没有之一。Spring 的事务管理器在事务开启时就会绑定一个 DataSource 到当前线程事务期间所有的数据库操作都用这个 DataSource。如果你在事务方法内部切换数据源切换是不生效的因为事务已经锁定了连接。看这段代码Transactional public void doSomething() { userMapper.insert(user); // 走 primary orderService.saveOrder(order); // 期望走 order 库实际还是 primary }saveOrder方法上即使加了DS(order)也不会切过去。因为Transactional开启时DataSourceTransactionManager已经拿到了 primary 的连接整个事务都在这一个连接上跑。正确的做法是把跨库操作拆成独立的事务方法通过不同的 Service 调用且不要在同一个事务里跨库。如果业务上必须保证一致性那就得考虑分布式事务方案但那是另一个话题了。提示Dynamic-Datasource 提供了DSTransactional注解它能在一定程度上支持多数据源事务但它的实现是基于多个本地事务的补偿不是强一致。用之前一定要想清楚业务能不能接受最终一致。4. 完整实操从零搭一个双数据源项目原理讲完了现在动手搭一个能跑的项目。我以一个用户库 订单库的场景为例把每一步都写清楚。4.1 数据库准备先在本地 MySQL 建两个库模拟两个独立的业务库CREATE DATABASE db_user DEFAULT CHARACTER SET utf8mb4; CREATE DATABASE db_order DEFAULT CHARACTER SET utf8mb4; USE db_user; CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, age INT DEFAULT 0 ); USE db_order; CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) DEFAULT 0 );两个库分别建一张表结构简单点方便验证切换是否生效。4.2 配置文件详解application.yml是核心每个参数都有讲究spring: datasource: dynamic: primary: user strict: true datasource: user: url: jdbc:mysql://127.0.0.1:3306/db_user?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://127.0.0.1:3306/db_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver几个关键点解释一下primary: user默认数据源叫 user没加注解的方法走它。strict: trueDS指定的数据源不存在时直接报错避免静默回退到主库导致数据写错地方。serverTimezoneAsia/ShanghaiMySQL 8 的时区参数不加可能报时区错误。useUnicode和characterEncoding防止中文乱码虽然现在默认基本没问题但加上更保险。4.3 实体类与 Mapper实体类用 MyBatis-Plus 的注解Data TableName(t_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private Integer age; }Data TableName(t_order) public class Order { TableId(type IdType.AUTO) private Long id; private String orderNo; private Long userId; private BigDecimal amount; }Mapper 接口继承BaseMapperpublic interface UserMapper extends BaseMapperUser { }public interface OrderMapper extends BaseMapperOrder { }Mapper 上不需要加DS注解加在 Service 层更合适因为数据源的归属本质上是业务逻辑的决策不是数据访问层的决策。4.4 Service 层的 DS 使用Service public class UserService { Autowired private UserMapper userMapper; DS(user) public User getById(Long id) { return userMapper.selectById(id); } DS(user) public void save(User user) { userMapper.insert(user); } }Service public class OrderService { Autowired private OrderMapper orderMapper; DS(order) public Order getByOrderNo(String orderNo) { return orderMapper.selectOne( new LambdaQueryWrapperOrder().eq(Order::getOrderNo, orderNo) ); } DS(order) public void save(Order order) { orderMapper.insert(order); } }DS可以加在方法上也可以加在类上。加在类上表示这个类所有方法都走指定数据源方法上的注解优先级更高。我一般建议加在方法上粒度更细也更容易看出每个方法的数据源归属。4.5 验证切换是否生效写个测试接口分别调用两个 Service然后看日志里实际连的是哪个库。Dynamic-Datasource 在 debug 日志级别下会打印数据源切换信息。把日志级别调一下logging: level: com.baomidou.dynamic.datasource: debug调用后你会看到类似dynamic-datasource switch to the datasource named [order]的日志说明切换成功。如果没看到说明注解没生效回去检查 AOP 是否被代理、方法是不是被同类内部调用内部调用不走代理注解失效。5. 常见问题与排查技巧实录多数据源的问题翻来覆去就那么几类。我把踩过的坑整理成一张速查表遇到问题先对号入座。5.1 数据源切换失效速查表现象可能原因排查方法解决方式加了 DS 还是走主库同类内部方法调用看调用方是否通过 this 调用拆到另一个 Bean或注入自身代理加了 DS 还是走主库方法不是 public检查方法修饰符改成 public加了 DS 还是走主库类没被 Spring 管理检查是否有 Service/Component加上注解事务内切换失效Transactional 锁定了连接看方法上是否有事务注解拆事务或调整事务边界异步线程走主库ThreadLocal 不跨线程看是否用了线程池异步方法上单独加 DS启动报数据源找不到strict 为 true 且名字写错看启动日志核对数据源名称拼写这张表里同类内部调用和事务锁定是两个最高频的坑。内部调用的问题在于 Spring AOP 是基于代理的this.method()调用不走代理注解自然不生效。解决办法是把方法挪到另一个 Service或者用AopContext.currentProxy()拿到代理对象再调用。5.2 事务场景下的正确姿势假设有个业务创建用户的同时要记录一条订单。这两个操作在不同库怎么处理错误示范Transactional public void createUserAndOrder(User user, Order order) { userService.save(user); // 走 user 库 orderService.save(order); // 期望走 order 库实际被事务锁定在 user 库 }正确做法是拆开让每个库的操作在自己的事务里public void createUserAndOrder(User user, Order order) { userService.save(user); // user 库自己的事务 orderService.save(order); // order 库自己的事务 }如果业务要求两个操作要么都成功要么都失败那就得引入分布式事务。但说实话很多业务场景其实可以接受最终一致比如先写用户再异步写订单失败了重试。强一致不是免费的想清楚业务到底需不需要。5.3 连接池配置的注意事项每个数据源都有自己的连接池配置不当会导致连接耗尽。Dynamic-Datasource 默认用的是 HikariCP可以在每个数据源下单独配spring: datasource: dynamic: datasource: user: url: jdbc:mysql://127.0.0.1:3306/db_user username: root password: 123456 hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000这里有个经验值maximum-pool-size 不是越大越好。MySQL 默认最大连接数是 151如果你有 5 个数据源每个都配 50那就是 250直接超过 MySQL 上限。一般单数据源 10 到 20 就够了具体看 QPS 和 SQL 执行时间。计算公式是连接数 QPS × 平均执行时间(秒)比如 QPS 100平均执行 50ms那理论上 5 个连接就够留点余量配 10 个。5.4 一个隐蔽的坑数据源名称大小写Dynamic-Datasource 的数据源名称是大小写敏感的。配置里写的是user注解里写DS(User)strict 模式下会直接报找不到数据源。这个坑很隐蔽因为肉眼看着差不多。建议统一用小写加下划线命名比如user_db、order_db避免大小写混淆。6. 进阶玩法动态数据源与多租户基础用法掌握后可以看看更复杂的场景。多租户是动态数据源最典型的进阶应用。6.1 运行时动态添加数据源有些场景下数据源不是启动时确定的而是运行时根据租户动态创建的。比如 SaaS 系统每个租户一个库租户注册时才建库。这时候静态配置就不够用了需要用DynamicRoutingDataSource的addDataSource方法Autowired private DynamicRoutingDataSource dynamicRoutingDataSource; public void addTenantDataSource(String tenantId, String url, String username, String password) { DataSourceProperty property new DataSourceProperty(); property.setUrl(url); property.setUsername(username); property.setPassword(password); property.setDriverClassName(com.mysql.cj.jdbc.Driver); DataSource dataSource new HikariDataSource(); // 设置连接池参数... dynamicRoutingDataSource.addDataSource(tenantId, dataSource); }添加后用DS(#tenantId)就能通过 SpEL 表达式动态指定数据源。这个特性在多租户系统里非常实用但要注意数据源的清理租户注销时记得调用removeDataSource否则连接池会一直占着资源。6.2 SpEL 表达式的灵活运用DS支持 SpEL 表达式这让数据源选择可以和方法参数挂钩DS(#tenantId) public ListOrder listOrders(String tenantId) { return orderMapper.selectList(null); }也可以从 Session 或 ThreadLocal 里取值DS(#session.tenantId) public ListOrder listOrders() { return orderMapper.selectList(null); }这个能力让多租户的实现变得很优雅不用在每个方法里手动切换数据源。6.3 数据源健康检查生产环境里数据源的健康状态需要监控。Dynamic-Datasource 集成了 SpringBoot 的 Actuator访问/actuator/health能看到每个数据源的状态。但要注意默认的健康检查可能不够细建议自己写一个定时任务定期对每个数据源执行SELECT 1把结果打到监控系统里。这样某个从库挂了能第一时间发现而不是等用户报障。7. 一些实操心得用多数据源这几年有几个体会比较深分享出来。第一数据源数量要克制。我见过一个项目配了八个数据源理由是业务模块多。结果每次启动要初始化八个连接池启动时间翻倍排查问题时要一个个确认走的是哪个库。数据源不是越多越好能合并的库尽量合并实在需要隔离再拆。三个以内是比较舒服的数量。第二注解位置要统一。团队里有人把DS加在 Mapper 上有人加在 Service 上还有人加在 Controller 上。结果就是同一个业务链路数据源归属混乱。我的建议是统一加在 Service 层的方法上因为 Service 是业务逻辑的边界数据源归属本质上是业务决策。第三日志一定要开。开发阶段把com.baomidou.dynamic.datasource的日志级别设成 debug每次切换都能看到。上线后可以调到 info但排查问题时记得临时打开。很多切换失效的问题看一眼日志就清楚了。第四写单元测试验证切换。多数据源的切换逻辑很容易被后续改动破坏比如有人重构时把方法挪了位置注解就失效了。写个集成测试分别调用两个库的方法断言数据确实写到了正确的库。这个测试能在 CI 阶段拦住大部分回归问题。第五别在事务里跨库。这条前面强调过但值得再说一遍。跨库事务是分布式事务的范畴用本地事务注解包着跨库操作只会让你以为有事务保护实际上第二个库的操作根本不在事务里。这种假事务比没有事务更危险因为它给了你错误的安全感。最后说个细节Dynamic-Datasource 的DS注解在方法执行完会清理 ThreadLocal但如果方法抛异常清理逻辑在 finally 里一样会执行。所以不用担心异常导致上下文残留。真正要担心的是异步线程和线程池复用那才是上下文串味的重灾区。