本文源代碼位置: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 表卻沒有對應的記錄。這個問題該怎麼解決呢?我會在下一篇文章中講到。