0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RailsのTransactionとRepeatable READを理解する

0
Posted at

はじめに

Railsでデータベースのトランザクションを使う機会は多いと思いますが、トランザクション分離レベルを意識して使っている人は少ないのではないでしょうか。

特に REPEATABLE READ の挙動を知らずにいると、本番環境で「なぜか子レコードが見えない」「外部キー制約エラーが発生する」といった不思議なバグに遭遇することがあります。

この記事では REPEATABLE READ の概念と、Railsのトランザクション内で起きる具体的な落とし穴を解説します。


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

MySQLやPostgreSQLには、トランザクション間のデータの見え方を制御する「分離レベル」があります。

分離レベル ダーティリード ノンリピータブルリード ファントムリード
READ UNCOMMITTED 発生する 発生する 発生する
READ COMMITTED 防げる 発生する 発生する
REPEATABLE READ 防げる 防げる 条件付き
SERIALIZABLE 防げる 防げる 防げる

MySQLのデフォルトは REPEATABLE READ です。


REPEATABLE READ の核心:スナップショット

REPEATABLE READ の最大の特徴は、トランザクションを開始した時点のスナップショット(データの静止画)を読み取り続けることです。

時系列
─────────────────────────────────────────────────►

[トランザクション開始] ← ここでスナップショットが確定!
      │
      │  ← この間に他トランザクションがデータを更新・追加しても
      │  ← このトランザクションからは「見えない」
      │
[コミット or ロールバック]

トランザクション開始後に他のトランザクションがコミットした変更は一切見えないのです。


Railsでの基本的な使い方

ActiveRecord::Base.transaction do
  user = User.find(1)
  user.update!(balance: user.balance - 100)

  # この時点のスナップショットで動作する
end

落とし穴:子レコードが「存在しない」と言われる問題

シナリオ

以下のようなモデルを考えます。

# app/models/order.rb
class Order < ApplicationRecord
  has_many :order_items
  validates :order_items, presence: true
end

# app/models/order_item.rb
class OrderItem < ApplicationRecord
  belongs_to :order
end

問題が起きるコード

# ジョブAが実行中(トランザクション開始済み)
ActiveRecord::Base.transaction do
  order = Order.find(1)

  # ★ここでスナップショットが確定

  # --- 別のジョブBが order_items を追加してコミット ---
  #   OrderItem.create!(order_id: 1, product_id: 99)
  # --- ジョブBのコミット完了 ---

  # スナップショット時点の情報で order_items を取得
  # → ジョブBが追加したレコードは「見えない」!
  items = order.order_items.to_a
  puts items.count  # ジョブBの追加分が含まれない

  # 集計処理が正しくない結果になる可能性がある
  total = items.sum(&:price)
  order.update!(total_price: total)  # 実際より少ない金額が入る!
end

外部キー制約エラーになるシナリオ

さらに厄介なのは、子レコードを参照するような処理で外部キーエラーが出るケースです。

# トランザクション開始
ActiveRecord::Base.transaction do
  # スナップショット確定(この時点では子レコードなし)
  parent = Parent.find(1)

  # 別プロセスが子レコードを作成してコミット
  # Child.create!(parent_id: 1, ...)  ← 他プロセスがやった

  # このトランザクション内で親レコードを削除しようとする
  # → スナップショットでは子がいないと思っているが、
  #   DBには実際に子レコードが存在する!
  parent.destroy  # ← 外部キー制約違反エラー!
  # ActiveRecord::InvalidForeignKey が発生
end

対策

1. lock! でロックして最新データを取得する

同一トランザクション内で最新データを強制的に取得するには SELECT FOR UPDATE を使います。

ActiveRecord::Base.transaction do
  order = Order.lock.find(1)  # SELECT ... FOR UPDATE
  # 他のトランザクションがコミットした最新データを取得できる
  items = order.order_items.lock.to_a
  total = items.sum(&:price)
  order.update!(total_price: total)
end

⚠️ ロックは排他的なので、デッドロックに注意してください。

2. READ COMMITTED に変更する

トランザクション単位で分離レベルを下げることができます。

ActiveRecord::Base.transaction(isolation: :read_committed) do
  order = Order.find(1)
  # 毎回最新データを読み取る
  items = order.order_items.to_a
  total = items.sum(&:price)
  order.update!(total_price: total)
end

⚠️ READ COMMITTED にするとノンリピータブルリードが発生しうる点は把握した上で使いましょう。

3. トランザクション外でデータ取得し、トランザクション内は書き込みのみにする

設計で解決する方法です。読み取りをトランザクション外に出すことで、常に最新データを参照できます。

# トランザクション外で最新データを取得
order = Order.find(1)
items = order.order_items.to_a
total = items.sum(&:price)

# トランザクション内は書き込みのみ
ActiveRecord::Base.transaction do
  order.lock!  # ここで再度ロックして整合性を確保
  order.update!(total_price: total)
end

4. 楽観的ロック(lock_version)を使う

lock_version カラムを追加することで、データが変更されていた場合に例外を発生させることができます。

# migration
add_column :orders, :lock_version, :integer, default: 0, null: false

# 使い方(特別なコードは不要、自動で機能する)
ActiveRecord::Base.transaction do
  order = Order.find(1)
  order.update!(total_price: 500)
  # 他で同じレコードが更新されていた場合
  # => ActiveRecord::StaleObjectError が発生
end

まとめ

ポイント 内容
REPEATABLE READ の特性 トランザクション開始時点のスナップショットを読む
落とし穴 他トランザクションが追加した子レコードが見えず、処理が不整合になる
外部キーエラー スナップショット上は「子なし」でも、DB上には子レコードが存在する場合がある
対策① lock! / ORDER.lock.find で SELECT FOR UPDATE
対策② isolation: :read_committed で分離レベルを下げる
対策③ 読み取りをトランザクション外に出す設計にする
対策④ 楽観的ロック(lock_version)でコンフリクトを検知する

REPEATABLE READ は「一貫性のある読み取り」を保証する一方で、長時間トランザクションや並行処理が絡むと想定外の動作を引き起こすことがあります。

Railsでトランザクションを書くときは、スナップショットの存在を常に意識しておきましょう!


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?