老项目里那套多数据源逻辑让我头疼了很久,业务线按行业拆库,用户入口却是同一个系统,Service层全是 if-else 手切 Connection 的代码,新增一个库就要把业务逻辑翻一遍。后来我用 Druid + Spring Boot + MyBatis 做了一套动态数据源切换方案,把这摊子彻底理顺了。整套方案核心就三件事:Druid 管理连接池、AbstractRoutingDataSource 做路由、注解加 AOP 收敛切换逻辑。落地之后,新增一个数据源只需要改配置和注册路由,业务代码零改动。
这篇文章围绕这套动态多数据源方案展开,背景、设计、实现、踩坑全部讲透。正在做读写分离、多租户拆分、按业务域拆库的 Spring Boot 开发者,尤其是想在现有 MyBatis 项目里平滑接入多数据源动态切换的人,可以直接照着抄。
1. 项目背景:这个方案到底解决什么问题
1.1 场景驱动的需求拆解
先别急着写代码,想清楚为什么需要动态多数据源,方案才不会做偏。我遇到的最典型场景就是业务隔离,某个平台有几套业务线,会员、订单、商品各放一个库,表面看起来是一个系统,实际数据分了三处。早期做法是在 DAO 层拿到DataSource 后手动 getConnection(),Service 层根据业务类型去选择库,代码里充满了 switch-case,每接入一个业务域就要改一遍上层逻辑。
还有一类常见需求是读写分离。主库承担写入,从库扛查询流量,这类场景在报表、监控和交易系统里很常见。如果不用动态切换,比较土的做法是同一个 Service 方法里直接声明两个 JdbcTemplate,Select 走 slave,Insert 走 master,虽然能用,但侵入性太强,尤其是你用的 ORM 是 MyBatis 的时候,这种写法几乎没有可维护性。
另一个容易忽略的需求是多租户。不同租户的数据希望物理隔离,可能同一套表的 Schema,在不同租户的库实例里各存一份。这种情况下,动态数据源选择器可以根据当前请求里的租户标识去路由到对应库里。这个场景最考验路由的灵活性和上下文传递的设计,因为租户信息往往在拦截器或者网关里,要跨层传递到 DAO 层。
回到主题,标题里"动态切换"这个词其实就是这套方案的灵魂。它不是像 MyBatis 插件那样拦截 SQL 改写表名做逻辑隔离,而是从连接层面实实在在路由到不同的库,路由规则完全由业务代码通过注解声明,和数据访问层彻底解耦。用纯 Spring 生态的做法,核心依赖就是 AbstractRoutingDataSource 加上一个 ThreadLocal,简单可靠。
1.2 为什么非要用 Druid,而不是 HikariCP 或 DBCP
多数据源场景下,连接池选型不只是为了连接复用那么简单,更重要的是监控和问题定位能力。Druid 在这方面优势明显,它为每个数据源提供了独立的监控页,慢 SQL、活跃连接数、SQL 执行次数、错误统计一目了然。多数据源一旦出现连接泄漏或者满连接,没有监控纯粹靠猜,排查问题会非常痛苦。
我最早试过 HikariCP,说实话性能确实好,社区也很活跃,但它主打的是轻量,监控能力远不如 Druid。生产环境里连接池满了、某个 SQL 执行时间异常,Druid 监控页能直接看到哪个数据源、哪个 SQL、哪个方法出了问题,Hikari 只能靠日志和外部工具去追。
Druid 在连接池管理上也有一些比较实用的能力。比如 removeAbandoned 连接泄漏检测,超过时间限制的连接会被强制回收,避免连接被业务代码借走之后忘记归还。还有 maxWait 和 keepAlive 参数的配合使用,可以在连接空闲时间过长时主动验证连接有效性。这些能力在单数据源配置下可能感受不明显,一旦切到多数据源,连接数量成倍增长,这些兜底机制的价值就出来了。
当然多数据源场景里也有个很容易踩的坑,就是每个数据源都初始化一套连接池,连接数会成倍增长。用 Druid 的监控页可以很快发现哪些库里其实没有流量,及时调整初始连接数和最大连接数,避免资源白占。
2. 核心方案设计:动态切换的技术骨架
2.1 AbstractRoutingDataSource:动态路由的基石
如果不用 AbstractRoutingDataSource,想要在运行期切换数据源,通常是在每个 DAO 调用前手动替换数据源,或者在 SqlSessionFactory 上动脑筋,这两种方式要么侵入性强,要么不稳定。Spring 为我们提供了一个现成的抽象类 AbstractRoutingDataSource,它就是为这种动态选择场景准备的。
AbstractRoutingDataSource 继承自 AbstractDataSource,内部维护了一个 Map,key 是我们自定义的数据源标识,value 是对应的 DataSource 对象。Spring 在每次获取数据库连接时,会调用它的 determineCurrentLookupKey() 方法,拿到返回的 key 之后,去这个 Map 里找到目标数据源,然后返回目标数据源连接。
这个设计最妙的地方在于,数据源的路由发生在连接获取阶段,而不是 SQL 执行阶段。这意味着 MyBatis 的 SqlSession 完全不需要知道连接到底是从哪个库拿的,你只管执行 SQL,连接池已经把连接准备好了。路由逻辑和应用层、数据访问层完全隔离,这就是整个方案能保持清爽的根本原因。
我见过有些团队自己实现了一个 RoutingDataSource,原理其实也是模仿 AbstractRoutingDataSource,但自己写容易在连接缓存的细节上出错。比如某些场景下 Spring 在事务环境中会缓存连接,如果没处理好,切了数据源但拿到的还是老连接。直接用 Spring 官方提供的抽象类,这些边界情况已经被大量项目验证过,不需要自己重新踩坑。
2.2 数据源路由规则:靠 ThreadLocal 记住本次请求的上下文
确定好基础类是 AbstractRoutingDataSource 之后,下一个问题就是:determineCurrentLookupKey() 到底从哪里拿到当前应该使用的数据源 key。最直接的方式是把它存在一个 ThreadLocal 里,请求进入时写入,调用完清理。每个线程各存一份,互不干扰,天然地符合一次请求内的上下文传递需求。
为什么不直接把数据源 key 作为参数一路传到 DAO 层?因为你的 DAO 接口是 MyBatis 的 Mapper,Mapper 方法的参数是业务字段,不可能为每个方法都加一个 dataSourceKey 参数,这样会把持久层接口污染得没法看。而且如果一个方法内部连续调用两个 Mapper,这两个 Mapper 如果属于不同数据源,参数传递也很难优雅地处理中间切换。放在 ThreadLocal 里,整个请求生命周期内,任何一层都能随时读取当前路由 key。
ThreadLocal 的使用有一个必须牢记的规则:用完之后一定要清理。特别现在大多数应用部署在 Tomcat 这类线程池容器里,线程是被复用的。如果请求结束后不调用 remove(),下一个请求进来时,ThreadLocal 里的旧 key 很可能还在,数据源路由就会串库。串库这种问题最阴的地方是它不是必现的,只在线程复用后才会出现,线上排查的时候让人抓狂。
有了 ThreadLocal 之后,路由规则就变得很灵活。可以在 AOP 切面里根据注解值写入,也可以在一个拦截器里根据租户 ID 从缓存里查出数据源 key 后写入,还可以在一个 Service 方法内部临时切换再手动恢复。方案的核心骨架不变,路由来源随便扩展。
2.3 注解+AOP:把切换逻辑从业务代码里剥出去
既然路由 key 是通过 ThreadLocal 传递,那怎么优雅地写入这个 ThreadLocal 就成了一个设计问题。我选择的是"自定义注解 + AOP 切面"这套组合,也是目前动态数据源方案里最常见、最易维护的方式。
自定义注解 DataSourceSelector,作用在 Service 方法或者类上,注解的值就是数据源 key。AOP 切面在这个方法执行前读取注解值,写入 ThreadLocal;方法执行完成后,在 finally 块里清理 ThreadLocal。这样业务方法里不需要显式调用任何切换数据源的方法,注解即声明,非常贴合"声明式"的编程风格。
AOP 方案还有一个好处是集中管理。切面里可以统一做日志、异常处理、上下文清理的操作,不需要在每个切换数据源的方法里手动 try-finally。尤其是清理动作,如果散落在各个方法里,一百个方法可能有一百种写法,总会有遗漏。收拢到一个切面里,所有方法都走同一套逻辑,出问题的概率大大降低。
也要提醒一下 Spring AOP 的一个限制:它是基于动态代理的,在类内部自调用的时候,比如一个 Service 内部调了一个带有切换注解的私有方法,切面不会生效。因为内部调用绕过了代理对象,直接在 this 上调用。这个细节在 6.1 节我会单独展开讲排查思路,现在先有个印象。
3. 环境准备与基础配置
3.1 工程依赖引入
项目的技术栈是 Spring Boot 2.x 加 MyBatis,这套方案对版本没有特别苛刻的要求。关键的一个依赖是 Druid 的 Spring Boot Starter,用它可以直接通过配置创建 Druid 数据源,省去手动构造 DruidDataSource 的样板代码。
pom.xml 里核心依赖大概这样:
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>有几个版本上的细节想说一下。mysql-connector-j 是 MySQL 官方连接器的新 artifactId,老项目里还在用 mysql-connector-java,新项目建议直接换新坐标,避免未来维护成本。mybatis-spring-boot-starter 用 2.x 的原因是 Spring Boot 2.x 对应这个版本线,如果你用的 Boot 是 3.x,建议直接上 mybatis-spring-boot-starter 3.x 加 mybatis-spring-boot-starter-thymeleaf 那种新生态,老方案也能平移过去,但配置方式略有差异。
spring-boot-starter-aop 必须要引入,否则 @Aspect 注解不会生效,这是很多人容易忽略的。另外如果你的项目里没有 spring-boot-starter-web,记得单独引入,因为 Druid 的监控页需要 web 环境才能访问。
3.2 application.yml 里的多数据源配置
Druid Spring Boot Starter 支持直接在前缀下面配置数据源参数,因为我们会手动创建多个 DataSource Bean,yml 里配置好连接参数,再通过 @ConfigurationProperties 绑定到对应的 Bean 上。下面这份配置是我在项目里实际用过的,主库和从库的示例配置:
spring: datasource: druid: master: url: jdbc:mysql://192.168.1.10:3306/member_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: master_pass driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 2 max-active: 20 max-wait: 60000 slave: url: jdbc:mysql://192.168.1.11:3306/order_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: slave_pass driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 2 max-active: 20 max-wait: 60000这里有个配置顺序的小技巧,spring.datasource.druid 前缀下挂 master 和 slave 两个子节点,每个节点就是一套完整的 Druid 连接池参数。绑定时,master 数据源的 @ConfigurationProperties 指定为 spring.datasource.druid.master,slave 的指定为 spring.datasource.druid.slave。
连接参数自己按实际情况改。我这里把 max-active 设成 20,如果你的库同时被多个应用共用,要综合评估一下连接数上限,防止某个多数据源应用把数据库连接占满。
3.3 Druid 监控与连接池参数调优
用了 Druid 不做监控配置就浪费了。Druid 的监控能力是通过内置的 StatFilter 和 WebStatFilter 实现的,Spring Boot Starter 里默认集成了 StatViewServlet,访问路径默认是 /druid。
监控页面的密码和账号可以在配置里设置,生产环境务必开启,防止任何人访问连接池监控和 SQL 监控泄露数据库结构信息。配置方式是在 yml 里把 server 的 servlet 配置起来:
spring: datasource: druid: stat-view-servlet: enabled: true url-pattern: /druid/* login-username: admin login-password: admin_pass连接池参数调优这点要专门拿出来说。多数据源场景下,每个数据源都有一套连接池,如果每个数据源都设 max-active: 50,三个库就是 150 个连接,数据库端的连接数压力会被明显放大。
我的经验是从小到大调。初始连接数不用太大,5 到 10 就够,业务高峰期连接不够会慢慢涨到 max-active。max-wait 建议设置 60 秒,超过这个时间拿不到连接直接抛异常,比无限等待下探到数据库端更利于排查。remove-abandoned 这个参数默认是关闭的,生产环境多数据源场景我非常建议开启,配合 remove-abandoned-timeout 设置 180 秒,可以自动回收被业务代码借走但没归还的连接。
慢 SQL 的监控是 Druid 的另一大杀器。配置里加一个慢 SQL 阈值,比如 3 秒,Druid 会把超过阈值的 SQL 单独在监控页标出来。多数据源场景下,每个库的慢 SQL 混在一起,通过监控页的"数据源"维度去过滤,很快就能定位是哪个库、哪类查询出了问题。
4. 核心实现拆解
4.1 数据源配置与注册
先把两个物理数据源创建出来。我习惯在一个专门的 DataSourceConfig 配置类里统一处理,因为不仅要创建主从两个独立的 Druid 数据源,还要把动态数据源 DynamicDataSource 创建出来并注册为主数据源。
代码是这样的:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.druid.master") public DataSource masterDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.druid.slave") public DataSource slaveDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @Primary public DynamicDataSource dynamicDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put(DataSourceKey.MASTER, masterDataSource()); targetDataSources.put(DataSourceKey.SLAVE, slaveDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); return dynamicDataSource; } }这里我定义了一个 DataSourceKey 常量类,把 master 和 slave 的 key 统一管理,避免业务代码里到处写魔法字符串。DynamicDataSource 的默认数据源设成 master,也就是如果没有标注注解,请求默认走主库。对于大多数系统来说,主库是兜底,保证不会因为忘记标注注解而出现"找不到数据源"的运行时异常。
@Primary 注解是必须的。Spring Boot 在自动注入 DataSource 时会优先选择 @Primary 标记的 Bean,如果我们不把自己创建的 DynamicDataSource 标上 @Primary,Spring Boot 的自动配置可能还是会注入它自己创建的单数据源 DataSource,导致整个动态切换方案根本不生效。
4.2 动态数据源路由实现
DynamicDataSource 内部的实现非常简洁,继承 AbstractRoutingDataSource,重写 determineCurrentLookupKey():
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSource(); } }上面提到过,这个方法就是 Spring 在获取连接时寻找目标数据源的钥匙。返回值是什么,Spring 就会从 targetDataSources 这个 Map 里取对应的 DataSource。返回值是 null 的时候,Spring 会使用默认数据源,也就是 DynamicDataSource 构造时 setDefaultTargetDataSource 设置的那个。
配合的 DynamicDataSourceContextHolder 如下:
public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSource(String dataSourceKey) { CONTEXT_HOLDER.set(dataSourceKey); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } }这里要特别留意 controller 层和 service 层之间可能存在的异常穿透。如果某个 Mapper 在执行时抛了异常,而异常没被上层接住,ThreadLocal 没有被清理,这个线程回到线程池后就会把刚才的数据源 key 带到下一个请求里。所以我建议在使用 AOP 清理的同时,可以在 Web 层加一个拦截器,在 afterCompletion 阶段调用 clearDataSource() 做第二层兜底清理,双保险更稳妥。
4.3 动态切换注解与 AOP 切面实现
注解定义:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DataSourceSelector { String value() default DataSourceKey.MASTER; }允许作用在类型和方法上,也就是说可以在类级别统一声明该类下所有方法默认走哪个库,如果类里某个方法需要特殊路由,可以在方法级别标注覆盖。需要注意的是注解是不支持继承的,所以不要指望在一个类上标了 @DataSourceSelector("master"),它的子类重写方法时还能自动继承,代理对象继承的时候注解信息大概率会丢。
切面代码:
@Aspect @Component public class DataSourceAspect { @Before("@annotation(dataSourceSelector)") public void switchDataSource(JoinPoint joinPoint, DataSourceSelector dataSourceSelector) { DynamicDataSourceContextHolder.setDataSource(dataSourceSelector.value()); } @After("@annotation(dataSourceSelector)") public void clearDataSource(JoinPoint joinPoint, DataSourceSelector dataSourceSelector) { DynamicDataSourceContextHolder.clearDataSource(); } }这段切面的逻辑很简单,进入方法之前把注解值写入 ThreadLocal,方法退出之后清理。@annotation 这个切点的写法值得解释一下,它的意思是在所有带有 DataSourceSelector 注解的方法执行前切入,并且可以直接拿到注解对象作为参数,这种做法比通过反射去拿注解要简洁得多。
用法示例:
@Service public class OrderService { @DataSourceSelector(DataSourceKey.SLAVE) public Order getOrderById(Long orderId) { return orderMapper.selectByPrimaryKey(orderId); } }这个方法运行时会走 order_db 从库,而方法内部完全没有出现任何数据源相关的代码。业务人员看到注解就知道这个方法是读哪个库,清晰直观。
4.4 MyBatis 集成与 Mapper 扫描配置
MyBatis 和动态数据源结合的核心点在于 SqlSessionFactory 必须使用 DynamicDataSource 作为底层数据源。如果你在配置类里自行创建了 SqlSessionFactoryBean,要注意 dataSource 属性不能指向某个具体的 masterDataSource 或者 slaveDataSource,而要指向 dynamicDataSource。
由于我们在 DataSourceConfig 里已经注册了 DynamicDataSource 并且加了 @Primary,MyBatis 在自动装配时会拿到 DynamicDataSource,正常情况下不用手动配置 SqlSessionFactory。但如果你配置了多套 SqlSessionFactory,比如主从各一套,那动态切换就失效了,因为每个 SqlSessionFactory 绑定的是固定数据源。
Mapper 扫描这块有一个比较容易踩的坑。如果项目里原来用了 @MapperScan 并且指定了 sqlSessionFactoryRef,要确认这个 sqlSessionFactoryRef 使用的数据源是不是 DynamicDataSource。多数据源场景下,最稳妥的写法是 Mapper 接口还是用 @MapperScan 去扫描,但不要分别给不同库的 Mapper 配不同的 SqlSessionFactory,那样动态切换的路由就完全没意义了。除非你确实需要每个库一套独立的 MyBatis 缓存和插件配置,否则优先保证只用一个 SqlSessionFactory。
MyBatis 插件这块也要注意,PageHelper 这类分页插件在配置时,如果通过 configLocation 加载 mybatis-config.xml,里面的 dataSource 属性不需要填,因为 SqlSessionFactory 创建时会统一注入动态数据源。如果填了固定数据源,分页插件会持有错误数据源的连接,严重时会造成跨库查询。
5. 事务处理与切换陷阱
5.1 事务环境下切换失效的典型坑
动态数据源方案里踩过最多的坑,不是路由配置错了,而是和 Spring 事务一起工作时,切换会莫名其妙失效。我举一个亲身经历的案例:有一个 Service 方法标了 @Transactional,方法内先查会员信息走 master,然后调用一个标了 @DataSourceSelector(SLAVE) 的方法查订单,结果订单查询还是走了 master 库。
原因是 Spring 管理事务时,是在进入 @Transactional 方法之前就开启了数据库事务,并且把数据库连接绑定到当前线程上。后续所有 DAO 操作都复用这个绑定在事务上下文里的连接,动态路由的 determineCurrentLookupKey() 方法根本不会再次执行。也就是说,一旦事务开启,数据源就已经定死了,之后的路由切换全部失效。
这个问题在读写分离场景里特别致命。比如订单详情方法需要先查主库确认订单状态,再去从库读商品快照,两个查询放在同一个事务方法里,从库路由不生效,慢查询压力全到主库上。而且这个问题有个迷惑性,启动阶段用单线程本地调试的时候,切换是正常的,压测或者并发上来之后才暴露出路由不稳定,排查难度很高。
5.2 事务与切换的正确配合方式
解决事务和动态切换的冲突,有两条路。一条是把事务边界缩小,让 @Transactional 只作用在必须有事务的方法上,把查询操作排除在事务方法之外。另一条是在需要事务的写操作里,确保当前数据源和事务数据源一致,不依赖中间切换。
我的建议是优先把只读方法上的 @Transactional 去掉,只保留必要写方法的事务。Spring 的事务传播机制默认是 REQUIRED,如果外层调用者开启了事务,内层一个有 @Transactional 的方法会直接加入外层事务,哪怕它想开独立事务也不容易。所以不想让查询操作进入事务的话,要么外层不要标事务,要么内层用 @Transactional(propagation = Propagation.NOT_SUPPORTED) 挂起外层事务。挂起事务之后,动态切换才能生效,但也要考虑挂起带来的额外复杂度,不是所有场景都适合。
如果是多个库需要写操作,这个问题就已经超出了动态数据源切换的范畴,升级成分布式事务了。我一般在代码里用 TransactionTemplate 来控制某个数据源上的事务,把每个库自己的操作包进各自的事务模板里,不做跨库的强一致,只保证单库内事务完整。
要谨记一个原则:动态数据源切换解决的是路由问题,不是分布式事务问题。别试图让动态切换去背分布式事务的锅。
6. 常见问题排查与避坑指南
6.1 切换不生效的排查思路
切换不生效是我群里被问得最多的问题。我总结了几个排查方向,从易到难,按这个顺序走,大多数问题能在五分钟内定位。
第一,确认切面是否生效。Spring Boot 项目里要确认引入了 spring-boot-starter-aop,并且没有把 Spring AOP 的自动配置关掉。可以直接在切面里打一条日志,看 @Before 是否被触发,如果注释都没打印,基本是依赖缺失或者代理没生效。
第二,确认接口是否被代理。动态切换依赖的是 Spring AOP 代理,而 AOP 对实现接口的类默认走的是 JDK 动态代理,对没有接口的类走 CGLIB。如果 Service 类没有被正确代理,注解肯定不生效。常见的错误是同类内部调用,一个方法调用另一个带注解的方法,比如 this.getOrderInfo() 内部调用 this.getOrderDetail(),后者就算标了注解也不会被切面拦截,因为内部调用绕过了代理对象,直接在原始对象上执行。
第三,确认数据源 key 是否真的被写入。可以在 DynamicDataSourceContextHolder.getDataSource() 附近临时加日志,或者直接在 determineCurrentLookupKey() 里打日志。如果返回的 key 是 null,那它走的默认库;如果 key 是预期值,但 SQL 还是走错库,那大概率是事务把连接提前绑定住了。这时候去看调用栈里有没有 TransactionInterceptor,或者直接看方法入参附近有没有 @Transactional。
第四,检查 MyBatis 是否缓存了数据源。如果手动配置了多套 SqlSessionFactory,并且某套 SqlSessionFactory 的数据源指向了固定 slave,那这个 Mapper 的操作永远走 slave,不管线程上下文怎么切。遇到这种情况,把 SqlSessionFactory 的数据源统一改成 DynamicDataSource 就能恢复。
6.2 线程池泄密问题
ThreadLocal 不清理会导致串库,这个问题在 4.2 节提过,但实际排查中它的隐蔽性值得单独列一节。我见过最典型的故障是:一个管理系统里,管理员用户访问 A 租户的数据,然后同一台机器上的另一次请求被线程池安排到同一个线程上,新请求没有写数据源 key,却读到了旧请求留下的 key 指向的库。
这个问题的根治思路是双保险。第一层保险是 AOP 切面的 @After 清理,保证任何标注了注解的方法执行完都会清理上下文。第二层保险是在请求级拦截器里清理,比如实现 HandlerInterceptor 的 afterCompletion 方法,在请求彻底结束时不管业务成功失败都调一次 clearDataSource()。这两层配合之后,哪怕业务代码外层先开了事务再调内部切换方法,事务结束或请求结束时上下文也会被清理干净。
另一个需要注意的场景是异步线程。@Async 注解开启的新线程不会继承父线程的 ThreadLocal,所以如果在异步任务里需要走某个数据源,必须在异步方法内部重新设置数据源 key。不要试图在主线程里先 set,然后等异步线程执行,因为异步线程根本看不到主线程的 ThreadLocal。如果确实需要子线程继承上下文,可以考虑 TransmittableThreadLocal 这类专门为线程池场景设计的工具类,它能把上下文在父线程和子线程之间传递。
6.3 连接池耗尽问题
多数据源配置如果没做好容量规划,连接池耗尽几乎不可避免。现象很直接:应用日志满屏报 Connection cannot be obtained from the pool,请求响应越来越慢,直到完全不可用。
遇到这种情况,先不要急着重启,打开 Druid 监控页,看每个数据源当前的活跃连接数、空闲连接数、等待获取连接的线程数。如果某个数据源活跃连接数一直逼近 max-active,先看是不是该库的慢 SQL 太多,查询长时间占用连接不释放。Druid 监控页能看到每个数据源上的慢 SQL 统计,定位到嫌疑 SQL 后,用 explain 分析执行计划,看是不是缺索引、走了全表扫描。
如果活跃连接数不高,但请求还是拿不到连接,那可能是连接没归还。检查业务代码里是否有手动获取 Connection 但没 close 的情况,尤其是自己封装过 JDBC 工具类的项目。这种情况开启 Druid 的 remove-abandoned 参数能缓解,设置 remove-abandoned-timeout 大于某个阈值后,Druid 会回收借出超时的连接。我给生产环境的建议值是 timeout 180 秒,小于业务最长执行时间,避免正常的长事务被误杀。内存里还有一条经验:多数据源的总连接数建议控制在数据库实例 max_connections 的 60% 以内,留出余量给其他应用和运维操作。
6.4 MyBatis 缓存引起的脏读
MyBatis 的一级缓存是 SqlSession 级别的,默认开启。在 Spring 整合 MyBatis 之后,每个 SqlSession 生命周期很短,一级缓存问题不大。但二级缓存是跨 SqlSession 的,如果你的工程里开了 MyBatis 二级缓存,并且多个数据源通过同一个 SqlSessionFactory 访问,那么一个数据源上缓存的查询结果可能会被另一个数据源读到,造成脏读。
我遇到过这样一个问题:主库里刚更新了一个商品价格,从库的查询结果因为走了同样的 Mapper 和 SqlSessionFactory,返回的是之前缓存的旧价格。排查了很久,直到看到 mybatis-config.xml 里 cacheEnabled 默认配置被改成了 true 才定位到原因。多数据源动态切换方案里,我强烈建议把二级缓存关闭,默认 cacheEnabled 就是 false,保持默认就好。
如果某个查询确实对性能要求很高且数据不常变化,也不要依赖 MyBatis 二级缓存,直接在业务层用 Caffeine 或者其他本地缓存,根据数据源 key 拼缓存 key,这样能把缓存和数据库路由彻底隔离开,不会出现跨库脏读。
另外要注意,MyBatis 的 mapper XML 文件里如果有 flushCache 和 useCache 设置,也会影响缓存行为。统一在项目规范里明确禁用二级缓存,可以省掉一大批奇怪的线上问题。
7. 扩展建议与性能优化
7.1 读写分离的扩展姿势
上面讲的主要是手动标注注解来控制路由,如果项目的读写分离规则比较统一,可以进一步把路由做自动化。比如约定方法名规则,select 开头的方法自动走从库,insert、update、delete 开头的方法自动走主库。这个思路在 Spring 的 AbstractRoutingDataSource 方案里非常好实现,只需要在 AOP 切面里读方法名,匹配前缀就知道该走哪个数据源。
实际使用中我建议把自动路由和手动注解结合。统一规则自动化处理大部分方法,遇到个别特殊需求时,再通过注解覆盖。比如某个方法名是 select 开头,但逻辑上必须走主库才能拿到最新数据,就在方法上标 @DataSourceSelector(DataSourceKey.MASTER) 覆盖自动路由。切面的优先级设计为:如果方法有显式注解,注解优先;没有注解,才走方法名匹配规则。
自动路由的一个隐患是会隐藏掉真实的数据源选择逻辑,排查问题时直接看代码未必能一眼看出这个方法是走主库还是从库。建议在日志里记录每次请求的路由 key,用 MDC 把数据源 key 放进日志上下文,排查问题时能看到链路里每一步是用哪个数据源执行的,时间成本能省下一大半。
7.2 多租户场景的应用
多租户的动态路由思路和读写分离类似,区别在于数据源 key 的生成方式。租户和库的映射关系一般存在一个配置表或者配置中心里,请求进入系统后,从登录态、请求头或 token 里解析出租户 ID,再通过映射关系找到对应的数据源 key。
实现上有两个容易踩的坑。第一个是租户上下文的传递链路被打断,比如网关层解析出租户ID,放到 ThreadLocal 后,Feign 调用到下游服务时没有把租户ID放进请求头,下游服务拿不到租户信息,路由直接落到默认库,可能发生越权访问其他租户数据的严重问题。这个问题的解决办法是使用网关或拦截器统一传递租户ID,Feign 调用时通过 RequestInterceptor 把租户ID写进新请求头,下游服务的拦截器再取出并放到上下文。
第二个坑是租户ID映射数据源 key 的缓存策略。租户量大的时候,每次请求都查一遍配置表会造成不必要的开销。我的做法是用一个本地缓存,租户ID到数据源 key 的映射缓存五分钟,加上一个手动刷新接口,配置变更时主动清理缓存。这个方案在租户几百个以内的规模下运行稳定,更大的租户规模建议引入配置中心,实时推送映射变更。
7.3 与分布式事务的配合
动态数据源方案本身不解决分布式事务,但实际业务里,一个用户操作同时更新了两个库的场景很常见。我在一个订单履约流程里遇到的就是这样,订单库写订单,库存库扣库存,两个库分属不同实例。
分布式事务这一层我建议交给专门的框架处理,不要在业务代码里手写两阶段提交。常见的方案是 Seata 的 AT 模式,它对业务侵入小,业务代码上还是正常的数据库操作,事务协调由 Seata 的全局事务管理器统一控制。多数据源场景下,只需要确保那些需要参与全局事务的数据源都注册到 Seata 的数据源代理里。
如果项目里不想引入重量级分布式事务框架,也可以考虑本地消息表加最终一致性。比如订单和库存的操作,先写主库的业务表,再写一张本地消息表记录待处理操作,异步任务把这张消息表的数据发送到库存系统,库存系统处理成功后再更新消息状态。这套方案链路长,但胜在不依赖第三方组件,团队对它的掌控力更强。
我的建议是:实时性要求高、一致性强依赖的场景,用 Seata;要求不高、允许最终一致的场景,异步消息加补偿机制更轻量。动态数据源切换只是一个路由工具,别让它背上分布式事务的期望。
8. 收尾的个人体会
整套方案落地完成之后,我又花了一个下午把团队的代码规范补了一下,核心就是一条:所有涉及到多库路由的方法,必须显式标注数据源,不依赖默认库兜底的隐式路由。因为默认库兜底虽然避免了报错,但也会掩盖很多设计问题,比如一个本该走从库的方法因为忘记标注而一直打主库,主库压力大了还没人发现。
动态切换这套东西,代码量不大,真正难的是理解 Spring 管理连接和事务的那套机制。只要能想明白连接是在事务开始时绑定的、路由发生在获取连接的那一刻、线程池会复用线程这三点,遇到问题基本都能自己排查出来。如果你正在做多数据源,我建议先把 AbstractRoutingDataSource 源码过一遍,配合 Druid 的监控页观察连接池变化,会比背任何配置都有用。
最后再分享一个小技巧:在切换注解里加一个 switchDescription 属性,标注一下这个切换的业务原因,比如"读订单从库"、"写会员主库"。排查问题时能根据描述快速定位是哪条业务链路设置的 key,遇到数据串库的问题时,这个字段能帮你省下大半天的时间。