MySQLでのロックの挙動について単純なSELECT、INSERTで解決するものやそうでなくともUPSERT(INSERT ... ON DUPLICATE KEY UPDATE)で解決するような問題ならいいが、そうでないテーブル構造たったり、データだったりした場合に困るのをどう解決するか?
前提)あるECサイトで初回購入時のみ割引とかの商品を用意したものと考える。
1注文1商品の注文データでまるっと注文を一つのレコードで管理しているものとする。
MySQLのトランザクション分離レベルはデフォルトのREPEATABLE READであるとする。
単純に考えると
-
START TRANSACTION;
-
SELECT * FROM 注文T WHERE ユーザID=XX AND 商品ID=YY
-
1存在すればエラー、存在しなければインサート
-
COMMIT;
となるが、注文画面で連打されたり、複数画面開いて同時に注文されたりしたらどうだろうという話を考える。
同時にAとBリクエストが飛んできてAのリクエストがCOMMITされる前にBのSELECTが通ってしまうとこの時点ではまだレコードの登録がないため「存在しない」判定になってしまう。
これを防ぐには2.のSELECTの時点でSELECT…FOR UPDATEを行うことで回避することができる。
ただし、この場合、SELECT ... FOR UPDATE は、対象レコードが存在しない場合、ギャップロック(Gap Lock) を取得する。InnoDBの仕様上、ギャップロックは異なるトランザクション間でも共有可能なため、AとBが同時に同じギャップ(挿入予定箇所)に対するロックを取得できてしまう。 その後、互いに INSERT を実行しようとした瞬間、相手のロック解放待ちとなり、相互ブロックによる デッドロック(Deadlock) が発生する。
(AとBで同じロックを取得してしまうため、A、Bの順でインサートがかかるとBがデッドロックを発生させ、Bがエラーになってロックを手放したところでようやくAのインサートが走るという動きをする)
要件自体は満たしているが美しくない実装になるので、より良い対処を考えるのであればトランザクション開始前に GET_LOCK 関数を使用したアドバイザリロック(名前付きロック) を実装する。 user_id と product_id を組み合わせた文字列(例: 'lock_user_1_item_99') でロックを取得することで、該当処理を直列化し、DBレベルのデッドロックを未然に防ぐことができる。