
简介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?useSSLfalsecharacterEncodingutf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: jdbc-url: jdbc:mysql://localhost:3307/db_slave?useSSLfalsecharacterEncodingutf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driverspring.datasource.dynamic.primary是自定义的默认路由开关意思是当没有显式路由规则时所有 SQL 走master。这样设计的好处是在 AOP 切面没命中时至少不会把写操作打到从库。很多人一开始把默认路由设成slave结果事务更新操作莫名失踪这是很典型的翻车点。接下来是核心类DynamicDataSource继承AbstractRoutingDataSourcepublic class DynamicDataSource extends AbstractRoutingDataSource { private static final ThreadLocalString 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负责在当前线程内传递 keyAOP 切面在方法执行前设置方法结束后务必清理否则连接池归还后 key 残留这个坑很大。3.2 把 DynamicDataSource 注入 Spring 容器然后写一个配置类创建主从数据源并把它们装进DynamicDataSourceConfiguration 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(); MapObject, 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 直连主库。这也是新手常见的“配了但没效果”的原因。如果项目同时用了 JdbcTemplateJdbcTemplate内部的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_only1直接抛异常。 解决明确事务内不能切换数据源。如果你确实需要一个事务内先查主库再写主库那就不要用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没有指定transactionManagerSpring 会按类型找默认的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 负责切面管控事务边界负责兜底。希望帮到你。本文还有配套的精品资源点击获取