26
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で学ぶ レースコンディション 〜二重登録の罠〜

26
Posted at

お疲れ様です!
シリーズで並行性・パフォーマンスの話を書いてきましたが、今回は レースコンディション(競合状態) をまとめてみました :pencil:
「ローカルでは再現しないのに、本番でたまに二重登録される…」の正体です。
興味ある方は読んでいただけると嬉しいです :thumbsup:

レースコンディションとは?

ひとことで言うと 「複数の処理が"同時"に動いたとき、タイミング次第で結果が壊れる」 バグです。

身近な例で言うと、在庫が1個の商品を、2人が同時に買う場面です :shopping_cart:

時刻 →
Aさん: 在庫を確認(1個ある!) ──────→ 購入(在庫を1減らす)
Bさん:        在庫を確認(1個ある!) ──→ 購入(在庫を1減らす)
                                         結果:在庫 -1(2人とも買えてしまった)💥

2人とも「在庫を確認した時点では1個あった」ので購入が通り、在庫がマイナスになります。
1つの処理だけなら絶対に起きないのに、同時に動くと壊れる。これがレースコンディションです。

Railsだと、Puma(複数スレッド・複数プロセス)やSidekiq(複数ワーカー)で常に複数の処理が同時に走っています
「自分のリクエストしか動いていない」前提で書くと、ここにハマります。

なぜ起きる? 〜 check-then-act(確認→実行のすきま)

レースコンディションの典型は 「確認してから実行する」 コードです。

# ❌ 確認(check) → 実行(act) の "すきま" で割り込まれる
unless User.exists?(email: email)   # ① 存在チェック
  User.create!(email: email)        # ② 作成
end

1つずつ動けば問題ありません。でも2つのリクエストが同時に①を通過すると、どちらも「まだ存在しない」と判断し、②で二重に作成してしまいます。

ポイントは 「①と②の間に、別の処理が割り込める」 こと。
この"すきま"こそがレースコンディションの温床です。

Railsでありがちな実例

  • 二重サインアップ … 同じメールで一瞬の差で2回登録される
  • 在庫の二重引き当て … 在庫1個を2人が同時購入 → 在庫マイナス
  • 二重課金 / ポイント二重付与 … 決済ボタン連打、リトライの重複

どれも「確認したときは大丈夫だったのに、実行する頃には状況が変わっていた」という同じ構造です。

対策

A. DBのユニーク制約

一番確実なのは、DB側に「重複を許さない」制約を貼ることです。

# マイグレーション
add_index :users, :email, unique: true

validates :email, uniqueness: true だけでは防げません。
これは「①SELECTで確認 → ②INSERT」が同時実行だとすり抜けます。
アプリのバリデーションは親切表示用、最終防壁はDBのユニーク制約、と考えるのが鉄則です(Railsガイドもユニークインデックスの併用を明記しています)。

# DB制約があれば、すり抜けても INSERT 時点で弾ける
begin
  User.create!(email: email)
rescue ActiveRecord::RecordNotUnique
  # 二重登録は握りつぶす or 既存を返す
end

B. 悲観ロック

在庫のように 「読んで → 更新する」 タイプは、ロックで処理を直列化します。
with_lock(中身は SELECT ... FOR UPDATE)で、他の処理を待たせてから処理します。

product.with_lock do          # この行を抜けるまで、他のトランザクションは待つ
  raise "在庫切れ" if product.stock < 1
  product.update!(stock: product.stock - 1)
end

ロックを取っている間、同じ行に触ろうとした他のリクエストは順番待ちになるので、「2人同時に在庫確認」が起きません。
※行ロックがかかるため、デッドロックや待ち時間が発生する可能性があるためなんでもかんでも使うのは避けるべきですが、競合が頻繁に起きる場合は確実な手段です。

C. 楽観ロック(optimistic lock)

「衝突が少なく、後からリカバリ可能」場合は、ロックで待たせるより楽観ロックが軽量です。
lock_version カラムを用意すると、更新が衝突したとき例外になります。

# lock_version カラムを追加しておくと…
product.update!(stock: product.stock - 1)
# 別の処理が先に更新していたら ActiveRecord::StaleObjectError → リトライ等で対応

D. 排他ロックキー(Redisの分散ロック)

ジョブやAPIの二重実行(Sidekiqワーカーが2回走る、リトライで決済が重複)も、本質はレースコンディションです。
悲観ロック(B)は DB1台の中の話。複数プロセス・複数サーバーをまたいで排他したいときは、Redisにロックキーを持たせます。

# SET key NX(キーが無いときだけセット)でロックを取れた1プロセスだけが実行
if redis.set("lock:job:#{id}", 1, nx: true, ex: 60)
  do_work
end
  • sidekiq-unique-jobs などのgemも、内部はまさにこのRedisロック
  • いわば「悲観ロックの、サーバーをまたぐ版

ロックキーは キー名を一意に決め打ちして、直接アクセスSET / GET / DEL)するのが鉄則です。
自前実装でKEYS pattern*SCAN毎回キーを探しに行くと、Redisが全キーを走査したりサーバーごとブロックみたいなことがあるため、注意してください!

E. 冪等性(idempotent)

そもそも 「同じ仕事を2回やっても結果が変わらない」 ように作れば、二重実行されても安全です。

  • 処理ずみIDを DBのユニーク制約で記録し、2回目はスキップ
  • 決済などは「冪等性キー(idempotency key)」で同じリクエストを1回だけに

ここでも前回の PROCESSED_IDS の教訓が効きます。「アプリのメモリ内(インスタンス変数)での重複チェック」は プロセスをまたぐと効かないので、必ず DBやRedisなど外部の共有ストレージで排他・記録するのが正解です。

どれを選ぶ?

対策 向いている場面 ひとこと
A. ユニーク制約 重複そのものを許したくない 必ず併用したい最後の砦
B. 悲観ロック 「読んで→更新」で競合が頻繁 確実だが待ちが発生
C. 楽観ロック 競合がめったに起きない、リカバリ可能 軽量、衝突時はリトライ
D. 分散ロック(Redis) ジョブ・APIの二重実行をサーバーまたぎで防ぐ 悲観ロックのサーバーまたぎ版
E. 冪等性 二重実行されても安全にしたい 2回やっても結果が変わらない設計

基本方針は 「アプリ側のチェックは過信しない。最終的な整合性はDB(制約・ロック)で守る」
迷ったら、まず ユニーク制約を貼っておくのが安全です。

最後に

レースコンディションは、「1つずつ動けば正しいのに、同時に動くと壊れる」 という、ローカルで再現しづらい厄介なバグです。
この類の調査に何度心折られたことか....

  • 原因の多くは check-then-act(確認→実行のすきま)
  • アプリ側のチェックは同時実行ですり抜ける → 最終防壁はDB(ユニーク制約・ロック)
  • ジョブ・APIの二重実行は 冪等性(プロセスをまたぐ重複対策)で

ただ、レースコンディションはアプリケーション側の設計・実装で防げることが多いのでまずはアプリ側の設計を見直しましょう!

26
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
26
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?