もしECサイトの人気商品に秒間100万リクエストが来たとしたら


売過ぎ

ECサイトにおいて、人気商品に同時に沢山の購買リクエストが来ることがあります。
今のシステムは複数のサーバーを使ってリクエストを分散させて処理することが多いが、
同じ在庫データに複数の購買処理が走ったらどうなるか?

商品の在庫数: 100
サーバー1とサーバー2が同時に在庫数を取得する。
サーバー1.在庫数取得() -> return 100
サーバー2.在庫数取得() -> return 100

購買処理をする
サーバー1.在庫数更新(100 - 1) -> return 99
サーバー2.在庫数更新(100 - 1) -> return 99

つまり、実際商品2個売ったけど、
プログラムの処理で在庫数1しか減らない。
これを繰り返すと、売過ぎが発生し、
顧客が商品を買ったのに、最終的に商品を供給できないことが起きます。

Lockをかける

上記のような売過ぎを防ぐにはLockを使うとうまく解決します。
ここで性能を考えてMySQLなどのロックではなくredisによるLockの実装方法を考えます。

オリジナル販売ロジック

$stock = $redis->getStock($productCode);
if ($stock > 0) {
    $this->makePurchase();
    $stock -= 1;
    $redis->setStock($productCode, $stock);
}

これはストックを更新するもっともシンプルなコード。
シングルサーバーの場合は何も問題ないが、
マルチサーバー x 並行処理になると売過ぎが発生します。

Lockをかける

$lockKey = 'lockKey';
$lock = $redis->setnx($lockKey, 'lock'); # ロックをかけてみる return bool
if (!lock) {
    # ロックがすでに存在していて、購入を進めるべきではない
    return "errorCode";
}

$stock = $redis->getStock($productCode);
if ($stock > 0) {
    $this->makePurchase();
    $stock -= 1;
    $redis->setStock($productCode, $stock);
}

$redis->delete($lockKey);

これで複数スレッドが来でも、最初のスレッドしかストックを更新することができず、
ストック数の一致性が保証されました。

しかし問題がありました。

ロックを確保できるのは$redis->setnx($lockKey, 'lock');の実行が成功したスレッドのみ、
他のスレッドはエラーで返すようになる。並行処理を使っているのにシングルスレッドかのようにしか実行できません。
処理が遅くなります。

もし$redis->delete($lockKey);が実行されるまでエラーになって、ロックを開放できなくなると、
デッドロックが発生し、その後のスレッドがもうロックをかけることも、解くこともできず、
購入ももちろんできません。
機能として破綻します。

try finallyを追加する

$lockKey = 'lockKey';
try {
    $lock = $redis->setnx($lockKey, 'lock'); # ロックをかけてみる return bool
    if (!lock) {
        # ロックがすでに存在していて、購入を進めるべきではない
        return "errorCode";
    }

    $stock = $redis->getStock($productCode);
    if ($stock > 0) {
        $this->makePurchase();
        $stock -= 1;
        $redis->setStock($productCode, $stock);
    }
} finally (\Throwable $th) {
    $redis->delete($lockKey);
}

これでスレッド内に何があっても最終的にロックを解放することができます。
しかし、もし$redis->delete($lockKey);が実行される前にプロセスがkillされ、あるいはサーバーがダウンしたらロックは解放されないまま残っています。

ロックにexpired timeを追加する

try {
    $lock = $redis->setnx($lockKey, 'lock'); # ロックをかけてみる return bool
    $redis->expired($lockKey, 60);
    // ...
} finally (\Throwable $th) {
    $redis->delete($lockKey);
}

これでシステムがダウンしても時間になったらロックが自動的に外されます。
ただ、タイミング的に$redis->expired($lockKey, 60);が実行される前にプロセスがkillされたらデッドロックが発生します。

try {
    $lock = $redis->setnx($lockKey, 'lock', 60); # ロックをかけてみる return bool
    // ...
} finally (\Throwable $th) {
    $redis->delete($lockKey);
}

それでロックをかける時にexpired timeを設定してあげます。
ここまでできればある程度に本番環境で使えるコードになりました。
ただ、本当にリクエスト数が多い場合問題はあります。

下図のようにスレッドの実行時間がことなるから、スレッド1がスレッド2のロックを解くという意図しない動きをすることがあります。

この場合はロックのexpired timeを都度延長してあげるといいでしょう。

redisson

上記の機能を簡単に実装してくれるリポジトリがすでに存在しています。
redisson https://github.com/redisson/redisson
を使うと簡単に実装することができます。