複数のPHPワーカーが同じリソースに対するタスクを同時に実行できる場合、ロックによって重複処理を減らせます。レポートの生成、注文の処理、共有リソースの更新などでよく検討されます。しかし、Redisを使うだけで処理の整合性が保証されるわけではありません。プロセスが一時停止することも、ロックが期限切れになることもあり、最初のワーカーが処理を終える前に別のワーカーが開始する可能性があります。
重要なのはキーの作り方だけではなく、どの性質を守る必要があり、それをどのシステムが保証できるかです。多くの場合、一意制約やデータベーストランザクションのほうが、より直接的な保証を提供します。PHPのRedis分散ロックは、単一のアトミックなデータベース操作では解決できないプロセス間の調整に役立ちます。ただし、最終的な真実の根拠ではなく、一時的な保護手段として設計する必要があります。
分散ロックで解決できること、保証できないこと

分散ロックを使うと、異なるサーバー上にある可能性のある複数のプロセスが、一時的な排他権を獲得しようと競合できます。あるワーカーがロックを取得した場合、ロックが有効な間、ほかのワーカーはクリティカルセクションに入らないようにします。たとえば、複数のプロセスが高コストなタスクを同時に開始するのを防いだり、外部サービスに影響する処理を調整したりするのに役立ちます。
ロックだけでは、書き込みが1回しか発生しないこと、所有者であるプロセスが稼働し続けること、Redisとデータベースの状態が整合することは保証されません。ロックはリースのような一時的な許可です。ワーカーが有効期間を超えて停止すると、再開後も最初のワーカーが処理を続けているのに、Redisが別のワーカーにロックを許可することがあります。
そのため、アプリケーションはRedisのキーだけに頼ってビジネスルールを保護すべきではありません。最終的な処理には、状況に応じてバリデーション、整合性制約、バージョン管理、冪等性などの独自の防御策が必要です。
Redisを使う場面とデータベースを選ぶ場面
ロックを追加する前に、維持したい条件を特定してください。たとえば、あるエンティティに同じ識別子を持つレコードが2つ作られるのを防ぐ場合、データベースの一意制約ならデータを書き込む場所でルールを表現し、強制できます。すべてのワーカーが外部プロトコルを守ることに依存しません。
複数の変更をまとめて確定する必要があるなら、トランザクションで十分な場合があります。並行更新による上書きを防ぐには、楽観的ロックが適することがあります。バージョンを読み込み、変更されていない場合にのみ更新し、競合を明示的に処理します。行やデータセットを操作する場合は、データベース自体の悲観的ロックが適切なこともありますが、対象範囲と保持時間を制限してください。
複数プロセス間の処理調整が必要で、単一のデータベース操作では解決できない場合や、多数のワーカーが同じ反復タスクを同時に開始するのを防ぐ必要がある場合、Redisは有力な選択肢です。待機や再試行、Redisの運用にかかるコストが妥当かどうかも評価してください。単一の一意キーでより少ない構成要素のまま問題を解決できるなら、通常はそちらが望ましい選択です。
アトミックな取得、単一の所有者、有効期限
ロックの取得は、キーが存在しない場合にのみ作成し、同じ操作で有効期限を設定する、単一のアトミックな処理にする必要があります。Redisでは通常、「存在しない場合のみ」に相当するオプションと有効期間を指定したSETを使います。キーの作成と有効期限の設定を分けると競合状態が生じます。2つの処理の間でプロセスが失敗すると、期限のないロックが残る可能性があります。
キーの値には、すべてのワーカーで共通の固定値ではなく、その試行ごとに生成した一意なトークンを設定してください。トークンによって所有者を特定でき、あるプロセスが後から別のプロセスの取得したロックを誤って削除するのを防げます。PHPクライアントは、Redisが条件付き操作の成功を確認した場合に限り、ロックを取得したと判断してください。
// 概念的な例。使用するRedisライブラリに合わせて呼び出しを調整してください。
$token = generateUniqueToken();
$acquired = redisSetIfNotExistsWithExpiration($key, $token, $duration);
if (!$acquired) {
// クリティカルセクションに入らない。
}
有効期限は想定される処理時間を上回る必要がありますが、障害時にほかのプロセスを不必要に長く待たせるほど長くしてはいけません。すべてのケースに通用する値はありません。実際の処理時間を測定し、その変動を考慮したうえで、上限に近づいた処理をどう扱うかを決めてください。リースの更新時にも、トークンによって現在の所有者であることを確認する必要があります。無条件に更新すると、別のワーカーのロックを延長してしまう可能性があります。
安全な解放と有効期限切れのリスク
処理の終了時に、単純な削除でキーを消してはいけません。元のロックが期限切れになり、別のプロセスが新しいロックを取得した後で、最初のワーカーが2つ目のキーを削除してしまう可能性があります。解放時には、保存された値と所有者のトークンを比較し、一致する場合に限ってキーを削除する処理をアトミックに行う必要があります。Redisで実行するスクリプトや、比較と削除を途中で分割せずに実行する同等のプリミティブで実装できます。
取得と解放が正しくても、プロセスの長時間停止によって並行書き込みが発生することがあります。ワーカーAがロックを取得した後に停止し、リースが期限切れになると、Bがロックを取得して書き込みます。その後Aが再開し、同じく書き込みを行う可能性があります。さらに悪いケースでは、ネットワーク分断や接続障害により、クライアントが取得操作の実行有無を判断できなくなることもあります。
遅れて到着した書き込みが損害を招く場合、保護対象のリソース側で独立してその書き込みを拒否する必要があります。たとえば、増加するフェンシングトークンやバージョンを使い、書き込み先のシステムに古い値を破棄させる方法があります。そのカウンターには、用途に応じた順序性と永続性が必要です。再起動やフェイルオーバーを考慮せずに、Redisのカウンターが自動的に保証を提供すると見なしてはいけません。別の方法として、書き込み操作自体の中でデータベースのバージョンを検証できます。
クリティカルセクション、競合、取得失敗
ロックの対象には、排他制御が本当に必要な処理だけを含めてください。遅い外部サービスへの呼び出し、ユーザーの応答待ち、独立して実行できる処理は避けます。クリティカルセクションが長くなるほど、競合と有効期限超過の可能性が高まります。処理を分割できるなら、短時間の調整処理と共有状態に依存しないタスクを分けてください。
ロックを取得できなかった場合、無期限に再試行したり、長時間待機させてPHPワーカーを占有したりするのは避けてください。処理の重要性に応じて、競合を記録して中止する、待機時間に上限を設けて再試行する、キューに送って後で処理するといった方針を決めます。再試行は安全かつ冪等でなければならず、通常の競合とRedisの障害をシステム上で区別できるようにしてください。
接続状態が不確かな場合、ロックを取得したとも取得していないとも決めつけてはいけません。取り消せない処理が二重に実行されずに復旧できるよう、フローを設計してください。たとえば、ビジネス処理に冪等性キーを使い、再実行前に結果を照会します。
テストと運用上の指標
正常系だけでなく、複数のワーカーを使い、処理の割り込みや実行順序を制御して動作をテストしてください。解放前にプロセスが終了するケース、処理中に有効期限が切れるケース、Redisが応答しないケース、別のワーカーがロックを取得した後で古いワーカーが解放しようとするケースを含めます。トークンによって他者のキーが削除されないことや、アプリケーションが保証を必要とする場合に遅延した書き込みが拒否されることを確認してください。
本番環境では、取得失敗、クリティカルセクションの処理時間、有効期限切れ、更新、接続エラー、再試行を監視します。競合率の上昇は、ロックの粒度が粗すぎる、またはロック内で必要以上の処理をしている兆候かもしれません。通常のタスク中に期限切れが頻発するなら、単にTTLを自動的に延長するのではなく、リース時間やプロセスの設計を見直す必要があります。
判断のためのチェックリスト

- どのビジネスルールを保護するのか。また、データベースの制約やトランザクションで表現できるか。
- 取得はアトミックかつ条件付きで、有効期限も同時に設定されるか。
- 試行ごとに一意なトークンを使い、解放時に所有者を比較してから削除しているか。
- プロセスがリース期間を超えて停止した場合や、Redisとの接続が失われた場合、どうなるか。
- 書き込み先のシステムは、古いバージョンや重複を拒否できるか。
- 待機、再試行、復旧に上限を設けた方針があり、メトリクスと並行処理のテストも整っているか。
Redisが提供するのは一時的な調整であり、あらゆる状況に通用する整合性保証ではありません。分散プロセスの調整が本当に必要な場合に使い、最終的な保護はデータを書き込むシステムに持たせてください。データベースでより簡単にルールを強制できるなら、まずはそこから始めましょう。



