Railsでのネストしたトランザクションについて、何度調べても分からない部分が多いので検証してみたことをまとめました。
ネストしたトランザクションでのロールバック① (ActiveRecord::Rollback)
まずはネストしたトランザクションでActiveRecord::Rollback
する場合を考えます。
Active::Record::RollbackはActiveRecord::ActiveRecordErrorを継承していて、またさらにこれはStandardErrorを継承しています。
エラークラスなのでraiseを使って以下のように使います。
class BooksController < ActionController::Base
def create
Book.transaction do
book = Book.new(params[:book])
book.save!
if today_is_friday?
raise ActiveRecord::Rollback
end
end
redirect_to root_url
end
end
(https://api.rubyonrails.org/classes/ActiveRecord/Rollback.htmlより抜粋)
こちらは金曜日の場合、トランザクション開始地点までロールバックされます。
このActiveRecord::Rollbackをネストしたトランザクションブロックの中で使ってみます。
def create
ActiveRecord::Base.transaction do
Subscription.create!(name: 'Amazon Prime', price: 1000, user_id: params[:user_id])
ActiveRecord::Base.transaction do
Subscription.create!(name: 'Hulu', price: 2000, user_id: params[:user_id])
raise ActiveRecord::Rollback
end
end
end
この状態でアクセスすると、Subscription.count => 2
となりました。
理由はネストしたブロック内では ActiveRecord::Rollback
例外がROLLBACKを発行しないからです。さらに、例外はトランザクションブロック内でキャプチャされるので、親ブロックからは例外が見えず、そのままコミットされます。その結果、Subscription.count => 2
という結果になります。
発行されるSQLは以下ですが、ロールバックされていないことが分かります。
TRANSACTION (0.3ms) SAVEPOINT active_record_1
User Load (1.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 193 LIMIT 1
Subscription Create (1.1ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 193, '2022-10-08 04:28:33.522882', '2022-10-08 04:28:33.522882', FALSE, NULL, NULL, 10, NULL, 0)
User Load (1.1ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 193 LIMIT 1
Subscription Create (0.7ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('b', 2000, NULL, 193, '2022-10-08 04:28:33.526742', '2022-10-08 04:28:33.526742', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_1
ネストしたトランザクションでROLLBACKされるようにするために、内側のサブトランザクションにrequires_new: true
を渡す方法があります。このオプションをつけてActiveRecord::Rollback
がraiseされると、サブトランザクション開始地点までロールバックされます。
def create
ActiveRecord::Base.transaction do
Subscription.create!(name: 'a', price: 1000, user_id: params[:user_id])
ActiveRecord::Base.transaction(requires_new: true) do # requires_new: trueオプションを追加
Subscription.create!(name: 'b', price: 2000, user_id: params[:user_id])
raise ActiveRecord::Rollback
end
end
end
この状態でアクセスすると、Subscription.count => 1
となり、サブトランザクションの先頭までロールバックできていることが分かります。
また、発行されているSQLを見ても、きちんと2つ目のSAVEPOINTへロールバックしていることが分かります。
TRANSACTION (0.3ms) SAVEPOINT active_record_1
User Load (1.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 192 LIMIT 1
Subscription Create (1.4ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 192, '2022-10-08 04:22:09.841106', '2022-10-08 04:22:09.841106', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.4ms) SAVEPOINT active_record_2
User Load (1.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 192 LIMIT 1
Subscription Create (1.9ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('b', 2000, NULL, 192, '2022-10-08 04:22:09.847767', '2022-10-08 04:22:09.847767', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.6ms) ROLLBACK TO SAVEPOINT active_record_2
TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_1
- トランザクションがネストしている状態でActiveRecord::Rollbackをraiseしたとき、ロールバックされずにトランザクションは無視されそのままコミットされる
- requires_new: trueオプションをつけることで、SAVEPOINTを打つことができ、その時点までロールバックすることができる!
また、もう一つの方法として親トランザクションでjoinable: false
オプションを渡す方法があります。joinable: false
はこのトランザクションの内部でネストしているトランザクションを無視しない(ゆえに、自前のトランザクションに合流しない)ことを意味します。
def create
ActiveRecord::Base.transaction(joinable: false) do
Subscription.create!(name: 'a', price: 1000, user_id: params[:user_id])
ActiveRecord::Base.transaction do
Subscription.create!(name: 'b', price: 2000, user_id: params[:user_id])
raise ActiveRecord::Rollback
end
end
この状態でアクセスしても、Subscription.count => 1
となり、サブトランザクションの先頭までロールバックできていることが分かります。
SQLを確認すると、2つのトランザクションが合流せずそれぞれ独自のトランザクションを貼っているのでこのような結果になったことが分かりますね。
TRANSACTION (0.3ms) SAVEPOINT active_record_1
TRANSACTION (0.4ms) SAVEPOINT active_record_2
User Load (2.8ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 220 LIMIT 1
Subscription Create (1.8ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 220, '2022-10-08 12:52:27.505146', '2022-10-08 12:52:27.505146', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (1.4ms) RELEASE SAVEPOINT active_record_2
TRANSACTION (0.2ms) SAVEPOINT active_record_2
User Load (0.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 220 LIMIT 1
Subscription Create (0.7ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('b', 2000, NULL, 220, '2022-10-08 12:52:27.510890', '2022-10-08 12:52:27.510890', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (5.1ms) ROLLBACK TO SAVEPOINT active_record_2
TRANSACTION (0.4ms) RELEASE SAVEPOINT active_record_1
親トランザクションにjoinable: falseオプションをつけることで、トランザクションの合流を防ぐことができる!
ネストしたトランザクションでのロールバック②
①ではActiveRecord::Rollback
を使いましたが、ActiveRecord::Rollback
以外のエラーが出たときにロールバックを行うという実装することが大半かと思います。この場合は、上記のようなオプションを考慮する必要がないです。
では、実際に試してみます。
def create
ActiveRecord::Base.transaction do
Subscription.create!(name: 'a', price: 1000, user_id: params[:user_id])
ActiveRecord::Base.transaction do
Subscription.create!(name: 'b', price: 2000, user_id: params[:user_id])
raise StandardError
end
end
end
この状態でアクセスすると、Subscription.count => 0
となり、先頭のトランザクションの冒頭までロールバックされていることが分かります。
トランザクションがネストしていてActiveRecord::Rollback以外のエラーがraiseされた場合、トランザクションの先頭までロールバックされる
もう少し詳しくみるために、発行されるSQLを確認します。
TRANSACTION (0.3ms) SAVEPOINT active_record_1
User Load (1.1ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 194 LIMIT 1
Subscription Create (2.4ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 194, '2022-10-08 04:43:31.139537', '2022-10-08 04:43:31.139537', FALSE, NULL, NULL, 10, NULL, 0)
User Load (1.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 194 LIMIT 1
Subscription Create (0.7ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('b', 2000, NULL, 194, '2022-10-08 04:43:31.144636', '2022-10-08 04:43:31.144636', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (1.3ms) ROLLBACK TO SAVEPOINT active_record_1
SQLを見ると明らかですが、2つ目のトランザクションでSAVEPOINTが貼られていません。
では、SAVEPOINTを打っておき、2つ目のトランザクションでエラーが起きたときにそのトランザクションのみをロールバックすることは可能なのでしょうか。requires_new: true
オプションをつけてみます。
TRANSACTION (0.3ms) SAVEPOINT active_record_1
User Load (1.2ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 195 LIMIT 1
Subscription Create (1.4ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 195, '2022-10-08 04:48:54.584879', '2022-10-08 04:48:54.584879', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.4ms) SAVEPOINT active_record_2
User Load (0.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 195 LIMIT 1
Subscription Create (1.0ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('b', 2000, NULL, 195, '2022-10-08 04:48:54.588712', '2022-10-08 04:48:54.588712', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (2.6ms) ROLLBACK TO SAVEPOINT active_record_2
TRANSACTION (0.4ms) ROLLBACK TO SAVEPOINT active_record_1
すると、SAVEPOINTは打たれるのですが、active_record_1とactive_record2のSAVEPOINTそれぞれまでロールバックされています。他のオプションをつけても2つ目のトランザクションのみをロールバックするということはできませんでした。
save!
メソッドなどで失敗するときに内側のトランザクションのみロールバックというケースはなさそうなので特に問題なさそうです。
コールバック内でのトランザクションについて
一点注意が必要なのが、コールバック内でActiveRecord::Rollback
を呼ぶケースです。
コールバックの仕様でRailsが対象のモデルと、そのコールバック処理をまとめてトランザクションを貼ります。
下のような例の場合、コントローラー内のSubscriptionを作成する処理がトランザクションで囲われています。そして、モデルのコールバックでActiveRecord::Rollback
をraiseしています。
この場合、トランザクションがネストしていて最初の例で見たようにロールバックされないということが起こります。
# subscriptions_controller.rb
def create
ActiveRecord::Base.transaction do
# do_something
Subscription.create!(name: 'a', price: 1000, user_id: params[:user_id])
end
end
# subscription.rb
after_create do
raise ActiveRecord::Rollback
end
実際に試してみると、Subscription.count => 1
となり、ロールバックされないことが分かります。
TRANSACTION (0.2ms) SAVEPOINT active_record_1
User Load (1.1ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 204 LIMIT 1
Subscription Create (1.1ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 204, '2022-10-08 05:28:48.940456', '2022-10-08 05:28:48.940456', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.5ms) RELEASE SAVEPOINT active_record_1
ロールバックされるようにするには自前のトランザクションにjoinable: false
オプションをつけます。Railsがトランザクションを内側に貼りrequires_new: true
オプションを付けることができないのでこの方法一択になります。
# subscriptions_controller.rb
def create
ActiveRecord::Base.transaction(joinable: false) do
# do_something
Subscription.create!(name: 'a', price: 1000, user_id: params[:user_id])
end
end
# subscription.rb
after_create do
raise ActiveRecord::Rollback
end
実際に試してみると、Subscription.count => 0
となり、ロールバックされていることが分かります。
TRANSACTION (0.3ms) SAVEPOINT active_record_1
TRANSACTION (0.3ms) SAVEPOINT active_record_2
User Load (0.9ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 242 LIMIT 1
Subscription Create (0.8ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 242, '2022-10-08 13:24:50.317126', '2022-10-08 13:24:50.317126', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (4.3ms) ROLLBACK TO SAVEPOINT active_record_2
TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_1
コールバックでActiveRecord::Rollbackを行うときに親トランザクションが存在する場合には、joinable: falseオプションをつける
自分は使ったことないですが、もし使うなら下のような使い方になるのかなと思います。
def create
ActiveRecord::Base.transaction(joinable: false) do
Subscription.create!(name: 'a', price: 1000, user_id: params[:user_id])
Subscription.create!(name: 'b', price: 1000, user_id: params[:user_id])
Subscription.create!(name: 'c', price: 1000, user_id: params[:user_id])
end
end
after_create do
raise ActiveRecord::Rollback if self.name == 'c'
end
TRANSACTION (0.3ms) SAVEPOINT active_record_1
TRANSACTION (0.4ms) SAVEPOINT active_record_2
User Load (1.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 243 LIMIT 1
Subscription Create (0.9ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('a', 1000, NULL, 243, '2022-10-08 22:50:55.137132', '2022-10-08 22:50:55.137132', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.5ms) RELEASE SAVEPOINT active_record_2
TRANSACTION (0.3ms) SAVEPOINT active_record_2
User Load (0.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 243 LIMIT 1
Subscription Create (0.7ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('b', 1000, NULL, 243, '2022-10-08 22:50:55.141029', '2022-10-08 22:50:55.141029', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_2
TRANSACTION (0.4ms) SAVEPOINT active_record_2
User Load (0.8ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 243 LIMIT 1
Subscription Create (1.4ms) INSERT INTO `subscriptions` (`name`, `price`, `contracted_at`, `user_id`, `created_at`, `updated_at`, `is_trial`, `trial_end_date`, `memo`, `update_cycle`, `next_contract_updated_at`, `charge_times`) VALUES ('c', 1000, NULL, 243, '2022-10-08 22:50:55.144930', '2022-10-08 22:50:55.144930', FALSE, NULL, NULL, 10, NULL, 0)
TRANSACTION (0.5ms) ROLLBACK TO SAVEPOINT active_record_2
TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_1
まとめ
今回はネストしたトランザクションについてまとめてみました。
Railsのこのあたりの挙動は複雑で分かりづらく、まとめていて何度も混乱しました。
個人的には保守性の観点からコールバックの中でActiveRecord::Rollback
は使わないほうがいいのではないかと思いました。
もし使うとすると、他でトランザクションを貼るたびにjoinable: false
オプションを付ける必要がでてきます。使うならすべてのトランザクションにオプションを付与するということをルール化してしまうくらいしないと不整合が出てしまいそうという印象です。
もちろん、joinable: false
オプションに副作用がないかの確認が必要ですが・・
他にもトランザクションについては書きたいことがあったのですが今回はこれまでにします。
分かりにくいやアドバイス等ありましたらコメントくださると幸いです。では!
Twitterもやってますので、フォローしていただけるとうれしいです。