ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot多数据源从静态切换到动态路由的完整实战指南

SpringBoot多数据源从静态切换到动态路由的完整实战指南 1. 为什么SpringBoot项目最终都会走到多数据源这条路我入行这么多年接手过的后端项目里但凡业务量到了一定规模几乎绕不开多数据源这个问题。你说一个单体SpringBoot应用早期开发为了方便一个库搞定所有读写用户表、订单表、日志表全塞在一起。等流量起来以后DBA第一个找你聊的就是读写分离或者按业务域拆库再往后部门之间数据要隔离几个微服务共用一个数据库实例这种事也经常发生。反正只要你的项目不是玩具级迟早要在数据访问层面对“多个数据源”这件事。先看两个最常见的落地场景。第一个是读写分离。MySQL主从同步已经是标配主库扛写入从库分担查询压力。但业务代码不会自动知道该连哪个库你得在框架层面做一个“写操作走主库、读操作走从库”的转发逻辑。这个需求直接指向多数据源切换。第二个是业务分库。比如用户服务和订单服务虽然在一个SpringBoot进程里别笑这种老项目很多但数据已经按照业务边界拆到了不同的数据库。这时候mapper接口就得按数据源分组A组Mapper查用户库B组Mapper查订单库而且同一次请求里两张表可能要一起操作。有些团队会直接上ShardingSphere、MyCat这类中间件通过代理层屏蔽多库的存在听起来很美好但代价是引入一个重量级组件SQL方言兼容、分布式事务、运维成本全都得跟着升级。大部分中小团队的项目阶段其实用不到这种重型武器SpringBoot原生机制加一点路由设计就能解决90%的问题。所以这个标题里的“从基础切换到动态路由”本质上是一条递进路径先学会手动指定数据源再进化到自动化路由。就算你最后决定上中间件先把这套切换机制搞清楚也绝对不吃亏因为你对数据源生命周期的理解深度直接决定你在中间件方案里踩不踩坑。为什么我不建议一上来就搞动态路由因为动态路由的前提是你已经知道静态切换的痛点在哪里否则你做出来的路由逻辑大概率是拍脑袋设计的。比如你有没有想过数据源切换是应该在Mapper执行前生效还是在整个Service方法调用期间保持同一个数据源事务要不要跟随切换这些都是有讲究的。2. 静态多数据源到动态路由的核心演进思路静态切换是最原始、也是最容易理解的方案。说白了就是提前配置好几个独立的数据源然后在使用时硬编码指定用哪一个。比如你在配置类里声明两个DataSource一个指向主库一个指向从库Service层里想查从库就注入从库的DataSource想写主库就注入主库的DataSource。这种方案的问题很直接业务代码跟数据源强耦合。一个Service方法里既要读从库又要写主库你得同时注入两个DataSource然后自己决定哪段逻辑用哪个。如果哪天加了第三个库所有相关Service的构造函数、字段注入全部得改一遍。这根本不是演进这是给自己挖坑。所以静态切换只适合数据源数量极少、且读写边界非常固定的场景比如测试环境连两个库手工验证但不适合作为生产环境的长期方案。动态路由的思路就聪明得多。核心思想是调用方不直接依赖具体的DataSource实现类而是依赖一个“路由数据源”由它在运行时根据某个上下文信息动态决定真正执行SQL的是哪个物理数据源。物理数据源可以任意增加或下线只要路由规则保持不变业务代码一行都不用动。这里要引入SpringJDBC模块里一个关键类AbstractRoutingDataSource。这个东西在Spring框架里存在很久了Spring 2.0.5就引入了但很多人不知道因为常规的JDBC开发根本碰不到它。它的工作机制本质上是一个懒加载代理它自己实现了DataSource接口但内部维护一个targetDataSources映射表key是数据源标识value是真实的DataSource实例。每次调用getConnection()时它会调用一个抽象方法determineCurrentLookupKey()拿到当前的“路由key”。根据这个key从映射表里取出真实的数据源然后委托给它返回连接。这个设计看着简单但背后有一个很微妙的点路由发生在“获取连接”这个动作上而不是发生在“执行SQL”的动作上。这意味着只要连接还没被创建你切换路由key都来得及一旦连接拿到手再切key就管不到这个连接了。这也是后来很多人遇到的“切库不生效”问题的根源连接被池子缓存了复用时压根不会重新走路由逻辑。determineCurrentLookupKey()的逻辑通常由一个上下文持有器来支撑业界一般的做法是用ThreadLocal保存当前线程的数据源标识。每个请求线程在进入业务方法时设置一个key使用完清空保证线程之间互相不污染。这种做法在绝大多数场景下是正确的但要注意一个例外如果代码里用了异步线程池、或者用了Async注解子线程里是没有父线程的ThreadLocal变量的这时候路由key会丢失需要额外做传递比如用TransmittableThreadLocal。动态路由相比静态切换的优势还有一个隐藏收益数据源的创建和销毁可以被集中管理。你可以把数据源的注册、启动时的预创建、空闲时的健康检查全部收口到路由层的生命周期里这对后续接入配置中心做数据源的动态下发非常友好因为你只需要更新映射表不需要重启应用。3. 路由框架联动与事务管理和ORM框架的配合逻辑动态路由单独跑通很简单难的是跟现有技术栈无缝配合。这里重点说两个层面Spring事务管理以及MyBatis的Mapper加载机制。事务管理这块是动态路由最容易翻车的地方。Spring默认的事务管理器DataSourceTransactionManager在创建事务的时候会从DataSource接口拿连接。如果你把路由数据源作为事务管理器绑定的数据源那么事务创建时也会走determineCurrentLookupKey()这其实是好事意味着你的Transactional可以继续工作路由数据源会自动把连接路由到正确的物理库上。但这里有一个很微妙的时序问题事务的开启必然伴随连接的获取而连接的获取发生在第一个数据操作之前。如果你在方法内部先调用了某个Mapper触发了连接获取然后才切换数据源key那这个连接已经绑定到旧库了后面再切key也没用。反过来如果你在进入方法前先切好key再触发第一个Mapper操作连接才会绑定到正确的库。所以实践上的黄金法则是路由key的设置必须早于连接获取并且在整个事务期间保持稳定。由于Transactional是从AOP层面拦截方法默认情况下拦截器先于业务方法执行如果你的key是在业务方法第一行代码设置的而事务拦截器在方法进入前就尝试获取连接比如配置了REQUIRED传播行为且外层没有事务那依然会出问题。解决办法是通常在Service层之上再套一层路由切面让路由切面比事务切面的order更靠前保证先切key再开事务。这个顺序搞反了你排查一天都很难定位。再来看MyBatis这边的配合。MyBatis自身的DataSource最终也是注入到SqlSessionFactory里的只要你的SqlSessionFactory配置的是路由数据源Mapper在执行时就会自动走路由逻辑。但要注意MyBatis-Plus或原生MyBatis的多数据源配置里很多人图省事会把每个数据源单独建一个SqlSessionFactory然后手工指定MapperScan的sqlSessionFactoryRef。这种方式对于少数固定数据源是可行的但对于动态路由来说反而多余了因为你把路由能力架在DataSource层一个SqlSessionFactory就够了不同Mapper最终还是会动态路由到不同库。我个人的建议是路由放在数据源层而不是放在会话工厂层。数据源层路由的好处是事务、连接池、MyBatis全部统一走同一套逻辑不存在各管一摊的问题。4. 实操从依赖与配置到动态数据源搭建先把项目基础依赖准备一下。既然标题是SpringBoot默认用SpringBoot 2.7.x作为基线引入Web、JDBC、MyBatis-Plus相关依赖。需要说明的是动态路由本身不依赖MyBatis-Plus但实际操作里大部分人是配合MyBatis-Plus用的所以我会以这个组合来演示。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency我这里选Druid而不是HikariCP主要考虑是国内团队对Druid的监控界面比较熟悉后续排查连接池问题方便。如果你偏好HikariCP把依赖换成spring-boot-starter-jdbc自带的即可核心逻辑不变。然后在application.yml里维护数据源配置。这里我把多个数据源的配置集中在一个自定义前缀下而不是直接用SpringBoot的标准spring.datasource前缀因为后者只能绑定一个主数据源多个数据源容易冲突custom: datasource: master: url: jdbc:mysql://localhost:3306/db_master?useUnicodetruecharacterEncodingutf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://localhost:3306/db_slave?useUnicodetruecharacterEncodingutf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver接着写一个配置属性类用来把这段配置加载成Map结构方便后续注册路由表Component ConfigurationProperties(prefix custom.datasource) public class DataSourceProperties { private MapString, DataSourceItem datasource new LinkedHashMap(); public MapString, DataSourceItem getDatasource() { return datasource; } public void setDatasource(MapString, DataSourceItem datasource) { this.datasource datasource; } public static class DataSourceItem { private String url; private String username; private String password; private String driverClassName; // getter/setter 省略 } }注册路由数据源的核心配置类如下Configuration public class DynamicDataSourceConfig { Bean Primary public DataSource dynamicDataSource(DataSourceProperties properties) { DynamicRoutingDataSource routingDataSource new DynamicRoutingDataSource(); MapObject, Object targetDataSources new HashMap(); properties.getDatasource().forEach((key, item) - { DruidDataSource ds new DruidDataSource(); ds.setUrl(item.getUrl()); ds.setUsername(item.getUsername()); ds.setPassword(item.getPassword()); ds.setDriverClassName(item.getDriverClassName()); ds.setInitialSize(5); ds.setMaxActive(20); targetDataSources.put(key, ds); }); routingDataSource.setDefaultTargetDataSource(targetDataSources.get(master)); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.afterPropertiesSet(); return routingDataSource; } }注意这里有两个关键点。第一Primary必须加在路由数据源这个Bean上否则MyBatis-Plus自动配置时找不到主数据源会报No qualifying bean of type DataSource的错误。第二afterPropertiesSet()一定要调用因为AbstractRoutingDataSource在afterPropertiesSet()里会初始化默认连接跳过这步会导致路由数据源启动时报错。不过上面这个写法有局限数据源全部在启动时创建属于半静态注册。真正的动态路由要支持运行时注册新数据源。所以DynamicRoutingDataSource要扩展一下暴露一个注册方法方便将来从配置中心拉取到新数据源后动态加入路由表。5. 核心切换工具类路由上下文与注解驱动的完整实现现在写最核心的路由上下文工具类DynamicDataSourceContextHolder它的职责是保存当前线程使用的数据源keypublic 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 clear() { CONTEXT_HOLDER.remove(); } }这个类看起来简单但有几个细节必须讲清楚。一用ThreadLocal而不是InheritableThreadLocal因为你并不希望子线程自动继承父线程的数据源key这会让异步任务切到错误的库。二必须提供clear()方法并在使用后调用。如果不清理Tomcat线程池里的线程会被复用下一个请求会读到上一个请求残留的key造成串库而且这种错误极难排查因为它是偶发的跟线程复用时机有关。三ThreadLocal的remove()和set(null)不一样remove()会彻底删除当前线程的变量副本set(null)只是把值设为null建议用remove()。有了上下文路由数据源的实现就顺理成章了public class DynamicRoutingDataSource extends AbstractRoutingDataSource { private final MapObject, Object targetDataSources new ConcurrentHashMap(); Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSource(); } Override protected DataSource determineTargetDataSource() { String key DynamicDataSourceContextHolder.getDataSource(); if (key null) { return super.determineTargetDataSource(); } DataSource dataSource (DataSource) targetDataSources.get(key); if (dataSource null) { throw new IllegalStateException(无法找到数据源: key , 请检查路由key是否正确); } return dataSource; } public void addDataSource(String key, DataSource dataSource) { targetDataSources.put(key, dataSource); // 关键调用afterPropertiesSet让父类重新构建 resolvedDataSources afterPropertiesSet(); } public void removeDataSource(String key) { targetDataSources.remove(key); afterPropertiesSet(); } }这里为什么要重写determineTargetDataSource()因为父类的默认实现在key为null时使用的是defaultTargetDataSource如果路由key没有设置我认为应当直接走默认库。但父类的逻辑不会检查key是否存在如果key不存在会抛IllegalStateException这个异常信息比较笼统所以我重写后加了更明确的报错提示。当然如果你不需要这种保护父类默认实现也够用。接下来封装一个DS注解用来标注Service或Mapper方法走哪个数据源Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value(); }然后写切面这是动态路由的调度中心Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class DynamicDataSourceAspect { Around(annotation(ds)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { String dataSourceKey ds.value(); DynamicDataSourceContextHolder.setDataSource(dataSourceKey); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clear(); } } }注意Order(Ordered.HIGHEST_PRECEDENCE)这一行我前面说过路由切面的顺序必须比事务切面靠前。Transactional默认的Order是最低优先级所以路由切面最先执行先设置key再开启事务这样事务内获取连接时路由key已经就位。切面也支持类级别的注解比如一个Service类整体标注DS(slave)那么该类下的所有方法默认都走从库方法上的注解优先级高于类上的注解这种覆盖逻辑可以通过在切面里先判断方法注解是否存在来实现。判断方式很简单用MethodSignature拿到方法确认其是否有DS注解如果没有再从类上取。6. 动态注册数据源从配置中心加载到运行期热更新前面实现的addDataSource和removeDataSource为动态注册预留好了接口但完整的动态能力还需要一个调度入口。实际生产中新数据源的配置通常不会写在本地yml里而是放在配置中心Nacos、Apollo等或管理后台通过发布配置触发各节点刷新。思路是在应用启动时先加载一个白名单列表作为初始数据源然后订阅配置中心的数据源配置变更事件。收到变更时对比新旧配置的差异计算出需要新增、删除、更新的数据源key分别调用addDataSource或removeDataSource最后把变更状态上报给监控系统。这里需要额外处理一个并发问题如果你在请求执行过程中动态删除一个正在被使用的数据源key那么正在执行中的SQL会受到影响吗答案是如果连接已经建立删除数据源不会立刻断掉现有连接但下次获取连接时路由会失败。所以删除数据源的合理时机是业务低峰期或者先确认该key下没有活跃连接。Druid的DruidDataSource提供getActiveCount()方法可以查一下当前活跃连接数再决定是否下线。动态注册的数据源还需要做连接池的健康检查。最实际的做法是周期性调用dataSource.getConnection().getMetaData()来验证连接可用性否则配置中心推送了一个错误地址你的路由表里有这个key但请求一来就是一个连接超时影响面会很大。健康检查失败的自动从路由表里摘除并发出告警等恢复后再重新注册。这块设计的关键不是代码量而是思路要清晰数据源注册表要跟“路由时机”解耦。注册表负责生命周期管理路由机制只负责在运行时查找key。7. 事务与多线程场景下动态路由的实战问题清单动态路由集成事务和多线程一定会遇到各种奇葩问题。我把高频问题整理成一个速查表方便直接对照排查。症状根因解决方案切了DS(slave)但SQL还是查了主库路由key设置晚于连接获取确保切面Order优先或把切点提到Service入口之前第一次查询正确第二次查询串库ThreadLocal未清理线程池复用finally块中必须调用clear()异步线程查了错误的库ThreadLocal不跨线程传递使用TransmittableThreadLocal或在异步任务中显式设置keyTransactional方法内切库不生效事务内连接已绑定旧库事务方法内禁止切换key应按“一次事务一个库”设计动态新增数据源后路由报找不到afterPropertiesSet()未调用新增后必须重建内部resolvedDataSources配置了路由数据源但MyBatis报无数据源缺少Primary标记给路由数据源Bean加Primary多个SqlSessionFactory导致Mapper走错库每个Factory绑定了不同数据源统一用一个SqlSessionFactory绑定路由数据源事务这块多说一句。动态路由跟本地事务的兼容性其实很好前提是你要接受“一次事务只能在一个物理库上完成”这个约束。跨库分布式事务就是另外一个话题了不要指望路由切切就能解决。真有跨库事务需求要么上分布式事务框架要么做最终一致性改造这是架构层面的事不在本文讨论范围。另外多线程场景再提醒一句Async线程池中的线程默认没有父线程的上下文所以异步方法里如果要用正确的数据源要么在异步方法内部重新按业务参数设置key要么用TransmittableThreadLocal配合TtlRunnable把上下文透传过去。第二种方式在框架集成上更优雅但需要引入额外的jar依赖看团队取舍。8. 连接泄漏与连接池参数调优的隐蔽坑位动态路由引入之后连接池的管理会被放大。我这里说几个隐蔽坑位都是我自己踩过或者帮别人排查过的。第一个坑是Druid的maxWait设置不当。动态路由下连接从多个池子里获取如果某一个数据源配置的maxActive很小比如5而请求量又大很容易出现连接等待超时。这个超时时间默认是-1也就是无限等下去在很多框架默认配置里这个值并不合理。我建议显式设置maxWait为5000毫秒快速失败好过无限阻塞拖垮整个线程池。custom: datasource: master: max-active: 20 initial-size: 5 min-idle: 5 max-wait: 5000 validation-query: SELECT 1 test-while-idle: true第二个坑是test-on-borrow的权衡。很多老项目为了保证拿到的连接一定有效会开启test-on-borrow: true这相当于每次取连接都执行一次SELECT 1性能损耗不低。动态路由下由于连接来源变多真正需要担心的不是连接断连而是数据库实例切换比如主从发生故障切换。这个场景更适合开启test-while-idle: true配合空闲连接检测而不是每次拿连接都验证。第三个坑是动态新增数据源后的连接预热。新注册的数据源连接池是空的首个请求触发建连时Druid会按初始化大小创建连接但这个过程耗时可能在几十到几百毫秒。如果第一个请求恰好在高峰可能造成超时。解决方案是在注册后主动调用init()方法提前初始化连接池DruidDataSource ds new DruidDataSource(); // ... 配置属性 ds.init();第四个坑是连接泄漏。动态路由下如果你在业务代码里手动获取了Connection但忘记关闭路由数据源无法替你兜底。池子里的连接被占光路由到任何一个库都会阻塞。所以手写JDBC操作必须用try-with-resourcesMapper操作依赖MyBatis自动归还问题不大。排查连接泄漏时Druid监控页面的ActiveCount和PoolingCount曲线是重要依据如果ActiveCount长期不降就要检查代码路径里有没有未关闭的Connection。9. 从注解到动态路由的几种方案选型对比最后聊一下方案选型。很多读者会问市面上已经有dynamic-datasource-spring-boot-starter这种现成框架为什么还要自己手写一套我的看法是两者不冲突。现成框架开箱即用功能非常丰富比如内置了多数据源的事务处理、Seata支持很多项目直接引入就够了。但它的黑盒性也比较明显如果你不明白它内部是怎么做到切换的出了问题很容易陷入盲猜。而且部分自定义需求比如按用户租户标识动态路由、根据请求参数动态选择数据源现成框架不一定能完全覆盖那时候还是要自己改。我前面从零开始搭一套的最小闭环代码量大概一百多行换来的收益是你彻底理解了SpringJDBC的路由原理后面不管是接配置中心、做数据源热加载还是排查疑难杂症心里都有底。从架构上来讲动态路由方案应该定位成基础组件而非业务组件。建议把它封装到独立的module里对外只暴露DS注解和少数几个API业务团队不需要知道底层怎么路由。这样就算某个版本你需要替换底层实现比如从自研切到ShardingSphere业务代码也不用改。最后分享一个实际项目里的经验动态路由不要做成“万能开关”。我看到有团队把数据源key做成请求参数每个接口的入参里都带dbSource字段然后根据这个字段路由这在REST API里非常危险因为调用方可以传任意key绕过你的数据隔离策略。合理的做法是路由key必须来自可信上下文比如登录用户的租户ID、请求头里的固定标识而不是直接来自业务参数。整个链路从静态切换到动态路由核心是三步用AbstractRoutingDataSource承载路由能力用ThreadLocal保存路由上下文用AOP统一处理路由切换时机。把这套机制跑通以后你再去接触分库分表中间件也会觉得很多设计是相通的无非是路由的粒度更细、复杂度更高而已。
返回列表