はじめに
Rails 7から、with_lock の引数にisolation(トランザクション分離レベル) を指定できるようになったということです。
参考:Rails 7: with_lockでもtransactionのロック戦略引数を指定可能になった(翻訳)
データベースの設定をRails で簡単に操作できることを知り、今回RSpec にて、データベースの「トランザクション分離レベル」ごとに挙動の違いを確認してみたいと思いました。
※今回は、with_lock(isolation:)
ではなく、ApplicationRecord.transaction(isolation:)
を使いました。
データベースの「トランザクション分離レベル」について
トランザクション分離レベル | ダーティリード | ファジーリード | ファントムリード |
---|---|---|---|
READ UNCOMMITTED | 発生する | 発生する | 発生する |
READ COMMITTED | 発生しない | 発生する | 発生する |
REPEATABLE READ | 発生しない | 発生しない | 発生する |
SERIALIZABLE | 発生しない | 発生しない | 発生しない |
- ダーティリードとは、別トランザクションのコミットされていない変更データを読み込んでしまうこと
- ファジーリードとは、別トランザクションのコミットされた変更データを読み込んでしまうこと
- ファントムリードとは、別トランザクションで行追加(または削除)による変更データを読み込んでしまうこと
やりたいこと
以下のシーケンス図のとおり、トランザクションAとトランザクションB(いずれも、データベーステーブルzandakas にアクセスするトランザクション)を動作させて、
トランザクションAで取得された結果zandakas_0 ~ zandakas_3 を「トランザクション分離レベル」ごとに確認する
テーブル:zandakas(残高)
カラム | 型 |
---|---|
id | bitint |
amount | integer | |
トランザクション分離レベルの表をみてRSpec を書く
# isolation_level_spec.rb
require 'rails_helper'
require 'parallel'
# SQLの実行ログ出力
ActiveRecord::Base.logger = Logger.new(STDOUT)
RSpec.configure do |config|
# ActiveRecord::TransactionIsolationError 対策
config.use_transactional_fixtures = false
end
RSpec.describe do
before do
Zandaka.destroy_all
Zandaka.create(id: 1, amount: 100)
Zandaka.create(id: 2, amount: 200)
end
it 'isolation: :read_uncommitted' do
Parallel.each([:transaction_A, :transaction_B], in_threads: 2) do |method_name|
send(method_name, :read_uncommitted)
end
# ダーティリード:発生する
# ファジーリード:発生する
# ファントムリード:発生する
exp = [
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_0
[{ 'id' => 1, 'amount' => 150 }, { 'id' => 2, 'amount' => 200 }], # zandakas_1
[{ 'id' => 1, 'amount' => 150 }, { 'id' => 2, 'amount' => 200 }], # zandakas_2
[{ 'id' => 1, 'amount' => 150 }, { 'id' => 2, 'amount' => 200 }, { 'id' => 3, 'amount' => 300 }] # zandakas_3
]
expect(exp).to eq @result
end
it 'isolation: :read_committed' do
Parallel.each([:transaction_A, :transaction_B], in_threads: 2) do |method_name|
send(method_name, :read_committed)
end
# ダーティリード:発生しない
# ファジーリード:発生する
# ファントムリード:発生する
exp = [
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_0
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_1
[{ 'id' => 1, 'amount' => 150 }, { 'id' => 2, 'amount' => 200 }], # zandakas_2
[{ 'id' => 1, 'amount' => 150 }, { 'id' => 2, 'amount' => 200 }, { 'id' => 3, 'amount' => 300 }] # zandakas_3
]
expect(exp).to eq @result
end
it 'isolation: :repeatable_read' do
Parallel.each([:transaction_A, :transaction_B], in_threads: 2) do |method_name|
send(method_name, :repeatable_read)
end
# ダーティリード:発生しない
# ファジーリード:発生しない
# ファントムリード:発生する
exp = [
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_0
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_1
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_2
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }, { 'id' => 3, 'amount' => 300 }] # zandakas_3
]
expect(exp).to eq @result
end
it 'isolation: :serializable' do
Parallel.each([:transaction_A, :transaction_B], in_threads: 2) do |method_name|
send(method_name, :serializable)
end
# ダーティリード:発生しない
# ファジーリード:発生しない
# ファントムリード:発生しない
exp = [
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_0
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_1
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }], # zandakas_2
[{ 'id' => 1, 'amount' => 100 }, { 'id' => 2, 'amount' => 200 }] # zandakas_3
]
expect(exp).to eq @result
end
def transaction_A(isolation)
Zandaka.transaction(isolation: isolation) do
zandakas_0 = Zandaka.order(:id).map(&:attributes) # 初期データ
sleep 5
zandakas_1 = Zandaka.order(:id).map(&:attributes) # ①の結果を受けたデータ
sleep 5
zandakas_2 = Zandaka.order(:id).map(&:attributes) # ②の結果を受けたデータ
sleep 5
zandakas_3 = Zandaka.order(:id).map(&:attributes) # ③の結果を受けたデータ
@result = [zandakas_0, zandakas_1, zandakas_2, zandakas_3]
end
end
def transaction_B(isolation)
sleep 2
Zandaka.transaction(isolation: isolation) do
# ①
Zandaka.connection.execute('UPDATE zandakas SET amount = 150 WHERE id = 1;')
sleep 5
# ②
Zandaka.connection.execute('UPDATE zandakas SET amount = 150 WHERE id = 1;')
Zandaka.connection.execute('COMMIT;')
sleep 5
# ③
Zandaka.connection.execute('INSERT INTO zandakas VALUES (3, 300);')
Zandaka.connection.execute('COMMIT;')
end
end
end
書いたRSpec を実行してみた結果
PostgreSQL で実行
# railsコンソール
Finished in 1 minute 2.94 seconds (files took 1.53 seconds to load)
4 examples, 2 failures
Failed examples:
rspec ./spec/system/isolation_level_spec.rb:19 # isolation: :read_uncommitted
rspec ./spec/system/isolation_level_spec.rb:53 # isolation: :repeatable_read
read_uncommitted
と、repeatable_read
で想定と異なる結果となった。
上記で記述したトランザクション分離レベルの一覧について、PosgreSQLでは異なる結果が得られるということでした。
トランザクション分離レベル | ダーティリード | ファジーリード | ファントムリード |
---|---|---|---|
READ UNCOMMITTED | 発生する ※PostgreSQL では発生しない |
発生する | 発生する |
READ COMMITTED | 発生しない | 発生する | 発生する |
REPEATABLE READ | 発生しない | 発生しない | 発生する ※PostgreSQL では発生しない |
SERIALIZABLE | 発生しない | 発生しない | 発生しない |
- 参考にしたもの
改めて、上記を参照し予想値を書き換えたところ、テストがすべて通りました 🎉
MySQL で実行
# railsコンソール
Finished in 7.4 seconds (files took 1.35 seconds to load)
4 examples, 1 failure
Failed examples:
rspec ./spec/system/isolation_level_spec.rb:53 # isolation: :repeatable_read
repeatable_read
で想定と異なる結果となった。
上記で記述したトランザクション分離レベルの一覧について、MySQLでも異なる結果が得られるということでした。
トランザクション分離レベル | ダーティリード | ファジーリード | ファントムリード |
---|---|---|---|
READ UNCOMMITTED | 発生する | 発生する | 発生する |
READ COMMITTED | 発生しない | 発生する | 発生する |
REPEATABLE READ | 発生しない | 発生しない | 発生する ※MySQL では発生しない |
SERIALIZABLE | 発生しない | 発生しない | 発生しない |
REPEATABLE READ
...
※ MySQL(InnoDB)では、MVCC(MultiVersion Concurrency Control)という技術でファントムリードを防いでいます。
トランザクション分離レベルについてのまとめより
こちらも、上記を参照し予想値を書き換えたところ、テストがすべて通りました 🎉
終わりに
普段Railsを使っているとあまり意識しない部分ですが
改めて自分でテストを書いて少し理解が深まった気がするため、良かったです。