コンテンツへスキップ

楽観ロックと悲観ロックの違い — DB更新の競合をどう防ぐか

複数の人やプロセスが同じデータを同時に更新しようとすると、後から書き込んだ側が先の変更を気づかないまま上書きしてしまうことがある。これは「lost update(更新の消失)」と呼ばれる典型的な問題で、データベース設計や複数プロセスが動くアプリケーションでは避けて通れない。この問題への対処法は大きく2系統に分かれ、「悲観ロック(pessimistic locking)」と「楽観ロック(optimistic locking)」と呼ばれる。名前は似ているが考え方は正反対で、向いている場面もはっきり異なる。

「同時に上書きしてしまう」問題

たとえば在庫数を管理するテーブルがあり、AとBという2つの処理がほぼ同時に同じ行を読み込んだとする。両方とも「現在の在庫は10個」という値を読み、それぞれ「1個減らして9個にする」という更新をかける。何も対策をしていないと、AとBのどちらの更新が後に実行されても、結果は「9個」になってしまう。本来は2個減って「8個」になるべきところが、片方の更新がもう片方に上書きされて消えてしまう。これが lost update の典型例である。

対策の方向性は2つある。「そもそも同時に触らせない」のが悲観ロック、「同時に触らせておいて、後から矛盾がないか確認する」のが楽観ロックである。

悲観ロック — 先に鍵をかけてから触る

悲観ロックは、データを読み込んだ時点で排他的なロックを取得し、自分の処理が終わるまで他の処理を待たせる方式である。「たぶん誰かと衝突するだろう」という前提(=悲観的)に立ち、先に鍵をかけてから作業する。

自社ツールの core/file_lock.py にある FileLock クラスがこの方式にあたる。GUI(site_manager_web.py)とバックグラウンドのメンテナンス実行(maintenance_agent.py)が同じ設定ファイル(sites_*.json 等)を同時に書き込もうとする可能性があるため、書き込み前に排他ロックを取得し、他方のプロセスをロック解放まで待たせる設計になっている。

from core.file_lock import FileLock

with FileLock('sites_default.json', timeout=10):
    # このブロックの中にいる間、他プロセスは待たされる
    _atomic_write_json('sites_default.json', data)

以前の記事で扱った fcntl(Unix)と msvcrt(Windows)の違いは、このロックを OS レベルで実際にどう実現するかという実装面の話だった。今回の記事はその一段上の話、つまり「そもそも書き込み前にロックを取る」という設計判断そのものを扱っている。

データベース側で悲観ロックを使う場合は、多くの RDBMS が SELECT ... FOR UPDATE という構文を用意している。

BEGIN;
SELECT quantity FROM stock WHERE id = 42 FOR UPDATE;
-- ここで他のトランザクションはこの行の更新をブロックされる
UPDATE stock SET quantity = quantity - 1 WHERE id = 42;
COMMIT;

補足: デッドロック(deadlock)とは、複数のトランザクションがお互いにロックしている行の解放を待ち合ってしまい、どちらも先に進めなくなる状態のこと。悲観ロックは衝突を確実に防げる一方、ロックを取る順序を誤ると発生しうる。多くの RDBMS はデッドロックを検知すると片方のトランザクションを強制的に失敗させ、アプリケーション側での再試行に委ねる。

悲観ロックの弱点は、ロックを保持している間、他の処理が待たされ続けることにある。処理時間が長い・同時アクセスが多いといった場面では、この待ち時間が積み重なりやすい。

楽観ロック — 競合はまれと考え、後から検出する

楽観ロックは逆の発想を取る。「同時に同じ行を更新することはそう頻繁には起きないだろう」という前提(=楽観的)に立ち、読み込み時にはロックを取らない。その代わり、更新を書き込む瞬間に「自分が読み込んだ時点から、この行は誰にも変更されていないか」を確認する。

典型的な実装は、テーブルに version や updated_at のような列を持たせ、更新文の WHERE 句にその値を含める方法である。

UPDATE stock
SET quantity = 9, version = 6
WHERE id = 42 AND version = 5;

このとき影響を受けた行数が 1 件なら、自分が読んだ時点から誰も更新していなかったということなので成功。もし影響行数が 0 件だった場合、version が読み込み時と一致しなかった、つまり自分が読んでから誰かが先に更新してしまったということになる。この場合はアプリケーション側で「競合が起きた」と判断し、最新の値を読み直して再試行するか、ユーザーに「他の変更と衝突しました」と伝える。

補足: この記事での「競合(コンフリクト)」とは、複数の処理が同じデータに対して行おうとした変更同士が両立できない状態を指す。楽観ロックは競合そのものを防ぐわけではなく、競合が実際に起きたかどうかを更新の瞬間に検出する仕組みである。

Web の世界にも同じ発想の応用がある。HTTPキャッシュヘッダーの記事で扱った ETag は、本来はキャッシュの有効性を確認するための仕組みだが、If-Match ヘッダーと組み合わせることで「自分が最後に取得した版と、サーバー上の現在の版が一致する場合のみ更新を許可する」という楽観ロックとしても使える。クライアントが更新リクエストに If-Match: "<以前取得したETag>" を付け、サーバー側の現在のETagと一致しなければ更新を拒否する、という形である。

WordPressの「編集ロック」は厳密には悲観ロックではない

WordPress の管理画面には、ある投稿を誰かが編集中に別のユーザーが同じ投稿の編集画面を開くと「〇〇さんがこの投稿を編集中です」という警告が出る仕組みがある。これは Heartbeat API を使って、編集中であることを示すロック情報を一定間隔でトランジェントとして保存・更新することで実現されている。

ただしこれは、データベースの更新そのものを機械的にブロックする厳密な悲観ロックとは性質が異なる。あくまで「今このユーザーが編集中です」という注意喚起(advisory lock)であり、警告を無視して両方のユーザーが保存ボタンを押した場合、wp_update_post() 自体には競合を検出する仕組みが組み込まれていないため、後から保存した側の内容がそのまま反映される。つまり UI 上は編集中であることを知らせてくれるが、DB 更新のレベルでは lost update を防ぐ保証にはなっていない。この事実を知っておくと、「警告が出ても無視して保存できてしまう」挙動が仕様であって不具合ではないと理解できる。

悲観ロックと楽観ロックの比較

観点 悲観ロック 楽観ロック
前提 衝突はよく起きる 衝突はまれ
動作のタイミング 読み込み時にロックを取得 書き込み時に競合を検出
他の処理への影響 ロック保持中は待たされる 通常は待たされない(競合時のみ再試行)
向いている場面 更新頻度が高い・失敗のやり直しコストが高い 読み取りが多く更新の衝突が少ない
リスク デッドロック 競合検出後の再試行ロジックの実装漏れ

どちらを選ぶか

競合が頻繁に起きる場面や、衝突をやり直すコストが高い場面(在庫の引き当てのように、やり直しが複雑な処理)では、先に鍵をかけてしまう悲観ロックの方が扱いやすい。逆に、読み取りが大半で書き込みの衝突がまれな場面では、常にロック待ちを発生させる悲観ロックはむしろ足かせになりやすく、楽観ロックの方が適している。

自社ツールの設定ファイル書き込みで FileLock(悲観ロック)を選んでいるのは、GUI とバックグラウンド処理が同時に同じファイルへ書き込む頻度自体がそう高くなく、待たされたとしても実害が小さい一方、書き込み中の破損(不完全な JSON が書き込まれる等)は避けたいという判断があったためである。ロックの保持時間も、ファイル1つ分の書き込みが完了するまでとごく短い。すべての場面で悲観ロックが正解というわけではなく、頻度・失敗時のコスト・待たせても構わないかという条件を見て選ぶべき性質のものである。

まとめ

悲観ロックは「読み込み時に鍵をかけて他者を待たせる」方式で、衝突を未然に防ぐ代わりにロック保持中の待ち時間とデッドロックのリスクを抱える。楽観ロックは「ロックを取らず、書き込みの瞬間に版が変わっていないかを確認する」方式で、通常時の待ち時間は発生しないが、競合を検出した後の再試行処理をアプリケーション側で用意しておく必要がある。自社ツールの FileLock は前者の実例であり、HTTPのETag+If-Matchは後者の考え方をWeb上で応用した例にあたる。どちらも「複数の処理が同じデータに触れる」という同じ問題への、異なる解決アプローチである。