YASD-TECH
YASD TECH
# DB

レースコンディション

投稿日:2026/5/9

更新日:2026/6/21

ttitleImage

はじめに

Webアプリは複数ユーザーが同時に使います。
この「同時実行」が原因で、まれにデータが壊れる不具合が起きます。これがレースコンディションです。

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

処理の順番次第で、結果が変わってしまう状態です。

たとえば在庫1個の商品に、同時に2人が注文した場合:

  1. Aさんが「在庫あり」を確認
  2. Bさんも「在庫あり」を確認
  3. Aさんが購入
  4. Bさんも購入

この結果、在庫がマイナスになることがあります。

なぜ起きるのか

「確認(SELECT)」と「更新(UPDATE)」を分けて書くと起きやすくなります。

-- 危険な例
SELECT stock
FROM products
WHERE product_id = 1;

-- アプリ側で stock > 0 を確認してから...

UPDATE products
SET stock = stock - 1
WHERE product_id = 1;

この書き方だと、AさんとBさんが同時に SELECT した時点では、どちらも「在庫あり」と判断できてしまいます。

基本対策

レースコンディション対策は、次の順で考えると整理しやすいです。

  1. 1クエリで原子的に更新する
  2. DB制約を入れる
  3. トランザクションを使う
  4. 必要に応じて行ロックを使う
  5. 処理全体の二重起動防止には advisory lock を使う

1. 1クエリで原子的に更新する

一番シンプルで強い対策です。
「確認」と「更新」を分けず、更新条件にビジネスルールを入れます。

-- 在庫がある時だけ減らす(安全な例)
UPDATE products
SET stock = stock - 1
WHERE product_id = $1
  AND stock > 0;

このSQLは、stock > 0 を満たす場合だけ更新します。
更新件数が 1 なら購入成功、0 なら在庫不足として扱います。

-- 残高が足りる時だけ引き落とす例
UPDATE accounts
SET balance = balance - 1000
WHERE account_id = $1
  AND balance >= 1000;

このように、可能なら SELECTしてから判断するのではなく、UPDATEの条件で守る のが基本です。

2. DB制約を入れる

アプリ側のバリデーションだけでは、実装漏れや直接SQL操作ですり抜ける可能性があります。
最終的にはDB制約で壊れたデータを拒否します。

代表的な制約:

  • UNIQUE: 重複登録を防ぐ
  • CHECK: 不正な値を防ぐ
  • FOREIGN KEY: 存在しない関連データを防ぐ
  • NOT NULL: 必須値の欠落を防ぐ
-- 同じユーザーが同じイベントに重複申込できないようにする
ALTER TABLE event_entries
ADD CONSTRAINT event_entries_user_event_unique
UNIQUE (user_id, event_id);

この制約があれば、同時に2つの申込リクエストが来ても、最終的にDBが重複を拒否します。

-- 在庫がマイナスにならないようにする
ALTER TABLE products
ADD CONSTRAINT products_stock_non_negative
CHECK (stock >= 0);

DB制約は、レースコンディション対策の「最後の砦」です。

3. トランザクションを使う

複数のSQLを1つのまとまった処理として扱いたい場合は、トランザクションを使います。

BEGIN;

UPDATE products
SET stock = stock - 1
WHERE product_id = $1
  AND stock > 0;

INSERT INTO orders (user_id, product_id)
VALUES ($2, $1);

COMMIT;

途中で失敗した場合は ROLLBACK して、在庫だけ減って注文が作られないような中途半端な状態を防ぎます。

ただし、トランザクションを使うだけで全てのレースコンディションが消えるわけではありません。
トランザクション内でも「SELECTしてからUPDATE」のような危険な読み書きをすれば、分離レベルやロックの取り方によっては問題が残ります。

4. SELECT ... FOR UPDATE を使う

特定の行を読み、その後に同じ行を更新する必要がある場合は、行ロックを取ります。

BEGIN;

SELECT *
FROM products
WHERE product_id = $1
FOR UPDATE;

-- 在庫確認、注文作成など

UPDATE products
SET stock = stock - 1
WHERE product_id = $1;

COMMIT;

FOR UPDATE を使うと、同じ行を更新しようとする他のトランザクションは待たされます。
複雑な業務ロジックで、どうしても読み取り結果をもとに後続処理をしたい場合に使います。

注意点:

  • ロックを持つ時間を短くする
  • 必要な行だけをロックする
  • ロック対象列に適切なインデックスを張る
  • 外部API呼び出しのような遅い処理をロック中に実行しない

5. advisory lock を使う

advisory lock は、DBが提供する「任意キーの排他制御」です。
行やテーブルに直接紐づかない処理を、同時に1つだけ実行したい場合に使います。

SELECT pg_try_advisory_lock(1001);

向いている用途:

  • 日次バッチの二重起動防止
  • 外部API同期処理の二重起動防止
  • マイグレーションの同時実行防止
  • 特定の業務ID単位での処理全体の排他制御

在庫更新のように、特定の行の整合性を守るだけなら、まずは原子的UPDATEや行ロックを優先します。
advisory lock は「処理全体を同時に1つだけにしたい」ときに向いています。

使い分け

やりたいこと 主な対策
在庫をマイナスにしたくない 原子的UPDATE + CHECK制約
同じユーザーの重複申込を防ぎたい UNIQUE制約
複数SQLをまとめて成功・失敗させたい トランザクション
読んだ行を後で確実に更新したい SELECT ... FOR UPDATE
バッチを同時に1つだけ動かしたい advisory lock
複数サービス・複数DBをまたいで排他したい 分散ロックやキューを検討

よくあるアンチパターン

アプリ側のチェックだけで守る

在庫をSELECT
アプリ側で stock > 0 を確認
UPDATEで在庫を減らす

同時実行されると、複数リクエストが同じ古い値を見てしまいます。

DB制約を入れない

アプリの実装が正しくても、将来の変更や管理画面、手動SQL、バッチ処理で不正データが入る可能性があります。
重要な不変条件はDB制約にします。

ロック中に遅い処理をする

トランザクション開始
行ロック取得
外部API呼び出し
DB更新
コミット

外部APIが遅いと、その間ずっと他の処理が待たされます。
ロックを取る範囲はできるだけ短くします。

パフォーマンスとスケーラビリティの注意

  • ロックを広く取りすぎると、同時実行性能が落ちる
  • 同じ1行に更新が集中するとボトルネックになる
  • インデックス不足は待ち時間を増やす
  • トランザクションを長くすると、ロック待ちやデッドロックが増える
  • retry 前提の設計が必要な場合がある

「正しさ」と「速さ」の両方を確認することが重要です。

まとめ

レースコンディション対策は、
アプリの工夫 + DBの仕組み + 制約の組み合わせが基本です。

特に重要なのは、次の考え方です。

  • 可能なら1クエリで原子的に更新する
  • 重要なルールはDB制約で守る
  • 複数SQLが必要ならトランザクションを使う
  • 読んだ行を後で更新するなら行ロックを検討する
  • 処理全体の二重起動防止には advisory lock を使う

学習リソース

Index

  • はじめに
  • レースコンディションとは
  • なぜ起きるのか
  • 基本対策
  • 1. 1クエリで原子的に更新する
  • 2. DB制約を入れる
  • 3. トランザクションを使う
  • 4. SELECT ... FOR UPDATE を使う
  • 5. advisory lock を使う
  • 使い分け
  • よくあるアンチパターン
  • アプリ側のチェックだけで守る
  • DB制約を入れない
  • ロック中に遅い処理をする
  • パフォーマンスとスケーラビリティの注意
  • まとめ
  • 学習リソース