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?
- 🔒 Pessimistic Locking - Najpierw zablokuj, potem pytaj
- 🎯 Optimistic Locking - Ufaj, ale weryfikuj
- ⚛️ Operacje atomowe z Redis
- 🧪 Testowanie Race Conditions z Pest
- 🧱 Zapobieganie Deadlockom
- ✅ Podsumowanie
⚡ 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 (
ShouldBeUniqueuż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_timeouti dodaj logikę ponawiania z losowym opóźnieniem
Obserwuj mnie na LinkedIn po więcej tipów z Laravel!