トランザクション分離レベルの挙動を実験する

に公開

概要

環境

  • MySQL 8.0.36
  • Rails 7.0.4

トランザクション分離レベルとは

  • 複数のトランザクションが同時に走る時に、「どこまでお互いを見えなくするか」を決めるもの。

分離性に関する問題のまとめ

ダーティリード

  • 別トランザクションの未コミットのデータを呼んでしまうこと

ファジーリード(ノン・リピータブルリード)

  • 別トランザクションでコミットされた上で、同じ行を読み直したときに値が変わってしまうこと

ファントムリード

  • 別トランザクションでコミットされた上で、同一条件で再検索したら行数が増減してしまうこと
  • 所感
    • 最初に勉強したときはファジーリードとファントムリードを概念として区別している理由がわからなかったが、新しいレコードが追加されたときはlockを効かせることができないため、複数のtransactionの整合性を取るときにファジーリードとは捉え方が異なることがわかった。

設定毎の発生状況

分離レベル ダーティリード ファジーリード ファントムリード
READ UNCOMMITTED 発生する 発生する 発生する
READ COMMITTED 発生しない 発生する 発生する
REPEATABLE READ 発生しない 発生しない 発生する
SERIALIZABLE 発生しない 発生しない 発生しない

※ InnoDBはREPEATABLE READでもファントムリードが発生しない

実験ログ

まずはMySQLのデフォルトREPEATABLE-READの場合

  • ダーティリード、ファジーリード、ファントムリードが発生していない
    • このトランザクション分離レベルの設定でファントムリードが発生しないのはMySQL独自の仕様の模様


READ-UNCOMMITTEDの場合

  • ダーティリードが発生している

READ-COMMITTEDの場合

  • ファジーリードが発生している

  • ファントムリードが発生している

SERIALIZABLEの場合

  • すべての行にロックがかかる。ファジーリードもファントムリードも発生しない。



具体的なバグの発生ケース

  • やや特殊なケースであるが、以下の状況で起こる
    • 整合性を取るべき2つのテーブルのうち、片方だけに行ロックをかける
    • 行ロックのコード行を通過する前に、テーブルにアクセスする(そのトランザクション内のスナップショットを確定させる)

実験条件(Rails)

  • 銀行残高(オレンジ色)...Accountテーブルのbalanceカラム
  • 取引(緑色)...入金と出金のログを保存する。Transactionテーブルのamountカラム
    • (プログラム機構としてのトランザクションと、取引テーブルを意味するTransactionが同じ名前で申し訳ない。カタカナを機構としてのトランザクション、英語をテーブルとしてのtransactionとする)
  • 残高と取引は常に整合性が取れる状態でありたい(my_account.balance == my_account.transactions.sum(:amount)
前準備のmigration
class CreateAccounts < ActiveRecord::Migration[7.0]
  def change
    create_table :accounts do |t|
      t.integer :balance, null: false, default: 0

      t.timestamps
    end

    create_table :transactions do |t|
      t.references :account, null: false, foreign_key: true, index: true
      t.integer :amount, null: false

      t.timestamps
    end
  end
end
# app/models/account.rb
class Account < ApplicationRecord
  has_many :transactions, dependent: :destroy

  # 保存のたびに整合性をログ出力
  after_save :check_consistency

  # 残高と取引合計の整合性チェック
  def check_consistency
    sum = transactions.sum(:amount)
    if sum != balance
      Rails.logger.warn("[Log] 整合性 NG: transactions.sum=#{sum}, balance=#{balance}")
      false
    else
      Rails.logger.info("[Log] 整合性 OK: transactions.sum=#{sum}, balance=#{balance}")
      true
    end
  end
end


# app/models/transaction.rb
class Transaction < ApplicationRecord
  belongs_to :account
end

# controllers/accounts_controller.rb
# app/controllers/api/v1/accounts_controller.rb
class Api::V1::AccountsController < Api::V1::ApplicationController
  def deposit
    puts("[Log] 0 amount: #{params[:amount]}")
    
    @account = Account.find(params[:id])
    puts("[Log] 1 amount: #{params[:amount]}, balance: #{@account.balance}")
    
    ActiveRecord::Base.transaction do
      puts("[Log] 2 amount: #{params[:amount]}, transaction begin")

      # --- 以下のコードは問題を起こす場合にのみコメントアウト
      # pre_sum = @account.transactions.sum(:amount)
      # puts("[Log] 2.1 amount: #{params[:amount]}, pre_sum #{pre_sum}")

      @account.lock!
      puts("[Log] 3 amount: #{params[:amount]}, balance: #{@account.balance}")
      
      Transaction.create!(account: @account, amount: params[:amount].to_i)
      @account.update!(balance: @account.balance + params[:amount].to_i)
      puts("[Log] 4 amount: #{params[:amount]}, balance: #{@account.balance}")
      
      sleep 10
      puts("[Log] 5 amount: #{params[:amount]}, balance: #{@account.balance}")
    end
  end
end

ログを見ながら結果を確認

問題が起きない場合

  • (以下の説明の①などは画像の中のコメントについた番号)
  • ① まだ1回目のトランザクションがコミットされていないので、2回目のトランザクションの中では古い値を読んでいる(ダーティーリードが起きていない)
  • ② 2回目のトランザクションでロック行に到達すると、その時点で最新の行を読み直す(2回目のトランザクションが始まった後にコミットされた1回目のトランザクションの値が読めてしまっているので、ファジーリードのようなことが発生している)
  • ③ 2回目のトランザクションの中で初めてtransactionsのテーブルにアクセスしたので、この時点でこの(2回目の)トランザクションとしての、transactionsテーブルのスナップショットを確定する。
  • ②と③から、いずれのテーブルも1回目のトランザクションがコミットされた後の最新の値を利用している。よって不整合が起きていない。

問題が起きる場合

  • ④ 2回目のトランザクションの中で、行ロックに到達する前に(まだ1回目のトランザクションがコミットされる前に)transactionsテーブルの情報を読む。この時点で2回目のトランザクションとしてのtransactionsテーブルのスナップショットの値が確定する。これは1回目のトランザクションコミット前の古い情報となる

  • ⑤ 再びtransactionsテーブルにアクセスしているが、ここで利用されるのは④で取ったスナップショットの古い値

  • しかし、Accountの方は行ロックの部分で最新の(1回目のトランザクションコミット後の)値を取得しているので、AccountとTransactionの間に時差が生じる。これが不整合の原因となる。

  • 問題が起きるケースを簡単に図解すると以下のようになる

  • 解決策としては、整合性の確認をafter_saveではなくafter_commitに変更する

Discussion