Blog

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!

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.