EF Core’da N+1 Sorgularını Bulma ve Düzeltme

EF Core ve SQLite ile N+1 sorgularını SQL günlüklerinde bulun. Aynı sipariş özetinin 4 SQL komutundan 1 komuta nasıl indiğini görün.

Can Yüksel

Erdoğancan Yüksel

13 Kasım 2026

2 dakika 52 saniye okuma süresi

EF Core N+1 örneğinde döngü 4, projeksiyon 1 SQL komutu

Kısa cevap. EF Core’da N+1, bir listeyi getiren sorgunun ardından listedeki her kayıt için ayrı bir sorgu çalıştığında ortaya çıkar. Bu yazıdaki üç siparişli örnek önce sipariş kimliklerini, ardından her siparişin kalem sayısını sorguluyor. Toplam dört komut çalışıyor. Kalem sayısını doğrudan Select içinde hesaplatınca aynı sonuç tek SQL komutuyla geliyor.

N+1 sorgusu nasıl anlaşılır?

Sorunu ararken ilgili isteğin SQL günlüğüne bakın. Ana listeyi getiren SELECT satırından sonra yalnızca sipariş parametresi değişen üç COUNT(*) sorgusu görüyorsanız uygulama aynı işi kayıt başına tekrarlıyordur. Sipariş sayısı arttıkça ek sorgular da artar.

Aşağıdaki örnekte sorguyu tetikleyen satır döngü içindeki CountAsync çağrısıdır. Gecikmeli yükleme açık olan bir projede de benzer bir desen görülebilir. Döngü içinde her kaydın ilişki özelliğine erişildiğinde EF Core, henüz yüklenmemiş veriyi getirmek için yeni bir komut çalıştırabilir.

Üç siparişle çalıştırılabilir SQLite örneği

Örneği .NET 10 ve EF Core SQLite 10.0.2 ile çalıştırdım. Aşağıdaki komutlarla bir konsol projesi açın. Ardından Program.cs dosyasını verilen kodla değiştirip dotnet run çalıştırın. SQLite veritabanı bellekte açılır. Bağlantı, iki sorgulama aşamasının da aynı veriyi görebilmesi için program boyunca açık kalır.

dotnet new console -n EfN1Demo -f net10.0
cd EfN1Demo
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.2
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();

var commandCount = 0;
var options = new DbContextOptionsBuilder<ShopDb>()
    .UseSqlite(connection)
    .LogTo(message =>
    {
        commandCount++;
        Console.WriteLine(message);
    }, new[] { RelationalEventId.CommandExecuted })
    .Options;

using (var setup = new ShopDb(options))
{
    await setup.Database.EnsureCreatedAsync();
    setup.Orders.AddRange(
        new Order { Id = 1, Lines = { new OrderLine(), new OrderLine() } },
        new Order { Id = 2, Lines = { new OrderLine() } },
        new Order { Id = 3 });
    await setup.SaveChangesAsync();
}

commandCount = 0;
var first = new List<OrderSummary>();
using (var db = new ShopDb(options))
{
    var ids = await db.Orders.OrderBy(o => o.Id).Select(o => o.Id).ToListAsync();
    foreach (var id in ids)
    {
        var count = await db.OrderLines.CountAsync(line => line.OrderId == id);
        first.Add(new OrderSummary(id, count));
    }
}
Console.WriteLine($"Döngü: {commandCount} SQL komutu");

commandCount = 0;
List<OrderSummary> second;
using (var db = new ShopDb(options))
{
    second = await db.Orders
        .OrderBy(o => o.Id)
        .Select(o => new OrderSummary(o.Id, o.Lines.Count))
        .ToListAsync();
}
Console.WriteLine($"Projeksiyon: {commandCount} SQL komutu");
Console.WriteLine($"Sonuçlar aynı: {first.SequenceEqual(second)}");
foreach (var row in second)
    Console.WriteLine($"{row.OrderId}: {row.LineCount} kalem");

record OrderSummary(int OrderId, int LineCount);

class Order
{
    public int Id { get; set; }
    public List<OrderLine> Lines { get; } = new();
}

class OrderLine
{
    public int Id { get; set; }
    public int OrderId { get; set; }
}

class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderLine> OrderLines => Set<OrderLine>();
}

SQL komutlarını ve sonuçları karşılaştırma

LogTo yalnızca CommandExecuted olaylarını sayıyor. Tablo oluşturma ve veri ekleme komutları da günlüğe yazılıyor. Sayaç, her okuma aşamasından hemen önce sıfırlanıyor. Böylece karşılaştırma sadece sipariş özetini üreten komutları kapsıyor.

Döngü: 4 SQL komutu
Projeksiyon: 1 SQL komutu
Sonuçlar aynı: True
1: 2 kalem
2: 1 kalem
3: 0 kalem

İlk yaklaşımın günlüğünde aşağıdaki iki sorgu biçimi var. İkinci biçim üç kez çalışıyor. @id, döngünün o anki sipariş kimliği. Bu örnekte günlük parametre değerlerini ? ile gizliyor. Tekrarlanan komutu görmek için bu değerleri açmak gerekmiyor.

-- Sipariş kimlikleri (bir kez)
SELECT "o"."Id"
FROM "Orders" AS "o"
ORDER BY "o"."Id"

-- Kalem sayısı (her sipariş için bir kez)
SELECT COUNT(*)
FROM "OrderLines" AS "o"
WHERE "o"."OrderId" = @id

İkinci sorguda EF Core, o.Lines.Count ifadesini sipariş kimliğine bağlı bir SQL alt sorgusuna çevirdi. Bu alt sorgu tek bir SQL komutunun parçası. Uygulama ayrı ayrı kalem listeleri çekmeden her sipariş için istediği sayıyı alıyor. Üçüncü siparişin sıfır kalemle sonuçta kalması da iki listenin eşdeğerliğini kontrol etmeye yardımcı oluyor.

SELECT "o"."Id", (
    SELECT COUNT(*)
    FROM "OrderLines" AS "o0"
    WHERE "o"."Id" = "o0"."OrderId")
FROM "Orders" AS "o"
ORDER BY "o"."Id"

Projeksiyon, Include ve split query ne zaman kullanılır?

  • Ekranda birkaç alan veya kalem sayısı gösterecekseniz Select ile o alanları seçin. Projeksiyon, kalem nesnelerinin tamamını belleğe almadan özet üretir.
  • Siparişlerle birlikte kalem nesneleri de gerekiyorsa Include ile ilişkiyi baştan yükleyebilirsiniz. Siparişin hem kalemlerini hem ödemelerini aynı sorguda birleştirmek, eşleşen satırları çoğaltabilir. Üç kalemi ve iki ödemesi olan bir sipariş, SQL sonucunda altı satır üretebilir.
  • AsSplitQuery() koleksiyonları ayrı komutlarla yükleyebilir. Komut sayısını değerlendirirken her sipariş için tekrarlanan komutlarla koleksiyon başına çalışan komutları ayırt edin.

Aşağıdaki kodu aynı örnekte record OrderSummary satırının üstüne ekleyebilirsiniz. İki yaklaşım da siparişlerle beraber gerçek kalem kayıtlarını yükler. AsSingleQuery() bu örnekte tek komut çalıştırır. AsSplitQuery() siparişler ve kalemler için iki komut çalıştırır. Her iki yaklaşımda da kalem sayıları sırasıyla 2, 1 ve 0 geldi.

commandCount = 0;
using (var db = new ShopDb(options))
{
    var orders = await db.Orders
        .OrderBy(o => o.Id)
        .Include(o => o.Lines)
        .AsSingleQuery()
        .ToListAsync();

    Console.WriteLine($"Include: {commandCount} SQL komutu");
    Console.WriteLine($"Kalem sayıları: {string.Join(", ", orders.Select(o => o.Lines.Count))}");
}

commandCount = 0;
using (var db = new ShopDb(options))
{
    var orders = await db.Orders
        .OrderBy(o => o.Id)
        .Include(o => o.Lines)
        .AsSplitQuery()
        .ToListAsync();

    Console.WriteLine($"Split query: {commandCount} SQL komutu");
    Console.WriteLine($"Kalem sayıları: {string.Join(", ", orders.Select(o => o.Lines.Count))}");
}

Bölünmüş sorguda komutlar arasında başka bir işlem veriyi değiştirebilir. Birden fazla komutun tutarlı bir veri görünümü okuması gerekiyorsa sağlayıcınızın işlem ve izolasyon seçeneklerini değerlendirin.

Include ve ThenInclude nasıl çalışır?

Bir sipariş ekranında her kalemin ürün adını da göstermek istediğinizi düşünün. İlişki yolu siparişten kaleme, kalemden ürüne gider. Aşağıdaki sorgu, önceki modelin OrderLine sınıfına bir Product ilişkisi eklendiğini varsayar.

var orders = await db.Orders
    .Include(order => order.Lines)
    .ThenInclude(line => line.Product)
    .ToListAsync();
  • Include(order => order.Lines) siparişin kalemlerini yükler. Burada order bir Order nesnesidir.
  • ThenInclude(line => line.Product) her kalemin ürününü yükler. Burada line koleksiyonun tek bir öğesidir: OrderLine. Sorgu yine sipariş listesi döndürür; ürüne ilgili kalemin Product özelliğinden ulaşırsınız.

Gecikmeli yükleme açıksa yalnızca kalemleri yüklemek yeterli olmayabilir: döngüde henüz yüklenmemiş bir ürüne erişmek ek SQL komutu tetikleyebilir. ThenInclude ürünleri de baştan yükler. Ekrana sadece ürün adı gibi birkaç alan gerekiyorsa Select ile gereken alanları seçmek daha uygun olabilir.

Kod incelemesinde neye bakılır?

  • ToListAsync() ile listenin nerede belleğe alındığını bulun. Sonraki döngüde CountAsync(), AnyAsync() veya gecikmeli yüklenen ilişki erişimi varsa SQL günlüğünde kayıt başına komut oluşup oluşmadığını kontrol edin.
  • Aynı filtre ve sıralamayla, farklı sayıda kayıt döndüren iki istek deneyin. Tekrarlanan komutların sayısı kayıt sayısıyla birlikte artıyorsa döngüyü inceleyin.
  • Ekranın ihtiyaç duyduğu alanları belirleyip uygun sorguyu seçin. Değişiklikten sonra kalemi olmayan siparişlerin de sonuçta kaldığını doğrulayın.

Kendi uygulamanızda değişiklikten önce ve sonra hem dönen veriyi hem SQL günlüğünü karşılaştırın. Bu örnekte ölçülen değer komut sayısıdır. Süreyi değerlendirmek için kullandığınız veritabanında gerçekçi bir veri hacmiyle ayrıca ölçüm yapın.

Kaynaklar

Benzer İçerikler