资讯动态

Spring Boot多数据源整合:动态路由、事务绑定与连接池隔离

发布时间:2026/10/24 3:34:38 来源:尧图企业网站定制
1. 为什么多数据源这件事总在项目中期才被想起来做后端的朋友大概都有过这种经历项目起步时一个数据库跑得好好的Spring Boot 里一份application.yml、一个DataSource、一个JdbcTemplate或者一套 MyBatis 配置干干净净。等到业务长出第二棵树——比如要读历史归档库、要连一个只读报表库、要对接一套遗留系统的独立库——才发现原来“一个 Spring 容器只认一个数据源”这件事根本不是加几行配置就能糊过去的。我接触过的几个中大型项目里多数据源几乎都是“事后补”的需求。业务方不会在立项时告诉你“我们以后要分库”他们只会在半年后丢过来一句“这个报表要从另一个库查你改一下”。所以这篇内容我想干的事情很明确把 Spring Boot 整合多数据源的完整思路、配置细节、踩坑记录摊开讲清楚让你下次接到这种需求时脑子里有一条清晰的路径而不是打开搜索引擎从零拼凑。核心关键词先摆在这里多数据源配置、动态数据源、AbstractRoutingDataSource、事务绑定、MyBatis 多数据源、连接池隔离。这篇内容适合三类人看一是刚接手一个需要连两个库的 Spring Boot 项目、还不太清楚从哪下手的朋友二是已经能跑起来但发现事务不生效、切换不稳定的同学三是在做技术方案评审、需要判断“多数据源到底该怎么切”的负责人。我会从整体设计思路讲到可复制的配置代码再到实际排查问题的记录尽量做到看完能直接动手。需要提前说明一点下面所有代码和配置都是我在实际项目里验证过的形态但库名、表名、业务场景会做抽象处理你替换成自己的即可。另外多数据源的实现方式不止一种我会重点讲目前最通用、扩展性最好的那条路同时在选型部分把其他方案的取舍讲明白避免你选错方向白干一遍。2. 整体方案怎么选先把“切”的维度想明白2.1 三种主流实现路径的取舍在动手写代码之前最有价值的一步其实是把方案想清楚。多数据源的本质问题是Spring 容器里的DataSource是一个 Bean当你有两个库时你到底想让程序在什么时机、依据什么条件去选择哪一个连接。围绕这个“选择时机”业界常见的做法大致分三类。第一类是静态多数据源也就是为每个库各配一套DataSource、SqlSessionFactory、MapperScannerConfigurer代码里按包路径区分A 包的 Mapper 走 A 库B 包的 Mapper 走 B 库。它的优点是结构清晰、启动即定型、互不干扰缺点是灵活性差一旦某个业务需要跨库访问就得在代码层面做拆分而且配置量随库数量线性增长。这种方案特别适合“主库 若干职责完全独立的辅助库”这种边界清晰的场景比如主业务库和一套日志归档库。第二类是动态数据源核心是继承AbstractRoutingDataSource在运行时通过一个上下文持有者通常用ThreadLocal来决定当前线程该用哪个数据源配合自定义注解 AOP 在方法入口切换。优点是灵活、代码侵入小、可以按方法粒度切换缺点是引入了“上下文”这个隐式状态事务、异步线程、连接池这些地方容易出问题。绝大多数需要“同一套 Mapper 访问不同库”的场景走的都是这条路。第三类是多数据源 分库中间件比如在应用层之上引入分片框架由框架统一管理路由规则。这套东西能力最强但对项目侵入也最大配置复杂、排查困难一般只有在数据量确实到了分片级别时才值得上。中小项目上这套方案属于用大炮打蚊子运维和排错成本反而更高。我的建议很直接如果你的需求是“少数几个库、按业务或按方法切换”走动态数据源如果是“每个库职责完全隔离、代码可以按包分”走静态多数据源分片中间件留到真正需要水平拆分时再考虑。后面的内容我会以动态数据源为主线展开因为它的通用性最强而且把它搞懂了静态方案自然也会写。2.2 动态数据源的核心结构长什么样把动态数据源拆开看其实就三个角色在配合。第一个是数据源注册中心负责把多个真实的DataSource实例注册进一个 Map键是我们自定义的标识值是连接池对象。第二个是路由决策器继承AbstractRoutingDataSource并重写determineCurrentLookupKey()它的职责就是告诉框架“这一次该取哪个键”。第三个是上下文持有者一般是一个基于ThreadLocal的工具类提供 set、get、clear 三个动作保证同一次请求内切换的可见性并且请求结束后必须清理否则线程池复用时会串数据。这个结构之所以经典是因为它恰好对应了“在哪里切、切给谁、切完怎么收尾”这三个问题。ThreadLocal的选择不是随便定的Web 请求在 Spring MVC 里是线程绑定的一次请求从头到尾基本在同一个线程上跑所以用线程变量传递数据源标识最自然。但这也埋下了两个隐患一个是异步线程和线程池里拿不到父线程的变量另一个是忘了 clear 导致的脏数据。这两点我在后面会专门展开。还有一个容易被忽略的点是默认数据源。AbstractRoutingDataSource自带setDefaultTargetDataSource()当determineCurrentLookupKey()返回 null 或者返回的键在 Map 里找不到时会回落到默认数据源。这个机制非常重要它相当于给系统兜了个底注解漏加、上下文没设置的情况下请求至少还能走主库不至于直接抛异常。所以默认数据源一定要设成业务主库而不是随便挑一个。2.3 事务为什么是多数据源里最容易翻车的地方很多人第一次做多数据源配置跑通了、查询也正常结果一加Transactional就发现数据写进了错误的库或者切换压根没生效。原因在于 Spring 的事务管理和数据源绑定是有先后顺序的事务开启时会向DataSourceTransactionManager索取一个连接而这个连接是从当时那个DataSource上拿的拿到之后就绑定到当前线程的事务上下文里后续整个事务期间都用这一个连接。问题就出在“当时”这两个字上。如果你的 AOP 切换顺序排在事务拦截器之后那么事务已经先拿了主库的连接后面再怎么切上下文都没用因为连接已经定死了。这就是为什么很多人写了切面却依然切不动的原因。解决办法是把数据源切换切面的优先级调到事务切面之前用Order控制让切换先发生、事务后开启。另一个坑是跨库事务。动态数据源解决的是“路由”不是“分布式事务”。一次方法调用里如果先写 A 库再写 B 库两个操作各自在自己的连接上Spring 的本地事务管不了它们的一致性。这种场景要么拆成两个独立事务并接受最终一致要么引入分布式事务方案但那已经是另一个话题了。我见过有人在同一个方法上加了Transactional又想切两个库最后数据只写进去一半排查了大半天才发现是连接绑定问题这个认知一定要提前建立。3. 核心配置与代码实现从零搭一套能跑的骨架3.1 依赖与配置文件怎么写才不乱先把依赖理清楚。基础三件套是spring-boot-starter-web、spring-boot-starter-jdbc或者你用的持久层 starter、以及具体的连接池。连接池我建议用 HikariCPSpring Boot 默认就带性能稳、配置项直观。如果持久层用 MyBatis再加mybatis-spring-boot-starter用 JPA 的话对应换spring-boot-starter-data-jpa。这里要注意mybatis-spring-boot-starter自带自动配置它会尝试用容器里唯一的数据源去装配SqlSessionFactory多数据源场景下这个自动配置反而会捣乱通常需要排除掉它的自动配置类改为手动装配。配置文件我习惯用 YAML 分块写每个库一块前缀自定义不要直接用spring.datasource因为那个前缀会被 Spring Boot 的自动配置接管。大概长这样app: datasource: primary: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/biz_main?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: app_user password: ${DB_PASSWORD} maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 archive: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3307/biz_archive?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: app_user password: ${DB_PASSWORD} maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000这里有个非常容易踩的细节Hikari 的配置项里连接地址的键名是jdbc-url而不是url。如果你写url绑定到HikariDataSource上会失败启动时报“jdbcUrl is required”很多人卡在这一步。用ConfigurationProperties绑定时要绑到HikariDataSource类型上键名必须对得上。密码这块不要硬编码用环境变量占位或者接配置中心这是基本要求。另外两个库的连接池参数不要照抄主库并发高可以给大一点归档库、报表库读多写少池子小一些反而更省资源。我一般按“预估峰值 QPS × 平均耗时 余量”来估算比如主库峰值 200 QPS、单次查询平均 20ms理论上需要 4 个活跃连接考虑到波动给到 20 就足够宽裕了。3.2 两个真实数据源的 Bean 怎么定义数据源 Bean 的定义有两种常见写法一种是每个库写一个Bean方法另一种是用DataSourceBuilder循环构建。库少的时候前者更直观库多的时候后者更省事。我先给直白写法Configuration public class DataSourceConfig { Primary Bean(name primaryDataSource) ConfigurationProperties(prefix app.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } Bean(name archiveDataSource) ConfigurationProperties(prefix app.datasource.archive) public DataSource archiveDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } }这里Primary必须加在其中一个上因为容器里出现多个DataSource类型的 Bean 时凡是按类型注入的地方都会歧义。把主库标成Primary可以让那些没显式指定名称的注入点默认拿到主库减少改造范围。但要注意这个Primary只是解决“按类型注入选谁”的问题动态路由里我们还是要显式把所有数据源注册进 Map两者不冲突。然后是动态数据源本身public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.get(); } }就这么几行。AbstractRoutingDataSource已经帮我们做完了 Map 查找、默认回落、getConnection()的包装这些事我们只需要回答“现在该用哪个键”。这也是为什么我说动态数据源本身不难难的全在周边。3.3 上下文持有者的线程安全细节上下文工具类看着简单但有两个地方必须写对public final class DataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); private DataSourceContextHolder() {} public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }第一个细节是remove()而不是set(null)。两者的区别在于ThreadLocal内部的 Entry 是否被真正清掉。Web 容器用线程池处理请求线程会被复用如果你只set(null)Entry 还挂在ThreadLocalMap里虽然值变空了但强引用链还在长期运行下是内存泄漏风险。remove()才是标准动作。第二个细节是必须在请求出口清理。最稳的做法是用拦截器或者过滤器在afterCompletion/finally里调clear()。如果项目里用了 AOP 切面切面的finally里也要清保证异常路径下不残留。我见过一个案例某个接口抛异常后上下文没清线程被下一个请求复用结果那个请求莫名其妙读到了归档库现象极其诡异查了两天才定位到。3.4 注解 AOP 的切换实现注解定义很简单一个值属性即可Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default DataSourceNames.PRIMARY; }切面里最关键的是执行顺序Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { String previous DataSourceContextHolder.get(); try { DataSourceContextHolder.set(ds.value()); return pjp.proceed(); } finally { if (previous null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(previous); } } } }Order(Ordered.HIGHEST_PRECEDENCE)这行的目的非常明确让数据源切换发生在事务拦截器之前否则事务会先绑定主库连接切换形同虚设。这是多数据源里最重要的一条经验比任何配置细节都关键。previous的保存与恢复也是为了支持嵌套调用。比如一个标了归档库的方法内部又调了另一个标主库的方法如果内层直接 clear外层返回后就丢了上下文保存现场、恢复现场才是正确姿势。4. 实操过程从启动验证到实际读写4.1 把 SqlSessionFactory 手动装配起来前面提过多数据源下 MyBatis 的自动配置要排除掉。如果你的持久层是 MyBatis需要为每个库分别装配SqlSessionFactory并指定各自的 Mapper 扫描包。假设主库的 Mapper 在com.example.biz.mapper归档库的在com.example.archive.mapperConfiguration MapperScan(basePackages com.example.biz.mapper, sqlSessionFactoryRef primarySqlSessionFactory) public class PrimaryMyBatisConfig { Bean(primarySqlSessionFactory) public SqlSessionFactory primarySqlSessionFactory( Qualifier(dynamicDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean factory new SqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setMapperLocations( new PathMatchingResourcePatternResolver().getResources(classpath*:mapper/primary/*.xml)); return factory.getObject(); } }归档库那套同理只是扫描包和资源路径换掉。这里注意两个点一是dataSource注入的应该是动态数据源而不是原始主库这样 Mapper 执行时还能享受路由能力二是两个库的SqlSessionFactoryBean 名不能重名sqlSessionFactoryRef要一一对应否则MapperScan不知道用哪个工厂。如果你的两个库表结构完全一样、只是数据不同其实可以共用一套 Mapper 和 XML只靠注解切换数据源配置量会小很多这也是动态数据源方案最舒服的地方。4.2 组装动态数据源并注入默认库Configuration public class DynamicDataSourceConfig { Bean(dynamicDataSource) public DataSource dynamicDataSource( Qualifier(primaryDataSource) DataSource primary, Qualifier(archiveDataSource) DataSource archive) { DynamicDataSource dynamic new DynamicDataSource(); MapObject, Object targets new HashMap(4); targets.put(DataSourceNames.PRIMARY, primary); targets.put(DataSourceNames.ARCHIVE, archive); dynamic.setTargetDataSources(targets); dynamic.setDefaultTargetDataSource(primary); dynamic.afterPropertiesSet(); return dynamic; } }afterPropertiesSet()这行不能漏AbstractRoutingDataSource在初始化时要解析 target 和 default不调这一步运行时会报错。setDefaultTargetDataSource(primary)就是我前面说的兜底一定要设成主库。到这一步整个链路就通了请求进来 → 被切面拦截 → 上下文写入 → 事务开启时从动态数据源拿连接 → 动态数据源根据上下文键找到真实连接池 → 返回对应库的连接 → 后续 SQL 落到正确的库。理解这条链路比记住任何单行代码都重要因为后面所有排查都围绕它展开。4.3 一个完整的读写验证流程配置写完之后必须验证不能凭感觉认为它通了。我一般按四步走。第一步验启动和默认路由不标任何注解调一个查询接口确认打到主库日志里能看到连接池名称。第二步验显式切换在方法上标DS(archive)查同一个表确认数据来自归档库。第三步验事务内切换在标了归档库的方法上加Transactional执行一条插入然后到归档库里查这条记录是否真的落库。这一步最能暴露顺序问题。第四步验异常路径故意让方法抛异常观察上下文是否被清理可以连续发两次请求第二次请求必须回到默认库。验证时要养成看日志的习惯。可以在动态数据源的determineCurrentLookupKey()里打一行 debug 日志或者在连接池开启leak-detection-threshold前者看路由键后者看连接是否归还。数据库侧也可以开general_log临时看看真正执行的 SQL 落在哪个实例上这是最硬的证据。我见过不止一次“以为切了其实没切”的情况都是靠数据库侧日志确认的。还有一个实操建议把数据源名称定义成常量类不要到处写字符串。DS(archive)这种写法拼错一个字母不会编译报错但运行时就会走到默认库属于典型的沉默错误。用常量能把这个风险降到最低。5. 常见问题与排查技巧实录5.1 高频问题速查表下面这张表是我这些年遇到或帮别人处理过的问题里频率最高的按出现频次排现象大概率原因定位方式处理办法加了注解仍走主库切面顺序晚于事务拦截器在determineCurrentLookupKey打日志看是否有键切面加Order(Ordered.HIGHEST_PRECEDENCE)启动报 jdbcUrl is required配置里写了url而非jdbc-url看完整堆栈定位到绑定失败改成jdbc-url或绑到HikariDataSource请求间数据源串了上下文未清理线程复用连续两次请求观察路由键拦截器afterCompletion中clear()事务内切换无效事务先绑定连接切换在后事务方法内打印连接所属库调整顺序或把事务方法拆到外层异步线程拿不到上下文ThreadLocal不跨线程异步任务里打印 get 结果手动传值或用可继承的持有者启动报 DataSource 注入歧义多个同类型 Bean 未标Primary看注入点堆栈主库标Primary或全部按名称注入表里第二条我特别想多说一句。jdbc-url这个坑之所以高频是因为网上大量早期示例用的是url直接抄过来在新版本上就挂。判断方法很简单看到jdbcUrl is required这类提示先回去检查键名。5.2 异步与线程池场景的补充方案ThreadLocal不跨线程这件事在多数据源场景里特别容易被忽视。比如你在一个标了归档库的方法里用CompletableFuture或者Async起了个异步任务任务内部执行的查询会拿不到上下文直接回落到主库而且不报错数据就查错了地方。处理思路有两条。一是显式传参把数据源键作为参数传进异步任务在任务开头手动set、结束clear简单可靠缺点是每个异步入口都要记得写。二是用InheritableThreadLocal或者包装线程池在提交任务时捕获当前上下文执行时还原。后者的好处是代码侵入小但要小心线程池复用导致上下文残留包装器里必须在任务结束后清理。我个人的偏好是显式传参尤其是异步任务数量不多的时候。隐式继承在复杂调用链下容易失控排查成本远高于那几行显式代码。如果异步任务很多、到处都是再考虑统一包装线程池但一定要配套写测试用例来验证上下文传递。5.3 连接池与慢查询的两个隐性影响多数据源上线后还有一个隐性问题连接数总量翻倍。原来一个库 20 个连接现在两个库各 20对数据库侧来说是两份独立的连接配额但对应用侧来说是内存和文件句柄的叠加。如果每个库都开得很大应用重启期间可能出现连接风暴数据库的max_connections也可能被打满。我的做法是给每个库单独评估不要简单复制主库参数同时对动态数据源加一层监控统计每个键的获取连接次数和耗时跑一段时间后按实际使用量调参。归档库、报表库这类低频访问的库池子给 5 到 10 就够配minimum-idle为 1 到 2避免空闲连接长期占着资源。另一个隐性影响是慢查询的归属判断变难了。原来只有一个库慢查询一眼能定位多数据源之后同一条 SQL 可能在两个库上执行耗时差异还很大。建议在日志里把数据源键和 SQL 一起打出来或者接一套链路追踪把库标识作为 tag 打进去排查时能直接筛。这个小投入在问题爆发时能省掉大量时间属于非常值得做的基建。5.4 几个只有踩过才知道的实操心得最后分享几个纯经验性的点都是文档里不会写的。第一注解不要只加在 Controller 上。很多人图省事在接口层标DS看起来能用但一旦 Service 层有自己的事务连接早就绑定了注解等于没加。正确位置是加在真正开启事务或者执行 SQL 的那一层通常是 Service 方法上。第二类级别注解和方法级别注解要定义好优先级。我把DS的Target同时设了 METHOD 和 TYPE这时候就要在切面里处理“方法注解覆盖类注解”的逻辑否则容易出现类上标了归档库、某个方法想临时走主库却切不动的情况。实现上可以用AnnotatedElementUtils.findMergedAnnotation配合方法优先的查找顺序。第三切换粒度尽量粗。一个方法内部频繁切换数据源每次切换都可能触发连接获取性能开销比想象中大。设计上应该按业务边界切一个事务方法对应一个库不要在循环里来回跳。第四测试环境要把两个库都备齐。我见过本地只起了主库、归档库指向一个空实例的情况开发阶段一路绿灯一上预发就炸。本地用容器化方式把两个库都拉起来成本很低收益是避免上线前的意外。第五改造存量项目时先做“旁路验证”。不要一上来就把所有查询改到新库先加一个开关让少量流量走新库、结果和旧库比对确认一致后再逐步放量。多数据源改造本质上是数据访问路径的重构稳妥推进比一次性切换靠谱得多。这套东西我在几个项目里反复用过骨架基本没变过变的只是库的数量和业务细节。真正需要花心思的从来不是那几十行配置代码而是对“连接什么时候被绑定、上下文什么时候被读取、什么时候必须清理”这条时间线的理解。把这条线在脑子里跑通遇到再奇怪的切换失效你都能顺着链路一段段往下查而不是对着一堆配置干瞪眼。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑