Firebase Authenticationにおける分散トランザクション

はじめに

2024年4月に株式会社プレックスにエンジニアとして新卒入社した佐藤祐飛と申します。現在はサクミルという建設業界向けのSaaSプロダクト開発を行っています。

sakumiru.jp

Firebase Authentication(以下Firebaseと略します)を利用した認証において、ユーザー作成時に分散トランザクションによってデータの整合性を担保する実装をRuby on Railsで行ったのでその知見について共有したいと思います。

firebase.google.com

背景

サクミルにおけるユーザー認証について

サクミルではFirebaseを活用したJWTによるユーザー認証を行なっています。ユーザー認証完了後、FirebaseのユーザーUIDを元にサクミルはDB上にあるユーザーデータを提供し、各ユーザーはサクミルにログインすることができます。

ユーザー作成方法について

サクミル管理画面ではアカウント発行機能としてFirebase上へのユーザー作成とサクミルのDB上へのユーザー作成を同時に行う機能を提供しています。サクミル管理画面のAPIにおけるユーザー作成手順を以下に示します。

ユーザー作成手順

サクミル管理画面のAPIRuby on Railsで書かれているのですが、Firebase公式からはRubySDKが提供されていないので、google-api-ruby-clientというgemを活用して、Firebase上へのユーザー作成を行なっております。

github.com

課題

ユーザーデータの不整合が生じる可能性がある

ユーザーデータがDBとFirebaseという異なる2つのノードに保存されるので、データの不整合が生じる可能性があります。

例えば、Aさんのユーザーデータを作成することを考えます。Firebase上へAさんのユーザーデータを保存することに成功したものの、その後のサクミルのDB上への保存が何らかの原因で失敗した場合、「サクミルDBにはAさんのデータがなく、FirebaseにはAさんのデータがある」という不整合が発生します。

ユーザーデータの不整合が生じる例

この不整合の状態で、再度サクミル管理画面からAさんのアカウント発行を実行するとFirebase上の一意制約によってアカウント発行の処理が失敗してしまいます。

Firebaseのコミット制御やロールバックができない

いわゆるIDaaSであるFirebaseを利用すると、認証サービスの実装コストや監視コストが下がるというメリットがある反面、デメリットとして認証サービスのカスタマイズ性が低下します。

FirebaseはFirebase内部のDBについて、コミットのタイミング制御やロールバックを行う機能を提供していないので、分散トランザクションの代表的手法である2フェーズコミット(2PC)を行うことができません。

サーガパターンによる整合性担保

上記の課題を解決するために、サーガパターンによる分散トランザクション管理を実装しました。

サーガパターンとは

サーガパターンとは、複数のサービスにまたがるビジネスプロセスを管理し、データの整合性を担保する分散トランザクション手法です。サーガパターンは、各サービスのローカルトランザクションのシーケンスであり、ローカルトランザクションはDBを更新して、次のローカルトランザクションをトリガーします。

ロールバックを実行する場合は、各ローカルトランザクションを取り消す操作として補償トランザクションを実行します。

サクミル管理画面 APIの実装

Firebaseへの操作を管理するFirebaseAdminクラスを以下に示します。

工夫したポイントは2点あります。 1点目はFirebaseへの操作を行うメソッド(今回はsign_up_user)内で補償トランザクションをスタックに積んだことです。 2点目は、補償トランザクションの処理をproc インスタンスとしてスタックに保持させ、rollbackメソッドによってロールバックを行えるようにしたことです。

これらの工夫によって、動的にUIDが変化するロールバックの処理を実現することができます。

class FirebaseAdmin
  def initialize
    # HTTP通信のclientを初期化する
    @client = Google::Apis::IdentitytoolkitV3::IdentityToolkitService.new
    
     # 認証
    ...
    # 補償トランザクションの処理を保持するスタック
    @revert_proc_stack = []
  end

  def sign_up_user(email:, password:)
    # Firebaseへユーザー作成のリクエストを送信
    request = Google::Apis::IdentitytoolkitV3::SignupNewUserRequest.new(email:, password:)
    response = @client.signup_new_user(request)

    uid = response.local_id

    # ユーザー削除の処理(補償トランザクション)をスタックにプッシュする
    @revert_proc_stack.push(proc { delete_user(uid:) })

    uid
  end

  def delete_user(uid:)
    request = Google::Apis::IdentitytoolkitV3::DeleteAccountRequest.new(local_id: uid)
    @client.delete_account(request)
  end
  ...

  def rollback
    # スタックが空になるまで補償トランザクションを実行する
    until @revert_proc_stack.empty?
      proc = @revert_proc_stack.pop
      proc.call
    end
  end

  def cleanup
    @revert_proc_stack.clear
  end
end

以下に、アカウント発行を実行した際に叩かれるcreate_user!メソッドを示します。

工夫したポイントはActiveRecordトランザクション内でFirebaseへの操作とDBへの操作を実行し、例外処理でロールバックを行ったことです。例外(FirebaseまたはDBに対する処理の失敗を想定)が発生するとFirebase上のロールバック処理とDBのロールバック処理が実行されることが保証されるため、ユーザーデータの整合性が担保されます。

def create_user!(email:, password:)
  # FirebaseAdminインスタンスを作成
  client = FirebaseAdmin.new

  begin
    User.transaction do
      # Firebase上にユーザーを作成する
      uid = client.sign_up_user(email:, password:)

      # DB上にユーザーを作成する
      user_record = User.create!(..., email:, uid:)
      ...
      save!
    end
  rescue StandardError => e
    # 例外をキャッチし、ロールバックを実行する
    client.rollback
    raise(e)
  ensure
    # スタックのクリーンナップを必ず実行する
    client.cleanup
  end
  ...
end

最後に

プレックスではエンジニアを募集しております。ご興味ある方がいらっしゃれば是非連絡をください! dev.plex.co.jp

また、入社エントリを執筆いたしましたのでご覧いただけるととても嬉しいです!

product.plex.co.jp