【Java・SpringBoot】@Transactionalで複数回DBにアクセスした場合の挙動について解説

2024/08/15に公開

はじめに

SpringBootにおいて@Transactional(rollbackFor = Exception.class)を付与したメソッドの複数回DBにアクセスしたときの挙動について解説します。
まず、@Transactional(rollbackFor = Exception.class)を付与すると、メソッド内でExceptionまたはそのサブクラスがスローされた場合、トランザクションはロールバックされます。
では、複数回DBにアクセスした場合の挙動はどうなるのかという疑問があったためこのような記事を書きました。

・JavaあるいはSpringBootを勉強している
・トランザクションの仕組みがまだよくわかっていない
・DBについて学習中
上記のような方はぜひ一度見てください。

検証環境

  • Java17
  • SpringBoot3.0
    • QueryDSL
  • MySQL8.0

前提として

@Transactional(rollbackFor = Exception.class)を使用した場合の挙動であり、使用しているRDBやトランザクションの状況によっては今回紹介する内容と実行結果が変わる可能性があるのを理解して頂きたいです。

  • 即時実行されるSQL:
    多くのデータベース操作は、メソッド内でそれらが呼び出された時点で即座に実行されます。例えば、JdbcTemplateを使用した直接的なSQLクエリや、EntityManagerのfindやflushの呼び出しは、その場でSQLが実行されます。

  • 遅延実行されるSQL:
    JPAやHibernateなどのORMツールでは、いくつかの操作が遅延される可能性があります。例えば、persistやmergeのような操作は、実際にはトランザクションのフラッシュポイント(通常はトランザクションのコミット時)までSQLが実行されない場合があります。これはパフォーマンス向上やバッチ処理のために行われます。

サンプルコード

まず、下記のコードを見てください。
今回は例として、トランザクションを付与したメソッドで2回DBにアクセスするcreateUserAndFindメソッドを用意しました。
このメソッドは1回目はINSERT処理で2回目は1回目でINSERTしたデータを検索をしています。

UserService.java
import com.example.transactiondemo.entity.User;
import com.example.transactiondemo.repository.UserRepository;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private JPAQueryFactory queryFactory;

    @Transactional(rollbackFor = Exception.class)
    public User createUserAndFind(long id, String name, String email) {
        // 1回目のDB接続: UserエンティティをINSERT
        User user = new User();
        user.setId(id);
        user.setName(name);
        user.setEmail(email);
        userRepository.save(user); // INSERT処理

        // 2回目のDB接続: INSERTしたデータを検索
        QUser qUser = QUser.user;
        User insertedUser = queryFactory.selectFrom(qUser)
                .where(qUser.id.eq(id))
                .fetchOne(); // SELECT処理

        return insertedUser;
    }
}

実際に、createUserAndFindメソッドが正常にDBに保存されたUserを返すか検証します。
createUserAndFindメソッド用にテストコードも今回作成しました。

TestUser.java
import com.example.transactiondemo.entity.User;
import com.example.transactiondemo.repository.UsersService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class TestUser {

    @Autowired
    UserService userService;

    @BeforeEach
    void setup() {
        System.out.println("------------  テスト開始  --------------------");
    }

    @AfterEach
    void finalizeTest() {
        System.out.println("------------  テスト完了  --------------------");
    }

    @Test
    void test_OK() {
        long id = 123;
        String name = "kota";
        String email = "xxx@gmail.com";

        var user = userService.createUserAndFind(id, name, email);

        Assertions.assertEquals(id, user.getId());
        Assertions.assertEquals(name, user.getName());
        Assertions.assertEquals(email, user.getEmail());
        System.out.println("ID:" + user.getId());
        System.out.println("Name:" + user.getName());
        System.out.println("Email:" + user.getEmail());
    }
}

テスト結果(コンソール)
トランザクションを付与していてもDBにINSERTされたデータを取得できているということがわかります。

解説

トランザクションを付与しているのにも関わらず、このような結果となりました。
原因として、トランザクション内で行われた操作(INSERT, UPDATE, DELETE)は、そのトランザクションがコミットされるまで他のトランザクションからは見えません。しかし、同じトランザクション内では、その操作の結果が即座に反映されます。そのため、1回目でINSERTしたデータは、2回目の検索処理で取得可能となります。

トランザクションの概念

トランザクションの開始: @Transactionalが適用されたメソッドが開始されると、トランザクションが開始されます。

SQLの実行
メソッド内でSQLクエリが発行されると、実際にはデータベース内でその操作が実行されます。ただし、その結果はトランザクションが「コミット」されるまで確定しません。

ロールバック
トランザクション内で例外が発生した場合、@Transactional(rollbackFor = Exception.class)の指定により、これまでに実行されたすべてのSQL操作が取り消されます。つまり、ロールバックされます。

コミット
メソッドが正常に終了すると、トランザクションがコミットされ、すべてのデータベース操作が確定されます。

SQLの実行、ロールバック、コミットの仕組み
  • SQLの実行
    SpringのアプリケーションがデータベースにSQLを送信します。この時点で、データベースはそのSQL操作を受け取り、内部的に処理します。、INSERT、UPDATE、DELETEなどの操作が行われます。
    しかし、トランザクション内でSQLが実行されたとしても、その操作は一時的な状態に留まります。データベースは、この操作を保留中の変更として管理します。

  • トランザクション中の保留状態
    SQLの操作が実行された直後、その変更はデータベース内で一時的に保留されます。これは、他のトランザクションからはまだ見えない状態であり、確定されていない仮の変更として扱われます。
    その場で実行されたSQLがデータベースに反映されても、他のトランザクションからはまだ見えません。
    また、トランザクションがコミットされない限り、これらの操作は最終的に確定されません。

  • コミット
    Springのトランザクション管理が正常に終了すると、commit操作が発生します。
    これにより、トランザクション内で行われたすべてのSQL操作がデータベースに正式に反映され、変更が確定します。これがコミットと呼ばれるプロセスです。

  • ロールバック
    トランザクション中に例外が発生すると、トランザクションマネージャが介入して、すべての変更を元に戻すことができます。
    これにより、トランザクション内で行われたすべてのSQL操作が取り消され、データベースは元の状態に戻ります。

また今回、同じトランザクション内で1回目のINSERT操作で追加したデータを、2回目の検索処理で取得できる理由は、「トランザクションの一貫性」と呼ばれるデータベースの特性によるものです。

トランザクションの一貫性とは?

トランザクションは、データベースにおける一連の操作を「一つのまとまり」として扱うため、その中で行われたすべての操作は、他のトランザクションからは一時的に見えなくても、同じトランザクション内では完全に反映されるという特性を持っています。これはデータの矛盾が生じないこと。常にデータベースの整合性が保たれていることを保証するためです。

トランザクションの独立性を含めて解説すると、具体的な動作の流れは以下のようになります。

  1. INSERT操作
    トランザクション内でデータベースに新しいデータを挿入する操作が行われます。この操作はデータベースに仮に反映され、まだコミットされていないため、他のトランザクションからは見えない状態です。

  2. 検索操作
    同じトランザクション内で、そのINSERT操作で追加されたデータを検索します。この検索は同じトランザクション内で行われているため、まだコミットされていない仮のデータであっても検索結果として返されます。

  3. トランザクション全体のコミット
    トランザクションが正常に終了した時点で、INSERT操作が確定(コミット)され、そのデータが他のトランザクションからも見えるようになります。

まとめ

トランザクションの一貫性
データの破損やエラーが起きた場合でもテーブルの整合性を保ち、意図しない実行結果を防ぎます。つまり、データの実行結果が開始状態か終了状態のどちらかになるように保証されていて、部分的にデータが反映されるのを防いでいます。そのため、この一貫性を実現するためにトランザクション内のすべての操作は、トランザクションが終了するまでは外部に公開されないが、トランザクション内部では見える状態となっています。

自己参照
トランザクション内で行われた操作(INSERT、UPDATE、DELETE)は、そのトランザクション内の後続の操作に反映されます。

感想

今回のメソッドを作成して、テストまで行った結果、上記の知見を理解することができました。
トランザクションの一貫性など、この記事を書く前まではほとんど理解していませんでしたが、自分の想定通りの結果になるのか試してみて非常に勉強になりました。

Discussion