Entity Framework Core'da N+1 Problemi ve Çözümleri
EF Core sorgularinda N+1 problemi nasil olusur, Include/Select/projection ile nasil cozulur.
Bir sipariş listesi her satırda sipariş numarasını ve ürün kalemi adedini gösteriyor. Geliştirmede birkaç kayıtla hızlı açılan ekran, 50 kayıtla her istek için 51 SQL komutu çalıştırabiliyor. İlk komut siparişleri alıyor, sonraki 50 komut sipariş başına bir koleksiyon yüklüyor. N+1 denilen bu davranışı anlamak için tek bir isteğin SQL günlüğüne ve ekranda gerçekten kullanılan alanlara bakmak yeterli bir başlangıç.
N+1’i SQL günlüğünde bulun
Aşağıdaki örnekte lazy loading açık ve Items koleksiyonları henüz yüklenmemiş. Siparişler ToListAsync ile belleğe alındıktan sonra Select çalışıyor. Bu sırada order.Items erişimi koleksiyonu getiriyor. Ekranın kullandığı tek bilgi adet olsa da ilgili kalemler yükleniyor. Döngüde her sipariş için ayrı bir sorgu çalıştırırsanız aynı desen lazy loading kapalıyken de oluşabilir.
var orders = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.ThenByDescending(o => o.Id)
.Take(50)
.ToListAsync(ct);
var rows = orders
.Select(o => new OrderRow(o.Id, o.Number, o.Items.Count))
.ToList();
ASP.NET Core uygulamasında geliştirme ortamı için Microsoft.EntityFrameworkCore.Database.Command günlük kategorisini Information seviyesinde açın. Tek isteği çalıştırıp Executed DbCommand kayıtlarını sayın. Sipariş sorgusunu aynı SQL kalıbıyla farklı OrderId değerlerini okuyan komutlar izliyorsa N+1’i yakalamışsınız demektir. İstek sonunda JSON serileştirmesi de gezinme özelliklerine erişebilir. Bu yüzden yalnızca LINQ sorgusunu incelemek yerine tüm isteğin günlüğüne bakın.
Liste için projeksiyon kullanın
Ekran sipariş kalemlerinin kendisini kullanmıyorsa sorgu sonucunu doğrudan OrderRow gibi bir DTO’ya dönüştürün. Select, ToListAsync çağrısından önce zincire girince ilişkisel sağlayıcı sayımı SQL tarafına çevirebilir. Böylece listeye yalnızca kimlik, numara ve adet taşınır. Bu örnekte adet ürün kalemi sayısıdır. Bir kalemdeki miktar da hesaba katılacaksa ekranın beklediği toplamı sorguya ayrıca yansıtın.
var rows = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.ThenByDescending(o => o.Id)
.Take(50)
.Select(o => new OrderRow(o.Id, o.Number, o.Items.Count()))
.ToListAsync(ct);
SQL Server gibi ilişkisel sağlayıcılarda bu basit projeksiyon tek SQL komutuyla çalışır. Uygulamanızdaki sorguyu ToQueryString ile inceleyebilir, gerçekleşen komut sayısını günlükten doğrulayabilirsiniz. CreatedAt aynı olan siparişlerin sırasını Id ile belirlemek, 50 satırlık seçimi kararlı hale getirir. Bu örnekte sorgu veritabanında hâlâ sipariş kalemlerini sayar. Kalem tablosu büyüdüğünde sorgu planını ve OrderId için kullanılabilir indeksi kontrol edin.
Koleksiyon gerekirse yükleyin
Sipariş düzenleme ekranı kalemleri gerçekten kullanıyorsa Include ile ilişkiyi baştan yükleyin. Aynı sipariş için hem Items hem Payments gibi iki koleksiyon yüklendiğinde tek sorgudaki JOIN’ler satırları çoğaltabilir. Örneğin on kalem ve beş ödeme, bir sipariş için 50 sonuç satırı üretebilir. Bu durumda aynı veri kümesinde AsSingleQuery ve AsSplitQuery davranışlarını karşılaştırın.
var order = await db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.SingleOrDefaultAsync(o => o.Id == orderId, ct);
AsSplitQuery bu örnekte ana sorguya her koleksiyon için bir sorgu ekler. Bu sayı sipariş başına büyümez. Karşılığında veritabanına birden fazla gidiş dönüş yapılır ve arada başka bir işlem veri yazarsa sonuçlar farklı anları yansıtabilir. Tek bir siparişin kalemlerini yalnızca belirli bir kullanıcı eyleminden sonra açıyorsanız, o eylem için ayrı bir sorgu da makul bir seçenektir.
Düzeltmeyi ölçün
Aynı veriyle önce ve sonra 5 ve 50 sipariş döndüren istekleri çalıştırın. Sipariş sırasını, kalem adetlerini ve kalemi olmayan siparişlerde sıfır sonucunu karşılaştırın. Ardından SQL komut sayısını ve uçtan uca gecikmeyi ölçün. Projeksiyonlu liste için beklenti, sayfa büyüse bile listeyi oluşturan sorgunun bir komut olarak kalmasıdır. Her isteği yeni bir DbContext ile çalıştırın. Önceden yüklenmiş gezinme özellikleri, sorunlu sürümde beklediğiniz ek sorguları gizleyebilir. Birden çok koleksiyon yükleyen ekranda taşınan satır sayısını ve gecikmeyi de izleyin. Komut sayısı tek başına hangi seçeneğin daha hızlı olacağını göstermez.