☰
Spring Boot读写分离实战:动态数据源路由与避坑指南
2026/9/29 4:39:53 网站建设 项目流程

简介:Spring Boot 数据库读写分离是提升高并发系统读取性能、减轻主库压力的常用优化策略。这份 PDF 面向具备一定 Spring Boot 基础、需要为已有项目接入读写分离能力的开发人员,围绕自定义数据源路由的实现思路展开,重点介绍了基于动态数据源路由的核心原理,以及如何通过线程私有变量保存当前线程的数据库类型,如何利用切面与自定义注解自动完成主从库切换,同时给出了事务隔离与数据一致性方面的注意事项。资源包为单个 PDF 文件,大小约 48KB,内容包含可运行的代码骨架和关键类的说明,便于快速通读与直接借鉴。目前已有 1000 余人学习使用,方案在社区中获得了较高关注。读者可从路由键的确定方法、线程私有变量的维护,到环绕通知的拦截逻辑,完整掌握一套整洁易扩展的读写分离方案,也能避开主从延迟和数据一致性方面的常见坑点,帮自己在实际项目中快速落地。

1. Spring Boot 读写分离:不是高可用,是给主库减负

数据库读写分离在 Spring Boot 项目里是一个非常老生常谈、但又经常被做坏的话题。很多团队一听到“主从同步”就以为把 MySQL 配好主从复制、在 Spring Boot 里配两个数据源就完事了。实际落地时,真正的问题根本不在于“怎么连两个库”,而在于“怎么让每个 SQL 都走对库”。一个不留神,事务里的写操作被路由到了从库,数据没生效;或者刚写完主库立刻去查从库,查到的是旧数据。这篇文章就从标题里的“方法”二字出发,把常见的实现路线、代码怎么写、路由规则怎么定、踩过哪些坑都拆开讲一遍。适合正在给 Spring Boot 项目做读写分离、或者已经在做但发现路由不稳定的开发者。先给结论:纯 Spring 生态内,用 AbstractRoutingDataSource 自己封装一套注解驱动路由,是控制力最强、也最容易排查问题的方案。

2. 读写分离的三种落地路线:选型之前先知道坑在哪

2.1 自己写路由与引入中间件的取舍

读写分离本质上是“多数据源 + 路由规则”的组合。Spring Boot 项目里常见做法有三类。

第一类是自己写,基于 Spring 自带的AbstractRoutingDataSource,配合 AOP 和自定义注解,实现“方法级别”的路由切换。这个方案的优点是完全可控、依赖少、出问题了自己能查;缺点是主从延迟、事务边界、连接池管理都要自己考虑。我一般建议中小型项目优先走这条路,因为你能精准控制每条 SQL 走哪个库。

第二类是用 ShardingSphere-JDBC 这类数据分片中间件。它能自动解析 SQL 并识别读写操作,不需要手动写切面。但代价是你得把数据源交给中间件统一管理,引入一层额外的解析开销,排障时概念变多。如果你的项目未来可能扩展到分库分表,这条路线更值。

第三类是在 MyBatis 层做拦截器,通过拦截 MappedStatement 判断 SQL 前缀来切换数据源。这个方案跳过了 Spring 的动态数据源机制,耦合度高,而且对事务、多数据源的支持不如前两者,基本只有在老项目改造这种特定场景才会用。

2.2 三套方案的对比与适用边界

方案核心机制路由粒度运维成本适合场景
AbstractRoutingDataSource + AOPSpring 动态数据源,手动切路由 key方法级低中小项目、主从结构固定
ShardingSphere-JDBCSQL 解析自动识别读写SQL 级中已有多分片需求、想统一管理
MyBatis 插件拦截器拦截执行器SQL 级高老项目改造、不想动 Spring 配置

顺带提一句,如果你的项目用了 Java 21 和 Spring Boot 3.5,启用虚拟线程后连接池的并发行为会变,但动态数据源的路由机制不受影响。区别在于你得更注意数据源的并发归还问题。我在实验环境里遇到过虚拟线程下连接池被占满的情况,后面会专门讲。

3. 用 AbstractRoutingDataSource 实现动态切换:一个最小可跑的配置

3.1 主从数据源配置与 DynamicDataSource 骨架

先准备好 yml 配置。这里用 HikariCP 作为连接池,主库和从库各自独立配置:

spring: datasource: dynamic: primary: master datasource: master: jdbc-url: jdbc:mysql://localhost:3306/db_master?useSSL=false&characterEncoding=utf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: jdbc-url: jdbc:mysql://localhost:3307/db_slave?useSSL=false&characterEncoding=utf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver

spring.datasource.dynamic.primary是自定义的默认路由开关,意思是当没有显式路由规则时,所有 SQL 走master。这样设计的好处是在 AOP 切面没命中时,至少不会把写操作打到从库。很多人一开始把默认路由设成slave,结果事务更新操作莫名失踪,这是很典型的翻车点。

接下来是核心类DynamicDataSource,继承AbstractRoutingDataSource:

public class DynamicDataSource extends AbstractRoutingDataSource { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSource(String key) { CONTEXT_HOLDER.set(key); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } @Override protected Object determineCurrentLookupKey() { return getDataSource(); } }

这段代码里determineCurrentLookupKey()是路由的决策点,Spring 在每次数据库连接操作前都会回调它,拿到 key 再按 key 从目标数据源解析连接。默认AbstractRoutingDataSource会在afterPropertiesSet()时把配置的数据源写入targetDataSources键值对。ThreadLocal负责在当前线程内传递 key,AOP 切面在方法执行前设置,方法结束后务必清理,否则连接池归还后 key 残留,这个坑很大。

3.2 把 DynamicDataSource 注入 Spring 容器

然后写一个配置类,创建主从数据源,并把它们装进DynamicDataSource:

@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.dynamic.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.dynamic.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } @Bean public DynamicDataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("slaveDataSource") DataSource slave) { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", master); targetDataSources.put("slave", slave); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(master); return dynamicDataSource; } }

setTargetDataSources是路由目标集合,key 是字符串,value 是数据源实例。setDefaultTargetDataSource指定默认数据源,也就是前面 yml 里primary: master的 Java 版体现。到这一步,动态数据源已经能被 Spring 管理,但还没接入 DAO 层。下一步需要把 JdbcTemplate 或 MyBatis 的SqlSessionFactory的dataSource属性指向dynamicDataSource。

@Bean public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean(); sessionFactoryBean.setDataSource(dataSource); sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/*.xml")); return sessionFactoryBean.getObject(); }

注意这里SqlSessionFactory只能引用dynamicDataSource,不能再直接引用masterDataSource或slaveDataSource。否则你配置的路由逻辑永远不会被触发,所有 SQL 直连主库。这也是新手常见的“配了但没效果”的原因。如果项目同时用了 JdbcTemplate,JdbcTemplate内部的DataSource也要注入dynamicDataSource,否则走的是独立连接池。

4. 用自定义注解 + Spring AOP 把路由变成一行代码

4.1 定义 @DataSource 注解和路由切面

手动调DynamicDataSource.setDataSource("slave")太丑,而且容易漏清理。实际工程里会用注解+AOP,把路由逻辑收敛到方法上。先定义注解:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DataSource { String value() default "master"; }

@Target同时允许写在类上和方法上,是为了支持“类级默认 + 方法级覆盖”的用法。比如整个查询 Service 默认走从库,其中某个核心接口强制走主库,这时候方法级注解优先级更高。然后写切面:

@Aspect @Component public class DataSourceAspect { @Pointcut("@annotation(com.example.datasource.DataSource) || @within(com.example.datasource.DataSource)") public void dataSourcePointcut() {} @Before("dataSourcePointcut()") public void before(JoinPoint joinPoint) { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); DataSource dataSource = method.getAnnotation(DataSource.class); if (dataSource == null) { dataSource = joinPoint.getTarget().getClass().getAnnotation(DataSource.class); } if (dataSource != null) { DynamicDataSource.setDataSource(dataSource.value()); } } @After("dataSourcePointcut()") public void after() { DynamicDataSource.clearDataSource(); } }

这里@Pointcut同时匹配了“方法上有注解”和“类上有注解”两种情况。@within是 Spring AOP 里一个容易被忽略的切点指示符,它专门处理类级别注解的匹配。切面逻辑很简单:前置通知按当前方法或类上的注解值设置路由 key,后置通知清空。执行顺序很关键:@After必须清理ThreadLocal,否则线程池复用线程时 key 会泄漏到下一次调用。

4.2 Service 层的正确打开方式:默认主库、只读走从库

路由切面配好后,Service 层就成了最直观的使用现场。看一个典型的查询场景:

@Service public class OrderQueryService { @DataSource("slave") public OrderVO getOrderById(Long orderId) { return orderMapper.selectById(orderId); } @DataSource("master") public void updateOrderStatus(Long orderId, Integer status) { orderMapper.updateStatus(orderId, status); } }

读操作getOrderById明确走从库,写操作updateOrderStatus强制走主库。这里有一个非常重要的隐含前提:如果调用链是 Service A 调用 Service B,而 A 没有设置注解、B 设置了注解,路由会不会生效?答案是要看 A 是否开启了事务。如果 A 方法上有@Transactional,事务管理器已经在这个方法入口绑定了连接,B 方法的路由设置不会改变当前事务内已持有的连接,甚至可能导致“从库上执行了 insert”的严重事故。所以我建议规则是:读写分离的方法不要和@Transactional混用,要么事务方法内全部走主库,要么把读操作拆到事务外。

如果你使用 MyBatis,还有一个隐藏参数需要注意:SqlSessionTemplate的ExecutorType和SqlSessionFactory的dataSource必须指向同一个动态数据源。如果 Mapper 依赖了另一个SqlSessionTemplate,那这个 Mapper 的路由不会经过切面。检查方法很简单,在配置类里看SqlSessionTemplate构造器的第一个参数。

5. 读写分离的 5 个踩坑:主从延迟、事务、连接池和统计误差

5.1 主从延迟导致“写完读不到”

现象:用户下单后跳转订单详情页,接口返回“订单不存在”或旧状态。 原因:写操作走了主库,随后的读操作路由到从库,而从库的 binlog 同步还没完成,这是典型的读写分离一致性问题。在大多数 MySQL 主从架构下,主从延迟通常在毫秒级,但在大事务或高并发写场景下延迟可能拉长到秒级。 解决:对一致性要求高的接口,强制走主库。常见做法有两种:一种是在方法上加@DataSource("master"),另一种是在论坛/评论这类场景中引入“最近写入 N 秒内读主库”的时间戳判断。我一般会用前者,简单直接,代码可读性好。如果你担心代码侵入性,可以用 ShardingSphere 的HintManager强制主库路由,但这会把逻辑从代码里抽走,排查时反而多一层。

5.2 事务方法内路由串库

现象:@Transactional方法内,先执行了更新,再执行查询,查询结果时而新、时而旧。 原因:Spring 事务管理器在事务开始时从数据源获取连接并绑定到事务上下文中。动态数据源的路由切换不会影响已经绑定的连接。更严重的是部分 JTA 环境下,事务传播到从库连接,更新最终落在从库上,MySQL 从库默认read_only=1,直接抛异常。 解决:明确事务内不能切换数据源。如果你确实需要一个事务内先查主库再写主库,那就不要用@Transactional包裹整个方法,把查询放到事务外,或者直接不切数据源。代码层面可以在事务传播配置上把读请求的传播行为设为REQUIRES_NEW,新开一个事务走从库连接。但事务会带来额外的连接开销,能不用尽量不用。

5.3 从库连接池被读流量打满

现象:主库 QPS 很低,从库连接数却达到上限,接口频繁报HikariPool-1 - Connection is not available, request timed out。 原因:读流量全量打到从库,但没有给从库连接池设置合理的上限。默认 HikariCP 的maximum-pool-size是 10,一旦某个慢查询占据连接,后续请求全部排队。 解决:给从库数据源单独调参,不要和主库共用一套连接池参数。

spring: datasource: dynamic: datasource: master: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 slave: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000

从库连接的maximum-pool-size要大于主库,因为读的 QPS 通常远高于写。另外从库上加慢查询监控,超过 500ms 的 SQL 要尽快优化,否则再大的连接池也会被拖垮。

5.4 Druid 连接池的统计导致路由失效

现象:使用 Druid 时,DruidDataSource的stat过滤器开启了 SQL 统计,结果所有 SQL 都统计在主库上,从库完全没有请求。 原因:Druid 的StatFilter在初始化连接时可能提前触发了getConnection(),而动态数据源的路由 key 尚未设置,连接被默认路由到主库。一旦连接建立,后续的统计和复用都可能固定在这条连接上。 解决:如果非要用 Druid,把从库数据源的stat过滤器关掉,只保留wall或slf4j。或者换回 HikariCP,省心得多。我在实践中看到太多“Druid + 动态数据源”组合的路由失效问题,定位到最后都是连接池的预创建机制在捣乱。

5.5 读写分离开启后,事务管理器仍绑定单数据源

现象:Mapper 走了从库,但事务管理器始终报告主库存在活动事务。 原因:如果配置了多个事务管理器,而@Transactional没有指定transactionManager,Spring 会按类型找默认的PlatformTransactionManager。如果你的DataSourceTransactionManager注入的是masterDataSource,那么动态数据源根本不在事务管理范围内。 解决:事务管理器的数据源必须指向dynamicDataSource。

@Bean public DataSourceTransactionManager transactionManager( @Qualifier("dynamicDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }

这一步排查起来比较隐蔽。因为项目启动不报错,只有当你打印DataSourceTransactionManager的dataSource属性时才能发现它绑定的是主库还是动态代理。遇到问题先断点看TransactionSynchronizationManager.getResourceMap()里的 key 是哪个数据源,比猜要快得多。

6. 用日志和压测验证读写分离真的生效

验证读写分离不是看接口通没通,而是确认“读流量确实到了从库”。最简单的办法是给两个数据源配置不同的 interceptor,打印数据源 key。我在DynamicDataSource里加过这样一段临时日志:

@Override protected Object determineCurrentLookupKey() { String dataSourceKey = getDataSource(); if (dataSourceKey == null) { dataSourceKey = "master"; } log.info("[DynamicDataSource] current route -> {}", dataSourceKey); return dataSourceKey; }

然后在 JMeter 里对/api/order/list施压,观察日志中slave出现的频率。注意如果 QPS 高,日志打印本身会拉低吞吐,验证完就要注释掉。更专业的做法是看 MySQL 侧的连接来源:

SELECT * FROM performance_schema.threads WHERE PROCESSLIST_DB = 'db_slave';

或者看SHOW STATUS LIKE 'Threads_connected'在主从两个实例上的变化。如果压测时读接口的并发数上涨而主库的Threads_connected纹丝不动,说明路由切面生效了。

进阶一点的验证方式:主动在从库造一个与主库不同的数据。比如把主库订单状态改为1,从库人工改成2,然后调用读接口,看返回什么。如果有自动故障转移或读写分离中间件在中间层干扰,这种方法能最快暴露路由是否真的落到指定实例。我习惯在正式环境变更前用这种对比法验一遍,比看日志直观得多。

关于压测,我的一个教训是:不要只测平均响应时间,要同时记录主从两库的 QPS 曲线。有一次我把路由规则写反了,从库完全没有流量,但平均响应时间依然很好看,因为主库扛得住。直到深夜流量高峰,主库连接先打满,业务才报警,这就是没有验证路由正确性导致的翻车。希望这篇文章能帮你绕开这些坑。

以上是我在 Spring Boot 读写分离上的一点实践经验,项目架构和中间件版本不同,细节会有差异,但核心思路是一致的:动态数据源负责按需切换,AOP 负责切面管控,事务边界负责兜底。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询