Alstrocrack Tech Blog

考えたこと、学んだこと など

Ruby on RailsのActive Recordのtransactionメソッドのネスト時の挙動を深掘る

はじめに

題名の「深掘る」は「ネスト」と掛けています。実際の題名は「Railsのネストしたtransactionにおける requires_new の挙動を検証する」とかの方が近いです。

さて、Ruby on RailsのORマッパーであるActive Recordには、データベースのトランザクションを制御する transaction メソッドがあります。

# 各種RDBMSでtransactionがBEGIN ~ COMMITされる
ActiveRecord::Base.transaction do
    user = User.create!(user_params)
    item = ActiveRecord::Base.transaction do
        Item.create!(item_params)
    end
end

この transaction メソッドをネストさせた際、どのような例外でどのようにロールバックされるのか。よくわかっていなかったので、実際にRailsを動かして検証してみました。

前提: requires_new オプションについて

Railsでネストしたトランザクションを発行する際、重要になるのが requires_new: true オプションです。これが指定されているかどうかで、Active Recordが「既存のトランザクションに合流する」か「SAVEPOINTを作成するか」が切り替わります。

# requires_new: true を渡す例(デフォルトは false)
ActiveRecord::Base.transaction do
    user = User.create!(user_params)
    item = ActiveRecord::Base.transaction(requires_new: true) do
        Item.create!(item_params)
    end
end

techracho.bpsinc.jp

検証環境

検証に使用したバージョンは以下の通りです。


1. 正常にコミットされる場合

まずは、例外が発生しない基本パターンのログを確認します。

requires_new: false (デフォルト)

TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
User Create (0.2ms)  INSERT INTO "users" ...
Item Create (0.0ms)  INSERT INTO "items" ...
TRANSACTION (2.3ms)  COMMIT TRANSACTION

この場合、ネストしたトランザクションは親のトランザクション合流します。発行されるSQLを見ても、BEGINCOMMIT は1回ずつであり、単一のトランザクションとして処理されています。

requires_new: true

TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
User Create (0.2ms)  INSERT INTO "users" ...
TRANSACTION (0.0ms)  SAVEPOINT active_record_1
Item Create (0.1ms)  INSERT INTO "items" ...
TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1
TRANSACTION (0.1ms)  COMMIT TRANSACTION

こちらは SAVEPOINT が発行されています。RDBレベルで「ここまでの状態を保存しておく」という目印を打っている状態です。最終的にエラーがなければ RELEASE SAVEPOINT され、親と一緒にコミットされます。


2. ActiveRecord::Rollback が発生した場合

ここからが本題です。Rails特有の例外である ActiveRecord::Rollback を投げた際の挙動を見ていきます。

requires_new: false (デフォルト)

注意:このパターンではロールバックされません。

ActiveRecord::Base.transaction do
    user = User.create!(user_params)
    ActiveRecord::Base.transaction do
        Item.create!(item_params)
        raise ActiveRecord::Rollback # 子でロールバックを試みる
    end
end
TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
User Create (0.2ms)  INSERT INTO "users" ...
Item Create (0.0ms)  INSERT INTO "items" ...
TRANSACTION (1.7ms)  COMMIT TRANSACTION

全ての変更がコミットされます。 理由は、ネストしたブロックが親トランザクションに吸収されているためです。ActiveRecord::Rollback はそのブロック内では捕捉されますが、親まで例外が伝播しないため、親は何事もなかったかのようにコミットを完了させます。

requires_new: true

TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
User Create (0.2ms)  INSERT INTO "users" ...
TRANSACTION (0.0ms)  SAVEPOINT active_record_1
Item Create (0.0ms)  INSERT INTO "items" ...
TRANSACTION (0.0ms)  ROLLBACK TO SAVEPOINT active_record_1
TRANSACTION (1.3ms)  COMMIT TRANSACTION

SAVEPOINT までロールバックROLLBACK TO SAVEPOINT)されました。結果として、Userのみ保存され、Itemは保存されません。トランザクションの結果だけを破棄したい場合に有効な手段です。


3. StandardError(一般的な例外)が発生した場合

ActiveRecord::Rollback 以外の例外が発生した場合は、挙動が異なります。

requires_new: false (デフォルト)

TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
User Create (0.2ms)  INSERT INTO "users" ...
Item Create (0.0ms)  INSERT INTO "items" ...
TRANSACTION (0.0ms)  ROLLBACK TRANSACTION

例外が捕捉されずに親まで伝播するため、トランザクション全体がロールバックされます。

requires_new: true

TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
User Create (0.2ms)  INSERT INTO "users" ...
TRANSACTION (0.0ms)  SAVEPOINT active_record_1
Item Create (0.0ms)  INSERT INTO "items" ...
TRANSACTION (0.0ms)  ROLLBACK TO SAVEPOINT active_record_1
TRANSACTION (0.0ms)  ROLLBACK TRANSACTION

まず SAVEPOINT まで巻き戻り、その後例外が親に伝わることで親もロールバックします。結果として、全ての変更が破棄されます。


まとめ

検証結果を比較表にまとめました。

発生した例外 requires_new: false (デフォルト) requires_new: true
ActiveRecord::Rollback 全てコミットされる (要注意) 子のみロールバック、親はコミット
StandardError 全てロールバック 全てロールバック

requires_new: false の状態で「子だけをロールバックさせる」ことはできません。また、ActiveRecord::Rollback は「親に例外を伝播させずに、そのブロックだけをなかったことにする」という特殊な挙動をします。

トランザクションを中断したい場合は、安易に ActiveRecord::Rollback を使うのではなく、適切なカスタム例外を定義して、呼び出し元で制御する方が意図しないコミットを防げるため安全だと感じました。