メインコンテンツへスキップ
  1. Posts/

[Spring] アプリケーションの原子性とトランザクションの原子性は異なる(Feat. @Transactional)

NineKoo9
著者
NineKoo9
目次

過去のプロジェクトを進める際、会員登録とログイン処理のためにFirebaseを利用したことがあります。

Androidアプリで認証が完了すると、バックエンドはログインと会員登録を簡単に処理できます。この過程でSpringのInterceptorとArgumentResolverを利用して認証を処理しました。

実装要件
#

バックエンドでログインや会員登録APIを作る必要はありませんでした。

フロントエンドでFirebaseを利用してメールまたはGoogleログインで認証を行い、その結果として得たidTokenのみをAuthorizationヘッダーに格納してバックエンドサーバーにリクエストを送ります。

するとサーバーでトークンを抽出後、Firebase SDKを利用して有効なトークンかどうか確認します。トークンが有効であれば、その後DBにリクエストを送って**該当会員が存在すれば会員を返す(ログイン)、なければ会員を作成して返す(自動的に会員登録)**します。

そしてその後ログインしたユーザー情報が必要であれば、ArgumentResolverを通じて注入しました。

ここで核心部分をコードで説明すると

RepositoryのfindメソッドでOptional<会員>オブジェクトを返し、 なければorElseGet()を通じて新しい会員オブジェクトの生成・保存・返却ロジックを実行します。

このロジックが一つの過程として束ねられ(原子化)、自動的に会員登録が行われてログイン処理がされることを望んでいました。

しかし実装途中に会員登録が重複して処理され、同一の会員がDBに重複して保存される問題が発生し、この問題はトランザクション処理をしていないために発生したものと考えました。

しかし、トランザクション処理をしてもなお重複保存の問題は発生しました。 明らかに同じトランザクション内で照会と保存を実行したにもかかわらず、なぜこの過程が原子性を保証されなかったのか、そしてどのような理由からなのかを見ていきましょう。

システム構造 — Interceptorを通じたFirebaseトークン検証
#

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
    @Column
    private String username;
    @Column(nullable = false)
    private String email;
    @Column
    private String uid;
}

Memberエンティティは上記のように構成しました。

uidフィールドはFirebaseがユーザーを識別するために提供するid値です。 プロジェクトのDBでもuidを通じて各メンバーを区別します。

public class FirebaseTokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // ヘッダー値の抽出
        String token = getAuthorizationToken(request);
        // トークン検証
        FirebaseToken decodedToken = decodeToken(token);
        // 検証済みトークンをHttpServletRequestオブジェクトに保存する
        request.setAttribute("decodedToken", decodedToken);
        return true;
    }
    
    // リクエストからAuthorizationヘッダーの値(idToken)を抽出します。
    private static String getAuthorizationToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            throw new IllegalArgumentException("No Token or Invalid Token.");
        }
        return header.split(" ")[1];
    }
    // Firebaseサーバーにトークンを送って検証します。
    private static FirebaseToken decodeToken(String token) {
        FirebaseToken decodedToken;
        try{
            // idTokenを検証する
            decodedToken = FirebaseAuth.getInstance().verifyIdToken(token);
        } catch (FirebaseAuthException e) {
            throw new IllegalArgumentException("Invalid Token.");
        }
        return decodedToken; // 検証済みトークンの結果を返す
    }
}

InterceptorでFirebase SDKを利用してAuthorizationヘッダーにあるトークンの有効性を検証します。

検証済みのFirebaseToken型のdecodedTokenにはユーザーの情報が入っており、その値をHttpServletRequestに保存します。 HttpServletRequestに保存したdecodedTokenは、ArgumentResolverで必要なときに取り出して使用するために保存しました。

@RequiredArgsConstructor
public class LoginMemberArgResolver implements HandlerMethodArgumentResolver {
    private final MemberRepository memberRepository;
    // @Loginアノテーションがあれば値を注入する
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginAnnotation && hasMemberType;
    }
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
      HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
      
      // requestオブジェクトからdecodedTokenを取得
      FirebaseToken decodedToken = (FirebaseToken) request.getAttribute("decodedToken");
      
      // AuthServiceを呼び出す
      return authService.joinAndLogin(decodedToken); 
    }
}
@RequiredArgsConstructor
@Service
public class AuthService {
    private final MemberRepository memberRepository;
    public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException {
        String uid = decodedToken.getUid();
        // uidでメンバーを照会し、DBに登録されていないuidであれば、
        // 新しいメンバーを作成して自動会員登録されます。
        return memberRepository.findByUid(uid)
                .orElseGet(() -> memberRepository.save(Member.builder()
                        .username(decodedToken.getName())
                        .email(decodedToken.getEmail())
                        .uid(uid)
                        .build()));
    }
}
// Controller
// パラメーターに@Loginアノテーションがあるので
// HandlerMethodArgumentResolverを使用してMemberを初期化する。
@PostMapping("/post/new")
public Post createPost(@RequestBody Post post, @Login Member member) {
 ...
}

ArgumentResolverは上記のようにコントローラーのパラメーターに@Loginアノテーションがあればログイン済みのメンバーを注入するようにしました。

decodedTokenにはFirebaseが管理するユーザー識別Id(先述のuid)情報があります。

これを利用してプロジェクトのDBでユーザーを照会して返す。もし、該当するユーザーがいなければ新しいMemberエンティティを作成してDBテーブルにメンバーを保存して返します。

つまり、ユーザーがアプリケーションを「初めて」使用するときに会員登録が自動的に行われます。

アプリケーションはすべてのリクエストにログイン済みのメンバーのみが使用できるという要件を持ちます。したがってすべてのAPIリクエストにはAuthorizationヘッダーの値があります。

問題発生 — 同一会員が重複保存される
#

Androidクライアントと接続してアプリケーションを実際に使用する途中、時々ログインが正常にできない問題が発生しました。

なぜそうなのかリクエストのログをすべて確認したとき、上記のようなログが現れました。

(画像は過去のプロジェクトのログなので現在の構成と少し異なります!)

問題の状況は

一人のユーザーが初めてアプリケーションを起動したときに複数のリクエストを同時に発生させた場合」でした。

そしてその結果

DBに会員が存在すればそのまま返し、なければ該当会員エンティティを生成、DB保存後に返す。

という処理が一つの処理として実行されなかったため、データベースには以下のように2つの同じuidを持つ会員が保存されました。

その後ユーザーが新しいリクエストを送ると、MemberRepositoryのfindByUid()メソッドは一人の会員ではなく、2つの会員レコードを照会してログインが正常にできないエラーの状況が発生しました。

解決試み — @Transactional、synchronized
#

以下のコードは上でお見せしたAuthServiceクラスです。

@RequiredArgsConstructor
@Service
public class AuthService {
    private final MemberRepository memberRepository;
    public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException {
        String uid = decodedToken.getUid();
        // memberRepositoryでuidでメンバーを照会し、DBに登録されていないuidであれば、
        // 新しいメンバーを作成して自動会員登録されます。
        return memberRepository.findByUid(uid)
                .orElseGet(() -> memberRepository.save(Member.builder()
                        .username(decodedToken.getName())
                        .email(decodedToken.getEmail())
                        .uid(uid).build()));
    }
}

Springフレームワークは一つのリクエストを一つのスレッドが担当します。 したがって前述のスクリーンショットに示された2つのリクエストは互いに異なるスレッドが処理します。

2つのリクエストがどちらも同じクライアントのリクエストである場合を例として説明します。

第1のスレッドがmemberRepository.findByUid()メソッドを通じてデータを照会している間に他のスレッドもmemberRepository.findByUid()メソッドを実行し、同じuidでfindByUid()を実行することができます。

これを防ぐためには

DBに会員が存在すればそのまま返し、なければ該当会員エンティティを生成、DB保存後に返す。

という過程を一つのトランザクションにまとめ、uid列は会員を一意に識別するためにUNIQUE制約が必要だと判断しました。

それなら@Transactionalアノテーションをつければいいのでは?

@RequiredArgsConstructor
@Service
public class AuthService {
    private final MemberRepository memberRepository;
     
    // @Transactionalを追加!
    @Transactional
    public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException {
        String uid = decodedToken.getUid();
        // uidでメンバーを照会し、DBに登録されていないuidであれば、
        // 新しいメンバーを作成して自動会員登録されます。
        return memberRepository.findByUid(uid)
                .orElseGet(() -> memberRepository.save(Member.builder()
                        .username(decodedToken.getName())
                        .email(decodedToken.getEmail())
                        .uid(uid).build()));
    }
}

しかし同時実行をテストしてみると、依然として同じ問題は解決されませんでした。

トランザクションが先に原子的に処理されることを確認するためにUNIQUE制約はまだ追加していませんでした。UNIQUE制約がなくても原子的に処理されれば重複して保存されないだろうと考えました。

トランザクションが動作しているのは明らかに確認できましたが、問題は解決されませんでした。

あ!トランザクションが原子性を提供するが、これは一つのスレッドがfindByUid()を呼び出すとき、他のスレッドがfindByUid()の呼び出しを防ぐのではなかった!

という考えも生まれ、synchronizedキーワードも追加してみました。

そしてここからログを出力しながら詳しく見ていくために、findByUid() 〜 orElseGet()メソッドを以下のようにOptionalに展開して記述します。

@RequiredArgsConstructor
@Service
public class AuthService {
    private final MemberRepository memberRepository;
    
    // synchronizedを適用
    @Transactional
    public synchronized Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException {
        String uid = decodedToken.getUid();
        // uidでメンバーを照会し、DBに登録されていないuidであれば、
        // 新しいメンバーを作成して自動会員登録されます。
        Optional<Member> memberOptional = memberRepository.findByUid(uid);
        if (memberOptional.isPresent()) {
            log.info("既存の会員です。");
            return memberOptional.get();
        }
        log.info("新しい会員です。");
        Member member = Member.builder()
                .username(decodedToken.getName())
                .email(decodedToken.getEmail())
                .uid(uid).build();
        memberRepository.save(member);
        return member;
    }
}

synchronizedを追加しても依然として解決されない
#

  1. MySQLはレコードベースでロック(lock)をかけるが、レコードがなければロック自体をかけられないのではないか?簡単に言えば、最初のfindByUid()に該当するレコードがないのでトランザクションを開始してもロックをかけたりレコードの変更ログを保存する対象自体がないのではないか?

  2. findByUid()メソッドを呼び出すときにトランザクションが開始すると考えていたが…ログが出力されたのを見ると何かおかしい…それならトランザクションの開始はどこだ?5つのリクエストを同時に送ったとき、トランザクションが同時に開始するのか?それなら…JPAはDBの基本トランザクションのIsolation levelを選択し、私はMySQLを使っているのでRepeatable ReadというIsolation Levelがデフォルトで適用されているはずで…

  3. トランザクションの開始時点がAuthServiceのjoinAndLogin()ではなくAOPプロキシのinvoke()によってトランザクションが実行されるなら…実際のjoinAndLogin()メソッドが実行される前に5つのリクエストがすべて同時に進んでいてsynchronizedキーワードによって実際のオブジェクトのメソッドだけが順次実行されたのか…?

原因分析
#

アプリケーションの原子性とトランザクションの原子性は異なる
#

この言葉はすなわち、Javaアプリケーションコードの原子性を保証するものではありません。

DBに会員が存在すればそのまま返し、なければ該当会員エンティティを生成、DB保存後に返す。

上記のJavaコードのメソッドをsynchronizedキーワードで原子化するのは、DBトランザクションの原子性とは別の過程です。

synchronizedがない場合、以下のような状況が発生します。

MySQLのRepeatable Read Isolation Levelでは、MVCC(Multi Version Concurrency Control)を利用します。このときconsistent read view(スナップショット)はトランザクションが開始(5番)するタイミングではなく、**そのトランザクション内で最初のSELECTが実行されるタイミング(8番のfindByUid())**に生成されます。ただし、START TRANSACTION WITH CONSISTENT SNAPSHOT構文を使用するとトランザクションの開始と同時にスナップショットを生成できますが、Springの@Transactionalはこの構文を使用しません。

5つのトランザクションがほぼ同時に開始(5番)し、各トランザクションの最初のSELECTであるfindByUid()(8番)もほぼ同時に実行されます。スナップショットはこの最初のSELECT時点に生成されるため、まだどのトランザクションもINSERTを実行していない状態ですべてのトランザクションが同様に**「該当するレコードが存在しません」**という結果を受け取ることになります。

したがってfindByUid()メソッドの結果は**「該当するレコードが存在しません」**という同じ結果を示すことになります。

MySQLのMVCCは一つのトランザクションが先に照会後に会員オブジェクトを保存してコミットしても、他のトランザクションの結果は本人の最初のSELECT時点を基準として、その前に記録されたデータのスナップショットを参照します。

synchronizedを適用した場合も、MySQLのMVCCとトランザクション隔離レベルによって同じ問題が発生します。

@Transactionalによってトランザクションの開始とコミットはプロキシオブジェクトで実行されますが、synchronizedキーワードはプロキシのメソッドに継承されず、実際のjoinAndLogin()メソッドのみに適用されます。

したがってスレッド1が実際のjoinAndLogin()メソッドのクリティカルセクション(critical section)を抜けると、プロキシオブジェクトでトランザクションをコミットする前に別のスレッドが実際のjoinAndLogin()メソッドに進入できます。

synchronizedを適用

ここで2つのトランザクションのBEGINの順序やsynchronized進入の試み順序は問題発生の有無に影響を与えません。スナップショットはBEGINではなく最初のSELECT時点に生成されるため、T2がT1よりも先にBEGINするか、先にsynchronized進入を試みても結果は同じです。重要なのはT2の最初のSELECTがT1のCOMMITより前に実行されるかどうかです。

トランザクション2のSELECT時点でトランザクション1のINSERTはまだコミットされていないデータです。コミットされていないデータはRepeatable ReadはもちろんのことRead Committedでも見えません。Read Committedは「コミットされたデータを読む」という隔離レベルであり、コミット前のデータを読むものではないからです。

もしそれでもデータが見えることを確認してみたいなら、最も低いトランザクション隔離レベルであるRead Uncommitted Isolation Levelを適用すればよいです。この場合コミットしていなくても他のトランザクションで保存されたデータをすぐに見ることができます。ただしdirty readによってロールバックされたデータを読む危険があるため、適切な解決策ではありません。

またsynchronizedキーワードは単一のJVMインスタンス内でのみ有効です。サーバーを2台以上運用する分散環境ではsynchronizedで同時実行を制御できないため、この方法自体が根本的な解決策にはなりません。

DBロックでは解決できないのか?
#

MySQLのInnoDBは照会時、基本的にロック(Lock)ではなくMVCCでRepeatable Readを処理しますが、以下のようにロックをかけることができます。

SELECT * FROM MEMBER WHERE uid="ASDW12SD3" FOR UPDATE; -- 書き込みロック
SELECT * FROM MEMBER WHERE uid="ASDW12SD3" FOR SHARE;  -- 読み込みロック

したがって現在のトランザクションでロックをかけて照会するとき、他のトランザクションで任意のデータが追加されないようにMySQLのレコードロック、ネクストキーロックなどが適用される可能性があります。InnoDBのロックはインデックスを基準に動作します。uid列にインデックス(またはUNIQUE制約)がない状態でSELECT ... FOR UPDATEを実行すると、意図したuid値だけを精密にロックすることが難しくなります。なお、InnoDBはレコードが存在しなくてもギャップロックを通じてその範囲への挿入をブロックできますが、インデックスがなければテーブル全体にロックがかかるなど意図しない範囲がロックされる可能性があり、実用的ではありません。

解決方法 — UNIQUE制約とリトライロジック
#

実はここまでの過程はすべてが正常に動作している中で発生したことです。ただ、プロジェクトの要件を満たす過程でアプリケーションレベルで特定のロジックを一つの過程として処理しようとしたものの、意図した結果が現れませんでした。そしてその過程を単純に@Transactionalを追加することで解決しようとしたことが問題でした。

少しのテストでアプリケーションレベルで原子性を保証することを確認するには、AuthServiceクラスの@TransactionalアノテーションをSpring AOPが適用されないよう削除して、synchronizedキーワードを使用してアプリケーションコードを原子化すればよいです。

@RequiredArgsConstructor
@Service
public class AuthService {
    private final MemberRepository memberRepository;
    // synchronizedを適用
    public synchronized Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException {
        String uid = decodedToken.getUid();
        // uidでメンバーを照会し、DBに登録されていないuidであれば、
        // 新しいメンバーを作成して自動会員登録されます。
        Optional<Member> memberOptional = memberRepository.findByUid(uid);
        if (memberOptional.isPresent()) {
            log.info("既存の会員です。");
            return memberOptional.get();
        }
        log.info("新しい会員です。");
        Member member = Member.builder()
                .username(decodedToken.getName())
                .email(decodedToken.getEmail())
                .uid(uid).build();
        memberRepository.save(member);
        return member;
    }
}

この場合トランザクションを使わなくてもほとんど正常に動作しますが、予期しない状況でデータの整合性を保証するためにトランザクションを使用することが望ましいです。適切な解決策ではありません。

私の場合、簡単な解決策としてuidで会員を区別するという要件に合わせてデータベース「uid列」に対するUNIQUE制約を追加しました。

そして重複したuid値を持つ会員の保存が発生した場合に例外を投げるように処理しました。その後、例外発生時にリトライする方式で処理し、エラーなしに自動会員登録とログインが正常に動作するようにしました。

追加でUNIQUE制約は照会時にインデックスを活用してより速い性能を発揮できます!

// build.gradleに追加
implementation "org.springframework.retry:spring-retry"
// FirebaseApplication.java
@EnableRetry // ここを追加
@SpringBootApplication
public class FirebaseApplication {}
// AuthService.java
@RequiredArgsConstructor
@Service
public class AuthService {
    private final MemberRepository memberRepository;
    // ここも追加!
    @Retryable(
            retryFor = {DataIntegrityViolationException.class},
            backoff = @Backoff(delay = 1000)
    )    
    @Transactional
    public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException {
        String uid = decodedToken.getUid();
        // uidでメンバーを照会し、DBに登録されていないuidであれば、
        // 新しいメンバーを作成して自動会員登録されます。
        Optional<Member> memberOptional = memberRepository.findByUid(uid);
        if (memberOptional.isPresent()) {
            log.info("既存の会員です。");
            return memberOptional.get();
        }
        log.info("新しい会員です。");
        Member member = Member.builder()
                .username(decodedToken.getName())
                .email(decodedToken.getEmail())
                .uid(uid).build();
        memberRepository.save(member);
        return member;
    }
}

この際に注意すべき点は@Retryable@TransactionalのAOP適用順序です。@Retryable@Transactionalよりも外側でラップしなければ(先に適用されなければ)、リトライするたびに新しいトランザクションが開かれません。順序が逆であれば、すでにロールバックされたトランザクション内でリトライが発生するため意味がありません。@RetryableのデフォルトのorderはOrdered.LOWEST_PRECEDENCEですので、必要に応じて@EnableRetry(order = ...)で順序を明示的に指定する必要があります。

本当に解決したのか?
#

リトライロジックの問題点
#

DataIntegrityViolationExceptionはUNIQUEキー違反の例外だけでなく、他の例外でも発生する可能性があります。

例えば外部キー制約違反、データ型不一致、NOT NULL制約違反などでもDataIntegrityViolationExceptionが発生する可能性があります。

この場合、リトライロジックが継続して実行される可能性があります。

より根本的な問題 — 誤ったプロセス
#

上記の過程はプロセス自体が誤っていました。ArgumentResolverはコントローラーのパラメーターを解析して注入する役割であり、会員作成のようなビジネスロジック(副作用)を実行する場所ではありません。副作用のあるロジックをパラメーター解析の段階に入れたため、すべてのAPI呼び出しのたびに会員登録が試みられるという異常な構造が生まれたのです。

問題が発生している状況で何とかして要件を実装レベルで解決しようとしたため、不必要なリトライロジックまで処理することになりました。

現在のAPIたちは会員を検証する部分とビジネスを実行する部分が一つのAPIで処理されています。したがって検証に失敗すると会員検証をリトライしてビジネスロジックを実行するようになっています。

この部分でプロセスを分離すれば不必要なリトライロジックも必要なかったでしょう。

まず正常なケースとしてプロセスを変えるなら、従来は自動会員登録またはログインAPIが必要ないと考えて作らなかったのですが、今は作る必要があります。

そして自動会員登録またはログインAPIを呼び出し、同時に入ってきたとすればその一方を失敗処理にすればよいのです。

その後ビジネスロジックAPIを呼び出せばいい問題でした。

数ヶ月が経過したプロジェクトで現れた問題ですが、解決のためにトランザクションやDBなどを学んでいく中で遅まきながら根本的な問題点まで発見したとき、あまりにも異常なプロセスだったので、当時きちんと作れなかったことへの悔しさが残りもします。

それでも意義を見出すとすれば、アプリケーションレベルの原子性とトランザクションについてより深く理解する契機を作ってくれた問題でした。

参考資料
#

Java ORM標準JPAプログラミング

Real MySQL

キム・ヨンハンのSpring DB 1、2編

Firebase認証REST API

Spring @Transactional mistakes everyone did