Bir müşteri listesini tek sorguyla çekip, ardından her müşterinin siparişlerini döngü içinde sorgulayan bir kod düşünün. Kod incelemesinden rahatça geçer; geliştirici makinesindeki on satırla anında çalışır. Production’da on bin satırla karşılaşınca sayfa saniyelerce bekler, veritabanı da aynı soruyu farklı bir id ile on bin kez cevaplar.
Bu yazıda bu sorunu bulmak için yazdığım aracı anlatacağım: N+1. .NET ve EF Core için ücretsiz, MIT lisanslı, üç NuGet paketinden oluşan bir araç seti. Bir Roslyn analyzer’ı sorunu derleme anında buluyor; bir runtime guard ise testlerde ve HTTP isteklerinde EF Core’un gerçekten gönderdiği SQL’e bakıyor.
Önceki yazılarda olduğu gibi burada da iddiaları ölçümle destekleyeceğim. Yazıdaki her sayı repodaki testlerden ve gerçek projeler üzerinde yaptığım taramalardan geliyor. Kodun tamamı GitHub’da: selcukgural/NPlusOne.
N+1 tam olarak nedir?
var customers = await db.Customers.ToListAsync(); // 1 sorgu
foreach (var customer in customers)
{
// + müşteri başına 1 sorgu
var orders = await db.Orders
.Where(o => o.CustomerId == customer.Id)
.ToListAsync();
}Tek tek bakınca her sorgu hızlı. Sorun sorgunun kendisinde değil, sayısında: liste büyüdükçe round trip sayısı da büyüyor. Bir ya da iki sorguyla bitecek iş N+1 sorguya dönüşüyor.
Kolay olanı foreach, zor olanlar başka yerlerde
Yukarıdaki örneği iyi bir kod incelemesi yakalar. Gerçek projelerde karşılaştığım N+1’lerin çoğu ise böyle görünmüyordu:
- Bir
Selectiçinde okunan, lazy loading ile yüklenen bir navigation property. Kodda sorgu yok, ama her satır için bir SQL gidiyor. - Döngüden çağrılan bir yardımcı metodun, onun da çağırdığı bir repository metodunun içindeki sorgu. Döngü bir dosyada, sorgu üç çağrı aşağıda başka bir dosyada.
- Her eleman için gönderilen bir MediatR isteği. Handler’ı açmadan sorguyu göremezsiniz.
- Bir AutoMapper profili.
CustomerNamegibi bir hedef property, isim kuralıylaCustomer.Name‘i okuyor ve her nesne için müşteriyi lazy load ediyor. - Bir endpoint’in döndürdüğü entity’ler. JSON serializer her entity’nin navigation’larını gezerken lazy loading onları teker teker yüklüyor.
Bunların ortak noktası, sorgu ile onu tekrarlatan döngünün kodda yan yana durmaması. Bu yüzden ne gözle ne de basit bir metin aramasıyla bulunabiliyorlar.
Neden iki katman?
Statik analiz kodun tamamını görür, ama çalışma zamanında verilen kararları göremez: başka bir assembly’deki interface implementasyonu, reflection, konfigürasyona göre seçilen bir servis. Runtime kontrolü ise olanı kesin olarak görür, ama yalnızca testlerin ve isteklerin çalıştırdığı kodu. Biri diğerinin kör noktasını kapatıyor; N+1’i bu yüzden iki katman olarak tasarladım:
| Paket | Ne zaman yakalar | Nereye eklenir |
|---|---|---|
| NPlusOne.Analyzers | Derleme anında, IDE’de ve CI’da | EF Core kodu olan her proje ve onu çağıran projeler |
| NPlusOne.EntityFrameworkCore | Testlerde ya da scope içine aldığınız herhangi bir kodda | Test projeleri ve DbContext‘i yapılandıran proje |
| NPlusOne.AspNetCore | HTTP isteklerinde, istek başına bir scope | ASP.NET Core uygulaması |
Analyzer ile guard birbirine bağımlı değil; ikisinden birini tek başına ya da ikisini birlikte kullanabilirsiniz.
Katman 1: Derleme anında analyzer
dotnet add package NPlusOne.AnalyzersPaket bir development dependency: compiler’ın içinde çalışıyor, uygulamanızın çıktısına tek bir byte eklemiyor. EF Core 8, 9 ve 10 ile, .NET 8’den bu yana her SDK’da çalışıyor. Kurduktan sonra dotnet build yeterli; ispatlayabildiği her N+1 build çıktısında ve IDE’de uyarı olarak çıkıyor:
OrderService.cs(42,31): warning NP0001:
'IOrderRepository.GetByCustomerAsync' runs a query
('ToListAsync' in OrderRepository.GetByCustomerAsync)
once per element of the foreach loop over 'customers'.
Load the rows for all elements up front with one query,
Where(o => keys.Contains(o.CustomerId)),
and group them with ToLookup(o => o.CustomerId).Uyarı üç şeyi söylüyor: hangi round trip’in tekrarlandığını (ToListAsync), onu hangi döngünün tekrarlattığını ve aradaki çağrı zincirini. Ardından onu kaldıracak yeniden yazımı öneriyor. Bu örnekte düzeltme, siparişleri tek sorguyla çekip bellekte gruplamak:
var customers = await db.Customers.ToListAsync();
var ids = customers.Select(c => c.Id).ToList();
var ordersByCustomer = (await db.Orders.Where(o => ids.Contains(o.CustomerId)).ToListAsync())
.ToLookup(o => o.CustomerId);
foreach (var customer in customers)
{
var orders = ordersByCustomer[customer.Id];
}İki kural için IDE’de code fix de var: NP0002 eksik Include‘u ekliyor, NP0003 de SaveChanges‘i döngünün dışına taşıyor.
Kurallar
| Kural | Ne yakalar | Varsayılan |
|---|---|---|
| NP0001 | Bir sorgu her eleman için bir kez çalışıyor | Warning |
| NP0002 | Lazy load edilen bir navigation her eleman için okunuyor | Warning |
| NP0003 | SaveChanges her eleman için çalışıyor | Warning |
| NP0004 | Recursive bir metot her çağrıda sorgu atıyor | Warning |
| NP0005 | Bir endpoint, lazy navigation’larını serializer’ın tek tek yükleyeceği entity’ler döndürüyor | Warning |
| NP0006 | Implementasyonu görünmeyen bir veri erişim soyutlaması her eleman için çağrılıyor | Info |
| NP0007 | Bir mapper, isim kuralıyla her elemanın lazy navigation’larını okuyor | Info |
Önce doğruluk
Analyzer’ı tasarlarken kendime koyduğum ilk kural şuydu: bir uyarı, gerçekten her eleman için bir sorgu (ya da lazy load, ya da save) çalıştıran koda işaret etmeli. Analiz bunu ispatlayamıyorsa warning kurallarında sessiz kalıyor; tahmine dayanan durumlar için ayrı bir Info kuralı var (NP0006).
Sebebi basit: yanlış bir uyarı, kaçırılan bir uyarıdan daha pahalı. Birkaç yanlış alarmdan sonra ekip uyarıları kapatıyor ve araç gerçek N+1’leri de göstermez oluyor. Analyzer’ın kaçırdığını ise zaten ikinci katman yakalıyor.
Uyarıların build’i kırması için solution kökündeki .editorconfig yeterli:
[*.cs]
dotnet_diagnostic.NP0001.severity = error
dotnet_diagnostic.NP0002.severity = error
dotnet_diagnostic.NP0003.severity = error
dotnet_diagnostic.NP0005.severity = errorKatman 2: Testlerde runtime guard
Statik analiz, hedefi çalışma zamanında belirlenen bir çağrının içini göremez. Guard ise EF Core’un gerçekten gönderdiği komutları izliyor:
dotnet add package NPlusOne.EntityFrameworkCoreusing NPlusOne.EntityFrameworkCore;
var options = new DbContextOptionsBuilder<ShopContext>()
.UseSqlite(connection)
.UseNPlusOneGuard()
.Options;
[Fact]
public async Task Listing_orders_does_not_query_per_customer()
{
await using var db = new ShopContext(options);
using var guard = NPlusOneGuard.Begin();
await new OrderService(db).ListAsync();
// N+1 varsa NPlusOneViolationException fırlatır
guard.AssertNoNPlusOne();
}Guard bir scope içindeki komutları kaydediyor; aynı sorgu ya da aynı lazy load varsayılan olarak üçüncü kez çalıştığında bunu şüpheli sayıyor. AssertNoNPlusOne() böyle bir şey bulursa testi düşürüyor ve tekrarlanan işi, kaç kez çalıştığını ve nereden çağrıldığını raporluyor:
N+1 guard found 2 suspect(s): the same work ran more than
2 times in one scope (11 commands, 5 lazy loads recorded).
- Query ran 5 times at OrderService.ListAsync (OrderService.cs:42)
SELECT "o"."Id", "o"."CustomerId", "o"."Number"
FROM "Orders" AS "o" WHERE "o"."CustomerId" = @p
- Lazy load of Customer.Orders ran 5 times
at OrderService.ListAsync (OrderService.cs:42)
Load the data once for all elements instead: Include,
a projection, or one query with Contains followed by a lookup.Rapordaki SQL her zaman normalize edilmiş hâli: literal’ler ve parametreler yer tutucuyla değiştiriliyor. Parametre değerleri hiçbir zaman rapora girmiyor; bu sayede rapor log’a ya da bir exception mesajına güvenle yazılabiliyor.
Scope’lar async akışı takip ediyor (AsyncLocal), bu yüzden paralel çalışan testler birbirinin komutlarını görmüyor. Guard herhangi bir test framework’üyle çalışıyor ve dependency injection gerektirmiyor.
Katman 3: HTTP isteklerinde
dotnet add package NPlusOne.AspNetCorebuilder.Services.AddNPlusOneGuard();
builder.Services.AddDbContext<ShopContext>(o =>
o.UseSqlServer(connectionString).UseNPlusOneGuard());
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
// istek başına bir scope; şüpheli durumlar warning olarak loglanır
app.UseNPlusOneGuard();
}Development ortamında her isteği izlemek için bu kadarı yeterli. Belirli endpoint’lerin N+1 yaptığında başarısız olmasını istiyorsanız, örneğin entegrasyon testlerinde, assert filtreleri var:
// minimal API: bu endpoint'i (ya da bir MapGroup'u) başarısız yapar
app.MapGet("/orders", (ShopContext db) => ...)
.WithNPlusOneAssert();
// MVC: bu action'ı ya da controller'ın tüm action'larını başarısız yapar
[NPlusOneAssert]
public sealed class OrdersController : ControllerBase { ... }AspNetCore paketi guard paketini de beraberinde getiriyor; ayrıca kurmanıza gerek yok.
Çalıştığını nasıl biliyorum?
Bir analyzer’ın çalıştığını göstermenin en kolay yolu, birkaç örnekte uyarı verdiğini göstermek. Ama bu iki soruyu cevapsız bırakıyor: uyardığı yer gerçekten N+1 mi, sustuğu yer gerçekten masum mu? Projede bu iki soru için üç şey var.
Veritabanında ispatlanan bir corpus
Repodaki corpus/ klasörü, her satırı üretmesi gereken uyarıyla işaretlenmiş C# dosyalarından oluşuyor (// expect: NP0001). İşaretsiz bir satır hiçbir uyarı üretmemeli.
Bu tek başına yeterli değil, çünkü işaretleri ben koyuyorum. Bu yüzden her corpus metodu ayrıca SQLite üzerinde gerçekten çalıştırılıyor: önce her entity’den 3, sonra 6 satırla. İşaretli bir metot satır sayısı arttıkça daha fazla SQL göndermek zorunda, işaretsiz bir metot ise aynı sayıda. Kararı benim düşündüğüm değil, EF Core’un gerçekte yaptığı veriyor. Bu testler EF Core 8, 9 ve 10’un her birinde ayrı ayrı çalışıyor.
N+1’in bilinen 143 kalıbını bir katalogda topladım; her biri bu şekilde test ediliyor.
Düzeltmeyi geri almak
Bir düzeltmeden sonra testlerin geçmesi, düzeltmenin test edildiğini göstermiyor. Bu yüzden her düzeltmeden sonra küçük bir script (tools/countercheck.py) düzeltmenin her parçasını tek tek geri alıp testleri yeniden çalıştırıyor. Her parça geri alındığında en az bir testin kırmızıya dönmesi gerekiyor. Yeşil kalan parça, hiçbir testin korumadığı kod demek.
Bu yazıyı yazdığım gün bu süreç işe yaradı. Analyzer, bir override’ın içinden yapılan base.Price(id) çağrısını, override’ın kendisine de gidebilen sıradan bir sanal çağrı gibi takip ediyordu. Sonuç: sorguyu yalnızca override yaptığı hâlde, döngüdeki base çağrısı için yanlış bir uyarı. Önce bunu gösteren corpus örneklerini yazdım: üç yanlış uyarı ve bir kaçırılan lazy load çıktı. Düzeltmeden sonra counter-check, 11 parçadan biri için yeşil kaldı. Sebep testteydi: türetilmiş sınıfın içinden yapılan çağrıyı Roslyn zaten override’a bağlıyordu, o parçayı sınayan bir örnek yoktu. Temel tip üzerinden çağıran bir örnek ekleyince 11 parçanın 11’i de kırmızıya döndü. Bu düzeltme bir sonraki sürümde yayında olacak.
Gerçek projeler
Corpus’u ben yazdığım için analyzer’ın onun üzerindeki skoru tanım gereği yüksek. Asıl test, analyzer’ın hiç görmediği kod. Bu yüzden tools/scan.sh ile açık kaynak projeleri taradım:
- eShop: 8 bulgu, hepsi gerçek. Sipariş edilen her ürün için bir
Find, her outbox olayı için birSinglesorgusu ve birSaveChanges. - ardalis/CleanArchitecture: 1 bulgu, gerçek. Seed verisinde her satır için bir
INSERT. - jasontaylordev/CleanArchitecture: bulgu yok.
- Kendi dört uygulamam: 12 gerçek N+1.
Bir taramada yanlış alarm çıktığında önce corpus’a bir örnek olarak giriyor, sonra düzeltiliyor.
Diğer araçlarla karşılaştırma
Aynı corpus üzerinde başka araçları da ölçtüm. Statik tarafta N+1 için en çok kurala sahip bulduğum analyzer LinqContraband, ispatlanmış 260 N+1 metodunun 55’ini buluyor. N+1 analyzer’ı 260’ının tamamını, sabit olduğu ispatlanmış 472 metodun hiçbirinde uyarı vermeden buluyor. Runtime tarafında EFCore.QueryGuard ve DbEye da vakaların çoğunu görüyor; farklar, her eleman için değişen SQL metninde, eleman başına açılan transaction’larda ve tek bir SaveChanges‘in birden çok satır yazdığı durumlarda çıkıyor.
Bu sayıları okurken dürüst olmam gereken bir nokta var: corpus bu araç için yazıldı, dolayısıyla kendi skoru tanım gereği %100. Karşılaştırma, bu ispatlanmış vakalardan hangisini başka bir aracın gördüğünü gösteriyor; iki aracın da hiç görmediği kod üzerindeki başarısını değil. Ayrıntılar karşılaştırma sayfasında.
Sınırlar
- Analyzer; analiz edilen projenin referans vermediği bir projedeki implementasyonu, reflection’ı ve çalışma zamanında verilen konfigürasyon kararlarını göremez. Bunlar için guard var.
- Guard yalnızca çalışan kodu görür. Testi olmayan bir endpoint’teki N+1’i yakalamaz.
- Şimdilik yalnızca EF Core. Dapper, ham ADO.NET ve GraphQL resolver’ları kapsam dışında.
- Bir döngünün her turda bir round trip yapması bazen bilinçli bir tercih: batch işleme, sayfalama, polling. Analyzer bu kalıpları tanıyıp sessiz kalıyor; tanımadığı bir durumda kuralı o satır için
#pragma warning disableile kapatabilirsiniz.
Başlamak için
dotnet add package NPlusOne.Analyzers # derleme anında
dotnet add package NPlusOne.EntityFrameworkCore # testlerde
dotnet add package NPlusOne.AspNetCore # isteklerde- Dokümantasyon: selcukgural.github.io/NPlusOne. Başlangıç rehberi sizi beş dakikada sıfırdan kırmızı bir build’e ve kırmızı bir teste götürüyor.
- Hangi paketin nereye ekleneceği: Packages.
- Kaynak kodu: github.com/selcukgural/NPlusOne.
- Paketler: NuGet.
Proje MIT lisanslı ve topluluk için ücretsiz. Kendi kodunuzda bir şey bulursa ya da bulması gerekeni kaçırırsa GitHub’da bir issue açın: kaçırılan her durum ve her yanlış uyarı, önce corpus’a yeni bir test olarak giriyor.