概要
MITライセンスに基づいて翻訳・公開いたします。
- 英語ドキュメント: ActiveRecord::Transactions::ClassMethods
- 原文バージョン: Rails 6.0.3.4
Rails APIドキュメント: Active Recordのトランザクション(翻訳)
トランザクションとは、それが1件のアトミックな操作としてすべて成功した場合に限りSQLステートメントが永続化する、保護的なブロックです。古典的な例としては「出金が成功した場合にのみ入金ができる(またはその逆の)2つの口座間での振替」があります。トランザクションはデータベースの一貫性を強制し、プログラムのエラーやデータベースの破損からデータを保護します。つまり、「すべて一括実行される」か「一切実行されない」かのどちらかでなければならないステートメントが複数ある場合は、基本的にトランザクションブロックを使うべきです。
以下の例をご覧ください。
ActiveRecord::Base.transaction do
david.withdrawal(100)
mary.deposit(100)
end
このコード例では、withdrawal
とdeposit
のどちらも例外をraise
しない場合に、Davidからお金を取り出してMaryに渡します。例外が発生するとROLLBACKを強制的に実行して、データベースをトランザクション開始前の状態に戻します。ただし、このオブジェクトは、トランザクション開始前のステートに戻されたインスタンスデータを「持たない」ことにご注意ください。
1つのトランザクション内に異なるActive Recordクラスがある場合
transaction
クラスのメソッドは、何らかのActive Recordクラス上で呼び出されますが、そのトランザクションブロック内部にあるこのオブジェクトは、必ずしもそのクラスのインスタンスである必要はありません。その理由は、トランザクションはモデル単位ではなく「データベースコネクション単位」だからです。
以下の例で言うと、balance
レコードは、transaction
がAccount
クラスで呼び出された場合であってもトランザクショナルに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のスコープを越えるようになります。
save
とdestroy
は自動的にトランザクションでラップされる
#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 5.6 リファレンスマニュアル :: 13.3.4 SAVEPOINT、ROLLBACK TO SAVEPOINT、および RELEASE SAVEPOINT 構文
コールバック
トランザクションのコミットやロールバックに関連するコールバックは、after_commit
とafter_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 now automatically released
end # RELEASE SAVEPOINT active_record_1
# ブブー!データベースエラーです!
end
TRUNCATEもMySQLのDDLステートメントのひとつである点にご注意ください。