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 with_lockによる悲観的ロック

0
Posted at

背景

  • 予約システムで「同じスペースに対して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_locktransaction + lock!SELECT ... FOR UPDATE)で行レベルの排他ロックを実現する
  • 「SELECT → 判定 → INSERT」のレースコンディションは、親リソースをロックして処理を直列化することで防げる
  • ロック対象は「同時アクセスを防ぎたい単位」で選ぶ。予約システムなら予約対象(スペース)が適切

感想

  • シーケンス図で理解したほうが良いな
  • 「何をロックするか」は「何の単位で直列化したいか」と同じなのか

参考

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?