MySQL Query Optimizasyonu: Index, Explain ve Slow Query Log
Bir Laravel projesinde "her sey calisiyordu" diyerek production'a cikmistim. Veriler 500 satiri gecene kadar da gercekten calisiyordu. Sonra blog listeleme sayfasi 200ms'den 2 saniyeye firlamaya basladi. O gun ogrendim ki, sorgu yazmak ile iyi sorgu yazmak arasinda daglar kadar fark var. Bu yazida MySQL query optimizasyonunda ogrendigim her seyi, pratik orneklerle paylasiyorum.
N+1 Query Problemi: Sessiz Katil
Laravel'de en sik karsilastigim performans sorunu N+1 query problemi. Sorunu anlamamak icin once nasil ortaya ciktigina bakalim.
Diyelim blog yazilarini listelemek istiyorsunuz:
// KOTU: N+1 problemi
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Her post icin ayri sorgu!
}
Bu kod 100 post icin tam 101 sorgu atar. Biri SELECT * FROM posts, digerleri her post icin SELECT * FROM users WHERE id = ?. Veritabani sunucunuz bu yukun altinda ezilir.
Cozum Eloquent eager loading:
// IYI: Eager loading ile 2 sorgu
$posts = Post::with('author')->get();
// DAHA IYI: Sadece gereken alanlar
$posts = Post::with('author:id,name,avatar')
->select('id', 'title', 'slug', 'author_id', 'created_at')
->latest()
->paginate(20);
Eager loading ile 101 sorgu 2 sorguya duser. Projelerimde bu farki ilk fark ettigimde Laravel Debugbar kurdum ve her sayfadaki sorgu sayisini takip etmeye basladim. Tavsiyem: barryvdh/laravel-debugbar paketini development ortaminizda mutlaka kullanin.
// Nested eager loading
$posts = Post::with([
'author:id,name',
'categories:id,name,slug',
'comments' => function ($query) {
$query->latest()->limit(5);
},
'comments.user:id,name'
])->paginate(20);
N+1 sorunlarini otomatik yakalamak icin beyondcode/laravel-query-detector paketini de kurabilirsiniz. Development ortaminda N+1 tespit edince uyari verir.
MySQL EXPLAIN Komutu: Sorgularinizi Rontgen Cekin
EXPLAIN, MySQL'in bir sorguyu nasil calistiracagini gosteren en degerli aractir. Kullanimi basit:
EXPLAIN SELECT p.*, u.name
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.status = 'published'
ORDER BY p.created_at DESC
LIMIT 20;
EXPLAIN ciktisinda dikkat etmeniz gereken sutunlar:
- type: Baglanti tipi.
ALLgoruyorsaniz full table scan yapiliyor demektir, bu felaket. Idealden kotuge:const > eq_ref > ref > range > index > ALL - possible_keys: MySQL'in kullanabilecegi index'ler
- key: Gercekte kullanilan index
- rows: Taranan satir sayisi tahmini. Ne kadar dusukse o kadar iyi
- Extra:
Using filesortveyaUsing temporarygoruyorsaniz optimizasyon gerekli
EXPLAIN ANALYZE SELECT * FROM posts
WHERE status = 'published' AND category_id = 5
ORDER BY created_at DESC;
EXPLAIN ANALYZE (MySQL 8.0+) ise gercek calisma surelerini gosterir. Tahmini degil, gercek veri elde edersiniz.
Index Turleri ve Ne Zaman Kullanilir
Index'ler veritabaninin kitap sonundaki dizin gibidir. Dogru index olmadan MySQL tum tabloyu okumak zorunda kalir.
Primary Key
Her tabloda olmali. Laravel migration'larda $table->id() ile otomatik gelir.
Unique Index
Tekrar etmemesi gereken alanlar icin:
Schema::table('users', function (Blueprint $table) {
$table->unique('email');
$table->unique(['provider', 'provider_id']); // Composite unique
});
Composite (Bilesik) Index
Birden fazla kolonu kapsayan index. Burada siralama kritik onem tasir:
// Migration
Schema::table('posts', function (Blueprint $table) {
$table->index(['status', 'category_id', 'created_at']);
});
MySQL composite index'lerde "soldan saga" kuralini uygular. Yani ['status', 'category_id', 'created_at'] indexi su sorgulari kapsar:
-- Bu calismaz (index kullanilmaz, ilk kolon eksik)
WHERE category_id = 5
-- Bu calisir (ilk kolon var)
WHERE status = 'published'
-- Bu calisir (ilk iki kolon var)
WHERE status = 'published' AND category_id = 5
-- Bu calisir (uc kolon da var, ORDER BY dahil)
WHERE status = 'published' AND category_id = 5 ORDER BY created_at DESC
Altin kural: Esitlik kosullarini once, aralik kosullarini (>, <, BETWEEN, ORDER BY) sona koyun. Bu kurali ogrendikten sonra projelerimde sorgu surelerini dramatik sekilde dusurdum.
Fulltext Index
Metin arama icin:
Schema::table('posts', function (Blueprint $table) {
$table->fullText(['title', 'content']);
});
// Eloquent ile fulltext arama
$posts = Post::whereFullText(['title', 'content'], 'laravel deploy')
->get();
Slow Query Log: Sorunlu Sorgulari Yakalama
MySQL'in slow query log'u, belirlediginiz sureden uzun suren sorgulari kaydeder. Production ortaminda bunu mutlaka aktif edin.
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow-query.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
Servisi yeniden baslatin:
sudo systemctl restart mysql
Log dosyasini analiz etmek icin mysqldumpslow araci kullanilabilir:
# En yavas 10 sorguyu goster
mysqldumpslow -s t -t 10 /var/log/mysql/slow-query.log
# En cok tekrar eden 10 sorguyu goster
mysqldumpslow -s c -t 10 /var/log/mysql/slow-query.log
Projelerimde slow query log'u aktif ettikten sonra fark ettigim bir sey: bazi sorgular tek basina hizli ama saniyede yuzlerce kez calisinca toplam yuku artiriyor. Bu tur sorgulari cache ile cozdugum oldu.
Query Cache Stratejileri
MySQL 8.0'da query cache kaldirildi ama uygulama seviyesinde cache stratejileri hala gecerli.
// Laravel cache ile query sonuclari
$posts = Cache::remember('published_posts_page_' . $page, 3600, function () use ($page) {
return Post::with('author:id,name')
->where('status', 'published')
->latest()
->paginate(20);
});
// Cache invalidation
// Post kaydetme/guncelleme observer'inda
public function saved(Post $post)
{
Cache::tags(['posts'])->flush();
}
Daha granular cache icin:
// Tek bir post icin cache
$post = Cache::remember("post_{$slug}", 7200, function () use ($slug) {
return Post::with(['author', 'categories', 'tags'])
->where('slug', $slug)
->where('status', 'published')
->firstOrFail();
});
Pratik Ornek: Blog Listeleme Optimizasyonu
Projemde blog listeleme sorgusunu 200ms'den 15ms'e dusurdugum adimlari paylasiyorum.
Onceki durum (200ms+):
$posts = Post::all();
// View'da: $post->author->name, $post->categories, $post->comments_count
Adim 1 - Eager loading (80ms):
$posts = Post::with(['author', 'categories'])->withCount('comments')->get();
Adim 2 - Select ve paginate (40ms):
$posts = Post::select('id', 'title', 'slug', 'excerpt', 'featured_image', 'author_id', 'created_at')
->with(['author:id,name', 'categories:id,name,slug'])
->withCount('comments')
->where('status', 'published')
->latest()
->paginate(20);
Adim 3 - Composite index (15ms):
// Migration
Schema::table('posts', function (Blueprint $table) {
$table->index(['status', 'created_at']);
});
Sonuc: 101 sorgu yerine 3 sorgu, full table scan yerine index kullanimi, 200ms yerine 15ms. Kullanici deneyimi tamamen degisti.
Ek Ipuclari
SELECT * kullanmayin. Sadece ihtiyaciniz olan kolonlari secin. Ozellikle TEXT veya BLOB kolonlari varsa bu fark cok buyuk olur.
COUNT sorgularini optimize edin:
// KOTU: Tum kayitlari cekip sayma
$count = Post::where('status', 'published')->get()->count();
// IYI: Veritabaninda say
$count = Post::where('status', 'published')->count();
Chunk kullanin buyuk islemler icin:
Post::where('status', 'draft')
->chunkById(500, function ($posts) {
foreach ($posts as $post) {
// Islem yap
}
});
Sonuc
MySQL query optimizasyonu bir kerelik is degil, surekli takip gerektiren bir surec. Projelerinizde Debugbar ile sorgu sayisini izleyin, EXPLAIN ile sorgulari analiz edin, slow query log ile sorunlari yakalayip index ve cache stratejileri ile cozun. Bu aliskanliklar, "calisiyor" ile "iyi calisiyor" arasindaki farki olusturuyor. Benim icin bu yolculuk o 200ms'lik blog sayfasiyla basladi. Sizin icin de bugun baslayabilir.