Veritabanı Sorgu Optimizasyonu ve Index Stratejileri
Bir uygulamanın performansı, büyük ölçüde veritabanıyla konuşma biçimine bağlıdır. Yavaş bir sorgu, tüm kullanıcı deneyimini bozar; gereksiz bir index ise yazma işlemlerini yavaşlatır. Bu yazıda, sorgu optimizasyonunun temel adımlarını, index tasarım stratejilerini ve .NET/EF Core tarafında sık yapılan hataları ele alacağız.
1. Önce Ölç: Yavaş Sorguyu Tespit Etmek
Optimizasyona başlamadan önce, gerçekten yavaş olan sorguları tespit etmek gerekir. SQL Server'da Query Store veya sys.dm_exec_query_stats DMV'si, en çok CPU tüketen ve en uzun süren sorguları listeler . PostgreSQL'de ise pg_stat_statements ve EXPLAIN ANALYZE bu iş için kullanılır .
Kritik Ayrım: Bir sorgunun yavaş olmasının iki temel nedeni vardır:
-
CPU-bound (Runner): Sorgu gerçekten hesaplama yapıyor; index eksik veya sorgu verimsiz.
-
Wait-bound (Waiter): Sorgu bir kaynağı bekliyor (kilit, I/O, ağ). Bu durumda index eklemek sorunu çözmez.
Elapsed Time ile CPU Time karşılaştırması bu ayrımı yapmanızı sağlar: CPU süresi toplam süreye yakınsa CPU-bound; toplam süre çok daha büyükse wait-bound'dur .
2. Index Stratejileri: Ne, Nerede, Neden?
Index'ler, okuma performansını artırırken yazma (INSERT/UPDATE/DELETE) maliyetini ve depolama alanını artırır . Bu nedenle "her sütuna index" değil, sorgu desenine göre stratejik tasarım gerekir.
A. Covering Index (Kapsayan Index): En Büyük Kazanç
Covering index, bir sorgunun ihtiyaç duyduğu tüm sütunları index'in yaprak düzeyinde barındırır. Bu sayede veritabanı, veri sayfalarına (table veya clustered index) ek erişim yapmadan sorguyu tamamen index'ten karşılar .
Nasıl Oluşturulur? Arama/sıralama sütunları index anahtarına, geri kalan sütunlar INCLUDE ifadesine eklenir:
sql
-- Sorgu: SELECT Status, CreatedAt FROM Orders WHERE UserId = @id CREATE NONCLUSTERED INDEX IX_Orders_UserId ON Orders(UserId) INCLUDE (Status, CreatedAt);
Neden Önemli? Covering index olmadan, index'te bulunmayan sütunlar için Key Lookup (her satır için ek I/O) yapılır. Gerçek bir projede, (CustomerId, PlacedAt) INCLUDE (Total, Status) şeklinde bir covering index eklenmesi, sıcak bir endpoint'in DTU kullanımını %90'dan %22'ye düşürmüştür .
B. Sıralama Yönü (ASC/DESC) Önemlidir
ORDER BY RejectedQty DESC, ProductID ASC gibi bir sorgu için, index'i aynı yönde tanımlamak Sort operatörünü ortadan kaldırır ve sorguyu hızlandırır :
sql
CREATE NONCLUSTERED INDEX IX_PurchaseOrderDetail_RejectedQty ON Purchasing.PurchaseOrderDetail (RejectedQty DESC, ProductID ASC, DueDate, OrderQty);
C. Clustered Index: Bir Tabloda Yalnızca Bir Tane
Clustered index, tablonun fiziksel sıralamasını belirler. Dar, benzersiz, artan ve değişmez (immutable) bir anahtar seçmek, tüm non-clustered index'lerin boyutunu ve bakım maliyetini düşürür .
D. Filtered Index: Belirli Bir Alt Küme İçin
Sorgular genellikle belirli bir koşula (ör. WHERE Status = 'Active') odaklanıyorsa, filtered index hem daha küçük hem daha hızlıdır . Ancak PostgreSQL'de filtered index'in kullanılması için sorgudaki WHERE koşulunun index tanımıyla birebir eşleşmesi gerekir; LIKE 'premium' gibi bir kullanım index'i devre dışı bırakır .
3. .NET ve EF Core Tarafında Sorgu Optimizasyonu
A. N+1 Problemi: En Sık Yapılan Hata
EF Core'da lazy loading veya döngü içinde ilişkili veri çekmek, her kayıt için ayrı bir sorgu çalıştırır. Örneğin, 100 blog ve her blogun post'larını çekmek 101 sorgu anlamına gelir .
Çözüm: Include ile eager loading veya projeksiyon (DTO) kullanın. Yalnızca ihtiyaç duyulan sütunları seçin; tüm entity'yi çekmeyin .
B. AsNoTracking() Kullanımı
Sadece okuma işlemlerinde AsNoTracking() kullanmak, EF Core'un Change Tracker yükünü ortadan kaldırır ve bellek/CPU tasarrufu sağlar .
C. Index'leri Kod Olarak Tanımlayın
EF Core'da index'leri IEntityTypeConfiguration<T> içinde tanımlamak, migration'larla birlikte versiyonlanmalarını ve kaynak kontrolünde yaşamalarını sağlar :
csharp
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasIndex(o => new { o.CustomerId, o.PlacedAt })
.IncludeProperties(o => new { o.Total, o.Status });
}
}
D. Buffering vs. Streaming
Büyük sonuç kümelerinde ToList() tüm veriyi belleğe alır (buffering). Mümkünse AsEnumerable() veya IAsyncEnumerable ile streaming yapın; bu, bellek kullanımını sabit tutar .
4. Ne Zaman Index Eklememelisiniz?
-
Düşük seçicilikli sütunlar:
Gender,IsActivegibi az farklı değer içeren sütunlar için index genellikle kullanılmaz; planner sequential scan'i tercih eder . -
Yazma yoğun tablolar: Her index, INSERT/UPDATE maliyetini artırır. Index sayısını minimumda tutun .
-
Sorgu yeniden yazımı daha etkiliyse: Bazen doğru cevap yeni bir index değil, sorguyu daha az veri çekecek şekilde yeniden yazmaktır (projeksiyon, split query) .
5. En İyi Pratikler
| Pratik | Açıklama |
|---|---|
| Önce EXPLAIN/Execution Plan okuyun | Index eklemeden önce sorgunun gerçekten index kullanıp kullanmadığını doğrulayın . |
| Covering index ile Key Lookup'u ortadan kaldırın | En yüksek getirili optimizasyon budur . |
| ORDER BY yönünü index'e yansıtın | Sort operatörünü kaldırarak CPU ve bellek tasarrufu sağlar . |
| N+1'den kaçının | Include veya projeksiyon kullanın; lazy loading'den uzak durun . |
| Index'leri migration ile yönetin | Kod olarak tanımlayın; ortamlar arası tutarlılık sağlayın . |
Sonuç
Sorgu optimizasyonu, "tahmin" değil "ölçüm" disiplinidir. Önce yavaş sorguyu tespit edin, ardından yürütme planını okuyun ve covering index, doğru sıralama yönü ve filtered index gibi stratejilerle müdahale edin. .NET tarafında ise N+1 problemi ve gereksiz Include kullanımı, veritabanı yükünün en yaygın nedenleridir. Unutmayın: Her index bir maliyettir; doğru index ise en ucuz performans iyileştirmesidir.