MeWrite Docs

Laravel Session Race Condition

Laravelで同時リクエスト時にセッションデータが競合・消失する問題

概要

Laravelで複数のリクエストが同時に実行された際、セッションデータが競合したり消失したりする問題です。Ajaxリクエストの並列実行や、同一ユーザーによる複数タブでの操作時に発生しやすい問題です。

エラーメッセージ

Session data unexpectedly changed or lost
Flash message not displayed
Cart items disappeared after concurrent requests
User logged out unexpectedly during parallel requests

原因

  1. Last-Write-Wins問題: 同時リクエストで後から書き込んだ方が前の変更を上書き
  2. セッションロックの欠如: ファイルセッションドライバのロック機構の問題
  3. Ajaxリクエストの並列実行: 複数のAjaxが同時にセッションを更新
  4. ブラウザのプリフェッチ: 先読みリクエストとの競合
  5. ロードバランサー: スティッキーセッションが設定されていない

解決策

1. セッションドライバをdatabaseに変更

1
2
3
4
5
6
// .env
SESSION_DRIVER=database

// マイグレーション作成
php artisan session:table
php artisan migrate
1
2
3
4
5
6
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'),

// データベースドライバはトランザクションでロックをかける
'connection' => env('SESSION_CONNECTION'),
'table' => 'sessions',

2. Redisセッションドライバ(推奨)

1
2
3
4
5
6
7
// .env
SESSION_DRIVER=redis
REDIS_CLIENT=phpredis

// config/session.php
'driver' => 'redis',
'connection' => 'session',  // 専用接続を使用
1
2
3
4
5
6
7
8
9
// config/database.php
'redis' => [
    'session' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_SESSION_DB', 2),  // セッション専用DB
    ],
],

3. セッションのブロッキング(Laravel 7+)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// routes/web.php
use Illuminate\Session\Middleware\StartSession;

// 特定のルートでセッションをブロック
Route::post('/cart/add', [CartController::class, 'add'])
    ->block($lockSeconds = 10, $waitSeconds = 10);

// または withoutBlocking() でブロックを無効化
Route::get('/api/status', [StatusController::class, 'index'])
    ->withoutBlocking();

4. セッション読み取り専用ミドルウェア

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// app/Http/Middleware/ReadOnlySession.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ReadOnlySession
{
    public function handle(Request $request, Closure $next)
    {
        // セッションを読み取り専用に
        config(['session.driver' => 'array']);

        return $next($request);
    }
}

// 読み取り専用のAPIルートに適用
Route::middleware(['read-only-session'])->group(function () {
    Route::get('/api/user/status', [UserController::class, 'status']);
});

5. Ajaxリクエストの直列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// フロントエンド側でリクエストを直列化
class RequestQueue {
    constructor() {
        this.queue = [];
        this.processing = false;
    }

    async add(requestFn) {
        return new Promise((resolve, reject) => {
            this.queue.push({ requestFn, resolve, reject });
            this.processQueue();
        });
    }

    async processQueue() {
        if (this.processing || this.queue.length === 0) return;

        this.processing = true;
        const { requestFn, resolve, reject } = this.queue.shift();

        try {
            const result = await requestFn();
            resolve(result);
        } catch (error) {
            reject(error);
        } finally {
            this.processing = false;
            this.processQueue();
        }
    }
}

// 使用例
const queue = new RequestQueue();

async function addToCart(productId) {
    return queue.add(() =>
        fetch('/cart/add', {
            method: 'POST',
            body: JSON.stringify({ product_id: productId }),
            headers: { 'Content-Type': 'application/json' }
        })
    );
}

6. Atomicなセッション操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// app/Services/SessionService.php
namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;

class SessionService
{
    public function atomicUpdate(string $key, callable $callback)
    {
        $lockKey = 'session_lock:' . Session::getId() . ':' . $key;
        $lock = Cache::lock($lockKey, 10);

        try {
            $lock->block(5);  // 最大5秒待機

            $value = Session::get($key);
            $newValue = $callback($value);
            Session::put($key, $newValue);

            return $newValue;
        } finally {
            $lock->release();
        }
    }
}

// 使用例
$sessionService->atomicUpdate('cart', function ($cart) use ($productId) {
    $cart = $cart ?? [];
    $cart[] = $productId;
    return $cart;
});

7. カートをデータベースに移動

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// セッションではなくデータベースにカートを保存
// app/Models/Cart.php
class Cart extends Model
{
    protected $fillable = ['user_id', 'session_id', 'items'];

    protected $casts = [
        'items' => 'array',
    ];

    public static function current()
    {
        return self::firstOrCreate([
            'user_id' => auth()->id(),
            'session_id' => auth()->check() ? null : session()->getId(),
        ]);
    }
}

// app/Http/Controllers/CartController.php
public function add(Request $request)
{
    DB::transaction(function () use ($request) {
        $cart = Cart::current()->lockForUpdate()->first();
        $items = $cart->items ?? [];
        $items[] = $request->product_id;
        $cart->update(['items' => $items]);
    });
}

8. ファイルセッションのロック改善

1
2
3
4
5
6
// config/session.php
// ファイルドライバ使用時はロック設定を確認
'files' => storage_path('framework/sessions'),

// storage/framework/sessionsのパーミッション確認
// chmod 775 storage/framework/sessions

9. ロードバランサーのスティッキーセッション

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Nginx設定例
upstream backend {
    ip_hash;  # IPベースのスティッキーセッション
    server backend1.example.com;
    server backend2.example.com;
}

# または Cookie ベース
upstream backend {
    server backend1.example.com;
    server backend2.example.com;
    sticky cookie srv_id expires=1h;
}

10. フラッシュメッセージの代替

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// セッションフラッシュの代わりにキャッシュを使用
class FlashService
{
    public function set(string $key, $value): void
    {
        $flashKey = 'flash:' . session()->getId() . ':' . $key;
        Cache::put($flashKey, $value, 60);  // 60秒間有効
    }

    public function get(string $key)
    {
        $flashKey = 'flash:' . session()->getId() . ':' . $key;
        $value = Cache::get($flashKey);
        Cache::forget($flashKey);
        return $value;
    }
}

デバッグ方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// セッションの状態をログ出力
Route::get('/debug-session', function () {
    \Log::info('Session Debug', [
        'id' => session()->getId(),
        'all' => session()->all(),
        'driver' => config('session.driver'),
    ]);

    return response()->json(session()->all());
});

// リクエストのタイミングをログ
// app/Http/Middleware/LogRequestTiming.php
public function handle($request, Closure $next)
{
    $start = microtime(true);
    \Log::info('Request start', ['path' => $request->path(), 'session_id' => session()->getId()]);

    $response = $next($request);

    $duration = microtime(true) - $start;
    \Log::info('Request end', ['path' => $request->path(), 'duration' => $duration]);

    return $response;
}

よくある間違い

  • ファイルセッションドライバで並列リクエストを処理しようとする
  • Ajaxリクエストを無制限に並列実行する
  • セッションにサイズの大きいデータを保存する
  • ロードバランサー環境でスティッキーセッションを設定しない

関連エラー

参考リンク

Laravel の他のエラー

最終更新: 2025-12-14