はじめに (対象読者・この記事でわかること)

この記事は、負荷分散のためにWebサーバを複数台構成し、NFSでファイルを共有しながらPHPとMySQLを使っている開発者・インフラエンジニアを対象にしています。
特に「同時リクエストで在庫がマイナスになる」「キャッシュファイルが壊れる」「重複注文が発生する」といった排他制御の悩みを抱えている方に最適です。

記事を読むことで、以下のことがわかります。

  • NFS上のファイルを安全に更新する方法
  • MySQLの行ロック・テーブルロックの使い分け
  • PHPで実装可能な楽観ロック・悲観ロックのコード例
  • 分散環境で「単一の真実」を保つための実践的な設計パターン

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - PHPの基本的な文法とPDOの利用経験 - MySQLのトランザクションとロックの概念 - LinuxにおけるNFSマウントの仕組み

なぜ排他制御が難しいのか〜分散+共有ストレージの落とし穴

Webサーバを2台用意し、NFSで/var/www/shareを共有することで、ファイルのアップロードやキャッシュの書き出しがどちらのサーバからでも可能になります。
しかし、同時に同じファイルを更新しようとすると、最後の書き込みが他を上書きしてしまう「Lost Update」が発生します。
同様に、在庫テーブルの残数をPHPでSELECT → 計算 → UPDATEするだけでは、2重注文で在庫がマイナスになる「超売り」が起きかねません。

この問題を解決するには、単一サーバ時代の「ファイルロック(flock)」や「トランザクション」では不十分です。
分散環境では、NFSのキャッシュ・属性キャストタイム、MySQLの分離レベル、PHPの実行時間まで含めて設計する必要があります。

実装編:PHP+MySQL+NFSで安全な排他制御を実現する

ステップ1:NFSファイル更新を「楽観ロック+一意ファイル名」で安全化

まず、キャッシュファイルやCSVをNFS上で更新するケースを考えます。
flockはNFSで有効ですが、PHPプロセスが死ぬとロックが残るため、タイムスタンプ付きの「楽観ロック」方式を採用します。

Php
$metaFile = '/var/www/share/cache/meta.json'; $lockFile = $metaFile . '.' . time() . '.lock'; // 1. 自分専用のロックファイルを作成(O_EXCLで既存チェック) $fp = @fopen($lockFile, 'x'); if (!$fp) { throw new RuntimeException('他のプロセスが更新中です'); } fclose($fp); try { // 2. メタデータ読み込み&更新 $meta = json_decode(file_get_contents($metaFile), true); $meta['count']++; file_put_contents($metaFile, json_encode($meta)); } finally { // 3. ロック解除 unlink($lockFile); }

ポイントは、ロックファイルにtime()を含めることで、万が一PHPが死んでも次のリクエストが新しいロックファイルを作れることです。
また、O_EXCLフラグでファイル作成をアトミックに行うため、競合が発生しても例外が飛ぶだけでデータが壊れることはありません。

ステップ2:在庫減算を「MySQLの行ロック(SELECT ... FOR UPDATE)」で排他

次に、在庫テーブルの排他です。
トランザクション内でSELECT ... FOR UPDATEを使うことで、該当行に対して他のトランザクションがUPDATEできないようにできます。

Php
$pdo->beginTransaction(); try { // 1. 在庫行を悲観的にロック $sql = 'SELECT stock FROM items WHERE id = :id FOR UPDATE'; $stmt = $pdo->prepare($sql); $stmt->execute([':id' => $itemId]); $stock = $stmt->fetchColumn(); // 2. 在庫チェック&減算 if ($stock < $quantity) { throw new Exception('在庫不足'); } $sql = 'UPDATE items SET stock = stock - :qty WHERE id = :id'; $pdo->prepare($sql)->execute([ ':qty' => $quantity, ':id' => $itemId, ]); // 3. 注文レコード挿入 $sql = 'INSERT INTO orders (item_id, qty) VALUES (:id, :qty)'; $pdo->prepare($sql)->execute([ ':id' => $itemId, ':qty' => $quantity, ]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; }

FOR UPDATEは行レベルでロックを取得するため、Webサーバが増えてもMySQLが排他を保証します。
なお、分離レベルはデフォルトのREPEATABLE READで十分です。SERIALIZABLEにするとデッドロックが増えるだけです。

ステップ3:デッドロックを回避するための「順序ルール」と「再試行」

分散環境ではデッドロックが発生しやすくなります。
対策として、以下の2つを守ります。

  1. テーブルアクセス順を統一(items→ordersの順で必ずロック)
  2. デッドロック検出時は3回まで再試行
Php
$retry = 3; while ($retry--) { try { $pdo->beginTransaction(); // 上記同様の在庫減算処理 $pdo->commit(); break; } catch (PDOException $e) { $pdo->rollBack(); if (strpos($e->getMessage(), 'Deadlock found') !== false && $retry) { usleep(100000); // 0.1秒待ってリトライ continue; } throw $e; } }

ハマった点:NFSの「attribute cache」でロックが見えない

flockを使った排他を試みたところ、サーバAでロック取得済みにもかかわらず、サーバBからはロックが取得できてしまう現象が発生しました。
これは、NFSクライアントが属性キャッシュ(acdirmaxなど)を保持しているため、ファイルの状態が即座に反映されないためです。

解決策:楽観ロック+ロックファイル廃棄でシンプルに

結局、flockを諦め、ステップ1で紹介した「ロックファイルの有無」で排他する方式に切り替えました。
NFS上でO_EXCL作成はサーバ間で即時反映されるため、キャッシュの影響を受けません。
また、ロックファイルに有効期限(mtime+30秒)を持たせ、古い場合は強制削除するcronを回すことで、プロセス死による永久ロックも回避しています。

まとめ

本記事では、Webサーバx2+NFS+MySQLという分散環境で、ファイル更新と在庫減算の競合を防ぐ排他制御を実装しました。

  • NFS上のファイル更新には「楽観ロック+一意ロックファイル」で安全に
  • 在庫などのDB更新には「SELECT ... FOR UPDATE」で行レベル排他
  • デッドロックは「順序統一+再試行ループ」で吸収

この記事を通して、単一サーバ時代の常識をそのまま適用するとデータ不整合が起きることを理解し、分散環境に適したロック戦略を身に付けていただければ幸いです。
次回は、RedisやConsulを使った「分散ロックサーバ」の構築と、スケールアウトに耐える在庫予約APIの設計について深掘りします。

参考資料