Zapisałeś zamówienie, ale pozycje zamówienia nigdy nie trafiły do bazy danych. A może licznik stanów magazynowych spadł, bramka płatności zgłosiła przekroczenie czasu oczekiwania (timeout), i teraz klient ma obciążoną kartę, ale brak rekordu zamówienia. Te scenariusze brzmią jak przypadki brzegowe (edge cases), dopóki nimi nie stają się. W intensywnie działającej aplikacji stają się one codziennym problemem.
Przyczyna źródłowa jest prawie zawsze taka sama: sekwencja zapisów do bazy danych, które traktowano jako oddzielne, odizolowane zdarzenia. Gdy jedno zawiedzie, pozostałe zostają w tyle. Rozwiązaniem jest transakcja bazodanowa, a w Laravelu narzędziem do tego jest DB::transaction().
Co tak naprawdę obiecuje transakcja
Transakcja bazodanowa wiąże wiele operacji w jedną jednostkę pracy. Silnik bazy danych gwarantuje, że wszystko wewnątrz albo zostanie trwale zatwierdzone (commit), albo całkowicie wycofane (rollback). Nie ma nic pomiędzy.
Pomyśl o przelewie bankowym. System musi obciążyć jedno konto i zaksięgować środki na drugim. Jeśli zaksięgowanie zawiedzie po obciążeniu, pieniądze nie znikają po prostu w próżni. Bank cofa obciążenie. To cofnięcie to właśnie rollback. Jeśli oba kroki zakończą się sukcesem, przelew zostaje zatwierdzony (committed), co oznacza, że nowe salda zostają trwale zapisane.
To zachowanie typu „wszystko albo nic” sprawia, że Twoje dane pozostają spójne. Bez niego częściowe awarie rozrzucają niepełne rekordy po tabelach, a te rekordy gniją w bazie danych, ponieważ żaden zautomatyzowany proces nie wie, jak bezpiecznie je wyczyścić.
Jak Laravel zajmuje się tym mechanizmem
W czystym SQL musiałbyś sam pisać BEGIN, COMMIT i ROLLBACK, pamiętając o przechwytywaniu każdego możliwego błędu, aby nigdy nie pozostawić transakcji w zawieszeniu. Laravel zamyka ten powtarzalny kod (boilerplate) w jednej metodzie.
Przekazujesz closure do DB::transaction(). Laravel rozpoczyna transakcję, wykonuje Twój kod, a jeśli closure zakończy się bez rzucenia wyjątku (exception), automatycznie zatwierdza zmiany (commit). Jeśli cokolwiek zawiedzie, Laravel przechwytuje wyjątek, wycofuje wszystko (rollback) i ponownie rzuca błąd, dzięki czemu Twoje logowanie i obsługa błędów nadal działają zgodnie z oczekiwaniami.
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$order = Order::create([/* ... */]);
foreach ($cart->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
]);
Product::find($item->product_id)
->decrement('stock', $item->quantity);
}
Payment::create([
'order_id' => $order->id,
'amount' => $cart->total,
'status' => 'completed',
]);
});
Jeśli wstawianie płatności zawiedzie z powodu braku klucza obcego lub przerwania połączenia z bazą danych, zamówienie, pozycje zamówienia i zmiany w stanach magazynowych zostaną cofnięte. Nie zostaniesz z zapasami, które zniknęły bez powodu, ani z zamówieniem wymagającym wysyłki, za którą nigdy nie zapłacono.
Gdzie to Cię ratuje
Niektóre procesy (workflows) absolutnie zależą od bezpieczeństwa transakcyjnego. Przykład z procesem zakupowym (checkout) jest najbardziej oczywisty, ale ten wzorzec pojawia się wszędzie.
Rejestracja użytkownika. Tworzenie wiersza użytkownika, następnie wiersza profilu, a potem ustawień domyślnych. Jeśli wstawianie profilu zawiedzie z powodu specyficznego przypadku walidacji, użytkownik bez profilu stanie się „konto-duchem”. Każda strona, która zakłada, że każdy użytkownik ma profil, ulegnie awarii lub wyświetli uszkodzony interfejs użytkownika (UI).
Masowe importy. Przesłanie pliku CSV, które wstawia pięćdziesiąt rekordów, nie powinno zostawić dwudziestu pięciu, ponieważ dwudziesty szósty wiersz miał błędną datę. Zamknięcie partii danych w transakcji pozwala na to, aby cały import zakończył się niepowodzeniem jako jedna czysta jednostka. Administrator naprawia plik i próbuje ponownie, zamiast spędzać godziny na szukaniu wierszy, które częściowo „przeciekły” do bazy.
Zapasy i księgowość. Za każdym razem, gdy jedna tabela śledzi zasób fizyczny, a inna pieniądze lub kredyty, obie muszą zmieniać się jednocześnie. Rozdzielenie ich zaprasza do audytów, które nie zgadzają się w bilansie, oraz raportów, które kłamią.
Kiedy musisz sterować ręcznie
Podejście oparte na closure pokrywa większość przypadków, ale czasami potrzebujesz większej kontroli. Złożona logika warunkowa wewnątrz klasy serwisowej lub potrzeba decyzji w czasie wykonywania programu (runtime), czy zatwierdzić zmiany, może sprawić, że pojedyncze closure będzie uciążliwe. W takich momentach możesz zarządzać transakcją samodzielnie:
DB::beginTransaction();
try {
// Run your operations
$order = Order::create([/* ... */]);
// ... more work ...
if ($someBusinessRulePasses) {
DB::commit();
} else {
DB::rollBack();
}
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
Zwróć uwagę na kolejność wewnątrz bloku catch: najpierw rollback, potem throw. Jeśli rzucisz wyjątek przed wycofaniem transakcji, transakcja pozostanie otwarta w połączeniu. Może to blokować wiersze, powodować zakleszczenia (deadlock) innych zapytań lub wyczerpać pulę połączeń. Ręczne transakcje są potężne, ale nakładają na Ciebie ciężar sprzątania.
Trzymaj efekty uboczne poza transakcją
To zasada, która potrafi „ugryźć” zespoły na produkcji. Transakcja może cofnąć tylko operacje na bazie danych. Nie może cofnąć wysłanego e-maila, usunięcia pliku z pamięci masowej w chmurze ani zwrotu płatności przez API płatności.
Jeśli umieścisz wywołanie Mail::send() wewnątrz closure transakcji, a baza danych wykona rollback dwie linie później, ten e-mail i tak trafi do skrzynki odbiorczej klienta. Odbiorca otrzyma fakturę za zamówienie, które nie istnieje w Twoim systemie. To samo dotyczy powiadomień Slack, przesyłania plików na S3 czy wysyłania webhooków.
Poprawna sekwencja to:
- Zakończ transakcję i przechwyć wszystkie potrzebne identyfikatory lub wyniki.
- Dopiero wtedy wywołaj zewnętrzne skutki uboczne.
Na przykład, kolejkuje e-mail z potwierdzeniem po zatwierdzeniu (commit), a nie w jego trakcie:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
Istnieje drugi powód, dla którego warto trzymać zewnętrzne wywołania poza transakcją: czas. Transakcja trzyma blokady i zajmuje połączenie z bazą danych. Czekanie trzy sekundy na odpowiedź Stripe API wewnątrz transakcji to trzy sekundy niepotrzebnego czasu blokady. Dbaj o to, aby transakcja była krótka i szybka.
Dlaczego ten błąd jest trudny do wykrycia
Na maszynie deweloperskiej z jednym użytkownikiem i lokalną bazą danych, powiązane operacje insert prawie zawsze kończą się sukcesem. Sieć jest stabilna, dysk nigdy się nie zapełnia, a obciążenie nie rywalizuje o zasoby. Kod wydaje się poprawny, ponieważ zazwyczaj działa.
Produkcja wygląda inaczej. Dwóch klientów składa zamówienia w tej samej milisekundzie. Worker kolejki restartuje się w trakcie pracy podczas wdrożenia. Dostawca płatności ma timeout trwający trzydzieści sekund. Bez transakcji zdarzenia te tworzą osierocone rekordy i niezgodne sumy, których śledzenie jest uciążliwe. Najgorsze jest to, że standardowe testy funkcjonalne rzadko je wyłapują, ponieważ tryb awarii zależy od czasu i infrastruktury, a nie od prostych błędów w logice.
Rozwiązanie nie jest egzotyczne. Jest mechaniczne. Widzisz dwa lub więcej powiązanych zapisów i obejmujesz je transakcją. Z czasem powinno to stać się tak automatyczne, jak walidacja żądania.
Wypracuj to jako odruch
DB::transaction() nie dodaje złożoności. Usuwa ukrytą złożoność wynikającą z prób sprzątania niepełnych danych po fakcie. Jeśli grupa operacji na bazie danych powinna być wykonywana razem, traktuj je tak od samego początku. Twoja aplikacja zachowa spójność pod obciążeniem, logi błędów pozostaną czyste, a baza danych nie stanie się cmentarzyskiem niedokończonych rekordów.
