ef core’da sessiz performans katilleri ve çözümleri
n+1, erken ToList, gereksiz tracking, büyüyen join’ler ve derin sayfalama: sipariş ekranı üzerinden sorunun nedenini görün, sorguyu düzeltin ve ölçün.
geliştirme ortamında sipariş listesi anında açılır. birkaç ay sonra aynı ekran bekletmeye başlar; kodda exception yoktur ve sonuç hâlâ doğrudur. sorun çoğu zaman ef core’un bozulması değil, küçük veriyle fark edilmeyen işin büyümesidir. bu yazıda bir liste ekranının veritabanından ne istediğine bakacağız: kaç kez gidiyor, kaç satır taşıyor ve bunların ne kadarını gerçekten kullanıyor?
güncellendi:

bu yazıda
01önce yavaşlığı tarif edin
ekranımız 20 siparişin numarasını, müşteri adını ve satır sayısını gösteriyor. ürün açıklamaları, ödeme geçmişi ve tüm müşteri bilgileri bu ekranın ihtiyacı değil. bu küçük sözleşme, sorgudan neyi çıkarabileceğimizi belirler.
ölçüm için aynı veri kümesini ve aynı isteği kullanın. toplam endpoint süresini, gerçekleşen sql komut sayısını, dönen veri miktarını ve uygulamanın bellek tahsislerini kaydedin. tek seferlik kronometre sonucu yeterli değildir; ilk çalıştırmayı sonraki çalıştırmalardan ayırıp birden çok denemede medyanı ve p95’i karşılaştırın.
20 kayıtta hızlı olan sorguyu temsilî veri hacmiyle ve hedef veritabanı sağlayıcısıyla da deneyin. burada verilen sayılar, sorgu davranışını açıklayan örneklerdir; ölçülmüş hızlanma oranı değildir.
02n+1: küçük sorgunun çok kez çalışması
önce 20 siparişi getirip sonra döngü içinde her siparişin müşterisini ayrı sorguyla aradığınızı düşünün. başlangıç sorgusuyla birlikte 21 veritabanı gidişi oluşur. her sorgu hızlı olsa bile ağ beklemeleri birikir. aynı davranış lazy loading etkinse navigation property okunurken de gizlice oluşabilir; lazy loading ef core’da kendiliğinden açık değildir.
liste yalnızca müşteri adı ve satır sayısı istiyorsa bunları Select içinde seçin. sağlayıcının çevirebildiği bu ifade ilişkili bilgiyi sql tarafında hesaplatır; tüm ilişkili nesneleri yüklemeniz gerekmez. aşağıdaki sorguda Include kullanmıyoruz çünkü entity grafiği değil, ekranın alanlarını istiyoruz.
her döngü n+1 değildir. veri zaten yüklenmiş olabilir veya lazy loading kapalı olabilir. tanıyı kodun görünüşüyle değil, istek sırasında yürütülen komutlarla koyun.
03erken ToList ve gereğinden büyük sonuç
ToListAsync çağrısı sorguyu çalıştırır. bundan sonra liste üzerinde yazdığınız Where artık veritabanına filtre göndermez. ilk örnekte bütün siparişler belleğe alınır; yalnızca 20 tanesini göstermek bu maliyeti geri almaz.
ikinci örnekte filtre, sıralama ve alan seçimi sorgu çalışmadan önce tanımlanır. ct bir CancellationToken, afterId önceki sayfanın son sipariş numarası, db ise ShopDb örneğidir. aşağıdaki tam demo bu modeli içerir.
ToQueryString üretilen sql’i incelemek için yararlıdır; sorguyu yürütmez. gerçek komut sayısını gözlemlemek için komut loglarını veya interceptor kullanın. üretimde sensitive data logging açıp parametre değerlerini toplamayın; sorgu etiketi de kullanıcı verisi içermesin.
// Bad: load every order before filtering.
var all = await db.Orders.ToListAsync(ct);
var page = all.Where(o => o.Id > afterId).Take(20).ToList();
// Better: filter, order and project in SQL.
var query = db.Orders
.Where(o => o.Id > afterId)
.OrderBy(o => o.Id)
.Select(o => new
{
o.Id,
Customer = o.Customer.Name,
LineCount = o.Items.Count()
})
.Take(20);
var rows = await query.ToListAsync(ct);04değiştirmeyeceğiniz nesneyi neden takip ediyorsunuz?
entity döndüren sorgular varsayılan olarak değişiklik takibine katılır. yalnızca okunacak büyük bir entity listesinde AsNoTracking bu işin bir kısmını kaldırabilir. ama yukarıdaki örnek yalnızca skaler alanlar seçiyor; sonuçta entity bulunmadığı için zaten takip edilecek entity yok. buraya AsNoTracking ekleyip ayrıca hız kazandığınızı söylemeyin.
anonim nesnenin içine entity koyarsanız durum değişir: Select(o => new { Order = o }) yine entity içerir. ayrıca aynı entity’nin çok kez tekrarlandığı sonuçlarda identity resolution bellek davranışını etkiler. AsNoTrackingWithIdentityResolution ayrı bir seçenektir; her sorguda otomatik olarak daha ucuz değildir.
güncelleyeceğiniz bir entity’de tracking yararlıdır. bütün uygulamada düşünmeden kapatıp sonra SaveChanges neden değişikliği kaydetmiyor diye uğraşmak yerine okuma ve yazma ihtiyaçlarını sorgu düzeyinde ayırın.
05Include çoğaldığında satırlar da çarpılabilir
tek siparişte 10 kalem ve 3 ödeme olduğunu varsayın. aynı seviyedeki iki koleksiyonu tek sorguda join ile getirirseniz bu sipariş için 30 satırlık birleşim oluşabilir. ef core nesneleri birleştirse de veritabanı ve ağ bu satırları taşımıştır. “tek sorgu” her zaman “az iş” demek değildir.
ayrıntı ekranı gerçekten iki koleksiyona da ihtiyaç duyuyorsa AsSplitQuery seçeneğini değerlendirin. aşağıdaki örnek, kök sipariş ve iki koleksiyon için ayrı sorgular kullanır. bu, satır çarpımını azaltabilir fakat ek veritabanı gidişleri ve tamponlama maliyeti getirir.
ayrı sorgular arasında veri değişebilir. tutarlı bir anlık görüntü şartsa sağlayıcının desteklediği transaction izolasyonunu ve maliyetini değerlendirin. liste ekranının ihtiyacı yalnızca satır sayısıysa önce projeksiyonu tercih edin; büyük bir grafiği bölerek yüklemek hâlâ gereksiz iş olabilir.
// A detail screen that really needs both collections:
var order = await db.Orders
.AsNoTracking()
.Where(o => o.Id == orderId)
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.SingleOrDefaultAsync(ct);06derin sayfa ve indeks ihtiyacı
Skip(100000).Take(20), önceki 100 bin kaydın maliyetini sihirli biçimde silmez. sonraki sayfa akışında son gördüğünüz anahtarı taşıyan keyset sayfalama daha uygun olabilir. örnekte Id > afterId ve OrderBy(Id) birlikte kullanılır. id sırası oluşturulma zamanı sırası olmak zorunda değildir; burada bilinçli olarak numara sırasını seçiyoruz.
tarihe göre sıralarsanız yalnız tarih yeterli olmayabilir: aynı anda oluşturulan kayıtlar vardır. tarih ve benzersiz id’yi birlikte sıralayıp cursor’a ikisini de koyun. keyset, doğrudan 57. sayfaya atlama ihtiyacını tek başına karşılamaz.
filtre ve sıralamaya uygun indeks olup olmadığını yürütme planından inceleyin. her kolona indeks eklemek yazma maliyetini ve depolamayı artırır. müşteri bazlı filtre ve id sırası için bileşik indeks aday olabilir; karar gerçek planla verilir. bu örnek yetkilendirmeyi modellemez; gerçek sistemde kullanıcı veya tenant filtresi sayfalama öncesinde uygulanmalıdır.
07async yavaş sql’i hızlandırmaz
ToListAsync, veritabanı beklenirken uygulama thread’ini meşgul etmemeye yardımcı olur; kötü sorgunun yaptığı işi azaltmaz. .Result ve .Wait ile tekrar bloklamak bu yararı kaybettirebilir. token’ı veritabanına iletmek, terk edilen bir isteğin işini durdurmaya yardımcı olur; iptalin uygulanması sağlayıcıya bağlıdır.
aynı DbContext üzerinde Task.WhenAll ile iki sorguyu eşzamanlı başlatmayın. context eşzamanlı kullanıma uygun değildir. önce sorguları sırayla await edin; bağımsız context ile paralellik gerçekten gerekiyorsa bağlantı havuzu ve veritabanı yükünü ölçün. daha çok eşzamanlı iş her zaman daha yüksek kapasite değildir.
08yerelde çalıştırın ve sonucu görün
.net 10 sdk ile “dotnet new console -n EfQueryDemo” oluşturun. proje klasöründe “dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12” çalıştırın. bu sürüm, örneğin yeniden üretilebilir tabanıdır; kendi uygulamanızda desteklenen güncel yama ve paket politikanızı kullanın. program.cs dosyasını aşağıdaki kodla değiştirip “dotnet run” çalıştırın.
demo yerel ef-demo.db dosyasını oluşturur ve ilk çalıştırmada 100 sipariş ekler. temiz başlangıçta 1–20 arası siparişler, Ada müşteri adı ve her sipariş için 2 satır görmelisiniz. Tracked: 0 sonucu, sorgunun entity döndürmediğini gösterir. veritabanına yazılmış veriyi ayrıca değiştirmediğiniz sürece tekrar çalıştırmak yeni sipariş eklemez.
bu bir sqlite davranış demosudur; sql server veya postgresql performansını kanıtlamaz. EnsureCreated yalnızca bu küçük örneğin kurulumudur. gerçek şema değişikliklerinde migration ve dağıtım politikanızı kullanın.
using Microsoft.EntityFrameworkCore;
var options = new DbContextOptionsBuilder<ShopDb>()
.UseSqlite("Data Source=ef-demo.db")
.Options;
await using var db = new ShopDb(options);
// Demo-only initialization, not a production migration strategy.
await db.Database.EnsureCreatedAsync();
if (!await db.Customers.AnyAsync())
{
var customer = new Customer { Name = "Ada" };
for (var i = 0; i < 100; i++)
{
customer.Orders.Add(new Order
{
Items = [new OrderItem(), new OrderItem()]
});
}
db.Customers.Add(customer);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
}
var afterId = 0;
var query = db.Orders
.TagWith("article:order-list")
.Where(o => o.Id > afterId)
.OrderBy(o => o.Id)
.Select(o => new
{
o.Id,
Customer = o.Customer.Name,
LineCount = o.Items.Count()
})
.Take(20);
Console.WriteLine(query.ToQueryString());
var rows = await query.ToListAsync();
foreach (var row in rows)
Console.WriteLine($"{row.Id} | {row.Customer} | {row.LineCount}");
Console.WriteLine($"Tracked: {db.ChangeTracker.Entries().Count()}");
public sealed class ShopDb(DbContextOptions<ShopDb> options)
: DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<Payment> Payments => Set<Payment>();
}
public sealed class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Order> Orders { get; set; } = [];
}
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public List<OrderItem> Items { get; set; } = [];
public List<Payment> Payments { get; set; } = [];
}
public sealed class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
}
public sealed class Payment
{
public int Id { get; set; }
public int OrderId { get; set; }
}09iyileştirmeyi nasıl kanıtlarsınız?
önce iki sürümün aynı siparişleri, müşteri adlarını ve satır sayılarını döndürdüğünü doğrulayın. ardından aynı veriyle komut sayısını karşılaştırın. demo kurulumundaki şema ve seed komutlarını liste sorgusunun ölçümüne katmayın. bir sonraki sayfada afterId değerini 20 yapın; 21–40 aralığını bekleyin.
boş sonuç, son sayfa ve sayfa büyüklüğü sınırlarını da sınayın. ilişkisel sorgu davranışını doğrulamak için ef in-memory sağlayıcısını performans kanıtı olarak kullanmayın. hedef sağlayıcıda yürütme planını ve temsilî eşzamanlı yük altında p95 süresini yeniden ölçün.
son kayıtta veri hacmini, sağlayıcı ve paket sürümlerini, kod revizyonunu ve ölçüm koşullarını belirtin. daha az taşınan veri ve bellek tahsisi kaynak tüketimini azaltabilir; bundan ölçüm olmadan karbon tasarrufu yüzdesi çıkarmayın. işe yarayan değişiklik, aynı doğru sonucu daha az gereksiz işle üreten ve bunu ölçümle gösterebildiğiniz değişikliktir.