Laravelで在庫管理やポイント加算などの処理を扱う際に問題となるのが、同時リクエストによるデータの競合です。適切なロック制御やトランザクションを実装していないと、意図しない値の上書きや二重登録といった不具合が発生します。
この記事では、Laravelで安全にデータを更新するためのトランザクション制御とロックの使い方について、実践的なコード例とともに解説します。
トランザクションの基本:DB::transaction()
Laravelでは、DB::transaction()
を使うことで、一連の処理を「まとめて成功」「まとめて失敗」させることができます。
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$user = User::find(1);
$user->points += 100;
$user->save();
Log::info('ポイント加算完了');
});
トランザクション内で例外が発生すると、全処理がロールバックされます。複数の更新がある場合は必ずトランザクションで囲むのが原則です。
ロックの必要性:同時リクエストを防ぐ
しかし、トランザクションだけでは同時実行による読み込み→更新競合は防げません。特に以下のような処理では「直前に読み込んだ値」が古くなる可能性があります。
$stock = Product::find($productId);
$stock->quantity -= 1;
$stock->save();
このような競合を防ぐには、行ロック(行単位での排他制御)が必要です。
悲観ロック:for update の活用
Laravelでは、EloquentまたはクエリビルダでlockForUpdate()
を使用することで、SELECT時に排他ロックをかけることができます。
DB::transaction(function () use ($productId) {
$product = Product::where('id', $productId)
->lockForUpdate()
->first();
if ($product->quantity <= 0) {
throw new \Exception('在庫が不足しています');
}
$product->quantity -= 1;
$product->save();
});
lockForUpdate()
はMySQLやPostgreSQLでSELECT ... FOR UPDATE
を発行し、同時トランザクションがそのレコードにアクセスするのを一時的にブロックします。
クエリビルダでも使用可能
ロックはEloquentだけでなく、クエリビルダでも利用可能です。
DB::table('products')
->where('id', $productId)
->lockForUpdate()
->first();
悲観ロックと楽観ロックの違い
悲観ロック(lockForUpdate)では「同時アクセスを前提としてロックをかける」のに対し、楽観ロックは「基本的に同時更新は起きないと仮定し、更新時に差分チェックを行う」方法です。
Laravelには楽観ロックの仕組みは組み込まれていませんが、updated_at
の一致確認などで簡易的に実装可能です。
例:updated_atを用いた楽観ロック
$product = Product::find($id);
if ($product->updated_at != $request->input('updated_at')) {
throw new \Exception('他のユーザーが更新しました');
}
$product->quantity -= 1;
$product->save();
更新時の整合性確認で軽量なロックとして活用できます。
注意点とベストプラクティス
- 複数レコードを同時にロックする場合はロック順に注意(デッドロックの原因)
- 外部APIを呼ぶ処理はトランザクション外に分離する(長時間ロックを避ける)
- lockForUpdate()は必ずtransaction()とセットで使用
まとめ
Laravelで高信頼なデータ更新を行うには、トランザクションで一貫性を保ち、ロックで同時実行の競合を防ぐことが重要です。
DB::transaction()
で複数操作を安全にまとめるlockForUpdate()
で行ロックによる競合回避- 更新衝突が少ない場合は楽観ロックも検討
在庫管理・決済・ポイント加算など、同時アクセスのある機能には必ずトランザクション+ロックを設計に取り入れるようにしましょう。