背景
- 予約システムで「同じスペースに対して2人が同時に同じ時間帯を予約できてしまう」レースコンディションが発覚した
- 予約の重複チェック(
time_must_not_overlapバリデーション)は「SELECT → 判定 → INSERT」の流れになるが、2リクエストが同時に来ると両方の SELECT が「空いてる」と判定してしまう - この問題を Rails の
with_lock(悲観的ロック)で解決したので、仕組みと使い方をまとめる
悲観的ロックとは
「他の誰かが同時に同じデータを触るかもしれない」と悲観的に考えて、処理中はその行を先にロックしてしまう方式。ロックを取得した側が処理を完了するまで、他のトランザクションは同じ行のロック取得を待たされる
対義語は「楽観的ロック(Optimistic Locking)」で、こちらは lock_version カラムを使って更新時に衝突を検知する方式。悲観的ロックは「衝突が起きてから検知する」のではなく「衝突が起きないようにする」アプローチ
with_lock の仕組み
with_lock は ActiveRecord の ActiveRecord::Locking::Pessimistic モジュールで定義されているメソッド
内部実装はシンプルで、transaction ブロックの中で lock! を呼んでいる
# Rails ソースコード(簡略化)
def with_lock(*args)
transaction do
lock!(*args) # → reload(lock: true) と同等
yield
end
end
lock! は内部で reload(lock: true) を呼び、DB に SELECT ... FOR UPDATE を発行する
実行される SQL の流れ
BEGIN -- 1. トランザクション開始
SELECT * FROM spaces WHERE id = 1 FOR UPDATE -- 2. 行ロック取得
-- ブロック内の処理(INSERT等)が実行される -- 3. ブロック実行
COMMIT -- 4. コミット(例外時は ROLLBACK)
FOR UPDATE 句が付いた SELECT は、対象の行に排他ロックをかける。他のトランザクションが同じ行に FOR UPDATE を発行すると、先のトランザクションが COMMIT か ROLLBACK するまで待たされる
なぜレースコンディションが起きるのか
with_lock なしの場合、以下の問題が起きる
ユーザーA ユーザーB
───────── ─────────
SELECT(空いてる?)→ 空いてる!
SELECT(空いてる?)→ 空いてる!
INSERT(予約作成)
INSERT(予約作成)← 重複!
2つの SELECT が「空いてる」と判定してしまうのは、どちらもまだ INSERT していないから。これがレースコンディション
with_lock で解決する仕組み
@space.with_lock で space の行をロックすると、同じスペースへの処理が直列化される
ユーザーA ユーザーB
───────── ─────────
BEGIN
SELECT ... FOR UPDATE(ロック取得)
BEGIN
SELECT ... FOR UPDATE(ロック待ち…)
INSERT(予約作成)
COMMIT(ロック解放)
SELECT ... FOR UPDATE(ロック取得)
INSERT → バリデーションエラー(重複検知)
ROLLBACK
ユーザーBの SELECT は、ユーザーAのトランザクションが完了するまで待たされる。ロックが解放された時点でユーザーAの INSERT は完了しているため、ユーザーBの SELECT は正しく「埋まっている」と判定できる
ロック対象の選び方
この実装でロック対象が @space(親リソース)になっている理由は、同じスペースへの同時予約を防ぎたいから
@space.with_lock { @reservation.save }
もし @reservation をロック対象にしようとしても、@reservation は新規レコード(まだ DB に保存されていない)なのでロックできない。親リソースである @space は既に DB に存在するため、ロック対象として適切
実装コード
# app/controllers/reservations_controller.rb
def create
@reservation = @space.reservations.build(reservation_params)
@reservation.user = current_user
saved = @space.with_lock { @reservation.save }
if saved
redirect_to root_path, notice: t('reservations.created')
else
render :new, status: :unprocessable_content
end
end
-
@space.reservations.build—@spaceに紐づくReservationのインスタンスを作成(space_idが自動設定される)。まだ DB には保存されない -
@space.with_lock—@spaceの行をFOR UPDATEでロックし、ブロック内の処理をトランザクションで囲む -
@reservation.save— バリデーション(time_must_not_overlap含む)を実行してから INSERT する。戻り値はtrue/false -
unprocessable_content— HTTP 422 ステータス。Rails 7.1 以降でunprocessable_entityの別名として追加された
注意点
対応 DB
SELECT ... FOR UPDATE は行レベルロックをサポートする RDBMS が必要。PostgreSQL、MySQL(InnoDB)、Oracle 等で使える。SQLite はファイルレベルのロックのみでこの構文をサポートしないため、期待通りの排他制御にならない
デッドロック
複数のテーブルを異なる順序でロックすると、デッドロックが発生する可能性がある。ロック対象のテーブル・行の順序はアプリケーション全体で統一するのが望ましい
パフォーマンス
悲観的ロックはロック待ちが発生するため、同時アクセスが非常に多い場合はボトルネックになりうる。ブロック内の処理は必要最小限にすることが重要
まとめ
-
with_lockはtransaction+lock!(SELECT ... FOR UPDATE)で行レベルの排他ロックを実現する - 「SELECT → 判定 → INSERT」のレースコンディションは、親リソースをロックして処理を直列化することで防げる
- ロック対象は「同時アクセスを防ぎたい単位」で選ぶ。予約システムなら予約対象(スペース)が適切
感想
- シーケンス図で理解したほうが良いな
- 「何をロックするか」は「何の単位で直列化したいか」と同じなのか
参考
-
ActiveRecord::Locking::Pessimistic - Rails API —
with_lock/lock!の公式リファレンス - Active Record Querying - Pessimistic Locking(Rails Guides) — 悲観的ロックの使い方ガイド
- PostgreSQL: Explicit Locking - Row-Level Locks — PostgreSQL の行レベルロック仕様
-
PostgreSQL: SELECT - FOR UPDATE —
FOR UPDATE句の仕様 -
MySQL: InnoDB Locking Reads — MySQL の
SELECT ... FOR UPDATE仕様 -
Rails ソースコード - pessimistic.rb(GitHub) —
with_lockの実装