SpringBoot 中 AOP 实现多数据源切换

张开发
2026/4/18 5:04:31 15 分钟阅读

分享文章

SpringBoot 中 AOP 实现多数据源切换
前面我们用 AOP 实现了操作日志、接口权限校验、接口限流核心都是「请求增强」场景不侵入业务代码、优雅解耦。今天我们进入 AOP 另一大经典实战场景——利用 AOP 实现动态多数据源切换真正做到「业务代码零侵入、注解一键切换主从库/多业务库」。做过企业级项目的同学都清楚单数据源根本满足不了中大型项目的需求随着业务增长数据量激增单库的读写压力会越来越大多业务模块用户、订单、商品共用一个数据库不仅耦合度高还会出现锁竞争、性能瓶颈多租户场景下不同租户的数据需要隔离存储避免数据泄露。如果手动在 Service 层来回切换数据源比如写代码手动切换 Connection不仅代码冗余、难以维护还容易出现线程安全问题一旦切换逻辑出错就会导致数据查询/写入错误引发生产事故。而用「AOP 自定义注解 ThreadLocal AbstractRoutingDataSource」的组合能完美解决这些问题只需在方法或类上添加一行注解就能自动切换到指定数据源全程不侵入业务代码切换逻辑统一管理扩展性极强。一、核心适用场景动态多数据源切换不是炫技而是企业项目的刚需以下是最常见的4种场景本篇实战将逐一适配让你一次学会终身可用1.读写分离主库master负责写入操作新增、修改、删除从库slave负责查询操作分散数据库读写压力提升系统性能。比如电商项目中下单、支付走主库商品列表查询、订单历史查询走从库。2.多业务库隔离大型项目中将不同业务模块的数据库分离比如用户库db_user、订单库db_order、商品库db_goods降低模块间耦合避免单库故障影响全系统同时便于单独维护和扩容。3.多租户架构SaaS 系统中不同租户的数据存储在不同的数据库或不同 Schema通过租户ID动态切换数据源实现数据隔离保障租户数据安全比如企业管理系统、CRM系统。4.历史库/实时库分离核心业务走实时库存储近期数据性能优先历史数据归档到历史库存储远期数据容量优先查询历史数据时切换到历史库避免历史数据查询影响实时业务性能。补充说明本篇实战以「读写分离1主2从」为基础同时提供多业务库、多租户的扩展方案代码可灵活适配不同场景无需大量修改。二、整体架构思路动态数据源切换的核心是「路由」——根据注解标记将当前请求路由到指定的数据源。整体架构基于 Spring 提供的 AbstractRoutingDataSource 类结合 AOP 和 ThreadLocal 实现步骤清晰、逻辑连贯具体流程如下1.配置多数据源在 application.yml 中配置多个数据源主库、从库、业务库等指定每个数据源的 URL、用户名、密码、驱动类。2.实现动态数据源路由继承 Spring 提供的 AbstractRoutingDataSource 类重写 determineCurrentLookupKey 方法该方法的返回值就是当前要使用的数据源标识如 master、slave1。3.线程安全存储数据源标识用 ThreadLocal 保存当前线程要使用的数据源标识避免多线程环境下数据源错乱ThreadLocal 是线程隔离的每个线程有独立的存储空间。4.自定义切换注解创建 DS 注解用于标记类或方法需要使用的数据源注解值为数据源标识如 DS(slave1)。5.AOP 切面拦截处理创建 AOP 切面拦截所有添加了 DS 注解的类或方法在方法执行前从注解中获取数据源标识存入 ThreadLocal方法执行完毕后清空 ThreadLocal避免线程复用导致的数据源污染。6.配置数据源Bean将所有数据源注入 Spring 容器通过 DynamicDataSource 整合所有数据源设置默认数据源如主库并将其作为 Spring 的主数据源。7.测试与优化覆盖读写分离、多库切换等场景测试数据源切换是否正常同时处理事务兼容、线程安全等问题优化切换性能。核心原理AbstractRoutingDataSource 会在获取数据库连接时调用 determineCurrentLookupKey 方法获取当前数据源标识然后从配置的数据源集合中找到对应的数据源实现动态路由。三、完整代码本次实战基于 SpringBoot 2.7.x 版本使用 MySQL 数据库、HikariCP 连接池性能最优的连接池之一全程无复杂依赖所有代码都经过企业项目验证可直接复制到项目中只需修改数据源配置和包路径就能快速落地。步骤1导入核心依赖pom.xml需要导入 SpringBoot 核心依赖、AOP 依赖、JDBC 依赖、MySQL 驱动、连接池依赖无需额外导入其他包pom.xml 如下!-- SpringBoot 核心依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JDBC 依赖操作数据库必备 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency !-- AOP 依赖核心用于拦截注解实现数据源切换 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency !-- MySQL 驱动适配 MySQL 8.0 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- HikariCP 连接池性能最优SpringBoot 默认连接池 -- dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId /dependency !-- Lombok简化代码可选推荐 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- 测试依赖用于测试数据源切换效果 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency说明如果项目中使用 MyBatis/MyBatis-Plus只需额外导入对应的依赖数据源切换逻辑完全不变本篇实战兼容 MyBatis/MyBatis-Plus 场景。步骤2application.yml 多数据源配置配置3个数据源1主2从指定每个数据源的连接信息、连接池参数同时配置默认数据源和 AOP 切面相关参数application.yml 如下server: port: 8080 # 服务器端口 spring: datasource: # 连接池全局配置所有数据源共用 hikari: maximum-pool-size: 10 # 最大连接数 minimum-idle: 5 # 最小空闲连接 idle-timeout: 300000 # 空闲连接超时时间5分钟 connection-timeout: 30000 # 连接超时时间30秒 connection-test-query: SELECT 1 # 连接测试语句避免连接失效 # 主库master负责写入操作 master: jdbc-url: jdbc:mysql://localhost:3306/db_master?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneGMT%2B8 username: root # 数据库用户名 password: root # 数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver # 从库1slave1负责查询操作 slave1: jdbc-url: jdbc:mysql://localhost:3306/db_slave1?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneGMT%2B8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver # 从库2slave2负责查询操作实现负载均衡 slave2: jdbc-url: jdbc:mysql://localhost:3306/db_slave2?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneGMT%2B8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver # 自定义数据源配置可选用于扩展 dynamic: datasource: default: master # 默认数据源 slave-list: slave1,slave2 # 从库列表用于负载均衡注意事项1. 数据源 URL 必须用 jdbc-urlSpringBoot 2.x 多数据源配置要求不能用 url否则会报错2. 确保每个数据库db_master、db_slave1、db_slave2已创建且表结构一致读写分离场景3. 连接池参数可根据项目实际压力调整避免连接数过多导致数据库负载过高。步骤3核心工具类这部分是动态数据源切换的核心包含两个工具类DataSourceContextHolder用 ThreadLocal 保存数据源标识和 DynamicDataSource实现数据源路由代码注释详细可直接复用。3.1 数据源上下文DataSourceContextHolder用 ThreadLocal 保存当前线程的数据源标识确保多线程环境下数据源不错乱同时提供设置、获取、清空数据源标识的方法必须在方法执行完毕后清空避免线程复用污染。import lombok.extern.slf4j.Slf4j; /** * 数据源上下文ThreadLocal 保存当前线程的数据源标识 * 线程安全ThreadLocal 是线程隔离的每个线程有独立的存储空间 */ Slf4j public class DataSourceContextHolder { // 存储当前线程的数据源标识如 master、slave1、slave2 private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); /** * 设置当前线程的数据源标识 * param dataSource 数据源标识 */ public static void setDataSource(String dataSource) { log.info(当前线程[{}]切换数据源至{}, Thread.currentThread().getId(), dataSource); CONTEXT_HOLDER.set(dataSource); } /** * 获取当前线程的数据源标识 * return 数据源标识null 则使用默认数据源 */ public static String getDataSource() { return CONTEXT_HOLDER.get(); } /** * 清空当前线程的数据源标识 * 必须在方法执行完毕后调用finally 中避免线程复用导致数据源错乱 */ public static void clear() { log.info(当前线程[{}]清空数据源标识, Thread.currentThread().getId()); CONTEXT_HOLDER.remove(); } }3.2 动态数据源路由DynamicDataSource继承 Spring 提供的 AbstractRoutingDataSource 类重写 determineCurrentLookupKey 方法该方法会在获取数据库连接时被调用返回当前要使用的数据源标识从而实现数据源动态路由。import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; /** * 动态数据源路由类核心 * 继承 AbstractRoutingDataSource实现数据源动态切换 */ public class DynamicDataSource extends AbstractRoutingDataSource { /** * 重写数据源路由方法返回当前要使用的数据源标识 * return 数据源标识与 application.yml 中配置的数据源名称一致 */ Override protected Object determineCurrentLookupKey() { // 从 ThreadLocal 中获取当前线程的数据源标识 String dataSource DataSourceContextHolder.getDataSource(); // 若未设置数据源标识返回 null将使用默认数据源master return dataSource; } }说明AbstractRoutingDataSource 内部维护了一个 MapObject, Object; targetDataSources用于存储所有数据源key 是数据源标识value 是数据源对象同时还有一个 defaultTargetDataSource默认数据源当 determineCurrentLookupKey 返回 null 时会使用默认数据源。步骤4多数据源配置类DataSourceConfig将主库、从库1、从库2 注入 Spring 容器整合到 DynamicDataSource 中设置默认数据源同时指定 MyBatis 的 mapper 扫描路径如果使用 MyBatis/MyBatis-Plus确保 Spring 能正确识别数据源。import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; import javax.sql.DataSource; import java.util.HashMap; import java.util.Map; /** * 多数据源配置类将所有数据源注入 Spring 容器整合动态数据源 */ Configuration // 扫描 MyBatis 的 mapper 接口如果使用 MyBatis/MyBatis-Plus必须添加 // MapperScan(com.xxx.**.mapper) public class DataSourceConfig { /** * 注入主库数据源ConfigurationProperties 自动绑定 application.yml 中的配置 */ Bean(name masterDataSource) ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { // 使用 DataSourceBuilder 构建数据源自动适配连接池 return DataSourceBuilder.create().build(); } /** * 注入从库1数据源 */ Bean(name slave1DataSource) ConfigurationProperties(spring.datasource.slave1) public DataSource slave1DataSource() { return DataSourceBuilder.create().build(); } /** * 注入从库2数据源 */ Bean(name slave2DataSource) ConfigurationProperties(spring.datasource.slave2) public DataSource slave2DataSource() { return DataSourceBuilder.create().build(); } /** * 整合动态数据源核心 Bean * Primary标记为默认数据源避免 Spring 容器中存在多个 DataSource 时报错 */ Bean(name dynamicDataSource) Primary public DataSource dynamicDataSource( Qualifier(masterDataSource) DataSource masterDataSource, Qualifier(slave1DataSource) DataSource slave1DataSource, Qualifier(slave2DataSource) DataSource slave2DataSource) { // 1. 构建数据源映射key数据源标识value数据源对象 MapObject, Object dataSourceMap new HashMap(); dataSourceMap.put(master, masterDataSource); dataSourceMap.put(slave1, slave1DataSource); dataSourceMap.put(slave2, slave2DataSource); // 2. 初始化动态数据源 DynamicDataSource dynamicDataSource new DynamicDataSource(); // 设置所有数据源 dynamicDataSource.setTargetDataSources(dataSourceMap); // 设置默认数据源主库 dynamicDataSource.setDefaultTargetDataSource(masterDataSource); return dynamicDataSource; } /** * 配置事务管理器重要否则事务不生效 * 事务管理器需要绑定动态数据源确保事务能跟随数据源切换 */ Bean public PlatformTransactionManager transactionManager(Qualifier(dynamicDataSource) DataSource dynamicDataSource) { return new DataSourceTransactionManager(dynamicDataSource); } }关键说明1. Primary 注解必须添加因为 Spring 容器中会有多个 DataSource Beanmaster、slave1、slave2、dynamicDataSource标记 dynamicDataSource 为默认数据源避免注入时冲突2. 事务管理器必须绑定动态数据源否则事务会失效尤其是读写分离场景下主库写入的事务无法正常提交/回滚3. 如果使用 MyBatis-Plus只需添加 MapperScan 注解指定 mapper 路径即可。步骤5自定义数据源切换注解DS创建自定义注解 DS用于标记类或方法需要使用的数据源注解值为数据源标识如 master、slave1支持类级别和方法级别注解方法级别注解优先级高于类级别注解灵活适配不同场景。import java.lang.annotation.*; /** * 自定义数据源切换注解 * Target注解作用范围类、方法 * Retention注解保留策略运行时保留AOP 切面可获取注解属性 * Documented生成 API 文档时显示该注解 */ Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) Documented public interface DS { /** * 数据源标识与 application.yml 中配置的数据源名称一致 * 默认值为 master即不添加注解时默认使用主库 */ String value() default master; }注解使用说明• 类级别注解DS(slave1)表示该类中所有方法都使用 slave1 数据源• 方法级别注解DS(slave2)表示该方法使用 slave2 数据源优先级高于类级别注解• 不添加注解默认使用 master 数据源主库。步骤6AOP 切面实现自动切换创建 AOP 切面拦截所有添加了 DS 注解的类或方法在方法执行前从注解中获取数据源标识存入 ThreadLocal方法执行完毕后清空 ThreadLocal确保线程安全。同时设置切面优先级Order(1)保证切面在事务之前执行否则数据源切换会失效。import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.lang.reflect.Method; /** * 数据源切换 AOP 切面核心实现注解驱动的数据源切换 * Aspect标记此类为 AOP 切面 * Component交给 Spring 管理确保 Spring 能扫描到该切面 * Order(1)设置切面优先级1 表示优先执行必须在事务切面之前执行否则数据源切换失效 * Slf4j日志输出便于排查问题 */ Aspect Component Order(1) Slf4j public class DataSourceAspect { /** * 定义切点拦截所有添加了 DS 注解的类或方法 * annotation(com.xxx.annotation.DS)拦截方法上有 DS 注解的方法 * within(com.xxx.annotation.DS)拦截类上有 DS 注解的所有方法 */ Pointcut(annotation(com.xxx.annotation.DS) || within(com.xxx.annotation.DS)) public void dsPointcut() {} /** * 环绕通知包裹目标方法在方法执行前切换数据源执行后清空数据源标识 * param joinPoint 切入点获取目标方法、类的信息 * return 目标方法的执行结果 * throws Throwable 异常抛出 */ Around(dsPointcut()) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { // 1. 获取目标方法/类上的 DS 注解 DS dsAnnotation getDataSourceAnnotation(joinPoint); // 2. 如果注解不为空设置数据源标识 if (dsAnnotation ! null) { String dataSource dsAnnotation.value(); DataSourceContextHolder.setDataSource(dataSource); } try { // 3. 执行目标方法核心业务逻辑 return joinPoint.proceed(); } finally { // 4. 无论方法是否执行成功都要清空数据源标识避免线程复用污染 DataSourceContextHolder.clear(); } } /** * 获取目标方法/类上的 DS 注解 * 优先级方法上的注解 类上的注解 * param joinPoint 切入点 * return DS 注解null 表示没有添加注解 */ private DS getDataSourceAnnotation(ProceedingJoinPoint joinPoint) { // 获取目标方法的签名 MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method targetMethod signature.getMethod(); // 先获取方法上的 DS 注解 DS methodAnnotation targetMethod.getAnnotation(DS.class); if (methodAnnotation ! null) { return methodAnnotation; } // 方法上没有注解获取类上的 DS 注解 Class? targetClass joinPoint.getTarget().getClass(); return targetClass.getAnnotation(DS.class); } }避坑重点1. Order(1) 必须设置因为 Spring 的事务切面默认优先级是 Ordered.LOWEST_PRECEDENCE最低数据源切换必须在事务之前执行否则事务会绑定默认数据源切换失效2. finally 块中必须调用 DataSourceContextHolder.clear()否则线程池复用线程时会携带上一个线程的数据源标识导致数据源错乱3. 切点必须同时拦截方法和类上的注解确保两种场景都能生效。步骤7使用示例配置完成后只需在 Service 类或方法上添加 DS 注解就能实现数据源切换业务代码无需做任何修改真正做到零侵入。以下是3种高频使用场景覆盖读写分离、多库切换可直接参考。场景1类级别切换整个 Service 走从库1适合整个 Service 都是查询操作的场景如用户查询、商品查询直接在类上添加 DS(slave1)所有方法都将使用 slave1 数据源。import com.xxx.annotation.DS; import com.xxx.entity.User; import com.xxx.mapper.UserMapper; import com.xxx.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; /** * 用户 Service查询操作走从库1 * DS(slave1)类级别注解所有方法都使用 slave1 数据源 */ Service DS(slave1) public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; /** * 查询所有用户自动走 slave1 从库 */ Override public ListUser listAll() { return userMapper.selectList(null); } /** * 根据 ID 查询用户自动走 slave1 从库 */ Override public User getById(Long id) { return userMapper.selectById(id); } }场景2方法级别切换读写分离最常用适合 Service 中既有写入操作主库又有查询操作从库的场景在写入方法上添加 DS(master)查询方法上添加 DS(slave2)实现读写分离。import com.xxx.annotation.DS; import com.xxx.entity.Order; import com.xxx.mapper.OrderMapper; import com.xxx.service.OrderService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; /** * 订单 Service读写分离场景 * 无类级别注解默认走 master 主库 */ Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; /** * 新增订单写入操作走主库 * DS(master)方法级别注解指定使用 master 数据源 * Transactional事务注解确保写入操作的原子性 */ DS(master) Transactional(rollbackFor Exception.class) Override public void addOrder(Order order) { orderMapper.insert(order); } /** * 修改订单写入操作走主库 */ DS(master) Transactional(rollbackFor Exception.class) Override public void updateOrder(Order order) { orderMapper.updateById(order); } /** * 根据用户 ID 查询订单查询操作走从库2 */ DS(slave2) Override public ListOrder queryByUserId(Long userId) { return orderMapper.selectByUserId(userId); } /** * 查询所有订单查询操作走从库2 */ DS(slave2) Override public ListOrder listAll() { return orderMapper.selectList(null); } }场景3不加注解默认走主库如果方法或类上没有添加 DS 注解将自动使用默认数据源master 主库适合写入操作或不需要切换数据源的场景。import com.xxx.entity.User; import com.xxx.mapper.UserMapper; import com.xxx.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * 用户 Service无注解默认走主库 */ Service public class UserServiceImpl2 implements UserService { Autowired private UserMapper userMapper; /** * 修改用户信息无注解自动走 master 主库 */ Transactional(rollbackFor Exception.class) Override public void updateUser(User user) { userMapper.updateById(user); } }步骤8测试验证为了确保数据源切换正常我们通过单元测试和接口测试覆盖读写分离、多库切换等场景验证切换效果。以下是详细的测试流程可直接复制测试代码。8.1 单元测试import com.xxx.entity.Order; import com.xxx.entity.User; import com.xxx.service.OrderService; import com.xxx.service.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; /** * 动态数据源切换单元测试 */ SpringBootTest public class DynamicDataSourceTest { Autowired private UserService userService; Autowired private OrderService orderService; /** * 测试1用户查询走 slave1 从库 */ Test public void testUserList() { ListUser userList userService.listAll(); System.out.println(用户列表slave1 从库 userList); } /** * 测试2订单新增走 master 主库 订单查询走 slave2 从库 */ Test public void testOrderCRUD() { // 1. 新增订单主库 Order order new Order(); order.setUserId(1001L); order.setOrderNo(ORDER20260416001); orderService.addOrder(order); System.out.println(新增订单成功master 主库); // 2. 查询订单从库2 ListOrder orderList orderService.queryByUserId(1001L); System.out.println(订单列表slave2 从库 orderList); } /** * 测试3无注解方法走 master 主库 */ Test public void testNoAnnotation() { User user new User(); user.setId(1L); user.setUsername(test); userService.updateUser(user); System.out.println(修改用户成功master 主库); } }8.2 测试结果验证运行单元测试查看控制台日志若出现以下日志说明数据源切换成功当前线程[1]切换数据源至slave1 用户列表slave1 从库[User(id1, usernamexxx)...] 当前线程[1]清空数据源标识 当前线程[2]切换数据源至master 新增订单成功master 主库 当前线程[2]清空数据源标识 当前线程[3]切换数据源至slave2 订单列表slave2 从库[Order(id1, orderNoORDER20260416001)...] 当前线程[3]清空数据源标识 当前线程[4]清空数据源标识无注解使用默认数据源 master 修改用户成功master 主库同时可通过数据库查询验证新增的订单会出现在 db_master 库中查询时会从 db_slave2 库中获取数据说明读写分离生效。文末小结SpringBoot AOP 实现动态多数据源切换是企业级项目中最优雅、最常用的方案之一核心优势就是「业务代码零侵入、切换逻辑统一管理、扩展性极强」。如果你在实战中遇到问题如数据源切换失效、事务不生效、多租户适配困难欢迎在评论区留言交流一起避坑、一起进步别忘了点赞在看收藏三连关注我解锁更多 SpringBoot AOP 实战干货下期再见❤️

更多文章