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ş?
Daha fazla veri transferi: 15 sütun yerine 3 sütun çekmek, ağ trafiğini %80 azaltabilir
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ç gitmedenBellek tüketimi: Uygulama tarafında gereksiz sütunlar bellek yer kaplar
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 = 5OR 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 != 12345koş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, ~5msN+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 varUygulama tarafında:
Laravel:
DB::enableQueryLog()ile sorgu sayısını izle, veya Laravel Debugbar / Telescope kullanDjango:
django-debug-toolbarkullanRails:
bulletgem'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 gelirSayfalama (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?
INsubquery'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.EXISTSher 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 →
INDış tablo küçük, iç sorgu büyük →
EXISTSEmin 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 kullanabilirsinGROUP 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çilir8. 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 var9. 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 yeterli10. 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:
DATE(o.order_date)— GROUP BY'da fonksiyon, index kullanılmazCorrelated subquery — her gün için ayrı sorgu çalışır (365 kez!)
SELECT *kullanılmamış amaCOUNT(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:
Correlated subquery → basit
AVG()aggregate (365 sorgu → 0 ek sorgu)BETWEEN→>=ve<(daha kesin)Covering index eklendi
Optimizasyon Özet Tablosu
| Best Practice | Neden | Etki |
|---|---|---|
| SELECT * kullanma | Gereksiz veri, covering index engellenir | Orta-Yüksek |
| Sütuna fonksiyon uygulama | Index devre dışı kalır | Yüksek |
| N+1 sorgulardan kaçın | Yüzlerce gereksiz round-trip | Çok Yüksek |
| LIMIT kullan | Gereksiz veri transferi | Orta |
| Keyset pagination | Büyük OFFSET'ler yavaş | Yüksek |
| JOIN'de index | Full table scan engellenir | Çok Yüksek |
| Doğru veri tipi | Implicit conversion index'i bozar | Yüksek |
| UNION ALL (ALL!) | Temporary table engellenir | Orta |
| Batch işlemler | Lock süresi azalır | Yüksek |
| Prepared statements | Parse overhead azalır | Düşük-Orta |
| Kullanılmayan index temizle | Yazma performansı iyileşir | Orta |
Ö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
"EXPLAIN'e bakmadan index eklemek" — Önce sorunu teşhis et, sonra çözüm uygula. Rastgele index eklemek yazma performansını düşürür.
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.
Development'da test edip "hızlı" demek — 100 satırlık tabloda her sorgu hızlıdır. Production veri hacmiyle test et.
OFFSET pagination'ı her yerde kullanmak — İlk birkaç sayfa hızlıdır ama sayfa 500'de felaket. Keyset pagination öğren.
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.
UNION ve UNION ALL farkını bilmemek — Tekrar kontrolü gerekmiyorsa her zaman UNION ALL kullan. UNION gereksiz yere temporary table ve sort yapar.
AI Asistan
Sorularını yanıtlamaya hazır