恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Spring Boot多数据源切换:@DS与@DSTransaction实战拆解

  • 首页
  • 资讯中心
  • /
  • Spring Boot多数据源切换:@DS与@DSTransaction实战拆解

相关资讯

Python灰度图像彩色化实战:从Lab特征到U-Net模型与可复现报告 2026/10/1 13:13:23
CDATA实战指南:解决XML特殊字符转义与解析的难题 2026/10/1 13:13:23
用Canvas实现像素级取色器:从getImageData到放大镜的完整实践 2026/10/1 13:13:23

最新资讯

无刷直流电机(BLDC)工作原理详解:与有刷电机、PMSM 的区别及选型
中文短文本LDA主题建模实战:豆瓣小组数据处理与调优
国产AI ERP服务商有哪些?2026年一份可核验的名单与选型思路
都AI时代了,我为何还在学习前端基础知识?
企业如何低门槛搭建AI智能体?2026年五步落地法与避坑清单
macOS卸载深信服EDR:系统扩展、TCC权限与MDM解绑全解析

今日推荐

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

本周热门

从像素到笔画:srt-whiteboard-animation骨架笔迹追踪实现(Zhang-Suen细化+8邻接追踪)
网站建设的英语怎么说?别只背单词,看完这套安全完整流程才敢上线
新手入门看这篇:建设网站加盟避坑指南与SEO实操

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

Spring Boot多数据源切换:@DS与@DSTransaction实战拆解

发布时间:2026/10/1 13:13:23
Spring Boot多数据源切换:@DS与@DSTransaction实战拆解 很多做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里先声明好数据源先看一个最基础的多数据源配置这里用的是HikariCPspring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://127.0.0.1:3306/master_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://127.0.0.1:3306/order_db?useSSLfalseserverTimezoneAsia/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表示找不到指定数据源时回退到primarystrict: true则直接抛异常。生产环境我建议设成true否则一个拼写错误会让数据悄悄写到主库定位问题的时候欲哭无泪。数据源类型不限制可以混用MySQL、PostgreSQL、Oracle框架统一管理。别忘了引入对应数据库的driver依赖这个不用多说。引入依赖就一行Maven坐标dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version4.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 ListOrder 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_dbService public class ReportServiceImpl implements ReportService { Override DS(slave) // slave_1 和 slave_2 之间轮询 public ListReportVO queryReport() { return reportMapper.selectAll(); } Override DS(slave_2) // 强制走 slave_2 public ListReportVO queryReportFromSlave2() { return reportMapper.selectAll(); } }分组的命名规则是下划线_后面的部分作为组内标识前缀相同的自动归为一组。默认负载均衡算法是轮询也支持随机和权重可以自己扩展。通配符匹配。这个功能在配置动态增加数据源的场景里很实用。比如你的数据源是order_2024、order_2025这种按年份拆的库用DS(*_2024)这种通配符写法就能匹配到后缀为_2024的库。它本质上就是用AntPath风格做匹配*匹配任意字符。不过说实话通配符用多了反而让路由逻辑变得隐晦我建议只在确实需要动态匹配的场景里用日常固定库还是写明确的key。SpEL动态选库。这个是大杀器它让DS的值可以不是写死的字符串而是从上下文里算出来的DS(#session.tenantId) public ListTenantOrder listTenantOrders() { // 根据当前会话的租户ID动态选择数据源 } DS(#header.tenantId) public ListTenantOrder listTenantOrdersByHeader() { // 根据请求头的租户ID动态选择数据源 } DS(#tenantId) public ListTenantOrder 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 多数据源下的事务困境先看一个经典场景订单系统下单要往订单库写一条订单记录同时要扣减用户库里的库存。两个操作在两个库上如果你只在方法上标TransactionalSpring事务管理器默认只管理一个数据源——也就是主数据源。你可能会想当然地认为事务覆盖了所有操作但实际上第二个库的操作在独立连接上执行根本不在同一个事务里。一个成功、一个失败数据就不一致了。有人会说用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的原理可以这样理解拦截器AOP拦截到createOrder方法执行前从当前数据源路由状态获取一个事务上下文。业务方法执行过程中只要用到了某个数据源通过DS切换框架就会从该数据源获取一个连接设置autoCommitfalse并把连接登记到当前线程的事务资源栈里。方法执行完毕、没有抛出异常就依次commit所有登记过的连接。如果任何一步抛出异常就依次rollback所有连接。这个过程本质上就是手动开启多个本地事务最终一起提交或者一起回滚所以也叫模拟分布式事务或者最大努力型事务Best Effort。这里有一个非常重要的细节DSTransaction不要求你显式写DS去切库它的内部会通过数据源路由的状态自动发现本次操作涉及了哪些数据源。但前提是相关操作必须有DS正确标识否则全部走默认主库那也就谈不上多数据源事务了。3.3 核心局限它不是分布式事务先泼冷水DSTransaction并不能替代真正的分布式事务。它的局限非常明显提交阶段无法保证原子性。假设A库commit成功B库commit的时候抛异常了框架只能对B库做rollback但A库已经提交了没法撤销。这种场景虽然罕见但一旦发生就是脏数据只能靠对账任务修正。没有隔离性。多库之间没有全局锁并发场景下两个事务同时改不同库的同一行数据都可能提交成功形成逻辑上的冲突。不支持跨服务。它基于线程内的连接管理RPC调用到另外一个服务那边是另一个线程本地连接根本传不过去。跨服务的分布式事务还是得靠Seata的GlobalTransactional。所以我给团队定的规矩是单应用内、多数据源、低并发、可以接受极少概率不一致的场景用DSTransaction完全没问题简单高效零额外依赖。一旦涉及跨服务、资金类强一致场景直接上Seata别拿模拟事务顶包。4. 原理深扒ThreadLocal、AOP和AbstractRoutingDataSource4.1 路由数据源determineCurrentLookupKey是心脏dynamic-datasource的核心类叫DynamicRoutingDataSource它继承了Spring的AbstractRoutingDataSource。这类里面的核心方法就是一个determineCurrentLookupKey()用来决定当前线程从哪拿连接public class DynamicRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.peek(); } }就这么简单。DynamicDataSourceContextHolder内部维护了一个ThreadLocalDequeStringDeque的原因是支持数据源的嵌套切换。你调用push(key)往里塞一个key调用poll()弹出一个keypeek()看栈顶的key。AbstractRoutingDataSource在每次getConnection()的时候都会调用这个determineCurrentLookupKey()然后用返回的key到它管理的targetDataSources这个Map里去取真正的DataSource再从这个DataSource里拿Connection。所以整个链条是这样的DS注解 → AOP拦截器 →push(key)→getConnection()→determineCurrentLookupKey()→peek()→ Map取真实数据源 → 拿到连接。4.2 拦截器三连push、执行、pollDS注解的切面是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()把这个值打印出来看看比任何日志分析都直观。希望这篇拆解能帮你少踩几个坑。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号