Spring Boot 读写分离实战:AbstractRoutingDataSource + ThreadLocal + AOP 动态路由

发布时间:2026/9/29 2:59:10
Spring Boot 读写分离实战:AbstractRoutingDataSource + ThreadLocal + AOP 动态路由
简介面向需要提升数据库性能的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代理它内部维护一个MapObject, 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 ThreadLocalDbType 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 ListUser 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 ListUser 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塞进ReadWriteSplitRoutingDataSourceimport 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(); MapObject, 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 ListUser 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 ListUser 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% 的翻车现场。读写分离本身不难难的是把路由、事务、线程生命周期这三件事对齐——对齐了它就是一套稳稳当当的架构没对齐它就是一颗埋在线程池里的雷。希望这篇文章能帮你少踩几个我已经替你踩过的坑。本文还有配套的精品资源点击获取