Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

EF Core Compiled Queries: Gerçekten Hızlandırıyor mu?

Yazar: Oğuzhan KARAGÜZEL ·

EF Core'un performans önerileri arasında compiled queries hep geçer. Sorguyu bir kez derleyip tekrar tekrar kullanıyorsunuz, EF Core da her seferinde LINQ'i SQL'e çevirmekle uğraşmıyor. Kâğıt üzerinde mantıklı. Ama ne kadar kazandırıyor? Bunu merak ettim ve BenchmarkDotNet ile ölçtüm.

Kısa cevap: kazandırıyor ama sandığım kadar değil. Ne kadar kazandıracağı da büyük ölçüde veritabanının ne kadar hızlı cevap verdiğine bağlı.

EF Core bir sorguyu çalıştırırken ne yapıyor?

LINQ ile yazdığım sorgu aslında bir ifade ağacı (expression tree). EF Core onu çalıştırmak için şu adımlardan geçiyor:

  1. İfade ağacını dolaşıp içindeki değişkenleri parametreye çeviriyor.
  2. Ağaçtan bir önbellek anahtarı hesaplıyor.
  3. Bu anahtarla daha önce derlenmiş bir sorgu var mı diye önbelleğe bakıyor.
  4. Yoksa LINQ'i SQL'e çeviriyor ve sonucu okuyacak kodu üretiyor. Bunu önbelleğe koyuyor.
  5. SQL'i veritabanına gönderiyor ve sonucu nesnelere dönüştürüyor.

Burada önemli bir nokta var: EF Core zaten kendi önbelleğini kullanıyor. Aynı sorguyu ikinci kez çalıştırdığınızda 4. adım atlanıyor. Yani compiled query, SQL çevirisini değil, 1–3. adımları atlıyor. İfade ağacını her seferinde dolaşmak ve önbellek anahtarını hesaplamak gerekmiyor.

Nasıl yazılıyor?

C#
private static readonly Func<ShopContext, int, Task<SalesOrder?>> OrderById =
    EF.CompileAsyncQuery((ShopContext db, int id) =>
        db.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));

// kullanım
var order = await OrderById(db, 250);

Birden fazla kayıt dönen sorgularda sonuç IAsyncEnumerable oluyor:

C#
private static readonly Func<ShopContext, int, IAsyncEnumerable<SalesOrder>> RecentOrdersOfCustomer =
    EF.CompileAsyncQuery((ShopContext db, int customerId) =>
        db.Orders.AsNoTracking()
            .Include(o => o.Lines)
            .Where(o => o.CustomerId == customerId && o.Lines.Any(l => l.Quantity > 1))
            .OrderByDescending(o => o.CreatedAt)
            .Take(10));

await foreach (var order in RecentOrdersOfCustomer(db, 7))
{
    // ...
}

Dikkat ettiyseniz derlenmiş sorgunun içinde FirstOrDefaultAsync değil FirstOrDefault yazdım. Asenkron olan sorgunun kendisi, içindeki LINQ operatörleri senkron yazılıyor.

Ölçüm Sonuçları Şu Şekilde

İki sorgu hazırladım:

  • Basit: Id ile tek bir sipariş getirmek.
  • Karmaşık: Bir müşterinin, en az bir satırında birden fazla ürün olan son 10 siparişi, satırlarıyla birlikte.

Her ikisini normal LINQ ile ve compiled query olarak yazdım. Her ölçümde yeni bir DbContext oluşturdum, gerçek bir uygulamada istek başına olduğu gibi. İki veritabanında ölçtüm: aynı makinede Docker içinde çalışan PostgreSQL 16 ve bellekte çalışan SQLite. SQLite'ı ağ ve veritabanı maliyetini neredeyse sıfıra indirmek için ekledim; böylece EF Core'un kendi maliyeti daha net görünüyor.

Ortam: Intel Core i7-13700H, 32 GB RAM, .NET 10, EF Core 10, BenchmarkDotNet 0.15.8.

PostgreSQL

SorguNormalCompiledFarkBellek (normal → compiled)
Basit643 µs601 µs%6,656,3 KB → 49,9 KB
Karmaşık1.508 µs1.466 µs%2,793,9 KB → 80,1 KB

SQLite (bellekte)

SorguNormalCompiledFarkBellek (normal → compiled)
Basit117 µs87 µs%25,958,7 KB → 52,4 KB
Karmaşık577 µs469 µs%18,7101,4 KB → 87,5 KB

Sonuçlar ne diyor?

Compiled query her seferinde aynı miktarda zaman kazandırıyor. Basit sorguda bu yaklaşık 30–40 µs, karmaşık sorguda 40–100 µs arası. Bu, EF Core'un ifade ağacını dolaşma ve önbelleğe bakma maliyeti.

Ama bu kazancın oranı veritabanına bağlı. SQLite bellekte çalıştığı için toplam süre kısa, kazanılan 30 µs da büyük bir oran tutuyor: %26. PostgreSQL'de veritabanına gidip gelmek zaten 500 µs'yi geçiyor, aynı kazanç %7'ye düşüyor. Canlıda veritabanı başka bir sunucudaysa her gidiş-dönüş en az birkaç yüz mikrosaniye daha ekleyecek ve oran daha da küçülecek.

Bellek tarafında daha tutarlı bir kazanç var. Her iki veritabanında da ayrılan bellek %11–15 azaldı. Çok sık çalışan bir sorguda bu çöp toplayıcının yükünü azaltıyor.

Ne zaman kullanmalı?

Ben compiled query'yi her sorguda kullanmıyorum. Kodu daha zor okunur hale getiriyor ve sorguyu çalışırken değiştiremiyorsunuz: koşula göre Where eklemek gibi şeyler yapılamıyor.

Kullandığım yer şu: saniyede çok kez çalışan, şekli hiç değişmeyen ve hızlı cevap dönen sorgular. Örneğin her istekte çalışan bir kullanıcı ya da ayar sorgusu, ya da bir API'de en çok çağrılan metodun sorgusu. Bu sorgularda kazanılan birkaç onlarca mikrosaniye, saniyede binlerce istekle çarpılınca anlamlı bir sayı oluyor.

Yavaş bir sayfayı hızlandırmak istiyorsanız ise compiled query'den önce bakılacak başka yerler var. N+1 problemi yazısında 147 sorguyu 1'e indirerek 114 ms'yi 1,4 ms'ye düşürdüm. EF Core sorgu performansı yazısında da projeksiyon, AsNoTracking ve AsSplitQuery'yi anlattım. Bunlar milisaniyeler kazandırıyor, compiled query ise mikrosaniyeler.

Bilmeniz gereken birkaç şey

  • Derlenmiş sorguyu static readonly bir alanda tutun. Her seferinde yeniden EF.CompileAsyncQuery çağırırsanız hiçbir şey kazanmazsınız.
  • Parametre sayısı sınırlı. EF.CompileAsyncQuery context'in yanında en fazla 15 parametre alabiliyor. Daha fazlasına ihtiyacınız varsa sorguyu yeniden düşünmek gerekir.
  • EF Core 9 ile gelen "precompiled queries" farklı bir şey. O, sorguları derleme zamanında üretip Native AOT ile çalışabilmek için geliştirilen deneysel bir özellik. Bu yazıdaki compiled queries ise çalışma zamanında, ilk çağrıda derleniyor.

Sonuç

Compiled queries gerçekten hızlandırıyor: benim ölçümümde %3 ile %26 arasında. Ama kazanç sabit bir miktar, oran değil. Veritabanı ne kadar yavaş cevap veriyorsa compiled query'nin etkisi o kadar az hissediliyor. O yüzden önce sorgu sayısına ve çekilen veriye bakıyorum. Compiled query'yi en son, en sık çalışan sorgular için kullanıyorum.