※本記事は「RailsからLaravelを眺める」シリーズの第12回です。Rails出身の私がLaravelを触りながら、Railsと比較して違いを整理していく連載になります。
はじめに
Webアプリケーション開発において、複数のユーザーが同時に同じデータを更新しようとする「同時実行制御」は避けて通れない問題です。例えば、ECサイトでの在庫管理や、チケット予約システムなどで、適切な制御がないとデータの整合性が崩れてしまいます。
この記事では、同時実行制御の代表的な手法である「悲観ロック」と「楽観ロック」について、LaravelとRailsでの実装方法を比較しながら解説します。
同時実行制御が必要なシナリオ
まず、具体的な問題を見てみましょう。
ECサイトで商品の在庫が10個ある状態で、ユーザーAとユーザーBが同時に1個ずつ購入しようとするケースを考えます。
- ユーザーAが在庫数を読み取ります(10個)
- ユーザーBも在庫数を読み取ります(10個)
- ユーザーAが1個購入し、在庫を9個に計算します
- ユーザーBも1個購入し、在庫を9個に計算します
- ユーザーAが在庫を9個に更新します
- ユーザー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は柔軟性が高く、独自の要件に合わせたカスタマイズがしやすいという特徴があります。
プロジェクトの要件、チームの経験、パフォーマンス要求などを総合的に判断して、適切な方式とフレームワークを選択してください。
参考資料:



コメント