Railsのtransaction
メソッドの使い方と内部挙動を解説(ネストトランザクション)
本記事では、ActiveRecordのtransaction
メソッドの引数や内部動作について詳しく解説します。(断片的に調べた内容をまとめたものです。ミスがあるかもしれません、、、)
序盤で動作のロジック的な部分を解説し、終盤で実際のコードを用いてRspecで検証を行います。
そのため、先にコードを見たい方はrspecで検証までスキップしてください。
まとめ
- 既存トランザクションへの「参加」: デフォルト動作では外部トランザクションに参加する。
-
新しいトランザクションの作成: 必要に応じて
requires_new: true
を指定する。 -
ActiveRecord::Rollback
の扱い: 外部トランザクションには影響しない。
以下の場合にネストトランザクションを使う際は注意が必要
require_new: true
を指定するかの考慮が必要
-
rescue
を使用する場合 -
ActiveRecord::Rollback
を使用する場合
メソッドの定義
以下がtransaction
メソッドの定義です。
def transaction(requires_new: nil, isolation: nil, joinable: true, &block)
主な引数の意味:
-
requires_new
: 新しいトランザクションを強制的に作成するかどうか(デフォルト:nil
)。 -
isolation
: トランザクションの分離レベルを指定(デフォルト:nil
)。指定可能なレベルは以下の通り。:read_uncommitted
:read_committed
:repeatable_read
:serializable
-
joinable
: 外部トランザクションに「参加」できるかどうかを指定(デフォルト:true
)。ただし、この引数を明示的に指定するのは非推奨です。
この記事では主にrequires_new
に焦点を当てて解説します。
デフォルトの動作
ActiveRecord::Base.transaction
を使用すると、デフォルトで外部トランザクションに「参加」する形で処理が実行されます。
つまり下記のコードでは、BトランザクションはAトランザクションに「参加」するため、1つのトランザクションとして扱われます。
1つのトランザクションとして扱われるため、ロールバックを行うかの判断は外部トランザクションに委ねられます。(外部トランザクションがロールバックされると、内部トランザクションもロールバックされる)
ActiveRecord::Base.transaction do # Aトランザクション
ActiveRecord::Base.transaction do # Bトランザクション
# BはAに「参加」する形で実行される
end
end
この「参加」する仕組みは、以下のコードで管理されています。
if !requires_new && current_transaction.joinable?
if isolation
raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
end
yield current_transaction.user_transaction
current_transaction.joinable?
は基本的にはtrue
を返すため、requires_new
が指定されていない場合は外部トランザクションに「参加」する形で処理が実行されます。
今回でいうとAトランザクションがcurrent_transaction (current_transaction.user_transaction)
で、Bトランザクションがyield
の部分です
yield current_transaction.user_transaction
は yeild
Tips: 一番外側のトランザクションについて
ちなみに最初に実行されるトランザクションの親のトランザクションはNULL_TRANSACTION
という形で表現されています
ActiveRecord::Base.transaction do # <-- 親のトランザクションはNULL_TRANSACTION
end
def current_transaction
@stack.last || NULL_TRANSACTION # <-- @stackにネストトランザクションが積まれる
end
def joinable?; false; end
NULL_TRANSACTION
はjoinable?
がfalse
なので、NULL_TRANSACTION
に「参加」することはできません。
requires_new: true
を指定した場合
requires_new: true
を指定すると、必ず新しいトランザクションが作成されます。
この動作は、内部的にwithin_new_transaction
メソッドを呼び出すことで実現されています。
within_new_transaction(isolation: isolation, joinable: joinable, &block)
以下のコードのように新しいトランザクションを強制的に作成することで、外部トランザクションの影響を受けない動作が可能です。
ActiveRecord::Base.transaction do # 外部トランザクションA
ActiveRecord::Base.transaction(requires_new: true) do # 新しいトランザクションB
# BはAに影響されない独立したトランザクション
end
end
ActiveRecord::Rollback
の扱い
ActiveRecord::Rollback
はトランザクション内でのロールバックを明示するために使用されますが、外部トランザクションでは検知されません。
ちなみに以下の例だとrequire_new: true
を指定していない(つまり外部トランザクションに「参加」している)ため、内部トランザクションでRollbackが検知されてもRollbackされません。
ActiveRecord::Base.transaction do # <-- ActiveRecord::Rollbackは検知されない
ActiveRecord::Base.transaction do # <-- ActiveRecord::Rollbackは検知される
raise ActiveRecord::Rollback
end
end
rspecで検証
#!/usr/bin/env ruby
require 'bundler/setup'
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'activerecord', require: 'active_record'
gem 'mysql2'
gem 'rspec', require: %w[rspec/autorun]
end
# MySQL 設定
config = {
adapter: 'mysql2',
database: 'transaction_test',
host: '127.0.0.1',
username: 'root',
password: ''
}
# データベース接続と初期化
ActiveRecord::Base.establish_connection(config.merge(database: nil))
ActiveRecord::Base.connection.create_database(config[:database]) rescue nil
ActiveRecord::Base.establish_connection(config)
ActiveRecord::Base.connection.create_table(:users, force: true) { |t| t.string :name, null: false }
# RSpec 設定
RSpec.configure { |config| config.default_formatter = 'documentation' }
# モデル定義
class User < ActiveRecord::Base; end
# RSpec テスト
RSpec.describe 'ネストトランザクションにおける挙動' do
before { User.delete_all }
describe 'トランザクションのロールバックの挙動' do
context '外部トランザクションでエラーが発生した場合' do
it 'すべての変更がロールバックされる' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
ActiveRecord::Base.transaction do
User.create!(name: "inner")
end
raise ActiveRecord::Rollback
end
}.not_to change { User.count }
expect(User.count).to eq(0)
end
end
context '内部トランザクションでエラーが発生した場合' do
context 'デフォルト設定の場合' do
it 'StandardError発生時にすべての変更がロールバックされる (rescueなし)' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
ActiveRecord::Base.transaction do
User.create!(name: "inner")
raise StandardError, 'エラーが発生しました'
end
User.create!(name: "outer-backward")
end
}.to raise_error(StandardError)
expect(User.count).to eq(0)
end
it 'StandardError発生時にrescueするとすべてのデータが保存される' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
begin
ActiveRecord::Base.transaction do
User.create!(name: "inner")
raise StandardError, 'エラーが発生しました'
end
rescue StandardError
end
User.create!(name: "outer-backward")
end
}.to change { User.count }.by(3)
expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward', 'inner'])
end
it 'ActiveRecord::Rollbackではすべてのデータが保存される' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
ActiveRecord::Base.transaction do
User.create!(name: "inner")
raise ActiveRecord::Rollback
end
User.create!(name: "outer-backward")
end
}.to change { User.count }.by(3)
expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward', 'inner'])
end
end
context 'requires_new: true を使用した場合' do
it 'StandardError発生時に全体がロールバックされる (rescueなし)' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
ActiveRecord::Base.transaction(requires_new: true) do
User.create!(name: "inner")
raise StandardError, 'エラーが発生しました'
end
User.create!(name: "outer-backward")
end
}.to raise_error(StandardError)
expect(User.count).to eq(0)
end
it 'StandardError発生時にrescueすると外部トランザクションのデータのみ保存される' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
begin
ActiveRecord::Base.transaction(requires_new: true) do
User.create!(name: "inner")
raise StandardError, 'エラーが発生しました'
end
rescue StandardError
end
User.create!(name: "outer-backward")
end
}.to change { User.count }.by(2)
expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward'])
end
it 'ActiveRecord::Rollbackでは外部トランザクションのデータのみ保存される' do
expect {
ActiveRecord::Base.transaction do
User.create!(name: "outer-forward")
ActiveRecord::Base.transaction(requires_new: true) do
User.create!(name: "inner")
raise ActiveRecord::Rollback
end
User.create!(name: "outer-backward")
end
}.to change { User.count }.by(2)
expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward'])
end
end
end
end
end
実行結果は以下の通り。
$ docker run --rm -p 3306:3306 --env MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:8
$ ruby transaction_behavior_test.rb
ネストトランザクションにおける挙動
トランザクションのロールバックの挙動
外部トランザクションでエラーが発生した場合
すべての変更がロールバックされる
内部トランザクションでエラーが発生した場合
デフォルト設定の場合
StandardError発生時にすべての変更がロールバックされる (rescueなし)
StandardError発生時にrescueするとすべてのデータが保存される
ActiveRecord::Rollbackではすべてのデータが保存される
requires_new: true を使用した場合
StandardError発生時に全体がロールバックされる (rescueなし)
StandardError発生時にrescueすると外部トランザクションのデータのみ保存される
ActiveRecord::Rollbackでは外部トランザクションのデータのみ保存される
Finished in 0.08909 seconds (files took 0.27644 seconds to load)
7 examples, 0 failures