Spring Boot读写分离:基于AbstractRoutingDataSource的轻量实现

发布时间:2026/10/5 17:11:54
Spring Boot读写分离:基于AbstractRoutingDataSource的轻量实现
简介《Spring Boot实现数据库读写分离的方法》是一份面向后端开发者的PDF文档系统讲解如何借助AbstractRoutingDataSource与自定义路由策略缓解主库压力、提升读取性能适合已有Spring Boot基础并希望优化数据层架构的Java工程师。文档先从读写分离的基本思想切入说明主库负责写、从库负责读的高并发场景价值再深入演示ReadWriteSplitRoutingDataSource如何重写determineCurrentLookupKey()以及DbContextHolder通过ThreadLocal实现线程安全的库类型切换并结合AOP与ReadOnlyConnection注解让业务代码无侵入地自动路由到从库。全流程围绕真实案例展开包含核心类设计、AOP拦截器实现与事务注意点读者可按图索骥快速落地。资源包仅含1个PDF文件体积约48KB轻量易读适合作为团队内部分享或日常查阅的速查资料。截至目前已有1008人学习下载兼顾原理与实战是理解Spring Boot读写分离实现路径的实用参考。1. Spring Boot 做数据库读写分离不一定要上中间件先试试 AbstractRoutingDataSource提到 Spring Boot 数据库读写分离很多人的第一反应是引入 ShardingSphere、MyCat或者换一个云数据库 proxy。但在项目只有一主一从、不想额外引入组件和运维成本的前提下Spring 自带的AbstractRoutingDataSource已经能解决问题。它的原理不难在查询前设置一个线程私有的路由标记让数据源在getConnection()时根据标记选择主库或从库查询结束后再清掉标记。下面这套代码就是典型实现适合用 Spring Boot JPA/MyBatis/JdbcTemplate 的 Web 服务不需要改业务 SQL只需要在只读方法上加一个注解。这套方案更适合主从复制已经搭好、读多写少、主库压力明显的项目。它也同时说明了自定义路由的边界单服务、单主库、从库数量可控时很好用如果要做分库分表、多从库自动负载均衡那还是考虑更重的中间件。2. 先从路由核心讲起AbstractRoutingDataSource 到底怎么选库2.1 为什么不能直接在代码里注入两个 DataSource很多第一次做读写分离的人会这样写在 Service 里注入两个DataSource读操作调slaveDataSource.getConnection()写操作调masterDataSource.getConnection()。这在一两个方法里能跑但问题很明显每一个读方法都要手动选择数据源业务代码里到处都是 if/else。如果之后接的是 JPA、MyBatis它们内部默认只认一个DataSource你很难在一条 SQL 执行链里动态换连接。事务一旦开启连接已经绑死再换也没有意义。AbstractRoutingDataSource解决的是“一个数据源对象内部按 key 分发到多个真实数据源”的问题。它维护了一个targetDataSources集合每次调用getConnection()时Spring 会先执行determineCurrentLookupKey()用返回值到targetDataSources里找对应的真实数据源。这个查找动作发生在获取连接的关键节点上所以路由能覆盖到 JdbcTemplate、MyBatis、JPA 等所有走 DataSource 的持久层框架。2.2 ReadWriteSplitRoutingDataSource只需要重写一个方法实现自定义路由数据源的核心类非常简单核心逻辑全在determineCurrentLookupKey()里。下面是典型写法import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; /** * 可动态路由的数据源。 * 每次从连接池取连接时Spring 都会调用 determineCurrentLookupKey() * 用返回值去 targetDataSources 中定位主库或从库。 */ public class ReadWriteSplitRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 返回当前线程设置的数据库类型MASTER 或 SLAVE return DbContextHolder.getDbType(); } }这里需要理解一个关键点determineCurrentLookupKey()不一定只在事务开始时执行。Spring 在每次通过DataSourceUtils.getConnection()获取连接时都会走一遍路由拿到的是连接池里的物理连接。如果当前线程没有设置任何标记就必须有一个默认值否则路由会返回 null导致找不到目标数据源。2.3 DbContextHolderThreadLocal 决定了每个线程走主还是走从路由标记为什么用ThreadLocal而不是一个静态变量因为 Web 容器是并发的。如果用一个普通静态字段存放当前要走的库一个请求设置了 SLAVE另一个线程的读操作也会跟着走从库整个系统的路由就乱了。ThreadLocal把数据库类型绑定在“当前线程”上互不影响。/** * 线程私有的数据库类型持有器。 */ public class DbContextHolder { public enum DbType { MASTER, SLAVE } private static final ThreadLocalDbType contextHolder new ThreadLocal(); public static void setDbType(DbType dbType) { if (dbType null) { throw new NullPointerException(dbType cannot be null); } contextHolder.set(dbType); } public static DbType getDbType() { // 没有设置时默认走 MASTER防止路由找不到目标数据源 return contextHolder.get() null ? DbType.MASTER : contextHolder.get(); } public static void clearDbType() { // 使用 remove 而不是 set(null)避免 ThreadLocal 内存泄漏 contextHolder.remove(); } }这里有两个细节值得注意。第一个是setDbType(null)直接抛空指针目的是尽快暴露问题而不是让一个 null 值流进路由逻辑。第二个是clearDbType()用remove()不是set(null)这是 ThreadLocal 使用的规范操作尤其是服务用线程池时不 remove 会导致旧值被其他任务读到。3. AOP 把切库抽离用注解代替散落在业务里的 set/clear3.1 为什么不用 try/finally 散落在业务代码里如果没有 AOP只读方法的写法会变成这样public ListUser getUsers(Integer page, Integer limit) { DbContextHolder.setDbType(DbContextHolder.DbType.SLAVE); try { return repository.findAll(...); } finally { DbContextHolder.clearDbType(); } }一个方法还好十个方法就会出现大量重复代码。而且开发人员很容易漏写finally一旦漏了线程池里的线程就可能带着 SLAVE 标记处理下一个写请求这是非常隐蔽的生产事故。所以更好的做法是定义一个ReadOnlyConnection注解通过 AOP 统一完成“进入方法前设为 SLAVE方法结束后无论是否抛异常都 clear”。3.2 只读注解与拦截器实现注解定义没什么特别关键是把Target定为方法和类Retention定为 RUNTIMEimport java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 标注在只读方法上AOP 会将该方法的数据库连接路由到从库。 */ Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface ReadOnlyConnection { }拦截器是 AOP 的核心用Around环绕通知。方法执行前设置 SLAVE方法执行完成后无论成功还是异常都清除标记import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.core.Ordered; import org.springframework.stereotype.Component; Aspect Component public class ReadOnlyConnectionInterceptor implements Ordered { Around(annotation(readOnlyConnection)) public Object proceed(ProceedingJoinPoint proceedingJoinPoint, ReadOnlyConnection readOnlyConnection) throws Throwable { DbContextHolder.setDbType(DbContextHolder.DbType.SLAVE); try { return proceedingJoinPoint.proceed(); } finally { DbContextHolder.clearDbType(); } } Override public int getOrder() { // order 越小优先级越高。路由切面要尽量靠前避免事务先绑定连接 return 0; } }这里有几个实现要点。Around(annotation(readOnlyConnection))会把注解实例传进方法参数虽然当前实现没有直接使用它但后续如果想在注解里加“强制主库”或“指定从库编号”之类的属性这个参数就能派上用场。finally清理是必须的异常路径也不能漏。getOrder()返回 0是希望它比常规业务切面先执行如果项目里同时出现事务管理还要注意跟事务切面的先后关系这一点后面避坑章节还会展开。3.3 在 Service 上的落地用法路由从业务代码里彻底抽走之后Service 层只需要关心业务逻辑import org.springframework.stereotype.Service; Service public class UserService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository repository; } /** * 只读查询自动走从库。 */ ReadOnlyConnection public ListUser getUsers(Integer page, Integer limit) { // 老版本 Spring Data 用 new PageRequest(page, limit)新版本建议用 PageRequest.of return repository.findAll(PageRequest.of(page, limit)); } }如果用的是 MyBatisMapper接口方法也可以这么标注效果是一样的。要记住一个边界AOP 默认只对 Spring 代理的方法生效。同一个类里this.getUsers()这样互相调用注解不会触发因为代理没有介入。跨类注入调用是正常用法自调用是常见误用。4. 装配数据源Druid 连接池 可路由数据源4.1 依赖与主从连接配置原方案里用的连接池是 Druid依赖写法是 Gradlecompile(com.alibaba:druid:1.0.18)如果你用 Maven对应的是dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.0.18/version /dependencyDruid 除了连接池功能还有监控页面后面验证路由时可以直接看它统计的主从连接数。除此之外工程里还需要spring-boot-starter-jdbc和spring-boot-starter-aopAOP 依赖少了Aspect不会生效。主从两个数据源的连接信息一般放在配置文件中datasource: master: url: jdbc:mysql://192.168.1.10:3306/app?useUnicodetruecharacterEncodingutf8useSSLfalse username: root password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://192.168.1.11:3306/app?useUnicodetruecharacterEncodingutf8useSSLfalse username: root password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver需要特别注意 MySQL 8 以下的老驱动类名是com.mysql.jdbc.DriverMySQL 8 以上要换成com.mysql.cj.jdbc.Driver这个和 Spring Boot 版本、MySQL 版本都有关系。4.2 把路由数据源注入 Spring 容器原文章里给的是 Groovy Bean DSL在 Grails 或 Spock 相关工程里比较多见import com.alibaba.druid.pool.DruidDataSource import DbContextHolder import ReadWriteSplitRoutingDataSource def dataSourceMaster new DruidDataSource() dataSourceMaster.url properties.get(datasource.master.url) dataSourceMaster.username properties.get(datasource.master.username) dataSourceMaster.password properties.get(datasource.master.password) def dataSourceSlave new DruidDataSource() dataSourceSlave.url properties.get(datasource.slave.url) dataSourceSlave.username properties.get(datasource.slave.username) dataSourceSlave.password properties.get(datasource.slave.password) beans { dataSource(ReadWriteSplitRoutingDataSource) { bean - targetDataSources [ (DbContextHolder.DbType.MASTER): dataSourceMaster, (DbContextHolder.DbType.SLAVE): dataSourceSlave ] defaultTargetDataSource dataSourceMaster } }这个 DSL 里最容易漏的是defaultTargetDataSource。如果不设默认主库当前线程没有设置路由标记时determineCurrentLookupKey()返回的 MASTER 理论上也能查到 map但万一某个请求把标记清掉了兜底会更稳妥。如果你用的是标准 Spring Boot 注解配置我更推荐直接用 JavaConfigimport org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import javax.sql.DataSource; import java.util.HashMap; import java.util.Map; Configuration public class DataSourceConfig { Bean Primary public DataSource routingDataSource(DataSource masterDataSource, DataSource slaveDataSource) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DbContextHolder.DbType.MASTER, masterDataSource); targetDataSources.put(DbContextHolder.DbType.SLAVE, slaveDataSource); ReadWriteSplitRoutingDataSource routingDataSource new ReadWriteSplitRoutingDataSource(); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource); return routingDataSource; } }Primary也很关键。Spring Boot 自动配置会找一个 DataSource 作为 JPA、MyBatis 或 JdbcTemplate 默认数据源如果不标记Primary容器里同时存在多个 DataSource 时自动配置不知道用谁启动阶段就可能报错。4.3 Druid 连接池参数建议一主一从两个DruidDataSource是独立连接池建议参数保持一致但按实际情况区分性能。我一般这么设参数建议值说明initialSize5启动时初始连接数minIdle5连接池最小空闲连接maxActive20主库和从库各自最大连接数根据压测调整maxWait60000获取连接超时时间避免连接池耗尽后线程无限等待validationQuerySELECT 1连接有效性检查 SQLtestWhileIdletrue空闲连接是否定时检查testOnBorrowfalse获取连接时不做实时检查性能更好从库可以稍微调大maxActive因为读流量往往远大于写流量。但要注意连接数是数据库侧的宝贵资源不是调越大越好尤其是从库数量多时连接数会成倍增长。5. 避坑记录读写分离最容易翻车的五个地方5.1 事务一开路由就“失灵”现象方法上同时加了ReadOnlyConnection和Transactional(readOnly true)日志里明明打印了 set SLAVE但实际 SQL 还是落在主库上。原因路由只发生在DataSource.getConnection()那一刻。如果事务管理切面比路由切面先执行事务管理器会先拿到连接并把连接绑定到当前事务之后路由切面再设置 SLAVE连接已经固定改标记也晚了。这个问题的出现和切面 order 直接相关只要事务切面的优先级被调得比路由切面高就会踩中。解决要么别混用两个注解读方法只加ReadOnlyConnection要么把路由切面的 order 设为Ordered.HIGHEST_PRECEDENCE也就是比事务切面更靠前。强制要求事务的读场景我一般会锁死路由切面在前不赌默认配置。5.2 ThreadLocal 没清理连接池线程被污染现象一个只读接口执行完后下一个请求没有被ReadOnlyConnection标注却仍然连接从库。查代码又看不出哪里设置了 SLAVE。原因ThreadLocal是线程私有的但 Web 容器会复用线程。如果setDbType(SLAVE)之后没有 finally 清理这个线程的标记会一直保留到下一次被分配任务。解决路由切换必须成对出现AOP 的finally里一定要clearDbType()。业务代码里如果自己 set 了数据库类型也一定要用 try/finally 包住这属于血泪经验不是锦上添花。5.3 从库延迟接口刚写入就立刻查读到了旧数据现象用户提交订单成功后马上刷新订单列表列表里看不到刚创建的订单但主库数据明明已经写进去了。原因MySQL 主从复制默认是异步的从库数据落后主库一个复制线程的时间差。走从库的读操作能扛并发但不保证读到实时数据。解决读自己的写这种强一致场景不能走从库。常见做法是这类方法不加ReadOnlyConnection强制走主库或者单独做一个“强制主库”的注解把延迟敏感查询和普通只读查询分开。5.4 单 SLAVE 键位一个从库能跑多从库直接乱套现象从库加到两个以后所有读流量还是打到一个从库另外一个闲着。原因DbContextHolder.DbType里 SLAVE 只有一个枚举值targetDataSources是一个 key 对应一个 DataSource 的 Map没法表达“多个从库选一个”的策略。解决多从库场景要把 SLAVE 从单值改成多值比如SLAVE_1、SLAVE_2、SLAVE_3在determineCurrentLookupKey()里做轮询或随机选择。要记住一旦某个从库被选中同一次事务内不要再换否则连接可能串到不同实例。5.5 同类调用或类级注解不触发AOP 静默失效现象方法上明明标了ReadOnlyConnection但日志里没有 set SLAVE 的记录接口也没有任何报错。原因两个常见情况。第一种是同类内部方法调用比如this.getUsers()Spring AOP 的代理没有经过注解自然不生效。第二种是注解声明支持ElementType.TYPE但切点写的是annotation(readOnlyConnection)它只匹配方法注解不匹配类注解。解决自调用场景把方法拆到另一个被注入的 Bean或者用AopContext.currentProxy()绕一下但不推荐。如果确实想支持类级注解切点要改成类似within(readOnlyConnection) || annotation(readOnlyConnection)的组合写法然后自己处理两种匹配叠加时重复 set/clear 的问题。6. 上线前这样验证日志、SQL 审计和一条强制路由的回归用例搭好一主一从后不要急着上线先用最直接的方式确认路由真的生效。最简单的是看拦截器日志调用只读接口时能看到 set SLAVE方法返回后能看到 restore主库写接口则完全没有这两条日志。更可靠的验证是让 SQL 自己说话。在主从两台实例上分别执行SELECT hostname, port返回值一定不同。我一般会临时加一个只读接口import org.springframework.jdbc.core.JdbcTemplate; ReadOnlyConnection public MapString, Object currentDbInfo() { JdbcTemplate jdbcTemplate new JdbcTemplate(routingDataSource); return jdbcTemplate.queryForMap(SELECT hostname AS hostname, port AS port); }注意这里不能用queryForObject(String.class)因为查询返回两个字段应该用queryForMap。调用这个接口后如果返回的主机名是从库的主机名说明路由链路是通的。Druid 的监控页面也可以作为辅助证据。启用 StatFilter 和 WebStatFilter 后访问/druid/index.html能够看到主从两个数据源各自的活跃连接数和执行 SQL 统计。如果只读接口压测后从库连接数明显上涨主库连接数不动基本可以确认读流量已经分流。我个人的习惯是维护一个极小的回归用例先调用一次带ReadOnlyConnection的只读方法再在同一个线程里执行一次写操作然后断言写操作落在主库。这个用例不是为了测业务正确性而是为了验证“SLAVE 标记被清掉了”。如果clearDbType()没执行写操作会带着旧标记落到从库写失败或者写丢失会在很晚才暴露。从那以后我每次把路由改动合并到主分支之前都强制走一遍这个动作日志里 set/restore 各出现一次才算过。希望帮到你。本文还有配套的精品资源点击获取