ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot多数据源实战:动态路由与静态隔离的完整指南

Spring Boot多数据源实战:动态路由与静态隔离的完整指南 多数据源这个需求说难不算难坑是真的不少。我第一次在Spring Boot项目里同时接入两个业务库和一个报表库的时候天真地以为只要配两个数据源就行结果运行时频繁出现“SQL执行到错误库”“事务切不过去”“saveOrUpdateBatch批量操作把数据写串”这类问题。这篇文章我把动态路由和静态隔离两条路线完整拆开讲再结合我实际踩过的坑把连接池规划、事务边界、MyBatis批量操作在多数据源下的隐患全部说清楚。适合正在做读写分离、多业务库并存、或者准备在主备切换场景中落地的Spring Boot开发者参考。1. 多数据源需求从哪来真正难在哪1.1 三类最常见的典型场景先说业务场景。虽然网上讲多数据源的文章一抓一大把但大多数人的需求其实可以归到下面三类里读写分离主库负责增删改从库负责查询。后台管理系统、报表服务、订单查询这类“查询远多于写入”的业务特别常见。多业务库并存用户中心和订单中心分开部署各自使用独立数据库但项目又是一个Spring Boot大应用需要同时读写两边的数据。主备切换/数据迁移为了容灾或迁移需要运行期要把请求切到另一个数据源或者在新旧库之间做数据归档对比。这三类场景看起来差别很大技术上最后都指向同一个问题在同一个Spring Boot进程里声明多个DataSource并在运行期按业务语义切到指定数据源。1.2 技术层面真正的三个难点第一数据源Bean的管理。Spring Boot默认会自动配置一个DataSource只要 classpath 里有数据库驱动和spring.datasource.url它就给你生成一个。多数据源意味着要排除这个自动配置手动创建多个连接池对象各自绑定独立的驱动、URL、账号、密码和连接池参数。第二切换机制。业务方法执行之前要把“本次调用走哪个数据源”这个信息告诉底层执行完了还要立刻清理不然线程被线程池复用之后下一个请求会读到上一个请求残留的老数据源标记。第三事务、连接缓存和MyBatis会话之间的绑定关系。这是最阴的坑。很多项目在普通查询时一切正常一旦在Service方法上加Transactional或者在Service实现里调用saveOrUpdateBatch之类的批量方法动态切换就失效了。这个我会在后面用单独章节详细分析。先把这三个难点吃透再选方案否则直接对着别人的博客抄代码很容易把自己的业务场景抄偏。2. 方案一基于AbstractRoutingDataSource的动态切库如果你需要在运行期按业务方法切换不同的库而且这些库的表结构基本一致比如主库和从库动态路由是最轻量的选择。2.1 AbstractRoutingDataSource的底层原理AbstractRoutingDataSource是Spring在org.springframework.jdbc.datasource包下提供的一个抽象类。它本身不是一个真实的物理数据源而是一个“路由外壳”。它内部维护了一个MapObject, Object targetDataSources当上层调用getConnection()时它先执行抽象方法determineCurrentLookupKey()拿到一个key再去这个Map里找到对应的真实数据源最终从真实连接池里返回一个连接。你可以把它理解成快递中转站包裹进来先看贴的是哪张面单再决定放哪条传输线。我们开发要做的就是三件事继承AbstractRoutingDataSource实现determineCurrentLookupKey()让它从ThreadLocal里取当前请求期望的数据源标识提供一个线程上下文工具类负责设置、获取、清理数据源标识用AOP在业务方法执行前设置标识执行后清理标识。2.2 完整代码实现首先是配置文件。这里用主从两个库演示前缀分别是spring.datasource.master和spring.datasource.slavespring: datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/master_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 slave: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/slave_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 10 minimum-idle: 3 connection-timeout: 30000这里有一个非常关键的细节Spring Boot的自动配置默认会读spring.datasource.url创建DataSource你的配置前缀变成了spring.datasource.master自动配置拿到不到URL通常会报错。所以主启动类上要排除掉数据源自动配置SpringBootApplication(exclude DataSourceAutoConfiguration.class) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }线程上下文。这里强调一下清理方法请用remove()不要只set(null)。ThreadLocal在线程池复用的场景下旧值残留是引发“切库忽好忽坏”的头号原因。public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setDataSource(String dataSourceType) { CONTEXT.set(dataSourceType); } public static String getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }路由数据源实现public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }DataSource配置类。三个Bean要理清关系Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean Primary public DataSource dataSource() { DynamicDataSource dynamicDataSource new DynamicDataSource(); MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave, slaveDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); return dynamicDataSource; } }setDefaultTargetDataSource相当重要。当ThreadLocal里没设置值或者设置了一个不在映射表里的key时路由数据源就会退回默认源。默认源一般指向主库这样即使有人忘记加DataSource注解也不至于直接报错最多是查询走了主库性能差一点但功能是好的。注解和AOP切面Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DataSource { String value() default master; }Aspect Component public class DataSourceAspect { Around(annotation(dataSource)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DataSource dataSource) throws Throwable { DataSourceContextHolder.setDataSource(dataSource.value()); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }使用方式非常简单Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override DataSource(slave) public ListUser pageUsers() { return userMapper.selectList(null); } Override DataSource(master) public void addUser(User user) { userMapper.insert(user); } }2.3 这套方案的优点和边界动态路由最大的优势是维护简单。整个应用只有一套SqlSessionFactory、一套Mapper、一套实体对象业务代码里加个注解就能完成切换。当你的两个库表结构一致、SQL方言一致时尤其是读写分离和主备切换我强烈推荐这种方式。但有边界如果多数据源承载的是完全不同的业务域表结构差别巨大Mapper的XML文件也不可能共用动态路由会让你在后期管理Mapper时非常难受。这时候要换方案看下面这种静态隔离。3. 方案二多套MyBatis配置的静态隔离写法当多个库各管各的业务比如用户库、订单库、报表库表结构完全不同强行塞进一套Factory里XML会乱成一锅粥。静态隔离更适合这类场景每个数据源有独立的SqlSessionFactoryMapper按包名分开扫描业务代码里选哪个Mapper就相当于选哪个库。3.1 双Factory双Template完整配置依然两个数据源这次叫shop和reportspring: datasource: shop: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/shop username: root password: 123456 report: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/report username: root password: 123456然后是一个多数据源配置类注意两个MapperScan注解写在同一个类上Configuration MapperScan(basePackages com.example.mapper.shop, sqlSessionFactoryRef shopSqlSessionFactory, sqlSessionTemplateRef shopSqlSessionTemplate) MapperScan(basePackages com.example.mapper.report, sqlSessionFactoryRef reportSqlSessionFactory, sqlSessionTemplateRef reportSqlSessionTemplate) public class MyBatisMultiConfig { Bean ConfigurationProperties(prefix spring.datasource.shop) Primary public DataSource shopDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.report) public DataSource reportDataSource() { return DataSourceBuilder.create().build(); } Bean public SqlSessionFactory shopSqlSessionFactory(Qualifier(shopDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactoryBean new SqlSessionFactoryBean(); sessionFactoryBean.setDataSource(dataSource); sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/shop/*.xml)); return sessionFactoryBean.getObject(); } Bean public SqlSessionFactory reportSqlSessionFactory(Qualifier(reportDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactoryBean new SqlSessionFactoryBean(); sessionFactoryBean.setDataSource(dataSource); sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/report/*.xml)); return sessionFactoryBean.getObject(); } Bean public SqlSessionTemplate shopSqlSessionTemplate(Qualifier(shopSqlSessionFactory) SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } Bean public SqlSessionTemplate reportSqlSessionTemplate(Qualifier(reportSqlSessionFactory) SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } }这个小节的执行步骤就是给其中一个数据源加上Primary避免Spring注入DataSource时找不到唯一候选而报NoUniqueBeanDefinitionExceptionMapperScan里把sqlSessionFactoryRef和sqlSessionTemplateRef都显式指出来不然它会就近找Primary的工厂可能导致另一个包里的Mapper注册错地方。3.2 静态隔离的代价两套Factory意味着MyBatis的插件、TypeHandler、二级缓存全部要在两边各配一遍。比如分页插件你以为配一个Configuration就全局生效实际上只对扫描到的那一个Factory生效另一个库的分页照样不走插件或者直接报分页不支持的SQL语法错误。另外跨库联表查询基本没戏。你不能写一条SQL同时查shop库和report库只能先查一个库拿到结果后再去另一个库查最后在内存里组装。好在大多数跨库场景对实时性要求不高两次查询加内存关联也可以接受。3.3 什么时候选这套方案我的建议是如果“多数据源”在你项目里的本质是“不同业务模块用不同库”而不是同一个表的读写分离优先选静态隔离。反之如果是同一个表结构在主备库之间来回切静态方案会让你写大量重复的Mapper代码这时动态路由才是最优解。很多人把这两件事混为一谈导致代码里既维护多套Factory又搞了一套DataSource注解极其臃肿。4. 批量操作遇到多数据源saveOrUpdateBatch翻车现场如果有人搜过“mybatis的saveorupdatebatch多数据源”那大概率是踩到了同一个坑。我用动态路由做了一个主力项目后某一天需要把几千条订单批量同步到对端图方便用了MyBatis-Plus的saveOrUpdateBatch()结果问题接踵而至。4.1 现象还原第一次调用数据正常写入主库。第二次调用开始数据要么写错库要么在日志里看到一堆诡异的连接异常、SQL执行报错。更麻烦的是有时一次请求里先查从库再批量写主库明明方法上标了DataSource(master)最后批量数据还是到了从库。很多人遇到这问题会第一时间怀疑切面有问题反复查AOP是不是没生效。其实切面没有错错在批量会话的缓存机制。4.2 根因分析MyBatis-Plus的saveOrUpdateBatch()实现并不是简单循环执行单条SQL它通过SqlHelper.sqlSessionBatch(clazz)获取一个批处理专用SqlSession这个会话会被缓存在静态Map里key是SqlSessionFactory。在你第一次调用时批处理SqlSession从当时的动态数据源获取了一个物理连接然后这个连接被会话绑定了。第二次你再切换数据源并调用批量方法MyBatis-Plus发现同一个Factory对应的批处理SqlSession还在缓存里就直接拿旧会话执行。旧会话里保存的还是第一次那个库的连接所以你的数据源切换标记对它来说压根无效。再加上事务同步的因素如果saveOrUpdateBatch被外层Transactional包裹事务管理器会在事务开始时就通过DataSourceUtils.getConnection()获取连接并绑定到当前线程的资源列表中之后的所有SQL都用这个连接数据源切换逻辑根本不参与。4.3 解决方案我最推荐的方案是把批量操作挪出动态路由体系单独走固定数据源。具体做法有三个层次拆分Service单独建一个MasterOperateService注入固定的主库SqlSessionTemplate所有批量写入方法都放这个Service里让它永远只操作主库。这样和动态路由体系彻底隔离批量会话按Factory缓存的bug也无从谈起。不用缓存批会话自己用SqlSessionTemplate的batch()方法手动开启批处理模式并且每次用完立刻关闭SqlSession不进入静态缓存。放弃批量接口手写批量SQLMyBatis的foreach拼批量INSERT性能同样很快一次插几百条毫无压力。对于批量UPDATE用update标签里的foreach做case when更新也可以。我个人现在生产环境基本都是方式3。saveOrUpdateBatch在单数据源项目里确实便利但放到动态多数据源下就是一颗定时炸弹。省几行代码的代价可能是某天下午莫名其妙地线上数据错乱不值得。5. 多数据源下的事务边界问题5.1 为什么Transactional一加切换就失效动态路由只能保证每次getConnection()时选对数据源。一旦方法声明了TransactionalSpring事务管理器会在方法执行入口处调用DataSourceUtils.getConnection()提前获取连接并且把这个连接绑定到当前事务的同步资源里。事务里的所有SQL操作都从这个已绑定的连接上执行之后你再怎么切换数据源标识MyBatis拿到的还是旧连接。这里有个很直观的比喻事务相当于已经检票上车的一位乘客你在站台上给他换了一张写着目的地的票但他坐的车还是原来那一辆最终到不了新目的地。5.2 正确的控制方式第一事务粒度要精确。查询方法如果对一致性要求不高干脆不加事务或者加Transactional(readOnly true)这类事务虽然也会提前拿连接但至少不会破坏数据而且很多数据库对只读事务有额外优化。第二写操作集中在哪个库就把事务放在哪个库上。比如你的写操作全部在主库那就把批量写服务独立出来注入主库专用的SqlSessionTemplate并在该类上使用独立的DataSourceTransactionManager。这相当于用物理隔离方式规避了路由失效问题。第三如果业务必须在一个事务内同时操作两个库动态路由已经无法解决问题。正确做法是把两个操作拆到不同的Service中分别由各自的数据源事务管理器管理。外层编排服务不加事务通过一个编排流程来保证“事务之外的业务顺序正确”。5.3 跨库强一致只能用分布式事务必须坦白说两个不同物理库之间的操作想要本地事务级别的原子性Spring单个事务管理器做不到。你看到的所有“多数据源事务管理器随风切换”的骚操作本质上都是把多个本地事务拼在一起失败时手工补偿并不能保证原子性。真要强一致得上一套分布式事务方案。选项有Seata的AT模式、TCC模式、本地消息表事务消息、异步补偿等。我自己的选型原则是数据量中等、响应时间要求不苛刻的应用优先本地消息表既轻量又成熟数据量大、并发高、对一致性要求极高再考虑Seata这类基础设施。别一上来就引一个笨重的分布式事务框架那是给自己找罪受。5.4 排查事务问题的一个线索遇到“切库无效”的第一反应应该是看调用链路里有没有未显眼的外层事务。Spring的声明式事务默认通过代理生效方法A不加事务方法B加了事务A调用B时事务从B开始了B内部所有SQL都被绑定到B所在数据源的连接上。这个时候即使A方法标了DataSource(slave)也只是在进入A时设置了ThreadLocal等真正执行到B内部时连接已经提前获取了ThreadLocal里的值根本参与不到连接获取环节。所以在多数据源项目里事务的每一条边界都值得你反复确认。多一个Transactional可能就多一次数据写串的风险。6. 连接池规划、actuator监控与目录结构细节6.1 多数据源的连接池参数不能一把梭多数据源意味着多个连接池独立运行。每个池的最大连接数、最小空闲数、超时时间都要单独考虑。常见错误是两个数据源配成完全一样结果从库并发一大数据库连接直接被查询流量吃满主库写入反而拿不到连接。我长期使用的基准参数如下参数建议范围说明maximum-pool-size主库20~50从库10~30从库连接大不代表性能好还会拖垮数据库minimum-idle主库5从库3保留基准连接减少冷启动建连connection-timeout30000ms超过这个时间拿不到连接就报错max-lifetime小于数据库wait_timeout避免空闲连接被数据库服务端断开idle-timeout10~15分钟空闲回收避免连接长期占着资源还有一点容易被忽略应用侧的连接数不是单条而是一大片池化连接。当业务量暴涨时某个数据源的连接数量可能飙升到上千甚至上万这时候数据库端的max_connections、CPU线程、内存都会成为瓶颈。不要光盯着应用连接池调大还要看数据库侧能不能扛住。6.2 actuator健康检查与端点安全引入spring-boot-starter-actuator后/actuator/health会默认显示各DataSource的健康状态。只要配置了多个DataSource健康检查里就会分别列出每个数据源的连接状态。这是排查“某个库连不上、切换后异常”的最直观入口。但要注意actuator端点也是高危区。有人图方便配置management.endpoints.web.exposure.include: *结果/actuator/env、/actuator/heapdump全对外暴露环境变量和堆快照直接裸奔。生产环境我建议只暴露这几个management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: when-authorizedhealth的详情尽量用when-authorized配合Spring Security或者网关拦截不要让任何人无鉴权就能看到所有数据源的连接细节。6.3 结合项目目录规范做一个好落地的结构多数据源相关的组件最好独立分包避免和业务代码混在一起。我当前项目的结构是src/main/java/com/example/ ├── config/ │ ├── DataSourceConfig.java │ └── MyBatisMultiConfig.java ├── context/ │ └── DataSourceContextHolder.java ├── annotation/ │ └── DataSource.java ├── aspect/ │ └── DataSourceAspect.java ├── mapper/ │ ├── master/ │ └── slave/ ├── entity/ ├── service/ └── controller/注意对于动态路由项目我强烈建议业务方不要直接调用DataSourceContextHolder.setDataSource()而是统一走注解AOP。这样切面负责try-finally清理避免有人忘记清理ThreadLocal导致下一次请求 “继承” 了上一个请求的数据源标记。如果你在代码审查里发现有人手写setDataSource十有八九过段时间会冒出切换错乱的线上事故。7. 多轮踩坑之后我沉淀下来的选型与落地规则最后分享几条我最近在项目里实际遵守的规则都是踩完坑之后定下来的。第一先回答“多数据源的本质是切换还是隔离”。切换关系选动态路由隔离关系选静态多Factory。不要混着用。如果项目里既有读写分离又有完全独立的业务库我建议把这两种模式拆成两个模块不要在一个Service里既要动态切换又要多套Factory那会让后续维护者崩溃。第二批量写操作永远别走动态路由。不管是MyBatis-Plus的saveOrUpdateBatch还是updateBatchById只要涉及SqlHelper批处理会话缓存就让它们走固定的数据源专属Service。把主库的写操作收敛到一个单独的服务里从架构上消灭串库的可能。第三事务边界越短越好。多数据源项目的复杂度和Transactional的滥用程度基本成正比。能用编程式事务控制的地方不要图省事声明一个大事务。如果确实需要跨库事务直接评估分布式方案不要在本地事务上硬凑。第四连接池参数要跟着业务形态走。主写从读的负载不同连接池参数就该不同。顺手把actuator暴露的端点收敛好这不是“安全小题大做”而是很多事故本来一句话就能避免。第五换数据库产品时要特别注意方言差异。如果项目里既有MySQL又有其他数据库比如报表库用了瀚高数据库并且开启兼容MySQL的模式那么在配置多套SqlSessionFactory时就得分库配置分页插件和方言参数不能在全局Configuration里一把梭。动态路由方案对SQL方言统一要求很高方言不一致时还是绕回静态隔离方案更稳妥。这些规则陪我顺利度过了好几个项目的上线和扩容也帮我挡掉了不少“半夜被电话叫起来看数据写错库”的体验。如果你也在设计多数据源架构不妨先照着这套原则把边界切清楚再动手写代码大概率能少走我之前走过的弯路。
返回列表