- データベース
- 技術 💻
楽観ロックと悲観ロックの違い|PostgreSQL実測でわかる使い分け
楽観ロックと悲観ロックの違いは、「更新がぶつかったときに、あとから検知してやり直す」か「先にロックを取って、ぶつからないようにする」かです。 楽観ロックは衝突がめったに起きない前提で、更新の瞬間にversion列などで変更の有無を確かめます。悲観ロックは衝突が起きる前提で、読む時点で行をロックし、ほかの更新を待たせます。
この記事では、PostgreSQL 16で「在庫引当」を16並列で実際に動かし、競合の強さごとに成功数・衝突回数・処理時間を比べました。さらに、SELECT ... FOR UPDATE・NOWAIT・SKIP LOCKED の違いを実行ログで示し、ロックなしだと更新が消える(ロストアップデート)様子も数字で確認しています。最後に、JPAの@Versionと使い分け判断表をまとめました。
楽観ロックと悲観ロックの違い早見表
| 項目 | 楽観ロック | 悲観ロック |
|---|---|---|
| 考え方 | 衝突はめったに起きない | 衝突は起きるものとして備える |
| しくみ | 更新時に「読んだときから変わっていないか」をversion列で確認 | 読む時点で行をロック(SELECT ... FOR UPDATE) |
| 衝突したとき | 更新0件 → アプリが読み直して再試行 | 後から来た処理がロック解放まで待つ |
| DBのロック保持時間 | 短い(UPDATEの一瞬) | 長い(読んでからCOMMITまで) |
| 向いている場面 | 競合が少ない、画面で長く編集する | 競合が多い、必ず成功させたい |
| 注意点 | 競合が多いとリトライが増え、失敗も出る | 待ちが発生する、デッドロックの可能性 |
| 今回の実測(全員が同じ1行) | 400件中225件成功、衝突2,505回 | 400件すべて成功、1.50秒 |
| 今回の実測(256行に分散) | 400件すべて成功、衝突13回、0.31秒 | 400件すべて成功、0.29秒 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「楽観ロックと悲観ロックの違い|PostgreSQL実測でわかる使い分け」
なぜロックが必要なのか:ロストアップデート
ロックが必要になる理由は、ロストアップデート(更新の消失)を防ぐためです。2つの処理が同じ在庫を読み、それぞれ「読んだ値 − 1」を書き戻すと、片方の更新が上書きされて消えます。
sequenceDiagram
participant A as 処理A
participant DB as product(stock=10)
participant B as 処理B
A->>DB: SELECT stock → 10
B->>DB: SELECT stock → 10
A->>DB: UPDATE stock = 9
B->>DB: UPDATE stock = 9
Note over DB: 2件引き当てたのに在庫は9(1件分が消えた)
このあとの実測でも、ロックなしの方式では、400件の引当のうち最大368件分の減算が消えました。
楽観ロックのしくみ
version列で「変わっていないか」を確かめる
テーブルにversion列を持たせ、更新のたびに1ずつ増やします。更新条件に「読んだときのversion」を入れ、更新件数が0件なら、誰かが先に更新したと判断します。
CREATE TABLE product (
id int PRIMARY KEY,
stock int NOT NULL,
version int NOT NULL DEFAULT 0
);
-- 1) 読む
SELECT stock, version FROM product WHERE id = 1; -- 例:stock=10, version=0
-- 2) 読んだversionを条件に更新する
UPDATE product
SET stock = 9, version = version + 1
WHERE id = 1 AND version = 0;
-- 更新件数 1 → 成功 / 0 → 衝突(読み直してやり直す)
実行ログ:2つの処理が同時に更新した場合
Python(psycopg 3)で2つの接続を使い、同じ行を同時に読んでから順に更新しました。
A read: (10, 0)
B read: (10, 0)
A update rowcount: 1
B update rowcount: 0
now: (9, 1)
Bの更新は0件になり、Aの更新が上書きされずに残りました。 Bはここで読み直してやり直すか、利用者に「ほかの人が更新しました」と伝えます。楽観ロックはDBの特別な機能を使わず、UPDATEの条件だけで実現できるのが特長です。
悲観ロックのしくみ
SELECT ... FOR UPDATE で先にロックする
読む時点で行ロックを取り、COMMITまで他の更新(や同じ行へのFOR UPDATE)を待たせます。
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- ここで行ロック
-- アプリで在庫チェックなど
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT; -- ここでロック解放
なお、PostgreSQLの通常のSELECT(ロックなし)は、FOR UPDATEでロックされた行も待たずに読めます。待たされるのは、同じ行を更新・ロックしようとする処理です。
FOR UPDATE/NOWAIT/SKIP LOCKED の挙動を比べる
3つの書き方を、2〜3の接続で実行したログです。
01:00:09.572 A: id=1 をロック(2秒後にCOMMIT)
01:00:09.572 B: SELECT ... WHERE id = 1 FOR UPDATE 実行
01:00:11.574 A: COMMIT
01:00:11.574 B: ロック取得(待ち 2.00秒)
NOWAIT: LockNotAvailable SQLSTATE=55P03 (0ms) could not obtain lock on row in relation "job"
A が取った行: [1, 2]
B が取った行: [3, 4]
C が取った行: [5, 6]
| 書き方 | ロック中の行に当たったとき | 実行結果 | 主な用途 |
|---|---|---|---|
FOR UPDATE |
解放まで待つ | Aの COMMIT まで2.00秒待った | 在庫引当など、必ず順番に処理したい |
FOR UPDATE NOWAIT |
すぐエラー | SQLSTATE 55P03、0ms | 待たせずに「処理中です」と返したい |
FOR UPDATE SKIP LOCKED |
その行を飛ばす | A・B・Cが別々の行を取得 | ジョブキュー、複数ワーカーでの取り出し |
SKIP LOCKED で使ったSQLは次のとおりです。複数のワーカーが同じジョブを取り合わずに、並行して処理を進められます。
SELECT id FROM job
WHERE status = 'waiting'
ORDER BY id
LIMIT 2
FOR UPDATE SKIP LOCKED;
待ち時間の上限をつけたい場合は、SET lock_timeout = '3s' のようにlock_timeoutを設定する方法もあります(超えるとSQLSTATE 55P03のエラーになります)。
実測:競合の強さで結果はどう変わるか
検証の条件
- PostgreSQL 16.13、Python 3.11 + psycopg 3.3、2コアのLinuxコンテナ
- 16スレッド × 25回 = 400件の在庫引当(1件ごとに在庫を1減らす)
- 読んでから書くまでにアプリ側の処理2msを挟む
- 対象の商品数を1・4・16・256に変えて、競合の強さを調整(商品が少ないほど同じ行を取り合う)
- 楽観ロックのリトライは最大10回。超えたら「断念」として数える
比べた方式は4つです。
def reserve_nolock(conn, pid): # ロックなし(比較用)
with conn.transaction():
stock, = conn.execute("SELECT stock FROM product WHERE id=%s", (pid,)).fetchone()
app_work()
conn.execute("UPDATE product SET stock=%s WHERE id=%s", (stock - 1, pid))
def reserve_optimistic(conn, pid): # 楽観ロック
for retry in range(MAX_RETRY + 1):
with conn.transaction():
stock, ver = conn.execute(
"SELECT stock, version FROM product WHERE id=%s", (pid,)).fetchone()
app_work()
cur = conn.execute(
"UPDATE product SET stock=%s, version=version+1 WHERE id=%s AND version=%s",
(stock - 1, pid, ver))
if cur.rowcount == 1:
return retry # 成功
raise RuntimeError(MAX_RETRY + 1) # 断念
def reserve_pessimistic(conn, pid): # 悲観ロック
with conn.transaction():
stock, = conn.execute(
"SELECT stock FROM product WHERE id=%s FOR UPDATE", (pid,)).fetchone()
app_work()
conn.execute("UPDATE product SET stock=%s WHERE id=%s", (stock - 1, pid))
def reserve_atomic(conn, pid): # 1文で減らす(参考)
conn.execute("UPDATE product SET stock = stock - 1 WHERE id=%s AND stock > 0", (pid,))
app_work()
実行結果
items=1 nolock ok=400 gaveup=0 conflicts=0 stock_decreased=32 lost=368 time=0.36s
items=1 optimistic ok=225 gaveup=175 conflicts=2505 stock_decreased=225 lost=0 time=1.40s
items=1 pessimistic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=1.50s
items=1 atomic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.36s
items=4 nolock ok=400 gaveup=0 conflicts=0 stock_decreased=118 lost=282 time=0.32s
items=4 optimistic ok=375 gaveup=25 conflicts=1015 stock_decreased=375 lost=0 time=0.73s
items=4 pessimistic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.46s
items=4 atomic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.22s
items=16 nolock ok=400 gaveup=0 conflicts=0 stock_decreased=247 lost=153 time=0.30s
items=16 optimistic ok=400 gaveup=0 conflicts=264 stock_decreased=400 lost=0 time=0.42s
items=16 pessimistic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.33s
items=16 atomic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.20s
items=256 nolock ok=400 gaveup=0 conflicts=0 stock_decreased=378 lost=22 time=0.30s
items=256 optimistic ok=400 gaveup=0 conflicts=13 stock_decreased=400 lost=0 time=0.31s
items=256 pessimistic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.29s
items=256 atomic ok=400 gaveup=0 conflicts=0 stock_decreased=400 lost=0 time=0.22s
lostは「成功と報告したのに、実際には在庫が減らなかった件数」、conflictsは楽観ロックで更新が0件になった回数です。
表にまとめると
| 競合の強さ(商品数) | 楽観ロックの衝突率 | 楽観ロック:成功/処理時間 | 悲観ロック:成功/処理時間 | ロックなし:消えた更新 |
|---|---|---|---|---|
| 非常に強い(1) | 約92% | 225/400、1.40秒 | 400/400、1.50秒 | 368件 |
| 強い(4) | 約73% | 375/400、0.73秒 | 400/400、0.46秒 | 282件 |
| 中くらい(16) | 約40% | 400/400、0.42秒 | 400/400、0.33秒 | 153件 |
| 弱い(256) | 約3% | 400/400、0.31秒 | 400/400、0.29秒 | 22件 |
※衝突率 = 衝突回数 ÷ 更新の試行回数(成功+衝突)。1回ずつの実行結果で、環境によって数値は変わります。
結果からわかること
- ロックなしは、競合が弱くても更新が消えます。 256商品に分散しても22件が消えました。在庫のように「読んで計算して書く」処理には、何らかの制御が必要です。
- 競合が強いと、楽観ロックはリトライが急増します。 全員が1行を狙うと衝突率は約92%、リトライ10回でも175件が断念になりました。必ず成功させたい処理には悲観ロックが向いています。
- 競合が弱ければ、両者の差はほぼありません。 256商品では衝突率約3%、処理時間もほぼ同じでした。この領域では、ロックを持ち続けない楽観ロックの身軽さが活きます。
- 悲観ロックは「待つ」ので、競合が強いと直列化します。 1行に集中した場合は1.50秒で、1件あたり約3.7msを順番に処理した計算です。
- 「1文で減らす」方式(atomic)は、どの条件でも最速で正確でした。
UPDATE ... SET stock = stock - 1 WHERE stock > 0はDBが行ロックを1文の間だけ持つため、アプリで値を計算する必要がない処理では、まずこの形を検討するとよいです。
JPA(Java)での書き方
実務では、ORMの機能を使うことが多いです。以下はJakarta Persistenceの仕様とSpring Data JPAのドキュメントに沿った書き方です(本記事では実行していません)。
楽観ロック:@Version
@Entity
public class Product {
@Id
private Long id;
private int stock;
@Version // 楽観ロック用のversion列
private long version;
}
@Versionを付けると、更新時のWHERE句にversionが自動で入り、更新のたびに値が増えます。- 使える型は
int/Integer/short/Short/long/Long/java.sql.Timestamp/Instant/LocalDateTimeです。1エンティティに1つまでとされています。 - versionが合わないと
jakarta.persistence.OptimisticLockExceptionが発生します。SpringではObjectOptimisticLockingFailureException(OptimisticLockingFailureExceptionの一種)として受け取ることが多く、ここでリトライするか、利用者に再読み込みを促します。
悲観ロック:LockModeType.PESSIMISTIC_WRITE
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
PESSIMISTIC_WRITE は、一般的なDBでは SELECT ... FOR UPDATE 相当のロックとして発行されます。待ち時間の上限は、ヒント jakarta.persistence.lock.timeout(ミリ秒)で指定できます。実際に発行されるSQLは、SQLログを有効にして一度確認しておくと安心です。
楽観ロックと悲観ロックの使い分け判断表
| 状況 | おすすめ | 理由 |
|---|---|---|
| 在庫・予約枠・カウンターなど、同じ行に更新が集中する | 悲観ロック | 楽観ロックだとリトライと断念が増える(実測で衝突率約92%) |
| 会員情報の編集画面など、画面を開いてから保存までが長い | 楽観ロック | DBロックを数分持ち続けるのは現実的でない |
| 更新対象が多くの行に分散している | 楽観ロック | 衝突が少なく(実測で約3%)、ロックを持たない分だけ身軽 |
| 値の計算をDBの1文で書ける(在庫を1減らす等) | 条件つきUPDATE | ロック時間が最短で、最速かつ正確 |
| 複数ワーカーでジョブを取り出す | FOR UPDATE SKIP LOCKED |
ロック中の行を飛ばして並行処理できる |
| 待たせずに「処理中」を返したい | FOR UPDATE NOWAIT |
すぐにエラーで判定できる |
| 複数の行・テーブルを悲観ロックする | 悲観ロック+ロック順の統一 | 順番がばらばらだとデッドロックの原因になる |
よくある失敗と対策
| よくある失敗 | 起きること | 対策 |
|---|---|---|
| 楽観ロックで衝突時に何もしない | 更新0件のまま「成功」扱いになる | 更新件数を必ず確認し、0件ならリトライかエラー |
| 楽観ロックのリトライを無制限にする | 競合時に処理が終わらない | 回数に上限をつけ、間隔を少しずつ空ける |
| FOR UPDATE をトランザクション外で使う | 文の終了でロックが解けて意味がない | BEGIN〜COMMIT の中で使う |
| ロック中に外部API呼び出しなど長い処理をする | ほかの処理が長く待たされる | ロックの外で準備し、ロック区間を短くする |
| 画面編集に悲観ロックを使う | 開いたままの画面がロックを持ち続ける | 画面編集は楽観ロック(version)にする |
よくある質問
Q. 楽観ロックと悲観ロックは、どちらを基本にすべきですか? A. 競合の見込みで決めるのがおすすめです。同じ行への同時更新が多いなら悲観ロック、少ないなら楽観ロックが扱いやすいです。今回の実測では、競合が弱い(256行に分散)と両者の差はほぼなく、強い(1行に集中)と楽観ロックの成功は400件中225件にとどまりました。
Q. version列の代わりに更新日時を使ってもよいですか?
A. JPAの@VersionはTimestampやInstantも使えます。ただし、同じ時刻に2回更新されると区別できない可能性があるため、整数のversion列のほうが確実です。
Q. 分離レベルをSERIALIZABLEにすれば、ロックは不要ですか? A. SERIALIZABLEでも整合性は守られますが、衝突時はシリアライズ失敗のエラーになり、アプリ側でのリトライが必要になる点は楽観ロックと同じです。処理の性質に合わせて選ぶのがよいでしょう。
Q. MySQLでも同じ書き方ができますか?
A. MySQL 8.0以降のInnoDBでも、SELECT ... FOR UPDATE・NOWAIT・SKIP LOCKED を使えます。ただし、デフォルトの分離レベル(REPEATABLE READ)やロックの範囲はPostgreSQLと違うため、本番と同じDBで挙動を確認してください。
まとめ
- 楽観ロックは「あとで検知してやり直す」、悲観ロックは「先にロックして待たせる」
- ロックなしでは更新が消える。実測では400件中最大368件分が消えた
- 競合が強いと楽観ロックはリトライが急増(衝突率約92%、成功225/400)。悲観ロックは400件すべて成功
- 競合が弱ければ両者の差は小さい(衝突率約3%、処理時間ほぼ同じ)
- 1文で書ける更新は条件つきUPDATEが最速かつ正確
- NOWAITは即エラー、SKIP LOCKEDは飛ばして取得。用途で使い分ける
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「楽観ロックと悲観ロックの違い|PostgreSQL実測でわかる使い分け」
株式会社RJCでは、同時実行の設計を実際に動かして確かめ、その結果をチームで共有しています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。