楽観的ロックvs悲観的ロック:本番アプリケーションにおける同時データベースアクセスの制御

Database tutorial - IT technology blog
Database tutorial - IT technology blog

レースコンディションと6ヶ月間戦って学んだこと

私たちのECプラットフォームには、フラッシュセール中にしか現れない微妙なバグがありました。2人のユーザーが同時に最後の在庫アイテムを取得し、両方のトランザクションが成功してしまい、在庫数がマイナスになるという問題です。典型的な同時書き込みの競合です。

ロック戦略の試行錯誤を6ヶ月間続け、アプローチを切り替え、影響を計測し、エッジケースに痛い目を見てきた経験から、本番環境で実際に機能するものを共有したいと思います。きれいに見えるだけの理論ではなく、実践的な知見です。

根本的に異なる2つの考え方

どちらの戦略も同じ根本問題に取り組んでいます。複数のトランザクションが同じ行を同時に変更する際のデータ破損です。違いはタイミングにあります——いつ排他性を強制するか、そしてそのコストです。

悲観的ロック

悲観的ロックは、競合が必ず起きると仮定します。そのため、データを読み取る前にロックを取得して競合を未然に防ぎます。ロックを解放するまで、他のトランザクションはロックされた行を変更できません。

-- PostgreSQL: 読み取り時に即座に行をロック
BEGIN;
SELECT quantity FROM inventory
WHERE product_id = 42
FOR UPDATE;  -- この行はロックされた

-- ここでビジネスロジックを実行
UPDATE inventory SET quantity = quantity - 1
WHERE product_id = 42;
COMMIT;

FOR UPDATE句はPostgreSQLに対して、トランザクション全体を通じてその行に排他ロックを保持するよう指示します。FOR UPDATEで読み取ろうとする他のトランザクションはブロックされ、順番を待ちます。

楽観的ロック

楽観的ロックは逆の前提を取ります。競合はめったに起きないので、事前に何もロックしません。代わりに、バージョン番号やタイムスタンプを使って書き込み時に競合を検出します。

-- テーブルにバージョン列が必要
ALTER TABLE inventory ADD COLUMN version INTEGER DEFAULT 0;

-- ロックなしで読み取り
SELECT quantity, version FROM inventory WHERE product_id = 42;
-- 結果: quantity=10, version=5

-- バージョンが変わっていない場合のみ更新
UPDATE inventory
SET quantity = quantity - 1, version = version + 1
WHERE product_id = 42 AND version = 5;
-- 別のトランザクションがすでに更新していた場合、バージョンは6になっている
-- このUPDATEは0行に影響 → 競合を検出

アプリケーションは影響を受けた行数を確認します。0行の更新は、読み取りと書き込みの間に誰かがデータを変更したことを意味します——その場合は操作を再試行します。

トレードオフ:率直な評価

悲観的ロック

うまく機能する点:

  • 競合がアプリケーション層に到達しない——エラー処理がシンプルに保てる
  • 書き込み競合が頻繁な場合に最適(フラッシュセール、座席予約など)
  • 正確性を推論しやすい——ロックが不変条件を保証してくれる

本番環境での問題点:

  • 高い同時実行時にロック競合がスループットを低下させる——トランザクションがすぐにキューに積み上がる
  • トランザクションが異なる順序でロックを取得するとデッドロックのリスクがある
  • 長時間実行するトランザクションがロックを長く保持し、後続の処理すべてをブロックする
  • リードレプリカとの相性が悪い——ロックはプライマリにのみ適用される

楽観的ロック

うまく機能する点:

  • 読み取り時のロックオーバーヘッドがゼロ——読み取り負荷の高いワークロードでより良くスケールする
  • デッドロックが構造的に不可能(何もロックされない)
  • 特別な処理なしで分散システムやリードレプリカをまたいで機能する
  • 競合が真に稀な場合(リクエストの約5%未満)はスループットが高い

本番環境での問題点:

  • リトライロジックが本当の複雑さをもたらす——StaleDataErrorが発生しうる箇所すべてで処理が必要
  • 競合が多い場合、リトライが積み上がってスループットが崩壊する——悲観的ロックより悪化することも
  • 既存テーブルへのスキーマ変更(バージョン列)が必要で、マイグレーションが伴う
  • ユーザーには短い待機ではなく操作失敗が見える——UXの丁寧な処理が必要

競合レベルに合った戦略の選択

複数のサービスで両方を本番環境で運用した経験から、次の経験則に落ち着きました:

  • 書き込み競合が多い場合(在庫管理、座席予約、金融口座)→ 悲観的ロック
  • 書き込み競合が少ない場合(ユーザープロフィール更新、ドキュメント編集、設定変更)→ 楽観的ロック
  • 混合ワークロード→ デフォルトは楽観的、特定のホットリソースに限り悲観的

私たちのフラッシュセール在庫問題には、悲観的ロックが正解でした。競合ウィンドウはごく短く——チェックアウト中の50〜200ms程度。ブロッキングは許容できます。在庫のオーバーセルは許容できません。

PostgreSQL固有のTips:SKIP LOCKED

キューのようなパターンには、FOR UPDATE SKIP LOCKEDが画期的です。ブロックする代わりに、トランザクションはすでにロックされた行をスキップして次の利用可能な行を処理します:

-- ブロックせずに保留中の注文を処理
SELECT id, customer_id, total
FROM orders
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;

このパターンはジョブキューやバッチ処理に威力を発揮します。特定の行ではなく何らかの利用可能な行があればよいので、ロック済みの行をスキップすることで全ワーカーが並列で動き続けられます。

実装:Python + SQLAlchemy

PostgreSQLをバックエンドにしたFastAPIサービスで私が実際に使っているコードです——コピーしてすぐ使えます。

SQLAlchemyによる楽観的ロック

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.exc import StaleDataError

class Base(DeclarativeBase):
    pass

class Inventory(Base):
    __tablename__ = "inventory"
    
    id = Column(Integer, primary_key=True)
    product_id = Column(Integer, nullable=False)
    quantity = Column(Integer, nullable=False)
    # SQLAlchemyはこの列を楽観的ロックに自動的に使用する
    version = Column(Integer, nullable=False, default=0)
    
    __mapper_args__ = {
        "version_id_col": version  # 楽観的ロックを有効化
    }


def deduct_inventory(session, product_id: int, qty: int) -> bool:
    """成功時はTrueを返し、競合時はFalseを返す(呼び出し元が再試行すること)。"""
    try:
        item = session.query(Inventory).filter_by(
            product_id=product_id
        ).first()
        
        if item.quantity < qty:
            return False  # 在庫不足
        
        item.quantity -= qty
        session.commit()  # バージョン不一致の場合はStaleDataErrorを発生させる
        return True
        
    except StaleDataError:
        session.rollback()
        return False  # 呼び出し元が再試行する

SQLAlchemyによる悲観的ロック

from sqlalchemy import select
from sqlalchemy.orm import Session

def deduct_inventory_pessimistic(
    session: Session, 
    product_id: int, 
    qty: int
) -> bool:
    """SELECT FOR UPDATEを使用——ロック取得までブロックする。"""
    # with_for_update()はSQLにFOR UPDATEを追加する
    item = session.execute(
        select(Inventory)
        .filter_by(product_id=product_id)
        .with_for_update()
    ).scalar_one_or_none()
    
    if item is None or item.quantity < qty:
        return False
    
    item.quantity -= qty
    session.commit()  # ここでロックが解放される
    return True


# スキップロックのバリアント(ジョブキューパターン):
def claim_pending_orders(session: Session, batch_size: int = 10):
    return session.execute(
        select(Order)
        .filter_by(status="pending")
        .order_by(Order.created_at)
        .limit(batch_size)
        .with_for_update(skip_locked=True)
    ).scalars().all()

楽観的ロック用のリトライデコレータ

リトライロジックは一元化しましょう。各サービスメソッドに散らばらせると保守の悪夢になります:

import functools
import time
from sqlalchemy.exc import StaleDataError

def retry_on_conflict(max_retries: int = 3, delay: float = 0.1):
    """楽観的ロックの競合時にリトライするデコレータ。"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                result = func(*args, **kwargs)
                if result is not False:  # 戻り値の規約に合わせて調整
                    return result
                if attempt < max_retries - 1:
                    time.sleep(delay * (attempt + 1))  # 指数バックオフ
            raise RuntimeError(
                f"{max_retries}回リトライ後も操作に失敗しました(競合)"
            )
        return wrapper
    return decorator


@retry_on_conflict(max_retries=3)
def update_user_profile(session, user_id, data):
    # ここに楽観的ロックのロジック
    pass

デッドロックの処理(悲観的ロック)

デッドロックは常に明示的に処理しましょう——PostgreSQLはデッドロックを検出してエラーとして通知します:

from psycopg2 import errors as pg_errors
from sqlalchemy.exc import OperationalError

def safe_checkout(session, order_id):
    try:
        # 悲観的ロックのロジック
        process_order(session, order_id)
    except OperationalError as e:
        if "deadlock detected" in str(e).lower():
            session.rollback()
            # ログに記録してリトライするか、ユーザーに通知する
            raise RetryableError("もう一度お試しください")
        raise

もう一つの実践的なTips

厄介なロック問題をデバッグしていたとき、データベースのCSVエクスポートとJSONの設定ファイルを相互参照する必要がありました。toolcraft.app/ja/tools/data/csv-to-jsonを使ってブラウザ上で直接変換しました——インストール不要、サーバーへのアップロードも不要です。環境間でバージョン列の値が一致しているか確認するだけのためにアドホックなスクリプトを書く手間が省け、20分の節約になりました。

まとめ

どちらの戦略が普遍的に優れているわけではありません。悲観的ロックは同時実行性を犠牲にして強力な保証を提供します。楽観的ロックはリトライの複雑さを犠牲にしてスケーラビリティを提供します。

本当のスキルは、システムの中で実際に競合のホットスポットとなっている箇所を特定することです。まずは遅いクエリとロック待ちを計測しましょう——推測は禁物です。ほとんどの更新には楽観的ロックから始め、本当に必要な少数の高競合リソースにのみ悲観的ロックを追加していきましょう。本番環境でのロック待ちを監視しpg_stat_activitypg_locksが強力な味方です)、トラフィックパターンの変化に合わせて戦略を見直してください。

Share: