LoginSignup
48
39

More than 3 years have passed since last update.

【Laravel】多重投稿操作されたときにレースコンディションを防ぐlockForUpdate句を使った実装例

Last updated at Posted at 2019-10-12

これはなに?

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の読み込みを行った後に書き込みを行うケースで発生するレースコンディションの解決方法について紹介しました。
並列処理やトランザクション分離レベルなどは不勉強なので、間違っているところやより良いやり方があれば教えていただけると嬉しいです。

48
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
39