Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

EF Core N+1 Problemi: Nasıl Fark Edilir, Nasıl Çözülür?

Yazar: Oğuzhan KARAGÜZEL ·

Bir sipariş listesi sayfası düşünün. Son 100 sipariş, her satırda müşterinin adı ve siparişin toplam tutarı. Kod temiz, okunaklı, testler geçiyor. Ama sayfa beklediğinizden yavaş. SQL loguna baktığınızda tek bir sorgu yerine yüzlerce sorgu görüyorsunuz.

Buna N+1 problemi deniyor. Bir sorguyla N tane kayıt çekiyorsunuz, sonra her kayıt için bir sorgu daha atıyorsunuz. Toplamda N+1 sorgu.

Bu yazıda N+1'in nasıl oluştuğunu, nasıl fark edileceğini ve nasıl çözüleceğini kendi ölçümlerimle anlatacağım.

N+1 nasıl oluşuyor?

İki yolu var. Birincisi lazy loading. EF Core'da lazy loading varsayılan olarak kapalı. Açmak için Microsoft.EntityFrameworkCore.Proxies paketini ekleyip UseLazyLoadingProxies() çağırmanız ve navigation property'leri virtual yapmanız gerekiyor. Açtığınızda şu kod masum görünüyor:

C#
var orders = await db.Orders
    .OrderByDescending(o => o.CreatedAt)
    .Take(100)
    .ToListAsync();

foreach (var o in orders)
{
    var name = o.Customer.Name;                       // her siparişte bir sorgu
    var total = o.Lines.Sum(l => l.Price * l.Quantity); // her siparişte bir sorgu daha
}

o.Customer'a ilk eriştiğiniz anda EF Core veritabanına gidip müşteriyi getiriyor. o.Lines'a eriştiğinizde de sipariş satırlarını. Döngü 100 kez döndüğü için bu sorgular da 100 kez çalışıyor.

İkinci yol lazy loading olmadan da karşımıza çıkıyor: döngü içinde sorgu yazmak.

C#
foreach (var o in orders)
{
    var lines = await db.OrderLines
        .Where(l => l.OrderId == o.Id)
        .ToListAsync();
}

Bu daha göze batan bir hata ama özellikle bir yardımcı metodun içine saklandığında kolayca gözden kaçıyor.

Logda neye benziyor?

EF Core'un çalıştırdığı SQL'i görmek için LogTo yeterli:

C#
optionsBuilder
    .UseNpgsql(connectionString)
    .LogTo(Console.WriteLine, [RelationalEventId.CommandExecuted]);

Lazy loading örneğinde log şöyle başlıyor:

Çıktı
Executed DbCommand (0ms)
SELECT o."Id", o."CreatedAt", o."CustomerId"
FROM "Orders" AS o
ORDER BY o."CreatedAt" DESC
LIMIT @p

Executed DbCommand (1ms)
SELECT c."Id", c."Name"
FROM "Customers" AS c
WHERE c."Id" = @p
LIMIT 1

Executed DbCommand (0ms)
SELECT o."Id", o."OrderId", o."Price", o."ProductName", o."Quantity"
FROM "OrderLines" AS o
WHERE o."OrderId" = @p
...

Aynı sorgunun sadece parametresi değişerek tekrar tekrar çalıştığını gördüğünüzde N+1'i bulmuşsunuz demektir. Her sorgunun süresi 0–1 ms görünüyor. Sorun da burada: tek tek bakınca hiçbiri yavaş değil. Yavaş olan, bunların toplamı.

Sorgu sayısını saymak

Logu gözle taramak yerine sorguları saymak daha güvenilir. EF Core'un interceptor'ları bunun için çok uygun:

C#
public class CommandCounter : DbCommandInterceptor
{
    public int Count;

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command, CommandEventData eventData,
        InterceptionResult<DbDataReader> result, CancellationToken ct = default)
    {
        Count++;
        return base.ReaderExecutingAsync(command, eventData, result, ct);
    }
}

AddInterceptors(counter) ile context'e ekleyip bir işlemden sonra counter.Count'a bakıyorum. Bunu bir entegrasyon testine koyarsanız "bu sayfa en fazla 3 sorgu atmalı" gibi bir kural yazabilirsiniz. N+1 bir daha geri gelirse test kırılır.

Ölçümlerden Elde Edilen Sonuçlar

Aynı listeyi altı farklı şekilde yazdım ve her birini ölçtüm: son 100 sipariş, müşteri adı ve sipariş satırları. Veritabanında 50 müşteri, 500 sipariş, her siparişte 5 satır ve 2 ödeme var. Her yöntemi 5 kez ısındırdıktan sonra 30 kez çalıştırdım ve ortanca süreyi aldım.

Ortam: Intel Core i7-13700H, 32 GB RAM, .NET 10, EF Core 10, Npgsql 10, aynı makinede Docker içinde PostgreSQL 16.

YöntemSorgu sayısıOrtanca süre
Lazy loading147114,42 ms
Döngüde sorgu10149,42 ms
Include (müşteri + satırlar)12,78 ms
Include + ödemeler16,00 ms
Include + ödemeler + AsSplitQuery34,40 ms
Select ile projeksiyon11,42 ms

Birkaç şey dikkatimi çekti.

Lazy loading neden 147 sorgu? 1 sipariş sorgusu ve 100 satır sorgusu, bir de müşteriler. 100 siparişte 46 farklı müşteri vardı. EF Core aynı müşteriyi ikinci kez sorgulamıyor, çünkü onu zaten takip ediyor. Bu yüzden 1 + 100 + 46 = 147.

Veritabanı aynı makinedeyken bile fark 80 kat. Burada her gidiş-dönüş 1 milisaniyenin altında. Canlıda veritabanı genelde başka bir sunucuda ve her gidiş-dönüş en az 1 ms sürüyor. O durumda 147 sorgu, hiçbir iş yapmadan sadece ağda en az 147 ms bekleme demek.

Lazy loading, döngüde sorgudan da yavaş. Daha fazla sorgu atıyor, üstüne proxy nesnelerinin maliyeti ekleniyor.

Çözümler

Include

İlişkili veriyi baştan, tek sorguda yüklemek:

C#
var orders = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
    .OrderByDescending(o => o.CreatedAt)
    .Take(100)
    .ToListAsync();

147 sorgu 1'e, 114 ms 2,78 ms'ye indi. Siparişleri okuyup değiştirecekseniz doğru yol bu.

Birden fazla koleksiyon: AsSplitQuery

Ödemeleri de Include ile eklediğimde sorgu hâlâ tek ama süre iki katından fazlasına çıktı. Sebebi, iki koleksiyonun aynı JOIN'de birleşmesi. 100 sipariş × 5 satır × 2 ödeme = 1000 satır geliyor. Ödemeler olmadan bu sayı 500'dü. Buna kartezyen patlama deniyor; EF Core da bu durumda loga bir uyarı yazıyor.

AsSplitQuery() her koleksiyonu ayrı bir sorguyla getiriyor. Benim ölçümümde 3 sorguyla 4,40 ms sürdü. Ama bunun da bir bedeli var: her sorgu ayrı bir gidiş-dönüş ve sorgular arasında veri değişirse tutarsız sonuç alabilirsiniz. Bu konuyu EF Core Sorgu Performansı yazısında ayrıntılı anlattım.

Select ile projeksiyon

Veriyi sadece göstereceksem ilk tercihim projeksiyon:

C#
var rows = await db.Orders
    .OrderByDescending(o => o.CreatedAt)
    .Take(100)
    .Select(o => new
    {
        o.Id,
        Customer = o.Customer.Name,
        LineCount = o.Lines.Count,
        Total = o.Lines.Sum(l => l.Price * l.Quantity)
    })
    .ToListAsync();

Include yazmadım ama o.Customer.Name'e eriştiğim için EF Core gereken JOIN'i kendisi ekliyor. Toplamı da veritabanı hesaplıyor. Uygulamaya sadece ekranda gösterdiğim dört değer geliyor. Ölçümlerin en hızlısı bu oldu: 1,42 ms.

Lazy loading'i kullanmalı mı?

Ben kullanmıyorum. Lazy loading'in sorunu, veritabanına gidip gitmediğinizi koddan anlayamamanız. o.Customer.Name sıradan bir property okuması gibi duruyor. Bir sorgu çalıştırdığını ancak loga bakınca görüyorsunuz.

Lazy loading kapalıyken ilişkiyi yüklemeyi unutursanız o.Customer null geliyor ve hata hemen ortaya çıkıyor. Bence bu daha iyi: yavaş bir sayfa yerine geliştirme sırasında patlayan kodu tercih ederim.

Hangisini ne zaman seçmeli?

DurumÖnerim
Liste ya da rapor, veriyi sadece göstereceğimSelect ile projeksiyon
Kaydı ilişkileriyle okuyup değiştireceğimInclude
Birden fazla koleksiyonu birlikte yüklüyorumInclude + AsSplitQuery
Lazy loading açık bir projeye geldimÖnce sorguları sayan bir test yazıp sonra sıcak noktaları Include ya da projeksiyonla düzeltmek

Sonuç

N+1 tek tek bakınca hızlı görünen sorguların toplamda sayfayı yavaşlatması. Benim ölçümümde aynı liste 147 sorgu ve 114 ms'den 1 sorgu ve 1,42 ms'ye indi. Çözümü zor değil. Zor olan fark etmek. O yüzden en önemli adımın sorguları görünür yapmak olduğunu düşünüyorum: geliştirme ortamında SQL logunu açık tutmak ve kritik sayfalar için sorgu sayısını test etmek.