注意! 大抵の場合、「lockForUpdate」では多重投稿対策できません。※1
時を超えて多数のLGTMをいただいており恐縮ですが、別の方法を取ることをお勧めします。
別の方法を使ったロックは以下の記事がわかりやすいのでお勧めです。
https://qiita.com/amitsuoka/items/60644c459b6a97a5fee1
※1 MySQLのトランザクション分離レベルのデフォルト値がRepeatable Readであり、この場合、select for updateによるロックが機能しないため
トランザクション分離レベルは影響度の大きな変更となりますので安易に変更しない方がよいです。
これはなに?
Laravelにおいて、DBの読み込みを行った後に書き込みを行うケースで発生するレースコンディションの解決方法について紹介します。
概要
ブラウザで送信ボタンの連続クリックを行った場合、サーバサイドではPHPのプロセスがほぼ同時に並列して実行されることがあります。
その状況において、DBの読み込みと書き込みを行う処理で不整合が生じてしまう問題(レースコンディション)が発生する場合があり、
その問題をデータベースの占有ロックを行うことで回避することができたので、その方法を紹介します。
多重投稿防止のためには、HTMLやJS側でそもそもHTTPリクエストを多重に送らないようにする対応もできますが、
今回はサーバサイドで、ほぼ同時にリクエストがあった時に問題が起こらないように対応する方法について記載してます。
具体例紹介
例えば、以下のようなビジネスロジックがあったとします。
①DB上のレコードAを取得する
②レコードAがXでなければ処理を終了する(SQLではなく、Laravel側で判定したい前提)
③DB上のレコードAの値をYに変更する
④成功したら、通知メールを送信する
このロジックを工夫せずに実装した場合を考えます。
プログラムを順番(直列)に実行した場合
1度目の実行では、レコードAの初期値がX
だったとすると、
①→②→③→④→END
と処理が進みます。
2度目の実行では、レコードAの値はすでにY
に書き換わっているので
①→②→END
のように、処理が終了します。
ところが、ほぼ同時に実行された場合、おかしなことが起こります。
プログラムをほぼ同時(並列)に実行した場合におかしくなる
プログラムをほぼ同時に2度実行した場合、
1度目と2度目の両方で
①→②→③→④
というように処理が実行されてしまいます。
本来は一度しか実行されないはずである「処理④ メール通知」が実行されてしまいました。
処理①
で取得された値が、どちらも値X
であるから、その後の処理②
の判定でTRUE
となり、
後続の処理③④
の処理が実行されてしまったのです。
これはどのようにしたら防ぐことができるでしょうか。
レコードの占有ロックを利用した対策
この問題は、①〜④の処理をトランザクション
として、①で読み込むレコードを占有ロック
することで回避できます。
占有ロック
は、ロックしたトランザクション以外からのレコードの読み込み・書き込みをロックするものです。
処理①(DB読み込み)
を実行した時点から処理④が終わるまでの間、
他のプロセスでの処理①(DB読み込み)
を待たせておけば、今回のレースコンディションは発生しません。
プロセス1 START→①→②→③→④→END
プロセス2 START→待→待→待→待→①→②→END
Laravelによる占有ロックの実現
DBはMySQLのInnoDBを利用している前提とします。
LaravelではクエリビルダーでlockForUpdate()
を指定すると、占有ロックをかけることができます。
DB::table('users')->where('votes', '>', 100)->lockForUpdate()->get();
ちなみに、占有ロックは、MySQLではselect for update
というらしいです。
ロックは、トランザクションの終了時に解放されます。
なお、並列処理におけるDBアクセスとロックは、デッドロックという厄介な問題を引き起こす可能性を孕んでいますので、
その点については十分に注意してください。
実装例
とても簡略化したコードです。
実際のアプリケーションでは、ドメインオブジェクトやEloquentなどクラスに分割したりしています。
DB::transaction(function () use ($id) {
//処理① DBからデータ取得 lockForUpdateが肝
$model = ModelA::where('id', 1)->lockForUpdate()->first();
//処理② 値Xであるかの判定処理
if ($model->isNotX()) {
return; // END
}
//処理③ DBに保存する処理
$model->setY();
//処理④ 後続処理
echo "Finished!"
});
占有ロックしても、Selectできちゃいます。そう、Repeatable Read
ならね
占有ロックについて、レコードの読み込み
もロックすると書きましたが、
DBのトランザクション分離レベル
がRepeatable Read
の場合、
実は、SelectForUpdate
を指定しないSelect文では、読み込みすることが可能です。
SelectForUpdate
を指定しないSelect文で読み込みを行った場合、ロック中のレコードが別のトランザクションで占有ロックされていても、その直前の値が取得されてしまいます。
何を言っているのかうまく伝わらないかもしれませんが、直感に反する動きをするので注意してください。
詳しくは以下の記事がわかりやすかったので、ご参照ください。
MySQLのデフォルトの設定では、トランザクション分離レベル
がRepeatable Read
になっています。
(この辺は勉強不足なので間違っていたらすみません)
補足:CSRFトークンがあるから多重投稿されないのでは?
答えはNOです。
トークンの再生成が行われ、それがストレージなどに格納されるのは、リクエストの処理が終わった後なので、
ほぼ同時にアクセスがあった場合は、同じトークンが送信されてきた場合でも、ストレージ上のトークンが変わっていないため、問題を検出できません。
LaravelではSessionに保存されている値はリクエストの最初の方でStore(FileやMemcached)などから読み込まれ、その後Arrayとして保持されて、レスポンスを返す直前でStoreに書き込まれます。
つまり連打した場合にはリクエスト後にregenerateTokenは実行されますが、その後のStoreへの書き込みが終わっていない状態でさらにリクエストが始まるという動きになり、Storeに書き込ま得れるまえのtokenが読み出されるので一致してしまいます。
これはStartSessionというMiddlewareに実装が書かれていて、レスポンスを作成したあとにreturnする直前にsaveSessionしていることがわかります。
まとめ
DBの読み込みを行った後に書き込みを行うケースで発生するレースコンディションの解決方法について紹介しました。
並列処理やトランザクション分離レベルなどは不勉強なので、間違っているところやより良いやり方があれば教えていただけると嬉しいです。