🔒

図解で分かる!楽観的ロックと悲観的ロック入門

に公開

はじめに:ECサイトでの素朴な疑問

「在庫が1個しかない商品に、2人が同時に購入ボタンを押したらどうなる?」

ECサイトやチケット予約サービスを使っているとき、こんな疑問を抱いたことはありませんか?

実は、この問題を解決する仕組みが 排他制御(Locking) と呼ばれるデータベースの技術です。特にフロントエンド開発者の方は、「バックエンドの話だし、自分には関係ない」と思われるかもしれませんが、実際にはAPIのエラーハンドリングやUI設計で理解が必要になる場面が多くあります。

本記事では、バックエンドの概念である排他制御を、フロントエンド開発者にも分かりやすく、図解とともに解説します。

排他制御の全体像

まず、排他制御の2つのアプローチを整理しましょう。

それぞれのアプローチは、異なる前提と戦略に基づいています。

項目 楽観的ロック 悲観的ロック
前提 競合はめったに起きない 競合が頻繁に起きる
戦略 更新時に競合をチェック 読み取り時にロックをかける
パフォーマンス 高い(ロック不要) 低い(ロック待機が発生)
実装 シンプル 複雑(デッドロック対策が必要)

楽観的ロック(Optimistic Locking)の仕組み

基本的な考え方

楽観的ロックは、「同時に複数の人が同じデータを編集することはめったにない」という前提で動作します。

実際の流れ(ECサイトの在庫管理を例に)

2人のユーザー(AさんとBさん)が同時に在庫1個の商品を購入しようとするケースを見てみましょう。

ポイント

  1. AさんとBさんが同時に商品情報を取得(この時点では在庫1個、version: 1)
  2. Aさんが先に購入処理を完了(在庫0個、version: 2に更新)
  3. Bさんが購入処理を試みるが、versionが一致しないためエラー
  4. Bさんには「在庫切れ」のメッセージを表示

楽観的ロックのメリット

  • パフォーマンスが高い: ロックをかけないため、読み取り処理が速い
  • 実装がシンプル: version番号を追加するだけで実装可能
  • デッドロックが発生しない: ロックを取得しないため、デッドロックの心配がない

楽観的ロックのデメリット

  • 競合時のリトライが必要: 更新失敗時、ユーザーに再度操作を求める必要がある
  • 高頻度の競合には不向き: 競合が頻繁に起きる場合、リトライが多発する

悲観的ロック(Pessimistic Locking)の仕組み

基本的な考え方

悲観的ロックは、「同時に複数の人が同じデータを編集する可能性が高い」という前提で動作します。

実際の流れ(チケット予約システムを例に)

2人のユーザー(AさんとBさん)が同時に最後の1席を予約しようとするケースを見てみましょう。

ポイント

  1. Aさんが座席情報を取得する際に、データベースにロックをかける
  2. Bさんも同じ座席を取得しようとするが、ロックがかかっているため待機
  3. Aさんが予約を確定し、ロックが解放される
  4. Bさんがロックを取得するが、既に予約済みのためエラー

悲観的ロックのメリット

  • データの整合性が確実: ロックをかけるため、確実に1人だけが編集できる
  • 競合が多い場合に有効: リトライの必要がなく、順番に処理される

悲観的ロックのデメリット

  • パフォーマンスが低い: ロック待機が発生するため、処理が遅くなる
  • デッドロックのリスク: 複数のリソースをロックする場合、デッドロックが発生する可能性
  • 実装が複雑: ロックのタイムアウト、デッドロック対策が必要

どちらを使うべき?ユースケース別ガイド

楽観的ロックが向いているケース

具体例

  • ユーザー設定の更新: 同じユーザーが複数のデバイスから同時に設定を変更することは稀
  • ブログ記事の編集: 通常、1人の著者が編集するため競合が少ない
  • プロフィール情報の変更: 自分のプロフィールを他の人が編集することはない

実装例(フロントエンド側)

// ユーザー設定の更新
interface UserSettings {
  id: string;
  theme: 'light' | 'dark';
  language: string;
  version: number; // 楽観的ロック用のバージョン番号
}

const updateSettings = async (settings: UserSettings) => {
  try {
    const response = await fetch('/api/settings', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(settings),
    });

    if (response.status === 409) {
      // 409 Conflict: versionが一致しない
      alert('設定が他のデバイスで変更されています。最新の設定を取得してください。');
      // 最新のデータを再取得
      const latestSettings = await fetchSettings();
      return latestSettings;
    }

    return await response.json();
  } catch (error) {
    console.error('設定の更新に失敗しました', error);
  }
};

悲観的ロックが向いているケース

具体例

  • チケット予約(人気イベント): 発売開始直後に大量のアクセスが集中
  • 在庫管理(限定商品): 在庫1個に対して多数のユーザーが同時購入を試みる
  • 銀行の残高更新: 金融取引では確実な整合性が必要

フロントエンドでの実装方法

1. ETagヘッダーを使った楽観的ロック

HTTPヘッダーのETag(Entity Tag)を使うことで、楽観的ロックを実現できます。

// React + TypeScript の例
import { useState, useEffect } from 'react';

interface Article {
  id: string;
  title: string;
  content: string;
}

const ArticleEditor = ({ articleId }: { articleId: string }) => {
  const [article, setArticle] = useState<Article | null>(null);
  const [etag, setEtag] = useState<string>('');

  // 記事を取得
  useEffect(() => {
    const fetchArticle = async () => {
      const response = await fetch(`/api/articles/${articleId}`);
      const data = await response.json();

      // ETagを保存
      const etagHeader = response.headers.get('ETag');
      if (etagHeader) {
        setEtag(etagHeader);
      }

      setArticle(data);
    };

    fetchArticle();
  }, [articleId]);

  // 記事を更新
  const updateArticle = async () => {
    if (!article) return;

    try {
      const response = await fetch(`/api/articles/${articleId}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
          'If-Match': etag, // ETagを条件として送信
        },
        body: JSON.stringify(article),
      });

      if (response.status === 412) {
        // 412 Precondition Failed: ETagが一致しない
        alert('記事が他のユーザーによって更新されています。最新版を取得してください。');
        // 最新のデータを再取得
        window.location.reload();
        return;
      }

      if (response.ok) {
        alert('記事を更新しました!');
        // 新しいETagを保存
        const newEtag = response.headers.get('ETag');
        if (newEtag) {
          setEtag(newEtag);
        }
      }
    } catch (error) {
      console.error('更新に失敗しました', error);
    }
  };

  return (
    <div>
      {article && (
        <>
          <input
            value={article.title}
            onChange={(e) => setArticle({ ...article, title: e.target.value })}
          />
          <textarea
            value={article.content}
            onChange={(e) => setArticle({ ...article, content: e.target.value })}
          />
          <button onClick={updateArticle}>保存</button>
        </>
      )}
    </div>
  );
};

ETagの仕組み

  1. サーバーがレスポンスにETagヘッダーを付加(例: ETag: "v1.0"
  2. クライアントが更新時にIf-Matchヘッダーで同じETagを送信
  3. サーバーが現在のETagと一致するか確認
  4. 一致しなければ412 Precondition Failedを返す

2. version番号を使った楽観的ロック

JSONレスポンスにversionフィールドを含める方法もあります。

// API レスポンス例
interface Product {
  id: string;
  name: string;
  stock: number;
  version: number; // バージョン番号
}

// フロントエンドでの実装
const purchaseProduct = async (product: Product, quantity: number) => {
  const response = await fetch('/api/products/purchase', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      productId: product.id,
      quantity,
      version: product.version, // versionを送信
    }),
  });

  if (response.status === 409) {
    // 409 Conflict: versionが一致しない(他のユーザーが先に購入した)
    alert('在庫が不足しています。最新の在庫を確認してください。');
    return;
  }

  if (response.ok) {
    alert('購入が完了しました!');
  }
};

バックエンド側の処理(概念)

-- SQLの例(疑似コード)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = :productId AND version = :version;

-- もし version が一致しない場合、更新件数が0件になる
-- → 409 Conflict を返す

3. Last-Modifiedヘッダーを使った楽観的ロック

Last-Modifiedヘッダーも、ETagと同様に使えます。

const fetchWithLastModified = async () => {
  const response = await fetch('/api/data');
  const lastModified = response.headers.get('Last-Modified');

  // 更新時に If-Unmodified-Since ヘッダーで送信
  await fetch('/api/data', {
    method: 'PUT',
    headers: {
      'If-Unmodified-Since': lastModified || '',
    },
    body: JSON.stringify(data),
  });
};

よくある疑問に答える

Q1: 楽観的ロックで十分なケースが多いのはなぜ?

A: 実際の競合発生率は低いことが多いためです。

多くのWebアプリケーションでは、同じデータを同時に編集するケースは稀です。例えば、ユーザー設定やブログ記事の編集では、通常1人のユーザーしか編集しません。

悲観的ロックは確実ですが、ロック待機によるパフォーマンス低下や、デッドロックのリスクがあります。そのため、まずは楽観的ロックを検討し、競合が頻繁に発生する場合のみ悲観的ロックを採用するのが一般的な戦略です。

Q2: 楽観的ロックで競合が発生したら、ユーザーに何を伝えればいい?

A: 分かりやすいエラーメッセージと、次のアクションを提示しましょう。

// 良い例
if (response.status === 409) {
  alert(
    'この商品は他のユーザーによって購入されました。\n' +
    '最新の在庫を確認してください。'
  );
  // 自動的に最新データを取得
  await fetchLatestData();
}

// 悪い例
if (response.status === 409) {
  alert('エラーが発生しました。');
}

Q3: ETagとversion番号、どちらを使うべき?

A: APIの設計方針によりますが、それぞれメリットがあります。

項目 ETag version番号
標準化 HTTP標準(RFC 7232) アプリケーション独自
実装 HTTPヘッダー JSONフィールド
可読性 ヘッダーに隠れる JSONで明示的に見える
柔軟性 ハッシュ値など多様な値を使用可能 シンプルな整数値

推奨

  • RESTful APIで標準的な実装をしたい → ETag
  • JSONレスポンスにバージョン情報を含めたい → version番号
  • 両方使う → より堅牢な実装(冗長性の確保)

Q4: 楽観的ロックで競合が発生したら、自動リトライすべき?

A: 基本的には避け、ユーザーに判断を委ねるべきです。

自動リトライは、以下の理由で推奨されません:

  • ユーザーが意図しない更新が行われる可能性
  • リトライループに陥り、サーバーに負荷がかかる
  • ユーザーに状況を伝えないため、不信感を与える
// ❌ 悪い例: 自動リトライ
const updateWithRetry = async (data: any, maxRetries = 3) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await updateData(data);
    } catch (error: any) {
      if (error.status === 409 && i < maxRetries - 1) {
        continue; // リトライ
      }
      throw error;
    }
  }
};

// ✅ 良い例: ユーザーに通知して再取得
const updateWithNotification = async (data: any) => {
  try {
    return await updateData(data);
  } catch (error: any) {
    if (error.status === 409) {
      // ユーザーに通知
      const shouldRetry = confirm(
        'データが他のユーザーによって更新されています。\n' +
        '最新データを取得して、もう一度編集しますか?'
      );

      if (shouldRetry) {
        // 最新データを取得して編集画面をリフレッシュ
        const latestData = await fetchLatestData();
        return latestData;
      }
    }
    throw error;
  }
};

例外的に自動リトライが許容されるケース

  • べき等性が保証されている操作(同じ操作を繰り返しても結果が同じ)
  • ユーザーの入力値が反映されない操作(カウンターのインクリメントなど)

比較表:一目で分かる選択ガイド

項目 楽観的ロック 悲観的ロック
前提 競合は稀 競合が頻繁
ロックのタイミング 更新時にチェック 読み取り時にロック
パフォーマンス 🟢 高い 🔴 低い(ロック待機)
実装の複雑さ 🟢 シンプル 🔴 複雑
デッドロックのリスク 🟢 なし 🔴 あり
ユーザー体験 🔴 競合時リトライ必要 🟢 順番に処理される
適したケース ユーザー設定、記事編集 チケット予約、在庫管理(高頻度)

まとめ

楽観的ロックと悲観的ロックは、データの整合性を保つための2つの戦略です。

楽観的ロック

  • 「たぶん大丈夫」と楽観的に考え、更新時に競合をチェック
  • パフォーマンスが高く、実装がシンプル
  • 競合が少ないケースに最適(ユーザー設定、記事編集など)

悲観的ロック

  • 「競合するかも」と悲観的に考え、読み取り時にロック
  • データの整合性が確実だが、パフォーマンスが低い
  • 競合が多いケースに最適(チケット予約、金融取引など)

フロントエンド開発者が覚えておくべきポイント

  1. まずは楽観的ロックを検討する(多くのケースで十分)
  2. ETagやversion番号を活用してAPIレスポンスで競合を検出
  3. 競合時のエラーハンドリングを丁寧に実装(ユーザーに分かりやすいメッセージ)
  4. 競合が頻繁に発生するケースでは、バックエンドチームと相談して悲観的ロックを検討

次のステップ

まずは、現在開発中のプロジェクトで以下を試してみましょう:

  1. APIレスポンスを確認

    • レスポンスヘッダーにETagLast-Modifiedが含まれているか確認
    • JSONレスポンスにversionフィールドがあるか確認
  2. エラーハンドリングをチェック

    • 409 Conflictエラーのハンドリングが適切に実装されているか確認
    • ユーザーに分かりやすいエラーメッセージを表示しているか確認
  3. 実装してみる

    • ユーザー設定の更新処理に、楽観的ロックを実装してみる
    • ETagまたはversion番号を使った実装例を参考にコードを書いてみる

この記事で紹介した実装例を参考に、実際のプロジェクトで排他制御を適切に扱えるようになりましょう。データの整合性を保ちながら、快適なユーザー体験を提供できるはずです!


株式会社くりぼー

Discussion