Zapobieganie Race Conditions w Laravel: Blokady Atomowe, Pessimistic Locking i Realne Przykłady

Dwóch użytkowników klika "Kup" dokładnie w tym samym momencie. Sprawdzenie stanu magazynowego przechodzi pomyślnie dla obu. Oba zamówienia zostają złożone. Właśnie sprzedałeś jeden produkt dwa razy. To jest race condition - i nie potrzeba do tego tysięcy użytkowników. Wystarczą dwa requesty trafiające w ten sam wiersz jednocześnie. Laravel daje Ci kilka narzędzi, żeby temu zapobiec, a wybór właściwego zależy od spodziewanej liczby konfliktów i tego, co możesz poświęcić w zamian za wydajność.

📋 Spis treści

⚡ Czym jest Race Condition?

Race condition pojawia się, gdy wynik operacji zależy od kolejności lub czasu niekontrolowanych zdarzeń - najczęściej równoległych requestów odczytujących i zapisujących te same dane.

Klasyczny przykład ze stanem magazynowym:

// app/Actions/PlaceOrderAction.php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Product;
use App\Models\Order;

class PlaceOrderAction
{
    public function handle(int $productId, int $quantity): Order
    {
        $product = Product::findOrFail($productId);

        // ❌ Race condition - dwa requesty mogą jednocześnie przejść ten check
        if ($product->stock < $quantity) {
            throw new \RuntimeException('Niewystarczający stan magazynowy.');
        }

        $product->decrement('stock', $quantity);

        return Order::create([
            'product_id' => $productId,
            'quantity'   => $quantity,
        ]);
    }
}

Między sprawdzeniem if a wywołaniem decrement inny request może odczytać tę samą wartość stanu, przejść check i niezależnie wykonać decrement. Efekt: stock = -1.

🔒 Pessimistic Locking - Najpierw zablokuj, potem pytaj

Pessimistic locking mówi bazie danych: "Zaraz odczytam ten wiersz i nikt inny nie może go modyfikować, dopóki nie skończę." Pod spodem używa SELECT ... FOR UPDATE.

// app/Actions/PlaceOrderAction.php

declare(strict_types=1);

namespace App\Actions;

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

class PlaceOrderAction
{
    public function handle(int $productId, int $quantity): Order
    {
        return DB::transaction(function () use ($productId, $quantity) {
            // Blokuje wiersz - inne transakcje czekają tutaj
            $product = Product::lockForUpdate()->findOrFail($productId);

            if ($product->stock < $quantity) {
                throw new \RuntimeException('Niewystarczający stan magazynowy.');
            }

            $product->decrement('stock', $quantity);

            return Order::create([
                'product_id' => $productId,
                'quantity'   => $quantity,
            ]);
        });
    }
}

lockForUpdate() zakłada wyłączną blokadę na wierszu. Każda inna transakcja próbująca odczytać ten sam wiersz przez lockForUpdate() będzie czekać, aż pierwsza transakcja zostanie zatwierdzona lub wycofana.

**sharedLock()** to wariant tylko do odczytu - wiele transakcji może jednocześnie trzymać shared lock, ale żadna nie może modyfikować wiersza, dopóki wszystkie blokady nie zostaną zwolnione:

// Użyj gdy potrzebujesz odczytać i zagwarantować, że wiersz się nie zmieni,
// ale sam nie zamierzasz go modyfikować
$product = Product::sharedLock()->findOrFail($productId);

Poziomy izolacji transakcji mają tu znaczenie. Domyślny w MySQL to REPEATABLE READ. Jeśli potrzebujesz READ COMMITTED dla konkretnych operacji:

DB::statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');

DB::transaction(function () {
    // ...
});

Kiedy używać pessimistic locking:

  • Duża liczba konfliktów na tych samych wierszach (wyprzedaże flash, rezerwacje miejsc, magazyn)
  • Krótkie transakcje (blokada trzymana przez chwilę)
  • Możesz sobie pozwolić na chwilowe blokowanie równoległych requestów

Kiedy nie używać:

  • Długie transakcje - locki blokują innych użytkowników przez cały czas
  • Scenariusze z małą liczbą konfliktów - narzut nie jest wart korzyści
  • Systemy rozproszone na wielu bazach - lockForUpdate() działa per baza

🎯 Optimistic Locking - Ufaj, ale weryfikuj

Optimistic locking niczego nie blokuje. Zamiast tego dodaje kolumnę version do wiersza. Przy aktualizacji sprawdzasz, czy wersja nie zmieniła się od momentu odczytu. Jeśli tak - ktoś inny ją zmodyfikował - próbujesz ponownie lub zwracasz błąd.

Laravel nie ma wbudowanego optimistic locking, ale implementacja jest prosta:

Migracja:

// database/migrations/2026_03_01_add_version_to_products_table.php

declare(strict_types=1);

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

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

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

Action z optimistic locking:

// app/Actions/PlaceOrderAction.php

declare(strict_types=1);

namespace App\Actions;

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

class PlaceOrderAction
{
    private const MAX_RETRIES = 3;

    public function handle(int $productId, int $quantity): Order
    {
        $attempts = 0;

        while ($attempts < self::MAX_RETRIES) {
            $product = Product::findOrFail($productId);

            if ($product->stock < $quantity) {
                throw new \RuntimeException('Niewystarczający stan magazynowy.');
            }

            $updated = DB::table('products')
                ->where('id', $productId)
                ->where('version', $product->version) // Strażnik
                ->update([
                    'stock'   => $product->stock - $quantity,
                    'version' => $product->version + 1,
                ]);

            if ($updated === 1) {
                // Nasza aktualizacja wygrała - tworzymy zamówienie
                return Order::create([
                    'product_id' => $productId,
                    'quantity'   => $quantity,
                ]);
            }

            // Ktoś inny zaktualizował pierwszy - próbujemy ponownie
            $attempts++;
        }

        throw new \RuntimeException('Nie udało się złożyć zamówienia po ' . self::MAX_RETRIES . ' próbach.');
    }
}

Warunek where('version', $product->version) to klucz. Jeśli inny request zinkrementował wersję między naszym odczytem a aktualizacją, $updated wyniesie 0 i ponawiamy z świeżymi danymi.

Kiedy używać optimistic locking:

  • Mała lub średnia liczba konfliktów - większość aktualizacji udaje się za pierwszym razem
  • Przepływy z dużą liczbą odczytów, gdzie zapisy są sporadyczne
  • Systemy rozproszone, gdzie locki na poziomie bazy są niepraktyczne

Kiedy nie używać:

  • Duża liczba konfliktów - zbyt wiele ponowień obniża wydajność
  • Operacje, których nie można bezpiecznie powtórzyć (efekty uboczne jak wysłane emaile)

⚛️ Operacje atomowe z Redis

Dla operacji nietrafiających bezpośrednio do bazy danych - rate limiting, rozproszone liczniki, jednorazowe przetwarzanie - Redis oferuje operacje atomowe przez Cache::lock().

// app/Actions/ProcessPaymentAction.php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Order;
use Illuminate\Support\Facades\Cache;

class ProcessPaymentAction
{
    public function handle(int $orderId): void
    {
        $lock = Cache::lock("order.payment.{$orderId}", seconds: 10);

        if (! $lock->get()) {
            throw new \RuntimeException('Płatność jest już przetwarzana.');
        }

        try {
            $order = Order::findOrFail($orderId);

            if ($order->isPaid()) {
                return; // Idempotentne - już zrobione
            }

            // Przetwarzanie płatności...
            $order->markAsPaid();
        } finally {
            $lock->release();
        }
    }
}

Cache::lock() używa pod spodem Redis SET NX PX - operacji atomowej. seconds: 10 to TTL; jeśli proces się zawiesi, blokada wygasa automatycznie.

**block() - czekaj na blokadę zamiast od razu rzucać wyjątek:**

// app/Actions/ProcessPaymentAction.php

$lock = Cache::lock("order.payment.{$orderId}", seconds: 10);

$lock->block(seconds: 5); // Czekaj maksymalnie 5 sekund na blokadę

try {
    // Bezpiecznie - mamy blokadę
    $order->markAsPaid();
} finally {
    $lock->release();
}

Tokeny właściciela - dla blokad między jobami w kolejce:

// app/Jobs/ProcessPaymentJob.php

declare(strict_types=1);

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;

class ProcessPaymentJob implements ShouldQueue
{
    use Queueable;

    public string $lockOwner;

    public function __construct(public readonly int $orderId)
    {
        $lock            = Cache::lock("order.payment.{$orderId}", seconds: 60);
        $this->lockOwner = $lock->acquire(); // Zwraca token właściciela
    }

    public function handle(): void
    {
        // Przywróć blokadę z tokenem właściciela - tylko ten job może ją zwolnić
        Cache::restoreLock("order.payment.{$this->orderId}", $this->lockOwner)
            ->release();
    }
}

Tokeny właściciela zapobiegają scenariuszowi, w którym wolny job trzyma blokadę, która już wygasła i została przejęta przez inny proces.

Kiedy używać blokad Redis:

  • Zapobieganie duplikatom przetwarzania jobów (ShouldBeUnique używa tego wewnętrznie)
  • Rate limiting kosztownych operacji
  • Koordynacja między wieloma workerami kolejek
  • Każda operacja, do której blokowanie na poziomie bazy nie ma zastosowania

Wymaganie: CACHE_STORE musi być redis (nie file ani array), żeby blokady działały między wieloma procesami.

🧪 Testowanie Race Conditions z Pest

Testowanie współbieżności jest trudne w PHP, bo domyślnie jest jednowątkowy. Najlepsze podejście to symulacja sekwencji zdarzeń powodujących race condition.

Test pessimistic locking:

// tests/Feature/PlaceOrderTest.php

declare(strict_types=1);

use App\Actions\PlaceOrderAction;
use App\Models\Product;

it('zapobiega sprzedaży ponad stan przy pessimistic locking', function () {
    $product = Product::factory()->create(['stock' => 1]);

    $action = app(PlaceOrderAction::class);

    // Symulacja dwóch równoległych requestów przez wywołanie sekwencyjne
    // z tym samym stanem początkowym
    $action->handle($product->id, 1);

    expect(fn () => $action->handle($product->id, 1))
        ->toThrow(\RuntimeException::class, 'Niewystarczający stan magazynowy.');

    expect($product->fresh()->stock)->toBe(0);
});

Test ponowienia przy optimistic locking:

// tests/Feature/PlaceOrderOptimisticTest.php

declare(strict_types=1);

use App\Actions\PlaceOrderAction;
use App\Models\Product;
use Illuminate\Support\Facades\DB;

it('ponawia gdy wersja się zmieniła', function () {
    $product = Product::factory()->create(['stock' => 2, 'version' => 0]);

    // Symulacja innego procesu inkrementującego wersję między naszym odczytem a zapisem
    DB::table('products')
        ->where('id', $product->id)
        ->update(['version' => 1, 'stock' => 1]);

    // Action powinien ponowić i udać się przy kolejnej próbie
    $order = app(PlaceOrderAction::class)->handle($product->id, 1);

    expect($order)->not->toBeNull();
    expect($product->fresh()->stock)->toBe(0);
});

Test blokady Redis:

// tests/Feature/ProcessPaymentTest.php

declare(strict_types=1);

use App\Actions\ProcessPaymentAction;
use App\Models\Order;
use Illuminate\Support\Facades\Cache;

it('zapobiega duplikatom przetwarzania płatności', function () {
    $order = Order::factory()->unpaid()->create();

    // Trzymamy blokadę zewnętrznie, symulując równoległe przetwarzanie
    Cache::lock("order.payment.{$order->id}", 10)->get();

    expect(fn () => app(ProcessPaymentAction::class)->handle($order->id))
        ->toThrow(\RuntimeException::class, 'Płatność jest już przetwarzana.');
});

🧱 Zapobieganie Deadlockom

Deadlock pojawia się, gdy dwie transakcje trzymają blokadę, której potrzebuje ta druga, i obie czekają w nieskończoność. MySQL to wykrywa i zabija jedną z nich z błędem:

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock

Najczęstsza przyczyna: dwie transakcje blokujące te same wiersze w różnej kolejności.

// Transakcja A: blokuje order, potem próbuje zablokować product
// Transakcja B: blokuje product, potem próbuje zablokować order
// → Deadlock

Rozwiązanie: zawsze blokuj w tej samej kolejności.

// app/Actions/PlaceOrderAction.php

DB::transaction(function () use ($productId, $orderId) {
    // Zawsze blokuj w kolejności rosnących ID - spójna kolejność zapobiega deadlockom
    $ids = collect([$productId, $orderId])->sort()->values();

    foreach ($ids as $id) {
        Product::lockForUpdate()->find($id);
    }

    // Teraz bezpiecznie kontynuujemy
});

Dostosuj timeout oczekiwania - domyślnie to 50 sekund, co oznacza, że zablokowane requesty wiszą prawie minutę:

// Ustaw per-transakcja lub globalnie w config/database.php

DB::statement("SET innodb_lock_wait_timeout = 5");

Przy 5 sekundach zablokowana transakcja szybko się nie udaje i możesz obsłużyć to elegancko, zamiast zostawiać użytkowników w oczekiwaniu.

Przechwytywanie i ponawianie deadlocków:

// app/Actions/PlaceOrderAction.php

use Illuminate\Database\QueryException;

public function handle(int $productId, int $quantity): Order
{
    $attempts = 0;

    while ($attempts < 3) {
        try {
            return DB::transaction(function () use ($productId, $quantity) {
                $product = Product::lockForUpdate()->findOrFail($productId);

                if ($product->stock < $quantity) {
                    throw new \RuntimeException('Niewystarczający stan magazynowy.');
                }

                $product->decrement('stock', $quantity);

                return Order::create([
                    'product_id' => $productId,
                    'quantity'   => $quantity,
                ]);
            });
        } catch (QueryException $e) {
            if ($e->getCode() !== '40001') {
                throw $e; // Nie deadlock - przekaż dalej
            }

            $attempts++;
            usleep(random_int(10_000, 100_000)); // Losowe opóźnienie w mikrosekundach
        }
    }

    throw new \RuntimeException('Nie udało się zakończyć transakcji z powodu deadlocka.');
}

Losowe opóźnienie (usleep) zmniejsza szansę, że dwa procesy ponowią próbę dokładnie w tym samym momencie i znowu wpadną w deadlock.

✅ Podsumowanie

  • Race conditions nie wymagają dużego ruchu - dwa równoległe requesty wystarczą, żeby je wywołać
  • Używaj pessimistic locking (lockForUpdate() wewnątrz transakcji) dla scenariuszy z dużą liczbą konfliktów i krótkimi operacjami: magazyn, rezerwacje miejsc
  • Używaj optimistic locking (kolumna version + warunkowa aktualizacja) przy małej liczbie konfliktów, gdzie odczyty znacznie przeważają nad zapisami
  • Używaj Redis Cache::lock() do koordynacji rozproszonej, zapobiegania duplikatom jobów i wszystkiego poza bazą danych
  • Zawsze pisz test symulujący race condition przed i po naprawie - dokumentuje błąd i chroni przed regresją
  • Zapobieganie deadlockom: blokuj wiersze w spójnej kolejności, skróć innodb_lock_wait_timeout i dodaj logikę ponawiania z losowym opóźnieniem

Obserwuj mnie na LinkedIn po więcej tipów z Laravel!

Komentarze (0)
Zostaw komentarz

© 2026 Wszelkie prawa zastrzeżone.