@Transactional徹底解剖!これで完璧なトランザクション管理を手に入れろ

この記事はアソビュー! Advent Calendar 2024 の21日目(裏面)です。

こんにちは!

アソビューでウラカタチケットチームのエンジニアを担当しています、李と申します。

はじめに

今回は、Spring Frameworkなどでデータアクセス層を扱う際に頻繁に利用される@Transactionalアノテーションについて、改めてまとめてみたいと思います。

なぜこのテーマを取り上げるかというと、バックエンドの開発においてトランザクションは非常に重要な概念であり、正しく理解していないと予期せぬ不具合やデータ不整合、パフォーマンス問題を引き起こすことがあるためです。

本記事では、@Transactionalに関する基本から詳細な設定項目、そしてよく起こりがちな適用ミスやログ確認方法まで改めて整理します。これをきっかけにしっかり理解し、安定したバックエンド開発を目指しましょう。

@Transactionalの基本を復習

@Transactionalは、Spring Frameworkが提供するアノテーションで、メソッドやクラスレベルでトランザクション境界を定義するために用いられます。 これを使用することで、メソッド呼び出し前にトランザクションが開始され、メソッド終了時にコミットまたはロールバックが行われます。 メリットとしては以下のような点があります。

  • トランザクション管理の簡略化: コード中で明示的にbegin, commit, rollbackを呼ぶ必要がなくなる。
  • 宣言的なトランザクション管理: アノテーションを用いることで、設定やアスペクト指向プログラミングにより横断的関心事として扱える。
  • 可読性・保守性の向上: トランザクション境界が明確になり、意図がコード上で明快に表現できる。

propagationとは

トランザクションの伝搬属性(Propagation)とは、@Transactionalメソッドを呼び出す際に既存のトランザクションコンテキストをどのように扱うかを指定するものです。これにより、呼び出し元と呼び出し先が異なるトランザクションポリシーを取れるようになります。主なPropagation属性は以下の通りです。

REQUIRED:

既存のトランザクションがあればそれを利用、なければ新規開始。最も一般的。

[呼び出し元にトランザクションなし]
└─> method B [REQUIRED]
    └─>[新規トランザクション: T2開始]

[呼び出し元トランザクション: T1]
└─> method B [REQUIRED]
    └─>[参加: 同じT1で処理]

REQUIRES_NEW:

必ず新しいトランザクションを開始し、呼び出し元のトランザクションは一時的にサスペンドする。

[呼び出し元トランザクション: T1 (実行中)]
└─> method B [REQUIRES_NEW]
    └─>[新規トランザクション: T2開始](T1は中断状態)
        └─>処理終了後T2を終了
            └─>[既存トランザクション: T1再開]

MANDATORY:

必ず既存のトランザクションが存在することを要求し、なければ例外を投げる。

[呼び出し元にトランザクションなし]
└─> method B [MANDATORY]
    └─>例外発生(トランザクションがないため)

[呼び出し元にトランザクション: T1]
└─> method B [MANDATORY]
    └─>[参加: 同じT1で処理]

SUPPORTS

トランザクションがあれば参加、なければ非トランザクションで実行。

[呼び出し元にトランザクションなし]
└─> method B [SUPPORTS]
    └─>トランザクションなしで実行

[呼び出し元にトランザクション: T1]
└─> method B [SUPPORTS]
    └─>[参加: 同じT1で処理]

NOT_SUPPORTED

トランザクションがあれば一時停止し、トランザクションなしで実行。

[呼び出し元にトランザクションなし]
└─> method B [NOT_SUPPORTED]
    └─>トランザクションなしで実行

[呼び出し元にトランザクション: T1]
└─> method B [NOT_SUPPORTED]
    └─>T1一時停止
        └─>トランザクションなしで実行
            └─>処理終了後T1再開

NEVER

トランザクションが存在してはいけない。存在していたら例外発生。

[呼び出し元にトランザクションなし]
└─> method B [NEVER]
    └─>トランザクションなしで実行

[呼び出し元にトランザクション: T1]
└─> method B [NEVER]
    └─>例外発生(トランザクション存在禁止)

NESTED

現在のトランザクション内でネストしたトランザクションを開始(JDBCのセーブポイントを利用)。

[呼び出し元にトランザクション: T1]
└─> method B [NESTED]
    └─> [T1内部にセーブポイントを設定 (T1-NEST)]
        └─>  内部的な独立性を確保して実行
            └─>  コミット時はセーブポイント単位で戻せる

下記はソースコード例です。

@Service
public class SampleService {
    
    @Transactional(propagation = Propagation.REQUIRED)
    public void methodA() {
        // トランザクション開始
        methodB(); // methodBも REQUIREDであれば同一トランザクション
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void methodB() {
        // 新しいトランザクション開始
    }
}

isolationとは

トランザクションの分離レベル(Isolation Level)は、同時実行制御においてどの程度の整合性を担保するかを指定します。 データベースでは、複数のトランザクションが並行して動くため、読み取り不整合、ダーティリード、リピータブルリード問題などが発生する可能性があります。 主なIsolation Levelは以下の通りです。

  • DEFAULT: 使用するデータベースのデフォルト分離レベルを使用。
  • READ_UNCOMMITTED: 最低レベル。コミットされていない変更(ダーティリード)が読める場合あり。
  • READ_COMMITTED: コミット済みデータのみ読み取る。多くのDBでデフォルト。
  • REPEATABLE_READ: 同一クエリ内での再読み取りで同じ結果が保証される。ファントムリード問題は残る。
  • SERIALIZABLE: 最も厳格。並行実行は直列実行と同等の結果になる。

データベースの分離レベル確認方法(MySQL例): 以下のコマンドを利用します。

SHOW VARIABLES LIKE 'transaction_isolation';

出力例:

+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+

下記はソースの例です。

@Service
public class HogeService {

    // 仮のリポジトリ
    private final HogeRepository hogeRepository;

    public HogeService(HogeRepository hogeRepository) {
        this.hogeRepository = hogeRepository;
    }

    /**
     * READ_COMMITTED分離レベルの例。
     * 多くのDBでデフォルトとなり、コミット済みデータのみ読み込むことで
     * ダーティリードを防ぐ。
     */
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public Hoge findById(Long id) {
        // コミット済みの状態のみを参照
        return hogeRepository.findById(id).orElse(null);
    }
}

timeoutとは

@Transactionalにはtimeout属性があり、トランザクションがどれだけの時間内に完了しなかった場合にロールバックするかを指定できます。 これにより、長時間ロックやデッドロックなどによる処理遅延からシステム全体を守ることができます。

@Transactional(timeout = 5) // 5秒以内に完了しない場合はロールバック
public void processData() {
    // 長時間かかる処理...
}

rollbackForとは

rollbackFor属性を指定することで、特定の例外が発生した場合にのみロールバックを行うよう制御できます。 通常、RuntimeException系はロールバック対象ですが、Exceptionなど非ランタイム例外もロールバック対象にしたい場合に使用します。

@Transactional(rollbackFor = Exception.class)
public void someMethod() throws Exception {
    // Exception発生時にもロールバック
}

noRollbackForとは

noRollbackFor属性は、逆に特定の例外が発生してもロールバックせずコミットしたい場合に利用します。 標準的な挙動を覆し、特定の例外が発生しても処理を確定させたい場合に有効です。

@Transactional(noRollbackFor = {IllegalArgumentException.class})
public void anotherMethod() {
    // IllegalArgumentException発生時はロールバックしない
}

トランザクションが有効にならないケース

しばしば発生する誤解として、@Transactionalを付与しているのにトランザクションが有効にならないケースがあります。主な要因は以下の通りです。

  • 同クラス内から@Transactionalメソッドを呼び出した場合: Spring AOPによるトランザクション境界はプロキシ経由で行われるため、自己呼び出しではトランザクションが適用されない。
  • @Transactionalがインターフェースではなくクラスレベルに付与されているが、AOPがJDK Dynamic ProxyでなくCGLIBを利用していない等の設定ミス
  • Bean管理外のクラスで@Transactionalアノテーションを使用した場合: Springコンテナ管理下でなければ効果なし。
  • @EnableTransactionManagementの付け忘れや適切なPlatformTransactionManagerの設定ミス。

トランザクションのログを見る方法の説明

トランザクション関連のログを確認することで、トランザクションがどのタイミングで開始・コミット・ロールバックされたかを追跡できます。 Spring Frameworkの場合は、application.propertiesやapplication.ymlで

logging.level.org.springframework.transaction.interceptor: TRACE
logging.level.org.springframework.transaction.support: DEBUG

のように設定を行い、詳細なログを出力します。

最後

ここまで、@Transactionalの基本から詳細な属性設定、トランザクション管理が意図した通りに動作しないケースまで幅広くカバーしました。 適切なトランザクション管理は、データ整合性やパフォーマンス、信頼性確保のために非常に重要です。ぜひこれらの内容を再確認し、より良いバックエンド実装へ役立ててください。

PR

アソビューではより良いプロダクトを素早く世の中に届けられるよう、様々な挑戦を続けています。 私達と一緒に働くエンジニアを募集していますので、興味のある方はぜひお気軽にエントリーください!

www.asoview.co.jp

speakerdeck.com