ActiveRecord

【Rails】ネストしたトランザクションでのロールバックについてまとめてみた

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もやってますので、フォローしていただけるとうれしいです。

ABOUT ME
sakai
三重出身の28歳。前職はメーカーで働いていて、プログラミングスクールに通って未経験からWeb業界に転職しました。Railsをメインで使っていて、AWSも少しできます。音楽を聞くこととYoutubeを見るのが好きです。最近はへきトラ劇場にハマってます