☰
Spring Boot多数据源切换:@DS与@DSTransaction实战拆解
2026/10/1 13:13:21 网站建设 项目流程

很多做Java后端的朋友看到"@DS"这个缩写,第一反应肯定是最近到处刷屏的那个大模型。这里得先把身份掰清楚:咱们要聊的@DS,来自dynamic-datasource-spring-boot-starter这个框架,全称是Dynamic Datasource,解决的是Spring Boot多数据源管理的问题。它和@DSTransaction搭配起来,能让你在一个应用里同时连好几套数据库,还能在多库之间做"模拟事务"。这篇文章我会从实际项目出发,把这俩注解的用法、底层原理、以及我在生产环境踩过的坑全部掰开揉碎讲一遍。适合正在做读写分离、多租户隔离、业务拆库,或者单纯被多数据源切换搞得怀疑人生的同学。

这套东西解决的核心问题只有一句话:让"一个方法到底用哪个数据源"从代码里解放出来,变成声明式的注解配置。下面我从为什么需要它开始讲起。

1. 为什么需要@DS:多数据源管理的三个经典场景

1.1 读写分离:一个库写,多个库读

最典型的就是MySQL主从架构。主库负责insert/update/delete,从库负责select,应用层需要根据SQL类型路由到不同的库。不用框架的时候,最常见的做法是MyBatis的Interceptor里根据SQL前缀判断,或者自己写个ThreadLocal保存当前要用的数据源key,然后在获取Connection之前手动切换。这种方式不是说不行,但代码侵入性太强,SQL解析还有各种边界case,一旦SQL写法不规范就翻车。

用@DS之后,读写分离就变成在Service方法上标一个注解的事。查询方法标@DS("slave"),写入方法默认走主库,清晰直白。尤其是从库多的时候,框架还自带负载均衡,不用你自己写轮询算法。

1.2 多租户隔离:每个租户一套库

SaaS系统里经常要求租户之间数据物理隔离,每个租户独立数据库。这种场景下,数据源的数量是动态变化的——新签一个租户就多一个库。如果用老办法在配置里写死,每次上租户都要改配置发版,运维想打人。基于@DS配合注册中心或者配置中心,把数据源key和租户ID绑定,请求进来的时候根据租户ID动态选择数据源,新租户只要往配置中心加一段配置就能生效。这是我在实际项目里觉得它最值钱的地方。

1.3 业务拆库:订单、用户、日志各管各的

微服务拆分的过渡阶段,经常会出现一个服务要同时操作好几个库的情况。比如订单服务需要查用户信息,但用户库是独立的。这时候如果你用默认的@Transactional标在方法上,事务只能作用在一个数据源上,第二个库的操作就完全不归这个事务管,数据一致性根本保证不了。这也是@DSTransaction存在的意义,后面我会专门讲。

1.4 手写切换的老路,痛点在哪里

很多人会说:不就用个AbstractRoutingDataSource吗?确实,Spring本身就提供了这个路由数据源的抽象,核心方法就是determineCurrentLookupKey(),返回当前线程要用哪个数据源的key。但问题在于:这个key怎么来?你得自己在业务代码里往ThreadLocal里塞、用完再清,不然就是线程污染。而且切数据源和事务的顺序是个大坑——事务管理器拿Connection的时候,如果数据源还没切过去,事务就已经绑定到错误的库上了。

dynamic-datasource这套框架的本质,就是把"往ThreadLocal塞key、切数据源、和Spring事务协同"这些脏活累活全部封装好了。你只需要关心业务逻辑,数据源怎么选,交给注解。这也是我推荐它的直接原因:不是因为它有多高大上,而是它把大家都要踩的坑提前填平了。

2. @DS注解使用详解:声明式切换的正确姿势

2.1 基础配置:yml里先声明好数据源

先看一个最基础的多数据源配置,这里用的是HikariCP:

spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://127.0.0.1:3306/master_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://127.0.0.1:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver user: url: jdbc:postgresql://127.0.0.1:5432/user_db username: postgres password: postgres123 driver-class-name: org.postgresql.Driver

这里要注意几个点:

  • primary指定默认数据源,不加@DS的时候就走它。
  • strict: false表示找不到指定数据源时回退到primary;strict: true则直接抛异常。生产环境我建议设成true,否则一个拼写错误会让数据悄悄写到主库,定位问题的时候欲哭无泪。
  • 数据源类型不限制,可以混用MySQL、PostgreSQL、Oracle,框架统一管理。
  • 别忘了引入对应数据库的driver依赖,这个不用多说。

引入依赖就一行Maven坐标:

<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.1.3</version> </dependency>

我目前用的是4.x版本,3.x版本API基本一致,但建议新项目直接上4.x。

2.2 类级别和方法级别:谁的优先级高

@DS可以标在Service实现类上,也可以标在方法上。用法很简单:

@Service @DS("order") public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private UserMapper userMapper; @Override @DS("user") public UserInfo getUserInfo(Long userId) { return userMapper.selectById(userId); } @Override public List<Order> listOrders(Long userId) { return orderMapper.selectByUserId(userId); } }

优先级规则很直白:方法上的@DS优先于类上的@DS。getUserInfo方法标了@DS("user"),所以它会去查user库;listOrders方法没标,就走类级别的"order"库。如果类上也没标,走primary。

需要提醒的一点:@DS建议标在Service实现类的方法上,不要直接标在Mapper接口上。原因有两个:一是Mapper接口的代理和Service的代理层级不同,事务管理器拿到的数据源上下文可能还没设置好;二是如果某个Service方法内部调用了多个Mapper,你希望这个Service方法统一走一个数据源,标在Mapper上会导致一个方法里来回切换,性能反而下降。当然,如果你的Mapper有特殊需求必须独立切库,比如一个Service方法里要同时查两个不同库的Mapper,那也是可以的,但这种情况我建议拆成两个独立Service方法,通过注入别的Service去调,代码结构更清晰。

2.3 进阶玩法:分组、通配符和SpEL动态选库

这三个功能是dynamic-datasource比较有特色的地方。

分组负载均衡。很多项目有多个从库,配置成slave_1、slave_2、slave_3的命名形式,然后用@DS("slave")就可以自动在这几个库之间做负载均衡:

spring: datasource: dynamic: datasource: master: url: jdbc:mysql://127.0.0.1:3306/master_db slave_1: url: jdbc:mysql://127.0.0.1:3306/slave_1_db slave_2: url: jdbc:mysql://127.0.0.1:3306/slave_2_db
@Service public class ReportServiceImpl implements ReportService { @Override @DS("slave") // slave_1 和 slave_2 之间轮询 public List<ReportVO> queryReport() { return reportMapper.selectAll(); } @Override @DS("slave_2") // 强制走 slave_2 public List<ReportVO> queryReportFromSlave2() { return reportMapper.selectAll(); } }

分组的命名规则是:下划线_后面的部分作为组内标识,前缀相同的自动归为一组。默认负载均衡算法是轮询,也支持随机和权重,可以自己扩展。

通配符匹配。这个功能在配置动态增加数据源的场景里很实用。比如你的数据源是order_2024、order_2025这种按年份拆的库,用@DS("*_2024")这种通配符写法就能匹配到后缀为_2024的库。它本质上就是用AntPath风格做匹配,*匹配任意字符。不过说实话,通配符用多了反而让路由逻辑变得隐晦,我建议只在确实需要动态匹配的场景里用,日常固定库还是写明确的key。

SpEL动态选库。这个是大杀器,它让@DS的值可以不是写死的字符串,而是从上下文里算出来的:

@DS("#session.tenantId") public List<TenantOrder> listTenantOrders() { // 根据当前会话的租户ID动态选择数据源 } @DS("#header.tenantId") public List<TenantOrder> listTenantOrdersByHeader() { // 根据请求头的租户ID动态选择数据源 } @DS("#tenantId") public List<TenantOrder> listTenantOrdersBySpel() { // 从Spring容器中获取名为tenantId的Bean,取toString()作为key }

SpEL支持从以下位置取值:#session、#header、#parameter(参数),以及Spring容器中的Bean(直接写Bean名)。配合多租户场景,可以在拦截器里把租户ID塞进请求上下文,然后注解直接用SpEL取,新租户接入连代码都不用改。实现原理是拦截器里用SpelExpressionParser解析表达式,然后从RequestContextHolder或Bean容器里解析出实际值,再作为数据源key去路由。这里有个小坑:SpEL解析失败或者Bean不存在时,框架会默认回退到primary数据源,所以租户信息拿不到的时候,错误会隐藏得很深,排查问题的时候留个心眼。

2.4 切换失效的几个雷区

用@DS最常见的翻车现场,我整理成几句话:

第一,同类内部调用不走代理。@DS是靠Spring AOP实现的,AOP代理只有在通过Spring容器注入的Bean上才生效。如果你在一个Service里直接this.listUsers()调用同类里的另一个方法,注解完全不生效,因为这里调用的是原生对象而不是代理对象。解决办法是把需要切数据源的方法拆到另一个Bean里,或者用@Resource注入自己(代理对象)再调用。

第二,线程切换之后ThreadLocal值丢失。@DS底层用ThreadLocal保存当前数据源key,你在主线程设置的key,到了子线程、线程池、异步任务里全都拿不到。如果异步任务里也需要切数据源,必须在任务方法内部重新标@DS,或者把数据源key作为参数传进去再手动DynamicDataSourceContextHolder.push(),用完记得poll()。

第三,事务一旦开启,数据源就锁死了。这个坑和Spring事务纠缠在一起,很多人排查半天发现是事务的问题。Spring的@Transactional会在事务开始时获取一个数据库连接,这个连接绑定到当前事务,直到事务结束。如果你的方法先被事务拦截器处理、数据源还没切、连接就拿错了,那后面再切数据源也没用了,因为事务里已经持有旧连接的引用。这就是为什么dynamic-datasource的@DS拦截器必须比Spring事务拦截器先执行的原因,后面原理章节会细讲。

3. @DSTransaction:没有XA的"模拟"事务

3.1 多数据源下的事务困境

先看一个经典场景:订单系统下单,要往订单库写一条订单记录,同时要扣减用户库里的库存。两个操作在两个库上,如果你只在方法上标@Transactional,Spring事务管理器默认只管理一个数据源——也就是主数据源。你可能会想当然地认为事务覆盖了所有操作,但实际上第二个库的操作在独立连接上执行,根本不在同一个事务里。一个成功、一个失败,数据就不一致了。

有人会说:用XA两阶段提交不就行了?理论上是这样,但XA协议在互联网公司用得很少,因为它对数据库锁的持有时间太长,并发一高就出问题,而且MySQL对XA的支持也不算友好。更常见的轻量级方案,就是@DSTransaction这种"手动管理多连接"的思路。

3.2 用法和实现思路

先看用法,和@Transactional几乎一样:

@Service @DS("order") public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private UserMapper userMapper; @Override @DS("user") public void deductStock(Long productId, Integer count) { userMapper.deductStock(productId, count); } @Override @DSTransaction // 注意:标在入口方法上 public void createOrder(OrderDTO dto) { orderMapper.insertOrder(dto.getOrder()); deductStock(dto.getProductId(), dto.getCount()); } }

注意看,@DSTransaction标在createOrder上,这个方法是入口。方法内部先写order库,再调用deductStock去写user库。@DSTransaction的原理可以这样理解:

  1. 拦截器AOP拦截到createOrder方法执行前,从当前数据源路由状态获取一个事务上下文。
  2. 业务方法执行过程中,只要用到了某个数据源(通过@DS切换),框架就会从该数据源获取一个连接,设置autoCommit=false,并把连接登记到当前线程的事务资源栈里。
  3. 方法执行完毕、没有抛出异常,就依次commit所有登记过的连接。
  4. 如果任何一步抛出异常,就依次rollback所有连接。

这个过程本质上就是"手动开启多个本地事务,最终一起提交或者一起回滚",所以也叫模拟分布式事务,或者最大努力型事务(Best Effort)。这里有一个非常重要的细节:@DSTransaction不要求你显式写@DS去切库,它的内部会通过数据源路由的状态自动发现本次操作涉及了哪些数据源。但前提是相关操作必须有@DS正确标识,否则全部走默认主库,那也就谈不上多数据源事务了。

3.3 核心局限:它不是分布式事务

先泼冷水:@DSTransaction并不能替代真正的分布式事务。它的局限非常明显:

  • 提交阶段无法保证原子性。假设A库commit成功,B库commit的时候抛异常了,框架只能对B库做rollback,但A库已经提交了,没法撤销。这种场景虽然罕见,但一旦发生就是脏数据,只能靠对账任务修正。
  • 没有隔离性。多库之间没有全局锁,并发场景下两个事务同时改不同库的同一行数据,都可能提交成功,形成逻辑上的冲突。
  • 不支持跨服务。它基于线程内的连接管理,RPC调用到另外一个服务,那边是另一个线程,本地连接根本传不过去。跨服务的分布式事务还是得靠Seata的@GlobalTransactional。

所以我给团队定的规矩是:单应用内、多数据源、低并发、可以接受极少概率不一致的场景,用@DSTransaction完全没问题,简单高效零额外依赖。一旦涉及跨服务、资金类强一致场景,直接上Seata,别拿模拟事务顶包。

4. 原理深扒:ThreadLocal、AOP和AbstractRoutingDataSource

4.1 路由数据源:determineCurrentLookupKey是心脏

dynamic-datasource的核心类叫DynamicRoutingDataSource,它继承了Spring的AbstractRoutingDataSource。这类里面的核心方法就是一个determineCurrentLookupKey(),用来决定当前线程从哪拿连接:

public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.peek(); } }

就这么简单。DynamicDataSourceContextHolder内部维护了一个ThreadLocal<Deque<String>>,Deque的原因是支持数据源的嵌套切换。你调用push(key)往里塞一个key,调用poll()弹出一个key,peek()看栈顶的key。AbstractRoutingDataSource在每次getConnection()的时候都会调用这个determineCurrentLookupKey(),然后用返回的key到它管理的targetDataSources这个Map里去取真正的DataSource,再从这个DataSource里拿Connection。

所以整个链条是这样的:@DS注解 → AOP拦截器 →push(key)→getConnection()→determineCurrentLookupKey()→peek()→ Map取真实数据源 → 拿到连接。

4.2 拦截器三连:push、执行、poll

@DS注解的切面是DynamicDataSourceAnnotationInterceptor,它实现了Spring AOP的MethodInterceptor。核心逻辑就三步:

public Object invoke(MethodInvocation invocation) throws Throwable { // 1. 解析注解上的数据源key,支持SpEL String dsKey = determineDatasourceKey(invocation); // 2. 把key压入栈 DynamicDataSourceContextHolder.push(dsKey); try { // 3. 执行真正的业务方法 return invocation.proceed(); } finally { // 4. 无论成功失败,最后都要弹出key DynamicDataSourceContextHolder.poll(); } }

这里有个细节值得注意:push和poll放在try-finally里,确保业务方法抛出异常时ThreadLocal也能被正确清理,不会污染线程池里的下一个任务。我之前见过有人自己写切数据源的工具类,finally里忘了清理,结果线上偶发"数据写错库"的问题,查了一天最后定位到是ThreadLocal残留。这就是为什么我强烈建议别自己造轮子,框架帮你处理了几千个这种边界case。

关于拦截器注册,框架用的是DynamicDataSourceAnnotationAdvisor,把拦截器绑定到所有被@DS标注的方法上。这个Advisor的order设得非常高(Ordered.HIGHEST_PRECEDENCE),就是为了保证它能在Spring事务拦截器之前拿到数据源切换的机会。为什么必须这样?因为@Transactional的拦截器会在业务方法开始前就调用DataSourceUtils.getConnection()把连接绑定到事务上,如果@DS拦截器在它后面执行,数据源还没切,事务已经拿到主库连接了,后面一切都晚了。

4.3 DSTransactionInterceptor:连接登记和统一提交回滚

@DSTransaction的核心是DSTransactionInterceptor。它比@DS的拦截器复杂得多,因为要在方法执行期间动态发现所有用到的数据源连接。大致的内部结构:

public Object invoke(MethodInvocation invocation) throws Throwable { DynamicTransactionContext context = new DynamicTransactionContext(); DynamicTransactionContextHolder.push(context); try { Object result = invocation.proceed(); // 业务正常,提交所有登记过的连接 context.commit(); return result; } catch (Throwable t) { // 业务异常,回滚所有连接 context.rollback(); throw t; } finally { DynamicTransactionContextHolder.poll(); context.close(); } }

连接是怎么被"发现"并登记的?框架定义了一个ConnectionFactory,当你在@DSTransaction包裹的方法里调用任何DAO操作时,一旦线程上下文里存在DynamicTransactionContext,框架就会拦截连接获取流程,从对应的数据源取出连接并设置autoCommit(false),记录到context的一个Map里(key是数据源,value是连接)。同一个数据源多次操作复用同一个连接,不同数据源各管各的连接。最后commit阶段,遍历这个Map逐个提交。

这也解释了为什么@DSTransaction能在一个方法里切多个数据源,而@Transactional不行——因为后者只认一个事务管理器和一个DataSource,而前者自己管理了一篮子连接。

4.4 事务和动态数据源要配合使用

既然聊到原理,顺便把@Transactional和@DS的配合说透。我的经验规则是:

  • 只在单个数据源上需要强事务,直接用@Transactional,不需要@DSTransaction。此时@DS要在事务方法里正确指向目标库,并且让@DS拦截器先执行。框架默认的拦截器顺序已经保证了这一点,但如果你自定义AOP切面排在了事务前面,就可能出问题。
  • 一个方法跨多个数据源且需要一致性兜底,用@DSTransaction。它自己管连接,不需要你同时标@Transactional,但要注意:@DSTransaction内部如果还有方法标了@Transactional,可能产生连接提前提交的问题,嵌套使用要小心。
  • 从性能角度看,@DSTransaction持有多个连接的时间是整个业务方法执行时间,比单库事务锁资源更重。所以方法体要尽量精简,不要在事务里做RPC、发消息、文件IO这种耗时操作。你不想让一个慢接口把好几个库的连接池都拖垮。

5. 常见问题与排查技巧实录

5.1 @DS不生效,先查这三件事

我整理了一个问题速查表,遇到"注解标了但没切库"的问题,按顺序排查:

现象根因解决办法
注解方法没走代理同类this调用或者对象未由Spring管理拆Bean注入代理对象,或者用AopContext.currentProxy()
子线程/线程池里切换失败ThreadLocal不跨线程传播异步任务方法内重新标@DS,或手动push/poll
事务绑定到了错误数据源@Transactional和@DS顺序不对确认自定义AOP没把数据源拦截器挤到后面

另外有个看起来很像"不生效"的现象:你标了@DS("slave"),但在@Transactional方法里查数据,结果查出来的是主库数据。原因上面说过——事务先拿连接。如果你确实需要事务+从库,有两条路:一是把查询和事务拆开,查询方法独立标@DS;二是用@DSTransaction替代@Transactional,让连接获取延迟到实际执行DAO的时候。

5.2 @DSTransaction的嵌套和并发坑

@DSTransaction嵌套@DSTransaction的情况,框架内部用栈来管理多个事务上下文。理论上嵌套没问题,内层方法回滚时只会回滚自己登记的连接,外层仍可正常提交。但这里有个隐蔽的问题:如果内层已经成功提交了某个数据源的连接,外层后续又出错了,内层提交掉的那部分是无法回滚的。所以嵌套@DSTransaction时,我会明确要求团队:内层方法如果涉及写操作,不要自己标@DSTransaction,统一交给最外层管理。

还有一个高频坑:@DSTransaction方法里起了异步线程去操作别的库。异步线程里的连接不会被外层事务context管理到,因为DynamicTransactionContext也是存在ThreadLocal里的,子线程压根看不到。结果就是异步线程自己auto-commit,主线程提交失败回滚的时候,异步线程的改动已经落库了。建议:@DSTransaction方法内禁止异步写操作,有异步需求就把异步任务放到事务外面去。

5.3 连接数暴涨:小心连接泄漏

用@DS最怕的另一个问题不是切错库,而是连接泄漏。正常情况下DynamicDataSourceContextHolder.push()之后,拦截器finally里会poll(),连接会正常归还。但有几个隐蔽场景会把连接卡死:

  • 业务方法非常长,事务context一直持有连接不释放,连接池被打满。Druid监控里能看到活跃连接数持续在高位。
  • @DSTransaction包裹的方法里调用了另一个服务,而那个服务又同步回调回来访问同一个应用的库,会造成连接等待死锁。
  • 框架版本比较老,某些异常路径下poll()没执行,ThreadLocal残留,连接被线程池的下一个任务复用导致串库。

排查手段:打开Druid监控页看activeCount、池中连接状态;或者用jstack看线程堆栈,确认是不是卡在getConnection();再不行就在DynamicDataSourceContextHolder.push()的调用处打断点,看key什么时候被push、什么时候被poll。我遇到过最离谱的一次,是同事在异步线程里手动调了push()但忘了poll(),导致线程池里所有线程的数据源key全部串了,线上数据写错库——排查的时候检查ThreadLocal里的Deque大小,问题立刻水落石出。

5.4 一个实践建议组合

最后给一套我比较推荐的生产配置组合。数据源用Druid连接池(监控好、参数丰富),事务这块明确区分场景:单数据源强一致用@Transactional;单应用内多数据源最终一致、可容忍极小概率不一致的用@DSTransaction;跨服务强一致直接上Seata。@DS的key命名强制规范,主库叫master,从库用slave_前缀,业务库用业务名,禁止随手写db1、db2这种数字命名。配置里strict设true,让错误尽早暴露。

这套dynamic-datasource框架我已经在生产环境用了四五年,从小项目到大流量业务都扛过来了。我个人的体会是:@DS的价值不在于它有多复杂的黑科技,而在于它把数据源切换从"每个人都自己写一遍还很可能会写错"变成了"一个注解解决",并且把事务顺序、ThreadLocal清理这些最容易出事的边界case都封装好了。反倒是@DSTransaction,我建议所有第一次接触它的人都把它当成"一个有一定容错能力的轻量工具"来用,千万别拿它当分布式事务的万能解药。最后再分享一个小经验:如果你在排查问题时不确定当前线程到底走的是哪个数据源,可以直接在代码里临时打一行DynamicDataSourceContextHolder.peek(),把这个值打印出来看看,比任何日志分析都直观。希望这篇拆解能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询