• Bazy danych i SQL
  • Transakcje w EF Core bez pułapek - SaveChanges, retry i rollback

Transakcje w EF Core bez pułapek - SaveChanges, retry i rollback

Bruno Krawczyk 9 czerwca 2026
Abstrakcyjny wizualny przepływ danych z elementami EF Core, symbolizujący transakcyjne punkty kontrolne i możliwość wycofania zmian.

Spis treści

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.

Diagram przepływu transakcji bazodanowych, w tym transakcji rozproszonych. Pokazuje kroki od rozpoczęcia do zatwierdzenia lub wycofania, uwzględniając scenariusze awarii i koordynację, kluczowe dla EF Core transaction.

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 izolacji

okreś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 SaveChangesAsync z 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ć.

FAQ - Najczęstsze pytania

Jedno wywołanie SaveChangesAsync zwykle automatycznie grupuje śledzone zmiany w jednej transakcji. Jawna transakcja przez BeginTransactionAsync jest potrzebna, gdy kilka niezależnych zapisów ma zostać zatwierdzonych razem, ponieważ każde osobne wywołanie SaveChangesAsync może mieć własną transakcję.

Całą transakcję trzeba umieścić wewnątrz delegata przekazanego do context.Database.CreateExecutionStrategy().ExecuteAsync. Kod wykonywany ponownie powinien być idempotentny, aby retry nie utworzył duplikatu ani nie naliczył płatności drugi raz.

Należy rozpocząć transakcję przez ten sam DbContext, wykonać polecenie SQL, zapisać zmiany EF Core i zatwierdzić całość przez CommitAsync. Bezpośredni SQL musi korzystać z tego samego połączenia i transakcji, a EF Core nie odświeża automatycznie danych już załadowanych i śledzonych przez kontekst.

Savepoint pozwala cofnąć pojedynczy etap po błędzie bez wycofywania całej transakcji. Jest przydatny między innymi przy konfliktach optymistycznej współbieżności, ale na SQL Server przy włączonym MARS savepointy nie są tworzone.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

transakcje
współbieżność
ef core
retry
savepointy
Autor Bruno Krawczyk
Bruno Krawczyk
Mam na imię Bruno i od 8 lat zgłębiam tajniki programowania w ekosystemie .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z technologią zaczęła się od ciekawości, jak złożone systemy mogą ułatwiać codzienne życie i rozwiązywać realne problemy. Dziś moją misją jest dzielenie się tą wiedzą, starając się przybliżyć nawet najbardziej skomplikowane zagadnienia w sposób zrozumiały i przystępny dla każdego. W moich artykułach na kursdotnet.pl skupiam się na praktycznych aspektach, analizuję najnowsze trendy i weryfikuję informacje, aby dostarczyć Wam treści, które są nie tylko dokładne i aktualne, ale przede wszystkim użyteczne w Waszej własnej ścieżce rozwoju technologicznego.

Udostępnij artykuł

Napisz komentarz