id:lizy さんからブコメでツッコミをいただきました。

これはISOLATION LEVELの問題じゃないですか？1個目はREPEATABLE READがあれば大丈夫か。2個目はSERIALIZABLEが必要かな



これについて考えてみました。説明を分かりやすくするため「2. counts レコードの生成におけるレースコンディション」について先に考えます。





これらをふまえて、「2. counts レコードの生成におけるレースコンディション」についてレースコンディションが発生しそうな3パターンと、それに対するISOLATION LEVEL 毎の挙動をまとめます。

LOCK IN SHARE MODE で実行すると何が起こるかは、以下のマニュアルが参考になります。

このレベルは REPEATABLE READ と似ていますが、 InnoDB は暗黙的に全ての単純な SELECT ステートメント を SELECT ... LOCK IN SHARE MODE にコミットします。

1. クリック数のインクリメントにおけるレースコンディションの場合

この問題の根本は「プロセスA, B がそれぞれが同じクリック数取得してしまう」ことです。本来ならプロセスB はプロセスA がインクリメントした結果の値を取得しなければなりません。

これについても、SERIALIZABLE ならば対応可能な問題ですが、REPEATABLE READ だと問題が発生する場合があります。例を書きます。

プロセスA がクリック数をインクリメントし、トランザクションをCOMMIT する前にプロセスB が現在のクリック数を取得した REPEATABLE READ の場合、他トランザクションのCOMMIT されていない変更を見ることができません。よって、プロセスB が取得するクリック数はプロセスA がインクリメントする前の値になります。結果、プロセスA, B 共に同じ値を新しいクリック数として書き込み、実質インクリメントは1回か行われません SERIALIZABLE の場合、プロセスA がクリック数を更新しているので、プロセスB のSELECT はプロセスA のトランザクションがCOMMIT するまで待たされます。よって、プロセスB が取得できるクリック数はプロセスA によってインクリメントされた後の値となります。結果、インクリメントは正常に2回行われます





結論として「1. クリック数のインクリメントにおけるレースコンディション」についてもSERIALIZABLE なら防止可能でした。

ちなみに、この例に対してはより適切な方法があります。



ここにある通り、SERIALIZABLE で対応する場合には、後にUPDATE を実行したトランザクションでデッドロックによるエラーが発生します。よって、正常な動作をさせるためには、もう一度SELECT によって値を取得し直さなければなりません。

そもそもこの問題の根本は「プロセスA, B がそれぞれが同じクリック数を読みとってしまう」ことでした。よって、プロセスB のSELECT 自体をプロセスA のトランザクションがCOMMIT されるまでブロックできれば良い訳です。そして、これを実現するのが「SELECT 〜 FOR UPDATE」です。このクエリを実行したプロセス(MySQL レベルではセッション)はSELECT 対象となった行の排他ロックを取得し、トランザクション中はロックを保持します。なので、後発のSELECT は排他ロックを獲得できるまで待たされ、ブロックされたSELECT の実行結果には先にロックを保持していたトランザクションの実行結果が反映されるという寸法です。

さらにちなむと、Rails のfind メソッドは「:lock => true」というオプションを渡してやると「FOR UPDATE」付きSELECT を発行します。但し、「find_or_create_by」系のメソッドではこのオプションが無効なので使いどころは微妙かもしれません。

今回の問題について、ISOLATION LEVEL のことは考えていませんでした。ツッコミいただき感謝です!勉強になりました。