Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

SQL Server İndeksleri: Clustered, Non-Clustered ve B-Tree

Yazar: Oğuzhan KARAGÜZEL ·

Uygulama yavaşladığında ilk bakmanız gereken yer çoğu zaman veritabanı. Sorgu her seferinde milyonlarca satırı tek tek okuyorsa önbellek, sıkıştırma ya da daha güçlü bir sunucu sorunu ancak bir süreliğine gizler. Bu yazıda indeksin ne olduğunu, SQL Server'da clustered ve non-clustered indeksler arasındaki farkı, aramanın B-Tree yapısında adım adım nasıl yapıldığını ve hangi sütunlara indeks koymanız gerektiğini anlatacağım. Örnekleri SQL Server üzerinden verdim ama mantık PostgreSQL ve MySQL'de de büyük ölçüde aynı.

İndeks nedir?

Kalın bir kitapta "B-Tree" kelimesinin geçtiği sayfayı aradığınızı düşünün. Ya kitabı baştan sona okursunuz ya da arkadaki dizine bakıp doğrudan 214. sayfaya gidersiniz. Veritabanındaki indeks de bu dizin gibi çalışır: bir ya da birkaç sütunun değerlerini sıralı tutan ve her değerin ait olduğu satıra nasıl ulaşılacağını gösteren ayrı bir yapıdır.

Baştan söyleyeyim, indeks bedava değil. Diskte ek yer kaplıyor ve tabloya her INSERT, UPDATE ya da DELETE yapıldığında indeksin de güncellenmesi gerekiyor. Yani indeks okumayı hızlandırırken yazmayı biraz yavaşlatıyor. İyi indeksleme, bu dengeyi uygulamanızın gerçekten çalıştırdığı sorgulara göre kurmak demek.

İndeks yokken: tablo taraması

Örneklerde aşağıdaki gibi bir müşteri tablosu kullanacağız. Tabloda bir milyon satır olduğunu varsayalım:

SQL
CREATE TABLE Musteriler (
    Id          INT IDENTITY(1,1) NOT NULL,
    Ad          NVARCHAR(50)  NOT NULL,
    Soyad       NVARCHAR(50)  NOT NULL,
    Email       NVARCHAR(256) NOT NULL,
    Sehir       NVARCHAR(50)  NULL,
    KayitTarihi DATETIME2     NOT NULL,
    CONSTRAINT PK_Musteriler PRIMARY KEY CLUSTERED (Id)
);

Şimdi e-posta adresine göre bir müşteri arayalım:

SQL
SET STATISTICS IO ON;

SELECT Id, Ad, Soyad
FROM Musteriler
WHERE Email = 'ayse@example.com';

Email sütununda indeks olmadığı için SQL Server aranan satırın nerede olduğunu bilemez. Tablonun bütün sayfalarını okuyup her satırda e-postayı tek tek karşılaştırır. Buna tarama (scan) denir. Sonuç tek satır olsa bile bütün tablo okunur. SET STATISTICS IO ON açıkken Messages sekmesinde gördüğünüz logical reads değeri, sorgunun kaç veri sayfası okuduğunu gösterir. İndeks eklemeden önce ve sonra bu sayıyı karşılaştırmak, farkı görmenin en kolay yolu. SQL Server Management Studio'da sorguyu Include Actual Execution Plan (Ctrl+M) açıkken çalıştırırsanız planda da Clustered Index Scan adımını görürsünüz.

B-Tree: indeksin içi

SQL Server indeksleri B-Tree ailesinden, dengeli bir ağaç yapısında saklar (teknik olarak B+ ağacı). Ağacın her düğümü 8 KB'lık bir veri sayfasıdır ve üç seviyeden oluşur:

  • Kök (root): Aramanın başladığı tek sayfa. Anahtar aralıklarını ve hangi aralık için hangi alt sayfaya gidileceğini tutar.
  • Ara seviyeler (intermediate): Aynı işi daha dar aralıklarla yapar. Tablo büyüdükçe ağaca yeni seviyeler eklenir.
  • Yaprak seviyesi (leaf): Aramanın bittiği yer. Clustered indekste satırların kendisi, non-clustered indekste ise anahtar ve satıra ulaşmak için bir işaretçi burada durur. Yaprak sayfaları birbirine bağlı olduğu için aralık sorguları yaprak seviyesinde sayfadan sayfaya ilerleyerek okunur.

Anahtarı 13 olan satırı aradığımızı düşünelim:

B-Tree üzerinde 13 değerinin aranması: kökten ara düğüme, oradan yaprak sayfaya10 | 204 | 713 | 1625 | 301 2 37 8 910 11 1213 14 1516 18 1925 26 28kökara seviyeyaprak seviyesi
13'ü ararken okunan sayfalar mavi: kök, bir ara düğüm ve bir yaprak sayfa. Diğer sayfalara hiç dokunulmuyor.
  1. Kök: 13, 10 ile 20 arasında. Ortadaki dala inilir. 10'dan küçük ve 20'den büyük değerlerin olduğu dallar hiç okunmaz.
  2. Ara seviye: Bu düğümdeki 13 ve 16 anahtarları "13 ile 16 arasındaki değerler şu sayfada" bilgisini verir. 13'ün olduğu yaprak sayfaya geçilir.
  3. Yaprak: Sayfadaki sıralı satırlar içinde 13 bulunur. Clustered indeksteysek satırın bütün sütunları zaten buradadır.

Her adımda aranan alan ciddi şekilde daralıyor. Bu yüzden ağacın derinliği satır sayısıyla çok yavaş artıyor: tamsayı anahtarlı, bir milyon satırlık bir tabloda indeks genelde üç seviyeli. Tarama binlerce sayfa okurken indeksle arama üç dört sayfa okuyor.

Clustered indeks

Clustered indekste B-Tree'nin yaprak seviyesi tablonun verisinin ta kendisi. Satırlar ayrı bir yerde durmuyor, indeks anahtarına göre sıralı şekilde yaprak sayfalarında saklanıyor. Veri sadece tek bir sırayla tutulabileceği için bir tabloda sadece bir clustered indeks olabiliyor.

Sık duyulan bir yanlış, "Id'si 7 olan satır diskte fiziksel olarak 7. sıraya yazılır" düşüncesi. Buradaki sıralama, sayfaların içinde ve sayfalar arasındaki bağlantılarla korunan mantıksal bir sıralama. Sayfaların diskte yan yana durması garanti değil; araya kayıt girdikçe sayfalar bölünüyor ve indeks zamanla parçalanıyor (fragmentation).

SQL Server'da bir tabloya PRIMARY KEY tanımladığınızda, aksini belirtmezseniz birincil anahtar clustered indeks olarak oluşturulur. Yukarıdaki Musteriler tablosundaki PK_Musteriler'in clustered olmasının sebebi bu. Tabloda zaten clustered indeks varken ikincisini oluşturmaya kalkarsanız SQL Server hata verir:

SQL
-- Hata verir: tabloda zaten PK_Musteriler adında bir clustered indeks var.
CREATE CLUSTERED INDEX IX_Musteriler_Soyad ON Musteriler (Soyad);

-- Msg 1902: Cannot create more than one clustered index on table 'Musteriler'.

Clustered anahtar seçerken şunlara dikkat edin:

  • Dar olsun: Her non-clustered indeks satıra ulaşmak için clustered anahtarı da yanında taşır. INT ya da BIGINT gibi küçük bir anahtar bütün indeksleri küçültür.
  • Benzersiz olsun ve değişmesin: Anahtarı değişen bir satırın yeni sırasına taşınması gerekir.
  • Artarak gitsin: IDENTITY gibi sürekli artan bir anahtarla yeni satırlar hep son sayfaya eklenir. NEWID() ile üretilen rastgele GUID'ler ise tablonun her yerine eklenir, sık sık sayfa bölünmesine ve parçalanmaya yol açar. GUID kullanmak zorundaysanız NEWSEQUENTIALID() ya da sıralı üretilen bir GUID türü daha iyi bir seçim.

Veri anahtara göre sıralı durduğu için clustered indeks aralık sorgularında ve sıralamada çok iyi çalışır. WHERE Id BETWEEN 1000 AND 2000 gibi bir sorgu yaprak seviyesinde tek bir noktaya iner ve oradan sonraki sayfaları sırayla okur.

Non-clustered indeks

Non-clustered indeks, tablodan ayrı duran ikinci bir B-Tree. Yaprak seviyesinde satırın kendisi değil, indekslenen sütunların değerleri ve satıra ulaşmak için bir işaretçi bulunur. Tabloda clustered indeks varsa bu işaretçi clustered anahtarın kendisidir; clustered indeksi olmayan tablolarda (heap) ise satırın fiziksel adresi (RID). Bir tabloda birden çok non-clustered indeks olabilir.

E-posta aramasını hızlandıralım:

SQL
CREATE UNIQUE NONCLUSTERED INDEX IX_Musteriler_Email
    ON Musteriler (Email);

SELECT Id, Ad, Soyad
FROM Musteriler
WHERE Email = 'ayse@example.com';

SQL Server artık e-postayı önce IX_Musteriler_Email ağacında buluyor (Index Seek). Id zaten indeksin içinde, çünkü clustered anahtar o. Ama Ad ve Soyad bu indekste yok. Onları almak için bulunan Id ile clustered indekse bir kez daha gidiliyor. Bu ek adıma Key Lookup deniyor. Tek bir satırda önemsiz, ama sorgu binlerce satır döndürüyorsa her satır için ayrı bir lookup yapılıyor ve SQL Server çoğu zaman indeksi bırakıp tabloyu taramayı tercih ediyor.

Burada sık yapılan bir yanlışı da düzelteyim: non-clustered indeksin "daha az disk alanı kullandığı" söylenir. Oysa tablonun yanında ayrı bir yapı olduğu için ek yer kaplar. Küçük olan, indeksin tabloya göre daha dar olması; toplam kullanılan alan ise her yeni indeksle artar.

Covering index ve INCLUDE

Key Lookup'tan kurtulmanın yolu, sorgunun ihtiyaç duyduğu bütün sütunları indeksin içine koymak. Bir sorguyu tek başına karşılayabilen indekse covering index deniyor. Aramada kullanılmayacak sütunları indeks anahtarına eklemek yerine INCLUDE ile sadece yaprak seviyesine eklemek daha verimli:

SQL
CREATE NONCLUSTERED INDEX IX_Musteriler_Sehir
    ON Musteriler (Sehir)
    INCLUDE (Ad, Soyad);

-- Bütün sütunlar indekste var: Key Lookup yapılmaz.
SELECT Ad, Soyad
FROM Musteriler
WHERE Sehir = N'İzmir';

Birden fazla sütundan oluşan (composite) indekslerde sütunların sırası önemli. (Soyad, Ad) üzerine kurulan bir indeks telefon rehberi gibi önce soyada, sonra ada göre sıralı. WHERE Soyad = ... ve WHERE Soyad = ... AND Ad = ... sorgularında bu indeksle doğrudan arama yapılabilir. Ama sadece WHERE Ad = ... diyen bir sorgu bu sıralamadan faydalanamaz. Genel kural olarak eşitlikle aranan sütunları başa, aralıkla aranan sütunu sona koyun.

Clustered ve non-clustered karşılaştırması

ÖzellikClusteredNon-clustered
Tablo başına sayıSadece birBirden fazla
Yaprak seviyesinde ne var?Satırın bütün sütunlarıİndeks sütunları, satıra işaretçi ve varsa INCLUDE sütunları
Ek yerTablonun kendisi, ayrı bir kopya yokTablonun yanında ek yer kaplar
En iyi olduğu yerAralık sorguları, sıralama, birincil anahtarla erişimSeçici WHERE filtreleri, JOIN sütunları, covering sorgular
Yazma maliyetiAnahtarı değişen satır yer değiştirirHer indeks her yazma işleminde ayrıca güncellenir

Hangi sütunlara indeks koymalı?

  • WHERE, JOIN ve ORDER BY'da sık geçen sütunlar: Özellikle çok farklı değer alan, yani seçici sütunlar. E-posta seçici bir sütun; sadece iki değer alan bir IsActive sütunu ise tek başına zayıf bir aday.
  • Yabancı anahtarlar: SQL Server yabancı anahtar sütunlarına kendiliğinden indeks oluşturmaz. JOIN'lerde ve silme sırasında yapılan kontrollerde bu indeksler büyük fark yaratır. (EF Core migration'ları yabancı anahtarların indeksini sizin yerinize oluşturur.)
  • Hep belli bir kısmı sorgulanan tablolar: Satırların çoğu silinmiş olarak işaretliyse ve sorgular hep silinmemişleri arıyorsa, WHERE IsDeleted = 0 koşuluyla oluşturulan bir filtered index hem küçük hem hızlı olur.
  • Yazmanın yoğun olduğu tablolarda abartmayın: Her indeks her eklemede ayrıca güncellenir. Log ya da olay tablolarında az ama doğru indeks, çok indeksten iyidir.

İndeks olduğu hâlde kullanılmamasının en yaygın sebebi, sütunun bir fonksiyonun içine alınması. WHERE YEAR(KayitTarihi) = 2025 yazdığınızda SQL Server her satır için fonksiyonu hesaplamak zorunda kalır ve indeksle arama yapamaz. Aynı sorguyu aralık olarak yazarsanız indeks devreye girer:

SQL
-- İndeksle arama yapılamaz: her satırda YEAR() hesaplanır.
SELECT Id FROM Musteriler WHERE YEAR(KayitTarihi) = 2025;

-- İndeksle arama yapılabilir: aynı sonuç, aralık olarak.
SELECT Id FROM Musteriler
WHERE KayitTarihi >= '2025-01-01' AND KayitTarihi < '2026-01-01';

Başında joker karakter olan aramalar da öyle: LIKE 'Kara%' indeksi kullanabilir, LIKE '%güzel' kullanamaz.

Son olarak indeksi tahminle değil, ölçerek ekleyin. Execution plan, STATISTICS IO ve SQL Server'ın Query Store özelliği hangi sorgunun gerçekten pahalı olduğunu gösterir. SQL Server'ın "eksik indeks" önerileri iyi bir başlangıç ama her sorgu için ayrı ayrı üretiliyor. Hepsini düşünmeden uygularsanız birbirine çok benzeyen onlarca indeksle baş başa kalırsınız.

EF Core ile indeks tanımlamak

Code First çalışıyorsanız indeksleri model yapılandırmasında tanımlarsınız. Migration oluşturduğunuzda yukarıdaki SQL'in karşılığı üretilir:

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Musteri>()
        .HasIndex(m => m.Email)
        .IsUnique();

    // SQL Server'a özel: INCLUDE sütunları ve filtreli indeks.
    modelBuilder.Entity<Musteri>()
        .HasIndex(m => m.Sehir)
        .IncludeProperties(m => new { m.Ad, m.Soyad })
        .HasFilter("[Sehir] IS NOT NULL");
}

Sorguların EF Core tarafında nasıl SQL'e dönüştüğünü ve bu SQL'i nasıl hafifletebileceğinizi EF Core sorgu performansı yazısında anlattım. EF Core'a yeni başlıyorsanız EF Core serisinin ilk yazısından başlayabilirsiniz.

Sonuç

İndeks, veritabanının kitap dizini. Doğru sütundaki doğru indeks, milyonlarca satırlık bir tabloda aramayı binlerce sayfa okumaktan birkaç sayfa okumaya indiriyor. Clustered indeks verinin kendisini anahtar sırasıyla tutuyor ve tabloda bir tane olabiliyor. Non-clustered indeksler ise ayrı yapılar; birden fazla olabiliyorlar ve INCLUDE ile bir sorguyu tek başlarına karşılayabilir hâle getirilebiliyorlar. Her indeksin yazmaya ve diske bir maliyeti olduğunu unutmadan, execution plan'a ve okunan sayfa sayısına bakarak karar verin. Sitenin genel hızıyla ilgili diğer konular için Web Sitesi Hızı ve Core Web Vitals yazıma bakabilirsiniz.

Daha ayrıntılı bilgi isterseniz Microsoft'un SQL Server belgelerindeki "Clustered and nonclustered indexes" ve "SQL Server index architecture and design guide" sayfaları iyi bir kaynak.