楽観的ロック

データの読み取り時にロックせず、更新時にバージョンを検証して競合を検出する排他制御

データベース排他制御

楽観的ロックとは

楽観的ロック (Optimistic Locking) は、データの読み取り時にロックを取得せず、更新時にバージョン番号を検証して競合を検出する排他制御である。「競合はめったに起きない」という楽観的な前提に基づく。

実装に必要なのはバージョン列 (更新時刻やハッシュでも代用できる) を 1 つ持たせ、更新文の条件に「読んだときのバージョンと今の値が一致するか」を入れることだけである。ロックの保持者を管理する表もロック待ちのキューも要らない。CPU 命令の CAS (Compare-And-Swap) をレコード単位に持ち上げたものと見ると機序がつかみやすい。読んだ値を前提に計算し、書き込む瞬間に前提が崩れていないかを確認する、という構造は同じである。

悲観的ロック vs 楽観的ロック

両者はロックを取る時点が違うだけに見えるが、競合したときに誰が損をするかが逆転する。悲観的ロックは後から来た側をロック解放まで待たせて必ず成功させる。楽観的ロックは待たせず更新まで進ませ、最後に負けた側だけを失敗させてやり直させる。

観点悲観的ロック楽観的ロック
ロックのタイミング読み取り時更新時 (検証のみ)
前提競合が頻繁に起きる競合はめったに起きない
スループット低い (ロック待ち)高い (ロックなし)
競合時の動作待機エラー → リトライ
デッドロック発生しうる (複数行を別々の順序でロックしたとき)発生しない (ロックを保持しないため)
主な失敗モードロック待ちによる詰まりリトライの空回り

DynamoDB での楽観的ロック

DynamoDB には行ロックに相当する仕組みがないため、排他制御は条件付き書き込み (ConditionExpression) で行う。読み取り時の version を条件に入れ、一致しなければ書き込み自体が拒否される。

// 1. アイテムを取得 (version を含む)
const item = await db.get({ TableName: 'products', Key: { id: 'P1' } });
// { id: 'P1', name: 'Widget', stock: 10, version: 3 }

// 2. 更新時に version を検証
await db.update({
  TableName: 'products',
  Key: { id: 'P1' },
  UpdateExpression: 'SET stock = :stock, version = :newVersion',
  ConditionExpression: 'version = :currentVersion',
  ExpressionAttributeValues: {
    ':stock': 9,
    ':newVersion': 4,
    ':currentVersion': 3,  // 読み取り時の version
  },
});
// version が 3 でなければ ConditionalCheckFailedException → リトライ

条件が偽になって失敗した書き込みも、書き込みキャパシティを消費する (消費量は既存アイテムのサイズ分・アイテムが無い場合は最低 1 単位)。リトライを裸のループで回すと課金とスロットリングの両方に効くため、指数バックオフを入れる。あわせて ReturnValuesOnConditionCheckFailureALL_OLD を指定しておくと、例外に現在のアイテムの属性が載るので、リトライ前の再読み取りを 1 往復省ける (既定値は NONE)。

競合の検出

失敗するのは「後から更新した側」ではなく「古いバージョンを握っていた側」である。読み取りから更新までの間に他者が書き込めば、その間隔がどれだけ短くても条件は成立しない。

ユーザー A: 読み取り (version=3) → 更新 (version=34) ✅ 成功
ユーザー B: 読み取り (version=3) → 更新 (version=34) ❌ 失敗 (version は既に 4)
ユーザー B: 再読み取り (version=4) → 更新 (version=45) ✅ 成功

RDS での楽観的ロック

リレーショナルデータベースでは version 列を WHERE 句に入れ、更新された行数で競合を判定する。行ロックを取る SELECT ... FOR UPDATE と違ってロック待ちが発生しないため、長い画面遷移をまたぐ編集 (読み取りと更新の間に人間の操作が挟まる場合) と相性が良い。

-- 更新時に version を検証
UPDATE products
SET stock = 9, version = version + 1
WHERE id = 'P1' AND version = 3;

-- 影響行数が 0 なら競合が発生 → リトライ

この方式の弱点は、version を更新しない書き込み経路が 1 つでもあると、その経路のロストアップデートだけ検出できなくなる点である。管理画面からの直接 UPDATE やバッチの一括更新が抜け道になりやすい。バージョンの加算は ORM のマッピングかデータベース側のトリガーで強制し、アプリケーションの書き手の記憶に頼らせない。

使い分け

選択の軸は競合の頻度そのものよりも、失敗したときにやり直せるかである。やり直しが利用者に見える形 (入力し直し) になるなら、競合が少なくても悲観的ロックの方が体験が良い場面がある。逆に処理がサーバー内で完結し自動リトライできるなら、競合がある程度あっても楽観的ロックが有利である。

ケース推奨
読み取りが多く、競合が少ない楽観的ロック
競合が頻繁に発生悲観的ロック
DynamoDB楽観的ロック (条件付き書き込み)
在庫管理 (高競合)アトミックカウンター + 在庫下限の条件付き書き込み

アトミックカウンター (バージョン検証を使わない方法)

加算・減算だけで済む属性なら、読み取りとバージョン検証を省いて 1 回の書き込みにまとめられる。DynamoDB の ADD は現在値に対する相対更新なので、読み取り時点の値を知らなくても安全に減算できる。

// version チェックなしで在庫を 1 減らす
await db.update({
  TableName: 'products',
  Key: { id: 'P1' },
  UpdateExpression: 'ADD stock :delta',
  ConditionExpression: 'stock >= :required',
  ExpressionAttributeValues: { ':delta': -1, ':required': 1 },
});

減算値と在庫下限を同じプレースホルダーで兼用してはならない。減算値の -1 をそのまま条件式の右辺にも流用すると、条件は「在庫が -1 以上か」を問うことになり事実上常に成立する。在庫切れを弾くつもりの条件が無効化され、在庫が負に沈む。減らす量と守りたい下限は別の値である。

この方式にはバージョン検証を捨てた代償がある。AWS のドキュメントはアトミックカウンターの更新が冪等でないこと (呼び出すたびに増減し、正の加算なら過大計上・負の加算なら過小計上を招くこと) を明記し、多重計上や計上漏れが許されない用途では条件付き更新を使うよう案内している。ネットワークエラーで結果が不明なまま同じリクエストを再送すれば、二重に減算され得る。一方、version 一致を条件にしてその version 自体を更新する書き込みは、更新する属性を条件にしているため再送しても結果が変わらない。

落とし穴

競合が想定より多い環境では、リトライが積み上がって実効スループットが悲観的ロックを下回ることがある。リトライ回数に上限を設け、超えたら利用者にやり直しを求めるか、悲観的ロックやアトミックカウンターへ切り替える経路を用意しておく。

バージョン一致が防ぐのは、同じ行に対する更新の取り違えだけである。読んだ後に別の処理が関連する別の行を変えたことで不変条件が破れる型の不整合は検出できない。「複数行にまたがる条件」を守りたいなら、条件検査と書き込みを 1 回のトランザクション (DynamoDB なら TransactWriteItems の条件チェック) にまとめる必要がある。

なお 競合状態 の解消手段として楽観的ロックを選ぶ場合、リトライは同じ処理を 2 回実行することを意味する。副作用のある処理 (課金・メール送信) を巻き込むなら 冪等性 の設計を同時に用意しておく。関連書籍では、この「検出してやり直す」方式と「待たせて 1 つずつ通す」方式の境界が、トランザクション分離レベルの議論と併せて整理されている。

この記事は役に立ちましたか?

関連用語

関連する記事