同學,你的多資料來源事務失效了

Java課代表發表於2021-12-15

一、引言

說起多資料來源,一般會在如下兩個場景中用到:

  • 一是業務特殊,需要連線多個庫。課代表曾做過一次新老系統遷移,由 SQLServer 遷移到 MySQL ,中間涉及一些業務運算,常用資料抽取工具無法滿足業務需求,只能徒手擼。
  • 二是資料庫讀寫分離,在資料庫主從架構下,寫操作落到主庫,讀操作交給從庫,用於分擔主庫壓力。

多資料來源的實現,從簡單到複雜,有多種方案。

本文將以SpringBoot(2.5.X)+Mybatis+H2為例,演示一個簡單可靠的多資料來源實現。

讀完本文你將收穫:

  1. SpringBoot是怎麼自動配置資料來源的
  2. SpringBoot裡的Mybatis是如何自動配置的
  3. 多資料來源下的事務如何使用
  4. 得到一個可靠的多資料來源樣例工程

二、自動配置的資料來源

SpringBoot的自動配置幾乎幫我們完成了所有工作,只需要引入相關依賴即可完成所有工作

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.0</version>
</dependency>

當依賴中引入了H2資料庫後,DataSourceAutoConfiguration.java會自動配置一個預設資料來源:HikariDataSource,先貼原始碼:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
// 1、載入資料來源配置
@EnableConfigurationProperties(DataSourceProperties.class)
@Import({ DataSourcePoolMetadataProvidersConfiguration.class,
      DataSourceInitializationConfiguration.InitializationSpecificCredentialsDataSourceInitializationConfiguration.class,
      DataSourceInitializationConfiguration.SharedCredentialsDataSourceInitializationConfiguration.class })
public class DataSourceAutoConfiguration {

   @Configuration(proxyBeanMethods = false)
   // 內嵌資料庫依賴條件,預設存在 HikariDataSource 所以不會生效,詳見下文
   @Conditional(EmbeddedDatabaseCondition.class)
   @ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
   @Import(EmbeddedDataSourceConfiguration.class)
   protected static class EmbeddedDatabaseConfiguration {

   }

   @Configuration(proxyBeanMethods = false)
   @Conditional(PooledDataSourceCondition.class)
   @ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
   @Import({ DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Tomcat.class,
         DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.OracleUcp.class,
         DataSourceConfiguration.Generic.class, DataSourceJmxConfiguration.class })
   protected static class PooledDataSourceConfiguration {
   //2、初始化帶池化的資料來源:Hikari、Tomcat、Dbcp2等
   }
   // 省略其他
}

其原理如下:

1、載入資料來源配置

通過@EnableConfigurationProperties(DataSourceProperties.class)載入配置資訊,觀察DataSourceProperties的類定義:

@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties implements BeanClassLoaderAware, InitializingBean

可以得到到兩個資訊:

  1. 配置的字首為spring.datasource;
  2. 實現了InitializingBean介面,有初始化操作。

其實是根據使用者配置初始化了一下預設的內嵌資料庫連線:

    @Override
    public void afterPropertiesSet() throws Exception {
        if (this.embeddedDatabaseConnection == null) {
            this.embeddedDatabaseConnection = EmbeddedDatabaseConnection.get(this.classLoader);
        }
    }

通過EmbeddedDatabaseConnection.get方法遍歷內建的資料庫列舉,找到最適合當前環境的內嵌資料庫連線,由於我們引入了H2,所以返回值也是H2資料庫的列舉資訊:

public static EmbeddedDatabaseConnection get(ClassLoader classLoader) {
        for (EmbeddedDatabaseConnection candidate : EmbeddedDatabaseConnection.values()) {
            if (candidate != NONE && ClassUtils.isPresent(candidate.getDriverClassName(), classLoader)) {
                return candidate;
            }
        }
        return NONE;
    }

這就是SpringBootconvention over configuration (約定優於配置)的思想,SpringBoot發現我們引入了H2資料庫,就立馬準備好了預設的連線資訊。

2、建立資料來源

預設情況下由於SpringBoot內建池化資料來源HikariDataSource,所以@Import(EmbeddedDataSourceConfiguration.class)不會被載入,只會初始化一個HikariDataSource,原因是@Conditional(EmbeddedDatabaseCondition.class)在當前環境下不成立。這點在原始碼裡的註釋已經解釋了:

/**
 * {@link Condition} to detect when an embedded {@link DataSource} type can be used.
 
 * If a pooled {@link DataSource} is available, it will always be preferred to an
 * {@code EmbeddedDatabase}.
 * 如果存在池化 DataSource,其優先順序將高於 EmbeddedDatabase
 */
static class EmbeddedDatabaseCondition extends SpringBootCondition {
// 省略原始碼
}

所以預設資料來源的初始化是通過:@Import({ DataSourceConfiguration.Hikari.class,//省略其他} 來實現的。程式碼也比較簡單:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(HikariDataSource.class)
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.type", havingValue = "com.zaxxer.hikari.HikariDataSource",
      matchIfMissing = true)
static class Hikari {

   @Bean
   @ConfigurationProperties(prefix = "spring.datasource.hikari")
   HikariDataSource dataSource(DataSourceProperties properties) {
   //建立 HikariDataSource 例項 
      HikariDataSource dataSource = createDataSource(properties, HikariDataSource.class);
      if (StringUtils.hasText(properties.getName())) {
         dataSource.setPoolName(properties.getName());
      }
      return dataSource;
   }

}
protected static <T> T createDataSource(DataSourceProperties properties, Class<? extends DataSource> type) {
// 在 initializeDataSourceBuilder 裡面會用到預設的連線資訊
return (T) properties.initializeDataSourceBuilder().type(type).build();
}
public DataSourceBuilder<?> initializeDataSourceBuilder() {
   return DataSourceBuilder.create(getClassLoader()).type(getType()).driverClassName(determineDriverClassName())
         .url(determineUrl()).username(determineUsername()).password(determinePassword());
}

預設連線資訊的使用都是同樣的思想:優先使用使用者指定的配置,如果使用者沒寫,那就用預設的,以determineDriverClassName()為例:

public String determineDriverClassName() {
    // 如果配置了 driverClassName 則返回
        if (StringUtils.hasText(this.driverClassName)) {
            Assert.state(driverClassIsLoadable(), () -> "Cannot load driver class: " + this.driverClassName);
            return this.driverClassName;
        }
        String driverClassName = null;
    // 如果配置了 url 則根據 url推匯出 driverClassName
        if (StringUtils.hasText(this.url)) {
            driverClassName = DatabaseDriver.fromJdbcUrl(this.url).getDriverClassName();
        }
    // 還沒有的話就用資料來源配置類初始化時獲取的列舉資訊填充
        if (!StringUtils.hasText(driverClassName)) {
            driverClassName = this.embeddedDatabaseConnection.getDriverClassName();
        }
        if (!StringUtils.hasText(driverClassName)) {
            throw new DataSourceBeanCreationException("Failed to determine a suitable driver class", this,
                    this.embeddedDatabaseConnection);
        }
        return driverClassName;
    }

其他諸如determineUrl()determineUsername()determinePassword()道理都一樣,不再贅述。

至此,預設的HikariDataSource就自動配置好了!

接下來看一下MybatisSpringBoot中是如何自動配置起來的

三、自動配置Mybatis

要想在Spring中使用Mybatis,至少需要一個SqlSessionFactory 和一個 mapper介面,所以,MyBatis-Spring-Boot-Starter 為我們做了這些事:

  1. 自動發現已有的DataSource
  2. DataSource傳遞給SqlSessionFactoryBean 從而建立並註冊一個SqlSessionFactory 例項
  3. 利用sqlSessionFactory 建立並註冊 SqlSessionTemplate 例項
  4. 自動掃描mapper,將他們與SqlSessionTemplate 連結起來並註冊到Spring 容器中供其他Bean注入

結合原始碼加深印象:

public class MybatisAutoConfiguration implements InitializingBean {
    @Bean
    @ConditionalOnMissingBean
    //1.自動發現已有的`DataSource`
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
        //2.將 DataSource 傳遞給 SqlSessionFactoryBean 從而建立並註冊一個 SqlSessionFactory 例項
        factory.setDataSource(dataSource);
       // 省略其他...
        return factory.getObject();
    }

    @Bean
    @ConditionalOnMissingBean
    //3.利用 sqlSessionFactory 建立並註冊 SqlSessionTemplate 例項
    public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
        ExecutorType executorType = this.properties.getExecutorType();
        if (executorType != null) {
            return new SqlSessionTemplate(sqlSessionFactory, executorType);
        } else {
            return new SqlSessionTemplate(sqlSessionFactory);
        }
    }

    /**
     * This will just scan the same base package as Spring Boot does. If you want more power, you can explicitly use
     * {@link org.mybatis.spring.annotation.MapperScan} but this will get typed mappers working correctly, out-of-the-box,
     * similar to using Spring Data JPA repositories.
     */
     //4.自動掃描`mapper`,將他們與`SqlSessionTemplate` 連結起來並註冊到`Spring` 容器中供其他`Bean`注入
    public static class AutoConfiguredMapperScannerRegistrar implements BeanFactoryAware, ImportBeanDefinitionRegistrar {
    // 省略其他...

    }

}

一圖勝千言,其本質就是層層注入:

mybatis-inject.png

四、由單變多

有了二、三小結的知識儲備,建立多資料來源的理論基礎就有了:搞兩套DataSource,搞兩套層層注入,如圖:
mybatis-inject2.png

接下來我們就照搬自動配置單資料來源的套路配置一下多資料來源,順序如下:

step.png

首先設計一下配置資訊,單資料來源時,配置字首為spring.datasource,為了支援多個,我們在後面再加一層,yml如下:

spring:
  datasource:
    first:
      driver-class-name: org.h2.Driver
      jdbc-url: jdbc:h2:mem:db1
      username: sa
      password:
    second:
      driver-class-name: org.h2.Driver
      jdbc-url: jdbc:h2:mem:db2
      username: sa
      password:

first資料來源的配置

/**
 * @description:
 * @author:Java課代表
 * @createTime:2021/11/3 23:13
 */
@Configuration
//配置 mapper 的掃描位置,指定相應的 sqlSessionTemplate
@MapperScan(basePackages = "top.javahelper.multidatasources.mapper.first", sqlSessionTemplateRef = "firstSqlSessionTemplate")
public class FirstDataSourceConfig {

    @Bean
    @Primary
    // 讀取配置,建立資料來源
    @ConfigurationProperties(prefix = "spring.datasource.first")
    public DataSource firstDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @Primary
    // 建立 SqlSessionFactory
    public SqlSessionFactory firstSqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        // 設定 xml 的掃描路徑
        bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mybatis/first/*.xml"));
        bean.setTypeAliasesPackage("top.javahelper.multidatasources.entity");
        org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration();
        config.setMapUnderscoreToCamelCase(true);
        bean.setConfiguration(config);
        return bean.getObject();
    }

    @Bean
    @Primary
    // 建立 SqlSessionTemplate
    public SqlSessionTemplate firstSqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

    @Bean
    @Primary
    // 建立 DataSourceTransactionManager 用於事務管理
    public DataSourceTransactionManager firstTransactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

這裡每個@Bean都新增了@Primary使其成為預設Bean@MapperScan使用的時候指定SqlSessionTemplate,將mapperfirstSqlSessionTemplate聯絡起來。

小貼士:

最後還為該資料來源建立了一個DataSourceTransactionManager,用於事務管理,在多資料來源場景下使用事務時通過@Transactional(transactionManager = "firstTransactionManager")用來指定該事務使用哪個事務管理。

至此,第一個資料來源就配置好了,第二個資料來源也是配置這些專案,因為配置的Bean型別相同,所以需要使用@Qualifier來限定裝載的Bean,例如:

@Bean
// 建立 SqlSessionTemplate
public SqlSessionTemplate secondSqlSessionTemplate(@Qualifier("secondSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
    return new SqlSessionTemplate(sqlSessionFactory);
}

完整程式碼可檢視課代表的GitHub

五、多資料來源下的事務

Spring為我們提供了簡單易用的宣告式事務,使我們可以更專注於業務開發,但是想要用對用好卻並不容易,本文只聚焦多資料來源,關於事務補課請戳:Spring 宣告式事務應該怎麼學?

前文的小貼士裡已經提到了開啟宣告式事務時由於有多個事務管理器存在,需要顯示指定使用哪個事務管理器,比如下面的例子:

// 不顯式指定引數 transactionManager 則會使用設定為 Primary 的 firstTransactionManager
// 如下程式碼只會回滾 firstUserMapper.insert, secondUserMapper.insert(user2);會正常插入
@Transactional(rollbackFor = Throwable.class,transactionManager = "firstTransactionManager")
public void insertTwoDBWithTX(String name) {
    User user = new User();
    user.setName(name);
    // 回滾
    firstUserMapper.insert(user);
    // 不回滾
    secondUserMapper.insert(user);

    // 主動觸發回滾
    int i = 1/0;
}

該事務預設使用firstTransactionManager作為事務管理器,只會控制FristDataSource的事務,所以當我們從內部手動丟擲異常用於回滾事務時,firstUserMapper.insert(user);回滾,secondUserMapper.insert(user);不回滾。

框架程式碼均已上傳,小夥伴們可以按照自己的想法設計用例驗證。

六、回顧

至此,SpringBoot+Mybatis+H2的多資料來源樣例就演示完了,這應該是一個最基礎的多資料來源配置,事實上,線上很少這麼用,除非是極其簡單的一次性業務。

因為這個方式缺點非常明顯:程式碼侵入性太強!有多少資料來源,就要實現多少套元件,程式碼量成倍增長。

寫這個案例更多地是總結回顧SpringBoot的自動配置,註解式宣告BeanSpring宣告式事務等基礎知識,為後面的多資料來源進階做鋪墊。

Spring 官方為我們提供了一個AbstractRoutingDataSource類,通過對DataSource進行路由,實現多資料來源的切換。這也是目前,大多數輕量級多資料來源實現的底層支撐。

關注課代表,下一篇演示基於AbstractRoutingDataSource+AOP的多資料來源實現!

七、參考

mybatis-spring

mybatis-spring-boot-autoconfigure

課代表的GitHub

相關文章