悲観ロック vs 楽観ロック|LaravelとRailsで学ぶ実装パターンと使い分け

RailsからLaravelを眺める

※本記事は「RailsからLaravelを眺める」シリーズの第12回です。Rails出身の私がLaravelを触りながら、Railsと比較して違いを整理していく連載になります。

はじめに

Webアプリケーション開発において、複数のユーザーが同時に同じデータを更新しようとする「同時実行制御」は避けて通れない問題です。例えば、ECサイトでの在庫管理や、チケット予約システムなどで、適切な制御がないとデータの整合性が崩れてしまいます。

この記事では、同時実行制御の代表的な手法である「悲観ロック」と「楽観ロック」について、LaravelとRailsでの実装方法を比較しながら解説します。

同時実行制御が必要なシナリオ

まず、具体的な問題を見てみましょう。

ECサイトで商品の在庫が10個ある状態で、ユーザーAとユーザーBが同時に1個ずつ購入しようとするケースを考えます。

  1. ユーザーAが在庫数を読み取ります(10個)
  2. ユーザーBも在庫数を読み取ります(10個)
  3. ユーザーAが1個購入し、在庫を9個に計算します
  4. ユーザーBも1個購入し、在庫を9個に計算します
  5. ユーザーAが在庫を9個に更新します
  6. ユーザーBも在庫を9個に更新します

この場合、本来は8個になるべき在庫が9個になってしまいます。これが「ロストアップデート問題」です。

悲観ロック (Pessimistic Locking)

悲観ロックは「競合が発生する」という前提で、データを読み取る時点でロックをかけます。

特徴

  • データ読み取り時にロックを取得
  • 他のトランザクションは待機させられる
  • デッドロックの可能性がある
  • 競合が多い場合に有効

Laravel での実装

Laravelでは lockForUpdate() メソッドを使用します。

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function purchase(Request $request, $productId)
    {
        return DB::transaction(function () use ($productId, $request) {
            // 悲観ロック: SELECT ... FOR UPDATE
            $product = Product::where('id', $productId)
                ->lockForUpdate()
                ->first();

            if ($product->stock < $request->quantity) {
                throw new \Exception('在庫不足です');
            }

            // 在庫を減らす
            $product->stock -= $request->quantity;
            $product->save();

            // 注文を作成
            auth()->user()->orders()->create([
                'product_id' => $product->id,
                'quantity' => $request->quantity,
                'price' => $product->price * $request->quantity,
            ]);

            return response()->json([
                'message' => '購入が完了しました',
                'remaining_stock' => $product->stock
            ]);
        });
    }

    // 共有ロック(読み取り専用)の例
    public function getStock($productId)
    {
        return DB::transaction(function () use ($productId) {
            // SELECT ... FOR SHARE (他の読み取りは許可、更新は不可)
            $product = Product::where('id', $productId)
                ->sharedLock()
                ->first();

            return response()->json([
                'stock' => $product->stock
            ]);
        });
    }
}

Laravelの悲観ロックメソッド

// 排他ロック (UPDATE/DELETE用)
$product = Product::lockForUpdate()->find($id);

// 共有ロック (READ用、他のSELECT ... FOR SHAREは可)
$product = Product::sharedLock()->find($id);

Rails での実装

Railsでは lock メソッドまたは with_lock メソッドを使用します。

class OrdersController < ApplicationController
  def purchase
    Product.transaction do
      # 悲観ロック: SELECT ... FOR UPDATE
      product = Product.lock.find(params[:product_id])

      if product.stock < params[:quantity].to_i
        raise ActiveRecord::RecordInvalid, '在庫不足です'
      end

      # 在庫を減らす
      product.stock -= params[:quantity].to_i
      product.save!

      # 注文を作成
      current_user.orders.create!(
        product_id: product.id,
        quantity: params[:quantity],
        price: product.price * params[:quantity].to_i
      )

      render json: {
        message: '購入が完了しました',
        remaining_stock: product.stock
      }
    end
  rescue ActiveRecord::RecordInvalid => e
    render json: { error: e.message }, status: :unprocessable_entity
  end

  # with_lockを使った書き方(推奨)
  def purchase_with_lock
    product = Product.find(params[:product_id])
    
    product.with_lock do
      if product.stock < params[:quantity].to_i
        raise ActiveRecord::RecordInvalid, '在庫不足です'
      end

      product.stock -= params[:quantity].to_i
      product.save!

      current_user.orders.create!(
        product_id: product.id,
        quantity: params[:quantity],
        price: product.price * params[:quantity].to_i
      )
    end

    render json: {
      message: '購入が完了しました',
      remaining_stock: product.stock
    }
  end
end

Railsの悲観ロックメソッド

# 基本的な書き方
product = Product.lock.find(id)

# ブロック形式(自動でトランザクション開始)
product.with_lock do
  product.stock -= 1
  product.save!
end

# ロックの種類を指定(PostgreSQL)
Product.lock("FOR UPDATE NOWAIT").find(id)

楽観ロック (Optimistic Locking)

楽観ロックは「競合は稀」という前提で、更新時にバージョンをチェックします。

特徴

  • データ読み取り時はロックしない
  • 更新時にバージョン番号で競合を検出
  • 競合時は例外が発生
  • 競合が少ない場合に効率的

Laravel での実装

Laravelには楽観ロックの組み込み機能がないため、手動で実装します。

マイグレーション

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::table('products', function (Blueprint $table) {
            $table->integer('version')->default(0);
        });
    }

    public function down()
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('version');
        });
    }
};

モデル

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = ['name', 'price', 'stock', 'version'];

    /**
     * 楽観ロックで在庫を更新
     */
    public function decrementStockOptimistically(int $quantity): bool
    {
        $currentVersion = $this->version;

        $affected = static::where('id', $this->id)
            ->where('version', $currentVersion)
            ->where('stock', '>=', $quantity)
            ->update([
                'stock' => DB::raw("stock - {$quantity}"),
                'version' => DB::raw('version + 1'),
                'updated_at' => now(),
            ]);

        if ($affected === 0) {
            return false; // 競合発生またはは在庫不足
        }

        // モデルを再読み込み
        $this->refresh();
        return true;
    }
}

コントローラー

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function purchaseOptimistic(Request $request, $productId)
    {
        $maxRetries = 3;
        $attempt = 0;

        while ($attempt < $maxRetries) {
            try {
                return DB::transaction(function () use ($productId, $request) {
                    $product = Product::find($productId);

                    if ($product->stock < $request->quantity) {
                        throw new \Exception('在庫不足です');
                    }

                    // 楽観ロックで更新
                    $success = $product->decrementStockOptimistically(
                        $request->quantity
                    );

                    if (!$success) {
                        throw new \Exception('競合が発生しました。再試行してください。');
                    }

                    // 注文を作成
                    auth()->user()->orders()->create([
                        'product_id' => $product->id,
                        'quantity' => $request->quantity,
                        'price' => $product->price * $request->quantity,
                    ]);

                    return response()->json([
                        'message' => '購入が完了しました',
                        'remaining_stock' => $product->stock
                    ]);
                });
            } catch (\Exception $e) {
                $attempt++;
                if ($attempt >= $maxRetries) {
                    return response()->json([
                        'error' => $e->getMessage()
                    ], 409);
                }
                // 短時間待機して再試行
                usleep(100000); // 0.1秒
            }
        }
    }
}

Rails での実装

Railsには楽観ロックの組み込みサポートがあります。lock_version カラムを追加するだけで自動的に動作します。

マイグレーション

class AddLockVersionToProducts < ActiveRecord::Migration[7.0]
  def change
    add_column :products, :lock_version, :integer, default: 0, null: false
  end
end

モデル

class Product < ApplicationRecord
  # lock_versionカラムがあれば自動的に楽観ロックが有効になる
  
  has_many :orders
  validates :stock, numericality: { greater_than_or_equal_to: 0 }
end

コントローラー

class OrdersController < ApplicationController
  MAX_RETRIES = 3

  def purchase_optimistic
    retries = 0

    begin
      Product.transaction do
        product = Product.find(params[:product_id])

        if product.stock < params[:quantity].to_i
          raise ActiveRecord::RecordInvalid, '在庫不足です'
        end

        # 在庫を減らす(lock_versionが自動的にチェックされる)
        product.stock -= params[:quantity].to_i
        product.save!

        # 注文を作成
        current_user.orders.create!(
          product_id: product.id,
          quantity: params[:quantity],
          price: product.price * params[:quantity].to_i
        )

        render json: {
          message: '購入が完了しました',
          remaining_stock: product.stock
        }
      end
    rescue ActiveRecord::StaleObjectError => e
      # 楽観ロックの競合が発生
      retries += 1
      if retries < MAX_RETRIES
        sleep 0.1
        retry
      else
        render json: { 
          error: '競合が発生しました。再試行してください。' 
        }, status: :conflict
      end
    rescue ActiveRecord::RecordInvalid => e
      render json: { error: e.message }, status: :unprocessable_entity
    end
  end
end

Railsの楽観ロックの仕組み

# 自動的に以下のようなSQLが実行される
# UPDATE products 
# SET stock = ?, lock_version = ?, updated_at = ?
# WHERE id = ? AND lock_version = ?

# lock_versionが一致しない場合、affected rowsが0になり
# ActiveRecord::StaleObjectErrorが発生

Laravel vs Rails: 実装の比較

悲観ロックの違い

Laravelでは lockForUpdate() メソッドで排他ロックを、sharedLock() で共有ロックを取得します。トランザクションは DB::transaction() で明示的に記述する必要があります。

一方、Railsは lock メソッドまたは with_lock メソッドを使用します。特に with_lock はブロック形式で記述でき、トランザクションも自動的に開始されるため、コードがよりシンプルで読みやすくなります。共有ロックが必要な場合は lock("FOR SHARE") のように引数で指定します。

楽観ロックの違い

ここが両フレームワークの大きな違いです。Railsには楽観ロックの組み込みサポートがあり、lock_version カラムを追加するだけで自動的に動作します。バージョンの管理や競合検出はフレームワークが担当し、競合時には ActiveRecord::StaleObjectError が発生します。実装難易度は非常に低く、数行のコードで実現できます。

対してLaravelには楽観ロックの組み込み機能がないため、バージョンカラムの管理やUPDATE文の条件指定、競合検出のロジックをすべて手動で実装する必要があります。実装難易度は高くなりますが、その分カスタマイズの自由度が高く、独自の要件に合わせた柔軟な実装が可能です。

パフォーマンスと使い分け

悲観ロックが適している場合

  • 競合が頻繁に発生する
  • データの整合性が最優先
  • 処理時間が短い
  • 確実に順序を保証したい

: 銀行の残高更新、座席予約システム

楽観ロックが適している場合

  • 競合が稀
  • 読み取りが多く、更新が少ない
  • スケーラビリティが重要
  • リトライが許容できる

: ブログ記事の編集、ユーザープロフィール更新

レコードの関連をロックする方法

実際のアプリケーションでは、単一のレコードだけでなく、関連するレコードも含めてロックする必要があることがよくあります。

Laravel での関連レコードのロック

Laravelでは、with() とクロージャを組み合わせて関連をロードする際にロックを適用します。

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    /**
     * ユーザーとその注文をロックして処理
     */
    public function cancelAllOrders($userId)
    {
        return DB::transaction(function () use ($userId) {
            // ユーザーと関連する注文を一度にロック
            $user = User::with(['orders' => function ($query) {
                $query->lockForUpdate()
                      ->where('status', 'pending');
            }])
            ->lockForUpdate()
            ->find($userId);

            foreach ($user->orders as $order) {
                // 商品も含めてロード & ロック
                $order->load(['product' => function ($query) {
                    $query->lockForUpdate();
                }]);

                $product = $order->product;
                $product->stock += $order->quantity;
                $product->save();

                $order->status = 'cancelled';
                $order->save();
            }

            return response()->json([
                'message' => "{$user->orders->count()}件の注文をキャンセルしました"
            ]);
        });
    }

    /**
     * 複数テーブルを一度にロック
     */
    public function transferStock($fromProductId, $toProductId, $quantity)
    {
        return DB::transaction(function () use ($fromProductId, $toProductId, $quantity) {
            // 常に同じ順序でロックを取得(デッドロック回避)
            $productIds = [$fromProductId, $toProductId];
            sort($productIds);

            $products = Product::whereIn('id', $productIds)
                ->lockForUpdate()
                ->orderBy('id')
                ->get()
                ->keyBy('id');

            $fromProduct = $products[$fromProductId];
            $toProduct = $products[$toProductId];

            if ($fromProduct->stock < $quantity) {
                throw new \Exception('在庫不足です');
            }

            $fromProduct->stock -= $quantity;
            $toProduct->stock += $quantity;

            $fromProduct->save();
            $toProduct->save();

            return response()->json([
                'message' => '在庫を移動しました'
            ]);
        });
    }

    /**
     * 関連の深いネストしたレコードのロック
     */
    public function processOrderWithDetails($orderId)
    {
        return DB::transaction(function () use ($orderId) {
            // 注文、明細、商品を一度にロード & ロック
            $order = Order::with([
                'orderItems' => function ($query) {
                    $query->lockForUpdate();
                },
                'orderItems.product' => function ($query) {
                    $query->lockForUpdate();
                }
            ])
            ->lockForUpdate()
            ->find($orderId);

            // 各商品の在庫確認と更新
            foreach ($order->orderItems as $item) {
                $product = $item->product;
                
                if ($product->stock < $item->quantity) {
                    throw new \Exception("商品「{$product->name}」の在庫不足");
                }
                
                $product->stock -= $item->quantity;
                $product->save();
            }

            $order->status = 'processing';
            $order->save();

            return response()->json(['message' => '注文処理を開始しました']);
        });
    }

    /**
     * 条件付きで関連をロック
     */
    public function processActiveOrders($userId)
    {
        return DB::transaction(function () use ($userId) {
            $user = User::with([
                'orders' => function ($query) {
                    $query->where('status', 'pending')
                          ->where('created_at', '>=', now()->subDays(30))
                          ->lockForUpdate();
                },
                'orders.orderItems.product' => function ($query) {
                    $query->where('is_active', true)
                          ->lockForUpdate();
                }
            ])
            ->lockForUpdate()
            ->find($userId);

            foreach ($user->orders as $order) {
                // 処理...
            }

            return response()->json(['message' => '処理完了']);
        });
    }
}

Rails での関連レコードのロック

Railsでは、関連を読み込む際に lock を組み合わせます。

class OrdersController < ApplicationController
  # ユーザーとその注文をロックして処理
  def cancel_all_orders
    user_id = params[:user_id]
    
    User.transaction do
      # ユーザーをロック
      user = User.lock.find(user_id)

      # ユーザーの全注文もロック
      orders = user.orders.lock.where(status: 'pending')

      orders.each do |order|
        # 在庫を戻す処理
        product = order.product.lock!
        product.stock += order.quantity
        product.save!

        # 注文をキャンセル
        order.status = 'cancelled'
        order.save!
      end

      render json: {
        message: "#{orders.count}件の注文をキャンセルしました"
      }
    end
  end

  # 複数テーブルを一度にロック
  def transfer_stock
    from_product_id = params[:from_product_id]
    to_product_id = params[:to_product_id]
    quantity = params[:quantity].to_i

    Product.transaction do
      # 常に同じ順序でロックを取得(デッドロック回避)
      product_ids = [from_product_id, to_product_id].sort
      
      products = Product.where(id: product_ids)
                       .lock
                       .order(:id)
                       .index_by(&:id)

      from_product = products[from_product_id.to_i]
      to_product = products[to_product_id.to_i]

      if from_product.stock < quantity
        raise ActiveRecord::RecordInvalid, '在庫不足です'
      end

      from_product.stock -= quantity
      to_product.stock += quantity

      from_product.save!
      to_product.save!

      render json: { message: '在庫を移動しました' }
    end
  end

  # includesと組み合わせた一括ロード & ロック
  def process_order_with_details
    order_id = params[:order_id]

    Order.transaction do
      # 注文と明細を一度に読み込んでロック
      order = Order.includes(:order_items)
                   .lock
                   .find(order_id)

      # 必要な商品をまとめてロック
      product_ids = order.order_items.pluck(:product_id)
      products = Product.where(id: product_ids)
                       .lock
                       .index_by(&:id)

      # 各商品の在庫確認と更新
      order.order_items.each do |item|
        product = products[item.product_id]
        
        if product.stock < item.quantity
          raise ActiveRecord::RecordInvalid, "商品「#{product.name}」の在庫不足"
        end
        
        product.stock -= item.quantity
        product.save!
      end

      order.status = 'processing'
      order.save!

      render json: { message: '注文処理を開始しました' }
    end
  end

  # with_lockを使ったより簡潔な書き方
  def process_order_simple
    order = Order.find(params[:order_id])

    order.with_lock do
      order.order_items.each do |item|
        item.product.with_lock do
          if item.product.stock < item.quantity
            raise ActiveRecord::RecordInvalid, '在庫不足'
          end
          
          item.product.decrement!(:stock, item.quantity)
        end
      end

      order.update!(status: 'processing')
    end

    render json: { message: '注文処理を開始しました' }
  end
end

関連ロックのベストプラクティス

1. ロックの順序を統一する

デッドロックを避けるため、複数のレコードをロックする際は常に同じ順序(例: ID昇順)でロックを取得します。

// Laravel
$productIds = [$id1, $id2, $id3];
sort($productIds);
$products = Product::whereIn('id', $productIds)
    ->lockForUpdate()
    ->orderBy('id')
    ->get();
# Rails
product_ids = [id1, id2, id3].sort
products = Product.where(id: product_ids).lock.order(:id)

2. N+1問題に注意

関連を個別にロックするとN+1問題が発生するため、必要なレコードをまとめて取得してからロックします。

// ❌ 悪い例: N+1が発生
foreach ($order->items as $item) {
    $product = Product::lockForUpdate()->find($item->product_id);
}

// ✅ 良い例: まとめて取得
$productIds = $order->items->pluck('product_id');
$products = Product::whereIn('id', $productIds)
    ->lockForUpdate()
    ->get()
    ->keyBy('id');

3. 必要最小限のロック範囲

パフォーマンスのため、ロックが必要なレコードだけをロックします。

# ❌ 悪い例: 全注文をロック
user.orders.lock.each { |order| ... }

# ✅ 良い例: 処理対象のみロック
user.orders.where(status: 'pending').lock.each { |order| ... }

デッドロックの回避

悲観ロックを使用する際はデッドロックに注意が必要です。

Laravel でのデッドロック検出とリトライ

<?php

use Illuminate\Database\QueryException;

public function handleDeadlock(callable $callback, int $maxRetries = 3)
{
    $attempt = 0;
    
    while ($attempt < $maxRetries) {
        try {
            return DB::transaction($callback);
        } catch (QueryException $e) {
            // デッドロック検出(MySQL)
            if ($e->getCode() === '40001' || 
                str_contains($e->getMessage(), 'Deadlock')) {
                $attempt++;
                if ($attempt >= $maxRetries) {
                    throw $e;
                }
                usleep(rand(50000, 150000)); // ランダム待機
                continue;
            }
            throw $e;
        }
    }
}

// 使用例
public function purchase(Request $request, $productId)
{
    return $this->handleDeadlock(function () use ($productId, $request) {
        $product = Product::lockForUpdate()->find($productId);
        // ... 処理
    });
}

Rails でのデッドロック検出とリトライ

class OrdersController < ApplicationController
  MAX_RETRIES = 3

  def purchase
    retries = 0
    
    begin
      Product.transaction do
        product = Product.lock.find(params[:product_id])
        # ... 処理
      end
    rescue ActiveRecord::Deadlocked => e
      retries += 1
      if retries < MAX_RETRIES
        sleep(rand(0.05..0.15))
        retry
      else
        raise
      end
    end
  end
end

テストコードの例

Laravel (PHPUnit)

<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ConcurrentPurchaseTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function pessimistic_lock_prevents_overselling()
    {
        $product = Product::factory()->create(['stock' => 10]);
        $users = User::factory()->count(15)->create();

        $promises = [];
        foreach ($users as $user) {
            $promises[] = async(function () use ($user, $product) {
                $this->actingAs($user)
                    ->postJson("/api/products/{$product->id}/purchase", [
                        'quantity' => 1
                    ]);
            });
        }

        // すべてのリクエストを待機
        await($promises);

        $product->refresh();
        $this->assertEquals(0, $product->stock); // 10個売れて在庫0
        $this->assertEquals(10, $product->orders()->count()); // 10個のみ注文成功
    }

    /** @test */
    public function optimistic_lock_with_retry_prevents_overselling()
    {
        $product = Product::factory()->create([
            'stock' => 10,
            'version' => 0
        ]);
        
        $users = User::factory()->count(15)->create();

        $successfulOrders = 0;
        foreach ($users as $user) {
            $response = $this->actingAs($user)
                ->postJson("/api/products/{$product->id}/purchase-optimistic", [
                    'quantity' => 1
                ]);

            if ($response->status() === 200) {
                $successfulOrders++;
            }
        }

        $product->refresh();
        $this->assertEquals(0, $product->stock);
        $this->assertEquals(10, $successfulOrders);
    }
}

Rails (RSpec)

require 'rails_helper'

RSpec.describe 'Concurrent Purchase', type: :request do
  describe 'pessimistic locking' do
    it 'prevents overselling with concurrent requests' do
      product = create(:product, stock: 10)
      users = create_list(:user, 15)

      threads = users.map do |user|
        Thread.new do
          post "/products/#{product.id}/purchase",
            params: { quantity: 1 },
            headers: { 'Authorization' => "Bearer #{user.token}" }
        end
      end

      threads.each(&:join)

      product.reload
      expect(product.stock).to eq(0)
      expect(Order.where(product: product).count).to eq(10)
    end
  end

  describe 'optimistic locking' do
    it 'prevents overselling with version checking' do
      product = create(:product, stock: 10, lock_version: 0)
      users = create_list(:user, 15)

      successful_orders = Concurrent::AtomicFixnum.new(0)

      threads = users.map do |user|
        Thread.new do
          begin
            post "/products/#{product.id}/purchase_optimistic",
              params: { quantity: 1 },
              headers: { 'Authorization' => "Bearer #{user.token}" }
            
            successful_orders.increment if response.status == 200
          rescue StandardError
            # 競合エラーは無視
          end
        end
      end

      threads.each(&:join)

      product.reload
      expect(product.stock).to eq(0)
      expect(successful_orders.value).to eq(10)
    end
  end
end

まとめ

選択のガイドライン

悲観ロックを選ぶとき:

  • 競合率が高い(>10%)
  • 金銭や在庫など重要なデータ
  • リトライが困難
  • 処理時間が短い

楽観ロックを選ぶとき:

  • 競合率が低い(<5%)
  • 読み取り >> 書き込み
  • リトライが可能
  • スケーラビリティが重要

フレームワーク選択の観点

Rails を選ぶなら:

  • 楽観ロックの実装が容易
  • コードがシンプルで読みやすい
  • 規約に沿った開発ができる

Laravel を選ぶなら:

  • 柔軟なカスタマイズが必要
  • 既存のPHP資産がある
  • 細かい制御が必要

どちらのフレームワークも同時実行制御の実装は可能ですが、Railsは楽観ロックの組み込みサポートが充実している点で、実装工数の面で有利です。一方、Laravelは柔軟性が高く、独自の要件に合わせたカスタマイズがしやすいという特徴があります。

プロジェクトの要件、チームの経験、パフォーマンス要求などを総合的に判断して、適切な方式とフレームワークを選択してください。


参考資料:

コメント

タイトルとURLをコピーしました