Kıvanç Ertürk
Blog Claude Code ile yapıldı EN

Tüm yazılar · Geliştirme

EF Core tuzakları

EF Core tuzakları

EF Core, veritabanı kodunu C# gibi yazdırıyor. async/await’te olduğu gibi sorun da burada: kod bir koleksiyon üzerinde çalışıyormuş gibi görünüyor ama her satırın arkasında bir SQL sorgusu var. Geliştirme ortamında yüz satırlık tabloyla hızlı çalışan kod, canlıda yüz binlerce satırla saniyeler sürüyor.

Bu yazıda code review’larda en sık gördüğüm EF Core hatalarını topladım. Her birinde sorun, belirtisi ve düzeltmesi var.

Önce görmeyi öğren

Aşağıdaki hataların hepsi, üretilen SQL’e bakınca birkaç saniyede görülüyor. Bu yüzden ilk adım SQL’i görünür yapmak.

options.UseSqlServer(connectionString)
       .LogTo(Console.WriteLine, LogLevel.Information);

Tek bir sorgunun SQL’ini görmek için ToQueryString() yeterli. TagWith("...") ise sorgunun başına bir yorum ekliyor, böylece veritabanı tarafındaki yavaş sorgu listesinde sorgunun koddaki yerini bulmak kolaylaşıyor.

ASP.NET Core’da LogTo’ya gerek yok, EF Core uygulamanın kendi logging altyapısını kullanıyor. appsettings.json’da Microsoft.EntityFrameworkCore.Database.Command kategorisini Information seviyesine çekmek yeterli.

EnableSensitiveDataLogging() parametre değerlerini de loglar. Sadece geliştirme ortamında aç.

N+1 sorgu

var orders = await _db.Orders.ToListAsync(ct);
foreach (var order in orders)
{
    var customer = await _db.Customers.FindAsync([order.CustomerId], ct);
    // ...
}

Bir sorgu siparişleri getiriyor, sonra her sipariş için bir sorgu daha çalışıyor. Yüz sipariş, en kötü durumda yüz bir sorgu demek. Lazy loading açıksa aynı şey görünmeden oluyor: her siparişte order.Customer’a ilk erişim ayrı bir sorgu.

Belirti: Log’da aynı sorgunun farklı parametrelerle art arda tekrar etmesi.

Düzeltme: İlişkili veriyi tek sorguda al. Sadece birkaç alan gerekiyorsa Select ile projeksiyon en iyisi:

var rows = await _db.Orders
    .Select(o => new { o.Id, o.Total, CustomerName = o.Customer.Name })
    .ToListAsync(ct);

Bütün entity gerekiyorsa Include(o => o.Customer) kullan. Lazy loading’i açmadan önce iki kez düşün. N+1’i kodu okurken görünmez yapıyor.

Gereksiz change tracking

EF Core varsayılan olarak okuduğu her entity’yi takip ediyor: bir kopyasını tutuyor ve SaveChanges’te değişip değişmediğine bakıyor. Sadece okuyup ekrana gönderdiğin veri için bu boşa bellek ve CPU harcıyor.

Düzeltme: Salt okunur sorgularda AsNoTracking() kullan.

var products = await _db.Products
    .AsNoTracking()
    .Where(p => p.IsActive)
    .ToListAsync(ct);

Çoğu sorgu salt okunursa varsayılanı tersine çevir, güncelleme yapacağın yerde AsTracking() de:

options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

DTO’ya ya da anonim tipe yapılan projeksiyonlar, içlerinde entity yoksa zaten takip edilmiyor. new { Order = o, o.Total } gibi entity içeren bir projeksiyonda ise o entity takip ediliyor.

Gerekenden fazla kolon

var names = (await _db.Products.ToListAsync(ct)).Select(p => p.Name);

Sadece isimler gerekiyor ama tablonun bütün kolonları, uzun açıklama ve görsel alanları dahil, veritabanından çekiliyor.

Düzeltme: Select’i sorgunun içine, ToListAsync’ten önce koy. EF Core sadece seçilen kolonları getiren bir SQL üretir.

var names = await _db.Products.Select(p => p.Name).ToListAsync(ct);

Aynı hata navigation’larda da oluyor: sadece sayıyı göstermek için Include(b => b.Posts) yapıp b.Posts.Count okumak bütün yazıları çekiyor. Select(b => new { b.Name, PostCount = b.Posts.Count() }) ise SQL’de bir COUNT üretiyor.

Veriyi belleğe çekip filtrelemek

var all = await _db.Orders.ToListAsync(ct);
var recent = all.Where(o => o.CreatedAt > since).ToList();

Filtre ToListAsync’ten sonra geldiği için bütün tablo belleğe çekiliyor, filtre C#‘ta çalışıyor. Aynı şey daha sinsi bir şekilde de oluyor: bir repository metodu IQueryable<T> yerine IEnumerable<T> döndürüyorsa, üstüne eklenen her Where bellekte çalışır.

public IEnumerable<Order> GetOrders() => _db.Orders;

// Bütün tablo çekilir, filtre bellekte çalışır
var recent = _repo.GetOrders().Where(o => o.CreatedAt > since).ToList();

Düzeltme: Filtreleme, sıralama ve sayfalamayı sorgu materyalize edilmeden önce yap. Repository’ler ya IQueryable<T> döndürsün, ya da filtreyi parametre olarak alıp sonucu kendisi üretsin.

EF Core 3.0’dan beri SQL’e çevrilemeyen bir Where sessizce bellekte çalışmıyor, hata fırlatıyor. Bu iyi bir şey. Tek istisna en dıştaki Select: oradaki çevrilemeyen metot çağrıları, veri geldikten sonra bellekte çalışabiliyor. Hatayı AsEnumerable() ya da ToList() ekleyerek “çözmek” ise sorunu geri getiriyor.

Count ile varlık kontrolü

if (await _db.Orders.CountAsync(o => o.CustomerId == id, ct) > 0) { }

Veritabanı eşleşen bütün satırları saymak zorunda kalıyor. Oysa soru sadece “en az bir tane var mı?”.

Düzeltme: AnyAsync kullan. İlk eşleşmede durur.

if (await _db.Orders.AnyAsync(o => o.CustomerId == id, ct)) { }

Birden fazla koleksiyon Include

var blogs = await _db.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToListAsync(ct);

EF Core varsayılan olarak bunu JOIN’li tek bir sorguya çeviriyor. 10 yazısı ve 10 katkıcısı olan bir blog için sonuç 100 satır dönüyor, her satırda blog kolonları tekrar ediyor. Koleksiyonlar büyüdükçe satır sayısı çarpılarak artıyor. Buna kartezyen patlama deniyor. Split davranışını ayarlamadıysan EF Core birden fazla koleksiyon yükleyen sorgular için log’a bir uyarı da yazıyor.

Düzeltme: AsSplitQuery() ile her koleksiyonu ayrı bir sorguda getir.

var blogs = await _db.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .AsSplitQuery()
    .ToListAsync(ct);

Split query’yi varsayılan yapmak için options.UseSqlServer(cs, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)) yeterli. Tek sorgu istediğin yerde AsSingleQuery() de.

Split query’nin de bedeli var. Birden fazla sorgu, veritabanına birden fazla gidiş demek. Sorgular arasında veri değişirse sonuçlar birbiriyle tutarsız olabilir. Bunu önlemek için snapshot ya da serializable bir transaction gerekiyor. Referans navigation’lar (Include(o => o.Customer)) satır çoğaltmıyor, sorun sadece koleksiyonlarda. Çoğu zaman en iyi çözüm yine sadece gereken alanları Select ile almak.

Sıralamasız sayfalama

var page = await _db.Products.Skip(40).Take(20).ToListAsync(ct);

ORDER BY olmadan veritabanı satırları hangi sırada döneceğini garanti etmiyor. Aynı sayfa iki istekte farklı ürünler gösterebilir, bazı ürünler hiç görünmeyebilir. EF Core bunun için de bir uyarı logluyor.

Düzeltme: Sayfalamada her zaman benzersiz bir sıralama kullan. Sıralama kolonu benzersiz değilse sona Id ekle.

var page = await _db.Products
    .OrderBy(p => p.Name).ThenBy(p => p.Id)
    .Skip(40).Take(20)
    .ToListAsync(ct);

Derin sayfalarda Skip yavaşlıyor, çünkü veritabanı atlanan bütün satırları yine de okuyor. Büyük listelerde son görülen değerle devam eden keyset pagination çok daha hızlı:

var page = await _db.Products
    .Where(p => p.Id > lastId)
    .OrderBy(p => p.Id)
    .Take(20)
    .ToListAsync(ct);

Sıralama birden fazla kolondan oluşuyorsa koşul da o kolonları birlikte karşılaştırmalı: string.Compare(p.Name, lastName) > 0 || (p.Name == lastName && p.Id > lastId).

Döngüde SaveChanges

foreach (var item in items)
{
    _db.Items.Add(item);
    await _db.SaveChangesAsync(ct);
}

Her SaveChanges ayrı bir veritabanı gidişi ve ayrı bir transaction. Bin kayıt, bin gidiş.

Düzeltme: Hepsini ekle, bir kez kaydet. EF Core komutları kendisi gruplayıp daha az gidişte gönderiyor.

_db.Items.AddRange(items);
await _db.SaveChangesAsync(ct);

Toplu güncellemeyi entity’lerle yapmak

var expired = await _db.Sessions
    .Where(s => s.ExpiresAt < now)
    .ToListAsync(ct);
_db.Sessions.RemoveRange(expired);
await _db.SaveChangesAsync(ct);

Silinecek her satır önce belleğe çekiliyor, takip ediliyor, sonra her biri için ayrı bir DELETE komutu gönderiliyor.

Düzeltme: EF Core 7’den beri ExecuteDeleteAsync ve ExecuteUpdateAsync tek bir SQL komutu çalıştırıyor, hiçbir şeyi belleğe çekmiyor.

await _db.Sessions
    .Where(s => s.ExpiresAt < now)
    .ExecuteDeleteAsync(ct);

await _db.Products
    .Where(p => p.CategoryId == categoryId)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m), ct);

Bu komutlar change tracker’ı atlıyor. Context’te zaten takip edilen entity’ler güncellenmiyor ve SaveChanges’e bağlı interceptor’lar ya da audit mantığı çalışmıyor. Bunlara ihtiyacın varsa bunu hesaba kat.

Ayrıca komut SaveChanges’i beklemeden hemen çalışıyor. Bekleyen diğer değişikliklerle aynı transaction’da olması gerekiyorsa transaction’ı BeginTransactionAsync ile kendin aç.

Index kullanamayan sorgular

var user = await _db.Users
    .FirstOrDefaultAsync(u => u.Email.ToLower() == email.ToLower(), ct);

ToLower(), SQL’de kolona uygulanan bir LOWER() fonksiyonuna dönüşüyor. Kolona fonksiyon uygulanınca veritabanı o kolondaki index’te arama (seek) yapamıyor ve bütün satırları tek tek tarıyor. Contains de benzer: LIKE '%değer%' gibi başında joker karakter olan bir arama da index’ten faydalanamıyor.

Düzeltme: Karşılaştırmayı veritabanının kendisine bırak. SQL Server’da varsayılan collation genellikle zaten büyük/küçük harf duyarsız (_CI_), düz u.Email == email yeterli. PostgreSQL varsayılan olarak duyarlı. Orada büyük/küçük harf duyarsız bir collation ya da citext tipi kullan. Değeri kaydederken normalize edip ayrı bir kolonda aramak da bir seçenek (ASP.NET Core Identity’deki NormalizedEmail gibi). Metin içinde arama gerçekten gerekiyorsa full-text search kullan.

Ham SQL ve SQL injection

// Yapma
var users = await _db.Users
    .FromSqlRaw($"SELECT * FROM Users WHERE Name = '{name}'")
    .ToListAsync(ct);

FromSqlRaw düz bir string alıyor. $"..." önce normal bir string’e dönüşüyor, yani değer doğrudan SQL metnine yapıştırılıyor. Bu klasik bir SQL injection açığı. EF Core 8’den beri gelen analyzer bunun için EF1002 uyarısı veriyor, bu uyarıyı susturma.

Düzeltme: EF Core 7’den beri gelen FromSql kullan. FormattableString aldığı için interpolation’daki her değeri bir SQL parametresine çeviriyor:

var users = await _db.Users
    .FromSql($"SELECT * FROM Users WHERE Name = {name}")
    .ToListAsync(ct);

FromSqlRaw sadece SQL metni gerçekten dinamik olduğunda gerekli. O durumda da değerleri metne gömme, FromSqlRaw("... WHERE Name = {0}", name) gibi parametre olarak ayrıca ver.

Uzun ömürlü DbContext

DbContext kısa ömürlü bir iş birimi olarak tasarlandı. Singleton olarak kaydedilen ya da uzun süre yaşayan bir context, takip ettiği entity’leri biriktiriyor. Bellek büyüyor, SaveChanges yavaşlıyor ve eski veri dönmeye başlıyor. Üstelik DbContext thread-safe değil. Aynı context üzerinde Task.WhenAll ile paralel sorgu çalıştırmak hata fırlatıyor.

Düzeltme: Web uygulamasında AddDbContext ile kaydet, her istek kendi context’ini alsın. Arka plan servisleri singleton olduğu için scoped context’i doğrudan alamaz. Orada ve paralel işlerde AddDbContextFactory ile kaydedip IDbContextFactory<TContext> üzerinden her iş için kısa ömürlü bir context oluştur, işi bitince dispose et.

Kısa kontrol listesi

  1. Geliştirmede üretilen SQL görünür.
  2. Döngü içinde sorgu yok. İlişkili veri Select ya da Include ile tek sorguda.
  3. Salt okunur sorgular AsNoTracking.
  4. Sadece gereken kolonlar Select ile.
  5. Filtre ve sayfalama materyalizasyondan önce. Repository IEnumerable döndürmüyor.
  6. Varlık kontrolü AnyAsync ile.
  7. Birden fazla koleksiyon için AsSplitQuery ya da projeksiyon.
  8. Sayfalamada benzersiz sıralama. Büyük listelerde keyset pagination.
  9. SaveChanges döngü dışında.
  10. Toplu işlemler ExecuteUpdate ve ExecuteDelete ile.
  11. Kolonlara fonksiyon uygulanmıyor, index kullanılabiliyor.
  12. Ham SQL’de FromSql, asla birleştirilmiş string yok.
  13. DbContext kısa ömürlü. Arka planda IDbContextFactory.