资讯详情

动态多数据源切换方案:Druid+Spring Boot+MyBatis实战解析

📅 2026/10/17 3:56:53 | 华诺云谱 👁 阅读
动态多数据源切换方案:Druid+Spring Boot+MyBatis实战解析
老项目里那套多数据源逻辑让我头疼了很久业务线按行业拆库用户入口却是同一个系统Service层全是 if-else 手切 Connection 的代码新增一个库就要把业务逻辑翻一遍。后来我用 Druid Spring Boot MyBatis 做了一套动态数据源切换方案把这摊子彻底理顺了。整套方案核心就三件事Druid 管理连接池、AbstractRoutingDataSource 做路由、注解加 AOP 收敛切换逻辑。落地之后新增一个数据源只需要改配置和注册路由业务代码零改动。这篇文章围绕这套动态多数据源方案展开背景、设计、实现、踩坑全部讲透。正在做读写分离、多租户拆分、按业务域拆库的 Spring Boot 开发者尤其是想在现有 MyBatis 项目里平滑接入多数据源动态切换的人可以直接照着抄。1. 项目背景这个方案到底解决什么问题1.1 场景驱动的需求拆解先别急着写代码想清楚为什么需要动态多数据源方案才不会做偏。我遇到的最典型场景就是业务隔离某个平台有几套业务线会员、订单、商品各放一个库表面看起来是一个系统实际数据分了三处。早期做法是在 DAO 层拿到DataSource 后手动 getConnection()Service 层根据业务类型去选择库代码里充满了 switch-case每接入一个业务域就要改一遍上层逻辑。还有一类常见需求是读写分离。主库承担写入从库扛查询流量这类场景在报表、监控和交易系统里很常见。如果不用动态切换比较土的做法是同一个 Service 方法里直接声明两个 JdbcTemplateSelect 走 slaveInsert 走 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内部维护了一个 Mapkey 是我们自定义的数据源标识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 的 MapperMapper 方法的参数是业务字段不可能为每个方法都加一个 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 groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 Beanyml 里配置好连接参数再通过 ConfigurationProperties 绑定到对应的 Bean 上。下面这份配置是我在项目里实际用过的主库和从库的示例配置spring: datasource: druid: master: url: jdbc:mysql://192.168.1.10:3306/member_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/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?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/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.masterslave 的指定为 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(); MapObject, 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 标上 PrimarySpring 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 ThreadLocalString 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 并且加了 PrimaryMyBatis 在自动装配时会拿到 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放进请求头下游服务拿不到租户信息路由直接落到默认库可能发生越权访问其他租户数据的严重问题。这个问题的解决办法是使用网关或拦截器统一传递租户IDFeign 调用时通过 RequestInterceptor 把租户ID写进新请求头下游服务的拦截器再取出并放到上下文。第二个坑是租户ID映射数据源 key 的缓存策略。租户量大的时候每次请求都查一遍配置表会造成不必要的开销。我的做法是用一个本地缓存租户ID到数据源 key 的映射缓存五分钟加上一个手动刷新接口配置变更时主动清理缓存。这个方案在租户几百个以内的规模下运行稳定更大的租户规模建议引入配置中心实时推送映射变更。7.3 与分布式事务的配合动态数据源方案本身不解决分布式事务但实际业务里一个用户操作同时更新了两个库的场景很常见。我在一个订单履约流程里遇到的就是这样订单库写订单库存库扣库存两个库分属不同实例。分布式事务这一层我建议交给专门的框架处理不要在业务代码里手写两阶段提交。常见的方案是 Seata 的 AT 模式它对业务侵入小业务代码上还是正常的数据库操作事务协调由 Seata 的全局事务管理器统一控制。多数据源场景下只需要确保那些需要参与全局事务的数据源都注册到 Seata 的数据源代理里。如果项目里不想引入重量级分布式事务框架也可以考虑本地消息表加最终一致性。比如订单和库存的操作先写主库的业务表再写一张本地消息表记录待处理操作异步任务把这张消息表的数据发送到库存系统库存系统处理成功后再更新消息状态。这套方案链路长但胜在不依赖第三方组件团队对它的掌控力更强。我的建议是实时性要求高、一致性强依赖的场景用 Seata要求不高、允许最终一致的场景异步消息加补偿机制更轻量。动态数据源切换只是一个路由工具别让它背上分布式事务的期望。8. 收尾的个人体会整套方案落地完成之后我又花了一个下午把团队的代码规范补了一下核心就是一条所有涉及到多库路由的方法必须显式标注数据源不依赖默认库兜底的隐式路由。因为默认库兜底虽然避免了报错但也会掩盖很多设计问题比如一个本该走从库的方法因为忘记标注而一直打主库主库压力大了还没人发现。动态切换这套东西代码量不大真正难的是理解 Spring 管理连接和事务的那套机制。只要能想明白连接是在事务开始时绑定的、路由发生在获取连接的那一刻、线程池会复用线程这三点遇到问题基本都能自己排查出来。如果你正在做多数据源我建议先把 AbstractRoutingDataSource 源码过一遍配合 Druid 的监控页观察连接池变化会比背任何配置都有用。最后再分享一个小技巧在切换注解里加一个 switchDescription 属性标注一下这个切换的业务原因比如读订单从库、写会员主库。排查问题时能根据描述快速定位是哪条业务链路设置的 key遇到数据串库的问题时这个字段能帮你省下大半天的时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑