☰
Spring Boot读写分离:基于AbstractRoutingDataSource与AOP的动态数据源主从切换
2026/10/9 11:23:33 网站建设 项目流程

简介:这是面向Java开发者的Spring Boot进阶技巧资料,重点讲解借助AbstractRoutingDataSource与自定义路由规则实现数据库读写分离。资料提取了主从库动态切换的完整思路,包含ReadWriteSplitRoutingDataSource、DbContextHolder以及基于@ReadOnlyConnection注解的AOP切面实现,适合有一定Spring Boot基础、需要优化高并发读性能的中级开发者参考。压缩包为单个PDF文档,体积仅48KB,内容紧凑,可直接阅读或打印。文档从主从库设计思想讲起,逐步拆解动态数据源路由、ThreadLocal线程隔离及环绕通知等关键环节,并给出代码片段与执行流程,帮助读者理解如何将读写操作分散到不同实例,降低主库压力。资源已有1008人浏览学习,若正在实践读写分离或准备相关技术改造,这份资料能提供清晰的设计思路与落地参考。

1. 读写分离难在代码路由:Spring Boot 主从切换的最后一公里

读多写少是绝大多数业务系统的常态,把主库的 SELECT 压力分走,往往比加缓存、加索引见效更快。但很多人把主从复制一配完就以为大功告成,结果压测一跑,应用层所有的查询还是怼在主库上,主库负载纹丝不动。原因很简单:数据库层面做了主从,代码层面没有做读写分离。应用到底连哪个库,取决于你给的数据源连接用的是谁的地址。Spring Boot 项目里真正能解决这件事的,就是AbstractRoutingDataSource配合ThreadLocal保存路由标记,再用 AOP 把切换动作从业务代码里剥出去。这套方案不依赖任何中间件,适合大多数中小团队直接落地。本文的代码和配置全部来自一个可复现的 Spring Boot 工程,我按自己拆过的项目把每一步的选型理由和踩坑点写清楚。

2. AbstractRoutingDataSource:动态数据源的路由键是怎么工作的

2.1 为什么 Spring Boot 默认的数据源做不到自动分流

Spring Boot 的spring.datasource.url只能配置一个物理地址,所有 Repository 操作共用一个连接。要做读写分离,第一反应是定义两个DataSourceBean,一个叫 masterDataSource,一个叫 slaveDataSource,然后在 Service 层手动指定。这种方案在代码里会出现大量@Qualifier("slaveDataSource"),每个查询方法都要声明自己走哪个库。用不了多久就发现问题:业务方法一多,注解满天飞,同事接手以后根本分不清哪些方法应该走主库、哪些走从库。更麻烦的是,如果同一个事务里既有读又有写,手工切换很容易把连接状态搞乱。

AbstractRoutingDataSource解决的是「一个 DataSource 门面,背后多个物理数据源」的问题。它继承自 Spring 的AbstractDataSource,内部维护一个Map<Object, DataSource> targetDataSources,每次getConnection()时,它会调用一个抽象方法determineCurrentLookupKey()拿到当前这条线程应该用的路由键,然后从 Map 里挑出对应的真实数据源来创建连接。这个机制很像 Nginx 的 upstream,对外只有一个入口,具体转发到哪台后端由路由规则决定。Spring Boot 里的 JPA、MyBatis 拿到的都是这个门面数据源,底层连接却来自不同的物理库。

这套设计的关键在于:路由键的获取时机是每次获取连接的时候,不是数据源初始化的时候。因此,你可以在一个请求里不同时刻拿到不同库的连接,这就是读写分离能跑起来的原理基础。

2.2 自定义路由数据源的代码骨架

继承AbstractRoutingDataSource只需要重写一个方法,代码量非常少。下面这个类就是整个方案的核心门面:

package com.example.datasource; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; /** * 可动态路由的数据源 * 每次获取数据库连接时,determineCurrentLookupKey() 被调用, * 返回的 key 对应 targetDataSources Map 中的键 */ public class ReadWriteSplitRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DbContextHolder.getDbType(); } }

这段代码逻辑很简单,但有一个关键点要注意:determineCurrentLookupKey()的返回值会与targetDataSources的 key 做精确匹配。也就是说,DbContextHolder.getDbType()返回什么,targetDataSources的 key 就必须对应什么,类型和值都不能差。后面配置阶段我会展示targetDataSources是怎么构造的,这里先记住这个匹配关系。

2.3 targetDataSources 和 defaultTargetDataSource 的语义边界

AbstractRoutingDataSource除了targetDataSources之外还有个defaultTargetDataSource属性。它表示路由键匹配不到任何数据源时的兜底连接。很多人会在这里犯一个错:把defaultTargetDataSource配成主库,以为这样路由失败时自动走主库比较安全。这个想法在读写分离场景里其实是隐患。因为一旦determineCurrentLookupKey()因为 ThreadLocal 没清理返回了一个未知值,或者 Map 构造少了一个 key,所有流量都会静默走主库。主库压力上来了还查不出原因,因为日志里没有报错。我的习惯是defaultTargetDataSource不配,让 Spring 直接抛出IllegalArgumentException,把路由问题尽早暴露出来。这算是「宁可报错也不静默降级」的运维思路。

2.4 为什么选 AbstractRoutingDataSource 而不是自己写代理

有些团队会自己写一个包装 DataSource 类,重写getConnection()方法做 if-else 判断。不是不行,但容易踩两个坑:一是unwrap、getConnection(String username, String password)这些方法都要自己维护,二是在 Spring 的事务管理器里,DataSourceUtils.getConnection()会对同一个 DataSource 做缓存,如果代理类没有正确实现equals/hashCode,事务内重复获取连接可能返回不同连接,导致事务失效。AbstractRoutingDataSource是 Spring 官方提供的能力,事务管理器、连接池、监控组件都认它,省掉的是最底层的一堆兼容性代码。

3. ThreadLocal 与 AOP 双通道:把从库路由从业务代码里拆干净

3.1 ThreadLocal 的「线程私有」和连接池的「线程复用」之间隔着什么

数据源说清楚了,接下来要解决「怎么告诉数据源当前线程应该走哪个库」。方案里用的是ThreadLocal,这几乎是读写分离的标准做法,因为数据库连接在绝大部分框架里都是跟着线程走的,一个请求的生命周期内,ThreadLocal 变量天然只对当前线程可见。

但这里藏着一个非常关键的问题:连接池和线程池都在复用资源。Tomcat 的工作线程处理完请求 A 之后,并不会销毁,而是继续处理请求 B。如果在请求 A 结束时没有清掉 ThreadLocal 里的路由标记,请求 B 的线程里残留的还是上一次的 SLAVE 标记,那么请求 B 里没加注解的写操作也走从库,数据写丢了,问题极其隐蔽。

所以DbContextHolder的设计必须同时具备三个能力:允许设置路由类型、允许读取当前路由类型、允许清理当前路由类型。下面是我在实际项目里调整过的版本:

package com.example.datasource; /** * 线程私有的数据库路由上下文 * 使用 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 传进来。如果不去拦截,ThreadLocal里被塞进一个 null,后面getDbType()的判空逻辑就失效了。这是很多线上问题「没报错但路由不对」的根源。

getDbType()的默认值设计也很重要:ThreadLocal 里没值的时候返回 MASTER。这个默认行为保证了没有标注任何读写分离语义的方法,走的是主库。只有显式标记了@ReadOnlyConnection的方法才会切换从库。这样设计的好处是「默认安全」。

3.2 AOP 拦截器的完整写法与执行顺序控制

有了路由上下文,剩下的问题是怎么在业务方法前后自动调用setDbType和clearDbType。直接在每个 Service 方法里写这两行代码不是不行,但会污染业务逻辑。用 AOP 的本质就是把这些横切动作抽出来。

package com.example.datasource.annotation; 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 { }
package com.example.datasource.aspect; import com.example.datasource.DbContextHolder; 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; /** * 拦截 @ReadOnlyConnection 注解,切换从库路由 * 实现 Ordered 接口并返回 0,确保比事务管理器更早拿到连接 */ @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("切换数据源到从库"); DbContextHolder.setDbType(DbContextHolder.DbType.SLAVE); return proceedingJoinPoint.proceed(); } finally { DbContextHolder.clearDbType(); logger.info("已恢复主库路由"); } } @Override public int getOrder() { return 0; } }

这个拦截器有两个设计点是必须要在项目里写清楚的。第一个是 finally 块里的clearDbType(),不管业务方法抛出什么异常,路由标记都必须清理,否则下一次线程复用时就会串路由。第二个是getOrder()的返回值。Spring 的切面是有顺序的,如果你在同一个方法上同时用了@ReadOnlyConnection和@Transactional,事务管理器也会参与切面协调。因为数据库连接实际是在事务开始时获取的,如果路由切面的事务顺序比事务管理器晚,事务管理器可能已经拿到主库连接了,再切换路由就晚了。把ReadOnlyConnectionInterceptor的 Order 设为 0,让它排在事务管理器之前执行,才能保证从库路由在事务开启前生效。这个坑后面还会专门展开。

3.3 @Around 为什么比 @Before + @After 更适合这个场景

可能有人会问,为什么不用@Before设置路由、@After清理路由两个注解搞定。技术上当然可以,但@Around有一种「最终解释权」的语义:路由的清理被放在 finally 块里,无论方法正常返回、抛出异常、还是被内部 try-catch 吞掉,清理一定会执行。而@After虽然也能对标 finally,但在同一个方法上有多个切面时,切面的织入顺序会让「环绕通知嵌套」的可读性更好。我自己的习惯是:凡是要改上下文再执行业务逻辑的场景,一律用@Around。顺手一提,切入点表达式@annotation(readOnlyConnection)里的参数名必须和拦截器方法参数名一致,两处都叫readOnlyConnection,改一处忘改另一处的话 AspectJ 会在启动时直接报错,这个报错信息有点绕,见过几次就知道是这里的问题。

3.4 注解直接打在 Service 方法上的调用关系

把注解加到业务方法上,使用方不需要关心底层路由逻辑。比如下面这个查询用户列表的方法:

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

方法上的@ReadOnlyConnection会让拦截器在方法执行前把路由设为 SLAVE,查询走从库,方法执行完清理路由,后续其他操作恢复主库。还要注意一点,这个注解同时也加在了@Target的 TYPE 上,也就是说可以标注在类级别,让整个类的所有方法默认走从库,适合纯查询类的 Service。如果类上标注了,方法上没标注,拦截器对类里的方法同样会生效,这是@annotation切点对注解继承机制的匹配结果,实际使用中很多人会忽略这一点。

4. 数据源配置落地:Druid 连接池参数与路由数据源注册

4.1 为什么连接池选 Druid 而不是 HikariCP

原项目里用的是阿里 Druid,版本 1.0.18。这个版本在现在来说有点老,但 Druid 在读写分离这个场景里的价值不在于性能,而在于它自带监控面板和 SQL 审计,主从流量一眼就能看出来。HikariCP 的启动速度快、单连接性能高,但它不提供内置的监控页面。读写分离上线后,你非常需要确认「这条路真的在走从库、那条路真的在主库」,Druid 的stat页面能直接看到每个物理数据源上的活跃连接数和 SQL 统计。这不是说 HikariCP 不能用,而是针对「验证路由是否正确」这个诉求,Druid 更顺手。从 1.0.18 到 1.2+,连接池参数语义基本没变,下面的配置可以平移到新版本。

4.2 双数据源的定义与路由数据源绑定

配置阶段要先创建两个独立的物理数据源,再把它们塞进路由数据源的targetDataSources。下面是 Groovy DSL 方式,也是原项目采用的配置风格:

import com.alibaba.druid.pool.DruidDataSource import DbContextHolder import ReadWriteSplitRoutingDataSource // load properties from config file def properties = new Properties() properties.load(new FileInputStream("datasource.properties")) // 主库数据源 def dataSourceMaster = new DruidDataSource() dataSourceMaster.url = properties.getProperty('datasource.master.url') dataSourceMaster.username = properties.getProperty('datasource.master.username') dataSourceMaster.password = properties.getProperty('datasource.master.password') dataSourceMaster.initialSize = 5 dataSourceMaster.minIdle = 5 dataSourceMaster.maxActive = 20 println("master set to " + dataSourceMaster.url) // 从库数据源 def dataSourceSlave = new DruidDataSource() dataSourceSlave.url = properties.getProperty('datasource.slave.url') dataSourceSlave.username = properties.getProperty('datasource.slave.username') dataSourceSlave.password = properties.getProperty('datasource.slave.password') dataSourceSlave.initialSize = 5 dataSourceSlave.minIdle = 5 dataSourceSlave.maxActive = 20 println("slave set to " + dataSourceSlave.url) beans { // 注册路由数据源为应用主数据源 dataSource(ReadWriteSplitRoutingDataSource) { bean -> targetDataSources = [ (DbContextHolder.DbType.MASTER): dataSourceMaster, (DbContextHolder.DbType.SLAVE) : dataSourceSlave ] } }

这段配置有三个容易出错的地方。第一,targetDataSources是个 Map,key 的类型是Object,但你必须保证 key 的值和determineCurrentLookupKey()的返回值完全一致。上面用了枚举作为 key,那getDbType()返回的必须也是同一个枚举实例,值相同但类型不同的字符串是匹配不上的。第二,dataSource(ReadWriteSplitRoutingDataSource)这里会把 Bean 名字直接注册成dataSource,Spring Boot 在自动配置阶段会对数据源做各种推断,你要确保没有其他命名为dataSource的 Bean 冲突,否则启动时会报BeanDefinitionOverrideException,或者你的路由配置被自动配置覆盖。第三,可以参考下面这张参数表来理解 Druid 连接池里几个关键参数的含义和调优方向:

参数作用读写分离场景建议
initialSize启动时建立的物理连接数主从都设为 5,避免启动流量打满
minIdle池中最低空闲连接数从库可设高一些,因为读流量通常更大
maxActive池中最大活跃连接数按接口 QPS 和单 SQL 耗时估算,建议先按主 20 / 从 50 起步
maxWait获取连接的超时时间默认 60000ms,压测时若频繁抛GetConnectionTimeoutException就是该调小或扩连接
testWhileIdle空闲连接是否保活探测打开,避免 MySQLwait_timeout杀掉空闲连接后拿到坏连接

4.3 纯 Spring Boot 工程里的 Java Config 写法

有人问原项目用的 Groovy DSL 是 Grails 风格,我这边是普通 Spring Boot 工程,能不能用 Java Config。当然可以,套路上完全一致。用@Bean显式声明两个 DruidDataSource,再声明路由数据源,把目标数据源 Map 灌进去。下面的代码是一个可直接粘进项目的 Java 配置版本,包路径换成你自己的:

package com.example.config; import com.alibaba.druid.pool.DruidDataSource; import com.example.datasource.DbContextHolder; import com.example.datasource.ReadWriteSplitRoutingDataSource; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.sql.DataSource; import java.util.HashMap; import java.util.Map; @Configuration public class DataSourceConfig { @Value("${datasource.master.url}") private String masterUrl; @Value("${datasource.master.username}") private String masterUsername; @Value("${datasource.master.password}") private String masterPassword; @Value("${datasource.slave.url}") private String slaveUrl; @Value("${datasource.slave.username}") private String slaveUsername; @Value("${datasource.slave.password}") private String slavePassword; @Bean(name = "masterDataSource") public DataSource masterDataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl(masterUrl); ds.setUsername(masterUsername); ds.setPassword(masterPassword); return ds; } @Bean(name = "slaveDataSource") public DataSource slaveDataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl(slaveUrl); ds.setUsername(slaveUsername); ds.setPassword(slavePassword); return ds; } @Bean(name = "dataSource") public DataSource routingDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put(DbContextHolder.DbType.MASTER, masterDataSource()); targetDataSources.put(DbContextHolder.DbType.SLAVE, slaveDataSource()); ReadWriteSplitRoutingDataSource routingDataSource = new ReadWriteSplitRoutingDataSource(); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } }

这里再补充一个application.properties里的配置习惯,数据库连接信息不要硬编码在 Java 文件里,用占位符引用外部配置是更稳的做法:

datasource.master.url=jdbc:mysql://192.168.1.10:3306/app_db datasource.master.username=root datasource.master.password=master_pwd datasource.slave.url=jdbc:mysql://192.168.1.11:3306/app_db datasource.slave.username=root datasource.slave.password=slave_pwd

4.4 路由键匹配的逻辑说明

在 Java Config 里setTargetDataSources传入的 Map 是Map<Object, Object>,Spring 内部会做类型转换。路由键是枚举DbContextHolder.DbType,路由数据源内部用resolveSpecifiedDataSource把 value 转成真实 DataSource。当执行 SQL 时,AbstractRoutingDataSource通过determineCurrentLookupKey()拿到枚举值,直接在 Map 里查目标数据源。这个过程是同步的、无锁的,每次获取连接都会重新走一遍,所以路由切换的粒度可以做到「每次连接」。

有一点要提醒:如果你同时配置了 JPA 的ddl-auto: update,Hibernate 会在启动时通过路由数据源获取连接来检查实体映射关系,这个阶段的 ThreadLocal 是空的,会走默认的 MASTER 分支,连的是主库。这没问题,但如果你期望启动时连接的是从库来分摊压力,那就想多了,启动检查本来就该走主库。

5. 读写分离避坑指南:切换失效、事务穿透等典型问题

5.1 同一个方法里先读后写,路由被事务管理器锁死

现象:一个 Service 方法上加了@Transactional,内部先查询再更新,查询迟迟没有真正落到从库,反而是主库扛着全部的读压力。

原因:Spring 事务默认的传播行为是 REQUIRED。@Transactional一旦生效,事务管理器在方法执行到第一次数据库操作时就会从路由数据源获取一个连接,并把这个连接绑定到当前线程的DataSourceTransactionManager资源映射里。后续这个事务内的所有数据库操作,都用同一个连接。如果你的@ReadOnlyConnection注解和@Transactional同时标注在一个方法上,但路由拦截器的Order没有比事务管理器更小,那么事务管理器先拿到连接,此时 ThreadLocal 里还是默认 MASTER,这个连接就是主库连接,后面路由拦截器再去设置 SLAVE 已经晚了,因为连接已经定了。

解决:确保ReadOnlyConnectionInterceptor实现Ordered并返回一个小于事务管理器顺序的值。Spring 默认的事务拦截器顺序是Ordered.LOWEST_PRECEDENCE,自定义切面的 Order 为 0 已经足够。另一个防御手段是,读操作单独拆出去,不放进写事务里,让事务的粒度尽量小,从架构上消除「读写同事务」的场景。如果业务确实无法避免,那就让该方法整个走主库,这也是可以接受的,读写分离的本意是大流量分流,而不是强一致性保障。

5.2 线程池复用导致的「从库幽灵路由」

现象:接口 A 加了@ReadOnlyConnection,每次返回正确;接口 B 没有加任何注解,偶尔却报出从库才有的数据延迟问题,或者写入的数据在主库却查不到。

原因:线程池里的工作线程在处理完接口 A 的请求后,ThreadLocal 中残留的 SLAVE 标记没有被清除。下一个请求 B 复用了同一根线程,getDbType()读到残留的 SLAVE,于是接口 B 里的写操作连接到了从库。在 MySQL 主从复制存在延迟的窗口期内,写入从库的数据不会立即同步回主库,临床表现就是「数据丢了」。

解决:ReadOnlyConnectionInterceptor的 finally 块必须调用DbContextHolder.clearDbType(),并且这个清理动作要覆盖所有路径。我当时排查线上问题时,发现有人在拦截器代码里漏写了 finally,而是放在了 try 块的末尾,导致异常路径下路由标记一直残留。从那以后,我检查别人的 AOP 代码时第一眼就看 finally 块有没有remove()。另外,如果你的项目里有异步线程池执行数据库操作,注意 ThreadLocal 不会从主线程传递到子线程,要么显式传参、要么在子线程里再设置路由标记,别指望继承。

5.3 主从复制延迟让刚写入的数据读不到

现象:用户注册成功跳转到详情页,详情页查到的数据还是旧的。

原因:这是读写分离架构固有的语义问题,主从复制是异步的,从库的数据存在秒级延迟。应用层把写请求发到主库,读请求路由到从库,这中间如果复制没有完成,读到的就是旧数据。这个问题的本质不是代码 bug,而是业务场景能否容忍最终一致性。

解决:分场景处理。强一致性的操作(比如用户注册后立即展示资料),让它走主库,最简单的方式是不加@ReadOnlyConnection,或者在这个请求内手动DbContextHolder.setDbType(MASTER)。弱一致性的操作(比如列表页、报表页),走从库没压力。还有一种更细的做法是「写后读同一线程强制主库」,在主库写入后,把当前线程的路由标记设为主库,直到请求结束再清理,这样保证了一个请求生命周期内的数据一致性。MySQL 8.0 的wait_for_executed_gtid_set也是方案,但这需要 DBA 配合改连接参数,不是应用层能独立解决的问题。

5.4 从库挂了,整个应用跟着雪崩

现象:从库宕机后,所有标记了@ReadOnlyConnection的查询方法开始抛连接异常,然后错误不断重试,最终把主库也压垮。

原因:路由数据源里没有降级机制。determineCurrentLookupKey()返回 SLAVE,Spring 直接去从库拿连接,从库不可用时异常抛出,没有回退到主库的逻辑。

解决:如果业务能容忍从库挂掉时临时读主库,可以在路由数据源外层包一层重试逻辑。我给自己的项目的做法是:捕获从库连接异常后,把当前线程的路由标记切回 MASTER,再重新获取一次连接。这个逻辑放在路由数据源的determineCurrentLookupKey()里没法实现,得包装一下getConnection()方法。你也可以更朴素一点:用连接池的健康检查机制来剔除坏连接,Druid 的testOnBorrow打开,保证从池里拿出来的连接一定是可用的,但这样并不能解决路由本身「只认从库」的问题。我的建议是先明确需求,如果业务对读的可用性要求很高,降级是值得做的;如果只是「从库挂了我就少看一些报表」,那直接让错误抛出来反而更透明。

5.5 一主多从时路由数据源 Map 里的 key 冲突

现象:配置了多个从库,把从库 A 和从库 B 都注册进targetDataSources,运行时不定期出现「连错库」的现象。

原因:targetDataSources是按 key 精确匹配的,多个从库如果共用同一个DbType.SLAVEkey,后面的配置会把前面的覆盖掉,最后只有一个从库生效。这不是路由逻辑的错误,而是配置的覆盖语义。

解决:把 key 细分,比如SLAVE_A、SLAVE_B,或者在determineCurrentLookupKey()返回SLAVE时内部再做一次负载均衡选择(下一章会给代码)。我一般会把「从库选择」抽出来独立维护,方便后续做权重调整。

6. 从能用到好用:一主多从扩展与路由验证方法

6.1 一主多从的路由数据源增强

当读流量上来以后,一个从库扛不住,需要多个从库分摊。此时把determineCurrentLookupKey()的重写逻辑从「返回固定 SLAVE」升级为「返回奴隶列表中的一个」,就成了负载均衡器。我常用的做法是维护一个AtomicInteger做轮询,或者用ThreadLocalRandom做随机。推荐一个简单可靠的轮询实现:

package com.example.datasource; import javax.sql.DataSource; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; /** * 支持一主多从的路由数据源 * 主库固定走 MASTER,从库按轮询策略选择 */ public class LoadBalanceRoutingDataSource extends ReadWriteSplitRoutingDataSource { private List<Object> slaveKeys; private AtomicInteger slaveCounter = new AtomicInteger(0); public void setSlaveKeys(List<Object> slaveKeys) { this.slaveKeys = slaveKeys; } @Override protected Object determineCurrentLookupKey() { if (DbContextHolder.getDbType() == DbContextHolder.DbType.SLAVE) { // 轮询选择一个从库 key int index = Math.abs(slaveCounter.getAndIncrement() % slaveKeys.size()); return slaveKeys.get(index); } return DbContextHolder.DbType.MASTER; } }

这个类的价值在于把「从库列表的维护」和「选择策略」集中在一个地方。后续如果要加权重,只需要把List<Object>换成带权重的数据结构,业务代码不用改。配置时,targetDataSources的 key 改为slave-1、slave-2之类的标识即可,setSlaveKeys里把这些 key 传进来。我的经验是:从库数量在 3 个以下时轮询足够,超过 5 个就得考虑按业务维度分片,比如不同的大查询走不同的从库,避免某个慢查询把整个从库拖垮。

6.2 验证路由是否真的生效:三种实用方法

第一种方法,看 Druid 监控面板。打开http://localhost:8080/druid/sql.html,SQL 列表里能看到每个 SQL 执行的数据源列,主库来的 SQL 和从库来的 SQL 会标在不同的数据源上。如果 SQL 列表里全部显示主库,说明你的@ReadOnlyConnection没有匹配上切点,去查注解的包名和拦截器的切点表达式是否一致。

第二种方法,临时在 Service 方法里打印当前数据源连接的信息。我的做法是在拦截器里用DataSourceUtils.getConnection(DataSource)拿一次连接,打印它的toString(),Druid 的连接对象里包含实例的 URL,能直接看出来连的是哪个库。打印完记得释放,别把连接占着。

第三种方法,业务层面的时间戳对比。在主库插入一条带时间戳的记录,然后在标记了@ReadOnlyConnection的查询接口里查该记录,如果查询结果的时间戳不等于主库刚写入的值,而等于几秒前的值,说明主从复制有延迟,同时也能证明查询确实走了从库。这个方法最适合做交付前的验证脚本。

最后分享一个我的个人习惯:每次新增一个标注了@ReadOnlyConnection的 Service 方法,我都会先看一眼这个方法里有没有嵌套调用另一个没有加注解的写方法。如果有,这个嵌套调用极为致命——外层方法把路由切到从库,内层写操作会跟着走从库,造成主从数据不一致。从那以后,我每次 code review 都强制自己过一遍「注解覆盖范围内的所有调用链」,确认没有写操作混入才会放行,希望这个教训也能帮到你。

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

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

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

立即咨询