Gdy jedna operacja zapisuje zamówienie, płatność i stan magazynowy, częściowy zapis może być gorszy niż całkowity błąd. W praktyce temat ef core transaction sprowadza się do świadomego wyboru między automatycznym działaniem SaveChanges a własną transakcją obejmującą kilka operacji. Pokażę, jak robić to w EF Core, jak łączyć transakcje z retry policy oraz gdzie najczęściej pojawiają się trudne do wykrycia błędy.
Najważniejsze zasady transakcji w EF Core w jednym miejscu
- Jedno SaveChanges zwykle automatycznie działa w transakcji.
- BeginTransactionAsync jest potrzebne, gdy kilka zapisów ma zostać zatwierdzonych razem.
- Execution strategy musi objąć całą transakcję, jeśli aplikacja korzysta z automatycznych ponowień.
- Savepoint pozwala cofnąć pojedynczy etap bez wycofywania całej transakcji.
- Długie transakcje zwiększają ryzyko blokad, deadlocków i problemów z wydajnością.
Kiedy SaveChanges wystarczy, a kiedy potrzebujesz transakcji
Najprostszy przypadek nie wymaga żadnego dodatkowego kodu. Jeżeli wywołujesz SaveChanges lub SaveChangesAsync, EF Core grupuje zmiany z tego wywołania w jedną transakcję, o ile używany dostawca bazy obsługuje transakcje. Gdy jeden z INSERT-ów lub UPDATE-ów się nie powiedzie, pozostałe zmiany również nie powinny zostać zapisane.
context.Orders.Add(order);
context.OrderItems.AddRange(items);
await context.SaveChangesAsync();To rozwiązanie jest dobre, gdy wszystkie operacje dotyczą jednego zestawu zmian śledzonych przez jeden DbContext. Problem zaczyna się wtedy, gdy pomiędzy zapisami wywołasz SaveChangesAsync więcej niż raz. Każde wywołanie może mieć wtedy własną transakcję, więc pierwszy zapis pozostanie w bazie nawet wtedy, gdy drugi zakończy się błędem.
order.Status = OrderStatus.Processing;
await context.SaveChangesAsync();
payment.Status = PaymentStatus.Captured;
await context.SaveChangesAsync();Sam fakt używania tego samego kontekstu nie oznacza, że wszystkie wywołania tworzą jedną transakcję. Jeżeli operacja biznesowa musi być atomowa, czyli albo wykonać się w całości, albo nie wykonać się wcale, potrzebujesz jawnie zarządzanej transakcji.
Jak utworzyć transakcję obejmującą kilka zapisów
Najczęściej korzystam z Database.BeginTransactionAsync. Transakcję otwieram możliwie późno, wykonuję w niej wyłącznie operacje potrzebne do spójnego zapisu, a po sukcesie jawnie wywołuję CommitAsync. Dzięki temu kod jasno pokazuje granice operacji biznesowej.
await using var transaction =
await context.Database.BeginTransactionAsync();
try
{
order.Status = OrderStatus.Processing;
await context.SaveChangesAsync();
payment.Status = PaymentStatus.Captured;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}W tym przykładzie oba zapisy należą do jednej transakcji. Jeżeli drugi zapis rzuci wyjątek, pierwszy nie zostanie ostatecznie zatwierdzony. Commit powinien znajdować się na końcu, po wszystkich operacjach, które muszą być objęte atomowością.
Nie warto trzymać transakcji otwartej podczas wywołań HTTP, pracy z plikiem, wysyłania wiadomości do zewnętrznego API ani oczekiwania na użytkownika. Baza w tym czasie utrzymuje blokady, a inne żądania mogą czekać. W praktyce transakcja powinna być krótka i przewidywalna.
Jeden DbContext czy kilka kontekstów
Najprościej używać jednego kontekstu i jego połączenia. Jeżeli kilka instancji DbContext ma uczestniczyć w tej samej transakcji, muszą korzystać z tego samego połączenia oraz tej samej transakcji bazodanowej. Samo otwarcie dwóch kontekstów nie zapewnia wspólnego zatwierdzania.
W przypadku jednego kontekstu zwykle nie ma powodu, aby komplikować kod przez współdzielenie połączeń. Takie rozwiązanie przydaje się dopiero wtedy, gdy część zapisu korzysta z innego modelu kontekstu albo z bezpośrednich poleceń ADO.NET.
Który sposób zarządzania transakcją wybrać
EF Core daje kilka możliwości. Nie są one równoważne, dlatego wybór powinien wynikać z granicy operacji, a nie z przyzwyczajenia.
| Mechanizm | Kiedy go użyć | Najważniejsze ograniczenie |
|---|---|---|
Automatyczna transakcja SaveChanges
|
Jeden spójny zestaw zmian | Nie obejmuje kilku niezależnych wywołań zapisu |
BeginTransactionAsync |
Kilka zapisów EF Core lub SQL w jednej operacji | Trzeba samodzielnie obsłużyć commit, rollback i retry |
DbTransaction |
Połączenie EF Core z ADO.NET | Wymaga współdzielenia połączenia i transakcji |
TransactionScope |
Koordynacja kilku zasobów zgodnie z modelem System.Transactions | Obsługa zależy od dostawcy, platformy i konfiguracji |
Jeżeli wykonuję kilka operacji na jednej bazie, wybieram zwykle BeginTransactionAsync. Jest czytelna, dobrze pasuje do kodu aplikacyjnego i nie ukrywa tego, co faktycznie dzieje się z połączeniem.
TransactionScope bywa wygodny, gdy transakcja ma objąć kod korzystający z kilku komponentów. Trzeba jednak dokładnie sprawdzić, czy używany provider i środowisko obsługują wymagany scenariusz. Przy kilku bazach lub zasobach może pojawić się transakcja rozproszona, co oznacza większą złożoność operacyjną i trudniejsze wdrażanie.
Łączenie EF Core z własnym SQL
Możesz wykonać polecenie SQL w tej samej transakcji, pod warunkiem że użyjesz tego samego połączenia i obiektu transakcji.
await using var transaction =
await context.Database.BeginTransactionAsync();
try
{
await context.Database.ExecuteSqlRawAsync(
"UPDATE Inventory SET Reserved = Reserved + 1 WHERE Id = {0}",
productId);
context.OrderItems.Add(item);
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}To podejście ma sens, gdy konkretna operacja SQL jest prostsza lub szybsza niż jej odpowiednik w LINQ. Trzeba tylko pamiętać, że EF Core nie śledzi zmian wykonanych bezpośrednim SQL-em. Po takim poleceniu dane już załadowane w kontekście mogą być nieaktualne.

Retry policy i transakcje w Azure SQL
W aplikacjach korzystających z Azure SQL lub niestabilnych połączeń często włącza się strategię ponawiania operacji. Chroni ona przed przejściowymi błędami sieci i chwilową niedostępnością bazy, ale zmienia sposób pisania transakcji.
options.UseSqlServer(
connectionString,
sqlOptions => sqlOptions.EnableRetryOnFailure());Gdy retry jest aktywne, ręczne wywołanie BeginTransactionAsync poza strategią może zakończyć się wyjątkiem. EF Core nie wie wtedy, jak bezpiecznie powtórzyć cały zestaw operacji, więc trzeba przekazać mu kompletną transakcję jako delegat do wykonania.
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction =
await context.Database.BeginTransactionAsync();
context.Orders.Add(order);
await context.SaveChangesAsync();
context.AuditLogs.Add(log);
await context.SaveChangesAsync();
await transaction.CommitAsync();
});Ważny szczegół polega na tym, że retry może uruchomić delegat ponownie. Operacje w środku powinny więc być idempotentne, czyli ich ponowne wykonanie nie może utworzyć niepożądanego duplikatu ani podwójnie naliczyć płatności.
Najtrudniejszy przypadek występuje wtedy, gdy połączenie zerwie się dokładnie podczas commitowania. Aplikacja nie wie wtedy, czy baza zatwierdziła transakcję. Pomaga własny klucz operacji, unikalny identyfikator żądania albo późniejsza weryfikacja stanu w bazie. Sam automatyczny retry nie rozwiązuje niejednoznaczności po stronie biznesowej.
Savepoint, poziomy izolacji i typowe pułapki
Jeżeli wywołasz SaveChanges wewnątrz już otwartej transakcji, EF Core może utworzyć savepoint. To punkt, do którego można się cofnąć po błędzie, bez wycofywania całej transakcji. Jest to szczególnie przydatne przy obsłudze konfliktów optymistycznej współbieżności.
Savepoint nie jest jednak pełnym zamiennikiem rollbacku. Nadal odpowiadasz za decyzję, czy poprawić dane i spróbować ponownie, czy wycofać całą transakcję. W przypadku SQL Server trzeba też uważać na włączone MARS, ponieważ w takim scenariuszu savepointy nie są tworzone.
Izolacja nie naprawia złego modelu operacji
Poziom izolacjiokreśla, jakie dane jedna transakcja może odczytać podczas pracy innych transakcji. Wyższa izolacja może ograniczyć problemy z odczytem, ale zwykle zwiększa liczbę blokad. Nie ustawiałbym najwyższego poziomu globalnie tylko dlatego, że brzmi bezpieczniej.
W typowym systemie dobrze zacząć od domyślnego poziomu dostawcy, a dopiero potem reagować na konkretny problem. Jeżeli pojawiają się deadlocki, analizuję kolejność odczytów i zapisów, długość transakcji oraz indeksy. Podniesienie izolacji bez takiej analizy często tylko przesuwa problem.
Przeczytaj również: Funkcja SQL - skalarna, tabelaryczna czy procedura?
Błędy, które widzę najczęściej
- Wywoływanie kilku
SaveChangesAsyncz założeniem, że tworzą jedną transakcję. - Trzymanie transakcji podczas zapytań do zewnętrznych usług.
- Łączenie retry policy z ręczną transakcją bez użycia
CreateExecutionStrategy. - Brak unikalnego identyfikatora operacji przy ponawianiu zapisów.
- Wykonywanie SQL na innym połączeniu niż to używane przez transakcję.
- Ignorowanie wyjątków po commit, mimo że stan transakcji może być nieznany.
Testuję takie przypadki nie tylko na lokalnym SQL Serverze. Warto sprawdzić timeout, zerwanie połączenia, konflikt współbieżności i dwukrotne wysłanie tego samego żądania. Dopiero wtedy widać, czy transakcja chroni dane, czy jedynie dobrze wygląda w kodzie.
Jaką decyzję podjąć w konkretnym projekcie
Jeśli wszystkie zmiany mieszczą się w jednym wywołaniu SaveChangesAsync, pozostawiłbym obsługę transakcji EF Core. Gdy operacja składa się z kilku zapisów, użyłbym jawnej transakcji na możliwie krótkim odcinku kodu.
Przy Azure SQL lub innym środowisku z retry policy całą transakcję umieściłbym wewnątrz strategii wykonania i zadbał o idempotencję. Przy bezpośrednim SQL-u dopilnowałbym wspólnego połączenia, a przy kilku kontekstach sprawdziłbym, czy rzeczywiście uczestniczą w tej samej transakcji.
Najlepsza transakcja to nie ta najdłuższa ani najbardziej skomplikowana. To najmniejszy spójny fragment operacji biznesowej, który można bezpiecznie zatwierdzić, wycofać i w razie potrzeby powtórzyć.
