データベース●●●●○

楽観ロックと悲観ロック

Optimistic / Pessimistic Locking

競合の起きやすさに応じて「先に塞ぐ」か「後から検知する」かを選ぶ同時更新の制御手法。

概要

楽観ロックと悲観ロックは、複数の処理が同じデータを同時に更新しようとしたときの衝突(更新競合)をどう防ぐかについての、対照的な2つの戦略です。悲観ロック(pessimistic locking)は「衝突はきっと起きる」と悲観して、データを読む時点でロックをかけ、他者を待たせます。楽観ロック(optimistic locking)は「衝突はめったに起きない」と楽観して、ロックせずに読み、書き込む瞬間に「読んだときから誰も変更していないか」を検証します。

典型的な舞台は「読んでから書く」処理です。管理画面で商品情報を開き、編集して保存する。在庫数を読み、1減らして書き戻す。この「読み」と「書き」の間に別の誰かが同じデータを更新すると、後から保存した側が前の変更を黙って上書きしてしまう — いわゆるロストアップデート(更新の消失)が起きます。トランザクションで読みと書きを囲むだけではこの問題は防げないことが多く、どちらかのロック戦略を明示的に選ぶ必要があります。

なぜ生まれたか

データベースが一人ずつ順番に使うものであれば、更新競合は存在しません。しかし業務システムでは多数のユーザーが同じレコードに同時に触れます。素朴な解決は「触る前に必ずロックする」ことですが、すべてをロックで守ると待ち行列が伸び、並行性が失われます。さらに Web アプリケーションでは「画面で編集している数分間」ロックを持ち続けるわけにいきません。ユーザーがブラウザを閉じてしまえば、ロックは持ち主を失って残り続けます。

そこで「衝突の起きやすさ」に応じて戦略を選ぶ考え方が確立しました。衝突が頻繁で、失敗のやり直しが高くつくならロックで先に塞ぐ。衝突が稀なら、ロックのコストを払わずに進み、万一衝突したときだけ検知してやり直す。これは排他制御一般(ミューテックスなど)にも通じる、並行処理の普遍的なトレードオフをデータベース更新に適用したものです。

詳細

悲観ロック — SELECT FOR UPDATE で先に塞ぐ

悲観ロックの代表的な手段は、SQL の SELECT ... FOR UPDATE です。行を読む時点で排他ロックを取得し、トランザクションが終わるまで保持します。他のトランザクションが同じ行を FOR UPDATE で読もうとするとブロックされて待たされるため、「読んでから書く」までの間に割り込まれることが構造的にありえません。在庫の引き当てのように、同じ行への更新が集中し、かつ絶対に取りこぼせない処理に向いています。

代償は待ち時間とロックの管理です。ロックの保持時間が長いほどスループットは落ち、複数の行を互い違いの順序でロックすればデッドロックが起きます(ロック順序を揃えるのが定石です)。また、ロックはデータベース接続と結びついたトランザクションの中でしか維持できないため、「編集画面を開いている間」のようにリクエストをまたぐ排他には使えません。

楽観ロック — バージョン番号で後から検知する

楽観ロックはロックを取りません。代わりにテーブルへ version 列(または更新日時)を持たせ、読み取り時にその値を控えておきます。更新時には次のように「自分が読んだバージョンのままであること」を条件に含めます。

UPDATE products
SET stock = 4, version = version + 1
WHERE id = 42 AND version = 7;

誰かが先に更新していれば version は 8 に進んでいるため、この UPDATE は 0 行に対して実行されます。更新件数が 0 だったことをアプリケーションが検知し、「他の人が先に更新しました」とエラーを返すか、最新の値を読み直して再試行します。ロックを保持しないので待ちが発生せず、画面をまたぐ長い編集フローでも成立するのが強みです。ORM の多くはこのバージョン列方式を標準機能として備えています。

ユーザーBデータベースユーザーAユーザーBデータベースユーザーAAは最新を読み直して再試行するか、ユーザーに競合を通知する商品を読む version 7商品を読む version 7更新 条件 version 7 → 成功し version 8 に更新 条件 version 7更新件数 0 → 競合を検知

2つの戦略の使い分け

悲観ロック衝突は起きる前提。読む時点で塞ぐ読み取り時にロック取得更新してロック解放他のトランザクションは解放まで待たされる確実だが待ちが伸びる競合が多い・やり直せない処理向き楽観ロック衝突は稀な前提。書く瞬間に検証するロックせず読むversion を控える更新時にversion を検証不一致なら更新失敗読み直して再試行待ちゼロだが失敗時のやり直しが必要競合が稀・編集が画面をまたぐ処理向き
悲観ロックと楽観ロックの構造比較 — 塞ぐタイミングが違う

選択の軸は「競合の頻度」と「失敗コスト」です。競合がめったに起きないなら、楽観ロックはほぼタダで安全性を手に入れられます。一方、人気商品の在庫のように同じ行に更新が殺到する状況で楽観ロックを使うと、大半の更新が失敗と再試行を繰り返して悲観ロックより遅くなります。再試行する場合は回数上限を設け、リトライとバックオフの作法に従うこと、そして再試行される処理が冪等であることを確認するのが実装上の勘所です。

落とし穴と周辺の話題

楽観ロックの検証は「同じ行の version 列」しか守りません。「合計がマイナスにならないこと」のように複数行にまたがる不変条件は守れないため、そうした整合性はトランザクション分離レベルの引き上げや悲観ロックで担保します。また UPDATE stock = stock - 1 WHERE stock > 0 のように、読み取りを挟まず1文で条件付き更新できるなら、それが最も単純で安全な「第三の選択肢」になることも覚えておくと便利です。

なお、ここで扱ったロックは1つのデータベースの中で完結する話です。複数のサービスやプロセスが同じ資源を取り合う場面では、Redis や ZooKeeper などを使う分散ロックという別の道具立てが必要になります。発想(先に塞ぐ/後で検証する)は共通ですが、ロックの寿命管理など難しさの質が変わります。