楽観的ロックと悲観的ロック — 待たせるか、やり直させるか
2人が同じ在庫レコードを、ほぼ同時に1つ減らそうとしたとします。どちらも「残り100」を読んでから「99にする」と書き込めば、2つ売れたのに在庫は99のまま――これがロストアップデート(更新の消失)です。これを防ぐ考え方が2つあります。
悲観的ロックは「どうせぶつかる」と考えて、先に鍵をかけて他人を待たせる。楽観的ロックは「たぶんぶつからない」と考えて、鍵はかけずぶつかったと分かった側がやり直す。下の図1で方式を切り替えながら「次へ」を押し、AとBがどう進むかを見比べてください。
待たせるコスト、やり直すコスト
悲観的ロックは SELECT ... FOR UPDATE のように読む時点で行に鍵をかけ、コミットまで他のトランザクションを待たせます。衝突は原理的に起きないので、アプリ側に再試行のコードが要りません。代わりに、待つ側はその間ずっと止まります。ロックを保持したまま長い処理をすると待ち行列が伸び、複数の行を違う順序でロックすればデッドロックにもなり得ます。
楽観的ロックは鍵をかけません。代わりに行に version 列を持たせ、UPDATE ... WHERE id = 1 AND version = 3 のように「読んだときと同じままなら書く」と条件をつけます。誰かが先に更新していればversionが進んでいて更新行数が0になる――これが衝突の検知です。誰も待たないので競合が少ない場面では速い一方、衝突したら読み直して再試行する処理をアプリ側に書く必要があります。
選び方はシンプルで、衝突がどれくらい起きそうかで決まります。同じ行を大勢が奪い合う(在庫僅少のセール、座席予約)なら、やり直しが連発する楽観的ロックより、素直に待たせる悲観的ロックが有利。ふつうの管理画面のように同じレコードを同時に触ることが稀なら、待ちゼロの楽観的ロックが快適です。Webの編集画面で「他のユーザーが更新しました。再読み込みしてください」と出るのは、まさにこの衝突検知が働いた瞬間です。
- ロストアップデート
- 同時更新で、後から書いた側が先の更新を上書きし、変更が消えてしまう現象。
- 悲観的ロック
- 読む時点で行をロックし、他を待たせる方式。SELECT ... FOR UPDATE など。
- 楽観的ロック
- ロックせず、version 列などで「読んだ後に変わっていないか」を更新条件にする方式。
- version 列
- 更新のたびに+1する番号。UPDATE の WHERE に入れることで衝突を検知できる。
- 再試行(リトライ)
- 衝突で更新が0行だったとき、最新値を読み直して処理をやり直すこと。
まとめ
同時更新の問題に対して、答えは2通りしかありません。ぶつからないようにする(悲観的ロック)か、ぶつかったら気づいてやり直す(楽観的ロック)か。前者のコストは待ち時間、後者のコストは再試行の実装とやり直しの手間です。どちらもロストアップデートは防げるので、あとはその行がどれくらい取り合いになるかで選ぶだけ。混み合う行は待たせ、空いている行は楽観的に――これが実務での基本方針です。