Sitenizi hızlandırmak için elinizden geleni yaptınız: görselleri sıkıştırdınız, CSS'i küçülttünüz, CDN taktınız. Tarayıcı araçlarında her şey yeşil ama sayfa hâlâ geç açılıyor ve ilk bayt üç saniyede geliyor. Bu tablo neredeyse her zaman tek bir şeye işaret eder: sunucu, tarayıcıya cevap yazmadan önce veritabanını bekliyordur. Veritabanı sorguları, sayfa hızının en sık gözden kaçan ve en sık yanlış teşhis edilen darboğazıdır; çünkü tarayıcı tarafındaki hiçbir araç onu göremez.
Bu rehberde sorunun sunucu tarafında olduğunu nasıl kanıtlayacağınızı, yavaş sorguları nasıl yakalayacağınızı, EXPLAIN çıktısını nasıl okuyacağınızı ve hangi indeksin gerçekten işe yaradığını anlatacağım. Ayrıca N+1 sorgu problemi, SELECT * alışkanlığı ve sayfalama tuzakları gibi uygulama tarafındaki klasik hataları gerçek örneklerle göstereceğim. Sonunda elinizde, bir sayfayı üç saniyeden üç yüz milisaniyeye indirmek için izleyeceğiniz somut bir teşhis sırası olacak.
Önce Kanıtlayın: Süre Gerçekten Veritabanında mı Geçiyor#
Optimizasyona başlamadan önce darboğazın yerini kesinleştirin, yoksa yanlış katmanda saatler harcarsınız. İlk ölçüm ilk bayt süresidir (TTFB): tarayıcı isteği gönderdikten sonra sunucudan ilk baytın gelmesi ne kadar sürüyor?
# Bir sayfanın zaman dökümü
curl -o /dev/null -s -w 'dns: %{time_namelookup}s\ntcp: %{time_connect}s\ntls: %{time_appconnect}s\nilk_bayt: %{time_starttransfer}s\ntoplam: %{time_total}s\n' \
https://firmaniz.com/urunler
# dns: 0.021s
# tcp: 0.048s
# tls: 0.112s
# ilk_bayt: 2.784s
# toplam: 2.801s
ilk_bayt ile tls arasındaki fark sunucunun sayfayı üretmek için harcadığı süredir. Yukarıdaki örnekte bu yaklaşık 2,67 saniyedir ve bu süre kesinlikle tarayıcı tarafında değil sunucudadır. Şimdi bu sürenin ne kadarının veritabanında geçtiğini görmemiz gerekiyor. En hızlı yöntem PHP-FPM'in yavaş istek günlüğüdür; beş saniyeyi aşan isteklerin yığın izini yazar ve genellikle karşınıza doğrudan bir sorgu satırı çıkar:
; PHP-FPM havuzunda
slowlog = /var/log/php-fpm/site-slow.log
request_slowlog_timeout = 3s
[25-Aug-2026 10:12:44] [pool www] pid 21455
script_filename = /var/www/firmaniz.com/index.php
[0x00007f2] mysqli_query() /var/www/firmaniz.com/lib/db.php:88
[0x00007f1] getUrunListesi() /var/www/firmaniz.com/urunler.php:31
Yığın izinin tepesinde mysqli_query, PDO->execute ya da benzeri bir çağrı görüyorsanız cevabınız net: süre veritabanında geçiyor. Aynı ölçümün diğer yarısı olan PHP tarafı için PHP-FPM havuz ayarları yazısındaki durum sayfası bölümü işinize yarar; orada süreçlerin gerçekten çalışıyor mu yoksa bekliyor mu olduğunu ayırt edersiniz.
Yavaş Sorgu Günlüğünü Açmak ve Okumak#
Hangi sorgunun yavaş olduğunu tahmin etmeyin; MySQL/MariaDB size doğrudan söyleyebilir. Yavaş sorgu günlüğü, belirlediğiniz eşiği aşan her sorguyu süresi ve incelediği satır sayısıyla birlikte kaydeder.
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1 # 1 saniyeyi aşan sorguları yaz
log_queries_not_using_indexes = 1 # indeks kullanmayanları da yaz
min_examined_row_limit = 100 # çok küçük sorguları gürültü olarak atla
systemctl restart mysql
# Günlüğü canlı izle
tail -f /var/log/mysql/slow.log
Tek tek satır okumak yerine özet çıkarın. mysqldumpslow MySQL ile birlikte gelir ve sorguları normalleştirip toplam etkiye göre sıralar:
# En çok toplam süre harcayan 10 sorgu
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
Çıktıdaki her blok şunu söyler: bu sorgu kaç kez çalıştı, toplam kaç saniye harcadı, ortalama ne kadar sürdü ve kaç satır inceledi. Sıralamayı ortalama süreye göre değil toplam süreye göre yapın. Beş saniye süren ama günde iki kez çalışan bir rapor sorgusu, 80 milisaniye süren ama her sayfa yüklemesinde kırk kez çalışan bir sorgudan çok daha az zarar verir. Optimizasyonu her zaman toplam etkiye göre önceliklendirin.
Bir de anlık görüntü almanın yolu var; sistem şu anda tıkanıyorsa ne çalıştığına bakın:
-- Şu anda çalışan sorgular, uzun sürenler üstte
SELECT id, user, host, db, time, state, LEFT(info, 120) AS sorgu
FROM information_schema.processlist
WHERE command != 'Sleep'
ORDER BY time DESC
LIMIT 10;
state sütununda Sending data, Copying to tmp table ya da Creating sort index gibi değerler görüyorsanız, sorgu diskte geçici tablo oluşturuyor ya da büyük bir sonuç kümesini tarıyor demektir — ikisi de indeks eksikliğinin klasik işaretidir.
EXPLAIN Çıktısını Okumak#
Yavaş sorguyu bulduğunuzda bir sonraki adım, MySQL'in o sorgu için hangi planı seçtiğini görmektir. EXPLAIN tam olarak bunu yapar:
EXPLAIN SELECT u.id, u.baslik, u.fiyat
FROM urunler u
WHERE u.kategori_id = 12
AND u.durum = 'aktif'
ORDER BY u.olusturma_tarihi DESC
LIMIT 20;
+----+-------+------+---------------+------+---------+------+--------+----------------------------------+
| id | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------+------+---------------+------+---------+------+--------+----------------------------------+
| 1 | u | ALL | NULL | NULL | NULL | NULL | 418302 | Using where; Using filesort |
+----+-------+------+---------------+------+---------+------+--------+----------------------------------+
Bu çıktı bir felaket tablosudur ve üç kelimesi her şeyi anlatır. type: ALL tam tablo taraması demektir; MySQL 418 bin satırın hepsine bakıp 20 tanesini seçiyor. key: NULL hiçbir indeks kullanılmadığını söyler. Using filesort ise sıralamayı bellekte ya da diskte ayrıca yaptığını gösterir.
type sütunundaki değerler kabaca kötüden iyiye şöyle sıralanır:
| type | Anlamı | Değerlendirme |
|---|---|---|
ALL | Tam tablo taraması | Küçük tablolar dışında kabul edilemez |
index | Tüm indeksi tarama | Taramadan iyi ama hâlâ pahalı |
range | İndekste aralık taraması | Genelde kabul edilebilir |
ref | İndeks üzerinden eşleşme | İyi |
eq_ref | Benzersiz indeksle tek satır | Çok iyi |
const | Sabit, tek satır | En iyi |
Extra sütununda görmek istemediğiniz üç ifade vardır: Using filesort (ek sıralama), Using temporary (geçici tablo) ve Using join buffer (indekssiz birleştirme). Görmekten memnun olacağınız ifade ise Using index'tir: sorgu ihtiyacı olan tüm sütunları indeksten okuyabilmiş, tabloya hiç dokunmamıştır. Buna kapsayan indeks (covering index) denir ve en hızlı senaryodur.
Daha ayrıntılı bilgi için EXPLAIN ANALYZE gerçek çalışma süresini ve satır tahminlerinin ne kadar tuttuğunu gösterir; tahmin ile gerçek arasındaki büyük fark, istatistiklerin bayat olduğuna işaret eder ve ANALYZE TABLE urunler; ile düzeltilir.
Doğru İndeksi Kurmak#
Yukarıdaki sorgu için doğru indeks, WHERE koşullarını ve ORDER BY sütununu aynı sırayla kapsayan bileşik bir indekstir:
-- Filtreleme sütunları önce, sıralama sütunu sonra
CREATE INDEX idx_urun_kategori_durum_tarih
ON urunler (kategori_id, durum, olusturma_tarihi);
+----+-------+------+-------------------------------+-------------------------------+---------+-------------+------+-------------+
| id | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------+------+-------------------------------+-------------------------------+---------+-------------+------+-------------+
| 1 | u | ref | idx_urun_kategori_durum_tarih | idx_urun_kategori_durum_tarih | 67 | const,const | 34 | Using where |
+----+-------+------+-------------------------------+-------------------------------+---------+-------------+------+-------------+
rows 418302'den 34'e düştü ve Using filesort kayboldu; çünkü indeks zaten olusturma_tarihi sırasında tutuluyor, MySQL sıralamayı ayrıca yapmak zorunda kalmadı. İşte bir sayfayı saniyelerden milisaniyelere indiren tek satır budur.
Bileşik indekste sütun sırası kritiktir ve en sık yapılan hata buradadır. İndeks bir telefon rehberi gibidir: soyada göre sıralı bir rehberde soyadını bilerek arama yapabilirsiniz, ama sadece adı bilerek yapamazsınız. Aynı şekilde (a, b, c) indeksi şu sorgularda kullanılabilir:
| Sorgu koşulu | (a, b, c) indeksi kullanılır mı |
|---|---|
WHERE a = 1 | Evet |
WHERE a = 1 AND b = 2 | Evet |
WHERE a = 1 AND b = 2 AND c = 3 | Evet, tamamen |
WHERE b = 2 | Hayır (soldan başlamıyor) |
WHERE a = 1 AND c = 3 | Kısmen (yalnızca a için) |
WHERE a > 5 AND b = 2 | Kısmen (aralıktan sonrası kullanılamaz) |
Son satır özellikle önemlidir: bir sütunda aralık koşulu (>, <, BETWEEN, LIKE 'abc%') kullandığınızda, indeksin o sütundan sonraki kısmı filtreleme için kullanılamaz. Bu yüzden bileşik indekste eşitlik koşullarını önce, aralık koşulunu en sona koyun.
İndeks eklemek bedava değildir: her INSERT, UPDATE ve DELETE işleminde indeksin de güncellenmesi gerekir ve disk alanı harcanır. Bu yüzden "her sütuna indeks atalım" yaklaşımı yazma performansını düşürür. Kullanılmayan indeksleri tespit edip temizleyin:
-- Hiç kullanılmayan indeksleri bul (performance_schema açık olmalı)
SELECT object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
AND count_star = 0
AND object_schema NOT IN ('mysql', 'performance_schema', 'sys')
ORDER BY object_schema, object_name;
Uygulama Tarafındaki Klasik Hatalar#
Bazı yavaşlıklar veritabanının değil, ona nasıl soru sorduğunuzun sonucudur. En pahalısı N+1 sorgu problemidir: bir liste çekersiniz, sonra listedeki her öğe için ayrı bir sorgu daha atarsınız.
// KÖTÜ: 1 + 50 = 51 sorgu
$urunler = $db->query("SELECT id, baslik, kategori_id FROM urunler LIMIT 50")->fetchAll();
foreach ($urunler as $u) {
$kat = $db->query("SELECT ad FROM kategoriler WHERE id = {$u['kategori_id']}")->fetch();
$u['kategori_adi'] = $kat['ad'];
}
Her sorgu ayrı ayrı hızlıdır — belki 2 milisaniye — ama elli tanesi 100 milisaniye eder ve gidiş-dönüş gecikmesi de üstüne biner. Doğrusu tek sorguda birleştirmektir:
// İYİ: tek sorgu, aynı sonuç
$sql = "SELECT u.id, u.baslik, k.ad AS kategori_adi
FROM urunler u
JOIN kategoriler k ON k.id = u.kategori_id
LIMIT 50";
$urunler = $db->query($sql)->fetchAll();
İkinci klasik hata SELECT * alışkanlığıdır. Tabloda uzun bir aciklama sütunu varsa ve listeleme sayfasında onu göstermiyorsanız, her satırda gereksiz kilobaytları ağdan geçirip belleğe alıyorsunuz demektir. Ayrıca SELECT * kapsayan indeks ihtimalini tamamen ortadan kaldırır; ihtiyacınız olan sütunları açıkça yazmak hem ağ trafiğini hem de plan kalitesini düzeltir.
Üçüncüsü derin sayfalama tuzağıdır. LIMIT 20 OFFSET 100000 yazdığınızda MySQL 100.020 satırı okur, 100.000 tanesini atar ve 20 tanesini döner. Sayfa numarası büyüdükçe sorgu doğrusal olarak yavaşlar:
-- KÖTÜ: derin offset
SELECT id, baslik FROM urunler ORDER BY id LIMIT 20 OFFSET 100000;
-- İYİ: son görülen anahtardan devam et (keyset pagination)
SELECT id, baslik FROM urunler WHERE id > 100000 ORDER BY id LIMIT 20;
Dördüncüsü indeksi işlevle kör etmektir. WHERE YEAR(olusturma_tarihi) = 2026 yazdığınızda MySQL her satır için fonksiyonu çalıştırmak zorunda kalır ve indeksi kullanamaz. Aynı şeyi aralık olarak yazın:
-- KÖTÜ: indeks kullanılamaz
WHERE YEAR(olusturma_tarihi) = 2026
-- İYİ: indeks aralığı olarak çalışır
WHERE olusturma_tarihi >= '2026-01-01' AND olusturma_tarihi < '2027-01-01'
Aynı tuzak WHERE LOWER(eposta) = '...' ve baştan joker karakterli LIKE '%kelime' için de geçerlidir; ikincisi tam metin araması gerektiren bir iştir ve FULLTEXT indeks ya da ayrı bir arama motoruyla çözülür.
Optimize Edemediğiniz Sorguyu Önbelleğe Alın#
Bazı sorgular doğaları gereği pahalıdır: aylık ciro raporu, kategori bazında sayım, "en çok satanlar" listesi. Bunları indeksle milisaniyelere indiremezsiniz ama her istekte çalıştırmak zorunda da değilsiniz. Sonucu bir nesne önbelleğinde tutmak, sorguyu saatte bir çalıştırıp geri kalan tüm isteklerde bellekten sunmak anlamına gelir:
$anahtar = 'anasayfa:cok_satanlar:v3';
$liste = $redis->get($anahtar);
if ($liste === false) {
// Pahalı sorgu yalnızca önbellek boşken çalışır
$liste = json_encode($db->query($agirSorgu)->fetchAll());
$redis->setex($anahtar, 900, $liste); // 15 dakika geçerli
}
$liste = json_decode($liste, true);
Buradaki iki detay önemlidir. Birincisi anahtara sürüm eklemek (v3): veri yapısını değiştirdiğinizde anahtarı değiştirerek eski önbelleği tek hamlede geçersiz kılarsınız. İkincisi, önbellek süresi dolduğunda aynı anda gelen yüzlerce isteğin hepsinin pahalı sorguyu birden çalıştırması riskidir; buna önbellek çığı denir ve kısa bir kilit ya da kademeli süre dağıtımıyla önlenir.
Nesne önbelleği seçenekleri ve kurulumu için Memcached nedir ve katman sıralamasının tamamı için sunucu tarafı önbellekleme katmanları yazılarına bakabilirsiniz. WordPress tarafında aynı işi eklenti düzeyinde çözmek isterseniz LiteSpeed Cache nesne önbelleğini de yönetir.
Son olarak: yaptığınız iyileştirmeyi yük altında doğrulayın. Tek kullanıcıyla hızlı görünen bir sayfa, elli eşzamanlı kullanıcıda veritabanı bağlantı havuzunu doldurup çökebilir. Ölçüm yöntemini k6 ile yük testi yazısında bulabilirsiniz.
Sıkça Sorulan Sorular#
Sayfamın yavaşlığının veritabanından kaynaklandığını nasıl anlarım#
İlk bayt süresine bakın: curl -w ile ölçtüğünüzde TLS bağlantısı tamamlandıktan sonra ilk baytın gelmesi uzun sürüyorsa sorun sunucu tarafındadır. Ardından PHP-FPM'in slowlog çıktısına bakın; yığın izinin tepesinde mysqli_query ya da PDO->execute görüyorsanız süre veritabanında geçiyordur. Kesinleştirmek için MySQL'in yavaş sorgu günlüğünü açıp aynı sayfayı yeniden yükleyin.
Yavaş sorgu günlüğünü açmak sunucuyu yavaşlatır mı#
Etkisi çok küçüktür ve teşhis değerinin yanında ihmal edilebilir. long_query_time değerini 1 saniye gibi makul bir eşikte tutarsanız yalnızca gerçekten sorunlu sorgular kaydedilir. Ancak log_queries_not_using_indexes ayarını uzun süre açık bırakmayın; küçük tablolarda indeks kullanmayan çok sayıda sorgu günlüğü şişirebilir. Teşhis bitince eşiği yükseltin ya da günlüğü kapatın.
Her sütuna indeks eklemek işe yarar mı#
Hayır, tersine zarar verir. Her indeks disk alanı harcar ve her yazma işleminde güncellenmesi gerekir; gereksiz indeksler INSERT ve UPDATE performansını düşürür. Doğru yaklaşım, yavaş sorgu günlüğünde gerçekten öne çıkan sorgulara bakıp onların WHERE ve ORDER BY desenine uygun bileşik indeksler kurmaktır. Kullanılmayan indeksleri de düzenli olarak temizleyin.
EXPLAIN çıktısında en çok neye dikkat etmeliyim#
Üç sütuna bakın: type, rows ve Extra. type: ALL tam tablo taraması demektir ve küçük tablolar dışında kabul edilemez. rows değeri, gerçekten döndürdüğünüz satır sayısına göre çok büyükse gereksiz iş yapılıyordur. Extra sütununda Using filesort ya da Using temporary görüyorsanız sıralama veya gruplama indeksten karşılanamıyordur; genellikle bileşik indeksin sırasını düzeltmek çözer.
N+1 sorgu problemi nedir ve nasıl anlarım#
Bir liste çektikten sonra listedeki her öğe için ayrı sorgu atmaktır; 50 öğe için 51 sorgu çalışır. Anlamanın en kolay yolu bir sayfa yüklemesinde toplam sorgu sayısını saymaktır — çatınızın hata ayıklama çubuğu ya da bir profilleyici bunu gösterir. Sayı öğe sayısıyla birlikte artıyorsa N+1 vardır. Çözüm, ilişkili veriyi JOIN ile ya da tek bir WHERE id IN (...) sorgusuyla toplu çekmektir.
Sorguyu optimize edemiyorum, ne yapmalıyım#
Bazı sorgular doğaları gereği pahalıdır (raporlar, toplu sayımlar). Bu durumda sorguyu hızlandırmak yerine çalışma sıklığını azaltın: sonucu Redis ya da Memcached gibi bir nesne önbelleğinde belirli bir süre saklayın, ya da gece çalışan bir işle önceden hesaplanmış bir özet tabloya yazın. Kullanıcı isteği sırasında ağır hesap yapmak yerine hazır sonucu okumak, en güvenilir çözümdür.
MySQL sürümünü yükseltmek sorgularımı hızlandırır mı#
Bir miktar yardımcı olur; yeni sürümler daha iyi sorgu planlayıcı ve optimizasyonlarla gelir. Ancak indeks eksikliğinden kaynaklanan tam tablo taramasını hiçbir sürüm yükseltmesi düzeltmez — 400 bin satır taranıyorsa yeni sürüm de 400 bin satır tarar, sadece biraz daha hızlı tarar. Önce sorgu planlarını düzeltin, sürüm yükseltmesini ayrı bir kazanç olarak değerlendirin.
Kapanış#
Sayfa hızı tartışmalarının çoğu tarayıcı tarafında geçer, oysa üç saniyelik bir ilk bayt süresinin arkasında neredeyse her zaman indekssiz bir sorgu ya da bir N+1 döngüsü vardır. Aklınızda kalması gereken dört alışkanlık şudur: optimize etmeden önce süreyi ölçüp darboğazı kanıtlamak, sorguları toplam etkiye göre önceliklendirmek (ortalamaya değil), her yavaş sorguya indeks eklemeden önce EXPLAIN çıktısını okumak ve optimize edilemeyen pahalı sorguları çalışma sıklığını azaltarak önbelleğe almak. Bu dördü, çoğu sitede tek bir eklenti kurmadan saniyeler kazandırır.
Veritabanı tarafını kendiniz ayarlamak yerine hazır ve optimize bir altyapıda çalışmak isterseniz Clou.TR paketleri bu işi büyük ölçüde üstlenir. Yoğun sorgu trafiği olan projeler için e-ticaret hosting ve kurumsal hosting paketlerimizi inceleyebilir, kendi MySQL ayarlarınızı yönetmek isterseniz tam root erişimli VDS ya da bulut sunucu çözümlerimizi tercih edebilirsiniz. Yavaş sorgu analizini ve indeks çalışmasını bize bırakmak isterseniz sunucu yönetimi hizmetimiz bu kapsamda destek verir.