JAVA Spring Boot 多数据源切换失败?@Transactional 与 AOP 冲突排查

Spring Boot 多数据源切换失败?@Transactional 与 AOP 冲突排查

大家好,今天我们来深入探讨一个在 Spring Boot 多数据源配置中比较常见,也比较棘手的问题:多数据源切换失败,并且往往与 @Transactional 注解以及 AOP(面向切面编程)产生冲突。我们将从原理出发,结合实际代码示例,一步步分析问题、排查问题,最终提供切实可行的解决方案。

一、多数据源配置基础:理论与实践

在单体应用中,我们通常只需要一个数据库。但随着业务发展,可能需要将不同类型的数据(例如,核心业务数据和日志数据)存储在不同的数据库中,或者为了提高性能,对数据进行分库分表。这时,就需要配置多个数据源。

Spring Boot 提供了便捷的多数据源配置方式。通常,我们需要定义多个 DataSource bean,并使用 @Primary 注解指定一个默认数据源。

下面是一个简单的多数据源配置示例:

@Configuration
public class DataSourceConfig {

    @Bean(name = "dataSource1")
    @ConfigurationProperties(prefix = "spring.datasource.db1")
    public DataSource dataSource1() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "dataSource2")
    @ConfigurationProperties(prefix = "spring.datasource.db2")
    public DataSource dataSource2() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "jdbcTemplate1")
    public JdbcTemplate jdbcTemplate1(@Qualifier("dataSource1") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    @Bean(name = "jdbcTemplate2")
    public JdbcTemplate jdbcTemplate2(@Qualifier("dataSource2") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    @Primary
    @Bean(name = "transactionManager1")
    public PlatformTransactionManager transactionManager1(@Qualifier("dataSource1") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean(name = "transactionManager2")
    public PlatformTransactionManager transactionManager2(@Qualifier("dataSource2") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

在这个例子中,我们配置了两个数据源:dataSource1dataSource2。 分别对应 spring.datasource.db1spring.datasource.db2 前缀的配置。 同时,我们也配置了两个 JdbcTemplate 和两个 PlatformTransactionManager,并使用 @Qualifier 注解来指定它们对应的数据源。 @Primary 注解指定了 transactionManager1 为默认事务管理器。

对应的 application.propertiesapplication.yml 文件需要包含如下配置:

spring.datasource.db1.url=jdbc:mysql://localhost:3306/db1
spring.datasource.db1.username=root
spring.datasource.db1.password=password
spring.datasource.db1.driver-class-name=com.mysql.cj.jdbc.Driver

spring.datasource.db2.url=jdbc:mysql://localhost:3306/db2
spring.datasource.db2.username=root
spring.datasource.db2.password=password
spring.datasource.db2.driver-class-name=com.mysql.cj.jdbc.Driver

二、数据源切换:利用 AOP 实现动态路由

仅仅配置多个数据源是不够的,我们需要一种机制来动态地切换数据源,以便在不同的业务场景下使用不同的数据库。常见的做法是使用 AOP 实现动态数据源路由。

首先,我们需要定义一个注解,用于标记需要切换数据源的方法:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TargetDataSource {
    String value() default "dataSource1"; // 默认数据源
}

然后,我们需要定义一个数据源上下文,用于存储当前线程使用的数据源:

public class DataSourceContextHolder {

    private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

    public static void setDataSource(String dbType) {
        contextHolder.set(dbType);
    }

    public static String getDataSource() {
        return contextHolder.get();
    }

    public static void clearDataSource() {
        contextHolder.remove();
    }
}

接下来,我们需要定义一个 AOP 切面,用于在方法执行前切换数据源:

@Aspect
@Component
@Order(1) // 确保这个切面在事务切面之前执行
public class DataSourceAspect {

    @Before("@annotation(targetDataSource)")
    public void switchDataSource(JoinPoint point, TargetDataSource targetDataSource) {
        if (targetDataSource != null) {
            DataSourceContextHolder.setDataSource(targetDataSource.value());
        } else {
            DataSourceContextHolder.setDataSource("dataSource1"); // 默认数据源
        }
    }

    @After("@annotation(targetDataSource)")
    public void restoreDataSource(JoinPoint point, TargetDataSource targetDataSource) {
        DataSourceContextHolder.clearDataSource();
    }

    @AfterThrowing(pointcut = "@annotation(targetDataSource)", throwing = "e")
    public void restoreDataSourceAfterException(JoinPoint point, TargetDataSource targetDataSource, Throwable e) {
        DataSourceContextHolder.clearDataSource();
    }
}

在这个切面中,我们使用 @Before 注解在方法执行前切换数据源,使用 @After@AfterThrowing 注解在方法执行后或抛出异常后清除数据源上下文。 @Order(1) 非常重要,它确保这个切面在事务切面之前执行。

最后,我们需要定义一个动态数据源,用于根据数据源上下文选择实际的数据源:

@Component
public class DynamicDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.getDataSource();
    }

    @Override
    public void afterPropertiesSet() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("dataSource1", applicationContext.getBean("dataSource1"));
        targetDataSources.put("dataSource2", applicationContext.getBean("dataSource2"));
        super.setTargetDataSources(targetDataSources);
        super.setDefaultTargetDataSource(applicationContext.getBean("dataSource1"));  // 默认数据源
        super.afterPropertiesSet();
    }

    @Autowired
    private ApplicationContext applicationContext;
}

在这个动态数据源中,我们重写了 determineCurrentLookupKey 方法,用于根据数据源上下文选择实际的数据源。 afterPropertiesSet 方法用于初始化 targetDataSourcesdefaultTargetDataSource

三、问题重现:@Transactional 与 AOP 的冲突

现在,我们已经配置好了多数据源和动态数据源路由。接下来,我们来模拟一个场景,看看 @Transactional 注解与 AOP 冲突的情况。

假设我们有一个 UserService,其中包含两个方法:createUserupdateUsercreateUser 方法使用 dataSource1updateUser 方法使用 dataSource2

@Service
public class UserService {

    @Autowired
    @Qualifier("jdbcTemplate1")
    private JdbcTemplate jdbcTemplate1;

    @Autowired
    @Qualifier("jdbcTemplate2")
    private JdbcTemplate jdbcTemplate2;

    @TargetDataSource("dataSource1")
    @Transactional(transactionManager = "transactionManager1")
    public void createUser(String username) {
        String sql = "INSERT INTO users (username) VALUES (?)";
        jdbcTemplate1.update(sql, username);
        System.out.println("Created user in dataSource1: " + username);
    }

    @TargetDataSource("dataSource2")
    @Transactional(transactionManager = "transactionManager2")
    public void updateUser(Long id, String username) {
        String sql = "UPDATE users SET username = ? WHERE id = ?";
        jdbcTemplate2.update(sql, username, id);
        System.out.println("Updated user in dataSource2: " + username);
    }
}

现在,我们编写一个测试用例,调用这两个方法:

@SpringBootTest
public class UserServiceTest {

    @Autowired
    private UserService userService;

    @Test
    public void testCreateAndUpdateUser() {
        userService.createUser("testUser");
        userService.updateUser(1L, "updatedUser");
    }
}

运行这个测试用例,你可能会发现以下问题:

  1. 数据源切换失败: 两个操作都使用了默认数据源 (dataSource1)。
  2. 事务失效: 如果其中一个操作失败,事务不会回滚。

四、原因分析:AOP 的执行顺序与事务代理

问题的根源在于 AOP 的执行顺序以及 Spring 的事务代理机制。

  • AOP 的执行顺序: 默认情况下,Spring AOP 的执行顺序是不确定的。这意味着,DataSourceAspect 可能在事务切面之后执行,导致数据源切换在事务启动之后。
  • 事务代理机制: Spring 的 @Transactional 注解是通过 AOP 实现的。Spring 会为带有 @Transactional 注解的方法创建一个事务代理。这个代理负责启动事务、提交事务或回滚事务。

DataSourceAspect 在事务切面之后执行时,事务已经启动,并且绑定了默认数据源。即使 DataSourceAspect 切换了数据源,事务仍然在原来的数据源上执行。

此外,如果在不同的数据源上开启了不同的事务,而这些事务嵌套在同一个方法中,Spring 事务管理器可能无法正确处理这些嵌套的事务,导致事务失效。

五、解决方案:确保数据源切换在事务启动之前

要解决这个问题,我们需要确保数据源切换在事务启动之前执行。这可以通过以下几种方式实现:

  1. 调整 AOP 的执行顺序: 使用 @Order 注解来指定 AOP 切面的执行顺序。确保 DataSourceAspect 的优先级高于事务切面。我们已经在前面的 DataSourceAspect 中使用了 @Order(1),这通常能解决问题。但是,如果还有其他的 AOP 切面,需要仔细调整它们的执行顺序。

  2. 使用 TransactionTemplate TransactionTemplate 允许你手动控制事务的边界。你可以将数据源切换的代码放在 TransactionTemplate 的回调函数中,确保数据源切换在事务启动之前执行。

    @Autowired
    private PlatformTransactionManager transactionManager1;
    
    @Autowired
    private PlatformTransactionManager transactionManager2;
    
    @TargetDataSource("dataSource1")
    public void createUserWithTransactionTemplate(String username) {
       TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager1);
       transactionTemplate.execute(status -> {
           String sql = "INSERT INTO users (username) VALUES (?)";
           jdbcTemplate1.update(sql, username);
           System.out.println("Created user in dataSource1: " + username);
           return null; // 返回 null 或其他合适的值
       });
    }
    
    @TargetDataSource("dataSource2")
    public void updateUserWithTransactionTemplate(Long id, String username) {
       TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager2);
       transactionTemplate.execute(status -> {
           String sql = "UPDATE users SET username = ? WHERE id = ?";
           jdbcTemplate2.update(sql, username, id);
           System.out.println("Updated user in dataSource2: " + username);
           return null; // 返回 null 或其他合适的值
       });
    }
  3. 自定义事务管理器: 可以自定义一个事务管理器,在事务启动之前切换数据源。这种方式比较复杂,但可以提供更细粒度的控制。

    public class DynamicDataSourceTransactionManager extends DataSourceTransactionManager {
    
       public DynamicDataSourceTransactionManager(DataSource dataSource) {
           super(dataSource);
       }
    
       @Override
       protected void doBegin(Object transaction, TransactionDefinition definition) {
           // 在事务启动之前切换数据源
           String dataSourceName = DataSourceContextHolder.getDataSource();
           if (dataSourceName != null) {
               // 这里可以根据 dataSourceName 获取对应的数据源,并设置到连接中
               // 例如,可以使用反射来设置 Connection 的 catalog 或 schema
           }
           super.doBegin(transaction, definition);
       }
    
       @Override
       protected void doCleanupAfterCompletion(Object transaction) {
           super.doCleanupAfterCompletion(transaction);
           DataSourceContextHolder.clearDataSource(); // 清除数据源上下文
       }
    }

    然后在 DataSourceConfig 中,将 DataSourceTransactionManager 替换为 DynamicDataSourceTransactionManager

  4. 避免嵌套事务: 尽量避免在同一个方法中开启多个不同数据源的事务。可以将这些操作拆分成多个方法,每个方法使用不同的数据源和事务管理器。

  5. 使用分布式事务: 如果需要在多个数据源上执行原子性操作,可以考虑使用分布式事务。例如,可以使用 Atomikos 或 Bitronix 这样的分布式事务管理器,或者使用 Seata 这样的 AT 模式的分布式事务解决方案。 但是,分布式事务会带来额外的性能开销和复杂性,需要谨慎评估。

六、调试技巧:日志与断点

在排查多数据源切换问题时,日志和断点是非常有用的工具。

  • 日志:DataSourceAspect 中添加日志,可以查看数据源切换的时机和切换后的数据源。

    @Before("@annotation(targetDataSource)")
    public void switchDataSource(JoinPoint point, TargetDataSource targetDataSource) {
       String dataSourceName = targetDataSource != null ? targetDataSource.value() : "dataSource1";
       DataSourceContextHolder.setDataSource(dataSourceName);
       logger.info("Switching to data source: {}", dataSourceName);
    }
    
    @After("@annotation(targetDataSource)")
    public void restoreDataSource(JoinPoint point, TargetDataSource targetDataSource) {
       logger.info("Restoring data source to default.");
       DataSourceContextHolder.clearDataSource();
    }
  • 断点:DataSourceAspectDynamicDataSource 和事务管理器的相关方法中设置断点,可以跟踪数据源切换的流程和事务的执行过程。

七、常见问题与注意事项

  • 数据源配置错误: 检查 application.propertiesapplication.yml 文件中的数据源配置是否正确。确保 URL、用户名、密码和驱动类名都是正确的。
  • 依赖冲突: 确保项目中没有冲突的依赖。特别要注意 Spring、MyBatis 和数据库驱动的版本。
  • 连接池配置: 合理配置连接池的大小和超时时间。过小的连接池可能导致连接不足,过大的连接池可能浪费资源。
  • 事务传播行为: 了解 Spring 事务的传播行为。不同的传播行为可能会影响事务的执行结果。
  • 数据库连接泄漏: 确保在使用完数据库连接后及时关闭连接。可以使用 try-with-resources 语句或手动关闭连接。
  • 编程式事务: 如果使用编程式事务,需要手动管理事务的生命周期。确保在事务启动之前切换数据源。

八、代码示例:完整的可运行示例

为了方便大家理解和实践,我提供一个完整的可运行示例。这个示例包含了多数据源配置、动态数据源路由、AOP 切面和事务管理。

(由于篇幅限制,这里只提供核心代码,完整的项目结构和依赖配置需要您自行创建。)

pom.xml (关键依赖)

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

DataSourceConfig.java (同上)

TargetDataSource.java (同上)

DataSourceContextHolder.java (同上)

DataSourceAspect.java (同上)

DynamicDataSource.java (同上,需要修改以适应Spring Boot 3+)

@Component
public class DynamicDataSource extends AbstractRoutingDataSource implements ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.getDataSource();
    }

    @Override
    public void afterPropertiesSet() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("dataSource1", applicationContext.getBean("dataSource1"));
        targetDataSources.put("dataSource2", applicationContext.getBean("dataSource2"));
        setTargetDataSources(targetDataSources);
        setDefaultTargetDataSource(applicationContext.getBean("dataSource1"));  // 默认数据源
        super.afterPropertiesSet();
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

UserService.java (同上)

UserServiceTest.java (同上)

application.properties (同上)

这个示例可以帮助你快速搭建一个多数据源环境,并验证前面提到的解决方案。

总结:灵活配置,谨慎调试,规避AOP陷阱

通过今天的讲解,我们了解了 Spring Boot 多数据源配置的基本原理和实现方式,以及 @Transactional 注解与 AOP 冲突的原因和解决方案。 关键在于理解 AOP 的执行顺序,并确保数据源切换在事务启动之前执行。 通过合理配置 AOP 的执行顺序、使用 TransactionTemplate 或自定义事务管理器,可以有效地解决这个问题。 调试时,要善用日志和断点,以便跟踪数据源切换的流程和事务的执行过程。希望这些知识能够帮助你在实际项目中更好地解决多数据源相关的问题。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注