悲観ロックは、更新する前に対象の行をロックし、同じ行を更新・削除しようとするほかのトランザクションを待たせる方式です(ロックを伴わない通常のSELECTは待たされません)。楽観ロックは、読む時点では行ロックを取らず、更新の瞬間にバージョン番号などで「読んだあとに誰かが書き換えていないか」を確かめ、書き換えられていれば更新を失敗させる方式です。この記事では、両者の違いと使い分けを、PostgreSQL 18.6とMySQL 8.4.8で実際にSQLを流した結果とpgbenchでの性能比較をもとに整理します。
まとめ:楽観ロックと悲観ロックの違いと選び方の結論
- 違いはロックを取るタイミングです。悲観ロックは読み取り時(SELECT FOR UPDATE)、楽観ロックは更新時(UPDATE … WHERE version = ?)に競合を扱います。楽観的排他制御・悲観的排他制御も同じ意味です。
- 同じ行に更新が集中するなら楽観ロックは不利です。1行に32並列で更新をかけた実測では、楽観ロックの試行の96%が競合で失敗し、成功件数は悲観ロックと同じ毎秒約200件で頭打ちでした。
- 在庫の減算のように条件と更新を1文にまとめられる処理では、条件付きUPDATEを先に検討します。今回の実測では3方式の中で最速でした。同じ条件で毎秒約2,000件と、ロック方式の約10倍でした。
- 編集画面を開いてから保存するまでのように、利用者の操作をまたぐ更新は楽観ロックで守ります。DBのトランザクションを数分間開いたまま行ロックを持ち続ける設計は避けます。
- MySQLのREPEATABLE READは読み取り後の上書きを止めません。PostgreSQLは同じ状況でエラー(SQLSTATE 40001)を返しますが、MySQLは黙って上書きしました。分離レベルを上げればロックが要らない、とは言えません。
以下、定義と実測、SQLとORMの実装、判断基準の順に説明します。
楽観ロックと悲観ロックの違い:ロックを取るタイミングと競合の扱い
どちらの方式も、複数の利用者が同じデータを同時に更新したときの不整合を防ぐための排他制御です。違いは、競合が「起きる前に防ぐ」か「起きたあとに検出する」かにあります。
読み取り後の上書きで起きる更新の消失(Lost Update)
在庫10個の商品を2人が同時に1個ずつ買う処理を、PostgreSQL 18.6の既定の分離レベル(READ COMMITTED)で再現しました。アプリ側で在庫を読み、1を引いた値を書き戻す処理を、0.3秒ずらして2本走らせています。
-- PostgreSQLのpsqlで実行する例。items(id PRIMARY KEY, stock, version)を事前に用意する
-- id=1、stock=10、version=0を初期値とし、AとBの2接続で実行する
BEGIN;
-- A・Bとも 10 を読む
SELECT stock AS s FROM items WHERE id = 1 \gset
SELECT pg_sleep(1); -- アプリ側の処理時間
UPDATE items SET stock = :s - 1 WHERE id = 1; -- A・Bとも 9 を書く
COMMIT;
2個売れたのに、最終的な在庫は8ではなく9になりました。エラーは一切出ません。これが更新の消失で、楽観ロックと悲観ロックはどちらもこの事故を防ぐための手段です。
悲観ロック:読み取り時の行ロック取得と後続処理の待機
同じ処理のSELECTにFOR UPDATEを付けると、Aが読んだ行をBは読めずに待ちます。実測では、Aが11:23:18に10を読み、Bは1秒待たされて11:23:19に9を読み、最終在庫は正しく8になりました。PostgreSQL 18の行ロックの説明のとおり、FOR UPDATEで押さえた行は、ほかのトランザクションからの更新・削除と行ロックの取得をすべて待たせます。
取得した行への他トランザクションの変更を防げますが、別の行とのデッドロックや制約違反などで更新が失敗する可能性は残ります。一方で、ロックを持っている間は後続が並んで待つため、ロックの保持時間がそのまま全体の待ち時間になります。
楽観ロック:更新時のバージョン照合
楽観ロックでは、テーブルにversion列を持たせ、読んだときのversionが更新時にも変わっていない場合だけ書き込みます。
SELECT stock AS s, version AS v FROM items WHERE id = 1 \gset
-- (アプリ側の処理)
UPDATE items SET stock = :s - 1, version = version + 1
WHERE id = 1 AND version = :v;
A・Bの両接続で同じversionを読み取ってから、A、Bの順にUPDATEを実行すると、AはUPDATE 1、BはUPDATE 0を返します。Bの更新件数が0であることをアプリが見て、競合と判断します。最終値は在庫9・version 1で、Bの購入は「失敗」として扱われます。読み取り時に行ロックは取得しませんが、更新時にはロック待ちが発生し得ます。競合を検出した側は再読込や処理のやり直しが必要です。
| 観点 | 悲観ロック | 楽観ロック |
|---|---|---|
| 競合を扱う時点 | 読み取り時(防ぐ) | 更新時(検出する) |
| 代表的な実装 | SELECT … FOR UPDATE | version列+条件付きUPDATE |
| 競合したときの挙動 | 後続が待つ | 後続の更新が0件で失敗 |
| 向く競合頻度 | 同じ行への更新が多い | 同じ行への更新がまれ |
| 画面をまたぐ更新 | DBの行ロック保持は非推奨 | 可(versionを画面に持たせる) |
| 主な失敗例 | 保持時間の長さ・デッドロック | リトライの嵐・version更新漏れ |
業務トランザクションとDBトランザクション:画面をまたぐ更新と行ロックの制約
「悲観ロック=SELECT FOR UPDATE」と覚えると、業務システムの設計で判断を誤ります。利用者が編集画面を開き、数分かけて入力して保存する一連の操作(業務トランザクション)は、複数のDBトランザクションにまたがるからです。
Martin Fowlerの『Patterns of Enterprise Application Architecture』は、この単位の排他をOptimistic Offline LockとPessimistic Offline Lockとして整理しています。前者は業務トランザクション同士の競合を検出してロールバックし、後者は同時にデータへアクセスできる業務トランザクションを1つに限定します。楽観か悲観かは「どこで排他をかけるか」という設計の型で、FOR UPDATEはそれを実現する手段の1つにすぎません。
- 楽観(Optimistic Offline Lock):編集画面を開くときにversionを画面へ渡し、保存時の
UPDATE ... WHERE version = ?で照合します。業務システムの編集画面は、まずこれを基本にします。 - 悲観(Pessimistic Offline Lock):「この伝票は山田さんが編集中」と表示して他者の編集を止めたい場合は、DBの行ロックではなく、ロック管理用の表に編集者・取得時刻を記録します。ブラウザを閉じたまま放置されるので、有効期限と強制解除の手段を必ず用意します。
FOR UPDATEで行ロックを取ったまま利用者の入力を待つ設計は、DBの接続を占有し続け、同じ行を更新したり競合する行ロックを取得したりする処理を待たせます。DBの行ロックは、1回のリクエスト処理の中で読み取りから更新までを完結させる場面に限って使います。
悲観ロックのSQL実装:FOR UPDATE・NOWAIT・SKIP LOCKEDの挙動
FOR UPDATEで在庫を引き当てる手順
BEGIN;
SELECT stock FROM items WHERE id = 1 FOR UPDATE; -- 行ロックを取得
-- アプリでstockを確認し、1未満または行が無ければROLLBACKして終了
-- 在庫がある場合だけ次のUPDATEへ進む。外部APIはここで呼ばない
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT; -- ここでロックが外れる
ロックはCOMMITかROLLBACKまで外れません。読み取りから更新までの間に外部APIの呼び出しやメール送信を入れると、同じ行への更新や競合する行ロック取得の待ち時間が延びます。
NOWAIT・SKIP LOCKED・ロック待ちタイムアウトのエラー
待たせたくない場合はNOWAITで即座にエラーにするか、SKIP LOCKEDでロック中の行を飛ばします。別セッションがid=1をロックしている状態で実行した結果です。
| 指定 | PostgreSQL 18.6 | MySQL 8.4.8 |
|---|---|---|
| FOR UPDATE NOWAIT | ERROR 55P03 | ERROR 3572 |
| FOR UPDATE SKIP LOCKED(id 1〜3) | 2と3を返す | 2と3を返す |
| 待ち時間の上限 | lock_timeout(既定は無制限) | innodb_lock_wait_timeout(既定50秒) |
| 上限を超えたとき | ERROR 55P03 | ERROR 1205 |
エラー文はPostgreSQLがcould not obtain lock on row in relation "items"とcanceling statement due to lock timeout、MySQLがStatement aborted because lock(s) could not be acquired immediately and NOWAIT is set.とLock wait timeout exceeded; try restarting transactionでした。MySQLの既定50秒はinnodb_lock_wait_timeoutの公式リファレンスの値で、Webリクエストには長すぎるため、画面から呼ぶ処理では数秒に下げます。OracleはFOR UPDATE WAIT nで待つ秒数を文ごとに指定できます。
SKIP LOCKEDは、ジョブキュー表から未処理の行を複数のワーカーで取り合う処理に向きます。在庫の引き当てに使うと、ロック中の行が「無いもの」として扱われるため使いません。
FOR SHAREとFOR UPDATE:共有ロックと排他ロック
FOR SHAREは共有ロックで、ほかのFOR SHAREとは両立しますが、更新とFOR UPDATEは待たせます。読んだ値が確定するまで変更だけを止めたい場合(親行の存在を確かめてから子行を挿入するなど)に使います。PostgreSQLにはさらに、キー列を変えない更新用のFOR NO KEY UPDATEと、外部キー検査用のFOR KEY SHAREがあり、計4段階です。
ロック範囲は索引で決まる:MySQLの実測
MySQLのFOR UPDATEは、InnoDBのlocking readsの説明のとおり、検索で通過したインデックスレコードをロックします。3行のテーブルで、索引の無い列を条件にWHERE stock = 10 FOR UPDATEを実行すると、該当は1行なのにレコードロックは4件(全3行+末尾の疑似レコード)になり、無関係なid=3へのFOR UPDATE NOWAITがエラー3572で弾かれました。stock列に索引を作ってから同じことをすると、id=3は取得できました。
範囲条件にも注意が必要です。REPEATABLE READでWHERE id >= 2 FOR UPDATEを実行している間、別セッションのid=5の挿入はロック待ちで1205になりました。レコードとその直前の隙間を押さえるネクストキーロックなどにより、検索範囲への挿入が阻止されるためです。悲観ロックを使うSQLは、条件に使う索引の定義を確認したうえで、EXPLAINで実際に選ばれる索引と走査範囲を確認します。
デッドロックとロックエスカレーション
悲観ロックで複数の行を押さえる処理は、取得の順序が処理ごとに違うとデッドロックになります。対策は「主キーの昇順で取る」のように順序を全処理でそろえることです。発生の仕組みとDB別の解消方法はデッドロックとは?DBトランザクションで起きる原因・例と解消方法で解説しています。
SQL Serverでは、1つの文が1つのテーブルで5,000個以上のロックを取得すると、行ロックがテーブルロックへ格上げされることがあります(ロックとバージョン管理のガイド)。大量行を一度にFOR UPDATE相当で押さえるバッチは、件数を区切って小さなトランザクションに分けます。
楽観ロックの実装:version列・ORM・リトライ
UPDATE … WHERE versionと更新件数の判定
楽観ロックの本体は「versionが一致したら更新し、versionを1増やす」の1文です。判定は更新件数で行い、0件なら競合として扱います。
MySQLで1つ落とし穴を確認しました。MySQLのROW_COUNT()は、UPDATEでは既定で実際に値が変わった行数を返します。ただし、接続時にCLIENT_FOUND_ROWSを指定すると条件に一致した行数になります。上のLost Updateの実験をMySQLで行うと、後から9を書いたセッションは行に一致していたのにROW_COUNT()が0でした(直前の値も9だったため)。versionを必ず増やす更新にしておけば値は毎回変わるので、更新件数で競合を判定できます。versionを増やさない更新経路(管理画面の一括更新や手書きSQLのバッチ)が1本でもあると、その更新は楽観ロックをすり抜けます。
ORM別の楽観ロックと例外
| ORM | 指定方法 | 競合時の例外 |
|---|---|---|
| JPA(Jakarta Persistence 3.2) | @Version | OptimisticLockException |
| Rails(Active Record 8.0) | lock_version列を置く | ActiveRecord::StaleObjectError |
| EF Core 10 | 同時実行トークン([Timestamp]など) | DbUpdateConcurrencyException |
| Django 5.2 | filter(version=v).update(…)の件数で判定 | 自前で例外を投げる |
JPAはJakarta Persistence 3.2仕様で@Versionをエンティティの楽観ロック用の列と定めています。Railsはlock_version列があれば自動で楽観ロックになり、更新ごとに整数を1増やします。EF Coreは同時実行トークンとして設定した列を、SaveChangesの更新・削除時に照合します。SQL Serverのrowversionは8バイトの増加する数値で、日時ではありません。Djangoの公式APIで行ロックに当たるのはselect_for_update()で、楽観ロックはQuerySetのupdate()が返す件数で自分で判定します。
競合したときのリトライ設計
競合した処理をやり直すときは、必ず最新の値を読み直してから判定し直します。REPEATABLE READでは同じトランザクション内の通常のSELECTが古いスナップショットを読み続けるため、再試行は新しいトランザクションで行います。古い値のまま再送すると、楽観ロックの意味がありません。リトライは、たとえば最大3回を初期値とし、応答時間の上限と実測した競合率に合わせて調整します。待ち時間を徐々に長くし、ばらつきも加えて再衝突を抑えます。
利用者が入力した内容を保存する画面では、自動リトライはしません。「ほかの人が先に更新しました」と表示し、最新の値と入力内容を並べて選ばせます。自動で上書きすると、競合を検出した意味が消えます。
分離レベルで変わる前提:PostgreSQLとMySQLのREPEATABLE READの差
「分離レベルをREPEATABLE READに上げればロックは要らない」と考えがちですが、DBによって結果が逆になります。冒頭のLost Updateの処理を、両方のDBでREPEATABLE READにして流しました。
| 処理 | PostgreSQL 18.6 | MySQL 8.4.8 |
|---|---|---|
| 読んだ値から計算して書き戻す | 後続がERROR 40001 | エラー無しで上書き(在庫9) |
| UPDATE stock = stock – 1 | 後続がERROR 40001 | 最新値に適用(在庫8) |
| 既定の分離レベル | READ COMMITTED | REPEATABLE READ |
PostgreSQLは、先行トランザクションが同じ行を更新してコミットすると、後続をcould not serialize access due to concurrent updateで止めます(トランザクションの分離の説明)。アプリはSQLSTATE 40001を受けたら、トランザクションごとやり直す前提で書きます。
MySQLのREPEATABLE READは、SELECTは最初の読み取り時点のスナップショットを読みますが、UPDATEは最新の行に対して実行されます。そのため「古い値を読んで計算した結果」を書き戻すと、エラー無しで更新が消えました。MySQLでは、読み取り後に更新するならFOR UPDATEか楽観ロックが要ります。分離レベル全体の違いはトランザクションとは?分離レベルの違い・ACID・commit/rollbackを実装目線で解説【2026年版】で、読み取りがロック無しで済む仕組みはMVCCとは?多版型同時実行制御の仕組みと過去版の回収を実装目線で解説で扱っています。
競合の強さで変わる性能:pgbenchで3方式を比較
悲観ロック、楽観ロック(リトライ無し)、条件付きUPDATEの3方式で、在庫を1減らす処理をpgbenchで10秒ずつ流しました。各方式とも、読み取りと更新の間(条件付きUPDATEは更新の前)に約4ミリ秒の処理時間(pg_sleep(0.002)の実測値)を入れています。環境はmacOS 26.6.2、Intel Core i9-9880H(8コア16スレッド)、PostgreSQL 18.6です。設定はmax_connections=200への変更を除き既定値です。1,000行・各在庫1,000,000のテーブルに対し、集中時はid=1、分散時は\set id random(1, 1000)で行を選び、pgbench -n -T 10 -c 並列数 -j 8以下で実行しました。各条件1回の参考計測です。楽観ロックだけは成否集計用のINSERTも実行しているため、その追加負荷を含み、方式だけの性能差を切り分けた比較ではありません。
-- 悲観ロック
BEGIN;
SELECT stock FROM items WHERE id = :id FOR UPDATE;
SELECT pg_sleep(0.002);
UPDATE items SET stock = stock - 1 WHERE id = :id;
COMMIT;
-- 条件付きUPDATE(事前のSELECTと明示的な行ロック取得は不要)
SELECT pg_sleep(0.002);
UPDATE items SET stock = stock - 1 WHERE id = :id AND stock > 0;
-- 楽観ロック(成否をattempts表に記録)
SELECT version AS v FROM items WHERE id = :id \gset
SELECT pg_sleep(0.002);
WITH u AS (UPDATE items SET stock = stock - 1, version = version + 1
WHERE id = :id AND version = :v RETURNING 1)
INSERT INTO attempts(ok) SELECT EXISTS (SELECT 1 FROM u);
同じ1行に更新が集中する場合
| 方式 | 1並列 | 8並列 | 32並列 |
|---|---|---|---|
| 条件付きUPDATE(件/秒) | 214 | 1,752 | 2,023 |
| 悲観ロック(件/秒) | 197 | 202 | 197 |
| 悲観ロックの平均処理時間(ミリ秒) | 5.1 | 39.5 | 162.2 |
| 楽観ロックの成功(件/秒) | 190 | 201 | 178 |
| 楽観ロックの競合失敗率 | 0% | 87.0% | 96.3% |
人気商品の在庫のように1行へ更新が集中すると、悲観ロックも楽観ロックも1件ずつしか通らず、毎秒約200件で頭打ちになりました。違いは負け方です。悲観ロックは1件あたりの処理時間が並列数にほぼ比例して伸び(5.1、39.5、162.2ミリ秒)、楽観ロックではこの条件で試行の約96%が競合により更新不成立となりました。リトライを入れればその分だけDBへの往復が増えるので、集中する行に楽観ロックを使うのは最も避けるべき組み合わせです。条件付きUPDATEは、ロックを持つ時間が更新1文だけなので、約10倍の件数を処理しました。
1,000行に更新が分散する場合
| 方式 | 1並列 | 8並列 | 32並列 |
|---|---|---|---|
| 条件付きUPDATE(件/秒) | 221 | 1,663 | 5,958 |
| 悲観ロック(件/秒) | 174 | 1,288 | 4,734 |
| 楽観ロックの成功(件/秒) | 196 | 1,490 | 4,741 |
| 楽観ロックの競合失敗率 | 0% | 0.45% | 2.4% |
更新が1,000行に散ると、どの方式も並列数に応じて伸び、悲観ロックと楽観ロックの差はほぼ消えました。32並列でも楽観ロックの競合は2.4%で、まれに起きる失敗をリトライで吸収できる水準です。方式の選択より、同じ行に更新が集中するかどうかのほうが性能を大きく左右します。
使い分けの判断基準:条件付きUPDATEを含めた3方式の選択
実測を踏まえると、選び方は次の順に決まります。
- 更新が1文で書けるか:在庫や残高の増減、状態の遷移(未処理から処理中へ)のように、条件と更新を1文にまとめられるなら
UPDATE ... WHERE stock > 0のような条件付きUPDATEにします。ロック方式を選ぶ必要がありません。 - 利用者の操作をまたぐか:編集画面を開いて保存するような更新は、version列による楽観ロックにします。
- 読んだ値を使った判定が1リクエスト内で完結し、同じ行に更新が集中するか:集中するなら悲観ロック(FOR UPDATE)で、保持時間を最短にします。集中しないなら楽観ロックで構いません。
既存システムのロック設計やDBの移行で、どの更新がどのパターンかの棚卸しから進めたい場合は、データベース設計・移行支援で相談を受け付けています。
採用を避けるべき組み合わせ
- FOR UPDATEのまま利用者の入力を待つ:接続を占有し、同じ行への更新や競合する行ロックの取得を待たせます。
- 集中する1行に楽観ロック+無制限リトライ:実測で試行の96%が失敗する状況で、失敗の山がDBの負荷になります。
- MySQLのREPEATABLE READに頼る:読んで計算した値の書き戻しはエラー無しで消えます。
- MySQL InnoDBのREPEATABLE READで索引を使わないFOR UPDATE:走査範囲が広がり、条件に一致しない行までロックされる場合があります。
- version列を通さない更新経路を残す:一括更新や手書きSQLが楽観ロックをすり抜けます。
DBの行を超える排他:アドバイザリーロックと分散ロック
「同じ顧客の請求処理を同時に2本走らせない」のように、守りたい対象が特定の行ではない場合は、任意のキーでロックを取るアドバイザリーロックを使います。PostgreSQLはpg_advisory_lockやpg_try_advisory_xact_lock、MySQLはGET_LOCK()です。MySQLのGET_LOCKで取ったロックはCOMMITやROLLBACKでは外れないため、RELEASE_LOCKの呼び忘れに注意します。PostgreSQLの_xact_付きの関数はトランザクション終了で自動的に外れるので、解放漏れを避けたいならこちらを選びます。
複数のサービスやDBをまたぐ場合はRedisなどで分散ロックを取ります。Redis公式の分散ロックの文書が示すRedlockは、独立した5台を使う例では、過半数の3台以上で取得でき、取得に要した時間がロックの有効期間より短い場合に成功とする方式で、同じ文書がフェンシングトークンの実装を勧めています。Martin Kleppmannは2016年の論考で、ロックの期限切れ後に遅れて届いた書き込みを防ぐには、取得のたびに増える番号を書き込みに付け、古い番号を保存側で拒否する仕組みが要ると指摘しました。これは楽観ロックのversion照合と同じ発想で、分散ロックを使う場合も最後の守りは保存側の照合に置きます。アプリ内のスレッド間の排他はGoのsync.MutexとRWMutexの違いと使い分け|実測ベンチで選ぶ排他制御、ブラウザの複数タブ間はWeb Locks APIとは?navigator.locks.requestの使い方と複数タブの排他制御【3ブラウザ実測】が扱う領域です。
よくある質問
楽観ロックと悲観ロックはどちらが速いですか?
今回の1回の計測では、1,000行に更新を分散させた条件の32並列で毎秒4,741件と4,734件で、ほとんど差はありませんでした。同じ行に集中すると両方とも毎秒約200件で頭打ちになり、楽観ロックは試行の大半が失敗します。速さが必要なら、条件付きUPDATEで書けないかを先に検討してください。
楽観的排他制御(楽観排他)とは何ですか?
楽観ロックの別名です。「競合はめったに起きない」と考えて読み取り時には行ロックを取らず、更新時にversion列などで競合を検出します。UPDATE自体は行ロックを取得するため、更新時には待機する場合があります。悲観的排他制御(悲観排他)は悲観ロックの別名です。
悲観ロックのメリットは何ですか?
ロックを保持している間、対象行への他トランザクションの更新を待たせられることです。同じ行への更新を順番に処理できますが、デッドロックやタイムアウトが発生した場合のエラー処理は必要です。代償は、ロックを持つ間の待ち時間です。
アドバイザリーロックとは何ですか?
テーブルの行ではなく、アプリが決めた任意のキー(数値や文字列)に対して取るロックです。DBはそのキーの意味を知らず、アプリ同士が「このキーを持っている間は処理しない」と約束して使います。PostgreSQLのpg_advisory_lockやMySQLのGET_LOCKが該当します。
MVCCのDBなら楽観ロックも悲観ロックも不要ですか?
不要にはなりません。MVCCで解消されるのは読み取りと書き込みの間の待ちで、書き込み同士の競合は残ります。実測のとおり、PostgreSQLもMySQLもMVCCですが、READ COMMITTEDやMySQLのREPEATABLE READでは読んだ値の書き戻しで更新が消えました。