レースコンディション

複数のプロセスやスレッドが共有リソースに同時アクセスし、実行順序によって結果が変わる不具合

並行処理品質

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

レースコンディション (Race Condition) は、複数のプロセスやスレッドが共有リソース (変数、ファイル、DB レコード) に同時にアクセスし、実行順序によって結果が予期しない値になる不具合である。再現が困難で、テストで検出しにくい厄介なバグだ。

典型的な例: 在庫の二重引き当て

在庫が 1 個しか残っていない商品に、購入リクエストが 2 つほぼ同時に届いた場面を追ってみる。どちらの処理も「在庫を読む」「在庫を減らす」の 2 段階に分かれており、この 2 段階の隙間に相手の更新が入り込む。

在庫: 1個

リクエスト A: 在庫を確認 → 1個ある → 購入処理
リクエスト B: 在庫を確認 → 1個ある → 購入処理  ← A の更新前に読んだ!

結果: 在庫 1 個なのに 2 人が購入 → 在庫が -1

「確認」と「更新」の間に別のリクエストが割り込むことで発生する。これを TOCTOU (Time of Check to Time of Use) 問題と呼ぶ。

割り込みが起こり得る時間は確認から更新までの区間だけなので、処理が速ければ窓は狭くなるが、ゼロにはならない。アクセスが少ないうちはたまたま重ならず正常に見え、同時アクセスが増えた途端に頻発する。手元で 1 リクエストずつ試す限り再現しないのは、テストが窓を踏まないからであり、バグが無いからではない。

DynamoDB での対策: 条件付き書き込み

読み取りと書き込みを別々の API 呼び出しに分けている限り、その間の割り込みは防げない。DynamoDB では、更新してよい条件を更新リクエスト自体に同梱することで、確認と更新を 1 回の操作にまとめられる。

// ❌ レースコンディション: 読み取りと書き込みの間に割り込まれる
const item = await ddb.send(new GetCommand({ TableName: 'Products', Key: { id: 'prod-1' } }));
if (item.Item!.stock > 0) {
  await ddb.send(new UpdateCommand({
    TableName: 'Products',
    Key: { id: 'prod-1' },
    UpdateExpression: 'SET stock = stock - :dec',
    ExpressionAttributeValues: { ':dec': 1 },
  }));
}

// ✅ 条件付き書き込み: アトミックに確認と更新を実行
await ddb.send(new UpdateCommand({
  TableName: 'Products',
  Key: { id: 'prod-1' },
  UpdateExpression: 'SET stock = stock - :dec',
  ConditionExpression: 'stock > :zero',
  ExpressionAttributeValues: { ':dec': 1, ':zero': 0 },
}));
// stock が 0 以下なら ConditionalCheckFailedException がスロー

更新式の中に数値をそのまま書くことはできず、減らす量も :dec のような式属性値として渡す。また、条件を満たさなかったときは例外が返るだけで在庫は動かないため、呼び出し側で ConditionalCheckFailedException を捕まえて「売り切れ」として扱う分岐が必要になる。捕まえ忘れると、正常な在庫切れがサーバーエラーとしてユーザーに見えてしまう。

RDB での対策

楽観的ロック (バージョン番号)

-- 読み取り時にバージョンを取得
SELECT stock, version FROM products WHERE id = 'prod-1';
-- stock: 5, version: 3

-- 更新時にバージョンを条件に含める
UPDATE products SET stock = stock - 1, version = version + 1
WHERE id = 'prod-1' AND version = 3;
-- 他のトランザクションが先に更新していたら、version が 4 になっており、0 行更新

悲観的ロック (SELECT FOR UPDATE)

BEGIN;
SELECT stock FROM products WHERE id = 'prod-1' FOR UPDATE;  -- 行ロック
UPDATE products SET stock = stock - 1 WHERE id = 'prod-1';
COMMIT;

Lambda でのレースコンディション

Lambda は同時に複数のインスタンスが実行されるため、共有リソース (DynamoDB、S3) へのアクセスでレースコンディションが発生しやすい。

対策:

  • DynamoDB の条件付き書き込み (ConditionExpression)
  • DynamoDB のアトミックカウンター (SET #count = #count + :incCOUNT は予約語なので ExpressionAttributeNames で別名を付ける)
  • 分散ロック (DynamoDB ベースのロック)
  • SQS FIFO キューで直列化 (順序が保証されるのは同じメッセージグループ ID の中だけなので、直列にしたい単位をグループ ID に選ぶ)

競合状態の対策

どの対策も「割り込みが入らない単位を作る」ことに行き着くが、単位の作り方が違う。ロックを取って他を待たせるか、衝突を後から検出して弾くか、確認と更新を 1 回の操作にまとめるか、順番に 1 つずつ処理するかの 4 通りである。

対策説明AWS での実装
ミューテックスロックを取った 1 つだけが進むDynamoDB ベースの分散ロック (有効期限付き)
楽観ロックバージョン番号で検出DynamoDB ConditionExpression
アトミック操作確認と更新を 1 回の操作にまとめるDynamoDB の更新式による加減算
キュー直列に 1 つずつ処理SQS FIFO (メッセージグループ ID 単位)

衝突がまれなら、楽観ロックやアトミック操作で待ち時間を作らずに済む。衝突が多い場所では再試行が空回りして処理量が落ちるため、ロックや直列化に寄せる。分散ロックを選ぶときは、ロックを持ったまま処理が落ちても解放されるよう有効期限を必ず付ける。期限の無いロックは 1 度の異常終了で在庫が永久に止まる。

現場での応用を知るには関連書籍も役立つ。

この記事は役に立ちましたか?

関連用語

関連する記事