Zenn
🍃

Amazon Aurora DSQL で必要になるリトライ処理は Spring Retry に任せてみたい

に公開

Amazon Aurora DSQL とは

詳しくは公式ページを見てくださいませ。
https://aws.amazon.com/jp/rds/aurora/dsql/

凄く大雑把に簡単に言うと、こんな感じで複数のリージョンにまたがったクラスタを構成可能で、アプリケーションはいつでも好きな方のリージョンにアクセスして読み書きしても大丈夫!というRDBです。

この構成なら可用性は99.999%

Springframework で使ってみる

Aurora DSQLは、postgreSQL互換で、普通に既存のJDBC Driverで接続することも可能です。なので、Springframeworkから使うときだって、DataSourceのURLにエンドポイントのドメイン名を書くだけで使えそうなのですが、、、実際はちょっとだけ作りこみが必要です。

application.yml
spring:
  datasource:
    url: jdbc:postgresql://xxxxxxxxxxxxxxxxxxxxxxxxxx.dsql.us-east-1.on.aws/postgres?ssl=required
    username: admin

でも、すでにユーザーガイドに必要な作業は記載済みなので楽勝です。
https://docs.aws.amazon.com/aurora-dsql/latest/userguide/SECTION_program-with-java-apps.html

そして、アプリケーションのロジックはいつも通りに書けばOKです。

SampleRepository.java
    @Transactional
    public Integer increment(final UUID key) {

        final JdbcTemplate sqlClient = new JdbcTemplate(this.dataSource);

        // 更新対象レコードをロックしながら取得
        final Integer currentValue = sqlClient.queryForObject("SELECT value FROM v_table WHERE id = ? FOR UPDATE", Integer.class, key);
        if (currentValue == null) {
            return null;
        }

        // 取得したレコードの値に+1して更新を実行
        final Integer nextValue = currentValue + 1;
        sqlClient.update("UPDATE v_table SET value = ? WHERE id = ?", nextValue, key);

        // 新しい値を返却
        return nextValue;
    }

楽観的同時実行制御(OCC)の罠

ところで、Aurora DSQLは通常のRDBが悲観的同時実行制御(PCC)を採用しているのに対して、楽観的同時実行制御(OCC)を採用しています。
https://aws.amazon.com/jp/blogs/news/concurrency-control-in-amazon-aurora-dsql/

両者を簡単に比較すると以下のようになります。(比較表は PerplexiAI によって生成)

項目 悲観的制御 楽観的制御
基本概念 データの競合が頻繁に発生することを前提とし、トランザクション中にロックをかけて他の操作を防ぐ。 データの競合は稀であると仮定し、ロックを使用せず、競合が発生した場合のみエラー処理や再試行を行う。
ロックのタイミング データ取得時にロックをかけ、トランザクション終了(コミットまたはロールバック)まで保持する。 ロックは使用せず、データ更新時に取得時点と状態が一致しているかを確認する(例: バージョン番号やタイムスタンプ)。
競合時の挙動 他のトランザクションはロックが解除されるまで待機する。 競合が検出された場合、エラーが発生しトランザクションを再試行する必要がある。
適用シナリオ - 同時更新が頻繁に発生する場合
- 長時間のトランザクションが必要な場合
- データ整合性が最優先の場合
- 同時更新が稀な場合
- 短時間で完了するトランザクションの場合
- スケーラビリティやパフォーマンスを重視する場合
実装コスト 高い(ロック管理やデッドロック解消の仕組みが必要)。 低い(バージョン管理やエラー処理の仕組みのみで済む)。
パフォーマンス 低下しやすい(特に競合が少ない場合でもロックによる待機時間が発生する)。 高い(競合が少ない環境では待機時間なしで処理可能)。
ユーザビリティ 高い(競合発生時も自動的に整合性を保つため、ユーザー側で再試行の必要がない)。 低い(競合発生時にはエラー通知や再入力を求められる可能性あり)。
リスク - ロック解放漏れによるリソース占有問題
- デッドロック発生の可能性
- 競合検出後のエラー処理や再試行負荷
- 更新失敗によるユーザー体験の低下

問題となるのは競合時の挙動として示されている「競合が検出された場合、エラーが発生しトランザクションを再試行する必要がある」のケースです。これまでPCCを採用していたRDBであればロック取得が競合した場合でも順次ロックを取得して処理に成功していました。

PCC -> ロック取得は順番です。誰かがコミットに成功したら次の誰かにロックが渡ります。

ところが、OCCでは競合しているSQLのうちのどれか一つがコミットに成功すると、残りのSQLはもうコミットを成功させることが出来なくなります。

OCC -> ロックの順番待ちはありません。誰かがコミットに成功したら残り全員は失敗します。

つまり、ロック取得を伴うRDB操作を実行すると、ロック待機が発生するのではなく、ロック取得失敗のエラーが発生する可能性が高くなっているということです。RDB側でロックがとれるまで待機してくれなくなるので、アプリケーションは必要に応じてリトライするロジックを作りこんであげないとなりません。

ちなみに、早い者勝ち競争に敗れたアプリケーションには、こんな感じの「ロックがとれなかった」という例外が投げつけられてしまいます。例外を受け取って諦めてしまったらそこで処理は終了なので、競合が多数存在する環境下で処理を成功させるには再試行を繰り返すロジックが必要になります。

Caused by: org.springframework.dao.CannotAcquireLockException: JDBC commit; ERROR: change conflicts with another transaction, please retry: (OC000)
        at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:128)
        at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:107)
        at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:116)
        at org.springframework.jdbc.support.JdbcTransactionManager.translateException(JdbcTransactionManager.java:180)
        at org.springframework.jdbc.datasource.DataSourceTransactionManager.doCommit(DataSourceTransactionManager.java:340)
        at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:795)
        at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:758)
        at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:698)
        at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:416)
        at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:727)
        at 以降省略

Spring Retry で楽をする

アプリケーション側でエラーハンドリングして再試行(リトライ)する必要があることが分かったところで、まじめに設計して実装してもいいのですが、そこは便利機能がそろっているSpringframeworkに頼ってしまうの一案です。実はSpring Retryというズバリそのままなライブラリがあります。
https://github.com/spring-projects/spring-retry

まずは、メインクラスにおまじない。

Main.java
@SpringBootApplication
@EnableRetry // 宣言的retryを有効にする
public class Main {
    
    public static void main(final String ...args) {
        new SpringApplicationBuilder(Main.class).run(args);
    }
}

あとはメソッドの先頭にアノテーションを追加するだけ。先ほどのレポジトリのメソッドも簡単にリトライロジック付きのメソッドに移行することが出来ます。

SampleRepository.java
    // 先頭に @Retryable アノテーションを追加するだけ!
    @Retryable(retryFor = CannotAcquireLockException.class, maxAttempts = 16, backoff = @Backoff(delay = 10, multiplier = 1.2, random = true))
    @Transactional
    public Integer increment(final UUID key) {

        final JdbcTemplate sqlClient = new JdbcTemplate(this.dataSource);

        // 更新対象レコードをロックしながら取得
        final Integer currentValue = sqlClient.queryForObject("SELECT value FROM v_table WHERE id = ? FOR UPDATE", Integer.class, key);
        if (currentValue == null) {
            return null;
        }

        // 取得したレコードの値に+1して更新を実行
        final Integer nextValue = currentValue + 1;
        sqlClient.update("UPDATE v_table SET value = ? WHERE id = ?", nextValue, key);

        // 新しい値を返却
        return nextValue;
    }

Backoffの設定は性能面で重要です

設定されている内容は以下の通りです。

  1. CannotAcquireLockExceptionがスローされたらリトライする。
  2. 最大16回試行する。(最初の失敗も回数に含む)
  3. 最初のリトライ間隔は10ミリ秒。徐々に試行間隔を広げていく。(1.2倍)
  4. ただし、広げる幅には揺らぎを持たせる。

最後の2つの項目はBackoffと呼ばれる手法を有効にする設定で、リトライ回数が急激に増加するなどしてシステムに過剰な負荷が発生することを避けるための重要な値となります。余裕があれば値の設計を行いましょう。
https://zenn.dev/akring/articles/6fb4106687f896

まずは試してみよう!

postgreSQL互換をうたいつつも制約や挙動の違いが結構な量で存在していて一筋縄ではいかない雰囲気が漂っている Aurora DSQL ですが、工夫を凝らせば既存システムを可用性99.999%の高みを押し上げてくれる可能性持ったサービスだと思います。この public preview の期間を活かしてよい活用方法を探していきたいですね。

Discussion

ログインするとコメントできます