logo
Published on

ActiveRecordのトランザクションについて考えてみる

Authors

トランザクションとは

まず、トランザクションとは何かを考えてみます。データベース操作において、複数の処理を「1つの単位」として扱いたいケースがあります。例えば、銀行の送金処理では、A口座から1万円を引き出し、B口座に1万円を入金するという2つの操作は、片方だけ成功してしまうと、お金が消えたり二重に発生したりしてしまう問題が発生してしまいます。

このような「複数の操作を不可分な一つの単位として扱う」ための仕組みがトランザクションです。トランザクションは4つの重要な特性を持っており、これをACID特性と呼びます。

  • Atomicity(原子性): 全て成功するか、全て失敗するかを保証する
  • Consistency(一貫性): データベースの整合性制約が常に保たれることを保証する
  • Isolation(独立性): 複数のトランザクションが同時に実行されても、お互いに干渉しないことを保証する
  • Durability(永続性): 一度コミットされたデータは永続的に保存されることを保証する

ActiveRecordでのトランザクションの基本的な使い方

ActiveRecord::Base.transaction do
  # ここに複数のデータベース操作を書く
  user.update!(name: "新しい名前")
  order.update!(status: "completed")
  # 例外が発生しなければ、ここまでの全ての変更がコミットされる
end

ここで重要なのは、ブロック内で例外が発生すると、そのトランザクション内で行われた全ての変更が自動的にロールバック(取り消し)されるという点です。これがトランザクションの「原子性」を実現する仕組みです。


【例外】
プログラムが正常に実行できない状況を知らせるためのしくみ。プログラムは通常、上から下へ順番に実行されていきます。しかし、時には想定しない問題が発生し、正常に処理を続けられないケースがあります。その場合に、プログラムには2つの選択肢があります。
1つは「そのまま処理を続けて、おかしな結果を返す」という選択肢です。しかし、これは危険であり、データの整合性が失われてしまう可能性があります。
もう一つの選択肢は、「例外を発生させて、施錠なフローを中断し、問題を明確に伝える」ということです。これが例外の本質的な役割です。

逆に言えば、例外が発生しない限りロールバックは起きません。例えば、saveメソッドがfalseを返した場合は例外が発生しないため、ロールバックされずにトランザクションはコミットされてしまいます。そのため、トランザクション内では明示的に例外を発生させるsave!やupdate!メソッドを使用するべきです。


トランザクションのスコープと適用範囲

トランザクションはどのモデルクラスに対して呼び出しても同じ結果になります。つまり、User.transactionでもOrder.transactionでもActiveRecord::Base.transactionでも、全て同じデータベース接続に対するトランザクションとして機能します。
ただし、複数のデータベースを使用している場合、それぞれのデータベース接続ごとに別々のトランザクションが必要になるという点に注意が必要です。

# 単一データベースの場合、どれも同じ
User.transaction do
  user.save!
  order.save!
end

# 複数データベースの場合は明示的に指定
User.transaction do
  user.save!

  LogEntry.transaction do
    log.save!
  end

ネストしたトランザクションとセーブポイント

トランザクションはネストさせることができます。

User.transaction do
  user.save!

  begin
    Order.transaction do
      order.save!
      raise "エラー発生" # 内側のトランザクションだけロールバック
    end
  rescue => e
    # エラーをキャッチ
  end

  # ここに到達したら、userの変更はコミットされる
end

この例では、内側のトランザクションでエラーが発生しても、外側のトランザクションには影響がありません。これはセーブポイントという機能を使って、トランザクション内の部分的なロールバックを実現しているためです。

しかし、requires_newオプションを使用すると、本当に新しいトランザクションが開始され、内側のトランザクションは完全に独立して動作します。

User.transaction do
  user.save!

  Order.transaction(requires_new: true) do
    order.save!
    # ここで例外が発生しても、userの変更に影響しない
  end

トランザクションを使用する時に気をつけること

トランザクションブロック内では例外処理を慎重に行う

トランザクションブロック内でrescueを使用して例外を握りつぶしてしまうと、エラーが発生したにも関わらずトランザクションがコミットされてしまいます。

# 悪い例
User.transaction do
  begin
    user.save!
  rescue => e
    # エラーを握りつぶす
    logger.error(e)
  end
end

# 良い例
User.transaction do
  user.save!
  # 例外は上位に伝達させ、トランザクションをロールバックさせる
end

この例では、なぜエラーが握りつぶされてしまうかというと、transactionメソッドは「ブロック内の処理が成功したか失敗したかを判断して、コミットするかロールバックするかを決める」ためです。

transactionメソッドが失敗したかどうかを判断する基準は、ブロック内で例外が発生したかどうかです。そのため、「悪い例」では、user.save!が失敗すると例外が発生します。
ここまではOKですが、次にrescueでその例外が捕まえられます。そして、rescueブロック内ではlogger.error(e)でエラーログが出力されます。このようにしてしまうと、rescueブロックが終了した時点で、begin-endブロック全体が正常に終了したとみなされてしまいます。
つまり、transactionメソッドから見れば「ブロック内の処理は成功した」と判断されてしまい、トランザクションがコミットされてしまいます。

例外を上位に伝達させるとは?

プログラムは関数やメソッドが入れ子状に呼び出されながら実行されます。

# レベル1: コントローラー
def create
  UserRegistrationService.register(params)
end

# レベル2: サービス
class UserRegistrationService
  def self.register(params)
    User.transaction do
      create_user_with_profile(params)
    end
  end

  def self.create_user_with_profile(params)
    # レベル3: トランザクションブロック内の処理
    user = User.create!(email: params[:email])
    Profile.create!(user: user, name: params[:name])
  end
end

この例では、createメソッド(レベル1)がregisterメソッド(レベル2)を呼び、registerメソッドがcreate_user_with_profileメソッド(レベル3)を呼んでいます。これは、階層構造になっていて、下の階層で問題が発生したら、呼び出し元である上の階層に戻っていく必要があります。

◼︎ 例外の伝達の仕組み

例外が発生すると、Rubyはその例外を「捕まえてくれる場所」を探し始めます。この探索は、例外が発生した場所から始まって、メソッドの呼び出しの階層を遡っていきます。これが「上位に伝達する」という意味です。

# レベル1
def level1
  puts "レベル1: 処理開始"
  level2
  puts "レベル1: この行には到達しない"
rescue => e
  puts "レベル1: 例外を捕まえました - #{e.message}"
end

# レベル2
def level2
  puts "レベル2: 処理開始"
  level3
  puts "レベル2: この行には到達しない"
end

def level3
  puts "レベル3: 処理開始"
  raise "レベル3でエラー発生!"
  puts "レベル3: この行には到達しない"
end

この例では、level3メソッドで例外が発生しています。例外が発生すると、まずlevel3の中でrescueをさがしますが、見つかりません。次に呼び出し元であるlevel2に戻って探しますが、ここにもrescueがありません。さらに呼び出し元のlevel1に戻ると、ようやくrescueが見つかるので、そこで例外が処理されます。

この「level3 → level2 → level1」という流れが、例外の伝達の仕組みです。

  • begin : 例外が発生する可能性のある領域を定義する。ここから例外処理の監視をはじめますよ〜みたいな宣言です。
  • rescue : 発生した例外を捕まえて処理する。
  • raise : 例外を明示的に発生させる。
  • retry : 処理を最初からやり直す。
  • ensure : 必ず実行される後始末の処理

トランザクション内では時間のかかる処理は避ける

トランザクションは、開始から終了までデータベースのロックを保持するため、長時間実行されるとデータベース全体のパフォーマンスに影響を与えます。特に、外部APIへのリクエストやファイルI/O、メール送信などはトランザクション外で行うべきです。

# 悪い例
User.transaction do
  user.save!
  UserMailer.welcome_email(user).deliver_now  # メール送信は時間がかかる
  SomeApi.notify(user)  # 外部API呼び出しも時間がかかる
end

# 良い例
User.transaction do
  user.save!
end
# トランザクション外で副作用のある処理を実行
UserMailer.welcome_email(user).deliver_later
SomeApi.notify(user)

また、after_commitコールバックとafter_rollbackコールバックを活用することで、トランザクションの結果に応じた処理を安全に実行することができます。これらのコールバックはトランザクションが確定した後に実行されるため、副作用のある処理を書くのに適しています。

class User < ApplicationRecord
  after_commit :send_welcome_email, on: :create
  after_rollback :log_failure

  private

  def send_welcome_email
    # トランザクションが成功した後に実行される
    UserMailer.welcome_email(self).deliver_later
  end

  def log_failure
    # トランザクションが失敗した時に実行される
    Rails.logger.error("User creation failed")
  end
end

デッドロックとその対策

複数のトランザクションが同時に実行される環境では、デッドロックが発生する可能性があります。デッドロックとは、2つ以上のトランザクションがお互いに相手を保持しているロックを待ち合ってしまい、永遠に進まなくなることです。

デッドロックを避けるには、常に同じ順序でレコードをロックするようにします。例えば、複数のレコードを更新する場合は、IDの昇順でロックするなどのルールを設定すると良いでしょう。

# デッドロックのリスクがある例
User.transaction do
  user1 = User.lock.find(1)
  user2 = User.lock.find(2)
  # 別のトランザクションが逆順でロックを取得しようとするとデッドロック
end

# デッドロックを避ける例
User.transaction do
  users = User.where(id: [1, 2]).order(:id).lock
  # 常に同じ順序でロックを取得
end

また、デッドロックが発生した場合は、例外が発生するので、それを検知してリトライする仕組みを実装することも有効です。

まとめ

トランザクションとは、複数の操作を全て成功させるか、全て失敗させるかを保証するものです。使用する際には以下の点に注意する必要がある。

まず、rescueで例外を握りつぶさないこと。もしrescue内で処理を行う場合は、必ずraiseで例外を再度投げてトランザクションに失敗を伝える必要があります。

次に、トランザクション実行中はデータベースロックが発生するため、外部API呼び出しやメール送信などの長時間かかる処理は行わないこと。

最後に、複数のトランザクションが同時に実行される環境ではデッドロックが発生する可能性があるため、常にID順でレコードを処理するなどの一貫したルールを設定することです。