問題の背景
MySQLだけではなぜ防げなかったのか?
MySQLで抽選ごとの「最大当選可能数」と「現在の当選者数」を管理していた。
アクセスが集中すると、複数のユーザーがほぼ同時に抽選処理を実行。
あるリクエストが「現在の当選者数」を読み取り、「まだ上限に達していない」と判断してから、実際に当選者数をインクリメントして書き込むまでのごくわずかな時間差があった。
この間に別のリクエストも同様に「まだ上限に達していない」と判断し、結果的に両方のリクエストが当選処理を行ってしまう競合状態が発生。
データベースレベルでの適切なロックや、アプリケーションレベルでの排他制御が施されていなかったため、当選者数が上限を超過する事態を招いた。
図で見るとこんな感じ
Redisを使っても上記の図のような挙動に
$remaining = Redis::incr('present_win_count');
$total_win_count = Redis::get('total_win_count')
if ($remaining < $total_win_count) {
// 当選処理
} else {
// 落選処理
}
上記のように一回Redisからpresent_win_count
値を取得→当選処理を行うと結局Mysqlを使ったデータ処理と同じフローになる。
解決策
RedisとLuaスクリプト
Luaスクリプトを使うと全体をアトミックに(不可分な操作として)実行。
つまり、スクリプトの実行が開始されたら、それが完了するまで他のどのコマンドも割り込むことができない。
以下のロジックをLuaスクリプトとして記述し、Redisサーバー上で実行。
- 現在の当選者数を取得
- 取得した当選者数が、あらかじめ設定された最大当選可能数未満かチェック
- もし上限未満であれば、当選者数を1増加させ、当選したことを記録
- 上限に達していれば、落選処理
感想
Redisはこれまでキャッシュ用途にしか使っていなかった。
シングルスレッド特性とLuaスクリプトの組み合わせによって、同時実行制御といったユースケースにも十分に対応できることを実感した。
今回の経験は、Redisの可能性を再認識し、今後より多様な場面で活用していきたいという気づきにつながった!