Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RSpec でデータベースの「トランザクション分離レベル」の挙動を確認する

はじめに

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を使っているとあまり意識しない部分ですが
改めて自分でテストを書いて少し理解が深まった気がするため、良かったです。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。