恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot读写分离:基于AbstractRoutingDataSource与AOP的动态数据源主从切换
首页
资讯中心
/
Spring Boot读写分离:基于AbstractRoutingDataSource与AOP的动态数据源主从切换
Spring Boot读写分离:基于AbstractRoutingDataSource与AOP的动态数据源主从切换
发布时间:2026/10/9 11:23:40
简介这是面向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内部维护一个MapObject, 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 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 传进来。如果不去拦截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 ListUser 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是个 Mapkey 的类型是Object但你必须保证 key 的值和determineCurrentLookupKey()的返回值完全一致。上面用了枚举作为 key那getDbType()返回的必须也是同一个枚举实例值相同但类型不同的字符串是匹配不上的。第二dataSource(ReadWriteSplitRoutingDataSource)这里会把 Bean 名字直接注册成dataSourceSpring 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() { MapObject, 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.urljdbc:mysql://192.168.1.10:3306/app_db datasource.master.usernameroot datasource.master.passwordmaster_pwd datasource.slave.urljdbc:mysql://192.168.1.11:3306/app_db datasource.slave.usernameroot datasource.slave.passwordslave_pwd4.4 路由键匹配的逻辑说明在 Java Config 里setTargetDataSources传入的 Map 是MapObject, ObjectSpring 内部会做类型转换。路由键是枚举DbContextHolder.DbType路由数据源内部用resolveSpecifiedDataSource把 value 转成真实 DataSource。当执行 SQL 时AbstractRoutingDataSource通过determineCurrentLookupKey()拿到枚举值直接在 Map 里查目标数据源。这个过程是同步的、无锁的每次获取连接都会重新走一遍所以路由切换的粒度可以做到「每次连接」。有一点要提醒如果你同时配置了 JPA 的ddl-auto: updateHibernate 会在启动时通过路由数据源获取连接来检查实体映射关系这个阶段的 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()返回 SLAVESpring 直接去从库拿连接从库不可用时异常抛出没有回退到主库的逻辑。解决如果业务能容忍从库挂掉时临时读主库可以在路由数据源外层包一层重试逻辑。我给自己的项目的做法是捕获从库连接异常后把当前线程的路由标记切回 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 ListObject slaveKeys; private AtomicInteger slaveCounter new AtomicInteger(0); public void setSlaveKeys(ListObject 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; } }这个类的价值在于把「从库列表的维护」和「选择策略」集中在一个地方。后续如果要加权重只需要把ListObject换成带权重的数据结构业务代码不用改。配置时targetDataSources的 key 改为slave-1、slave-2之类的标识即可setSlaveKeys里把这些 key 传进来。我的经验是从库数量在 3 个以下时轮询足够超过 5 个就得考虑按业务维度分片比如不同的大查询走不同的从库避免某个慢查询把整个从库拖垮。6.2 验证路由是否真的生效三种实用方法第一种方法看 Druid 监控面板。打开http://localhost:8080/druid/sql.htmlSQL 列表里能看到每个 SQL 执行的数据源列主库来的 SQL 和从库来的 SQL 会标在不同的数据源上。如果 SQL 列表里全部显示主库说明你的ReadOnlyConnection没有匹配上切点去查注解的包名和拦截器的切点表达式是否一致。第二种方法临时在 Service 方法里打印当前数据源连接的信息。我的做法是在拦截器里用DataSourceUtils.getConnection(DataSource)拿一次连接打印它的toString()Druid 的连接对象里包含实例的 URL能直接看出来连的是哪个库。打印完记得释放别把连接占着。第三种方法业务层面的时间戳对比。在主库插入一条带时间戳的记录然后在标记了ReadOnlyConnection的查询接口里查该记录如果查询结果的时间戳不等于主库刚写入的值而等于几秒前的值说明主从复制有延迟同时也能证明查询确实走了从库。这个方法最适合做交付前的验证脚本。最后分享一个我的个人习惯每次新增一个标注了ReadOnlyConnection的 Service 方法我都会先看一眼这个方法里有没有嵌套调用另一个没有加注解的写方法。如果有这个嵌套调用极为致命——外层方法把路由切到从库内层写操作会跟着走从库造成主从数据不一致。从那以后我每次 code review 都强制自己过一遍「注解覆盖范围内的所有调用链」确认没有写操作混入才会放行希望这个教训也能帮到你。本文还有配套的精品资源点击获取