☰
Spring Boot 读写分离实战:AbstractRoutingDataSource + ThreadLocal + AOP 动态路由
2026/9/29 2:59:05 网站建设 项目流程

简介:面向需要提升数据库性能的Java开发人员,这份资源系统讲解基于Spring Boot实现数据库读写分离的完整方法,覆盖从主从数据库配置到代码层面动态路由的落地环节。内容围绕AbstractRoutingDataSource动态路由、DbContextHolder线程私有切换、AOP注解与拦截器三个核心模块展开,并给出ReadWriteSplitRoutingDataSource、ReadOnlyConnection、ReadOnlyConnectionInterceptor等关键代码片段,示例代码注释完整,类与方法间关系清晰,可直接作为项目改造的参考模板。资源按思路梳理、源码解析和组合应用三层递进,以PDF格式呈现,共1个文件,包体约48KB,便于快速查阅与收藏。目前已有1007人学习下载,是同类主题中较受欢迎的实战笔记。借助这份资料,读者可以理解读写分离的基本原理,掌握从库切换与事务隔离的处理方式,并能够将自定义路由规则迁移到实际项目中,实现读操作与写操作的自动分流,提高系统整体吞吐量。

1. Spring Boot 读写分离:主从搭好了,代码层却还在裸奔

不少团队把 MySQL 主从同步配完,Binlog 也确认在追,结果一压测发现主库照样被读请求打满。原因很简单:主从是数据库层面的,应用层的DataSource还指着主库一个连接池,业务代码里根本没有「读走从、写走主」的意识。Spring Boot 实现读写分离的关键不在数据库配置,而在应用层能不能在每次数据库操作前决定「这次该连谁」。本文用一套可落地的方案解决这个问题:基于AbstractRoutingDataSource动态路由数据源,配合ThreadLocal记录当前线程的库类型,再用 AOP 注解把「切库」从业务代码里彻底抽走。整套代码量不大,适合已经搭好主从、想在 Spring Boot 项目里快速接上读写分离的团队,也适合想搞懂动态数据源原理的读者。读完你不仅能跑通,还能知道自己会在哪些地方翻车。

2. AbstractRoutingDataSource 路由核心:ThreadLocal 决定你连主还是连从

2.1 为什么是 AbstractRoutingDataSource,而不是自己写代理

Spring 的AbstractRoutingDataSource本质上是一个DataSource代理,它内部维护一个Map<Object, DataSource>,也就是目标数据源集合。每次getConnection()调用时,它都会先执行determineCurrentLookupKey(),拿返回的 key 去 Map 里找真正要用的数据源,再委托给那个数据源创建连接。

这个设计最舒服的地方在于:它把「路由规则」和「连接管理」解耦了。你不需要自己实现DataSource接口,不需要管连接池的生命周期,只需要回答一个问题——当前这个线程该用哪个 key。Spring 在拿到 key 之后,连接池的获取、释放、事务同步全部走标准逻辑。

对应到读写分离,key 就是「主库」还是「从库」。我们定义两个枚举常量MASTER和SLAVE,路由数据源根据当前线程的标记返回对应枚举,就能实现同一套代码里查询走从库、写入走主库。这是整个方案的地基,后面所有 AOP 和注解都建立在它之上。

2.2 路由数据源的实现

直接继承AbstractRoutingDataSource,实现determineCurrentLookupKey(),把决策权交给一个持有当前线程状态的类。代码如下:

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; public class ReadWriteSplitRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DbContextHolder.getDbType(); } }

这段代码很短,但它是整个读写分离的咽喉。determineCurrentLookupKey()的返回值会被 Spring 拿去和targetDataSources里的 key 做匹配。我们返回的是一个DbContextHolder.DbType枚举,那配置路由数据源时,targetDataSources的 key 也必须是同一个枚举类型,否则匹配不上,会直接报IllegalStateException,提示找不到目标数据源。所以后面配置数据源时,key 别手滑写成字符串"master"、"slave",必须和这里返回的类型一致。

2.3 ThreadLocal:为什么路由状态必须线程私有

determineCurrentLookupKey()在执行时并不知道调用方是谁,它需要一个全局可访问、但又不能跨线程污染的状态源。ThreadLocal是最合适的载体:每个线程有自己的副本,线程之间互不干扰,用完记得清理就不会泄漏。

public class DbContextHolder { public enum DbType { MASTER, SLAVE } private static final ThreadLocal<DbType> contextHolder = new ThreadLocal<>(); public static void setDbType(DbType dbType) { if (dbType == null) { throw new NullPointerException(); } contextHolder.set(dbType); } public static DbType getDbType() { return contextHolder.get() == null ? DbType.MASTER : contextHolder.get(); } public static void clearDbType() { contextHolder.remove(); } }

这里有两个细节值得说。第一,setDbType对 null 做了拦截,防止有人误传 null 把状态搞成空值;第二,getDbType在ThreadLocal没有值时默认返回MASTER,这是一个兜底策略——如果某个线程从来没设置过路由状态,说明它大概率是写操作或普通操作,走主库最安全。clearDbType()用remove()而不是set(null),是因为ThreadLocal的remove()会真正清除当前线程的 entry,能避免内存泄漏,尤其在 Tomcat 这类线程池复用的容器里,线程不会销毁,残留的ThreadLocal值会被下一个请求读到。

2.4 先跑通手动切换的最小闭环

在引入 AOP 之前,先看看手工切换是什么效果,这样你才能理解 AOP 到底省了什么。常见的做法是在 Service 方法里手动设置和清理:

@Service public class UserService { @Autowired private UserRepository repository; public List<User> getUsersFromSlave() { try { // 查询前手动标记走从库 DbContextHolder.setDbType(DbContextHolder.DbType.SLAVE); return repository.findAll(); } finally { // 执行完必须清理,否则当前线程下一次操作还会走从库 DbContextHolder.clearDbType(); } } }

这个写法能跑通,但很丑。每个只读方法都要写 try-finally,漏掉一个clearDbType()就是事故——线程池复用时,下一个请求会带着上一个请求的 SLAVE 标记执行写操作,数据直接写进从库,主从一同步就丢数据。这也是为什么后面必须用 AOP 把这段样板代码收敛掉。

3. AOP 切面接管路由:一个 @ReadOnlyConnection 注解搞定从库查询

3.1 注解定义:把意图写进代码里

手动切换的痛点在于「切库」是隐式的,散落在各个方法里。我们用注解把意图显式化:方法上标注@ReadOnlyConnection,就代表这个方法的所有查询只读、走从库。

package com.example.config; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface ReadOnlyConnection { }

注意@Target同时声明了METHOD和TYPE,也就是既支持标在方法上,也支持标在类上。但这里有个隐藏陷阱:我们的拦截器用的是@annotation(readOnlyConnection)绑定方式,它只拦截「注解直接标在方法上」的情况。标在类上不会触发这个拦截器,如果你指望类级注解一次生效,得配合@within或者另写一个类级切面。这一点放到第 5 章「避坑指南」详细展开。

3.2 环绕通知:Around 切面 + finally 清理

拦截器的核心是一个环绕通知,在方法执行前切到从库,执行完在finally里清掉标记。用环绕而不是前置+后置两个通知,好处是状态清理一定在方法返回后执行,即使方法抛异常也不会漏。

import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.stereotype.Component; @Aspect @Component public class ReadOnlyConnectionInterceptor implements Ordered { private static final Logger logger = LoggerFactory.getLogger(ReadOnlyConnectionInterceptor.class); @Around("@annotation(readOnlyConnection)") public Object proceed(ProceedingJoinPoint proceedingJoinPoint, ReadOnlyConnection readOnlyConnection) throws Throwable { try { logger.info("set database connection to read only"); DbContextHolder.setDbType(DbContextHolder.DbType.SLAVE); Object result = proceedingJoinPoint.proceed(); return result; } finally { DbContextHolder.clearDbType(); logger.info("restore database connection"); } } @Override public int getOrder() { return 0; } }

这个方法有两个关键点。第一,@Around("@annotation(readOnlyConnection)")会把注解实例绑定到方法参数上,虽然这里没用到实例内容,但这种写法能精确匹配「方法上带有 @ReadOnlyConnection 注解」的调用;第二,finally里先clearDbType(),清理动作本身会让getDbType()回到默认的 MASTER,所以不需要显式 set 成 MASTER。如果你在清理之后还有其他逻辑想走主库,直接让它走默认即可。

3.3 getOrder() 为什么必须设置

这里getOrder()返回 0,不是随便写写的。Spring 管理多个切面时,@Order值越小优先级越高,越先执行。我们要求路由切面必须优先于事务切面执行——换句话说,先切库,再开事务。

如果顺序反了,事务管理器会先拿到连接并绑定到当前事务,这时候你再切数据源,事务里已经持有的连接不会变,路由等于白切。Spring 的@Transactional默认顺序是Ordered.LOWEST_PRECEDENCE,也就是最后执行,所以我们的拦截器返回 0 就能排在它前面,保证连接是在路由已经切好之后才获取的。

3.4 业务代码里的最终形态

有了注解和拦截器,业务方法就变得很干净:

@ReadOnlyConnection public List<User> getUsers(Integer page, Integer limit) { return repository.findAll(new PageRequest(page, limit)); }

方法上只多了一行注解,路由逻辑完全透明。查询走从库,写操作不标注解走默认主库,代码阅读者一眼能看出方法的数据访问特性。这里我习惯把@ReadOnlyConnection标在 Service 方法而不是 Repository 方法上,因为 Service 才是事务边界所在,路由和事务保持一致,才不会出现「事务开了,路由没切」的割裂。

4. 数据源装配与事务边界:Druid 与路由数据源的组合姿势

4.1 为什么连接池选 Druid

原文用的是 Druid 1.0.18,这个版本比较老,但 Druid 到现在依然是国内 Spring Boot 项目里最常见的连接池之一。它自带监控面板、SQL 慢查询统计、连接泄漏检测,对排查读写分离问题很有帮助——尤其验证「查询到底走了哪个库」时,Druid 的监控界面能直接看到每个数据源的连接数和 SQL 执行情况。

依赖声明如下:

compile("com.alibaba:druid:1.0.18")

实际项目中我更建议用较新的稳定版,比如 1.2.x,并且加上spring-boot-starter-jdbc配合使用。Druid 的核心参数里,initialSize、minIdle、maxActive这三个要针对主从分别设置——主库并发写压力大,maxActive可以给大一些;从库连接数根据读流量估算。有一点容易踩坑:主从两个连接池是独立的DruidDataSource实例,参数分开调,别共用一套配置,否则从库连接数被写流量占满,读请求反而排队。

4.2 注册路由数据源:Groovy DSL 与 Java Config

原文的配置是 Groovy DSL 风格,在 Spring Boot 早期版本里比较常见。它先创建两个DruidDataSource,分别填上主库和从库的连接信息,然后把它们作为targetDataSources塞进ReadWriteSplitRoutingDataSource:

import com.alibaba.druid.pool.DruidDataSource import DbContextHolder import ReadWriteSplitRoutingDataSource // 伪代码:这里加载 properties 配置,实际项目中用 @ConfigurationProperties 更规范 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 ] } }

这段配置里最需要注意的是targetDataSources的 key 用了枚举DbContextHolder.DbType.MASTER。因为ReadWriteSplitRoutingDataSource.determineCurrentLookupKey()返回的是DbContextHolder.getDbType(),也就是枚举本身,key 必须和它 equals 匹配。如果你改用 Java Config,思路完全一样:

@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "datasource.master") public DataSource masterDataSource() { return new DruidDataSource(); } @Bean @ConfigurationProperties(prefix = "datasource.slave") public DataSource slaveDataSource() { return new DruidDataSource(); } @Bean public DataSource routingDataSource() { ReadWriteSplitRoutingDataSource routingDataSource = new ReadWriteSplitRoutingDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put(DbContextHolder.DbType.MASTER, masterDataSource()); targetDataSources.put(DbContextHolder.DbType.SLAVE, slaveDataSource()); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource()); return routingDataSource; } }

Java Config 里我额外调了setDefaultTargetDataSource(masterDataSource()),这是一个兜底:如果determineCurrentLookupKey()返回 null 或者找不到对应 key,路由数据源会 fallback 到默认数据源。配合DbContextHolder.getDbType()默认返回 MASTER 的规则,相当于双保险。

4.3 事务边界:为什么 @ReadOnlyConnection 必须配在事务外层

这是整套方案里最容易出问题的地方。@Transactional一旦开启,Spring 事务管理器会从DataSource获取一个连接,并把这个连接绑定到当前线程的事务资源里。如果路由切面在事务之后执行,事务已经拿着主库连接在跑了,你切到从库也没意义——事务内的所有 SQL 都走那个已经绑定的连接。

所以正确的使用姿势是:@ReadOnlyConnection和@Transactional同时标注时,路由切面必须先执行。我们前面给拦截器设置了getOrder() = 0,而@Transactional的默认顺序是最后,这保证了执行链是:先切路由 → 再开事务 → 事务获取连接时已经是正确数据源。

这里有个「只读事务」的进阶写法,我自己项目里经常这样组合:

@ReadOnlyConnection @Transactional(readOnly = true) public List<User> getUsers(Integer page, Integer limit) { return repository.findAll(new PageRequest(page, limit)); }

@Transactional(readOnly = true)会让底层 JDBC 驱动做只读优化,MySQL 的 Connector/J 还会把 session 设置为readOnly,从库复制延迟导致的数据不一致在读取层面能被一定程度上规避。但注意,readOnly只是建议性的,如果你在事务里执行了写操作,MySQL 不一定报错,所以别把readOnly当成写操作的挡箭牌。

4.4 事务内多次查询:一次事务只路由一次

事务边界还带来另一个约束:一个事务从开始到提交,始终用同一个连接。即使你在事务中间调用DbContextHolder.setDbType(SLAVE),已经在事务里持有的连接也不会切换。换句话说,路由切换只在获取连接的时刻生效。理解了这一点,你就知道为什么要避免在事务内穿插读写操作——比如先写主库、再查从库,这个查询大概率还是走主库,因为事务连接已经绑定了。遇到这种「写完立刻读」的业务,要么拆成两个事务,要么接受主库读,别指望事务内动态切库。

5. 读写分离避坑指南:四个最隐蔽的翻车现场

5.1 坑一:注解标在类上,结果完全不生效

现象:在类上标了@ReadOnlyConnection,想着整个类都走从库,结果一查日志,数据源还是主库,路由切面压根没进来。

原因:我们的拦截器绑定的是@annotation(readOnlyConnection),AspectJ 的@annotation只匹配「注解直接声明在方法上」的连接点。类上的注解对@annotation是不可见的,它需要@within才能匹配。

解决:把注解从类上挪到方法上,或者给拦截器增加一个@within通知处理类级注解。我一般选择前者,类上标注解粒度太粗,容易把不打算走从库的方法也带进去。

// 正确姿势:注解标在方法上 @ReadOnlyConnection public List<User> getUsers(Integer page, Integer limit) { return repository.findAll(new PageRequest(page, limit)); }

5.2 坑二:查询走了从库,但数据是旧的

现象:刚写入一条记录,紧接着查询这条记录,结果是空,或者旧数据。主从架构下这个现象叫「主从延迟」。

原因:从库同步 Binlog 是异步的,写入主库后,从库可能还有毫秒级甚至秒级的延迟。我们只做了读写分离,没考虑「读己之写」的一致性需求。

解决:对一致性要求高的场景,不能一味走从库。我一般会在写操作后设置一个「写后读走主库」的标记,比如把DbContextHolder扩展成支持一个「写后短时间内强制走主库」的策略,或者干脆在业务上规定:刚提交的数据,查询接口带forceMaster参数,路由到主库。这是读写分离绕不开的取舍,纯从库读只适合可以接受轻微延迟的数据。

5.3 坑三:ThreadLocal 没清理,下一个请求继承了 SLAVE 标记

现象:某个标注了@ReadOnlyConnection的方法执行后,下一个请求执行写操作,数据居然写进了从库,主从同步一追,从库数据被覆盖,主库还没这条数据。

原因:Tomcat 的工作线程是复用的,线程执行完请求后不会销毁。如果finally里没有执行DbContextHolder.clearDbType(),当前线程的ThreadLocal里残留 SLAVE 标记,下一个请求拿到这个线程时,路由数据源一看getDbType()返回 SLAVE,就把写操作也发到从库去了。

解决:拦截器的finally块里必须调用clearDbType(),这是硬性要求。另外建议在拦截器里打印当前线程 ID 和设置/清理的配对日志,方便排查「谁切了没清」的现场。

5.4 坑四:从库挂了,读写分离直接雪崩

现象:从库宕机或者网络抖动,所有标了@ReadOnlyConnection的查询全部报连接超时,应用整体不可用,即使主库是健康的。

原因:路由数据源只负责按规则分发,没有健康检查。从库不可用时,查询仍然被路由到从库,不会自动降级到主库。

解决:至少做两层防护。第一,Druid 连接池配置testWhileIdle和validationQuery,及时剔除坏连接;第二,在路由数据源里做失败重试,捕获从库连接异常后清除 SLAVE 标记,重新走主库。我之前做过一个版本是继承AbstractRoutingDataSource后重写getConnection(),从库获取连接失败时记录告警并降级到主库。

5.5 坑五:@Transactional 和 @ReadOnlyConnection 一起用,路由反而失效

现象:方法上同时标了@Transactional和@ReadOnlyConnection,查询还是走了主库,日志里路由切面明明执行了。

原因:切面执行顺序问题。如果ReadOnlyConnectionInterceptor的getOrder()大于事务切面的顺序,事务会先拿到连接并绑定,路由切面执行setDbType(SLAVE)时,事务里的连接已经确定是主库了,后面所有 SQL 都走这个连接。

解决:确认ReadOnlyConnectionInterceptor的getOrder()返回 0 或更小,保证在事务之前执行。排查时最直接的方法是看日志时间线:先打印「set database connection to read only」,再看到事务开始的日志,顺序就对了。

6. 进阶:把读路由和事务强制对齐的三种验证手段

读写分离不是写完代码就完事的,你有没有想过一个问题:你怎么确定查询真的走了从库?我在项目里踩过几次「自认为配好了」的亏之后,养成了三个验证习惯,每个都是实打实的操作。

第一种是打连接日志。在ReadWriteSplitRoutingDataSource里临时重写getConnection(),把实际返回连接的DataSource名称打到日志里。最简单的做法是给主从两个数据源设置不同的connectionInitSql,从库执行SELECT 'SLAVE',主库执行SELECT 'MASTER',然后看日志里Connection建立时打印的值。这个方法不需要引入额外依赖,改一下配置就能验证。

第二种是看数据库端连接来源。MySQL 的performance_schema里记录着每个连接的源 IP 和数据库用户名,如果主从库在不同机器上,直接查SHOW PROCESSLIST能看到当前有哪些连接在执行查询——从库的查询进程多了,说明路由生效了。我在压测时经常挂着SHOW PROCESSLIST刷屏,观察读流量是不是均匀落在从库上。如果从库一个连接都没有,路由八成是无效的。

第三种是事务顺序校验。这是很多人忽略的一步:写了@Transactional(readOnly = true)和@ReadOnlyConnection组合的方法,一定要在测试里故意抛异常,看事务有没有正常回滚、路由状态有没有清理干净。我在一个项目里就是靠这个测试发现了finally块里clearDbType()被优化掉的问题——因为拦截器方法写的太「简洁」,编译器把清理逻辑放到了异常路径之外,测试一抛异常,下一个请求直接继承了 SLAVE 标记。

从那以后,我每次接入读写分离都强制走一遍验证流程:先确认主从同步正常,再跑最小查询确认注解生效,最后做一次异常注入测试确认 ThreadLocal 清理没有漏洞。这套流程能挡住上面 90% 的翻车现场。读写分离本身不难,难的是把路由、事务、线程生命周期这三件事对齐——对齐了,它就是一套稳稳当当的架构;没对齐,它就是一颗埋在线程池里的雷。希望这篇文章能帮你少踩几个我已经替你踩过的坑。

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

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

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

立即咨询