TekinAli ⎇ main

EF Core'da toplu ekleme, güncelleme ve silme: SaveChanges batch'lemeden SqlBulkCopy'ye

EF Core'un batch'lenen SaveChanges'ı EF6 dönemi yaklaşımdan nasıl ayrılır; ExecuteUpdate/ExecuteDelete nasıl çalışır, nerede ısırır; EF Core 10'un koşullu setter'ları, map'lenmemiş tipler için SqlQuery ve ne zaman SqlBulkCopy ya da PostgreSQL COPY'ye geçmeli.

50.000 entity'yi tek tek silmek için belleğe yükleyin ya da bir milyon satırı change tracker üzerinden eklemeye çalışın: EF tam olarak istediğinizi yapar, ama yavaş, bol bellekle ve bol round trip'le. Bilinen geçici çözümler (change detection'ı kapatmak, her N satırda context'i yeniden oluşturmak, raw SQL'e inmek) EF6'dan geliyor ve orada anlamlıydı.

EF Core farklı bir motor. O hilelerin bir kısmı artık EF Core'da büyük ölçüde var olmayan sorunları çözüyor ve EF Core'un tam da bu işler için eklediği araçları atlıyor. Bu yazıda o araçları tek tek ele alıyorum: batch'lenen SaveChanges, küme bazlı güncelleme ve silme, istediğiniz tipe raw SQL ve EF'in kendisinin darboğaz olduğu durumlar için veritabanının kendi toplu yükleme API'leri.

Araç kutusu

  • Ekleme: EF Core'da AddRange + SaveChangesAsync batch'lenir ve on binlerce satıra kadar gayet yeterlidir. Büyük import'larda provider'ın toplu yükleme API'sini kullanın: SQL Server için SqlBulkCopy, PostgreSQL için Npgsql üzerinden binary COPY.
  • Koşula göre güncelleme/silme: ExecuteUpdateAsync ve ExecuteDeleteAsync (EF Core 7+). Tek bir SQL cümlesi çalışır, hiçbir entity belleğe yüklenmez.
  • Koşullu setter'lar: EF Core 10'dan beri ExecuteUpdateAsync düz bir lambda da kabul ediyor, yani içinde if yazabiliyorsunuz.
  • İstediğiniz tipe sorgu: map'lenmemiş tiplerle Database.SqlQuery<T> (EF Core 8+).

Ekleme: EF6 alışkanlıklarından EF Core batch'lemesine

EF6 döneminde hızlı ekleme için klasik tarif, AutoDetectChangesEnabled'ı kapatmak, her N kayıtta bir SaveChanges çağırmak ve context'i belli aralıklarla dispose edip yeniden oluşturmaktı. EF6 için yerinde bir tavsiyeydi. EF6'da change detection, takip edilen her entity ile biraz daha pahalılaşıyordu ve SaveChanges her satır için ayrı bir round trip yapıyordu.

EF Core farklı çalışıyor. SaveChanges birden fazla INSERT, UPDATE ve DELETE komutunu daha az round trip'e sığacak şekilde batch'liyor, EF Core 7 de ekleme işlemlerini ayrıca belirgin şekilde hızlandırdı. Büyük bir döngüde geriye kalan asıl maliyet, change tracker'ın eklediğiniz her entity'yi tutmaya devam etmesi. "Context'i yeniden oluştur" tavsiyesinin bugünkü karşılığı, onu temizlemek:

foreach (var chunk in incoming.Chunk(5_000))
{
    db.Products.AddRange(chunk);
    await db.SaveChangesAsync(ct);
    db.ChangeTracker.Clear();
}

Batch boyutunu provider bazında ayarlayabilirsiniz (UseSqlServer(cs, o => o.MaxBatchSize(...))), ama dokunmadan önce ölçün. Varsayılanlar makul.

Güncelleme ve silme: ExecuteUpdate ve ExecuteDelete

Çok sayıda satırı silmenin eski yolları ya her entity'yi yükleyip tek tek Remove çağırıyor, ya raw SQL'e iniyor ya da extension kütüphaneleri kullanıyordu. EF Core 7'den beri bu yerleşik:

var cutoff = DateTime.UtcNow.AddDays(-90);

int deleted = await db.AuditLogs
    .Where(l => l.CreatedAt < cutoff)
    .ExecuteDeleteAsync(ct);

int repriced = await db.Products
    .Where(p => p.CategoryId == categoryId)
    .ExecuteUpdateAsync(s => s
        .SetProperty(p => p.Price, p => p.Price * 1.10m)
        .SetProperty(p => p.UpdatedAt, DateTime.UtcNow), ct);

Her çağrı, Where koşulunuz SQL'e çevrilmiş tek bir DELETE ya da UPDATE olur. Belleğe hiçbir şey yüklenmez, iş olması gereken yerde, veritabanında yapılır.

EF Core 10: expression tree yazmadan koşullu setter'lar

EF Core 10'dan önce setter argümanı bir expression tree'ydi. Bunu koşullu kurmak (mesela sadece client gönderdiyse adı güncellemek) Expression düğümlerini elle birleştirmek demekti. Bundan keyif alan pek yoktu. Kasım 2025'te çıkan ve güncel LTS sürüm olan EF Core 10 normal bir lambda kabul ediyor:

await db.Products
    .Where(p => p.Id == id)
    .ExecuteUpdateAsync(s =>
    {
        s.SetProperty(p => p.UpdatedAt, DateTime.UtcNow);

        if (patch.Name is not null)
            s.SetProperty(p => p.Name, patch.Name);

        if (patch.Price is not null)
            s.SetProperty(p => p.Price, patch.Price.Value);
    }, ct);

PATCH tarzı endpoint'lerde bu epey hantal kodu ortadan kaldırıyor.

Dikkat edilmesi gerekenler, doğrudan dokümandan

Bu metotlar change tracker'ı tamamen atlar ve bunun sonuçları var. EF Core dokümantasyonu bunları açıkça sıralıyor:

  1. Change tracker'ın haberi olmaz. Bir entity'yi yükleyip sonra üzerinde ExecuteUpdate çalıştırdıysanız, takip edilen instance hâlâ eski değerleri taşır. Sonraki bir SaveChanges, ExecuteUpdate'in yaptığını ezebilir. Aynı context'te aynı satırlar üzerinde iki yaklaşımı karıştırmayın.
  2. Çağrılar arasında örtük transaction yok. Her ExecuteUpdate/ExecuteDelete kendi başına çalışır. İki çağrıdan ikincisi hata verirse birincisi commit edilmiş olarak kalır. Transaction'ı kendiniz açın:
await using var tx = await db.Database.BeginTransactionAsync(ct);

await db.OrderLines.Where(l => l.OrderId == orderId).ExecuteDeleteAsync(ct);
await db.Orders.Where(o => o.Id == orderId).ExecuteDeleteAsync(ct);

await tx.CommitAsync(ct);

EnableRetryOnFailure gibi tekrar deneyen bir execution strategy açtıysanız, kendi başlattığınız transaction'ı db.Database.CreateExecutionStrategy() üzerinden çalıştırın. Yoksa EF transaction'ı başlatmayı reddeder.

  1. Otomatik concurrency kontrolü yok. Bunun yerine dönen satır sayısını kullanın:
int updated = await db.Orders
    .Where(o => o.Id == orderId && o.Version == expectedVersion)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Status, OrderStatus.Shipped)
        .SetProperty(o => o.Version, o => o.Version + 1), ct);

if (updated == 0)
    throw new DbUpdateConcurrencyException("Order was modified or deleted by someone else.");
  1. Batch'leme yok. Her çağrı ayrı bir round trip'tir. On ExecuteUpdate çağrısı on ayrı cümle demek.
  2. Ekleme yok. ExecuteInsert diye bir şey yok. Eklemeler hâlâ Add + SaveChanges ya da provider'ın toplu yükleme API'si üzerinden yapılıyor.

Entity olmayan tiplere raw SQL

Yaygın bir geçici çözüm, sırf bir rapor sorgusu çalıştırmak için keyless entity type kaydetmekti. EF Core 8'den beri buna gerek yok. Database.SqlQuery<T>, sonuçları property'leri eşleşen herhangi bir tipe map'liyor:

public sealed class CategoryStats
{
    public int CategoryId { get; init; }
    public int ProductCount { get; init; }
    public decimal AveragePrice { get; init; }
}

var stats = await db.Database
    .SqlQuery<CategoryStats>($"""
        SELECT CategoryId, COUNT(*) AS ProductCount, AVG(Price) AS AveragePrice
        FROM Products
        WHERE IsActive = {onlyActive}
        GROUP BY CategoryId
        """)
    .OrderByDescending(s => s.ProductCount)
    .ToListAsync(ct);

Interpolasyonla verilen değer string birleştirme değil, gerçek bir SQL parametresi olur. Sonuç bir IQueryable olduğu için üzerine LINQ ekleyebilirsiniz. Satır döndürmeyen komutlar için aynı şekilde parametrelendiren Database.ExecuteSqlAsync var.

Gerçek toplu ekleme: veritabanının kendi aracını kullanın

Yüz binlerce ya da milyonlarca satır için batch'lenmiş INSERT'ler bile yanlış araç. Ciddi her veritabanının bir toplu yükleme yolu var ve aradaki fark büyük.

SQL Server'da, EF'in zaten elindeki bağlantıyla:

var table = new DataTable();
table.Columns.Add("Sku", typeof(string));
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));

foreach (var p in incoming)
    table.Rows.Add(p.Sku, p.Name, p.Price);

await db.Database.OpenConnectionAsync(ct);
try
{
    var connection = (SqlConnection)db.Database.GetDbConnection();

    using var bulk = new SqlBulkCopy(connection)
    {
        DestinationTableName = "dbo.Products",
        BatchSize = 10_000
    };
    bulk.ColumnMappings.Add("Sku", "Sku");
    bulk.ColumnMappings.Add("Name", "Name");
    bulk.ColumnMappings.Add("Price", "Price");

    await bulk.WriteToServerAsync(table, ct);
}
finally
{
    await db.Database.CloseConnectionAsync();
}

Aktif bir EF transaction'ı varsa onu SqlBulkCopy constructor'ına geçin ((SqlTransaction)tx.GetDbTransaction()). Şunu da bilin: bulk copy varsayılan olarak trigger'ları tetiklemez ve constraint'leri kontrol etmez. Bunu SqlBulkCopyOptions ile ayrıca istemeniz gerekir.

PostgreSQL'de, Npgsql'in binary COPY'si ile:

await db.Database.OpenConnectionAsync(ct);
var conn = (NpgsqlConnection)db.Database.GetDbConnection();

await using (var writer = await conn.BeginBinaryImportAsync(
    "COPY products (sku, name, price) FROM STDIN (FORMAT BINARY)", ct))
{
    foreach (var p in incoming)
    {
        await writer.StartRowAsync(ct);
        await writer.WriteAsync(p.Sku, NpgsqlDbType.Text, ct);
        await writer.WriteAsync(p.Name, NpgsqlDbType.Text, ct);
        await writer.WriteAsync(p.Price, NpgsqlDbType.Numeric, ct);
    }

    await writer.CompleteAsync(ct); // bu çağrılmazsa import geri alınır
}

İkisi de EF'i tamamen devre dışı bırakır. Üretilen key'ler geri gelmez, takip edilen entity olmaz. Upsert gerektiğinde ben önce bir staging tablosuna toplu yükleme yapıyorum, sonra ExecuteSqlAsync ile tek bir MERGE (SQL Server) ya da INSERT ... ON CONFLICT (PostgreSQL) çalıştırıyorum.

Bunu her provider için ayrı yazmak istemiyorsanız, bu API'leri EF tarzı extension metotların arkasına saran üçüncü parti kütüphaneler var. EFCore.BulkExtensions, linq2db.EntityFrameworkCore ve Entity Framework Extensions en sık duyulanlar. Birini seçmeden önce lisans koşullarını okuyun ve hangi provider'ları desteklediğine bakın.

Özet

  • EF6 hilelerini unutun. EF Core'da AddRange + SaveChangesAsync zaten batch'leniyor. Uzun döngülerde parçalar arasında ChangeTracker.Clear() çağırın.
  • Küme bazlı değişiklikler için ExecuteDeleteAsync / ExecuteUpdateAsync kullanın. Change tracker'ı atladıklarını, siz açmadıkça ortak bir transaction'da çalışmadıklarını ve concurrency kontrolünü sizin yerinize yapmadıklarını unutmayın.
  • EF Core 10'da koşullu güncellemeler için expression tree kurmak yerine ExecuteUpdateAsync'in lambda overload'unu kullanın.
  • Raporlar ve diğer okuma modelleri için Database.SqlQuery<T> kullanın. Sahte entity'lere gerek yok.
  • Büyük import'larda SqlBulkCopy ya da PostgreSQL COPY kullanın, mümkünse bir staging tablosuna.
  • Kendi verinizle ve kendi veritabanınızla ölçün. Başkasının ortamında ölçülmüş benchmark sayıları, eski ya da yeni, sizin ortamınıza taşınmaz.
tekinali.db · salt okunur · ● bağlı

SQLite 3.53.3 · 8 tablo, 2 view.

Soldaki bir tabloya tıkla, aşağıdan bir sorgu seç ya da kendin yaz. Yardım: \?

↑↓ seç · Enter aç · Esc kapat