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:

  • orders tablosu: type: ALL, rows: 3000000
  • order_items tablosu: 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 status kullanılıyorsa küçük bir değer döner
  • status ve customer_id kullanı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.

Benzer Konular

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir