← Kursa Dön
📄 Text · 30 min

Query Optimization Best Practices

Giriş — İyi Sorgu Yazmak Bir Alışkanlıktır

Bir önceki derste yavaş sorguları nasıl tespit edeceğimizi öğrendik. Ama asıl soru şu: en baştan yavaş sorgu yazmamak mümkün mü? Evet, mümkün. Çoğu performans sorunu, sorgu yazarken dikkat edilecek birkaç temel kuralla önlenebilir.

Bu derste, SQL sorgularını yazarken uyman gereken best practice'leri öğreneceğiz. Bunlar sadece teorik kurallar değil — binlerce production veritabanında kanıtlanmış, deneyimli veritabanı yöneticilerinin yıllar içinde öğrendiği pratik bilgiler. Bu kuralları alışkanlık haline getirirsen, optimizasyona ihtiyaç duyma sıklığın dramatik şekilde azalır.

🎯 Analoji: Arabayı tamir etmek yerine düzenli bakım yapmak gibi düşün. Yağ değiştirme, lastik kontrolü, fren bakımı — bunları zamanında yaparsan tamirciye gitme ihtiyacın çok azalır. SQL best practice'leri de "bakım" gibidir: sorun oluşmadan önleme.


1. SELECT * Kullanma — Sadece İhtiyacın Olan Sütunları Seç

Bu, en yaygın ve en kolay düzeltilebilir performans hatası. SELECT * kullanmak alışkanlık haline gelir ama ciddi performans maliyeti vardır.

Neden SELECT * Kötü?

-- ❌ KÖTÜ: Tüm sütunları çeker
SELECT * FROM orders WHERE customer_id = 12345;

Bu sorgu, orders tablosundaki tüm sütunları getirir: order_id, customer_id, order_date, status, shipping_address, billing_address, notes, total_amount, discount_amount, tax_amount, created_at, updated_at... Belki sen sadece order_id ve total_amount istiyorsun.

-- ✅ İYİ: Sadece ihtiyacın olan sütunlar
SELECT order_id, total_amount, order_date 
FROM orders 
WHERE customer_id = 12345;

SELECT * neden yavaş?

  1. Daha fazla veri transferi: 15 sütun yerine 3 sütun çekmek, ağ trafiğini %80 azaltabilir

  2. Covering index kullanılamaz: Eğer (customer_id, order_id, total_amount, order_date) şeklinde bir composite index varsa, SELECT * yerine sadece bu sütunları seçersen index'ten direkt cevap alırsın — tabloya hiç gitmeden

  3. Bellek tüketimi: Uygulama tarafında gereksiz sütunlar bellek yer kaplar

  4. Kırılgan kod: Tabloya yeni sütun eklenince uygulama kodun beklenmedik veriler alabilir

-- Covering index örneği
CREATE INDEX idx_orders_cust_covering 
ON orders(customer_id, order_id, total_amount, order_date);

-- ✅ Bu sorgu sadece index'ten cevap alır (Using index)
SELECT order_id, total_amount, order_date 
FROM orders 
WHERE customer_id = 12345;

-- ❌ Bu sorgu tabloya da gitmek zorunda (SELECT * her sütunu ister)
SELECT * FROM orders WHERE customer_id = 12345;

💡 İpucu: Geliştirme aşamasında bile SELECT * yerine sütun isimlerini yaz. Bu alışkanlığı edinmek, production'da seni kurtaracak en değerli alışkanlıklardan biri.


2. Index-Friendly WHERE Koşulları Yaz

Index'in çalışması için WHERE koşulunun index ile uyumlu olması gerekir. Bazı yazım şekilleri index'i devre dışı bırakır — bunlara sargable olmayan (Search ARGument ABLE) koşullar denir.

Sütuna Fonksiyon Uygulama

-- ❌ Non-sargable: Fonksiyon index'i devre dışı bırakır
SELECT * FROM orders WHERE YEAR(order_date) = 2024;
SELECT * FROM customers WHERE UPPER(email) = 'TEST@EMAIL.COM';
SELECT * FROM orders WHERE DATE(created_at) = '2024-06-15';

-- ✅ Sargable: Index kullanılır
SELECT * FROM orders 
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';

SELECT * FROM customers WHERE email = 'test@email.com';
-- (email karşılaştırmasında collation'a bağlı — utf8_general_ci zaten case-insensitive)

SELECT * FROM orders 
WHERE created_at >= '2024-06-15 00:00:00' 
  AND created_at < '2024-06-16 00:00:00';

Matematiksel İşlem

-- ❌ Non-sargable: Sütun üzerinde hesaplama
SELECT * FROM products WHERE price * 1.18 > 1000;
SELECT * FROM order_items WHERE quantity + 5 > 10;

-- ✅ Sargable: Hesaplamayı değer tarafına taşı
SELECT * FROM products WHERE price > 1000 / 1.18;  -- price > 847.46
SELECT * FROM order_items WHERE quantity > 5;  -- 10 - 5 = 5

OR Koşulları

-- ❌ Potansiyel problem: OR farklı sütunlarda
SELECT * FROM orders 
WHERE customer_id = 12345 OR status = 'pending';
-- Optimizer bu sorguyu optimize etmekte zorlanabilir

-- ✅ Alternatif: UNION ALL kullan
SELECT * FROM orders WHERE customer_id = 12345
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND customer_id != 12345;
-- Her iki sorgu da kendi index'ini kullanabilir

⚠️ Dikkat: UNION ALL kullanırken tekrar eden satırları yönetmen gerekir. İkinci sorgudaki customer_id != 12345 koşulu, birinci sorguyla çakışan satırları engelliyor. Eğer tekrar önemli değilse veya olamayacağını biliyorsan bu ek koşula gerek yok.


3. N+1 Sorgu Problemi — Sessiz Katil

N+1 problemi, veritabanı performans sorunlarının en sinsi olanıdır. Tek tek bakıldığında her sorgu hızlıdır ama toplam etki felaket olabilir.

N+1 Nedir?

Bir liste çekiyorsun (1 sorgu), sonra listedeki her eleman için ayrı bir sorgu atıyorsun (N sorgu). Toplam: N+1 sorgu.

Senaryo: Sipariş listesi sayfası — her siparişin yanında müşteri adı gösterilecek.
-- ❌ N+1 YAKLAŞIMI (uygulama kodunda):

-- Sorgu 1: Tüm siparişleri çek
SELECT * FROM orders WHERE status = 'pending';
-- → 500 sipariş döndü

-- Sorgu 2-501: Her sipariş için müşteri bilgisini çek
SELECT first_name, last_name FROM customers WHERE customer_id = 101;
SELECT first_name, last_name FROM customers WHERE customer_id = 102;
SELECT first_name, last_name FROM customers WHERE customer_id = 103;
... (497 sorgu daha)

-- Toplam: 501 sorgu! Her biri 2ms sürse bile → 501 × 2ms = 1 saniye
-- ✅ JOIN YAKLAŞIMI: Tek sorgu

SELECT o.order_id, o.total_amount, o.order_date,
       c.first_name, c.last_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'pending';

-- Toplam: 1 sorgu, ~5ms

N+1 Problemi Neden Bu Kadar Yaygın?

Çünkü ORM'ler (Laravel Eloquent, Django ORM, Hibernate) varsayılan olarak lazy loading yapar — ilişkili verileri ancak erişildiğinde yükler:

// ❌ Laravel'de N+1 (varsayılan lazy loading)
$orders = Order::where('status', 'pending')->get();
foreach ($orders as $order) {
    echo $order->customer->first_name; // Her döngüde 1 sorgu!
}
// 1 + N sorgu

// ✅ Laravel'de eager loading ile çözüm
$orders = Order::where('status', 'pending')
    ->with('customer')  // İlişkili veriyi önceden yükle
    ->get();
foreach ($orders as $order) {
    echo $order->customer->first_name; // Ek sorgu yok!
}
// 2 sorgu: 1 orders + 1 customers (WHERE customer_id IN (...))

N+1'in SQL Tarafında Çözümü

Eğer uygulama kodunu değiştiremiyorsan, SQL tarafında da çözüm var:

-- Adım 1: İhtiyacın olan tüm customer_id'leri topla
-- Adım 2: Tek sorguda hepsini çek

-- Subquery ile:
SELECT first_name, last_name, customer_id
FROM customers
WHERE customer_id IN (
    SELECT DISTINCT customer_id 
    FROM orders 
    WHERE status = 'pending'
);

-- Ya da daha iyi: JOIN ile tek sorguda her şeyi al
SELECT o.order_id, o.total_amount, c.first_name, c.last_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'pending';

🎯 Analoji: N+1 problemi, markete gidip her seferinde tek bir ürün almak gibi. 20 ürün lazım — 20 kez markete gidersin. Bunun yerine bir liste yap, bir kez git, hepsini al. Veritabanına her "gidiş" bir ağ round-trip'idir ve bu maliyetlidir.

N+1'i Tespit Etme

-- MySQL'de son çalışan sorgu sayısını kontrol et
SHOW STATUS LIKE 'Questions';
-- Bir sayfa yüklemesi sırasında bu değerin artışı, sorgu sayısını gösterir

-- Bir sayfa yüklemesinde 100+ sorgu varsa, büyük ihtimalle N+1 problemi var

Uygulama tarafında:

  • Laravel: DB::enableQueryLog() ile sorgu sayısını izle, veya Laravel Debugbar / Telescope kullan

  • Django: django-debug-toolbar kullan

  • Rails: bullet gem'i N+1 sorgularını otomatik tespit eder


4. LIMIT Kullan — Gereksiz Veri Çekme

Kullanıcıya 20 sipariş göstereceksen, 500.000 siparişin hepsini çekme:

-- ❌ KÖTÜ: Tüm verileri çek, uygulamada filtrele
SELECT * FROM orders ORDER BY order_date DESC;
-- 500.000 satır gelir → uygulama ilk 20'yi alır, 499.980'ini çöpe atar

-- ✅ İYİ: Veritabanında limitle
SELECT order_id, order_date, total_amount 
FROM orders 
ORDER BY order_date DESC 
LIMIT 20;
-- Sadece 20 satır gelir

Sayfalama (Pagination) İçin OFFSET

-- Sayfa 1 (ilk 20)
SELECT order_id, order_date, total_amount 
FROM orders ORDER BY order_date DESC 
LIMIT 20 OFFSET 0;

-- Sayfa 2 (21-40)
SELECT order_id, order_date, total_amount 
FROM orders ORDER BY order_date DESC 
LIMIT 20 OFFSET 20;

-- Sayfa 1000 (19981-20000) — ⚠️ SORUN!
SELECT order_id, order_date, total_amount 
FROM orders ORDER BY order_date DESC 
LIMIT 20 OFFSET 19980;
-- MySQL 19.980 satır okur, 19.960'ını atar, 20 satır döndürür
-- OFFSET büyüdükçe sorgu yavaşlar!

Keyset Pagination (Cursor-based) — Büyük OFFSET'lerin Çözümü

-- ❌ OFFSET tabanlı (sayfa 1000'de yavaş)
SELECT * FROM orders ORDER BY order_id DESC LIMIT 20 OFFSET 19980;

-- ✅ Keyset tabanlı (her sayfada aynı hızda)
-- Önceki sayfanın son order_id'si: 480020
SELECT * FROM orders 
WHERE order_id < 480020 
ORDER BY order_id DESC 
LIMIT 20;
-- Index kullanır, OFFSET'e bağımlı değil, her zaman hızlı

Keyset pagination'ın mantığı: son gördüğün kaydın ID'sini hatırla, bir sonraki sayfada "bundan küçük/büyük olanları getir" de. Offset yerine WHERE koşulu kullandığı için index'ten doğrudan atlama yapılır.

💡 İpucu: Modern API'ler (Twitter, Facebook, Stripe) hep cursor-based pagination kullanır. Eğer bir API geliştiriyorsan, keyset pagination tercih et.


5. JOIN Optimizasyonu

JOIN Sırası

MySQL optimizer genellikle en iyi JOIN sırasını kendisi belirler ama bazı durumlarda yardımcı olabilirsin:

-- İpucu: Küçük tabloyu önce yaz (okunabilirlik için)
-- Ama optimizer sırayı değiştirebilir
SELECT p.product_name, cat.category_name
FROM categories cat            -- 50 satır
JOIN products p ON p.category_id = cat.category_id  -- 10.000 satır
WHERE cat.category_name = 'Elektronik';

JOIN Koşullarında Index

Her JOIN koşulundaki sütunların index'li olması kritik:

-- orders.customer_id'de index yoksa:
SELECT o.order_id, c.first_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;
-- Her orders satırı için customers tablosunda full scan → felaket

-- Index ekle:
CREATE INDEX idx_orders_customer ON orders(customer_id);
-- Artık her orders satırı için index lookup → hızlı

Gereksiz JOIN'lerden Kaçın

-- ❌ Gereksiz JOIN: departments tablosundan veri kullanılmıyor
SELECT e.first_name, e.last_name, e.salary
FROM employees e
JOIN departments d ON e.department_id = d.department_id;
-- departments tablosundan hiçbir sütun SELECT'te yok!

-- ✅ Eğer department_id'nin geçerli olduğunu kontrol etmek istiyorsan:
-- EXISTS veya FK constraint ile sağla, gereksiz JOIN yapma
SELECT first_name, last_name, salary
FROM employees
WHERE department_id IS NOT NULL;

JOIN vs Subquery — Hangisi Daha Hızlı?

Genel kural: çoğu durumda JOIN, correlated subquery'den hızlıdır. Ama MySQL 8.0+ optimizer çoğu subquery'yi otomatik olarak JOIN'e çevirir.

-- Subquery versiyonu
SELECT product_name, price
FROM products
WHERE category_id IN (
    SELECT category_id FROM categories WHERE category_name = 'Elektronik'
);

-- JOIN versiyonu (genellikle eşdeğer performans)
SELECT p.product_name, p.price
FROM products p
JOIN categories c ON p.category_id = c.category_id
WHERE c.category_name = 'Elektronik';

-- Correlated subquery (her satır için tekrar çalışır — dikkat!)
SELECT p.product_name, p.price,
    (SELECT COUNT(*) FROM order_items oi WHERE oi.product_id = p.product_id) AS order_count
FROM products p;
-- products tablosunda 10.000 satır varsa, subquery 10.000 kez çalışır!

-- JOIN ile aynı sonuç, daha hızlı:
SELECT p.product_name, p.price, COUNT(oi.order_item_id) AS order_count
FROM products p
LEFT JOIN order_items oi ON p.product_id = oi.product_id
GROUP BY p.product_id;

6. EXISTS vs IN — Ne Zaman Hangisi?

-- IN: Subquery sonuç kümesi küçükse iyi
SELECT * FROM customers 
WHERE customer_id IN (
    SELECT customer_id FROM orders WHERE total_amount > 10000
);

-- EXISTS: Subquery sonuç kümesi büyükse iyi
SELECT * FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o 
    WHERE o.customer_id = c.customer_id AND o.total_amount > 10000
);

Neden fark var?

  • IN subquery'nin tüm sonuçlarını belleğe alır, sonra dış sorguyla karşılaştırır. Sonuç kümesi büyükse bellek problemi yaratabilir.

  • EXISTS her dış satır için subquery'yi çalıştırır ama ilk eşleşmede durur. Dış tablo küçükse veya subquery iyi indexlenmişse daha hızlıdır.

Pratik kural:

  • Dış tablo büyük, iç sorgu küçük → IN

  • Dış tablo küçük, iç sorgu büyük → EXISTS

  • Emin değilsen → EXISTS (genellikle daha güvenli)

💡 İpucu: MySQL 8.0+ optimizer çoğu durumda IN ve EXISTS arasında otomatik dönüşüm yapar. Ama yine de bilinçli seçim yapmak iyi bir alışkanlıktır.


7. Aggregate Sorgularında Optimizasyon

COUNT Optimizasyonu

-- ❌ YAVAŞ: Tüm satırları say (WHERE yoksa)
SELECT COUNT(*) FROM orders;
-- InnoDB'de full table/index scan yapar — büyük tablolarda yavaş

-- 💡 Yaklaşık sayı yeterliyse (dashboard için):
SELECT TABLE_ROWS 
FROM information_schema.TABLES 
WHERE TABLE_NAME = 'orders';
-- Anında döner ama tahmini değer (±%5-10)

-- ✅ Koşullu COUNT için index kullan
SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- status sütununda index varsa hızlı

-- ❌ YAVAŞ: COUNT(DISTINCT) büyük tablolarda ağır
SELECT COUNT(DISTINCT customer_id) FROM orders;
-- Her farklı değer için bellek kullanır

-- ✅ Alternatif: Yaklaşık unique count (HyperLogLog — sadece PostgreSQL)
-- MySQL'de bu doğrudan yok ama uygulama katmanında Redis HyperLogLog kullanabilirsin

GROUP BY Optimizasyonu

-- ❌ YAVAŞ: GROUP BY index'siz sütunda
SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Index yoksa: full table scan + temporary table + filesort

-- ✅ HIZLI: GROUP BY sütununda index var
CREATE INDEX idx_orders_status ON orders(status);
SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Index-only scan — tabloya gitmeden sonuç

-- ❌ YAVAŞ: GROUP BY + ORDER BY farklı sütunlar
SELECT customer_id, SUM(total_amount) AS total
FROM orders
GROUP BY customer_id
ORDER BY total DESC;
-- GROUP BY index kullanır ama ORDER BY hesaplanmış değer → filesort

-- ✅ Filesort kaçınılmazsa, LIMIT ile sınırla
SELECT customer_id, SUM(total_amount) AS total
FROM orders
GROUP BY customer_id
ORDER BY total DESC
LIMIT 20;
-- Filesort yapılır ama sadece top-20 seçilir

8. Veri Tipi Uyumuna Dikkat Et

Yanlış veri tipi karşılaştırmaları, index'i sessizce devre dışı bırakır:

-- ❌ customer_id INT ama string ile karşılaştırıyorsun
SELECT * FROM orders WHERE customer_id = '12345';
-- MySQL implicit conversion yapar: '12345' → 12345
-- Bu durumda genellikle index çalışır (string → int dönüşümü)

-- ❌ phone VARCHAR ama sayı ile karşılaştırıyorsun
SELECT * FROM customers WHERE phone = 5551234567;
-- MySQL her satırda VARCHAR → INT dönüşümü yapar → INDEX KULLANILMAZ!
-- Çünkü '05551234567' ve '5551234567' sayısal olarak aynı ama string olarak farklı

-- ✅ Doğru tip kullan
SELECT * FROM customers WHERE phone = '5551234567';

Kural: Her zaman sütun tipiyle uyumlu literal değer kullan. VARCHAR sütunu → string, INT sütunu → sayı.

-- Tip uyumsuzluğunu tespit etme
EXPLAIN SELECT * FROM customers WHERE phone = 5551234567;
-- Eğer type: ALL görüyorsan ve phone'da index varsa → tip uyumsuzluğu var

9. Temporary Table ve Filesort'tan Kaçınma

EXPLAIN çıktısında Using temporary ve Using filesort gördüğünde, MySQL ek bellek ve disk işlemi kullanıyor demektir.

-- Using temporary + Using filesort tetikleyen durumlar:

-- 1. GROUP BY ve ORDER BY farklı sütunlarda
SELECT city, COUNT(*) FROM customers GROUP BY city ORDER BY COUNT(*) DESC;

-- 2. DISTINCT + ORDER BY
SELECT DISTINCT city FROM customers ORDER BY created_at;

-- 3. UNION (UNION ALL değil)
SELECT city FROM customers UNION SELECT city FROM employees;
-- UNION tekrar eden satırları çıkarır → temporary table + sort gerekir
-- UNION ALL tekrarları bırakır → temporary table gerekmez
-- ✅ UNION yerine UNION ALL (tekrar önemli değilse)
SELECT city FROM customers 
UNION ALL 
SELECT city FROM employees;
-- Daha hızlı — temporary table yok

-- ✅ GROUP BY ve ORDER BY aynı sütunda
SELECT city, COUNT(*) AS cnt FROM customers 
GROUP BY city ORDER BY city;
-- Tek index yeterli

10. Batch İşlemler — Toplu Operasyonlar

Büyük veri setlerinde tek seferde tüm satırları işlemeye çalışma — parçalara böl:

-- ❌ TEHLİKELİ: 500.000 satırı tek seferde güncelle
UPDATE orders SET status = 'archived' 
WHERE order_date < '2020-01-01';
-- Uzun süre tablo kilitlenir, diğer sorgular bekler

-- ✅ GÜVENLİ: Parça parça güncelle
-- Her seferinde 5.000 satır
UPDATE orders SET status = 'archived' 
WHERE order_date < '2020-01-01' AND status != 'archived'
LIMIT 5000;
-- Bunu affected_rows = 0 olana kadar tekrarla

-- ❌ TEHLİKELİ: Büyük DELETE
DELETE FROM log_entries WHERE created_at < '2023-01-01';
-- Milyonlarca satır silmek undo log'u şişirir

-- ✅ GÜVENLİ: Parça parça sil
DELETE FROM log_entries 
WHERE created_at < '2023-01-01' 
LIMIT 10000;
-- Tekrarla
-- ❌ YAVAŞ: Her satır için ayrı INSERT
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 101, 2);
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 102, 1);
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 103, 3);
-- 3 ayrı network round-trip + 3 ayrı transaction

-- ✅ HIZLI: Toplu INSERT (multi-row insert)
INSERT INTO order_items (order_id, product_id, quantity) VALUES 
    (1, 101, 2),
    (1, 102, 1),
    (1, 103, 3);
-- 1 network round-trip + 1 transaction — 10x-100x daha hızlı

🎯 Analoji: Kargo göndermek gibi düşün. 100 paket göndermen gerekiyor. Her paketi ayrı ayrı kargo şubesine götürmek yerine, hepsini bir kamyona yükleyip tek seferde teslim etmek çok daha verimli.


11. Sorgu Önbelleği ve Hazırlıklı İfadeler (Prepared Statements)

Prepared Statements

Aynı sorgu farklı parametrelerle tekrar tekrar çalışıyorsa, prepared statement kullan:

-- Prepared statement: sorgu bir kez parse ve optimize edilir
PREPARE stmt FROM 
    'SELECT order_id, total_amount FROM orders WHERE customer_id = ? AND status = ?';

SET @cid = 12345;
SET @status = 'completed';
EXECUTE stmt USING @cid, @status;

SET @cid = 67890;
EXECUTE stmt USING @cid, @status;

DEALLOCATE PREPARE stmt;

Avantajları:

  • Parse ve optimize etme bir kez yapılır, sonraki çalıştırmalarda atlanır

  • SQL Injection'a karşı koruma — parametreler otomatik escape edilir

  • Network trafiği azalır (sadece parametre değerleri gönderilir)

MySQL Query Cache (8.0 Öncesi)

MySQL 5.7 ve öncesinde Query Cache vardı — aynı sorgu tekrar geldiğinde önbellekten döndürülürdü. Ama MySQL 8.0'da kaldırıldı çünkü:

  • Yüksek eş zamanlılıkta (concurrent access) darboğaz oluşturuyordu

  • Tabloda bir değişiklik olunca o tabloya ait tüm önbellek geçersiz oluyordu

  • Faydasından çok zarar veriyordu

⚠️ Dikkat: MySQL 8.0+ kullanıyorsan Query Cache yok. Uygulama tarafında Redis veya Memcached gibi önbellek çözümleri kullan.


12. Index Bakımı — Kullanılmayan Index'leri Temizle

Her index, INSERT/UPDATE/DELETE işlemlerini yavaşlatır çünkü her yazma işleminde index de güncellenmeli. Kullanılmayan index'ler saf maliyet:

-- Kullanılmayan index'leri tespit et (MySQL 8.0+)
SELECT 
    object_schema AS db_name,
    object_name AS table_name,
    index_name,
    count_star AS usage_count
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'ecommerce'
  AND index_name IS NOT NULL
  AND count_star = 0
ORDER BY object_name, index_name;
-- Duplicate index'leri tespit et
SELECT 
    t.TABLE_NAME,
    t.INDEX_NAME AS index1,
    t2.INDEX_NAME AS index2,
    t.COLUMN_NAME
FROM information_schema.STATISTICS t
JOIN information_schema.STATISTICS t2 
    ON t.TABLE_SCHEMA = t2.TABLE_SCHEMA
    AND t.TABLE_NAME = t2.TABLE_NAME
    AND t.COLUMN_NAME = t2.COLUMN_NAME
    AND t.SEQ_IN_INDEX = t2.SEQ_IN_INDEX
    AND t.INDEX_NAME != t2.INDEX_NAME
WHERE t.TABLE_SCHEMA = 'ecommerce'
ORDER BY t.TABLE_NAME, t.INDEX_NAME;
-- Gereksiz index'i kaldır
DROP INDEX idx_old_unused ON orders;
-- Yazma performansı iyileşir, disk alanı boşalır

💡 İpucu: pt-duplicate-key-checker (Percona Toolkit) aracı, duplicate ve gereksiz index'leri otomatik tespit eder. Production ortamında periyodik olarak çalıştırılması önerilir.


Gerçek Dünya Örneği — E-Ticaret Dashboard Optimizasyonu

Yönetim panelinde şu dashboard sorgusu var — sayfa 15 saniyede yükleniyor:

-- ❌ Orijinal: 15 saniye
SELECT 
    DATE(o.order_date) AS day,
    COUNT(*) AS order_count,
    SUM(o.total_amount) AS daily_revenue,
    COUNT(DISTINCT o.customer_id) AS unique_customers,
    (SELECT AVG(total_amount) FROM orders WHERE DATE(order_date) = DATE(o.order_date)) AS avg_amount
FROM orders o
WHERE o.order_date BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY DATE(o.order_date)
ORDER BY day DESC;

Sorunlar:

  1. DATE(o.order_date) — GROUP BY'da fonksiyon, index kullanılmaz

  2. Correlated subquery — her gün için ayrı sorgu çalışır (365 kez!)

  3. SELECT * kullanılmamış ama COUNT(DISTINCT) ağır

Optimizasyon:

-- ✅ Optimize: 0.3 saniye
SELECT 
    DATE(o.order_date) AS day,
    COUNT(*) AS order_count,
    SUM(o.total_amount) AS daily_revenue,
    COUNT(DISTINCT o.customer_id) AS unique_customers,
    AVG(o.total_amount) AS avg_amount  -- Correlated subquery yerine AVG
FROM orders o
WHERE o.order_date >= '2024-01-01' 
  AND o.order_date < '2025-01-01'
GROUP BY DATE(o.order_date)
ORDER BY day DESC;
-- Index ekle
CREATE INDEX idx_orders_date ON orders(order_date);
-- Veya daha iyi: covering index
CREATE INDEX idx_orders_date_covering 
ON orders(order_date, total_amount, customer_id);

Değişiklikler:

  1. Correlated subquery → basit AVG() aggregate (365 sorgu → 0 ek sorgu)

  2. BETWEEN>= ve < (daha kesin)

  3. Covering index eklendi


Optimizasyon Özet Tablosu

Best PracticeNedenEtki
SELECT * kullanmaGereksiz veri, covering index engellenirOrta-Yüksek
Sütuna fonksiyon uygulamaIndex devre dışı kalırYüksek
N+1 sorgulardan kaçınYüzlerce gereksiz round-tripÇok Yüksek
LIMIT kullanGereksiz veri transferiOrta
Keyset paginationBüyük OFFSET'ler yavaşYüksek
JOIN'de indexFull table scan engellenirÇok Yüksek
Doğru veri tipiImplicit conversion index'i bozarYüksek
UNION ALL (ALL!)Temporary table engellenirOrta
Batch işlemlerLock süresi azalırYüksek
Prepared statementsParse overhead azalırDüşük-Orta
Kullanılmayan index temizleYazma performansı iyileşirOrta

Özet

  • SELECT * kullanma — sadece ihtiyacın olan sütunları seç, covering index'ten faydalan

  • WHERE koşullarında sütuna fonksiyon uygulama — hesaplamayı değer tarafına taşı

  • N+1 problemi en yaygın performans katili — JOIN veya eager loading kullan

  • Büyük sayfalamada OFFSET yerine keyset pagination tercih et

  • Her JOIN koşulundaki sütunda index olduğundan emin ol

  • Veri tipi uyumuna dikkat et — VARCHAR sütunu string ile karşılaştır

  • Büyük veri operasyonlarında batch çalış — tek seferde milyonlarca satır işleme

  • Kullanılmayan index'leri periyodik olarak temizle — yazma performansını düşürürler

Sıkça Yapılan Hatalar

  1. "EXPLAIN'e bakmadan index eklemek" — Önce sorunu teşhis et, sonra çözüm uygula. Rastgele index eklemek yazma performansını düşürür.

  2. ORM'e güvenip SQL'i ihmal etmek — ORM kolaylık sağlar ama ürettiği SQL'i izle. N+1 problemi ORM'lerden kaynaklanır.

  3. Development'da test edip "hızlı" demek — 100 satırlık tabloda her sorgu hızlıdır. Production veri hacmiyle test et.

  4. OFFSET pagination'ı her yerde kullanmak — İlk birkaç sayfa hızlıdır ama sayfa 500'de felaket. Keyset pagination öğren.

  5. Index eklemeyi sihirli çözüm sanmak — Index her şeyi çözmez. Kötü yazılmış sorgu, kötü yazılmış sorgudur — bazen sorguyu yeniden yazmak gerekir.

  6. UNION ve UNION ALL farkını bilmemek — Tekrar kontrolü gerekmiyorsa her zaman UNION ALL kullan. UNION gereksiz yere temporary table ve sort yapar.