SpringBoot+Mybatis配置多數據源並且實現事務一致性

最近博客寫得少,不知道怎麼說開場白了。呃,本文一共分三部分:SpringBoot+Mybatis環境搭建兩種方式配置多數據源兩種方式實現跨數據源事務,您可以直接跳到喜歡的部分,不過按順序看完也不會花很多時間。。。

一、搭建SpringBoot+Mybatis框架環境

看標題就知道,這部分不是重點,所以簡單說一下(如果你是小白那對不起了~)。

1、引入依賴

Mybatis整合包和jdbc驅動包,默認使用的是HikariDataSource數據源(如果你要使用阿里爸爸的Druid就要單獨引入)。

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.1</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

2、填寫配置

application.yml寫上數據源配置信息。

spring:
  datasource:
    username: test
    password: test
    url: jdbc:mysql://jiubugaosuni.com:8888/test?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
    driver-class-name: com.mysql.cj.jdbc.Driver

實際開發中還有其它更詳細的配置,這裏就不列了。還可以配置一下Mybatis的參數。

3、建立mapper接口及配置Mybatis掃描包

建一個mapper包並把所有DAO接口都放裏面。比如StudentMapper長這樣。

package cn.zhh.mapper;

import cn.zhh.entity.Student;

/**
 * 學生Mapper
 *
 * @author Zhou Huanghua
 * @date 2019/10/25 23:09
 */
public interface StudentMapper {

    /**
     * 插入一條學生記錄
     *
     * @param student 學生信息
     * @return 插入數量
     */
    int insert(Student student);

    /**
     * 根據id刪除學生記錄
     *
     * @param id 學生id
     * @return 刪除數量
     */
    int deleteById(Long id);
}

但是目前這些接口不會被Mybatis掃描到,所以需要在接口上面加一個Mapper註解。不過,更推薦配置Mybatis掃描包的方式,比如在啓動類加一下這個:@MapperScan(basePackages = "cn.zhh.mapper.**")。好像在application.yml也是可以配的,沒驗證過。

4、Mapper.xml文件

推薦在resources目錄下新建一個和Mapper接口一樣的包,把xml文件都放裏面,然後什麼都不用配置。比如StudentMapper對應的StudentMapper.xml長這樣。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.zhh.mapper.StudentMapper">

    <resultMap id="BaseResultMap" type="cn.zhh.entity.Student">
        <result column="id" jdbcType="BIGINT" property="id"/>
        <result column="name" jdbcType="VARCHAR" property="name"/>
        <result column="code" jdbcType="VARCHAR" property="code"/>
        <result column="sex" jdbcType="TINYINT" property="sex"/>
        <result column="create_time" jdbcType="DATE" property="createTime"/>
        <result column="last_update_time" jdbcType="DATE" property="lastUpdateTime"/>
        <result column="is_deleted" jdbcType="TINYINT" property="isDeleted"/>
    </resultMap>

    <insert id="insert" parameterType="cn.zhh.entity.Student">
        INSERT INTO student (`name`, `code`, `sex`) VALUES (#{name, jdbcType=VARCHAR}, #{code, jdbcType=VARCHAR}, #{sex, jdbcType=TINYINT})
    </insert>

    <delete id="deleteById" parameterType="java.lang.Long">
        DELETE FROM student WHERE id = #{id, jdbcType=BIGINT}
    </delete>
</mapper>

另外,在Mapper接口的上面也是可以寫sql的,但是那樣不推薦,第一是靈活性有限複雜的搞不了,第二是不利於統一管理維護。

總結Q&A    Q:你這寫得太簡單了吧!?A:畢竟這不是重點(*^_^*)。

二、兩種方式配置多數據源

啥叫多數據源?簡單來說就是一個系統需要操作多個數據庫(同實例或者不同實例),一個讀這個寫那個,一個讀那個寫這個。

1、動態切換數據源 

在進入一個方法執行DB操作之前,根據配置(註解或者包名啥的)切換到對應的那個數據源,執行完成後再切換回去(可選),這個方法執行前執行後通過AOP處理。

這種方式之前寫過對應的博客傳送門),此處不再贅述。

如果方法使用事務的話,可能會導致切換失敗。我在項目使用時遇到過,當時由於工期緊沒去深入研究,直接採用了第二種實現方式。下圖是當時百度了一下看到的

2、不同的mapper接口包使用不同的數據源

通過配置不同的Mapper接口掃描路徑使用不同的SqlSessionTemplate來實現。不同的SqlSessionTemplate就是不同的SqlSessionFactory,也就是不同的DataSource。

1)配置兩個不同的數據源

application.yml配置兩個數據源信息。

spring:
  datasource:
    username: test
    password: test
    url: jdbc:mysql://jiubugaosuni.com:8888/test?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
    driver-class-name: com.mysql.cj.jdbc.Driver
  datasource2:
      username: test2
      password: test2
      url: jdbc:mysql://biexiangzhidao.net:6666/test2?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
      driver-class-name: com.mysql.cj.jdbc.Driver

Java Bean的方式註冊兩個數據源。注意選擇一個加上@Primary,這樣基於Type方式的注入(如@Autowired)就可以使用它作爲默認的注入對象了。

/**
 * 數據源配置
 *
 * @author Zhou Huanghua
 * @date 2019/10/26 0:24
 */
@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource dataSource(@Value("${spring.datasource.username}") String username,
                                 @Value("${spring.datasource.password}") String password,
                                 @Value("${spring.datasource.url}") String url,
                                 @Value("${spring.datasource.driver-class-name}") String driverClassName) {
        return createDataSource(username, password, url, driverClassName);
    }

    @Bean
    public DataSource dataSource2(@Value("${spring.datasource2.username}") String username,
                                  @Value("${spring.datasource2.password}") String password,
                                  @Value("${spring.datasource2.url}") String url,
                                  @Value("${spring.datasource2.driver-class-name}") String driverClassName) {
        return createDataSource(username, password, url, driverClassName);
    }

    private DataSource createDataSource(String username, String password, String url, String driverClassName) {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        dataSource.setJdbcUrl(url);
        dataSource.setDriverClassName(driverClassName);
        return dataSource;
    }
}

2)配置兩套Mapper接口掃描路徑,並指定對應的SqlSessionTemplate

這裏我們使用一個外部類兩個靜態內部類的方式使用Java Bean配置,分成兩個獨立的類也是可以的。兩個basePackages設置不同的sqlSessionTemplateRef屬性:mapper包的將使用第一個數據源,mapper2包的將使用第二個數據源。使用@Primary的目的同上。

/**
 * MybatisConfig配置類
 *
 * @author Zhou Huanghua
 * @date 2019/10/26 0:31
 */
public class MybatisConfig {

    @Configuration
    @MapperScan(
            basePackages = "cn.zhh.mapper",
            sqlSessionTemplateRef = "sqlSessionTemplate")
    public static class Db1 {

        @Bean
        @Primary
        public SqlSessionFactory sqlSessionFactory(@Qualifier("dataSource") DataSource dataSource) throws Exception {
            SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
            factoryBean.setDataSource(dataSource);
            return factoryBean.getObject();
        }

        @Bean
        @Primary
        public SqlSessionTemplate sqlSessionTemplate(@Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) throws Exception {
            return new SqlSessionTemplate(sqlSessionFactory);
        }

        @Bean
        @Primary
        public DataSourceTransactionManager dataSourceTransactionManager(@Qualifier("dataSource") DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }
    }

    @Configuration
    @MapperScan(
            basePackages = "cn.zhh.mapper2",
            sqlSessionTemplateRef = "sqlSessionTemplate2")
    public static class Db2 {

        @Bean
        public SqlSessionFactory sqlSessionFactory2(@Qualifier("dataSource2") DataSource dataSource) throws Exception {
            SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
            factoryBean.setDataSource(dataSource);
            return factoryBean.getObject();
        }

        @Bean
        public SqlSessionTemplate sqlSessionTemplate2(@Qualifier("sqlSessionFactory2") SqlSessionFactory sqlSessionFactory) throws Exception {
            return new SqlSessionTemplate(sqlSessionFactory);
        }

        @Bean
        public DataSourceTransactionManager dataSourceTransactionManager2(@Qualifier("dataSource2") DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }
    }
}

3)使用不同數據源的Mapper接口和mapper.xml放到不同的包

4)使用事務時指定對應的事務管理器

如果是第一個數據源使用事務,則不需要指定,因爲它的事務管理器註冊時加了@Primary。其它數據源要使用的話則指定對應的事務管理器。如第二個數據源的事務管理器註冊時這樣寫的

那麼在使用事務時,就需要指定對應事務管理器

因爲@Transactional只能指定一個事務管理器,並且註解不允許重複,所以就只能使用一個數據源的事務管理器了。那麼對於一個方法涉及到多個數據源操作需要保證事務一致性的怎麼辦呢?請繼續往下看。

三、兩種方式實現跨數據源事務

1、atomikos

atomikos是實現了XA的一種分佈式事務處理工具。XA協議就是兩階段提交,更詳細的說明放在第二種方式那裏去講。

這也是推薦大家使用的,畢竟人家是流行框架。我呢就不詳細講了,童鞋們去Google一下會有你想要的。

2、註解+切面 = 自己實現

先說一下兩階段提交:首先多個數據源的事務分別都開起來,然後各事務分別去執行對應的sql(此所謂第一階段提交),最後如果都成功就把事務全部提交,只要有一個失敗就把事務都回滾——此所謂第二階段提交。

前面說過,Transactional註解只能指定一個數據源的事務管理器。我們重新定義一個,讓它支持指定多個數據源的事務管理器,然後我們在使用了這個註解的方法前後進行所謂的兩階段協議。

/**
 * 多數據源事務註解
 *
 * @author Zhou Huanghua
 * @date 2019/10/26 1:16
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface MultiDataSourceTransactional {

    /**
     * 事務管理器數組
     */
    String[] transactionManagers();
}

說到方法前後,相信很多人想到了AOP。那麼就用Spring的Aspect來完成我們的想法吧。 

先來回顧一下它的切入點

  • @Before:  標識一個前置增強方法,相當於BeforeAdvice的功能。
  • @After:  後置增強,不管是拋出異常或者正常退出都會執行。
  • @AfterReturning:  後置增強,似於AfterReturningAdvice, 方法正常退出時執行。
  • @AfterThrowing:  異常拋出增強,相當於ThrowsAdvice。
  • @Around: 環繞增強,相當於MethodInterceptor。

咋一看,@Around是可以的:ProceedingJoinPoint的proceed方法是執行目標方法,在它前面聲明事務,try...catch...一下如果有異常就回滾沒異常就提交。不過,最開始用這個的時候,好像發現有點問題,具體記不住了,大家可以試一下。因爲當時工期緊沒仔細研究,就採用了下面這種

@Before + @AfterReturning + @AfterThrowing組合,看名字和功能簡直是完美契合啊!但是有一個問題,不同方法怎麼共享那個事務呢?成員變量?對,沒錯。但是又有線程安全問題咋辦?ThreadLocal幫你解決(*^_^*)。

直接上代碼

/**
 * 多數據源事務切面
 * ※採用Around似乎不行※
 *
 * @author Zhou Huanghua
 * @date 2019/10/26 1:16
 */
@Component
@Aspect
public class MultiDataSourceTransactionAspect {

    /**
     * 線程本地變量:爲什麼使用棧?※爲了達到後進先出的效果※
     */
    private static final ThreadLocal<Stack<Pair<DataSourceTransactionManager, TransactionStatus>>> THREAD_LOCAL = new ThreadLocal<>();

    /**
     * 用於獲取事務管理器
     */
    @Autowired
    private ApplicationContext applicationContext;

    /**
     * 事務聲明
     */
    private DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    {
        // 非只讀模式
        def.setReadOnly(false);
        // 事務隔離級別:採用數據庫的
        def.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT);
        // 事務傳播行爲
        def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    }

    /**
     * 切面
     */
    @Pointcut("@annotation(cn.zhh.annotation.MultiDataSourceTransactional)")
    public void pointcut() {
    }

    /**
     * 聲明事務
     *
     * @param transactional 註解
     */
    @Before("pointcut() && @annotation(transactional)")
    public void before(MultiDataSourceTransactional transactional) {
        // 根據設置的事務名稱按順序聲明,並放到ThreadLocal裏
        String[] transactionManagerNames = transactional.transactionManagers();
        Stack<Pair<DataSourceTransactionManager, TransactionStatus>> pairStack = new Stack<>();
        for (String transactionManagerName : transactionManagerNames) {
            DataSourceTransactionManager transactionManager = applicationContext.getBean(transactionManagerName, DataSourceTransactionManager.class);
            TransactionStatus transactionStatus = transactionManager.getTransaction(def);
            pairStack.push(new Pair(transactionManager, transactionStatus));
        }
        THREAD_LOCAL.set(pairStack);
    }

    /**
     * 提交事務
     */
    @AfterReturning("pointcut()")
    public void afterReturning() {
        // ※棧頂彈出(後進先出)
        Stack<Pair<DataSourceTransactionManager, TransactionStatus>> pairStack = THREAD_LOCAL.get();
        while (!pairStack.empty()) {
            Pair<DataSourceTransactionManager, TransactionStatus> pair = pairStack.pop();
            pair.getKey().commit(pair.getValue());
        }
        THREAD_LOCAL.remove();
    }

    /**
     * 回滾事務
     */
    @AfterThrowing(value = "pointcut()")
    public void afterThrowing() {
        // ※棧頂彈出(後進先出)
        Stack<Pair<DataSourceTransactionManager, TransactionStatus>> pairStack = THREAD_LOCAL.get();
        while (!pairStack.empty()) {
            Pair<DataSourceTransactionManager, TransactionStatus> pair = pairStack.pop();
            pair.getKey().rollback(pair.getValue());
        }
        THREAD_LOCAL.remove();
    }
}

需要注意的點:

  • 1)聲明事務和提交事務或者回滾事務的順序應該相反的,就是先進後出,所以採用了棧來存儲。
  • 2)線程執行結束後記得清空本地變量。
  • 3)Pair用來存儲一對數據,很多場景能夠派上用場取代Map。
  • 4)可以參照@Transactional的那些屬性升級功能,比如隔離級別回滾異常等。

用法的話就是下面這樣子啦,親測可用(student和user在不同數據庫的表)。

廢話不說,本文結束。

所有代碼均已上傳至Github,點此前往

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