Yavaş sorguyu bulmak işin kolay kısmı; asıl soru "neden yavaş" sorusudur ve bu sorunun tek doğru cevap kaynağı MySQL EXPLAIN çıktısıdır. EXPLAIN, sorguyu çalıştırmadan optimizer'ın hangi tabloları hangi sırayla okuyacağını, hangi index'i seçtiğini ve kaç satır tarayacağını tahmin ederek gösterir. Yani sorgunun içine bakmanı sağlayan tek pencere odur; onsuz yapılan her optimizasyon denemesi tahmindir.
Bu rehberde EXPLAIN çıktısının her sütununu ne anlama geldiğiyle birlikte açıklayacağım, type sütunundaki erişim yöntemlerini en iyiden en kötüye sıralayacağım, Extra sütunundaki Using filesort ve Using temporary gibi uyarıların gerçekte ne maliyeti olduğunu göstereceğim ve tahmin ile gerçeği karşılaştıran EXPLAIN ANALYZE komutunu ele alacağım. Sonunda elinde, bir plan çıktısına bakıp beş saniye içinde "sorun burada" diyebilmeni sağlayacak bir okuma yöntemi olacak.
EXPLAIN Nasıl Çalıştırılır ve Hangi Biçimler Var#
En basit kullanım, sorgunun başına EXPLAIN yazmaktır. MySQL 8.0 ve MariaDB 10.x'te üç ayrı çıktı biçimi vardır ve her biri farklı bir işe yarar:
-- 1) Klasik tablo biçimi: hızlı bakış için
EXPLAIN SELECT s.id, s.tutar, m.ad
FROM siparis s
JOIN musteri m ON m.id = s.musteri_id
WHERE s.durum = 2 AND s.olusturma >= '2026-01-01';
-- 2) JSON biçimi: maliyet tahminleri ve iç detay
EXPLAIN FORMAT=JSON SELECT ...;
-- 3) Gerçekten çalıştırıp ölçer (MySQL 8.0.18+)
EXPLAIN ANALYZE SELECT ...;
Klasik biçim günlük kullanımda yeterlidir. FORMAT=JSON ise optimizer'ın hesapladığı query_cost değerini ve hangi koşulun index'e itildiğini (attached_condition, index_condition) gösterir; iki alternatif index arasında karar verirken bakılacak yer burasıdır. EXPLAIN ANALYZE ise sorguyu gerçekten çalıştırır ve her adım için tahmin edilen satır sayısıyla gerçekleşen satır sayısını yan yana koyar. Bu ayrım kritiktir: klasik EXPLAIN'deki rows sütunu bir tahmindir ve istatistikler bayatsa gerçeğin çok uzağında olabilir.
Bir uyarı: EXPLAIN ANALYZE sorguyu çalıştırdığı için UPDATE veya DELETE üzerinde deneme yaparken dikkatli ol; canlıda değil, yedekten dönülmüş bir kopyada çalıştırmak doğru olanıdır.
Çıktıdaki Sütunlar Tek Tek Ne Anlatır#
Klasik EXPLAIN çıktısı satır başına bir tablo erişimi gösterir. Sütunların anlamı şöyle:
| Sütun | Ne söyler | Nelere dikkat edilir |
|---|---|---|
id | Sorgu içindeki seçim bloğunun numarası | Alt sorgular ayrı id alır; büyük id önce çalışır |
select_type | SIMPLE, PRIMARY, SUBQUERY, DERIVED, UNION | DEPENDENT SUBQUERY çoğu zaman kötü haberdir |
table | Bu adımda okunan tablo veya türetilmiş tablo | derived2 gibi değerler geçici tabloyu gösterir |
type | Erişim yöntemi | En kritik sütun; aşağıda ayrıntılı |
possible_keys | Kullanılabilecek index'ler | Boşsa uygun index hiç yok demektir |
key | Gerçekten seçilen index | NULL ise tam tarama yapılıyor |
key_len | Index'in kaç baytının kullanıldığı | Bileşik index'in kaç kolonunun devrede olduğunu gösterir |
ref | Index'e karşılaştırılan değer | const en iyisi, kolon adı JOIN'i gösterir |
rows | Taranacağı tahmin edilen satır | Toplam satıra yakınsa index işe yaramıyordur |
filtered | rows içinden WHERE sonrası kalan yüzde | Düşükse gereksiz satır okunuyordur |
Extra | Ek işlemler | Using filesort, Using temporary, Using index |
Bu sütunlar arasında en çok yanlış anlaşılan key_len'dir. Bileşik bir index'te bu değer, index'in kaç kolonunun gerçekten aramada kullanıldığını hesaplamanı sağlar. INT kolon 4 bayt, NULL olabilen kolon +1 bayt, VARCHAR(50) utf8mb4'te 50×4+2 bayt yer kaplar. KEY (musteri_id, durum, olusturma) index'inde key_len yalnızca 4 görünüyorsa, sadece ilk kolon devrededir; diğer ikisi aramaya katılmıyordur. Bu, bileşik index sırasının neden bu kadar önemli olduğunu ölçülebilir biçimde gösterir; konunun tamamını SQL index tasarımı yazısında ele aldım.
type Sütunu: En İyiden En Kötüye Erişim Yöntemleri#
type, sorgunun performansını belirleyen tek sütun sayılır. Aşağıdaki sıralamayı ezberlemek yerine mantığını kavramak yeterli: yukarıdan aşağı indikçe okunan satır sayısı artar.
| type | Anlamı | Yorum |
|---|---|---|
system / const | Tabloda tek satır var ya da PK ile tek satır bulundu | En hızlısı |
eq_ref | JOIN'de her satır için tekil index'ten tam bir satır | JOIN'lerde hedeflenen değer |
ref | Index üzerinden eşitlikle birden çok satır | Sağlıklı, yaygın durum |
range | Index üzerinde aralık taraması | BETWEEN, >, IN için normal |
index | Tüm index ağacı baştan sona tarandı | Tam taramadan biraz iyi, yine de pahalı |
ALL | Tam tablo taraması | Küçük tablolar dışında kırmızı alarm |
type: ALL gördüğünde önce şunu sor: tablo gerçekten küçük mü? Birkaç bin satırlık bir referans tablosunda tam tarama tamamen normaldir, hatta index'ten hızlıdır. Ama satır sayısı yüz binlerdeyse, possible_keys sütununa bak. Orada da NULL varsa uygun index yok demektir; index varsa ama key boşsa optimizer onu kullanmamayı seçmiştir ve nedeni genelde tip uyumsuzluğu, kolona uygulanan bir fonksiyon ya da bayat istatistiklerdir.
-- Optimizer'ın kararını istatistikler belirler; toplu yüklemeden sonra tazele
ANALYZE TABLE siparis, musteri;
-- Zorlamak gerekirse (son çare, kalıcı çözüm değil)
SELECT * FROM siparis FORCE INDEX (ix_musteri_durum_tarih)
WHERE musteri_id = 42 AND durum = 2;
FORCE INDEX bir teşhis aracıdır, çözüm değildir. Zorladığında sorgu hızlanıyorsa, asıl yapılacak iş index'i ya da sorguyu düzeltmektir; FORCE INDEX kodda kalırsa veri dağılımı değiştiğinde yanlış planı sabitlemiş olursun.
Extra Sütunundaki Uyarılar ve Gerçek Maliyetleri#
Extra sütunu, tablo erişiminden sonra yapılan işleri anlatır ve bazen asıl maliyet oradadır. En sık karşılaştıkların şunlardır:
Using index: Covering index devrede, tabloya hiç gidilmiyor. Görmek istediğin ifade budur.Using where: Index'ten gelen satırlar ayrıca filtreleniyor. Tek başına kötü değil amafiltereddeğeri düşükse boşa satır okunuyordur.Using index condition: Index Condition Pushdown çalışıyor; koşul storage engine katmanına itilmiş, iyi bir işaret.Using filesort: Sıralama index'ten karşılanamadı, sonuç kümesi ayrıca sıralanıyor. Küçük kümelerde bellekte olur, büyükte diske taşar.Using temporary: Ara sonuç için geçici tablo oluşturuldu.GROUP BYveDISTINCTile birlikte çok yaygındır.
Using filesort ifadesi ismine rağmen her zaman disk kullanmaz; küçük kümeler sort_buffer_size içinde bellekte sıralanır. Ama sonuç kümesi büyükse diske taşar ve maliyet fırlar. Bunu ölçebilirsin:
-- Oturum içinde sıfırla, sorguyu çalıştır, sonra bak
FLUSH STATUS;
SELECT ... ORDER BY olusturma DESC LIMIT 50;
SHOW SESSION STATUS LIKE 'Sort_merge_passes';
SHOW SESSION STATUS LIKE 'Created_tmp_disk_tables';
Sort_merge_passes sıfırdan büyükse sıralama diske taşmıştır. Created_tmp_disk_tables artıyorsa geçici tablo diske düşmüştür; bu genelde TEXT/BLOB kolonlarının sonuç kümesinde bulunmasından ya da tmp_table_size değerinin düşük kalmasından kaynaklanır. Bellek parametrelerini nasıl dengeleyeceğini my.cnf optimizasyon rehberi yazısında anlattım.
EXPLAIN ANALYZE: Tahmin ile Gerçeği Karşılaştırmak#
Klasik EXPLAIN sana optimizer'ın ne düşündüğünü söyler. EXPLAIN ANALYZE ise ne olduğunu söyler. Çıktı ağaç biçimindedir ve her düğümde iki değer taşır: rows= (tahmin) ve actual rows= (gerçek), ayrıca actual time= ile ilk satırın ve son satırın geldiği milisaniye.
EXPLAIN ANALYZE
SELECT m.ad, COUNT(*) AS adet
FROM siparis s JOIN musteri m ON m.id = s.musteri_id
WHERE s.olusturma >= '2026-01-01'
GROUP BY m.ad;
Çıktıda tipik olarak şuna benzer satırlar görürsün:
-> Table scan on temp_tablo (actual time=0.002..0.310 rows=1200 loops=1)
-> Aggregate using temporary table (actual time=182.4..182.6 rows=1200 loops=1)
-> Nested loop inner join (cost=48210 rows=96000) (actual time=0.09..151.2 rows=284100 loops=1)
Buradaki en değerli bilgi rows=96000 tahmini ile actual rows=284100 gerçeği arasındaki üç katlık sapmadır. Tahmin gerçekten bu kadar uzaksa optimizer yanlış plan seçmiş olabilir; ilk yapılacak iş ANALYZE TABLE ile istatistikleri tazelemektir. Bir diğer kritik değer loops= sayısıdır: iç içe döngü birleşiminde iç tablonun kaç kez tarandığını gösterir. loops binlerse ve iç tabloda index yoksa, asıl sorun budur.
Gerçek Bir Sorguyu Adım Adım Düzeltmek#
Teoriyi somutlaştıralım. Aşağıdaki sorgu bir sipariş panelinde çalışıyor ve dört saniye sürüyor:
SELECT s.id, s.tutar, m.ad
FROM siparis s JOIN musteri m ON m.id = s.musteri_id
WHERE YEAR(s.olusturma) = 2026 AND s.durum = 2
ORDER BY s.olusturma DESC
LIMIT 50;
İlk EXPLAIN çıktısında siparis için type: ALL, key: NULL, rows: 2.8M ve Extra: Using where; Using filesort görüyorsun. Düzeltme sırası şöyle olmalı:
- Fonksiyonu kaldır.
YEAR(s.olusturma) = 2026index'i öldürüyor; aralığa çevir:s.olusturma >= '2026-01-01' AND s.olusturma < '2027-01-01'. - Doğru sıralı bileşik index'i aç. Eşitlik önce, aralık sonra:
KEY ix_durum_tarih (durum, olusturma). Böylece hem filtre hemORDER BYaynı index'ten karşılanır veUsing filesortkaybolur. - JOIN kolonunu doğrula.
musteri.idbirincil anahtar olduğu içintype: eq_refgörmelisin; görmüyorsan karakter seti veya tip uyumsuzluğu vardır. - Tekrar EXPLAIN al. Beklenen çıktı:
siparisiçintype: range,key: ix_durum_tarih,rowsbirkaç bin,ExtraiçindeUsing index condition. - Gerekirse covering index'e çevir.
tutarvemusteri_idkolonlarını index'e ekleyerek tabloya dönüşü tamamen ortadan kaldırabilirsin.
Bu beş adım, gerçek hayatta karşılaştığım yavaş sorguların büyük çoğunluğunu çözer. Sorgu hâlâ yavaşsa sorun genelde tekil sorguda değil, eşzamanlılıkta veya kilitlerdedir; o zaman MySQL deadlock çözümü yazısındaki kilit teşhis yöntemlerine geçmen gerekir. Çok büyük geçmiş tablolarında ise bölümleme (partitioning) EXPLAIN çıktısındaki partitions sütununda görünen bir kazanç sağlar.
Sıkça Sorulan Sorular#
EXPLAIN sorguyu gerçekten çalıştırır mı#
Klasik EXPLAIN sorguyu çalıştırmaz; yalnızca optimizer'ın planını üretir, dolayısıyla veriye dokunmaz ve güvenlidir. EXPLAIN ANALYZE ise sorguyu gerçekten çalıştırır ve ölçüm yapar. Bu yüzden SELECT dışındaki ifadelerde EXPLAIN ANALYZE kullanmadan önce iki kez düşün; canlı veriyi değiştirebilir. Türetilmiş tablolar içeren bazı sorgularda klasik EXPLAIN de alt sorguyu materyalize edebilir, ama bu okuma maliyetiyle sınırlıdır.
rows sütunundaki sayı neden gerçekten okunan satırla aynı değil#
Çünkü o sayı bir tahmindir ve index istatistiklerinden türetilir. InnoDB istatistikleri örnekleme ile toplar, bu yüzden veri dağılımı dengesizse tahmin sapabilir. Toplu veri yükledikten, büyük bir silme yaptıktan veya tabloyu yeniden inşa ettikten sonra ANALYZE TABLE çalıştırmak tahminleri gerçeğe yaklaştırır. Gerçek sayıyı görmek istiyorsan EXPLAIN ANALYZE çıktısındaki actual rows değerine bakmalısın.
Using filesort her zaman kötü müdür#
Hayır. LIMIT 20 ile sonlanan ve yalnızca birkaç yüz satır sıralayan bir sorguda filesort bellekte gerçekleşir ve maliyeti ihmal edilebilir. Sorun, sıralanacak küme büyüdüğünde ve sort_buffer_size yetmeyip disk kullanıldığında başlar. Bunu Sort_merge_passes durum değişkeninden anlayabilirsin; sıfırdan büyükse sıralama diske taşmıştır ve ORDER BY kolonunu kapsayan bir index açmak doğru çözümdür.
possible_keys dolu ama key boş, neden#
Optimizer index'i görmüş ama kullanmamayı seçmiştir. En yaygın üç sebep şunlardır: kolona uygulanan bir fonksiyon veya tip dönüşümü index'i kullanılamaz hale getirmiştir; index çok fazla satır eşleştirdiği için optimizer tam taramayı daha ucuz bulmuştur; ya da istatistikler bayattır. Sırayla sorgudaki fonksiyonları temizle, ANALYZE TABLE çalıştır, sonuç değişmezse FORCE INDEX ile karşılaştırma yaparak hangi planın gerçekten hızlı olduğunu ölç.
EXPLAIN çıktısındaki filtered yüzdesi neyi ifade eder#
filtered, o adımda okunan rows satırının yüzde kaçının WHERE koşullarından geçtiğini tahmin eder. Yüzde 100 ise okunan her satır sonuca katkı sağlıyordur. Yüzde 2 gibi düşük bir değer, satırların yüzde 98'inin boşuna okunduğu anlamına gelir ve genelde index'e ek bir kolon eklemek gerektiğini gösterir. rows ile filtered çarpımı, o adımdan sonraki tabloya kaç satır taşınacağının kaba tahminidir.
Alt sorgu mu JOIN mi daha hızlıdır#
Modern MySQL sürümlerinde optimizer birçok alt sorguyu otomatik olarak yarı birleşime (semi-join) çevirir, bu yüzden fark eskisi kadar keskin değildir. Yine de EXPLAIN çıktısında select_type: DEPENDENT SUBQUERY görüyorsan alt sorgu dış sorgunun her satırı için tekrar çalışıyordur ve bu neredeyse her zaman yavaştır. Böyle durumlarda sorguyu açık bir JOIN ya da EXISTS biçimine çevirmek belirgin kazanç sağlar.
EXPLAIN'i canlı sunucuda çalıştırmak güvenli mi#
Klasik EXPLAIN neredeyse maliyetsizdir ve canlıda rahatlıkla kullanılabilir. EXPLAIN FORMAT=JSON da aynı şekilde güvenlidir. Dikkat edilmesi gereken tek biçim EXPLAIN ANALYZE'dır: sorguyu gerçekten çalıştırdığı için ağır bir raporlama sorgusunda sunucuyu meşgul edebilir. Ağır sorguları önce bir kopya ortamda ölçmek, canlıda ise yalnızca klasik EXPLAIN ile plan doğrulaması yapmak iyi bir denge kurar.
Kapanış#
EXPLAIN okumayı öğrenmek, veritabanı tarafında yapabileceğin en yüksek getirili yatırımdır. Aklında kalması gereken birkaç refleks var: önce type sütununa bak, ALL görüyorsan index'e; sonra key_len ile bileşik index'in kaç kolonunun gerçekten devrede olduğunu doğrula; Extra sütunundaki filesort ve temporary uyarılarını sıralama ve gruplama index'leriyle çözmeye çalış; tahminden şüphelendiğinde EXPLAIN ANALYZE ile gerçeği ölç ve gerektiğinde ANALYZE TABLE ile istatistikleri tazele.
Sorgu planı düzeldiği hâlde yanıt süreleri hâlâ dalgalanıyorsa, darboğaz büyük ihtimalle sorguda değil altyapıdadır: yetersiz bellek, yavaş disk ya da CPU sıkışması. Veritabanını izole bir kaynak havuzunda çalıştırmak istersen VDS ve bulut sunucu paketlerimiz NVMe disk ve garanti edilen çekirdek sunar; performans testlerini yaparken bant genişliği hesaplayıcı gibi araçlarımız da işini kolaylaştırır. Ayar ve izleme yükünü devretmek isterseniz sunucu yönetimi hizmetimiz sorgu analizini de kapsıyor.