データベース●●●●○

楽観ロックと悲観ロック

Optimistic / Pessimistic Locking ・ らっかんろっくとひかんろっく

同じデータへの同時更新を、読む時点で塞ぐか、書く時点で検知するかを選ぶ制御手法。

✦ 30秒で試す — 航海在庫を正しく守るのに、あえて鍵をかけずにおく方法がある2人が買い終わったあと、在庫の数は?予想してみる

概要

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

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

なぜ生まれたか

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

そこで「衝突の起きやすさ」に応じて戦略を選ぶ考え方が定着しました。衝突が頻繁で、やり直しが高くつくならロックで先に塞ぐ。衝突がまれなら、ロックの代金を払わずに進み、衝突したときだけ検知してやり直す。後者は、1981年にクンとロビンソンが論文「On Optimistic Methods for Concurrency Control」で、データベースの同時実行制御の方式として定式化しました。どちらも、排他制御一般に通じる並行処理のトレードオフを、データベースの更新に当てはめたものです。

詳細

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

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

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

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

楽観ロックはロックを取りません。代わりにテーブルへ 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つの戦略の比べ方

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

選ぶ軸は「競合の頻度」と「失敗したときのやり直しの代金」です。競合がめったに起きないなら、楽観ロックはほとんどタダで安全を手に入れられます。一方、人気商品の在庫のように同じ行に更新が殺到する場面で楽観ロックを使うと、1回の競争で成功するのは1人だけなので、残りが失敗と再試行を繰り返し、悲観ロックより遅くなります。

ブロックを自分で重ねて確かめてください。在庫 10 の商品を、何人かが「在庫を読む → 1減らして書く」で買います。利用者の READ と WRITE をドラッグして時間を重ね、戦略を切り替えると、更新が消える・待たされる・0件になって再試行する、の違いが見えます。人数を増やすと、楽観ロックの再試行がどう増えるかも比べられます。

⚡ 体験: 同じ在庫に、同時に書く
戦略
人数
時間 →U1READ10WRITE→9U2READ9WRITE→8在庫の行の版v110v29U1v38U2
最終の在庫8(正しくは 8)消えた更新0待ち0.0再試行0全員が終わる時刻7.5
○ ロックなしで更新が消える○ 悲観ロックで待たされる○ 楽観ロックで0件→再試行○ 人数を増やすと再試行が殺到

在庫 10 の商品を、2人がそれぞれ「在庫を読む → 1減らして書く」で買います。U2 の READ・WRITE をつかんで左へ動かし、U1 と時間を重ねてください。いまは「ロックなし」です。

時間は単純化した単位です。悲観ロックは SELECT ... FOR UPDATE、楽観ロックは UPDATE ... WHERE version = 読んだ版 を想定しています。

再試行する場合は回数に上限を設け、リトライと指数バックオフの作法に従います。全員が同じ間隔で再試行すると、また一斉にぶつかるからです。再試行される処理が冪等性を持つこと(2回実行しても結果が変わらないこと)も確かめておきます。

落とし穴と周辺の話題

楽観ロックの検証が守るのは「同じ行の version 列」だけです。「当直が最低1人いる」「合計がマイナスにならない」のように複数の行にまたがる条件は守れないので、そうした整合性はトランザクション分離レベルを SERIALIZABLE に上げるか、関係する行をまとめて悲観ロックします。また UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0 のように、読み取りを挟まず1文で条件付きの更新ができるなら、それが最も単純で安全な「第三の選択肢」です。読んで計算して書く、という往復そのものをなくせるからです。

ここで扱ったロックは、1つのデータベースの中で完結する話です。複数のサービスやプロセスが、データベースの外にある資源(外部 API の呼び出し、ファイル、ジョブの実行権)を取り合う場面では、Redis や ZooKeeper などを使う分散ロックという別の道具が必要になります。発想(先に塞ぐ/後で検証する)は共通ですが、ロックの持ち主が落ちたときにどう解放するか、という寿命管理の難しさが加わります。分散ロックでも、書き込みの側で「フェンシングトークン」と呼ばれる単調に増える番号を検証する方式があり、これは楽観ロックの version 列と同じ考え方です。

試問

在庫10の商品を、U1 と U2 の2人がそれぞれ「在庫を読む(READ)→ 1減らして書く(WRITE)」で1個ずつ買います。利用者の READ・WRITE のブロックを左右にドラッグして時間を重ねられ、上の切り替えで戦略(ロックなし・悲観ロック・楽観ロック)を選べます。右には在庫の行の版が積み上がります。

✦ 命題挑戦中

2人の「在庫を読む→1減らして書く」を時間で重ねても、在庫を正しく保ち、しかも誰も待たせないようにせよ

  • 2人以上の「読む→書く」を重ねる(相手が書く前に読む)
  • 最終の在庫が正しい(消えた更新がない)
  • 誰も待たされない(待ち 0)
⚡ 体験: 同じ在庫に、同時に書く
戦略
人数
時間 →U1READ10WRITE→9U2READ9WRITE→8在庫の行の版v110v29U1v38U2
最終の在庫8(正しくは 8)消えた更新0待ち0.0再試行0全員が終わる時刻7.5

在庫 10 の商品を、2人がそれぞれ「在庫を読む → 1減らして書く」で買います。U2 の READ・WRITE をつかんで左へ動かし、U1 と時間を重ねてください。いまは「ロックなし」です。

時間は単純化した単位です。悲観ロックは SELECT ... FOR UPDATE、楽観ロックは UPDATE ... WHERE version = 読んだ版 を想定しています。