楽観ロックと悲観ロックの違いは、「更新がぶつかったときに、あとから検知してやり直す」か「先にロックを取って、ぶつからないようにする」かです。 楽観ロックは衝突がめったに起きない前提で、更新の瞬間に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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?