【133】Spring Boot 1 + MyBatis 多數據源分佈式事務(一)

本文源代碼位置:https://gitee.com/zhangchao19890805/csdnBlog.git 倉庫中的 blog133 文件夾就是項目文件夾。

使用 Spring Boot 和 Spring Cloud 做分佈式微服務系統,難免會碰到跨數據庫的事務。衆所周知的CAP原則,即一致性(C)、可用性(A)和分區容錯性(P)只能做到其中兩個比較強,剩下一個較弱。Spring Cloud 分佈式微服務系統天生可用性(A)和分區容錯性(P)較強。如何保證一致性就是個重要的問題。我準備就這個問題寫多篇文章,結合實例,向讀者一步一步展示如何實現分佈式事務,保證數據的一致性。我的文章會從最簡單的方法開始,一步一步的不斷完善系統,逐漸增強系統的一致性。這也恰好符合我在實踐中經歷。

首先,我先模擬一個場景:我們在添加用戶的時候,既要添加用戶的基本信息,又要添加用戶的身份證信息。這兩個信息儲存在兩臺物理機的數據庫中。在添加用戶的時候,要保證身份證也一起添加,這構成了一個事務。啓用用戶基本信息存在 t_user 表中,身份證信息存在 t_card 表中。t_user 和 t_card 一一對應。

下面是數據結構:

儲存t_user的物理機

CREATE DATABASE `db_test`

CREATE TABLE `t_user` (
  `c_id` varchar(70) CHARACTER SET utf8 NOT NULL,
  `c_user_name` varchar(45) CHARACTER SET utf8 NOT NULL,
  `c_password` varchar(45) CHARACTER SET utf8 NOT NULL,
  `c_create_time` datetime NOT NULL,
  `c_balance` decimal(9,2) NOT NULL DEFAULT '0.00',
  PRIMARY KEY (`c_id`),
  UNIQUE KEY `c_user_name_UNIQUE` (`c_user_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `t_log` (
  `c_id` varchar(80) NOT NULL,
  `c_content` text,
  `c_datetime` datetime NOT NULL,
  PRIMARY KEY (`c_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;

儲存 t_card 的物理機

CREATE DATABASE `db_home_2` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */;
CREATE TABLE `t_card` (
  `c_id` varchar(80) NOT NULL,
  `c_user_id` varchar(80) NOT NULL,
  `c_no` varchar(30) NOT NULL,
  `c_create_time` datetime NOT NULL,
  PRIMARY KEY (`c_id`),
  UNIQUE KEY `c_user_id_UNIQUE` (`c_user_id`),
  UNIQUE KEY `c_no_UNIQUE` (`c_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

接下來是關鍵代碼。

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>zhangchao</groupId>
	<artifactId>blog133</artifactId>
	<version>0.0.1</version>
	
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>1.5.17.RELEASE</version>
		<relativePath/>
	</parent>
	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<java.version>1.8</java.version>
		<maven.compiler.source>1.8</maven.compiler.source>
		<maven.compiler.target>1.8</maven.compiler.target>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
		    <groupId>mysql</groupId>
		    <artifactId>mysql-connector-java</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>		
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>io.springfox</groupId>
			<artifactId>springfox-swagger-ui</artifactId>
			<version>2.6.1</version>
		</dependency>
		<dependency>
			<groupId>io.springfox</groupId>
			<artifactId>springfox-swagger2</artifactId>
			<version>2.6.1</version>
		</dependency>
		<!-- 熱部署模塊 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<optional>true</optional> <!-- 這個需要爲 true 熱部署纔有效 -->
		</dependency>


		<dependency>
			<groupId>org.mybatis</groupId>
			<artifactId>mybatis</artifactId>
			<version>3.4.6</version>
		</dependency>
		
	</dependencies>
	
	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</project>

必須要屏蔽掉 Spring Boot 1 默認的數據源配置,否則你只要沒在 application.properties 或 application.yml 裏配置數據源,啓動時就會報錯。需要在 main 方法中屏蔽。

Blog133Application.java

package zhangchao;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

@SpringBootApplication(exclude = {
		DataSourceAutoConfiguration.class
})
public class Blog133Application {
	public static void main(String[] args) {
        SpringApplication.run(Blog133Application.class, args);
    }
}

兩個數據源的信息分別寫到 mybatis-config.xml 和 mybatis-config-2.xml 兩個文件中。

mybatis-config.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://127.0.0.1:3306/db_test"/>
        <property name="username" value="root"/>
        <property name="password" value="123456"/>
      </dataSource>
    </environment>
  </environments>
  <mappers>
    <mapper resource="mapper/UserMapper.xml"/>
    <mapper resource="mapper/LogMapper.xml"/>
  </mappers>
</configuration>

mybatis-config-2.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://192.168.1.230:3306/db_home_2"/>
        <property name="username" value="root"/>
        <property name="password" value="123456"/>
      </dataSource>
    </environment>
  </environments>
  <mappers>
    <mapper resource="mapper/CardMapper.xml"/>
  </mappers>
</configuration>

爲兩個數據源配置兩個不同的 SqlSessionFactory。

FirstDBFactory.java

package zhangchao.common.db;

import java.io.IOException;
import java.io.InputStream;

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

/**
 * 保存第一個數據庫的SqlSessionFactory
 * @author 張超
 */
public class FirstDBFactory {
	private static SqlSessionFactory sqlSessionFactory = null;
	
	static {
		String resource = "mybatis-config.xml";
		InputStream inputStream = null;
		try {
			inputStream = Resources.getResourceAsStream(resource);
		} catch (IOException e) {
			e.printStackTrace();
		}
		sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
	}
	
	public static SqlSessionFactory getInstance(){
		return sqlSessionFactory;
	}
}

SecondDBFactory.java

package zhangchao.common.db;

import java.io.IOException;
import java.io.InputStream;

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

/**
 * 保存第二個數據庫的SqlSessionFactory
 * @author 張超
 *
 */
public class SecondDBFactory {
	private static SqlSessionFactory sqlSessionFactory = null;
	
	static {
		String resource = "mybatis-config-2.xml";
		InputStream inputStream = null;
		try {
			inputStream = Resources.getResourceAsStream(resource);
		} catch (IOException e) {
			e.printStackTrace();
		}
		sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
	}
	
	public static SqlSessionFactory getInstance(){
		return sqlSessionFactory;
	}
}

我把事務放到 Service 層中,基本思路是先打開兩個數據庫的SqlSession,然後執行 SQL 語句,等執行完後再提交兩個數據庫的事務。如果有異常,先判斷事務有沒有提交:沒提交就是常規的回滾;已經提交了的就執行補償操作。

與此同時,對異常的處理還做了如下加強:如果恰巧在前一個數據庫回滾或者補償操作的時候出現數據庫宕機,那麼回滾或者補償操作就會拋出異常,造成程序沒有執行下一個數據庫的回滾或者補償操作。爲了避免上述情況,還特意爲回滾和補償操作加了 try … catch ,並增加了合併異常的代碼。下面是最關鍵的部分:

UserService.java

package zhangchao.service;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import zhangchao.common.db.FirstDBFactory;
import zhangchao.common.db.SecondDBFactory;
import zhangchao.common.exception.ExceptionUtils;
import zhangchao.dao.CardDao;
import zhangchao.dao.UserDao;
import zhangchao.domain.*;


/**
 * 用戶的服務類
 * @author 張超
 *
 */
@Service
public class UserService {
	
	@Autowired
	private UserDao userDao;
	@Autowired
	private CardDao cardDao;


	public List<User> selectList(){
		SqlSession sqlSession = FirstDBFactory.getInstance().openSession(true);
		List<User> r = null;
		try {
			r = this.userDao.selectList(sqlSession);
		} finally{
			sqlSession.close();
		}
		return r;
	}
	
	/**
	 * 刪除用戶
	 * @param id
	 */
	public void delete(String id) {
		SqlSession sqlSession = FirstDBFactory.getInstance().openSession(true);
		try {
			this.userDao.delete(sqlSession, id);
		} finally{
			sqlSession.close();
		}
	}
	
	public void save(User user, Card card) {
		// 第一個數據庫,放User表
		SqlSession sqlSession_1 = FirstDBFactory.getInstance().openSession();
		// 第二個數據庫,放Card表
		SqlSession sqlSession_2 = SecondDBFactory.getInstance().openSession();
		boolean firstSessionCommit = false;
		try {
			this.userDao.save(sqlSession_1, user);
			this.cardDao.save(sqlSession_2, card);
			new BigDecimal("11").divide(user.getBalance(),2);
			
			// 提交
			sqlSession_1.commit();
			firstSessionCommit = true;
			new BigDecimal("11").divide(new BigDecimal(card.getNo()), 2);
			sqlSession_2.commit();
		} catch (Exception e) {
			throw ExceptionUtils.catchException(e, 
					sqlSession_1, firstSessionCommit, ()->{
						this.delete(user.getId());
						System.out.println("調用UserService.delete方法");
					}, 
					sqlSession_2);
		} finally {
			sqlSession_1.close();
			sqlSession_2.close();
		}
	}

}

ExceptionUtils.java

package zhangchao.common.exception;

import org.apache.ibatis.session.SqlSession;

/**
 * 統一的異常處理
 * @author 張超
 *
 */
public class ExceptionUtils {
	
	/**
	 * 異常轉成字符串
	 * @param t 異常
	 * @return 異常的詳細信息的字符串
	 */
	private static String throwable2Str (Throwable t) {
		StackTraceElement[] steArr = t.getStackTrace();
		String[] details = new String[steArr.length];
		for (int i = 0; i < details.length; i++) {
			details[i] = steArr[i].toString();
		}
		String message = t.getMessage();
		StringBuilder content = new StringBuilder();
		for (String str : details) {
			content.append(str).append("\n");
		}
		content.append("\n").append(message).append("\n");
		return content.toString();
	}
	
	/**
	 * 把多個異常合併成一個異常。
	 * @param arr
	 */
	public static RuntimeException join(Throwable[] arr){
		StringBuilder sb = new StringBuilder();
		if (null == arr || arr.length == 0) {
			return null;
		}
		for (int i = 0; i < arr.length; i++) {
			Throwable t = arr[i];
			if (null != t) {
				sb.append(throwable2Str(t)).append("\n");
			}
		}
		return new RuntimeException(sb.toString());
	}
	
	/**
	 * 統一處理異常
	 * @param e
	 */
	public static RuntimeException catchException(Exception e, 
			SqlSession sqlSession_1, boolean sessionCommit_1, Compensate c1,
			SqlSession sqlSession_2, boolean sessionCommit_2, Compensate c2){
		Exception rbEx_1 = null;
		Exception rbEx_2 = null;
		try{
			// 如果User表已經提交,做補償操作
			if (sessionCommit_1){
				if (null != c1) {
					c1.compensate();
				}
			} else {
				// 如果User表還沒提交,回滾
				sqlSession_1.rollback();
				System.out.println("sqlSession.rollback()");
			}
		} catch (Exception ex1) {
			// 這裏加入 try ... catch 的原因是,如果 user 表的
			// 數據庫停止,上面代碼 拋出異常,如果不處理,
			// 後面的代碼就無法執行,也就無法回退 sqlSession_2 和包裝異常。
			rbEx_1 = ex1;
		}
		try {
			if (sessionCommit_2) {
				if (null != c2) {
					c2.compensate();
				}
			} else {
				sqlSession_2.rollback();
				System.out.println("sqlSession_2.rollback()");
			}
		} catch(Exception ex2) {
			rbEx_2 = ex2;
		}
		RuntimeException r = ExceptionUtils.join(new Exception[]{e, rbEx_1, rbEx_2});
		return r;
	}
	
	public static RuntimeException catchException(Exception e, 
			SqlSession sqlSession_1, boolean sessionCommit_1, Compensate c1,
			SqlSession sqlSession_2){
		RuntimeException r = catchException(e, sqlSession_1, sessionCommit_1, c1,
				sqlSession_2, false, null);
		return r;
	}
}

爲了方便補償操作而加的接口 Compensate

package zhangchao.common.exception;
/**
 * 異常處理中的補償操作接口
 * @author 張超
 *
 */
public interface Compensate {
	void compensate();
}

ExceptionHandleAdvice.java

package zhangchao.common.exception;

import java.sql.Timestamp;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import zhangchao.common.core.R;
import zhangchao.domain.Log;
import zhangchao.service.LogService;

/**
 * Spring Boot 1 統一封裝異常
 * @author 張超
 *
 */
@RestControllerAdvice
public class ExceptionHandleAdvice {
	@Autowired
	private LogService logService;
	
	
	@ExceptionHandler(value=Throwable.class)
	public R exception(Throwable t){
		StackTraceElement[] steArr = t.getStackTrace();
		String[] details = new String[steArr.length];
		for (int i = 0; i < details.length; i++) {
			details[i] = steArr[i].toString();
		}
		String message = t.getMessage();
		StringBuilder content = new StringBuilder();
		for (String str : details) {
			content.append(str).append("\n");
		}
		content.append("\n").append(message).append("\n");
		
		Log log = new Log();
		log.setId(UUID.randomUUID().toString());
		log.setContent(content.toString());
		log.setDatetime(new Timestamp(System.currentTimeMillis()));
		// 這裏加上try catch,就算數據庫停止了,也能處理異常。
		try {
			logService.save(log);
		} catch (Exception e) {
			// 此處也可以改成存入日誌文件中
			e.printStackTrace();
		}
		return R.error().put("msg", message);
	}
}

好了,最關鍵的部分就是這些。想查看更多細節建議克隆下代碼再仔細查看。運行Blog133Application 後,瀏覽器中輸入http://localhost/test.html 就可以執行添加用戶的操作。瀏覽器中輸入 http://localhost/api/user 就能看的已經添加的用戶。

反思我們的代碼,雖然我們爲了保證事務的一致性加入了回滾和補償操作,但是系統的一致性仍然有瑕疵。假如系統執行完 t_user 表的數據庫提交操作後,Tomcat 突然宕機,會造成 t_card 數據庫沒提交,導致兩臺機器的數據不一致。也就是 t_user 表已經有記錄了,而 t_card 表卻沒有對應的記錄。這個問題該怎麼解決呢?我會在下一篇文章中講到。

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