MySQL’de EXPLAIN ile Sorgu Planını Okuma ve Yorumlama
Bir gün production veritabanında sorgular aniden yavaşlamaya başladı. Uygulama logları timeout hatalarıyla doldu, kullanıcılar şikayet etmeye başladı ve sen de ekrana bakıp ne olduğunu anlamaya çalışıyorsun. İşte tam bu noktada MySQL’in en güçlü araçlarından biri devreye giriyor: EXPLAIN. Bu komut, MySQL’in bir sorguyu nasıl çalıştırdığını adım adım gösterir ve performans sorunlarının köküne inmenizi sağlar.
EXPLAIN Nedir ve Neden Kullanmalısınız
EXPLAIN, MySQL’e “bu sorguyu çalıştırırken ne yapacaksın?” diye sormaktır. MySQL, sorguyu gerçekten çalıştırmak yerine size bir yürütme planı döndürür. Bu plan sayesinde hangi tablolara hangi sırayla erişildiğini, hangi indekslerin kullanıldığını, kaç satırın tarandığını ve hangi operasyonların gerçekleştiğini görebilirsiniz.
Performans sorunlarını körü körüne çözmek yerine, EXPLAIN ile bilinçli kararlar alırsınız. “Bu sorguya indeks eklesem ne olur?” sorusunun cevabını tahmin etmek yerine görebilirsiniz. Bu farkı bir kez tattıktan sonra, EXPLAIN olmadan sorgu optimizasyonu yapmak size anlamsız gelmeye başlar.
Temel EXPLAIN Kullanımı
En basit haliyle EXPLAIN şöyle kullanılır:
mysql> EXPLAIN SELECT * FROM orders WHERE customer_id = 1001;
Bir adım daha ileri giderek EXPLAIN ANALYZE kullanabilirsiniz. Bu komut sorguyu gerçekten çalıştırır ve tahmini değerlerin yanında gerçek değerleri de gösterir:
mysql> EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 1001;
JSON formatında daha ayrıntılı çıktı almak istiyorsanız:
mysql> EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE customer_id = 1001;
Ve EXPLAIN EXTENDED ile ek bilgiler alabilirsiniz (MySQL 5.6 ve öncesi için önemliydi, günümüzde varsayılan olarak gelir):
mysql> EXPLAIN EXTENDED SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.city = 'Istanbul';
Çıktıdaki Sütunları Anlamak
EXPLAIN çıktısı birkaç kritik sütundan oluşur. Her birini gerçek dünya senaryolarıyla açıklayalım.
id Sütunu
Her satır bir sorgu bloğunu temsil eder. Alt sorgularda farklı id değerleri göreceksiniz. Aynı id’ye sahip satırlar birlikte çalışır, yüksek id’li satırlar önce çalışır.
mysql> EXPLAIN SELECT * FROM orders WHERE customer_id IN (
SELECT id FROM customers WHERE city = 'Ankara'
);
Bu sorguda iki farklı id değeri görürsünüz: iç sorgu için yüksek id, dış sorgu için düşük id. MySQL iç sorguyu önce çözümler.
select_type Sütunu
Sorgunun türünü belirtir. Sık karşılaştığınız değerler şunlardır:
- SIMPLE: Alt sorgu veya UNION içermeyen basit sorgu
- PRIMARY: UNION veya alt sorgu içeren dış sorgu
- SUBQUERY: SELECT veya WHERE içindeki alt sorgu
- DERIVED: FROM içindeki türetilmiş tablo (alt sorgu)
- UNION: UNION’daki ikinci veya sonraki sorgu
- DEPENDENT SUBQUERY: Dış sorguya bağımlı alt sorgu (genellikle kötü haber)
DEPENDENT SUBQUERY gördüğünüzde dikkatli olun. Bu, dış sorgunun her satırı için iç sorgunun tekrar çalıştırıldığı anlamına gelir.
type Sütunu
Bu sütun en kritik bilgiyi taşır. Tablo erişim türünü gösterir ve performans hakkında çok şey söyler. En iyiden en kötüye doğru sıralarsak:
- system: Tabloda sadece bir satır var
- const: PRIMARY KEY veya UNIQUE ile tek satır erişimi
- eq_ref: JOIN’de PRIMARY KEY veya UNIQUE indeks kullanımı
- ref: İndeksle eşleşme, birden fazla satır mümkün
- range: İndeks üzerinde aralık taraması (BETWEEN, IN, > gibi)
- index: Tüm indeks taranıyor ama tablo taranmıyor
- ALL: Tam tablo taraması, kötü haber
ALL veya index görüyorsanız ve tablonuz büyükse, bir sorun var demektir.
possible_keys ve key Sütunları
possible_keys MySQL’in kullanabileceklerini düşündüğü indeksleri listeler. key ise gerçekte kullanılan indeksi gösterir.
mysql> EXPLAIN SELECT * FROM orders
WHERE status = 'pending' AND created_at > '2024-01-01';
Bu sorguda possible_keys birkaç indeks listeleyebilir ama key sadece birini gösterir. MySQL en verimli olduğunu düşündüğü indeksi seçer. Bazen seçim yanlış olabilir ve bu durumda FORCE INDEX kullanabilirsiniz.
rows Sütunu
MySQL’in tarayacağını tahmin ettiği satır sayısıdır. Bu bir tahmindir, kesin değil. Ama büyük farklılıklar soruna işaret eder.
Bir tabloda 1 milyon satır varsa ve rows değeri de 1 milyon gösteriyorsa, tam tablo taraması yapılıyor demektir. Bu durumda indeks eklemeyi düşünmelisiniz.
Extra Sütunu
Ek bilgiler içerir ve bazı değerler kritik uyarılar niteliğindedir:
- Using index: Sorgu sadece indeksten karşılanıyor, tablo erişimi yok (harika!)
- Using where: WHERE filtresi uygulanıyor
- Using temporary: Geçici tablo oluşturuluyor (dikkat!)
- Using filesort: Bellekte veya diskte sıralama yapılıyor (dikkat!)
- Using join buffer: JOIN buffer kullanılıyor, indeks eksik olabilir
- Impossible WHERE: WHERE koşulu hiçbir zaman doğru olamaz
Using temporary ve Using filesort beraber görünüyorsa, büyük ihtimalle GROUP BY veya ORDER BY içeren sorgularınızda indeks optimizasyonu gerekiyor.
Gerçek Dünya Senaryosu 1: Tam Tablo Taraması Tespiti
Bir e-ticaret sisteminde şikayetler gelmeye başladı. Sipariş listesi sayfası 8-10 saniye yükleniyor. Sorguya bakalım:
mysql> EXPLAIN SELECT o.id, o.total_amount, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'completed'
AND o.created_at BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY o.created_at DESC
LIMIT 50;
Çıktıda orders tablosu için type: ALL ve rows: 2450000 görüyorsunuz. 2.4 milyon satır taranıyor! Extra sütununda da Using filesort var.
Çözüm: Composite indeks ekleyelim:
mysql> ALTER TABLE orders
ADD INDEX idx_status_created (status, created_at);
Sonra tekrar EXPLAIN çalıştırın:
mysql> EXPLAIN SELECT o.id, o.total_amount, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'completed'
AND o.created_at BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY o.created_at DESC
LIMIT 50;
Şimdi type: range, key: idx_status_created, rows: 15000 ve Extra: Using index condition görmelisiniz. Sorgu süresi 8 saniyeden 0.2 saniyeye indi.
Gerçek Dünya Senaryosu 2: JOIN Optimizasyonu
Raporlama sorgularından biri her gece çalışıyor ve saatlerce sürüyor. Sorguya bakalım:
mysql> EXPLAIN SELECT
p.name AS product_name,
COUNT(oi.id) AS order_count,
SUM(oi.quantity) AS total_quantity,
SUM(oi.price * oi.quantity) AS revenue
FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
LEFT JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
AND o.created_at >= '2024-01-01'
GROUP BY p.id
ORDER BY revenue DESC;
EXPLAIN çıktısını analiz ediyorsunuz:
orderstablosu:type: ALL,rows: 3000000order_itemstablosu:type: ALL,rows: 8000000- Her iki tabloda da
Using temporary; Using filesort
Bu felakettir. 24 milyon satır kombinasyonu işleniyor. Çözüm:
mysql> ALTER TABLE orders ADD INDEX idx_status_date (status, created_at);
mysql> ALTER TABLE order_items ADD INDEX idx_product_order (product_id, order_id);
mysql> ALTER TABLE order_items ADD INDEX idx_order_id (order_id);
İndeksler eklendikten sonra EXPLAIN tekrar çalıştırılır ve type değerleri ref ve range olarak değişir, rows tahminleri dramatik biçimde düşer.
Gerçek Dünya Senaryosu 3: Alt Sorgu Tuzağı
Bir dashboard sorgusu her yüklenmede sunucuyu %90 CPU kullanımına çıkarıyordu:
mysql> EXPLAIN SELECT
c.name,
c.email,
(SELECT COUNT(*) FROM orders WHERE customer_id = c.id) AS order_count,
(SELECT SUM(total_amount) FROM orders WHERE customer_id = c.id) AS total_spent
FROM customers c
WHERE c.created_at >= '2024-01-01';
EXPLAIN çıktısı üç satır döndürür. İki alt sorgu için select_type: DEPENDENT SUBQUERY görürsünüz. Bu, her müşteri için iki ayrı sorgu çalıştırılıyor demektir. 50.000 müşteri varsa, 100.000 ek sorgu demektir!
Çözüm, sorguyu JOIN’e dönüştürmektir:
mysql> EXPLAIN SELECT
c.name,
c.email,
COUNT(o.id) AS order_count,
SUM(o.total_amount) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE c.created_at >= '2024-01-01'
GROUP BY c.id, c.name, c.email;
Şimdi select_type: SIMPLE, type: ref ve tek bir geçiş var. Sorgu süresi 45 saniyeden 1.2 saniyeye indi.
filtered Sütununu Anlamak
EXPLAIN çıktısında filtered sütunu da önemlidir. Bu değer, WHERE koşulunun kaç satırı eleyeceğini yüzde olarak tahmin eder.
mysql> EXPLAIN SELECT * FROM orders
WHERE customer_id = 500 AND status = 'pending';
Eğer rows: 1000 ve filtered: 10.00 görüyorsanız, indeks 1000 satır bulacak ama WHERE koşulu bunların yalnızca %10’unu (100 satır) geçirecek demektir. Bu, composite indeks için iyi bir aday olduğunu gösterir.
EXPLAIN ile Covering Index Doğrulaması
Covering index, sorgunun ihtiyaç duyduğu tüm sütunları içeren bir indekstir. Bu durumda MySQL tabloya hiç gitmez. Extra: Using index bunu doğrular.
mysql> EXPLAIN SELECT customer_id, status, created_at
FROM orders
WHERE status = 'pending'
AND created_at > '2024-06-01';
Eğer status ve created_at üzerinde bir indeks varsa ve SELECT’te sadece bu sütunları istiyorsanız, Extra: Using index göreceksiniz. Tablo erişimi sıfır, bu ideal durumdur.
Şimdi customer_id ekleyin:
mysql> ALTER TABLE orders
ADD INDEX idx_covering (status, created_at, customer_id);
Tekrar EXPLAIN çalıştırdığınızda Using index korunur çünkü indeks artık customer_id‘yi de kapsıyor.
EXPLAIN ile Partition Kontrolü
Partitioned tablolarla çalışıyorsanız, EXPLAIN PARTITIONS hangi bölümlerin tarandığını gösterir:
mysql> EXPLAIN PARTITIONS SELECT * FROM logs
WHERE log_date = '2024-11-15'
AND level = 'ERROR';
partitions sütununda sadece ilgili partition görünüyorsa, partition pruning çalışıyor demektir. Tüm partitionlar listeleniyorsa, partition key’ini WHERE’e eklemeniz gerekebilir.
EXPLAIN Sonuçlarını Betik ile Kaydetme
Production ortamında yavaş sorguları otomatik olarak analiz etmek için şöyle bir yaklaşım kullanabilirsiniz:
#!/bin/bash
# slow_query_explain.sh
# MySQL slow query log'dan sorguları alıp EXPLAIN çalıştırır
MYSQL_USER="monitor"
MYSQL_PASS="password"
MYSQL_DB="production_db"
SLOW_LOG="/var/log/mysql/slow.log"
OUTPUT_DIR="/var/log/mysql/explain_reports"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $OUTPUT_DIR
# pt-query-digest ile benzersiz sorguları al ve EXPLAIN çalıştır
pt-query-digest --output=query_review $SLOW_LOG 2>/dev/null |
grep "^SELECT|^select" | head -20 | while read query; do
echo "=== Query: $query ===" >> "$OUTPUT_DIR/explain_$DATE.txt"
mysql -u$MYSQL_USER -p$MYSQL_PASS $MYSQL_DB
-e "EXPLAIN FORMAT=JSON $query" 2>/dev/null >> "$OUTPUT_DIR/explain_$DATE.txt"
echo "" >> "$OUTPUT_DIR/explain_$DATE.txt"
done
echo "EXPLAIN raporları $OUTPUT_DIR/explain_$DATE.txt dosyasına yazildi"
Key Length (key_len) Sütununu Doğru Okumak
key_len, kullanılan indeksin kaç byte’lık kısmının kullanıldığını gösterir. Bu özellikle composite indekslerde önemlidir.
mysql> EXPLAIN SELECT * FROM orders
WHERE status = 'completed'
AND customer_id = 1001
AND created_at = '2024-11-15';
Diyelim ki (status, customer_id, created_at) composite indeksiniz var. key_len değeri:
- Sadece
statuskullanılıyorsa küçük bir değer döner statusvecustomer_idkullanılıyorsa orta bir değer döner- Üçü de kullanılıyorsa tam uzunluk döner
Bu değeri bilmek, hangi sütunlara kadar indeksin aktif çalıştığını anlamanızı sağlar. VARCHAR(50) için yaklaşık 203 byte (UTF8MB4 için), INT için 4 byte, NULL içerebilen sütunlar için +1 byte eklenir.
Sık Yapılan Hatalar ve Kaçınma Yolları
EXPLAIN kullanırken dikkat edilmesi gereken birkaç nokta var:
Fonksiyon içindeki sütunlar indeksi devre dışı bırakır. Şunu yazmayın:
mysql> EXPLAIN SELECT * FROM orders
WHERE YEAR(created_at) = 2024;
-- Bu tam tablo taraması yapar!
-- Bunun yerine:
mysql> EXPLAIN SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- Bu range taraması yapar
Implicit type conversion da indeksi etkisiz kılar. customer_id INT ise şunu yazmayın:
mysql> EXPLAIN SELECT * FROM orders WHERE customer_id = '1001';
-- String'i int'e dönüştürme indeksi bozabilir
-- Doğrusu:
mysql> EXPLAIN SELECT * FROM orders WHERE customer_id = 1001;
OR koşulları bazen indeksi atlayabilir. Bu durumda UNION daha iyi sonuç verebilir:
mysql> EXPLAIN SELECT * FROM orders
WHERE status = 'pending' OR status = 'processing';
-- Alternatif:
mysql> EXPLAIN SELECT * FROM orders WHERE status = 'pending'
UNION ALL
SELECT * FROM orders WHERE status = 'processing';
EXPLAIN ile İndeks Seçimini Yönlendirme
Bazen MySQL optimizer yanlış indeks seçer. Bunu EXPLAIN ile tespit edip FORCE INDEX ile düzeltebilirsiniz:
mysql> EXPLAIN SELECT * FROM orders
USE INDEX (idx_status_created)
WHERE status = 'completed'
AND created_at > '2024-01-01';
Farklı indeksleri test edip rows değerlerini karşılaştırın, hangisi daha az satır tarıyorsa onu tercih edin. Ancak FORCE INDEX kullanmadan önce defalarca düşünün; istatistikleri güncellemek genellikle daha sağlıklı bir çözümdür:
mysql> ANALYZE TABLE orders;
Sonuç
EXPLAIN komutu, MySQL performans sorunlarını çözmek için vazgeçilmez bir araçtır. Yavaş sorgularla karşılaştığınızda ilk yapmanız gereken şey EXPLAIN çalıştırmak ve çıktıyı dikkatle okumaktır.
Özetlemek gerekirse, type sütununda ALL görüyorsanız alarm zilleri çalmalı. Extra sütununda Using temporary; Using filesort kombinasyonu varsa, sorgunuzu veya indekslerinizi gözden geçirin. DEPENDENT SUBQUERY görüyorsanız, alt sorguyu JOIN’e dönüştürmeyi deneyin. rows sütunundaki değer tablonuzun toplam satır sayısına yakınsa, indeks ya yok ya da kullanılmıyor.
Günlük rutin olarak MySQL slow query log’u açık tutun, periyodik olarak EXPLAIN analizleri yapın ve production’a bir sorgu göndermeden önce test ortamında EXPLAIN ile kontrol edin. Bu alışkanlıkları edindikten sonra, “veritabanı neden yavaş?” sorusuyla uğraşmak yerine çok daha hızlı ve bilinçli çözümler üreteceksiniz.
