Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #13

EF Core'da Transaction'lar: SaveChanges, BeginTransaction ve Savepoint'ler

Mert Özen 5 Kas 2026 11 dk 2 görüntülenme
EF Core'da Transaction'lar: SaveChanges, BeginTransaction ve Savepoint'ler

SaveChanges'in örtük transaction'ını loglarla görüyor, birden fazla SaveChanges'i BeginTransaction ile tek bütün yapıyor, savepoint'lerle kısmi geri alma yapıyor ve TransactionScope ile retry stratejisinin tuzaklarına bakıyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Sipariş Yazıldı mı?

Aşağıdaki method bir sipariş oluşturuyor, ödemeyi alıyor ve stoktan düşüyor. Ödeme service'i tam ortada hata fırlatıyor. Kodu okumadan önce cevabını bir kenara yaz: method bittiğinde veritabanında kaç sipariş var ve Dune kitabının stoku kaç?

static async Task PlaceOrder()
{
    using var db = new BookstoreContext();
    var book = await db.Books.SingleAsync(b => b.Title == "Dune");

    var order = new Order { CustomerName = "Ada" };
    order.Items.Add(new OrderItem { BookId = book.Id, Quantity = 2, UnitPrice = book.Price });
    db.Orders.Add(order);
    await db.SaveChangesAsync(); // 1. SaveChanges: sipariş yazıldı, order.Id artık belli

    ChargePayment(order.Id); // ödeme service'i hata fırlatırsa?

    book.Stock -= 2;
    await db.SaveChangesAsync(); // 2. SaveChanges: buraya hiç gelinmeyebilir
}

static void ChargePayment(int orderId)
{
    // ödeme sağlayıcısı cevap vermedi
    throw new InvalidOperationException($"Payment failed for order {orderId}");
}

Önceki yazıda SaveChanges'in bütün değişiklikleri tek seferde gönderdiğini gördük. Burada iki ayrı SaveChanges var ve aralarında bir hata patlıyor. Cevabı birazdan çalıştırarak göreceğiz. Bu yazının konusu tam olarak bu: hangi işlerin bir bütün olarak ya tamamen yapılması ya da hiç yapılmaması gerektiği ve EF Core'da bunun transaction'larla nasıl sağlandığı.

Örnek Model ve Transaction Logları

Bookstore modelimizin bu yazıya yetecek kadarını kullanıyoruz: stoku olan Book, kalemleriyle Order ve adı unique olan Tag. Context'e bir de log ayarı ekliyoruz. LogTo'ya bir event listesi verip yalnız transaction'ın başlaması, commit edilmesi, savepoint'ler ve çalışan SQL komutlarını yazdırıyoruz. Transaction event'leri Debug seviyesinde olduğu için minimum seviyeyi de LogLevel.Debug yaptık.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = "";
    public List<OrderItem> Items { get; set; } = new();
}

public class OrderItem
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int BookId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Tag> Tags => Set<Tag>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=bookstore.db")
            .LogTo(Console.WriteLine,
                new[]
                {
                    RelationalEventId.TransactionStarted,
                    RelationalEventId.TransactionCommitted,
                    RelationalEventId.CreatedTransactionSavepoint,
                    RelationalEventId.RolledBackToTransactionSavepoint,
                    RelationalEventId.CommandExecuted
                },
                LogLevel.Debug,
                DbContextLoggerOptions.Level);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Tag>().HasIndex(t => t.Name).IsUnique();
    }
}

DbContextLoggerOptions.Level logdaki tarih, kategori ve event id kalabalığını atıp yalnız seviyeyi bırakıyor. Paketler her zamanki gibi Microsoft.EntityFrameworkCore.Sqlite. Başlangıçta veritabanında 10 adet stoklu Dune kitabı ve sci-fi adlı bir tag var.

SaveChanges Zaten Bir Transaction'dır

Önce tek bir SaveChanges çağrısında ne olduğuna bakalım. Bir sipariş ekliyor, aynı anda stoku güncelliyoruz:

using var db = new BookstoreContext();
var book = await db.Books.SingleAsync(b => b.Title == "Dune");

var order = new Order { CustomerName = "Ada" };
order.Items.Add(new OrderItem { BookId = book.Id, Quantity = 2, UnitPrice = book.Price });
db.Orders.Add(order);
book.Stock -= 2;

await db.SaveChangesAsync(); // tek çağrı: UPDATE + 2 INSERT

EF Core 10 ile çalıştırınca logun SaveChanges'e ait kısmı şöyle:

dbug: 
      Began transaction with isolation level 'Serializable'.
info: 
      Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      UPDATE "Books" SET "Stock" = @p0
      WHERE "Id" = @p1
      RETURNING 1;
info: 
      Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 3)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "Orders" ("CustomerName")
      VALUES (@p0)
      RETURNING "Id";
info: 
      Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p2='?' (DbType = Int32), @p3='?' (DbType = Int32), @p4='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "OrderItem" ("BookId", "OrderId", "Quantity", "UnitPrice")
      VALUES (@p1, @p2, @p3, @p4)
      RETURNING "Id";
dbug: 
      Committed transaction.

Biz hiçbir yerde transaction açmadık ama EF Core üç komutu bir BEGIN ile COMMIT arasına aldı. Bu, SaveChanges'in temel sözüdür: o çağrıdaki değişikliklerin hepsi yazılır ya da hiçbiri yazılmaz. Sipariş eklenip stok düşmemiş bir ara durum hiçbir zaman oluşmaz. SQLite'ta varsayılan isolation level Serializable olduğu için logda onu görüyorsun; SQL Server'da bu satır ReadCommitted olur.

EF Core 6 ile aynı log neredeyse aynı. Tek fark SQL'in yazılışında: EF Core 6, eklenen satırın id'sini RETURNING yerine ayrı bir SELECT ... WHERE changes() = 1 AND "rowid" = last_insert_rowid() ile okuyor. Transaction davranışı ikisinde de aynı.

Peki SaveChanges tek bir komut gönderecekse? Tek bir tag ekleyelim:

using var db = new BookstoreContext();
db.Tags.Add(new Tag { Name = "classic" });
await db.SaveChangesAsync(); // tek bir INSERT

Burada sürümler ayrışıyor. EF Core 6 bu tek INSERT'i de Began transaction ve Committed transaction satırlarıyla sarıyor. EF Core 10'da ise log yalnız şundan ibaret:

info: 
      Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 7)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "Tags" ("Name")
      VALUES (@p0)
      RETURNING "Id";

Tek bir SQL komutu veritabanı açısından zaten atomiktir; yarısı çalışıp yarısı kalmaz. EF Core 7'den itibaren EF bunu biliyor ve gereksiz transaction'ı atlıyor. Davranış değişmiyor, yalnızca veritabanına iki gidiş-dönüş eksik gidiliyor. Bu ve benzeri sürüm farklarını sürüm yazılarında ayrıca göreceğiz.

Ya Biri Patlarsa? Ya Hep Ya Hiç

"Hepsi ya da hiçbiri" sözünü test edelim. Aynı SaveChanges'te iki tag ekliyoruz. Birincisi yeni, ikincisi zaten var ve unique index'e takılacak:

using (var db = new BookstoreContext())
{
    db.Tags.Add(new Tag { Name = "fantasy" });
    db.Tags.Add(new Tag { Name = "sci-fi" }); // zaten var, unique index'e takılacak

    try
    {
        await db.SaveChangesAsync();
    }
    catch (DbUpdateException ex)
    {
        Console.WriteLine(ex.InnerException?.Message);
    }
}

using (var db = new BookstoreContext())
{
    var names = await db.Tags.Select(t => t.Name).ToListAsync();
    Console.WriteLine(string.Join(", ", names));
}
SQLite Error 19: 'UNIQUE constraint failed: Tags.Name'.
sci-fi

fantasy için çalışan INSERT aslında başarılıydı, ama ikinci komut patlayınca EF Core transaction'ı geri aldı. Veritabanında hâlâ yalnız sci-fi var. Hatanın asıl sebebi DbUpdateException'ın InnerException'ında duruyor; loglarken onu da yazdırmayı alışkanlık edin.

Bir ayrıntı daha: hata sonrası change tracker'daki iki Tag hâlâ Added durumunda. Aynı context ile tekrar SaveChanges dersen aynı iki INSERT'i yeniden dener. Bu yüzden örnekte kontrolü yeni bir context ile yaptık.

Birden Fazla SaveChanges: BeginTransaction

Şimdi açılıştaki soruya dönelim. PlaceOrder'ı çalıştırınca çıktı şu:

Payment failed for order 1
Orders: 1, Items: 1, Tags: 1, Stock: 10

Sipariş yazılmış, stok yerinde duruyor. Her SaveChanges kendi transaction'ını açıp kapattığı için birinci çağrı commit edildi ve geri dönüşü kalmadı. Elimizde parası alınmamış, stoktan düşmemiş ama sistemde görünen bir sipariş var. İki SaveChanges'e bölmenin sebebi de anlaşılır: ödeme service'ine sipariş numarasını göndermek için önce order.Id'nin oluşması gerekiyor.

Çözüm, iki çağrıyı da kapsayan bir transaction'ı kendimiz açmak:

static async Task PlaceOrderInTransaction()
{
    using var db = new BookstoreContext();
    using var transaction = await db.Database.BeginTransactionAsync();

    var book = await db.Books.SingleAsync(b => b.Title == "Dune");

    var order = new Order { CustomerName = "Ada" };
    order.Items.Add(new OrderItem { BookId = book.Id, Quantity = 2, UnitPrice = book.Price });
    db.Orders.Add(order);
    await db.SaveChangesAsync();

    ChargePayment(order.Id);

    book.Stock -= 2;
    await db.SaveChangesAsync();

    await transaction.CommitAsync(); // Commit yoksa, Dispose sırasında her şey geri alınır
}

Bu kez çıktı:

Payment failed for order 1
Orders: 0, Items: 0, Tags: 1, Stock: 10

Logun ilgili satırları da hikâyeyi anlatıyor:

dbug:
      Began transaction with isolation level 'Serializable'.
info:
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT "b"."Id", "b"."Price", "b"."Stock", "b"."Title"
dbug:
      Created transaction savepoint.

Bu kez Began transaction satırı BeginTransactionAsync çağrısında yazıldı, SaveChanges'te değil. SaveChanges açık bir transaction gördüğünde yenisini açmıyor, mevcut olanın içinde çalışıyor. Committed transaction satırı ise hiç yok. Hata fırlayınca CommitAsync'e ulaşılmadı, using bloğu bitince transaction dispose edildi ve dispose edilen, commit edilmemiş bir transaction geri alınır. Bu yüzden catch içinde elle Rollback çağırmana gerek yok. Kural basit: transaction'ı using ile aç, en sonda Commit et, gerisini Dispose'a bırak.

Bir de şu var: bu transaction açık kaldığı sürece bağlantı açık kalır ve veritabanı kilitleri tutulur. Transaction'ın içinde dış bir service'i çağırmak, yukarıdaki örnekte olduğu gibi, gerçek hayatta tartışmalı bir karardır. Ödeme service'i 30 saniye cevap vermezse kilitler de 30 saniye sürer. Transaction'ları kısa tut; mümkünse dış çağrıyı transaction'ın dışına al.

Savepoint'ler: Transaction İçinde Ara Durak

Yukarıdaki logda gözüne çarpmış olabilir: Created transaction savepoint. EF Core 5'ten beri SaveChanges, açık bir transaction içinde çalışırken önce __EFSavePoint adında bir savepoint oluşturur. SaveChanges başarısız olursa yalnızca o noktaya geri döner. Böylece senin transaction'ın bozulmaz; önceki SaveChanges'lerin yaptıkları yerinde kalır ve sen hatayı yakalayıp devam etmeye karar verebilirsin.

Aynı mekanizmayı elle de kullanabilirsin. Siparişi kaydettikten sonra bir ara durak koyuyoruz. Ardından gelen "ekstralar" iki ayrı SaveChanges'ten oluşuyor ve biri patlarsa ikisini birden geri almak istiyoruz:

using var db = new BookstoreContext();
using var transaction = await db.Database.BeginTransactionAsync();

var book = await db.Books.SingleAsync(b => b.Title == "Dune");
var order = new Order { CustomerName = "Ada" };
order.Items.Add(new OrderItem { BookId = book.Id, Quantity = 1, UnitPrice = book.Price });
db.Orders.Add(order);
await db.SaveChangesAsync();

await transaction.CreateSavepointAsync("BeforeExtras");
try
{
    order.Items.Add(new OrderItem { BookId = book.Id, Quantity = 1, UnitPrice = 0m }); // hediye kitap
    await db.SaveChangesAsync();

    db.Tags.Add(new Tag { Name = "sci-fi" }); // unique index'e takılır
    await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
    await transaction.RollbackToSavepointAsync("BeforeExtras");
    db.ChangeTracker.Clear(); // geri alınan entity'leri unut
}

await transaction.CommitAsync();
Orders: 1, Items: 1, Tags: 1, Stock: 10

Sipariş ve ilk kalemi duruyor. Hediye kitap kalemi başarıyla yazılmıştı, ama RollbackToSavepointAsync("BeforeExtras") onu da geri aldı. ChangeTracker.Clear() satırını unutma. Veritabanını geri sardın ama change tracker hâlâ hediye kalemini Unchanged, patlayan tag'i Added sanıyor. Temizlemezsen sonraki SaveChanges o tag'i yeniden dener.

Savepoint'leri SQL Server, PostgreSQL ve SQLite destekler; örneği zaten SQLite'ta çalıştırdık. Bir istisna var: SQL Server'da MARS (MultipleActiveResultSets=True) açıksa savepoint'ler kullanılamaz ve EF Core SaveChanges'te kendi savepoint'ini oluşturmaktan vazgeçer.

TransactionScope Notu

.NET Framework günlerinden gelen biri büyük ihtimalle System.Transactions.TransactionScope'u soracaktır. Kod blokunu bir scope'a alırsın, içindeki bağlantılar bu "ambient" transaction'a kendiliğinden katılır. SQLite ile deneyelim:

using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
using var db = new BookstoreContext();

db.Tags.Add(new Tag { Name = "classic" });
await db.SaveChangesAsync();

scope.Complete();
InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Database.Transaction.AmbientTransactionWarning': An ambient transaction has been detected, but the current provider does not support ambient transactions. See https://go.microsoft.com/fwlink/?LinkId=800142. This exception can be suppressed or logged by passing event ID 'RelationalEventId.AmbientTransactionWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

Çıktı EF Core 10'dan, EF Core 6'da da birebir aynı exception geliyor. SQLite provider'ı ambient transaction'ı desteklemiyor ve EF Core bunu sessizce geçiştirmek yerine hata fırlatıyor. İyi ki de öyle; aksi hâlde scope.Complete() çağrılmasa bile değişiklikler yazılırdı. SQL Server provider'ı TransactionScope'u destekler, ama iki şeye dikkat: async kodda TransactionScopeAsyncFlowOption.Enabled vermezsen scope await'ten sonra kaybolur; scope içinde ikinci bir bağlantı açılırsa transaction dağıtık hâle gelmeye çalışır ve .NET 6'da bu desteklenmez. Tek bir veritabanıyla çalışıyorsan BeginTransaction hem daha açık hem daha az sürprizlidir.

Sık Düşülen Tuzaklar

Retry ile kendi transaction'ını karıştırmak. SQL Server'da geçici hatalara karşı EnableRetryOnFailure() açmak yaygın bir ayar:

public class SqlServerBookstoreContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlServer(
            "Server=localhost;Database=Bookstore;User Id=sa;Password=Your_password123;TrustServerCertificate=True",
            sql => sql.EnableRetryOnFailure());
}

Bu ayar varken BeginTransaction ile kendi transaction'ını açıp SaveChanges çağırırsan EF Core "The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions" mesajıyla bir InvalidOperationException fırlatır. Sebebi mantıklı: bağlantı koptuğunda EF tek bir SaveChanges'i tekrar deneyebilir, ama senin transaction'ının başından beri ne yaptığını bilemez. Çözüm, transaction'ın tamamını execution strategy'ye teslim etmek; böylece hata olursa bütün blok baştan çalışır:

var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    using var transaction = await db.Database.BeginTransactionAsync();

    db.Tags.Add(new Tag { Name = "classic" });
    await db.SaveChangesAsync();

    await transaction.CommitAsync();
});

Bunu SQLite'ta özel bir retry strategy ile denedik; mesaj ve çözüm birebir aynı çalışıyor. Bloğun baştan çalışabileceğini unutma: içinde e-posta göndermek gibi tekrarlanmaması gereken işler olmamalı.

Commit'i unutmak. BeginTransaction açıp CommitAsync'i yazmazsan kod hata vermez, her SaveChanges başarılı görünür, ama context dispose olunca hepsi geri alınır. "Kaydediyor ama veritabanında yok" diye saatlerce aranan hataların klasiği budur.

Uzun transaction. Transaction içinde kullanıcıdan cevap beklemek, büyük dosya işlemek ya da yavaş bir dış service'i çağırmak, kilitleri o kadar uzun tutmak demek. Transaction'ı mümkün olan en geç anda aç, en erken anda kapat.

Gereksiz transaction. Tek SaveChanges zaten atomik. Her SaveChanges'i refleksle BeginTransaction içine almak bir şey kazandırmaz, kodu kalabalıklaştırır. Elle transaction'ı yalnız birden fazla SaveChanges'i, raw SQL'i ya da aynı bağlantıyı paylaşan başka bir kodu (örneğin Dapper serisinde gördüğümüz sorguları) tek bir bütün hâline getirmen gerektiğinde aç.

Küçük Bir Deneme

★☆☆ PlaceOrderInTransaction içinde ChargePayment satırını yorum satırı yap ama CommitAsync satırını da sil. Programı çalıştır ve Orders sayısına bak. Ardından logda hangi satırın eksik olduğunu bul.

★★☆ Bir CancelOrder(int orderId) method'u yaz: siparişi silsin ve kalemlerdeki adetleri stoğa geri eklesin. Silme ve stok güncellemesini önce tek bir SaveChanges ile yap, sonra iki ayrı SaveChanges'e bölüp aralarına bir exception koy ve transaction ile korumayı dene.

★★★ Üç siparişi sırayla kaydeden bir döngü yaz ve ikincisini bilerek hatalı yap (örneğin zaten var olan bir tag ekleyerek). Her sipariş için bir savepoint oluştur ve hatalı siparişte yalnızca o siparişi geri al. Döngü bittiğinde tek bir Commit ile hatasız siparişleri yaz. ChangeTracker.Clear() çağrısını nereye koyman gerektiğini bulmak işin asıl kısmı.

Transaction'lar tek bir isteğin içindeki işleri bir arada tutuyor. Ama aynı kitabın son nüshasını aynı anda iki farklı kullanıcı satın almaya çalışırsa ne olur? İki ayrı transaction, ikisi de kendi içinde kusursuz, sonuç yine yanlış. Bir sonraki yazıda optimistic concurrency'ye, concurrency token'lara ve DbUpdateConcurrencyException'a bakacağız.