Entity Framework Core Performans Rehberi: Yavaş Sorgulara Son Veren 12 Teknik
EF Core uygulamalarında yavaşlığın gerçek sebeplerini bulmayı ve N+1 problemi, AsNoTracking, projeksiyon, toplu güncelleme, indeks ve sorgu bölme gibi tekniklerle sorguları hızlandırmayı örneklerle anlatıyoruz.
Entity Framework Core, veritabanıyla C# nesneleri üzerinden çalışmayı inanılmaz kolaylaştırır. Ama bu kolaylığın bir bedeli olabilir: birkaç satırlık masum görünen LINQ kodu, arka planda yüzlerce SQL sorgusuna dönüşebilir. "EF Core yavaş" cümlesini çok duyarız; gerçekte ise yavaş olan çoğu zaman EF Core değil, onu nasıl kullandığımızdır. Bu rehberde, gerçek projelerde en çok fark yaratan teknikleri önem sırasına göre ele alıyoruz.
Önce ölçün: hangi sorgu yavaş?
Performans iyileştirmesinin ilk kuralı tahmin etmek değil ölçmektir. EF Core'un ürettiği SQL'i görmeden optimizasyon yapmak karanlıkta ok atmaya benzer. Geliştirme ortamında SQL loglarını açmak için:
builder.Services.AddDbContext<AppDbContext>(o => o
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()); // sadece geliştirmede!
Loglarda aynı sorgunun art arda onlarca kez çalıştığını görüyorsanız büyük ihtimalle bir N+1 problemi vardır. Tek bir sorgu çok uzun sürüyorsa sorun indeks veya gereksiz veri çekme olabilir. Production için ise Application Insights, OpenTelemetry veya veritabanının kendi sorgu istatistikleri (SQL Server Query Store gibi) en güvenilir kaynaklardır.
Üretilen SQL'i görmeden yapılan optimizasyon, karanlıkta ok atmaya benzer.
1. N+1 problemini ortadan kaldırın
En yaygın ve en pahalı hata budur. Siparişleri listeleyip her birinin müşterisine erişen şu koda bakın:
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
Console.WriteLine(order.Customer.Name); // lazy loading açıksa her döngüde yeni sorgu!
100 sipariş için 1 + 100 = 101 sorgu çalışır. Çözüm, ilişkili veriyi tek seferde yüklemek ya da daha iyisi sadece ihtiyacınız olanı seçmektir:
var orders = await db.Orders
.Include(o => o.Customer)
.ToListAsync();
2. Sadece ihtiyacınız olan kolonları seçin (projeksiyon)
Include N+1'i çözer ama tüm kolonları getirir. Bir liste ekranında sadece birkaç alana ihtiyacınız varsa Select ile projeksiyon yapın:
var list = await db.Orders
.Select(o => new OrderListItem(o.Id, o.Customer.Name, o.Total, o.CreatedAt))
.ToListAsync();
Bu sorgu hem JOIN'i otomatik yapar hem de ağ üzerinden çok daha az veri taşır. Liste ekranlarında en büyük kazanç genellikle buradan gelir.
3. Okuma sorgularında AsNoTracking kullanın
EF Core varsayılan olarak çektiği her entity'yi change tracker'a kaydeder; böylece değişiklikleri SaveChanges ile yazabilir. Sadece okuyup ekrana basacağınız veride bu takip boşuna bellek ve CPU harcar:
var products = await db.Products.AsNoTracking().Where(p => p.IsActive).ToListAsync();
Uygulamanızın büyük kısmı okuma ağırlıklıysa, DbContext seviyesinde varsayılanı değiştirmeyi de düşünebilirsiniz: optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking).
4. Filtreyi veritabanında yapın
ToListAsync() çağrıldığı anda sorgu çalışır ve sonrasındaki her şey bellekte olur. Aşağıdaki kod tüm tabloyu çekip C# tarafında filtreler:
// Kötü: tüm ürünler belleğe gelir
var cheap = (await db.Products.ToListAsync()).Where(p => p.Price < 100);
// İyi: WHERE koşulu SQL'e çevrilir
var cheap = await db.Products.Where(p => p.Price < 100).ToListAsync();
Kural basit: Where, OrderBy, Select, Skip, Take gibi işlemler materialize etmeden önce gelmeli.
5. Doğru indeksleri tanımlayın
En iyi yazılmış LINQ sorgusu bile indeks olmayan bir kolonda yavaş çalışır. Sık filtrelediğiniz, sıraladığınız veya JOIN yaptığınız kolonlara indeks ekleyin:
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt });
modelBuilder.Entity<Product>()
.HasIndex(p => p.Slug).IsUnique();
Bileşik indekslerde kolon sırası önemlidir: önce eşitlikle filtrelenen (CustomerId), sonra aralıkla filtrelenen veya sıralanan (CreatedAt) kolon gelmelidir. Ama her kolona indeks koymayın; her indeks yazma işlemlerini yavaşlatır ve disk kaplar.
6. Toplu güncelleme ve silmede ExecuteUpdate / ExecuteDelete
EF Core 7 ile gelen bu metotlar, entity'leri belleğe çekmeden doğrudan tek bir SQL komutu çalıştırır:
// 10.000 ürünü belleğe çekmeden fiyat güncelle
await db.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.10m));
await db.Logs.Where(l => l.CreatedAt < DateTime.UtcNow.AddMonths(-6)).ExecuteDeleteAsync();
Eski yöntemle aynı iş binlerce UPDATE cümlesi ve ciddi bellek tüketimi demekti.
7. Kartezyen patlamaya karşı AsSplitQuery
Bir entity'ye birden fazla koleksiyonu Include ettiğinizde, tek SQL sorgusu satırları çarpar. 10 yorumu ve 10 görseli olan bir ürün 100 satır olarak döner. Bu durumda sorguyu bölmek daha verimlidir:
var product = await db.Products
.Include(p => p.Images)
.Include(p => p.Reviews)
.AsSplitQuery()
.FirstAsync(p => p.Id == id);
Bölünmüş sorgular birden fazla tur (round trip) demektir; bu yüzden sadece birden çok koleksiyon yüklediğinizde tercih edin.
Yapılan her iyileştirmenin etkisini ölçmeden "hızlandı" demek, tahminden öteye geçmez.
8. Sayfalama yapın, büyük sonuçları akıtın
Liste ekranlarında Skip/Take ile sayfalama yapın. Raporlama gibi çok büyük veri setlerinde ise AsAsyncEnumerable() ile satırları tek tek işleyerek tüm sonucu belleğe almaktan kaçının:
await foreach (var row in db.Orders.AsNoTracking().AsAsyncEnumerable())
await writer.WriteLineAsync($"{row.Id};{row.Total}");
9. Sık çalışan sorgular için compiled query
Aynı sorgu saniyede yüzlerce kez çalışıyorsa, LINQ ifadesinin her seferinde SQL'e çevrilme maliyetinden kurtulmak için derlenmiş sorgu kullanabilirsiniz:
private static readonly Func<AppDbContext, string, Task<Product?>> BySlug =
EF.CompileAsyncQuery((AppDbContext db, string slug) =>
db.Products.AsNoTracking().FirstOrDefault(p => p.Slug == slug));
Bu teknik her sorgu için gerekli değildir; ölçüm sonucunda sıcak (hot) yol olarak öne çıkan sorgularda kullanın.
10. DbContext pooling
Yüksek trafikli uygulamalarda her istek için yeni bir DbContext oluşturmanın küçük bir maliyeti vardır. AddDbContextPool ile context örnekleri havuzda tutulup yeniden kullanılır:
builder.Services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(connectionString));
Dikkat: havuzlanan context'lerde constructor'a istek bazlı durum (ör. kullanıcı id) enjekte etmeyin.
11. SaveChanges çağrılarını toplayın
Döngü içinde her eklemede SaveChangesAsync çağırmak, her seferinde ayrı bir veritabanı turu demektir. Değişiklikleri biriktirip tek seferde kaydedin; EF Core bunları mümkün olduğunca gruplayarak (batching) gönderir.
foreach (var item in items) db.OrderItems.Add(item);
await db.SaveChangesAsync(); // tek tur
12. Gerektiğinde ham SQL'den korkmayın
Çok karmaşık raporlar, pencere fonksiyonları veya veritabanına özgü özellikler için EF Core'un FromSql ve SqlQuery metotlarıyla ham SQL çalıştırabilirsiniz. Parametreler interpolasyonla otomatik olarak güvenli hale getirilir:
var top = await db.Database
.SqlQuery<TopProduct>($"SELECT TOP 10 ProductId, SUM(Quantity) AS Sold FROM OrderItems WHERE CreatedAt >= {from} GROUP BY ProductId ORDER BY Sold DESC")
.ToListAsync();
ORM kullanmak, her şeyi ORM ile yapmak zorunda olduğunuz anlamına gelmez.
Hızlı kontrol listesi
| Belirti | Muhtemel sebep | Çözüm |
|---|---|---|
| Aynı sorgu onlarca kez çalışıyor | N+1 | Include veya Select |
| Liste ekranı yavaş, bellek yüksek | Gereksiz kolon ve tracking | Projeksiyon + AsNoTracking |
| Tek sorgu saniyeler sürüyor | İndeks eksik | Uygun indeks |
| Include sonrası binlerce satır | Kartezyen patlama | AsSplitQuery |
| Toplu güncelleme çok yavaş | Entity'leri belleğe çekmek | ExecuteUpdate |
Sonuç
EF Core'da performans çoğunlukla birkaç alışkanlığa bağlıdır: üretilen SQL'i görmek, sadece gereken veriyi çekmek, filtreyi veritabanına bırakmak ve doğru indeksleri tanımlamak. Bunları uyguladığınızda EF Core'un sağladığı üretkenlikten vazgeçmeden, elle yazılmış SQL'e çok yakın performans elde edersiniz. En önemlisi: her değişiklikten önce ve sonra ölçün.
Sık sorulan sorular
Lazy loading kullanmalı mıyım?
Genellikle hayır. Lazy loading, fark etmeden N+1 problemine yol açmanın en kolay yoludur. İlişkili veriyi Include veya projeksiyonla açıkça yüklemek davranışı öngörülebilir kılar.
Dapper mı, EF Core mu?
İkisi rakip değil, tamamlayıcıdır. Uygulamanın büyük kısmı için EF Core üretkenlik sağlar; çok özel ve sıcak raporlama sorgularında Dapper veya ham SQL tercih edilebilir.
AsNoTracking ile çektiğim veriyi güncelleyebilir miyim?
Evet, ama önce db.Update(entity) ile context'e bağlamanız gerekir. Güncelleme yapacağınız senaryolarda tracking'li sorgu kullanmak genellikle daha az hataya açıktır.