複数のリクエストが同じデータを更新する処理では、「あとから保存した値で、先に保存した値が意図せず消える」ことがあります。
たとえば、在庫数を減らす、予約枠を確保する、管理画面で注文情報を編集するといった処理です。
こうした更新競合に対処する代表的な方法が、楽観ロックと悲観ロックです。
名前だけ聞くとイメージしにくいですが、考え方はシンプルです。
- 楽観ロック:競合はめったに起きないと考え、更新時にだけ衝突を検出する
- 悲観ロック:競合が起きる前提で、処理中はほかの更新を待たせる
どちらかが常に正しいわけではありません。競合の頻度と、競合したときの失敗コストで選ぶのが大事です。
まず、何が困るのか
ユーザー A と B が、同じプロフィールの表示名を編集しているとします。
- A が現在の表示名
fujiを読む - B も同じ
fujiを読む - A が
antonに変更して保存する - B が
fujimotoに変更して保存する
更新時に何も確認しないと、最後に保存した B の値だけが残ります。
これは lost update(更新の取りこぼし) と呼ばれる問題です。
プロフィール編集なら「もう一度確認してください」で済むこともあります。一方、在庫や送金のように整合性が重要なデータでは、黙って上書きするわけにはいきません。
楽観ロック 〜更新時に「途中で変わっていないか」を確かめる〜
楽観ロックでは、データに version のような更新番号を持たせます。
CREATE TABLE documents (
id BIGINT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 1
);
アプリケーションは、読み取った version も一緒に覚えておきます。更新時には、その番号がまだ同じであることを WHERE 句で確認します。
UPDATE documents
SET
title = $1,
body = $2,
version = version + 1
WHERE id = $3
AND version = $4;
更新件数が 1 なら成功です。0 件なら、誰かが先に更新して version が変わった可能性があります。ただし、対象の id が存在しない場合や、すでに削除されている場合も 0 件になります。対象の存在を保証できないAPIでは、競合とリソース未存在を区別して扱う必要があります。
const result = await db.query(updateDocument, [title, body, documentId, version]);
if (result.rowCount === 0) {
throw new ConflictError('別の更新が先に保存されています');
}
この方式では、データを読んでから更新するまで、DBの行をロックし続けません。普段は競合しない処理を軽く扱えるのが強みです。
競合したらどうするか
楽観ロックで重要なのは、競合を検出した後の扱いです。主な選択肢は3つあります。
- 最新の値を再取得して、ユーザーに差分を見せる
- もう一度処理を試す。値の加算など、再試行が安全な操作に向く
- サーバー側のルールでマージする
何でも自動リトライすればよいわけではありません。
「入力欄に書いた文章を最新の文章へそのまま再適用する」と、別の人の編集をまた上書きする可能性があります。人が意図を決める必要がある更新では、競合として返すほうが安全です。
また、再試行の範囲にメール送信や決済、外部API呼び出しを含めると、副作用が二重に実行されるおそれがあります。再試行する処理はDB更新のように範囲を限定し、外部処理には冪等キーやアウトボックスなど別の仕組みを検討します。
悲観ロック 〜先にロックを取り、ほかの更新を待たせる〜
悲観ロックでは、更新対象を読み取る時点でロックを取ります。PostgreSQL なら、代表例は SELECT ... FOR UPDATE です。
BEGIN;
SELECT id, stock
FROM products
WHERE id = $1
FOR UPDATE;
-- stock を確認し、在庫があれば減らす。更新件数も確認する
UPDATE products
SET stock = stock - 1
WHERE id = $1
AND stock > 0;
COMMIT;
同じ行に対して競合する更新やロック取得を行う別トランザクションは、ロックを取ったトランザクションが COMMIT または ROLLBACK するまで待ちます。PostgreSQLの通常の SELECT まで、必ず待たされるわけではありません。
在庫を 1 減らす前に残数を確認したい、同じ予約枠を二重に確保させたくない、といった処理に向いています。
悲観ロックで気をつけること
ロックは強力なぶん、扱いを誤ると待ち時間や障害につながります。
- トランザクションを短く保つ。外部 API 呼び出しやユーザー入力待ちは入れない
- 複数行をロックするなら、常に同じ順番で取得する
- ロック待ちのタイムアウトと、デッドロック時の再試行を設計する
- DB製品やロックモードごとの差を確認する
特に複数の行やテーブルを扱うときは、A が products → orders の順、B が orders → products の順にロックを取ると、互いに待ち続けるデッドロックが起きることがあります。
DBは通常、どちらか一方のトランザクションを失敗させて検出します。アプリケーション側ではエラーを適切に返し、必要なら安全に再試行します。
どう使い分けるか
ざっくり言えば、次の表を起点に考えると選びやすいです。
| 観点 | 楽観ロック | 悲観ロック |
|---|---|---|
| 選びやすい状況 | 競合がまれで、失敗しても再編集・再試行で扱える | 競合が多い、または同じ対象にアクセスが集中しやすい |
| 競合時の挙動 | 更新を失敗させて検出する | ほかの更新を待たせ、順番に処理する |
| 処理中のロック | 原則として保持しない | トランザクション中に保持する |
| 向く例 | 設定画面、記事編集、プロフィール更新 | 在庫、座席・予約枠、残高に関わる処理 |
| 主な注意点 | 競合後のUXと、安全に再試行できる操作かを設計する | ロック待ち、タイムアウト、デッドロックを設計・監視する |
ただし、在庫なら必ず悲観ロック、編集画面なら必ず楽観ロック、という決まりではありません。
たとえば在庫数を単純に減らすだけなら、条件付きの UPDATE 一発で済むこともあります。
UPDATE products
SET stock = stock - 1
WHERE id = $1
AND stock > 0;
更新件数が 1 なら確保成功、0 なら在庫切れです。このように、業務ルールをSQLの条件へ落とし込めるなら、明示的なロック範囲を小さくできる場合があります。
競合が多いからといって、悲観ロックが必ず最適とは限りません。ロック待ちが長くなると、スループットが落ちたりタイムアウトが増えたりします。条件付き UPDATE、ユニーク制約や CHECK 制約、処理を直列化するキューなど、業務ルールをより小さく表現できる方法も候補に入れます。
トランザクション分離レベルとは別の話
楽観ロック・悲観ロックと一緒に、トランザクション分離レベルも話題になりがちな気がしてる。
分離レベルは、トランザクション間でほかの処理の途中経過をどこまで見せるか、同じ検索を繰り返したときに結果が変わり得るか、といったデータの見え方を決めます。
一方で、楽観ロックは「期待した版のデータだけを更新する」というアプリケーションの条件です。悲観ロックは「この対象を今から更新するので、ほかを待たせる」という明示的なロック操作です。
どちらか一方だけ知っていればよいものではなく、守りたい整合性に合わせて組み合わせます。
実装前に確認したいこと
ロックを入れる前に、次を言葉にしておくと設計しやすくなります。
- 同時更新が起きたとき、絶対に壊してはいけない状態は何か
- 競合したとき、待たせる・失敗させる・再試行するのどれが自然か
- 更新処理の途中に、外部通信や長い処理が入っていないか
- DBの制約や条件付き
UPDATEだけで表現できないか - 競合・ロック待ち・デッドロックを監視できるか
- 再試行しても外部処理が二重実行されないか
ロックは「とりあえず付ける安全装置」ではなく、同時に起きた操作をどの順番で、どんな体験で処理するかを決める仕組みです。
まずは競合が起きたときの振る舞いを決め、そのために楽観ロック・悲観ロック・DB制約のどれが最も小さく正しく表現できるかを考えるのがよさそうです。
まとめ
- 競合がまれで、ユーザーに再編集をお願いできるなら楽観ロック
- 短いトランザクションの中で、同じ対象を順番に処理したいなら悲観ロック
- 条件をSQLや制約に落とし込めるなら、明示的なロックを使わずに済む場合もある
大事なのは、ロックの種類から決めることではなく、競合したときに何を守り、どんな結果を返したいかを先に決めることです。