はじめに
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でトランザクションを書くときは、スナップショットの存在を常に意識しておきましょう!