0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

@Transactional(readonly = true) を使うべきか? 考えてみました

Posted at

はじめに

私は現在、@Transaction(readonly = true) オプションを使用しておりますが、これが JPA の 2 次キャッシング(dirty checking)を行わず、パフォーマンスの最適化を実現するという点だけを認識しておりました。

しかし、実際にどのように動作するのか、またこれを必ず付与すべきかなど、明確に理解している点が多くなかったため、学習することにいたしました。

Transaction(readOnly = true) とは何でしょうか?

@Transactional(readOnly = true) オプションは、該当トランザクションがデータ変更を伴わず、読み取り専用であることを明示する設定でございます。この設定により、データベースおよび JPA は内部的にそれに合わせた最適化を実施するようになります。

Spring の公式ドキュメントに該当部分が詳細に記載されております。

image.png

"This just serves as a hint for the actual transaction subsystem"

このオプションは、実際のトランザクションシステムに対する ヒント として適用されます。
この内容は org.springframework.transaction.TransactionDefinition にも記載されております。

	/**
	 * Return whether to optimize as a read-only transaction.
	 * <p>The read-only flag applies to any transaction context, whether backed
	 * by an actual resource transaction ({@link #PROPAGATION_REQUIRED}/
	 * {@link #PROPAGATION_REQUIRES_NEW}) or operating non-transactionally at
	 * the resource level ({@link #PROPAGATION_SUPPORTS}). In the latter case,
	 * the flag will only apply to managed resources within the application,
	 * such as a Hibernate {@code Session}.
	 * <p>This just serves as a hint for the actual transaction subsystem;
	 * it will <i>not necessarily</i> cause failure of write access attempts.
	 * A transaction manager which cannot interpret the read-only hint will
	 * <i>not</i> throw an exception when asked for a read-only transaction.
	 * @return {@code true} if the transaction is to be optimized as read-only
	 * ({@code false} by default)
	 * @see org.springframework.transaction.support.TransactionSynchronization#beforeCommit(boolean)
	 * @see org.springframework.transaction.support.TransactionSynchronizationManager#isCurrentTransactionReadOnly()
	 */
	default boolean isReadOnly() {
		return false;
	}

"Doing so does not, however, act as a check that you do not trigger a manipulating query (although some databases reject INSERT and UPDATE statements inside a read-only transaction)."

  • しかし、このように設定したからといって、操作クエリ(例: INSERT, UPDATE など)の実行ができなくなる検証機能が作動するわけではございません
  • (一部のデータベースでは、読み取り専用トランザクション内で INSERT や UPDATE 文を拒否する場合がございます。)

実際に使用するデータベースや JDBC ドライバーによって実装が異なるため、必ずしも同一の動作となるとは限りません。

"The readOnly flag is instead propagated as a hint to the underlying JDBC driver for performance optimizations."

  • 代わりに、readOnly フラグは性能最適化のため、下位の JDBC ドライバーにヒントとして渡されます

@Transaction(readOnly = true) を通じてどのような最適化が行われるのか?

1. データベースレベルでの最適化

  • MySQL 5.6 以降では、読み取り専用トランザクションに対して次のような最適化が提供されます

トランザクション ID (TRX_ID) 生成の省略

  • 一般的なトランザクションでは、固有のトランザクション ID が割り当てられ、データベースの MVCC (Multi-Version Concurrency Control) システムで使用されます
  • 読み取り専用トランザクションではデータの変更がないため、このプロセスを省略することで最適化が図られます
SET TRANSACTION READ ONLY;

内部データ構造の簡素化

  • 書き込み作業に必要なロックや変更検知(Dirty Checking)などの複雑な処理を省略できるため、内部データ構造のサイズが縮小され、システム資源の使用が最適化されます

ノンロック読み取り作業の効率性向上

  • 単一の SELECT 文などにおいて、不要なロック関連のコストが削減されることで、応答速度と処理量が改善されます

2. JPA/Hibernate レベルでの最適化

Dirty Checking の無効化

以前、Spring 公式ドキュメントに引き続き記載されていた内容でございます

"Furthermore, Spring performs some optimizations on the underlying JPA provider. For example, when used with Hibernate, the flush mode is set to NEVER when you configure a transaction as readOnly, which causes Hibernate to skip dirty checks (a noticeable improvement on large object trees)."

  • さらに、Spring は内部の JPA プロバイダに対して追加的な最適化を実施いたします。
  • 例えば、Hibernate を使用する場合、トランザクションを読み取り専用に構成すると、flush mode が NEVER に設定され、Hibernate が不要な変更検知(Dirty Checking)をスキップするため、特に大規模なオブジェクトツリーにおいてパフォーマンスの向上をもたらします

@Transaction(readonly = true) を使用すると flush mode が NEVER に設定され、変更検知(Dirty Checking)を省略することで、パフォーマンス上の利点がございます

@Transaction(readonly = true) はデータの修正防止機能ではございません

以前、Spring 公式ドキュメントに記載されていた内容でございます

"Doing so does not, however, act as a check that you do not trigger a manipulating query (although some databases reject INSERT and UPDATE statements inside a read-only transaction)."

  • しかし、このように設定したからといって、操作クエリ(例: INSERT, UPDATE など)の実行ができなくなる検証機能が作動するわけではございません。(一部のデータベースでは、読み取り専用トランザクション内で INSERT や UPDATE 文を拒否する場合がございます。)

  • readOnly = true は基本的にデータの修正を防止するための検証機能ではございません

  • 一部のデータベースでは読み取り専用トランザクション内で INSERT や UPDATE 文を拒否する場合がございますが、これはデータベースの実装によって異なります

  • Spring 自体は、このオプションが設定されたからといって、修正クエリを自動的にブロックすることはいたしません

jdbcではどのように設定するのでしょうか?

image.png

  • 基本的には、Connection.setReadOnly(true) を通じてヒントとして渡されますが

image.png

  • 次のように明示的に使用する場合、実際のデータベースコネクションに対して SET TRANSACTION READ ONLY というコマンドを実行して強制する方法も存在いたします
  • 詳細な内容は、こちらをご参照ください(韓国語ですが、整理がよくされていると考えられます)

jpaでは readOnly はどのように伝達されるのでしょうか?

image.png

  • jpaは JpaTransactionManager を通じて readOnly フラグを伝達いたします
    image.png

Transaction(readonly = true) を使用すると得られる利点

私が考える利点は次の通りでございます

  1. パフォーマンスの最適化

    • JPA の永続性コンテキストにおける変更検知(Dirty Checking)の無効化
    • DB のトランザクション ID の生成省略、内部データ構造の簡素化など、パフォーマンス的に最適化されます
  2. 意図の明示

     @Transactional(readOnly = true)
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }
  • 上記のようなコードが存在する場合、@Transactional(readOnly = true) が付与されることで、そのメソッドの目的がデータの参照であることを明示的に表現できます
  • すなわち、コードの可読性が向上し、保守性の改善が期待できます

Transaction(readonly = true) を使用する際に得られる欠点

楽観的ロック(Optimistic Lock)との互換性の問題

@Transactional(readOnly = true) を使用する際に発生し得る重要な問題点のひとつは、JPA の楽観的ロック(Optimistic Lock)メカニズムとの互換性です。

@Entity
public class Student {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Department department;
    
    @Version
    private Long version;

JPAにおける楽観的ロックは、@Version を用いてエンティティが修正される際に該当のバージョンフィールドを増加させる方式で動作いたしますが、readOnly = true オプションが有効な場合、

永続性コンテキストの flush mode が NEVER に設定され、変更検知が無効化されます。そのため、エンティティに変更があった場合でもバージョンフィールドが更新されません。

これにより、楽観的ロックのメカニズムが正しく機能せず、データの整合性に問題を引き起こす可能性がございます。

しかし、楽観的ロックは問題にならないと考えます

  1. 読み取り専用トランザクションでは楽観的ロックは必要ありません
    読み取り専用トランザクションでは本質的にデータの修正が発生いたしません。楽観的ロックは、データ修正時にバージョンを確認するメカニズムでございますので、読み取りのみを行うトランザクションにおいては不要でございます

  2. トラフィックの観点から

    • 低トラフィック環境では楽観的ロックが問題とならない場合がございます。
    • 高トラフィック環境では、単に楽観的ロックに依存するのではなく、より効率的なアーキテクチャ(読み取り/書き込み分離(CQRS)、イベントソーシング、補償トランザクションなど)を適用する方が望ましいと考えられます。むしろ、データベースロックによる性能低下の方が大きくなる可能性がございます
  3. 実用的な観点
    多くのアプリケーションにおいて、読み取り操作は書き込み操作よりもはるかに頻繁に発生いたします。readOnly=true 設定による性能向上の利点が、楽観的ロックとの潜在的な互換性問題よりもはるかに重要である場合が多いと考えられます

したがって、readOnly=true と楽観的ロックの互換性は、@Transaction(readonly = true) を検討する際に特に考慮すべき問題にはならないと考えられます。

性能問題

私は性能面での利点しかないと思っていたのですが、最近、逆に欠点に関する記事も目にいた次第でございます。

一度、クエリが追加で発行されるかどうかをテストしてみたいと思います。

@RestController
@RequiredArgsConstructor
public class TestController {
    
    private final StudentRepository studentRepository;

    @GetMapping("/students")
    @Transactional(readOnly = true)
    public List<StudentDto> test() {
        List<Student> students = studentRepository.findAll();

        return students.stream()
                .map(student -> new StudentDto(
                        student.getId(),
                        student.getName(),
                        student.getDepartment().getName()
                ))
                .toList();
    }
    public record StudentDto(Long id, String name, String department) {

    }
}

次のように簡単なコードを作成し、postmanで呼び出してみました

MySql general query log 有効化方法

  • rootアカウントで接続した後、以下のクエリを実行します
SET GLOBAL general_log = 'ON';
  • 正しく反映されているかどうかを確認いたします
SHOW VARIABLES LIKE '%general_log%';

image.png

결과

image.png

다음과 같이 쿼리가 발생했습니다

SET SESSION TRANSACTION READ ONLY
SET autocommit=0
select s1_0.id,s1_0.department_id,s1_0.name from students s1_0
select d1_0.id,d1_0.name from departments d1_0 where d1_0.id=1
COMMIT
SET autocommit=1 
SET SESSION TRANSACTION READ WRITE

結果

image.png

以下のようにクエリが発生いたしました。

実際のクエリは、lazy loading のため合計で2個発生したのですが、

合計で5個が追加で実行されております。

  1. セッションを読み取り専用に設定 (SET SESSION TRANSACTION READ ONLY)
  2. 自動コミットの無効化 (SET autocommit=0)
  3. 実際の参照クエリを2個実行
  4. トランザクションのコミット (COMMIT)
  5. 自動コミットの再有効化 (SET autocommit=1)
  6. セッションをデフォルト状態(読み書き)に復元 (SET SESSION TRANSACTION READ WRITE)

これらの追加クエリは、明らかにオーバーヘッドを発生させており、特に多数のクエリが実行されるアプリケーションでは、性能低下の原因となり得ます。

下記のカカオペイのブログ記事に、実際のテスト結果が記載されております(韓国語)。

自動コミットとは?(autocommit)

  • データベースで各 SQL コマンドが実行されるとすぐに、即座に永続的に保存されるモードでございます
  • 自動コミットが有効の場合(autocommit=1)、すべての変更が即座に適用され、無効の場合(autocommit=0)は明示的に COMMIT コマンドを実行する必要がございます

なぜ使用するのか?

  • ACID の原子性(Atomicity)を保証するため、これによりデータの整合性を維持するためでございます
  • 複数のユーザーが同時にデータにアクセスする際に、同時性を制御して一貫性を維持するためでございます

では、どうすればよいのでしょうか?

spring:
  jpa:
    properties:
      hibernate:
        connection:
          provider_disables_autocommit: true
          handling_mode: DELAYED_ACQUISITION_AND_RELEASE_AFTER_STATEMENT
  • Hibernate がデータベース接続の autoCommit 状態を変更しないようにいたします
  • データベース接続を、実際に SQL が実行されるまで獲得しないようにいたします
  • SQL 文が実行された後、即座に接続を解放するようにいたします

image.png

次のように、必要なクエリのみが正確に発行されたことが確認できます。

"select d1_0.id,d1_0.name from departments d1_0 where d1_0.id=1"
"select s1_0.id,s1_0.department_id,s1_0.name from students s1_0"
  • この部分(Hibernateチューニング)は次の記事で取り扱います

私の考察まとめ

すべての技術的な決定と同様に、@Transactional(readOnly = true) の使用にも明確なトレードオフが存在いたします。

私はまだ大規模なトラフィックを経験していないため、直接の性能比較や大規模なチームでの協業は行っておりませんが、私自身の結論としては、各選択肢の長所と短所を把握し、有意義な指標、性能、妥当な理由、そしてチームコンベンションを考慮した上で、そのチームに最も適した最良の選択肢を見出すことが正解ではないかと考えております。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?