Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails APIドキュメント: Active Recordのトランザクション(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。


  • 2020/11/30: 初版公開(77f7b2d
  • 2022/12/07: 更新

Rails APIドキュメント: Active Recordのトランザクション(翻訳)

トランザクションとは、それが1件のアトミックな操作としてすべて成功した場合に限りSQLステートメントが永続化する、保護的なブロックです。古典的な例としては「出金が成功した場合にのみ入金ができる(またはその逆の)2つの口座間での振替」があります。トランザクションはデータベースの一貫性を強制し、プログラムのエラーやデータベースの破損からデータを保護します。つまり、「すべて一括実行される」か「一切実行されない」かのどちらかでなければならないステートメントが複数ある場合は、基本的にトランザクションブロックを使うべきです。

以下の例をご覧ください。

ActiveRecord::Base.transaction do
  david.withdrawal(100)
  mary.deposit(100)
end

このコード例では、withdrawaldepositのどちらも例外をraiseしない場合にのみ、Davidからお金を取り出してMaryに渡します。例外が発生するとROLLBACKを強制的に実行して、データベースをトランザクション開始前の状態に戻します。ただし、このオブジェクトは、トランザクション開始前のステートに戻されたインスタンスデータを「持たない」ことにご注意ください。

1つのトランザクション内に複数の異なるActive Recordクラスがある場合

transactionクラスのメソッドは、何らかのActive Recordクラス上で呼び出されますが、そのトランザクションブロック内部にあるこのオブジェクトは、必ずしもそのクラスのインスタンスである必要はありません。その理由は、トランザクションはモデル単位ではなく「データベースコネクション単位」だからです。

以下の例で言うと、balanceレコードは、transactionAccountクラスで呼び出された場合であってもトランザクショナルにsaveされます。

Account.transaction do
  balance.save!
  account.save!
end

transactionメソッドは、モデルのインスタンスメソッドとしても利用できます。たとえば以下のようにも書けます。

balance.transaction do
  balance.save!
  account.save!
end

Transactionsは複数のデータベースコネクションに分散されない

ひとつのトランザクションの操作は、ひとつのデータベースコネクション上で行われます。クラス固有のデータベースが複数ある場合、トランザクションはそれらのデータベース間でのやりとりを保護しません。これを回避する方法のひとつは、改変するモデルのクラスごとにトランザクションを開始することです。

Student.transaction do
  Course.transaction do
    course.enroll(student)
    student.units += course.units
  end
end

これは解決方法としては今ひとつですが、完全に分散した(複数の)トランザクションがActive Recordのスコープを越えるようになります。

savedestroyは自動的にトランザクションでラップされる

#save#destroyは、どちらもひとつのトランザクション内にラップされ、バリデーションやコールバックで行うあらゆる操作はこのトランザクションの保護下に置かれます。これによって、トランザクションが依存する値をチェックするためのバリデーションを使うことも、after_*コールバックで例外をraiseしてロールバックすることもできます。

それにより、データベースの変更は「操作が完了するまで」そのデータベースコネクションの外部からは見えなくなります。たとえば、ある検索エンジンのインデックスをafter_saveコールバック内で更新しようとする場合、このインデクサは更新済みレコードを参照しません。唯一の例外はafter_commitコールバックで、更新がひとたびコミットされればトリガーされます。詳しくは以下をご覧ください。

Exceptionハンドリングとロールバック

もうひとつ忘れてはならないのが、あるトランザクションブロック内で発生した例外は(ROLLBACKがトリガーされた後で)伝搬することです。すなわち、これらの例外はアプリケーションコード内でキャッチできるようにしておくべきです。

ひとつの例外はActiveRecord::Rollbackです。これはraiseの時点でROLLBACKをトリガーしますが、そのトランザクションブロックによって再度raiseされることはありません。

警告: ActiveRecord::StatementInvalid例外をトランザクションブロック内部でキャッチしてはいけません。ActiveRecord::StatementInvalid例外は、エラーがデータベースレベルで発生したことを表します(一意性制約に違反した場合など)。データベースによっては、ひとつのトランザクション内部でのデータベースエラーによってそのトランザクション全体が利用不能になり、最初からやり直すまで利用できなくなるものがあります(PostgreSQLなど)。この問題を説明するためのコード例を以下に示します。

# Numberモデルに`i`というuniqueカラムがあるとする
Number.transaction do
  Number.create(i: 0)
  begin
  # unique制約エラーをraiseする...
    Number.create(i: 0)
  rescue ActiveRecord::StatementInvalid
  # (ここは無視する)
  end

  # PostgreSQLではここでトランザクションが利用不能になる。
  # 以下のステートメントはunique制約に違反しなくなったとしても
  # PostgreSQLエラーになる。
  Number.create(i: 1)
  # => "PG::Error: ERROR:  current transaction is aborted, commands
  #     ignored until end of transaction block"
end

ActiveRecord::StatementInvalidが発生したら、トランザクション全体をやり直すべきです。

ネステッドトランザクション

transactionの呼び出しはネストできます。デフォルトでは、ネステッドトランザクション(nested transaction)のブロック内にあるデータベースステートメントはすべて「親トランザクションの一部」になります。たとえば、以下の振る舞いに驚くかもしれません。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

上のコードは"Kotori"と"Nemu"を両方とも作成します。その理由は、ネストしたブロック内では ActiveRecord::Rollback例外がROLLBACKを発行しないからです。これらの例外はトランザクションブロック内でキャプチャされるので、親ブロックからは例外が見えず、実際のトランザクションがコミットされます。

ネステッドトランザクションでROLLBACKされるようにするために、実際のサブトランザクションにrequires_new: trueを渡す方法が考えられます。そして何かが失敗すると、データベースはサブトランザクションの冒頭までロールバックし、親トランザクションはロールバックしません。これを上述のコード例に追加すると以下のようになります。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction(requires_new: true) do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

今度は"Kotori"だけが作成されます。この方法はMySQLとPostgreSQLで動作し、SQLite3 3.6.8以上でもサポートされています。

多くのデータベースは、「真の」ネステッドトランザクションをサポートしていません。本ドキュメント執筆時点では、真のネステッドトランザクションをサポートしていることを私たちが把握できているのはMicrosoft SQL Serverだけです。このため、Active RecordではMySQLやPostgreSQLのsavepointを用いてネステッドトランザクションをエミュレートしています。savepointについて詳しくは以下をご覧ください。

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.3.4 SAVEPOINT、ROLLBACK TO SAVEPOINT および RELEASE SAVEPOINT ステートメント

コールバック

トランザクションのコミットやロールバックに関連するコールバックは、after_commitafter_rollbackの2種類です。

after_commitコールバックは、あるトランザクション内でレコードがsaveまたはdestroyされると、そのトランザクションがコミットされた直後に呼び出されます。after_rollbackコールバックは、あるトランザクション内でレコードがsaveまたはdestroyされると、そのトランザクションまたはsavepointがロールバックされた直後に呼び出されます

これらのコールバックは、データベースが永続的なステートにある場合にのみ実行されることが保証されるので、他のシステムとやりとりするうえで有用です。たとえばafter_commitは、キャッシュをクリアするフックをかけるのに適しています(トランザクション内部でキャッシュをクリアすれば、データベースが更新される前にキャッシュの再生成をトリガーできるようになる)。

注意事項

MySQLでは、savepointを用いてエミュレートされるDDL(Data Definition Language: データ定義言語)をネステッドトランザクションブロック内で使いません。したがって、こうしたブロック内部でCREATE TABLEのようなステートメントを実行してはいけません。その理由は、MySQLがDDL操作の実行時にすべてのsavepointを自動的に解放してしまうためです。transactionが完了したときに以前作成したsavepointを解放しようとすると、savepointが自動的に解放済みになっているためデータベースエラーが発生します。以下はこの問題を説明するためのコード例です。

Model.connection.transaction do                           # BEGIN
  Model.connection.transaction(requires_new: true) do     # CREATE SAVEPOINT active_record_1
  Model.connection.create_table(...)                      # active_record_1はここで自動的に解放される
  end                                                     # RELEASE SAVEPOINT active_record_1
                                                          # ブブー!データベースエラーです!
end

TRUNCATEもMySQLのDDLステートメントのひとつでもある点にご注意ください。

関連記事

Rails 5.1〜7.0: ‘form_with’ APIドキュメント(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。