SpringBootにおけるトランザクション「@Transactional」の動きをシンプルに理解する

こんにちは。SpringBootの勉強シリーズです。

今回はSpringBootのアノテーション「@Transactional」の挙動を整理します。

なお、トランザクション分離レベルなどは触れません。トランザクションのシンプルな挙動の理解を目指します。

- [環境](#環境)
- [まずはじめに](#まずはじめに)
- [トランザクションとは](#トランザクションとは)
- [トランザクションの挙動](#トランザクションの挙動)
  - [正常終了時](#正常終了時)
  - [異常終了時](#異常終了時)
- [何が嬉しいのか](#何が嬉しいのか)
- [トランザクションの処理中のDBの状態は？](#トランザクションの処理中のdbの状態は)
- [ではどんな時でも、トランザクションにしておけば安心なのか](#ではどんな時でもトランザクションにしておけば安心なのか)
- [まとめ](#まとめ)


## 環境
- SpringBoot（バージョンは適当）
- Postgresql（バージョンは適当）

※たぶんバージョンとかDBに関係あることは書きません。

## まずはじめに

あいかわらずSpringBootの勉強中です。

よく以下のような実装を見ます。今日はこの実装を紐解いていきます。
（実際のソースコードは10倍くらい複雑ですけど）

```java
    @Transactional
    public void insertSample(String name, int point) {
        sampleMapper.insertSample(name, point);
    }
```

## トランザクションとは

複数の処理をまとめた単位らしいです。

## トランザクションの挙動

以下のような処理があります。

```java
    @Transactional
    public void insertSample(String name, int point) {
        sampleMapper.insertSample(name, point); // ①DBへの登録
        doSomething(); // ②ここでエラーが発生するかもしれない
    }
```

### 正常終了時
@Transactionalを付与されたメソッドが完了時に、DBに対してコミットして、更新を実際に反映します。

```mermaid
sequenceDiagram
    participant C as Controller
    participant S as Service<br>(@Transactional)
    participant M as Mapper
    participant DB as Database
    
    C->>+S: insertSampleWithTransaction3<br>(name, point)
    Note over S: @Transactionalにより<br>トランザクション開始
    
    S->>+M: insertSample(name, point)
    M->>DB: INSERT INTO sample_table
    DB-->>M: 登録成功
    M-->>-S: 成功応答
    
    S->>S: doSomething()
    Note over S: 正常終了
    
    Note over S: メソッド終了により<br>自動的にcommit
    S->>DB: COMMIT
    DB-->>S: コミット完了
    
    S-->>-C: 正常終了
    C-->>Client: 200 OK
```

### 異常終了時
①で登録後に、②でエラーが発生するとします。

この場合に、トランザクションとして、エラーを検知してロールバックして、①のINSERTがなかったことになります。

以下のように、トランザクション内でエラーが起こった場合に、ロールバックが発生します。

```mermaid
sequenceDiagram
    participant C as Controller
    participant S as Service<br>(@Transactional)
    participant M as Mapper
    participant DB as Database
    
    C->>+S: insertSampleWithTransaction3<br>(name, point)
    Note over S: @Transactionalにより<br>トランザクション開始
    
    S->>+M: insertSample(name, point)
    M->>DB: INSERT INTO sample_table
    DB-->>M: 登録成功
    M-->>-S: 成功応答
    
    S->>S: doSomething()
    Note over S: エラー発生！
    
    S-->>C: RuntimeException
    Note over S: Spring Framework<br>トランザクション自動ロールバック
    
    S->>DB: ROLLBACK
    DB-->>S: ロールバック完了

```

## 何が嬉しいのか

以下のような場合にメリットがあります。

- 処理取り消し（ロールバック）の個別実装が不要
- 複数テーブルに跨った更新に対して、一貫性を担保できる
- Javaの領域で起こったエラーに対して、DBのロールバックをフレームワークに任せることができる

たとえば下記の図のように、2つのテーブルを更新する処理があるとします。この場合、片方のテーブルだけが更新されて、もう片方が更新されないという状況を防ぐことができます。トランザクションを使用することで、両方のテーブルの更新が成功するか、両方とも元の状態に戻るかのどちらかになります。

```mermaid
sequenceDiagram
    participant C as Controller
    participant S as Service<br>(@Transactional)
    participant M1 as テーブル1<br>Mapper
    participant M2 as テーブル2<br>Mapper
    participant DB as Database

    C->>+S: updateSomething()
    Note over S: @Transactionalにより<br>トランザクション開始

    S->>+M1: updateTable1()
    M1->>DB: UPDATE テーブル1
    DB-->>M1: 更新成功
    M1-->>-S: 成功応答

    S->>+M2: updateTable2()
    M2->>DB: UPDATE テーブル2
    Note over M2: ここでエラー発生！
    M2-->>-S: RuntimeException

    Note over S: トランザクションにより<br>両方のテーブルの<br>更新がロールバック
    S->>DB: ROLLBACK
    DB-->>S: ロールバック完了

    S-->>-C: RuntimeException
```

## トランザクションの処理中のDBの状態は？

コミットされていなければ、DBに反映されません。

すなわち、処理中のトランザクションは、コミットされるまで他者からは反映を確認できません。

以下の例ですと、ユーザーBからは、ユーザーAの処理がコミットされるまでは、変更前の情報が参照されます。

```mermaid
sequenceDiagram
    participant UA as ユーザーA
    participant DB as データベース
    participant UB as ユーザーB
    
    Note over UA,UB: ポイント現在値: 1000
    
    UA->>+DB: BEGIN TRANSACTION
    UA->>DB: UPDATE sample_table SET point = 2000 WHERE id = 1
    Note over DB: DBには2000で更新済み<br>（まだコミットされていない）
    
    UB->>+DB: SELECT point FROM sample_table WHERE id = 1
    DB-->>-UB: 1000を返却（コミット前の値）
    
    UA->>DB: COMMIT
    Note over DB: 更新が確定
    DB-->>-UA: コミット完了
    
    UB->>+DB: SELECT point FROM sample_table WHERE id = 1
    DB-->>-UB: 2000を返却（コミット後の値）
    
    Note over UA,UB: トランザクション分離性により<br>コミットされるまで他のユーザーには<br>更新前の値が見える
```

## ではどんな時でも、トランザクションにしておけば安心なのか

そういうわけではありません。トランザクション内での更新は、そのトランザクションがコミットされるまでレコードをロックするため、他からの更新が待機してしまいます。

これは以下のような状況で問題になる可能性があります：

- 大量のデータを更新する処理
- 外部APIを呼び出すなど、処理時間が長くなる可能性がある処理
- 複数のトランザクションが同じテーブルに対して更新を行う処理

特に注意が必要なのは、複数のトランザクションが異なる順序でテーブルを更新する場合です。
以下のような状況では、デッドロックが発生する可能性があります。

```mermaid
sequenceDiagram
    participant T1 as トランザクション1
    participant DB as Database
    participant T2 as トランザクション2
    
    T1->>+DB: BEGIN TRANSACTION
    T2->>+DB: BEGIN TRANSACTION
    
    T1->>DB: UPDATE テーブルA
    Note over DB: テーブルAをロック
    
    T2->>DB: UPDATE テーブルB
    Note over DB: テーブルBをロック
    
    T1->>DB: UPDATE テーブルB
    Note over DB: テーブルBのロック待ち
    
    T2->>DB: UPDATE テーブルA
    Note over DB: テーブルAのロック待ち<br>デッドロック！
    
    Note over T1,T2: 両方のトランザクションが<br>互いのロック解放を待つ状態
```

:::note info
実際には、デッドロックが発生した場合、各DBMSによって検知してエラーとなります。そして場合によりますが、どちらかのトランザクションをエラーとし、どちらかを完了させます。
:::


このような問題を防ぐためには、以下のようなことをします。

1. トランザクションの範囲は必要最小限にする
   - 外部APIの呼び出しはトランザクションの外で行う
   - 大量データの更新は適切な単位で分割する

2. テーブルの更新順序を統一する
   - 複数のトランザクションで同じテーブルを更新する場合は、更新順序をアプリケーション側で揃える
   - 例：テーブルA → テーブルB → テーブルC という順序を統一する

トランザクションは便利な機能ですが、適切な範囲で使用することが重要です。
必要以上に大きな範囲をトランザクションにすると、アプリケーションのパフォーマンスに影響を与える可能性があります。

## まとめ

SpringBootにおいて、トランザクション（@Transactional）配下の処理が

- 成功した場合
    - DBにも反映される
- 失敗した場合
    - DBもロールバックする

という挙動でACID特性のA（Atomicity）が担保されます。

実際には

- フレームワークの挙動（アノテーションのその他のオプション）
- DBの挙動
- トランザクション分離レベル

などなど、理解すべきことが多いですが、まずはシンプルに挙動をまとめてみました。

初学者の方、実際に案件で使用している方に、勉強も兼ねてご参考になれば幸いです。

以上です。また何かまとめます。