多數據源下事務支持

最近工作中遇到一個問題,由於某個業務明細表數據量越來越大,因此對該表進行了分表處理,考慮到其他業務未進行分表,因此重新配置了一套分表的數據源配置,這樣項目中同時有兩套數據源,兩套事務管理器。項目開發完畢後同事在偶然的情況下發現部分數據不正常,經過問題追蹤,發現是在某個事務中,同時涉及兩個數據源,@Transactional啓動事務的時候使用了默認的事務管理器,導致新加的分表數據源不被事務管理,異常出現時無法進行回滾。
之前沒有在項目中使用多個數據源,一直沒發現這方面的問題,網上查找發現關於這個問題的文章挺多的。然而網上找的一些這方面問題的解決的文章,大多數說明的也只是單一事務的使用問題,並不是在同一事務中多個數據源的使用問題。畢竟也是,多數據源事務是一個分佈式事務,想通過本地事務很好的解決分佈式事務的確不太可能。

Seata下多數據源事務支持

正好最近在學習Seata框架,先嚐試下Seata對多數據源的支持。

搭建項目

1、在zhengcs-seata下新增module:zhengcs-seata-muli-datasource(過程參考《Seata學習筆記二:Spring Boot + Dubbo下AT模式的嘗試性使用》)
2、配置兩個數據源信息

spring:
  application:
    name: zhengcs-seata-multi-datasource
  datasource:
    order:
      type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/order
      username: test
      password: 123456
    account:
      type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/account
      username: test
      password: 123456

3、配置兩個數據源

@Configuration
@MapperScan(basePackages = "com.zhengcs.seata.multi.datasource.mapper.account",
sqlSessionTemplateRef = "accountSqlSessionTemplate")
public class DBAccountConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.account")
    public DataSource accountDataSource(){
        return new DruidDataSource();
    }


    @Bean
    public SqlSessionFactory accountSqlSessionFactory(@Qualifier("accountDataSource") DataSource dataSource) throws Exception{
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/account/*.xml"));
        //factoryBean.setConfigLocation(new ClassPathResource("mybatis-configuration.xml"));
        return factoryBean.getObject();
    }

    @Bean
    public SqlSessionTemplate accountSqlSessionTemplate(@Qualifier("accountSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

}
@Configuration
@MapperScan(basePackages = "com.zhengcs.seata.multi.datasource.mapper.order",
sqlSessionTemplateRef = "orderSqlSessionTemplate")
public class DBOrderConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.order")
    public DataSource orderDataSource(){
        return new DruidDataSource();
    }


    @Bean
    public SqlSessionFactory orderSqlSessionFactory(@Qualifier("orderDataSource") DataSource dataSource) throws Exception{
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/order/*.xml"));
        //factoryBean.setConfigLocation(new ClassPathResource("mybatis-configuration.xml"));
        return factoryBean.getObject();
    }

    @Bean
    public SqlSessionTemplate orderSqlSessionTemplate(@Qualifier("orderSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

}

3、業務編碼
OrderService.java

@GlobalTransactional
    public void create(String userId, String commodityCode, Integer count) {

        BigDecimal orderMoney = new BigDecimal(count).multiply(new BigDecimal(5));
        Order order = new Order();
        order.setUserId(userId);
        order.setCommodityCode(commodityCode);
        order.setCount(count);
        order.setMoney(orderMoney);
        orderMapper.insert(order);
        log.info("全局事務ID[{}], 訂單記錄增加成功", RootContext.getXID());


        AccountRequest accountRequest = AccountRequest.builder()
                .userId(userId)
                .money(orderMoney)
                .build();
        accountService.debit(userId, orderMoney);
        log.info("全局事務ID[{}], 賬戶金額扣減成功", RootContext.getXID());

    }

AccountService.java

public void debit(String userId, BigDecimal num) {
        log.info("賬戶扣減接口,全局事務ID:{}");
        Account account = accountMapper.selectByUserId(userId);
        account.setMoney(account.getMoney().subtract(num));
        if(account.getMoney().compareTo(BigDecimal.ZERO) < 0){
            throw new RuntimeException("account branch exception");
        }
        accountMapper.updateById(account);
    }

模擬測試

場景1:訂單創建成功,賬戶扣減成功

@Test
	void test() {

		orderService.create("001", "123", 10);

	}

在這裏插入圖片描述
場景2:訂單創建成功,賬戶扣減失敗

@Test
	void test() {

		orderService.create("001", "123", 100);

	}

在這裏插入圖片描述
賬戶服務發生異常,訂單服務事務回滾
場景3、訂單創建成功,賬戶扣減成功,拋出異常
在這裏插入圖片描述
在這裏插入圖片描述
事務順利進行了回滾,而且是先account進行回滾,然後order進行回滾。

從上述場景來看,Seata對多數據源下事務的支持的還是比較好的,而且最重要的是對業務邏輯的侵入比較小,就我們當前的服務來說,引入Seata後只需要將@Transactional註解替換爲@GlobalTransactional註解即可。但是缺點是,對於一個未搭建Seata服務的系統來說,成本還是比較大的。
那麼在不引入分佈式事務的前提下,如何解決在同一個方法中使用多個數據源的事務一致性問題呢?提供兩個不完善的解決辦法,歡迎大家補充。

本地事務解決多數據源事務一致性問題

1、使用單一事務

這個應該是最簡單直接的處理方式,但是成本就未必是最小的了。
這個方案也是我的同事採取的處理方案----將mapper進行分類調整,不同的數據源對應不同的mapper,避免在同一個事務中出現mapper交叉,保證同一個事務方法中只會出現一類mapper操作(非查詢)。
該方案的好處是,單一事務管理,避免了分佈式事務的複雜性。
壞處主要有以下幾點:
1)對於一套已經長久運行的成熟的業務複雜的單數據源單事務管理系統,改造成本較大,風險較高。
2)會出現同一個表對應多個mapper的情況,特別是對於一些insert等涉及到表結構的操作,如果表結構改變會導致多處需要改變,維護成本較高;
3)如果允許同一事務方法中使用非事務管理器對應數據源執行查詢操作,可能會出現對同一個表數據的更新無法在接下來的查詢中查到的情況(事務隔離級別:讀已提交及以上)。

2、使用多個事務管理器

在網上發現一個比較有趣的思路 — 通過註解指定多個事務管理器,然後通過aop在方法執行前依次開啓多個事務,捕捉方法執行異常,方法執行成功則依次反序提交事務,否則依次反序回滾事務。
這個思路的重點就是手動控制事務的開啓、提交與回滾。基於這種思路,簡單寫了一個測試用例,如下:

@Autowired
    @Qualifier( "orderTransactionManager")
    DataSourceTransactionManager orderTransactionManager;
    @Autowired
    @Qualifier( "accountTransactionManager")
    DataSourceTransactionManager accountTransactionManager;


    public void create(String userId, String commodityCode, Integer count) {

        //開啓事務
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        /*PROPAGATION_REQUIRES_NEW:  事物隔離級別,開啓新事務*/
        def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
        TransactionStatus status =  orderTransactionManager.getTransaction(def);
        TransactionStatus status2 =  accountTransactionManager.getTransaction(def);
        try {
            BigDecimal orderMoney = new BigDecimal(count).multiply(new BigDecimal(5));
            Order order = new Order();
            order.setUserId(userId);
            order.setCommodityCode(commodityCode);
            order.setCount(count);
            order.setMoney(orderMoney);
            orderMapper.insert(order);
            log.info("全局事務ID[{}], 訂單記錄增加成功");


            AccountRequest accountRequest = AccountRequest.builder()
                    .userId(userId)
                    .money(orderMoney)
                    .build();
            accountService.debit(userId, orderMoney);
            log.info("全局事務ID[{}], 賬戶金額扣減成功");
            //int a = 1/0;
        } catch (Exception e) {
            e.printStackTrace();
            accountTransactionManager.rollback(status2);
            orderTransactionManager.rollback(status);
            return;
        }

        accountTransactionManager.commit(status2);
        orderTransactionManager.commit(status);

    }
}

這種方案實現倒是簡單,而且相對於方案1來說,改造成本也要小的多,但是這種方案問題還是比較多的。
1)系統無法保證多個事務的提交或者回滾操作的原子性,這樣就失去了事務一致性的意義。
2)由於涉及多個事務,在數據庫隔離級別爲RC及以上的前提下,事務之間的數據變動不可見,稍不注意就會出現問題。

結語

以上是本人在查找多數據源下事務支持問題時總結的一些知識及簡單嘗試,並沒有過於深入的瞭解底層的原理,所以如果有不對的地方希望大家及時指正。
在嘗試過程中,我還嘗試過在事務執行中途進行dataSource的切換,這樣可以支持同一個事務裏面進行多個數據源的操作,但是無助於事務回滾,因爲事務啓動的時候數據源和事務都已經確認,並且dataSource是存儲在DataSourceTransactionManager對象中,但是transaction對象是維護在TransactionStatus對象中的,中途可以切換數據源,但是事務的提交和回滾是依賴TransactionStatus對象進行的。當然,可能有人說那就重寫TransactionStatus對象,有必要嗎?如果真的這樣做的話還有必要使用事務管理器嗎?自己手動控制吧。

參考資料

https://github.com/seata/seata-samples

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章