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:
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:
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:
- 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.
- 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.
- 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:
-- 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.
INTya daBIGINTgibi 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:
IDENTITYgibi 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ızNEWSEQUENTIALID()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:
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:
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ı
| Özellik | Clustered | Non-clustered |
|---|---|---|
| Tablo başına sayı | Sadece bir | Birden 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 yer | Tablonun kendisi, ayrı bir kopya yok | Tablonun yanında ek yer kaplar |
| En iyi olduğu yer | Aralık sorguları, sıralama, birincil anahtarla erişim | Seçici WHERE filtreleri, JOIN sütunları, covering sorgular |
| Yazma maliyeti | Anahtarı değişen satır yer değiştirir | Her 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
IsActivesü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 = 0koş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:
-- İ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:
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.

