EF Core'da (Entity Framework Core) tek bir SaveChanges çağrısı, sağlayıcı transaction destekliyorsa zaten bir transaction içinde çalışır: değişikliklerin tamamı ya uygulanır ya da hiçbiri uygulanmaz. Birden fazla SaveChanges çağrısını, araya giren sorguları veya ADO.NET komutlarını tek bir atomik birim olarak yürütmek istediğinizde ise context.Database.BeginTransactionAsync() ile transaction'ı kendiniz açar, CommitAsync() ile onaylarsınız; onaylanmayan transaction nesne dispose edildiğinde geri alınır.

Bu yazıda varsayılan davranışı, AutoTransactionBehavior ayarını, savepoint kullanımını, birden fazla DbContext örneğinin aynı transaction'ı paylaşmasını, harici DbTransaction ile çalışmayı ve System.Transactions (TransactionScope) seçeneğini resmî belgelere dayanarak ele alıyoruz. İzolasyon seviyelerinin veritabanı tarafındaki anlamı ayrı bir konu; kavram için transaction ve izolasyon seviyeleri yazısına bakabilirsiniz. Serinin tamamı için EF Core öğrenme rehberi sayfasından başlayabilirsiniz.

Transaction EF Core açısından ne garanti eder?

Transaction, birkaç veritabanı işleminin atomik olarak, yani bölünmez bir bütün halinde işlenmesini sağlar. Transaction commit edilirse içindeki işlemlerin hepsi kalıcı hale gelir; rollback yapılırsa hiçbiri veritabanına yansımaz. EF Core bu kavramı kendisi icat etmez, veritabanının transaction mekanizmasını kullanır. Bu nedenle transaction API'lerinin davranışı büyük ölçüde sağlayıcıya bağlıdır: tüm ilişkisel sağlayıcılar transaction'ı destekler, ancak ilişkisel olmayan sağlayıcılar transaction API'si çağrıldığında hata fırlatabilir ya da çağrıyı sessizce yok sayabilir.

Pratikte bunun anlamı şudur: kodunuz SQL Server, SQLite veya başka bir ilişkisel veritabanıyla çalışıyorsa bu yazıdaki desenlerin tamamı kullanılabilir. Test ortamında EF Core'un in-memory sağlayıcısını kullanıyorsanız transaction başlatıldığında varsayılan olarak hata alırsınız; bu konu EF Core test yazma yazısında ayrıca anlatılıyor.

Varsayılan davranış: her SaveChanges kendi transaction'ında

Varsayılan davranış: her SaveChanges kendi transaction'ında: WhenNeeded (varsayılan), Always, Never

Sağlayıcı transaction destekliyorsa tek bir SaveChanges veya SaveChangesAsync çağrısındaki tüm değişiklikler bir transaction içinde uygulanır. Değişikliklerden biri başarısız olursa transaction geri alınır ve hiçbir değişiklik veritabanına yazılmaz. Dolayısıyla SaveChanges ya tamamen başarılı olur ya da hata durumunda veritabanını değiştirmeden bırakır. Örneğin bir blog ile ona ait üç yazıyı aynı anda ekleyip tek bir SaveChangesAsync çağırırsanız, yazılardan birinin eklenmesi bir kısıt ihlali yüzünden başarısız olduğunda blog da eklenmez.

Belgeler bu varsayılanın uygulamaların çoğu için yeterli olduğunu, transaction'ı elle yönetmenin ancak gereksinimler bunu zorunlu kıldığında yapılması gerektiğini vurgular. Yani her kayıt işleminin etrafına refleks olarak BeginTransaction yazmanıza gerek yoktur. Elle yönetim; birden fazla SaveChanges çağrısının birlikte başarılı olması gerektiğinde, kaydetme ile ham SQL komutlarının aynı birimde olması gerektiğinde veya özel bir izolasyon seviyesi istediğinizde anlam kazanır. Kaydetme işleminin kendisi için SaveChanges ile ekleme, güncelleme ve silme yazısına bakın.

AutoTransactionBehavior ile otomatik transaction'ı kontrol etmek

Kullanıcı tarafından açılmış bir transaction yokken SaveChanges çağrıldığında EF'nin kendiliğinden transaction oluşturup oluşturmayacağını DatabaseFacade.AutoTransactionBehavior özelliğiyle belirlersiniz. Üç değer vardır:

DeğerDavranışNe zaman düşünülür
WhenNeeded (varsayılan)EF yalnızca gerektiğinde transaction açar; tek SQL ifadesi zaten örtük transaction içinde çalıştığı için ayrıca transaction açmazNeredeyse her zaman
AlwaysKullanıcı transaction'ı yoksa her zaman transaction açar; ek veritabanı gidiş-dönüşü ekleyebilirKod, IDbTransactionInterceptor transaction oluşturma geri çağrılarının her durumda tetiklenmesine dayanıyorsa
NeverEF hiçbir zaman otomatik transaction açmazYalnızca riskleri bilerek; çok komutlu kayıtta kısmi değişiklik kalabilir
csharp
context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always;

BeginTransactionAsync ile transaction'ı elle yönetmek

DbContext.Database API'si transaction başlatmak, commit etmek ve geri almak için kullanılır. Aşağıdaki örnek, belgelerdeki örneğe dayanır: iki ayrı SaveChangesAsync çağrısı ve bir LINQ sorgusu aynı transaction içinde yürütülür.

csharp
using var context = new BloggingContext();
await using var transaction = await context.Database.BeginTransactionAsync();

try
{
    context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/dotnet" });
    await context.SaveChangesAsync();

    context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/visualstudio" });
    await context.SaveChangesAsync();

    var blogs = await context.Blogs
        .OrderBy(b => b.Url)
        .ToListAsync();

    // Tum komutlar basariliysa commit; hata olursa transaction
    // dispose edilirken otomatik olarak geri alinir
    await transaction.CommitAsync();
}
catch (Exception)
{
    // Hatayi burada ele alin
}

Bu kodda dikkat edilmesi gereken birkaç ayrıntı var. Birincisi, await using sayesinde transaction nesnesi blok sonunda dispose edilir; CommitAsync çağrılmadan dispose edilen transaction geri alınır. Bu yüzden hata yolunda ayrıca RollbackAsync yazmak zorunda değilsiniz, ancak açıkça yazmak okuyucuya niyetinizi gösterir. İkincisi, transaction açıkken çağrılan her SaveChangesAsync değişiklikleri veritabanına gönderir ama kalıcı hale getirmez; kalıcılık yalnızca CommitAsync ile gelir. Üçüncüsü, araya giren sorgu da aynı transaction içinde çalışır ve henüz commit edilmemiş kendi eklemelerinizi görür.

Mini senaryo: ikinci kayıt başarısız olursa ne olur?

Yukarıdaki örnekte ilk SaveChangesAsync başarılı olsun, ikincisi ise örneğin bir benzersizlik kısıtı yüzünden hata fırlatsın. Akış doğrudan catch bloğuna geçer ve CommitAsync hiç çalışmaz. Blok bittiğinde transaction dispose edilir ve ilk blogun eklenmesi de geri alınır. Sonuçta veritabanında iki blogdan hiçbiri bulunmaz. Transaction açmasaydınız ilk blog kalıcı olarak eklenmiş, ikincisi eklenmemiş olurdu; işte elle transaction yönetiminin çözdüğü sorun budur.

Bu senaryoda bir ayrıntıyı daha bilmek gerekir: transaction zaten açıkken çağrılan SaveChanges, veri kaydetmeden önce otomatik olarak bir savepoint oluşturur ve hata halinde transaction'ı o noktaya geri sarar (aşağıda anlatılıyor). Yani ikinci SaveChanges başarısız olduğunda transaction ölü değildir; isterseniz sorunu düzeltip yeniden denemeye devam edebilirsiniz. Commit etmek ya da vazgeçmek yine sizin kararınızdır.

İzolasyon seviyesi belirtmek

BeginTransactionAsync, bir IsolationLevel değeri alan aşırı yüklemeye de sahiptir. Belgelerin test bölümündeki bir örnek, seri hale getirilmiş (serializable) transaction'ı şöyle açar:

csharp
await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

Aynı örneğin yorumunda belirtildiği gibi, basit bir güncelleme için transaction açmak genellikle gerekmez; örnek yalnızca konuyu göstermek içindir. Hangi seviyenin hangi okuma anomalilerini engellediği ve kilitlenme maliyetleri veritabanına özgü bir konudur; EF Core yalnızca seviyeyi veritabanına iletir.

ExecuteUpdate ve ExecuteDelete transaction açmaz

ExecuteUpdateAsync ve ExecuteDeleteAsync çağrıları change tracker'ı kullanmadan doğrudan tek bir SQL UPDATE veya DELETE gönderir ve çağrıldıklarında örtük bir transaction başlatmaz. Belgelerdeki örnekte iki ExecuteUpdate, bir sorgu ve bir SaveChanges art arda çalışır; bu dört işlemin her biri kendi transaction'ında yürütülür. İkinci güncelleme başarısız olursa birincinin etkisi veritabanında kalır. Birden fazla toplu işlemi tek birim yapmak istiyorsanız bunları da BeginTransaction ile açtığınız transaction'ın içine almalısınız. Toplu güncelleme ayrıntıları için ExecuteUpdate ve ExecuteDelete yazısına bakın.

Savepoint: transaction içinde geri dönülebilir noktalar

Savepoint: transaction içinde geri dönülebilir noktalar

Savepoint, bir veritabanı transaction'ı içinde daha sonra geri dönülebilecek bir noktadır. EF Core, bağlam üzerinde zaten süren bir transaction varken SaveChanges çağrıldığında, veri kaydetmeden önce otomatik olarak bir savepoint oluşturur. SaveChanges herhangi bir hatayla karşılaşırsa transaction'ı bu savepoint'e geri sarar; transaction, o SaveChanges hiç başlamamış gibi bir duruma döner. Bu da özellikle iyimser eşzamanlılık (concurrency) çakışmalarında sorunu düzeltip kaydetmeyi yeniden denemenize imkân tanır.

Savepoint'leri transaction'lar gibi elle de yönetebilirsiniz. Aşağıdaki örnekte ilk blog kaydedildikten sonra bir savepoint oluşturulur; sonraki iki blogun kaydı başarısız olursa transaction yalnızca bu noktaya geri alınır ve devam edebilir:

csharp
using var context = new BloggingContext();
await using var transaction = await context.Database.BeginTransactionAsync();

try
{
    context.Blogs.Add(new Blog { Url = "https://devblogs.microsoft.com/dotnet/" });
    await context.SaveChangesAsync();

    await transaction.CreateSavepointAsync("BeforeMoreBlogs");

    context.Blogs.Add(new Blog { Url = "https://devblogs.microsoft.com/visualstudio/" });
    context.Blogs.Add(new Blog { Url = "https://devblogs.microsoft.com/aspnet/" });
    await context.SaveChangesAsync();

    await transaction.CommitAsync();
}
catch (Exception)
{
    // Hata olursa savepoint'e donulur, transaction surdurulebilir
    await transaction.RollbackToSavepointAsync("BeforeMoreBlogs");

    // Hatayi ele alin; gerekirse eklemeyi yeniden deneyin
}

Burada önemli bir nokta, savepoint'e geri dönmenin transaction'ı sonlandırmamasıdır. RollbackToSavepointAsync çağrısından sonra transaction hâlâ açıktır; ilk blogun eklenmesi geçerliliğini korur ve sizin yeniden CommitAsync çağırmanızı bekler. Örnekte catch bloğunda commit olmadığı için blok sonunda transaction dispose edilir ve tamamı geri alınır. Kısmi sonucu korumak istiyorsanız hatayı ele aldıktan sonra commit etmeyi unutmamalısınız.

Birden fazla DbContext arasında transaction paylaşmak

Aynı transaction'ı birden fazla bağlam örneği arasında paylaşmak da mümkündür. Bu özellik yalnızca ilişkisel sağlayıcılarda vardır, çünkü ilişkisel veritabanlarına özgü DbTransaction ve DbConnection türlerini gerektirir. Paylaşım için bağlamların hem aynı DbConnection nesnesini hem de aynı DbTransaction nesnesini kullanması gerekir.

Bağlantıyı dışarıdan verebilmek için en kolay yol, bağlamı OnConfiguring içinde yapılandırmayı bırakıp DbContextOptions nesnesini dışarıda oluşturmak ve yapıcıya geçirmektir. Bağlamın yapıcısı DbContextOptions<BloggingContext> parametresi alır; bu desen DbContext yapılandırması yazısında ayrıntılı anlatılıyor. Alternatif olarak OnConfiguring'i koruyup yapıcıda bir DbConnection alabilir ve onu UseSqlServer(_connection) ile kullanabilirsiniz.

csharp
using var connection = new SqlConnection(connectionString);
var options = new DbContextOptionsBuilder<BloggingContext>()
    .UseSqlServer(connection)
    .Options;

using var context1 = new BloggingContext(options);
await using var transaction = await context1.Database.BeginTransactionAsync();
try
{
    context1.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/dotnet" });
    await context1.SaveChangesAsync();

    using (var context2 = new BloggingContext(options))
    {
        await context2.Database.UseTransactionAsync(transaction.GetDbTransaction());

        var blogs = await context2.Blogs
            .OrderBy(b => b.Url)
            .ToListAsync();

        context2.Blogs.Add(new Blog { Url = "http://dot.net" });
        await context2.SaveChangesAsync();
    }

    await transaction.CommitAsync();
}
catch (Exception)
{
    // Hatayi ele alin
}

Buradaki anahtar çağrı UseTransactionAsync(transaction.GetDbTransaction())'dır: ikinci bağlam yeni bir transaction açmaz, birinci bağlamın açtığı alttaki ADO.NET transaction'ına katılır. Her iki bağlam da aynı bağlantı nesnesini kullandığı için bu katılım mümkündür. Bağlantılar farklı olsaydı transaction paylaşılamazdı.

ADO.NET ile aynı transaction'ı kullanmak

Aynı ilişkisel veritabanına birden fazla veri erişim teknolojisiyle eriştiğinizde, örneğin bazı işleri doğrudan ADO.NET komutlarıyla, bazılarını EF Core ile yaptığınızda, bu işlemleri tek transaction'da birleştirmek isteyebilirsiniz. Bu durumda bağlantıyı ve transaction'ı ADO.NET tarafında açar, EF Core bağlamına UseTransactionAsync(transaction) ile verirsiniz:

csharp
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();

await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync();
try
{
    // Ham ADO.NET komutu ayni transaction icinde
    var command = connection.CreateCommand();
    command.Transaction = transaction;
    command.CommandText = "DELETE FROM dbo.Blogs";
    command.ExecuteNonQuery();

    // EF Core islemi ayni transaction icinde
    var options = new DbContextOptionsBuilder<BloggingContext>()
        .UseSqlServer(connection)
        .Options;

    using (var context = new BloggingContext(options))
    {
        await context.Database.UseTransactionAsync(transaction);
        context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/dotnet" });
        await context.SaveChangesAsync();
    }

    await transaction.CommitAsync();
}
catch (Exception)
{
    // Hatayi ele alin
}

Bu desende transaction'ın sahibi ADO.NET kodudur: commit ve dispose sorumluluğu ondadır. EF Core bağlamı yalnızca mevcut transaction'ı kullanır. Bağlam dispose edildiğinde dışarıdan verilen transaction kapanmaz; commit çağrısı bağlamın kullanım bloğundan sonra gelir.

System.Transactions ve TransactionScope

System.Transactions ve TransactionScope: EF Core, System.Transactions desteğini veritabanı sağlayıcısının uygulamasına..., System.Transactions içindeki dağıtık transaction desteği...

Daha geniş bir kapsamda koordinasyon gerekiyorsa ortam (ambient) transaction'ları kullanabilirsiniz. TransactionScope açıldığında, kapsam içinde açılan bağlantılar bu transaction'a katılır; scope.Complete() çağrılmadan kapsam kapanırsa işlemler geri alınır. Belgelerdeki örnek bir ADO.NET komutunu ve bir EF Core kaydını aynı kapsamda yürütür:

csharp
using (var scope = new TransactionScope(
           TransactionScopeOption.Required,
           new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }))
{
    using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync();

    try
    {
        var command = connection.CreateCommand();
        command.CommandText = "DELETE FROM dbo.Blogs";
        await command.ExecuteNonQueryAsync();

        var options = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlServer(connection)
            .Options;

        using (var context = new BloggingContext(options))
        {
            context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/dotnet" });
            await context.SaveChangesAsync();
        }

        scope.Complete();
    }
    catch (Exception)
    {
        // Hatayi ele alin
    }
}

Ortam transaction'ı yerine açık bir CommittableTransaction oluşturup bağlamı context.Database.EnlistTransaction(transaction) ile ona kaydettirmek de mümkündür; bu durumda bağlantıyı OpenConnectionAsync ile açar, işiniz bitince CloseConnectionAsync ile kapatır ve en sonda transaction.Commit() çağırırsınız.

System.Transactions sınırlamaları

  • EF Core, System.Transactions desteğini veritabanı sağlayıcısının uygulamasına bırakır. Sağlayıcı desteklemiyorsa bu API'lere yapılan çağrılar tamamen yok sayılabilir. SqlClient destekler; başka bir sağlayıcıya güvenmeden önce davranışı kendiniz test etmeniz önerilir.
  • System.Transactions içindeki dağıtık transaction desteği .NET 7.0 ile ve yalnızca Windows için eklendi. Eski .NET sürümlerinde veya Windows dışı platformlarda dağıtık transaction denemesi başarısız olur. Linux sunucuda çalışan bir uygulama için bu önemli bir kısıttır.
  • TransactionScope async commit veya rollback desteklemez; kapsamın senkron olarak dispose edilmesi, işlem tamamlanana kadar çalışan thread'i bloklar.

EF Core 10: Application Name ve beklenmedik dağıtık transaction

EF Core 10 sürüm notlarındaki düşük etkili bir kırıcı değişiklik doğrudan bu konuyla ilgilidir. SQL Server sağlayıcısına Application Name içermeyen bir bağlantı dizesi verildiğinde EF artık bu alana EF ve SqlClient sürümleri hakkında anonim bilgi ekler. Aynı veritabanına hem EF hem de Dapper veya ADO.NET gibi başka bir teknolojiyle bağlanıyorsanız, SqlClient iki bağlantı dizesini farklı kabul eder ve farklı iç bağlantı havuzları kullanır. Bu karma erişim bir TransactionScope içinde yapılıyorsa, daha önce gerekmeyen bir dağıtık transaction'a yükselme yaşanabilir. Çözüm basittir: bağlantı dizenizde kendiniz bir Application Name tanımlayın; tanımlıysa EF onu değiştirmez. Bağlantı dizesi yönetimi için connection string yönetimi yazısına bakabilirsiniz.

Yeniden deneme stratejileri ile uyumsuzluk

Belgeler, transaction'ı bu şekilde elle yönetmenin örtük olarak devreye giren yeniden deneme (retrying) yürütme stratejileriyle uyumsuz olduğunu not eder. EnableRetryOnFailure açıksa ve kodunuz BeginTransactionAsync çağırırsa bir InvalidOperationException alırsınız; çözüm, transaction'ı içeren kod bloğunun tamamını CreateExecutionStrategy() ile alınan strateji üzerinden yürütmektir. Bu desen ve commit sırasında bağlantı kopması gibi durumlar connection resiliency yazısında ayrıntılı olarak anlatılıyor.

Sık yapılan hatalar

  • Her işleme gereksiz transaction eklemek: Tek bir SaveChanges zaten atomiktir. Ek transaction yalnızca birden fazla işlemi birleştirmeniz gerektiğinde gereklidir.
  • CommitAsync çağrısını unutmak: Commit edilmeyen transaction dispose edildiğinde geri alınır. Testte her şey çalışıyor görünür ama veriler kalıcı olmaz.
  • ExecuteUpdate'in otomatik transaction açtığını varsaymak: Toplu işlemler tek tek kendi transaction'larında çalışır; birden fazlasını birleştirmek için açık transaction gerekir.
  • MARS açıkken savepoint'e güvenmek: SQL Server'da MARS etkinse EF savepoint oluşturmaz ve hata sonrası transaction'ın durumu belirsiz kalabilir.
  • Farklı bağlantılarla transaction paylaşmaya çalışmak: Bağlamlar arasında paylaşım için aynı DbConnection ve aynı DbTransaction şarttır.
  • TransactionScope'u async kodda akış seçeneği olmadan kullanmak: TransactionScopeAsyncFlowOption.Enabled belirtilmezse ortam transaction'ı async çağrılara taşınmaz.
  • Linux'ta dağıtık transaction beklemek: Dağıtık transaction desteği yalnızca Windows'ta ve .NET 7.0 ve sonrasında vardır.

Sık Sorulan Sorular

SaveChanges için ayrıca transaction açmam gerekir mi?

Hayır. Sağlayıcı transaction destekliyorsa tek bir SaveChanges çağrısındaki tüm değişiklikler zaten bir transaction içinde uygulanır ve hata olursa hiçbiri kalıcı olmaz. Açık transaction, birden fazla çağrıyı birleştirmek istediğinizde gerekir.

Transaction açıkken SaveChanges hata verirse transaction bozulur mu?

İlişkisel sağlayıcılarda EF, transaction açıkken SaveChanges öncesinde otomatik savepoint oluşturur ve hata halinde o noktaya geri döner. Böylece transaction sürdürülebilir. SQL Server'da MARS etkinse bu savepoint oluşturulmaz.

İki farklı DbContext türünü aynı transaction'da kullanabilir miyim?

Belgelerdeki koşul, bağlamların aynı DbConnection ve DbTransaction nesnelerini paylaşmasıdır. İkinci bağlamda UseTransactionAsync ile mevcut transaction'a katılırsınız. Bu yalnızca ilişkisel sağlayıcılarda çalışır.

TransactionScope mu, BeginTransaction mı?

Tek bir veritabanı bağlantısı üzerinde çalışıyorsanız BeginTransaction daha doğrudan ve async commit destekleyen yoldur. TransactionScope daha geniş kapsamlı koordinasyon içindir; ancak sağlayıcı desteğine bağlıdır, async commit desteklemez ve dağıtık senaryolarda platform kısıtları vardır.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: EF Core öğrenme rehberi · SaveChanges ile kaydetme · Connection resiliency · Concurrency ve rowversion · ExecuteUpdate ve ExecuteDelete · Transaction izolasyon seviyeleri