Site sabah normal açılıyor, öğleden sonra tıkanıyor. Sunucuya bağlanıp htop çalıştırıyorsunuz, mysqld sürecinin CPU'yu doldurduğunu görüyorsunuz. Bir arama motorunda "mysql yavaş" yazıyorsunuz ve karşınıza innodb_buffer_pool_size değerini artırmanızı söyleyen onlarca yazı çıkıyor. Değeri artırıyorsunuz, servisi yeniden başlatıyorsunuz, yirmi dakika iyi gidiyor, sonra aynı yere geliyorsunuz.
Bunun sebebi şu: buffer pool'u büyütmek, tam tablo taraması yapan bir sorguyu tam tablo taraması olmaktan çıkarmaz. Yalnızca aynı hatayı biraz daha hızlı bellekten yaptırır. Veritabanı yavaşlıklarının büyük bölümü ayar eksikliğinden değil, tek bir sorgunun her çağrıldığında yüz binlerce satır okumasından kaynaklanır. Çözüm o sorguyu bulmak ve okuduğu satır sayısını düşürmektir.
Bu yazı ayar tavsiyesi vermez; teşhis yöntemi anlatır. Sırasıyla yavaş sorgu günlüğünü açacağız, mysqldumpslow ile en pahalı sorguları listeleyeceğiz, canlıda o anda takılan sorguyu SHOW PROCESSLIST ile yakalayacağız ve merkezdeki konuya geleceğiz: EXPLAIN çıktısındaki type, key ve rows sütunlarından eksik indeksi okumak. En sonunda da eklediğiniz indeksin gerçekten işe yaradığını ölçüyle kanıtlayacağız — tahminle değil.
Suçlunun Veritabanı Olduğunu Nasıl Doğrularsınız?#
Teşhise başlamadan önce sorunun gerçekten veritabanında olduğundan emin olun. PHP tarafındaki bir döngü, dış API'ye giden yavaş bir istek veya disk doluluğu da aynı belirtiyi üretir.
Hızlı bir ayrım için üç ölçüme bakın:
# 1. MySQL'in o anki yükü ve çalışan sorgu sayısı
mysqladmin -u root -p status
mysqladmin -u root -p extended-status | grep -E "Threads_running|Slow_queries|Questions"
# 2. Disk gerçekten mi meşgul, yoksa CPU mu?
iostat -x 2 3
# 3. Bekleyen bağlantı var mı?
mysql -u root -p -e "SHOW GLOBAL STATUS LIKE 'Threads_%';"
Threads_running değeri sürekli 1-2 civarındaysa MySQL boştur, sorun başka yerdedir. Bu sayı onlara çıkıyor ve orada takılı kalıyorsa sorgular birbirini bekliyor demektir; doğru yerdesiniz. Slow_queries sayacının dakikalar içinde yüzlerce artması da güçlü bir işarettir.
Uygulama tarafındaki diğer olası sebepleri elemek için sitenin neden yavaş açıldığını anlatan yazıdaki kontrol listesini kullanabilirsiniz.
Yavaş Sorgu Günlüğünü (Slow Query Log) Açma#
Yavaş sorgu günlüğü, belirlediğiniz süreden uzun süren her sorguyu tam metniyle bir dosyaya yazar. Teşhisin temel aracı budur ve varsayılan olarak kapalıdır.
Servisi Yeniden Başlatmadan Açma#
MySQL 5.7 ve 8.0'da bu değişkenler dinamiktir; canlı sunucuda kesinti olmadan açabilirsiniz:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
SET GLOBAL min_examined_row_limit = 1000;
Buradaki dört ayarın anlamı şu:
| Değişken | İşlevi | Teşhis için önerilen |
|---|---|---|
long_query_time | Kaç saniyeden uzun sorgular yazılsın | 1 (varsayılan 10, çok yüksek) |
log_queries_not_using_indexes | İndeks kullanmayan sorguları süreye bakmadan yaz | Geçici olarak ON |
min_examined_row_limit | Bu sayıdan az satır okuyanları yazma | 1000 |
slow_query_log_file | Günlük dosyasının yolu | Yazma izni olan bir dizin |
min_examined_row_limit satırı çoğu rehberde atlanır ama kritiktir: log_queries_not_using_indexes açıkken küçük referans tablolarına giden yüzlerce zararsız sorgu da günlüğe düşer ve dosya gürültüden okunmaz hâle gelir. Bu limit, "indekssiz ve çok satır okuyan" sorguları süzer.
Önemli bir davranış: long_query_time oturum başlangıcında kopyalanır. Değeri değiştirdiğinizde halihazırda açık olan bağlantılar eski değeri kullanmaya devam eder. Uygulamanız kalıcı bağlantı havuzu kullanıyorsa değişikliğin etkisini görmek için havuzun yenilenmesini bekleyin ya da uygulamayı yeniden başlatın.
Kalıcı Hâle Getirme#
Teşhisi birkaç gün sürdürecekseniz ayarları yapılandırma dosyasına yazın:
# /etc/mysql/mysql.conf.d/mysqld.cnf (MariaDB'de /etc/mysql/mariadb.conf.d/50-server.cnf)
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
min_examined_row_limit = 1000
log_slow_admin_statements = 1
sudo systemctl restart mysql
sudo tail -f /var/log/mysql/mysql-slow.log
Bir kayıt şuna benzer görünür:
# Time: 2026-08-18T09:14:22.481293Z
# User@Host: shopuser[shopuser] @ localhost [] Id: 41827
# Query_time: 4.912384 Lock_time: 0.000118 Rows_sent: 24 Rows_examined: 1842770
SET timestamp=1755508462;
SELECT * FROM siparisler WHERE musteri_id = 9042 ORDER BY olusturma DESC LIMIT 25;
Bu üç satır teşhisin özetidir. Rows_sent: 24 ama Rows_examined: 1842770 — MySQL 24 satır döndürmek için 1,8 milyon satır okumuş. Bu oran bir indeks eksikliğinin en net imzasıdır ve Query_time değerinden bile daha bilgilendiricidir.
Günlük dosyası hızla büyür; teşhis bitince kapatmayı unutmayın ve dosyayı logrotate ile döndürmeyi yapılandırın.
mysqldumpslow ile En Pahalı Sorguları Çıkarma#
Birkaç saatlik bir günlük dosyası binlerce satır olur ve elle okunmaz. mysqldumpslow, MySQL ile birlikte gelen bir Perl betiğidir; sorgulardaki sayı ve metin değerlerini soyutlayıp aynı kalıptaki sorguları tek satırda toplar.
# Toplam süreye göre en pahalı 10 sorgu kalıbı
sudo mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
# Ortalama süreye göre (nadiren çalışan ama çok yavaş olanlar)
sudo mysqldumpslow -s at -t 10 /var/log/mysql/mysql-slow.log
# En çok satır okuyanlar
sudo mysqldumpslow -s r -t 10 /var/log/mysql/mysql-slow.log
# Sadece belirli bir tabloyu içerenler
sudo mysqldumpslow -s t -g "siparisler" /var/log/mysql/mysql-slow.log
Sıralama seçenekleri:
| Bayrak | Sıralama ölçütü | Ne zaman kullanılır |
|---|---|---|
-s t | Toplam süre | Varsayılan tercihiniz. Sunucuya en çok yük bindiren kalıbı bulur |
-s at | Ortalama süre | Tek tek çok yavaş olan raporlama sorgularını yakalar |
-s c | Çağrılma sayısı | N+1 sorgu probleminin işareti |
-s r | Döndürülen satır | SELECT * ile fazla veri çeken sorgular |
-t N | İlk N sonucu göster | Çıktıyı sınırlar |
-s t ile -s at farkı önemlidir. 0,4 saniye süren ama saatte 40 bin kez çağrılan bir sorgu, 9 saniye süren ve günde iki kez çalışan bir rapordan çok daha fazla zarar verir. Toplam süreye göre sıralama, gerçek yükü gösterir.
performance_schema ile Günlük Dosyası Olmadan#
Paylaşımlı bir sunucudaysanız veya günlük dosyasına erişemiyorsanız, MySQL 5.7+ ve MariaDB 10.x'te aynı bilgi performance_schema üzerinden alınabilir:
SELECT
DIGEST_TEXT AS sorgu,
COUNT_STAR AS calisma_sayisi,
ROUND(SUM_TIMER_WAIT/1000000000000, 2) AS toplam_saniye,
ROUND(AVG_TIMER_WAIT/1000000000000, 4) AS ortalama_saniye,
SUM_ROWS_EXAMINED AS okunan_satir,
SUM_ROWS_SENT AS donen_satir
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
Bu sorgu, sunucu açıldığından beri biriken istatistikleri verir; günlük dosyasının aksine geçmişe dönük veri sağlar. Sayaçları sıfırlamak için TRUNCATE performance_schema.events_statements_summary_by_digest; kullanabilirsiniz.
Canlıda O An Takılan Sorguyu Yakalama#
Site şu anda tıkanıyorsa günlüğü beklemeyin; sorgu hâlâ çalışıyordur.
SHOW FULL PROCESSLIST;
Daha okunabilir bir çıktı için filtreleyin:
SELECT id, user, host, db, command, time, state, LEFT(info, 120) AS sorgu
FROM information_schema.processlist
WHERE command <> 'Sleep' AND time > 2
ORDER BY time DESC;
time sütunu sorgunun kaç saniyedir çalıştığını gösterir. Aynı sorgunun onlarca kopyasını 20-30 saniyelik sürelerle görüyorsanız suçluyu bulmuşsunuz demektir.
state sütunu neden beklediğini söyler:
state değeri | Anlamı | İlk bakılacak yer |
|---|---|---|
Sending data | Satırlar okunuyor ve süzülüyor (yanıltıcı ad — ağ değil, disk/CPU işi) | Eksik indeks |
Copying to tmp table | Geçici tablo oluşturuluyor | GROUP BY / ORDER BY indekssiz |
Creating sort index | Bellekte sıralama yapılıyor | ORDER BY için indeks yok |
Waiting for table metadata lock | Bir DDL komutu diğerlerini bekletiyor | Çalışan ALTER TABLE |
Locked / Updating | Satır kilidi bekleniyor | Uzun süren transaction |
Acil durumda bir sorguyu sonlandırabilirsiniz:
KILL QUERY 41827; -- sadece sorguyu iptal eder, bağlantı açık kalır
KILL 41827; -- bağlantıyı tümüyle keser
KILL QUERY daha nazik seçenektir. Ancak bu kalıcı bir çözüm değil, nefes alma alanıdır; sorgu birazdan yine çalışacaktır.
Bekleyen bir transaction şüpheniz varsa:
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS suren_saniye,
trx_rows_locked, LEFT(trx_query, 100) AS sorgu
FROM information_schema.innodb_trx
ORDER BY trx_started;
Dakikalardır RUNNING durumda kalan bir transaction, kilitlediği satırlara dokunan tüm sorguları bekletir ve bu genellikle bağlantı sayısının patlamasına yol açar. Bağlantı limitine dayanıyorsanız MySQL Too Many Connections hatası yazısı ayrı ayrı ele alıyor.
EXPLAIN Çıktısını Okuma: type, key ve rows#
Suçlu sorguyu bulduğunuza göre asıl iş burada başlıyor. EXPLAIN, MySQL'in o sorguyu nasıl çalıştırmayı planladığını gösterir. Sorguyu çalıştırmaz, sadece planı basar; bu yüzden üretimde güvenle kullanılır.
EXPLAIN SELECT * FROM siparisler
WHERE musteri_id = 9042
ORDER BY olusturma DESC
LIMIT 25;
+----+-------------+-------------+------+---------------+------+---------+------+---------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------------+------+---------------+------+---------+------+---------+-----------------------------+
| 1 | SIMPLE | siparisler | ALL | NULL | NULL | NULL | NULL | 1842770 | Using where; Using filesort |
+----+-------------+-------------+------+---------------+------+---------+------+---------+-----------------------------+
Çıktıda önce şu üç sütuna bakın — teşhisin yüzde doksanı buradadır.
type: Erişim Yöntemi#
type, MySQL'in satırlara nasıl ulaştığını söyler. Kötüden iyiye doğru:
type | Anlamı | Değerlendirme |
|---|---|---|
ALL | Tam tablo taraması | Kırmızı alarm. Küçük referans tabloları dışında kabul edilemez |
index | Tam indeks taraması | Yine baştan sona okuma; ALL'dan biraz iyi |
range | İndeks üzerinde aralık taraması | Kabul edilebilir; BETWEEN, >, IN için normal |
ref | İndeksle eşleşen satırlar | İyi. Tekil olmayan indeks üzerinden erişim |
eq_ref | Birincil/tekil anahtarla tek satır | Çok iyi. JOIN için ideal |
const / system | Sabit tek satır | En iyisi |
Bizim örneğimizde type: ALL görünüyor: MySQL 25 satır döndürmek için 1,8 milyon satırın tamamını okumayı planlıyor.
key ve possible_keys#
possible_keys, MySQL'in kullanabileceğini düşündüğü indeksleri listeler; key ise gerçekten seçtiğidir. Üç durum vardır:
possible_keys: NULL,key: NULL— Kullanılabilir hiçbir indeks yok. Eksik indeks kesin.possible_keysdolu,key: NULL— İndeks var ama optimizer kullanmamayı seçmiş. Genellikle tablo istatistikleri bayattır (ANALYZE TABLEdeneyin) ya da sorgu, sütunu bir fonksiyona sardığı için indeksi kullanılamaz hâle getirmiştir.keydolu — İndeks kullanılıyor;rowsdeğerine bakıp yeterli mi diye değerlendirin.
İkinci maddedeki tuzak yaygındır. Şu iki sorgudan yalnızca ilki indeks kullanabilir:
-- İndeks kullanır
SELECT * FROM siparisler WHERE olusturma >= '2026-08-01' AND olusturma < '2026-09-01';
-- İndeksi kullanamaz: sütun fonksiyona sarılmış
SELECT * FROM siparisler WHERE DATE(olusturma) = '2026-08-01';
Aynı kural WHERE YEAR(tarih) = 2026, WHERE UPPER(eposta) = ... gibi tüm kullanımlar için geçerlidir. İndeks sütunun ham değerine kuruludur; değeri dönüştürdüğünüz anda indeks devre dışı kalır.
rows: Okunması Beklenen Satır Sayısı#
rows, optimizer'ın tahminidir. Kesin sayı değildir ama büyüklük mertebesi doğrudur. Değerlendirme ölçütü basittir: rows değeri, sorgunun döndürdüğü satır sayısına yakın olmalıdır. 25 satır isteyip 1,8 milyon satır okumayı planlıyorsanız arada dört mertebe fark var demektir.
Extra: Ek Uyarılar#
Extra değeri | Anlamı |
|---|---|
Using where | Satırlar okunduktan sonra süzülüyor — tek başına sorun değil |
Using filesort | Sonuçlar ayrıca sıralanıyor; ORDER BY indeksten karşılanamıyor |
Using temporary | Geçici tablo oluşturuluyor; GROUP BY/DISTINCT pahalı |
Using index | Covering index — tüm veri indeksten geliyor, tabloya hiç gidilmiyor. En iyi durum |
Using index condition | Süzme indeks katmanında yapılıyor; iyi işarettir |
Using filesort ile Using temporary birlikte görünüyorsa sorgu iki kez pahalı iş yapıyor demektir.
Gerçekten Ne Olduğunu Görmek: EXPLAIN ANALYZE#
MySQL 8.0.18 ve sonrasında planla yetinmeyip gerçek çalışma sürelerini de görebilirsiniz:
EXPLAIN ANALYZE SELECT * FROM siparisler
WHERE musteri_id = 9042 ORDER BY olusturma DESC LIMIT 25;
Çıktıda her adımın actual time ve actual rows değerleri yer alır. Tahmin ile gerçek arasında büyük fark varsa istatistikler bayattır. Bu komut sorguyu gerçekten çalıştırır; UPDATE/DELETE üzerinde kullanmayın.
Eksik İndeksi Ekleme ve Sonucu Kanıtlama#
Örnek sorgumuzun ihtiyacı belli: musteri_id ile süzüp olusturma ile sıralıyor. İkisini kapsayan bileşik bir indeks hem süzmeyi hem sıralamayı karşılar.
-- Mevcut indeksleri görün
SHOW INDEX FROM siparisler;
-- Bileşik indeksi ekleyin
ALTER TABLE siparisler ADD INDEX idx_musteri_olusturma (musteri_id, olusturma);
Bileşik indekste sütun sırası belirleyicidir. Kural şudur: önce eşitlikle süzülen sütunlar, sonra sıralama veya aralık sütunu. (olusturma, musteri_id) sırası bu sorgu için işe yaramazdı, çünkü indeksin ilk sütunu üzerinde bir eşitlik koşulu olmadan sonraki sütunlara verimli erişilemez.
Ölçmeden önce ve sonra kıyaslama yapın:
FLUSH STATUS;
SELECT SQL_NO_CACHE * FROM siparisler
WHERE musteri_id = 9042 ORDER BY olusturma DESC LIMIT 25;
SHOW SESSION STATUS LIKE 'Handler_read%';
Handler_read_rnd_next sayacı, "tabloyu sırayla okuma" işlemlerini sayar; tam tablo taramasının doğrudan göstergesidir. İndeks öncesi bu sayaç milyonlarda, sonrasında onlarda olmalıdır. Handler_read_key değerinin artması ise indeksin kullanıldığını doğrular.
İndeks eklendikten sonra EXPLAIN çıktısı şuna dönmelidir:
| type | possible_keys | key | rows | Extra |
| ref | idx_musteri_olusturma | idx_musteri_olusturma | 38 | Using where |
type: ALL → ref, rows: 1842770 → 38, Using filesort kayboldu. Kanıt budur; "daha hızlı geldi" hissi değil.
Optimizer yeni indeksi hemen kullanmıyorsa istatistikleri tazeleyin:
ANALYZE TABLE siparisler;
İndeks Eklerken Dikkat#
Her indeks yazma işlemlerini biraz yavaşlatır ve disk alanı tüketir. Bu yüzden "ne olur ne olmaz" diye indeks eklemek zararlıdır. Kullanılmayan indeksleri MySQL 8.0'da sys şeması üzerinden görebilirsiniz:
SELECT * FROM sys.schema_unused_indexes;
SELECT * FROM sys.schema_redundant_indexes;
Büyük tablolarda ALTER TABLE işlemi tabloyu kilitleyebilir. MySQL 8.0'da çevrimiçi DDL çoğu indeks ekleme için desteklenir, yine de yoğun saatlerde yapmayın ve öncesinde mysqldump ile yedek alın.
Teşhis Bitti, Sırada Ne Var?#
Sorguyu bulup indeksledikten sonra çoğu durumda sorun kapanır. Kapanmadıysa sıradaki adaylar şunlardır:
- N+1 sorgu problemi.
mysqldumpslow -s cçıktısında aynı sorgunun on binlerce kez çalıştığını görüyorsanız sorun tek sorguda değil, uygulamanın döngü içinde sorgu atmasındadır. Çözüm veritabanında değil koddadır. SELECT *alışkanlığı. İhtiyaç duyulmayanTEXT/BLOBsütunlarını çekmek hem ağı hem geçici tabloları şişirir. Sütunları açıkça yazmak, covering index imkânını da açar.- Bayat istatistikler. Toplu veri yüklemesinden sonra
ANALYZE TABLEçalıştırılmadıysa optimizer yanlış plan seçebilir. - Gerçek kaynak darboğazı. Tüm sorgular indeksli ve hâlâ yavaşsa artık ayarlara bakma sırası gelmiştir — o noktada MySQL/MariaDB performans optimizasyonu yazısındaki buffer pool ve bağlantı ayarları anlamlı hâle gelir.
Sıralama bu yönde olmalıdır. Ayarları önce değiştirmek, ölçüm zeminini kaydırdığı için teşhisi de zorlaştırır. WordPress gibi hazır uygulamalarda tabloların şişmesi ayrı bir konudur; WordPress veritabanı optimizasyonu yazısı o tarafa odaklanıyor.
Sıkça Sorulan Sorular#
Slow query log açık kalırsa performansı düşürür mü?#
Etkisi çoğu sistemde ölçülemeyecek kadar küçüktür; yalnızca eşiği aşan sorgular için birkaç satır dosyaya yazılır. Asıl risk disk alanıdır: long_query_time değerini 0 yapıp log_queries_not_using_indexes seçeneğini de açık bırakırsanız yoğun bir sunucuda dosya saatler içinde gigabaytlara ulaşabilir. Teşhis bitince kapatın veya eşiği yükseltin ve dosyayı logrotate ile döndürün.
EXPLAIN çıktısındaki rows değeri neden gerçek satır sayısıyla uyuşmuyor?#
rows, tablo istatistiklerine dayanan bir tahmindir; kesin sayı değildir. Örnekleme yoluyla hesaplandığı için sapması normaldir. Ancak sapma birkaç kat değil de mertebelerce ise istatistikler bayatlamış demektir; ANALYZE TABLE tablo_adi; çalıştırıp tekrar bakın. Gerçek sayıları görmek isterseniz MySQL 8.0.18+ sürümlerinde EXPLAIN ANALYZE komutu tahmin ile gerçek değerleri yan yana verir.
İndeks eklediğim hâlde MySQL kullanmıyor, neden?#
En yaygın üç sebep var. Birincisi, sorgu sütunu bir fonksiyona sarıyor olabilir (WHERE DATE(tarih) = ...); bu durumda indeks devre dışı kalır. İkincisi, bileşik indeksin sütun sırası sorgunun süzme düzenine uymuyordur. Üçüncüsü, tablo çok küçüktür ve optimizer tam taramayı daha ucuz bulmuştur — ki bu doğru bir karardır. EXPLAIN çıktısında possible_keys dolu ama key boşsa ilk iki ihtimale odaklanın.
mysqldumpslow ile pt-query-digest arasındaki fark ne?#
mysqldumpslow MySQL ile birlikte gelir, ek kurulum istemez ve temel gruplama ile sıralamayı yapar. Percona Toolkit'in parçası olan pt-query-digest ise çok daha ayrıntılıdır: sorgu başına süre dağılımını, yüzdelik dilimleri, kilit sürelerini ve okunan satır istatistiklerini raporlar. Küçük ve orta ölçekli teşhislerde mysqldumpslow yeterlidir; düzenli performans takibi yapıyorsanız pt-query-digest kurmaya değer.
Paylaşımlı hostingte slow query log'a erişemiyorum, ne yapabilirim?#
Dosya sistemine erişiminiz yoksa performance_schema.events_statements_summary_by_digest tablosunu sorgulayarak aynı bilgiyi alabilirsiniz — bu tablo sunucu açıldığından beri biriken sorgu istatistiklerini tutar ve normal bir veritabanı kullanıcısıyla okunabilir (yetki verilmişse). Alternatif olarak uygulama tarafında sorgu süresi ölçen bir profilleyici kullanabilir, WordPress kullanıyorsanız sorgu izleme eklentileriyle sayfa başına çalışan sorguları görebilirsiniz.
Sorgu bazen hızlı bazen yavaş çalışıyor, sebebi ne olabilir?#
Bu genellikle önbellek etkisidir: veri InnoDB buffer pool'da duruyorsa sorgu bellekten, durmuyorsa diskten okunur ve aradaki fark on kata kadar çıkabilir. İkinci yaygın sebep kilit beklemeleridir; aynı satırlara dokunan bir transaction açıkken sorgu bekler, kapanınca hızlanır. Ölçüm yaparken SQL_NO_CACHE kullanın ve aynı sorguyu birkaç kez çalıştırıp SHOW PROCESSLIST çıktısındaki state değerine bakın.