深入理解 Spring 事務:入門、使用、原理

大家好,我是樹哥。

Spring 事務是複雜一致性業務必備的知識點,掌握好 Spring 事務可以讓我們寫出更好地代碼。這篇文章我們將介紹 Spring 事務的誕生背景,從而讓我們可以更清晰地瞭解 Spring 事務存在的意義。

接着,我們會介紹如何快速使用 Spring 事務。接着,我們會介紹 Spring 事務的一些特性,從而幫助我們更好地使用 Spring 事務。最後,我們會總結一些 Spring 事務常見的問題,避免大家踩坑。

Spring事務-思維導圖

誕生背景

當我們聊起事務的時候,我們需要明白「事務」這個詞代表着什麼。

事務其實是一個併發控制單位,是用戶定義的一個操作序列,這些操作要麼全部完成,要不全部不完成,是一個不可分割的工作單位。事務有 ACID 四個特性,即:

  1. Atomicity(原子性):事務中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環節。
  2. 一致性(Consistency):在事務開始之前和事務結束以後,數據庫的完整性沒有被破壞。
  3. 事務隔離(Isolation):多個事務之間是獨立的,不相互影響的。
  4. 持久性(Durability):事務處理結束後,對數據的修改就是永久的,即便系統故障也不會丟失。

而我們說的 Spring 事務,其實是事務在 Spring 中的實現。

明白了什麼是事務之後,我們來聊聊:爲什麼要有 Spring 事務?

爲了解釋清楚這個問題,我們舉個簡單的例子:銀行裏樹哥要給小黑轉 1000 塊錢,這時候會有兩個必要的操作:

  1. 將樹哥的賬戶餘額減少 1000 元。
  2. 將小黑的賬戶餘額增加 1000 元。

這兩個操作,要麼一起都完成,要麼都不完成。如果其中某個成功,另外一個失敗,那麼就會出現嚴重的問題。而我們要保證這個操作的原子性,就必須通過 Spring 事務來完成,這就是 Spring 事務存在的原因。

如果你深入瞭解過 MySQL 事務,那麼你應該知道:MySQL 默認情況下,對於所有的單條語句都作爲一個單獨的事務來執行。我們要使用 MySQL 事務的時候,可以通過手動提交事務來控制事務範圍。Spring 事務的本質,其實就是通過 Spring AOP 切面技術,在合適的地方開啓事務,接着在合適的地方提交事務或回滾事務,從而實現了業務編程層面的事務操作。

使用指南

Spring 事務支持兩種使用方式,分別是:聲明式事務(註解方式)、編程式事務(代碼方式)。一般來說,我們使用聲明式事務比較多,這裏我們就演示聲明式事務的使用方法。

項目準備

爲了較好地進行講解,我們需要搭建一個具備數據庫 CURD 功能的項目,並創建 tablea 和 tableb 兩張表。

首先,創建 tablea 和 tableb 兩張表,兩張表都只有 id 和 name 兩列,建表語句如下圖所示。

CREATE TABLE `tablea` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1;
CREATE TABLE `tableb` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1;

接着,創建一個 SpringBoot 項目,隨後加入 MyBatis 及 MySQL 的 POM 依賴。

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

最後,我們創建對應的 controller 接口、service 接口、mapper 接口,代碼如下所示。

創建 controller 接口:

@SpringBootApplication
@RestController
@RequestMapping("/api")
public class SpringTransactionController {

    @Autowired
    private TransactionServiceA transactionServiceA;

    @RequestMapping("/spring-transaction")
    public String testTransaction() {
        transactionServiceA.methodA();
        return "SUCCESS";
    }
}

創建 TableService 接口。

public interface TableService {
    void insertTableA(TableEntity tableEntity);
    void insertTableB(TableEntity tableEntity);
}

創建 Service 接口實現類 TransactionServiceA 類,在 methodA() 方法中先往 tablea 表格插入一條數據,隨後會調用 TransactionServiceB 服務的 methodB() 方法。

@Service
public class TransactionServiceA {

    @Autowired
    private TableService tableService;

    @Autowired
    private TransactionServiceB transactionServiceB;

    public void methodA(){
        System.out.println("methodA");
        tableService.insertTableA(new TableEntity());
        transactionServiceB.methodB();
    }
}

創建 TransactionServiceB 類實現,在 methodB() 方法中往 tableb 表格插入一條數據。

@Service
public class TransactionServiceB {

    @Autowired
    private TableService tableService;

    public void methodB(){
        System.out.println("methodB");
        tableService.insertTableB(new TableEntity());
    }
}

創建 Mapper 接口方法:

@Mapper
public interface TableMapper {
    @Insert("INSERT INTO tablea(id, name) " +
            "VALUES(#{id}, #{name})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void insertTableA(TableEntity tableEntity);

    @Insert("INSERT INTO tableb(id, name) " +
            "VALUES(#{id}, #{name})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void insertTableB(TableEntity tableEntity);
}

數據庫表對應的 TableEntity:

@Data
public class TableEntity {
    private static final long serialVersionUID = 1L;

    private Long id;

    private String name;

    public TableEntity() {
    }

    public TableEntity(String name) {
        this.name = name;
    }
}

最後,我們在配置文件中配置好數據庫地址:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
# MyBatis 配置
mybatis:
  type-aliases-package: tech.shuyi.javacodechip.spring_transaction.model
  configuration:
    map-underscore-to-camel-case: true

最後,我們運行 SpringBoot 項目。通過瀏覽器訪問地址:localhost:8080/api/spring-transaction,正常的話應該是接口請求成功。

查看數據庫表,會看到 tablea 和 tableb 都插入了一條數據。

到這裏,我們用於測試 Spring 事務的 Demo 就準備完畢了!

快速入門

使用聲明式事務的方法很簡單,其實就是在 Service 層對應方法上配置 @Transaction 註解即可。

假設我們的業務需求是:往 tablea 和 tableb 插入的數據,要麼都完成,要麼都不完成。

這時候,我們應該怎麼操作呢?

首先,我們需要在 TransactionServiceA 類的 methodA() 方法上配置 @Transaction 註解,同時也在 TransactionServiceB 類的 methodB() 方法上配置 @Transaction 註解。修改之後的 TransactionServiceA 和 TransactionServiceB 代碼如下所示。

// TransactionServiceA
@Transactional
public void methodA(){
    System.out.println("methodA");
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
}
// TransactionServiceB
@Transactional
public void methodB(){
    System.out.println("methodB");
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

可以看到,我們在 methodB() 中模擬了業務異常,我們看看是否 tablea 和 tableb 都沒有插入數據。

修改之後重新啓動項目,此時我們繼續訪問地址:localhost:8080/api/spring-transaction,我們會發現執行錯誤,並且控制檯也報錯了。

這時候我們查看數據庫,會發現 tablea 和 tableb 都沒有插入數據。這說明事務起作用了。

事務傳播類型

事務傳播類型,指的是事務與事務之間的交互策略。例如:在事務方法 A 中調用事務方法 B,當事務方法 B 失敗回滾時,事務方法 A 應該如何操作?這就是事務傳播類型。Spring 事務中定義了 7 種事務傳播類型,分別是:REQUIRED、SUPPORTS、MANDATORY、REQUIRES_NEW、NOT_SUPPORTED、NEVER、NESTED。其中最常用的只有 3 種,即:REQUIRED、REQUIRES_NEW、NESTED。

針對事務傳播類型,我們要弄明白的是 4 個點:

  1. 子事務與父事務的關係,是否會啓動一個新的事務?
  2. 子事務異常時,父事務是否會回滾?
  3. 父事務異常時,子事務是否會回滾?
  4. 父事務捕捉異常後,父事務是否還會回滾?

REQUIRED

REQUIRED 是 Spring 默認的事務傳播類型,該傳播類型的特點是:當前方法存在事務時,子方法加入該事務。此時父子方法共用一個事務,無論父子方法哪個發生異常回滾,整個事務都回滾。即使父方法捕捉了異常,也是會回滾。而當前方法不存在事務時,子方法新建一個事務。 爲了驗證 REQUIRED 事務傳播類型的特點,我們來做幾個測試。

還是上面 methodA 和 methodB 的例子。當 methodA 不開啓事務,methodB 開啓事務,這時候 methodB 就是獨立的事務,而 methodA 並不在事務之中。因此當 methodB 發生異常回滾時,methodA 中的內容就不會被回滾。用如下的代碼就可以驗證我們所說的。

public void methodA(){
    System.out.println("methodA");
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
}

@Transactional
public void methodB(){
    System.out.println("methodB");
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

最終的結果是:tablea 插入了數據,tableb 沒有插入數據,符合了我們的猜想。

當 methodA 開啓事務,methodB 也開啓事務。按照我們的結論,此時 methodB 會加入 methodA 的事務。此時,我們驗證當父子事務分別回滾時,另外一個事務是否會回滾。

我們先驗證第一個:當父方法事務回滾時,子方法事務是否會回滾?

@Transactional
public void methodA(){
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
    throw new RuntimeException();
}

@Transactional
public void methodB(){
    tableService.insertTableB(new TableEntity());
}

結果是:talbea 和 tableb 都沒有插入數據,即:父事務回滾時,子事務也回滾了。

我們繼續驗證第二個:當子方法事務回滾時,父方法事務是否會回滾?

@Transactional
public void methodA(){
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
}

@Transactional
public void methodB(){
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

結果是:talbea 和 tableb 都沒有插入數據,即:子事務回滾時,父事務也回滾了。

我們繼續驗證第三個:當字方法事務回滾時,父方法捕捉了異常,父方法事務是否會回滾?

@Transactional
public void methodA() {
    tableService.insertTableA(new TableEntity());
    try {
        transactionServiceB.methodB();
    } catch (Exception e) {
        System.out.println("methodb occur exp.");
    }
}
    
@Transactional
public void methodB() {
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

結果是:talbea 和 tableb 都沒有插入數據,即:子事務回滾時,父事務也回滾了。所以說,這也進一步驗證了我們之前所說的:REQUIRED 傳播類型,它是父子方法共用同一個事務的。

REQUIRES_NEW

REQUIRES_NEW 也是常用的一個傳播類型,該傳播類型的特點是:無論當前方法是否存在事務,子方法都新建一個事務。此時父子方法的事務時獨立的,它們都不會相互影響。但父方法需要注意子方法拋出的異常,避免因子方法拋出異常,而導致父方法回滾。 爲了驗證 REQUIRES_NEW 事務傳播類型的特點,我們來做幾個測試。

首先,我們來驗證一下:當父方法事務發生異常時,子方法事務是否會回滾?

@Transactional
public void methodA(){
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
    throw new RuntimeException();
}
    @Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB(){
    tableService.insertTableB(new TableEntity());
}

結果是:tablea 沒有插入數據,tableb 插入了數據,即:父方法事務回滾了,但子方法事務沒回滾。這可以證明父子方法的事務是獨立的,不相互影響。

下面,我們來看看:當子方法事務發生異常時,父方法事務是否會回滾?

@Transactional
public void methodA(){
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
}
    @Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB(){
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

結果是:tablea 沒有插入了數據,tableb 沒有插入數據。

從這個結果來看,貌似是子方法事務回滾,導致父方法事務也回滾了。但我們不是說父子事務都是獨立的,不會相互影響麼?怎麼結果與此相反呢?

其實是因爲子方法拋出了異常,而父方法並沒有做異常捕捉,此時父方法同時也拋出異常了,於是 Spring 就會將父方法事務也回滾了。如果我們在父方法中捕捉異常,那麼父方法的事務就不會回滾了,修改之後的代碼如下所示。

@Transactional
public void methodA(){
    tableService.insertTableA(new TableEntity());
    // 捕捉異常
    try {
        transactionServiceB.methodB();
    } catch (Exception e) {
        e.printStackTrace();
    }
}
    @Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB(){
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

結果是:tablea 插入了數據,tableb 沒有插入數據。這正符合我們剛剛所說的:父子事務是獨立的,並不會相互影響。

這其實就是我們上面所說的:父方法需要注意子方法拋出的異常,避免因子方法拋出異常,而導致父方法回滾。因爲如果執行過程中發生 RuntimeException 異常和 Error 的話,那麼 Spring 事務是會自動回滾的。

NESTED

NESTED 也是常用的一個傳播類型,該方法的特性與 REQUIRED 非常相似,其特性是:當前方法存在事務時,子方法加入在嵌套事務執行。當父方法事務回滾時,子方法事務也跟着回滾。當子方法事務發送回滾時,父事務是否回滾取決於是否捕捉了異常。如果捕捉了異常,那麼就不回滾,否則回滾。

可以看到 NESTED 與 REQUIRED 的區別在於:父方法與子方法對於共用事務的描述是不一樣的,REQUIRED 說的是共用同一個事務,而 NESTED 說的是在嵌套事務執行。這一個區別的具體體現是:在子方法事務發生異常回滾時,父方法有着不同的反應動作。

對於 REQUIRED 來說,無論父子方法哪個發生異常,全都會回滾。而 REQUIRED 則是:父方法發生異常回滾時,子方法事務會回滾。而子方法事務發送回滾時,父事務是否回滾取決於是否捕捉了異常。

爲了驗證 NESTED 事務傳播類型的特點,我們來做幾個測試。

首先,我們來驗證一下:當父方法事務發生異常時,子方法事務是否會回滾?

@Transactional
public void methodA() {
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
    throw new RuntimeException();
}
@Transactional(propagation = Propagation.NESTED)
public void methodB() {
    tableService.insertTableB(new TableEntity());
}

結果是:tablea 和 tableb 都沒有插入數據,即:父子方法事務都回滾了。這說明父方法發送異常時,子方法事務會回滾。

接着,我們繼續驗證一下:當子方法事務發生異常時,如果父方法沒有捕捉異常,父方法事務是否會回滾?

@Transactional
public void methodA() {
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
}
@Transactional(propagation = Propagation.NESTED)
public void methodB() {
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

結果是:tablea 和 tableb 都沒有插入數據,即:父子方法事務都回滾了。這說明子方法發送異常回滾時,如果父方法沒有捕捉異常,那麼父方法事務也會回滾。

最後,我們驗證一下:當子方法事務發生異常時,如果父方法捕捉了異常,父方法事務是否會回滾?

@Transactional
public void methodA() {
    tableService.insertTableA(new TableEntity());
    try {
        transactionServiceB.methodB();
    } catch (Exception e) {
        
    }
}
@Transactional(propagation = Propagation.NESTED)
public void methodB() {
    tableService.insertTableB(new TableEntity());
    throw new RuntimeException();
}

結果是:tablea 插入了數據,tableb 沒有插入數據,即:父方法事務沒有回滾,子方法事務回滾了。這說明子方法發送異常回滾時,如果父方法捕捉了異常,那麼父方法事務就不會回滾。

看到這裏,相信大家已經對 REQUIRED、REQUIRES_NEW 和 NESTED 這三個傳播類型有了深入的理解了。最後,讓我們來總結一下:

事務傳播類型 特性
REQUIRED 當前方法存在事務時,子方法加入該事務。此時父子方法共用一個事務,無論父子方法哪個發生異常回滾,整個事務都回滾。即使父方法捕捉了異常,也是會回滾。而當前方法不存在事務時,子方法新建一個事務。
REQUIRES_NEW 無論當前方法是否存在事務,子方法都新建一個事務。此時父子方法的事務時獨立的,它們都不會相互影響。但父方法需要注意子方法拋出的異常,避免因子方法拋出異常,而導致父方法回滾。
NESTED 當前方法存在事務時,子方法加入在嵌套事務執行。當父方法事務回滾時,子方法事務也跟着回滾。當子方法事務發送回滾時,父事務是否回滾取決於是否捕捉了異常。如果捕捉了異常,那麼就不回滾,否則回滾。

使用方法論

看完了事務的傳播類型,我們對 Spring 事務又有了深刻的理解。

看到這裏,你應該也明白:使用事務,不再是簡單地使用 @Transaction 註解就可以,還需要根據業務場景,選擇合適的傳播類型。那麼我們再昇華一下使用 Spring 事務的方法論。一般來說,使用 Spring 事務的步驟爲:

  1. 根據業務場景,分析要達成的事務效果,確定使用的事務傳播類型。
  2. 在 Service 層使用 @Transaction 註解,配置對應的 propogation 屬性。

下次遇到要使用事務的情況,記得按照這樣的步驟去做哦~

Spring 事務失效

  1. 什麼時候 Spring 事務會失效?

若同一類中的其他沒有 @Transactional 註解的方法內部調用有 @Transactional 註解的方法,有 @Transactional 註解的方法的事務會失效。

這是由於 Spring AOP 代理的原因造成的,因爲只有當 @Transactional 註解的方法在類以外被調用的時候,Spring 事務管理才生效。

另外,如果直接調用,不通過對象調用,也是會失效的。因爲 Spring 事務是通過 AOP 實現的。

@Transactional 註解只有作用到 public 方法上事務才生效。

被 @Transactional 註解的方法所在的類必須被 Spring 管理。

底層使用的數據庫必須支持事務機制,否則不生效。

彩蛋

Spring 事務執行過程中,如果拋出非 RuntimeException 和非 Error 錯誤的其他異常,那麼是不會回滾的哦。例如下面的代碼執行後,tablea 和 tableb 兩個表格,都會插入一條數據。

@Transactional
public void methodA() throws Exception {
    tableService.insertTableA(new TableEntity());
    transactionServiceB.methodB();
}
@Transactional
public void methodB() throws Exception {
    tableService.insertTableB(new TableEntity());
    // 非 RuntimeException
    throw new Exception();
}

參考資料

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