WordPress, XenForo ya da kendi geliştirdiğiniz bir uygulama yavaşladığında ilk şüpheli genellikle veritabanıdır. İlk tepki de çoğu zaman bir forumdan kopyalanan birkaç my.cnf ayarı ya da daha büyük bir sunucu olur; ikisinin de etkisi kısa sürer. Veritabanında harcanan sürenin büyük bölümünü genellikle birkaç sorgu tüketir ve her birini ölçerek, yürütme planını okuyarak ve doğru indeksi (index) ekleyerek düzeltebilirsiniz. Bu rehber MySQL ve MariaDB için bu yolu adım adım izliyor: pahalı sorguları bulmak, EXPLAIN çıktısını okumak, bileşik indeks (composite index) tasarlamak, indeksi siteyi kilitlemeden eklemek ve kazancı ölçerek doğrulamak. Önbellek isabet oranı, kilit çekişmesi, bağlantı havuzu ve donanım gibi daha genel konular için veritabanı performansı yazımıza bakabilirsiniz.
Sürüm notları
Aşağıdaki komutların çoğu hem MySQL'de hem MariaDB'de çalışır, ancak bazı özellikler sürüme bağlıdır; sürümünüzü SELECT VERSION(); ile öğrenin. MySQL 8.0 Nisan 2026'da ömrünü tamamladı (EOL); Oracle 8.4 LTS ya da 9.7 LTS sürümüne geçilmesini öneriyor ve yeni kurulumlar da bu sürümlerle yapılmalı.| Özellik | MySQL | MariaDB |
|---|---|---|
| Yavaş sorgu değişkenleri | slow_query_log, long_query_time, slow_query_log_file | Aynı; 10.11 ile log_slow_query, log_slow_query_time, log_slow_query_file adları da geldi (eski adlar çalışmaya devam ediyor) |
| Günlükte ek ayrıntı | log_slow_extra (8.0.14+) | log_slow_verbosity |
| Gerçek çalışma değerleriyle plan | EXPLAIN ANALYZE (8.0.18+) | ANALYZE SELECT … (10.1+) |
| performance_schema, sys şeması | Varsayılan olarak açık | performance_schema varsayılan olarak kapalı ve yalnızca başlangıçta açılabilir; sys 10.6'dan itibaren |
| İndeksi silmeden devre dışı bırakma | Görünmez indeks (invisible index, 8.0+) | Yok sayılan indeks (ignored index, 10.6+) |
| Azalan (DESC) indeks sütunu | 8.0+ | 10.8+ |
1. Belirtiler ve ilk kontroller
Sitenin geri kalanı normalken tek bir sayfa yavaşsa önce o isteği profilleyin (WordPress'te Query Monitor, Laravel Debugbar, Django Debug Toolbar): 400 küçük sorgu çalıştıran bir sayfanın çözümü, tek bir 3 saniyelik sorgunun çözümünden farklıdır. Her şey birden yavaşsa sorun sürerken sunucuya bakın:
Code:
SHOW FULL PROCESSLIST;
-- en eski açık işlemler (transaction)
SELECT trx_mysql_thread_id, trx_started, LEFT(trx_query, 80) AS query
FROM information_schema.INNODB_TRX
ORDER BY trx_started LIMIT 10;
- Çok sayıda oturum "Waiting for table metadata lock" durumunda: genellikle bir ALTER TABLE (ya da başka bir DDL komutu) uzun süren bir işlemi (transaction) bekliyordur ve o tabloya gelen her yeni sorgu onun arkasında sıraya girer. İşlemi INNODB_TRX tablosunda bulun; bu bir kilit sorunudur, indeks sorunu değil.
- Uzun süren birkaç sorgu ve %100'e yakın CPU kullanan mysqld: bellekte milyonlarca satır okuyan sorgular. Bu rehberin çözdüğü durum budur.
- Düşük CPU, meşgul disk: iostat -x 1 çıktısında %util ve bekleme süreleri (r_await ve w_await; eski sürümlerde await) yüksektir. Büyük olasılıkla çalışma kümesi (working set) belleğe sığmıyordur.
Tampon havuzunu (buffer pool) bir çözüm olarak değil, bir ipucu olarak görün: Innodb_buffer_pool_reads (diske giden okumalar; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'; çıktısında) hızla artıyorsa innodb_buffer_pool_size değerine bakın. Varsayılan değer hem MySQL'de hem MariaDB'de 128 MB'tır ve tamamen veritabanına ayrılmış bir sunucu için çok küçüktür. Daha fazla bellek 2 milyon satırlık bir taramayı ucuzlatır, ama onu 20 satırlık bir okumaya dönüştürmez.
2. Yavaş sorgu günlüğünü (slow query log) açın
Mevcut değerleri SHOW GLOBAL VARIABLES LIKE '%slow_query%'; ve SHOW GLOBAL VARIABLES LIKE '%query_time%'; ile kontrol edin, ardından günlüğü sunucuyu yeniden başlatmadan açın (paylaşımlı hostingde bunu sağlayıcınızdan isteyin). mysql kullanıcısının yazabildiği bir yol seçin; en kolayı veri dizinidir (SELECT @@datadir; ile bakın ve aşağıdaki yolu buna göre değiştirin):
Code:
SET GLOBAL slow_query_log_file = '/var/lib/mysql/mysql-slow.log';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log = 1;
Sık düşülen bir tuzak: SET GLOBAL long_query_time yalnızca bundan sonra açılan bağlantıları etkiler; mevcut bağlantıları, hatta komutu yazdığınız oturumu bile etkilemez. Sıradan PHP istekleri her seferinde yeniden bağlanır, ama kalıcı bağlantılar (persistent connection), bağlantı havuzları ve uzun ömürlü worker süreçleri yeniden bağlanana kadar eski 10 saniyelik varsayılanla çalışır. Günlük boş kalıyorsa bu süreçleri yeniden başlatın.
SET GLOBAL ile yapılan ayar yeniden başlatmada kaybolur; bu yüzden ayarları seçenek dosyasının (option file) [mysqld] bölümüne de ekleyin (MySQL 8 ve sonrasında SET PERSIST de kullanılabilir). Yalnızca kendi sunucunuza uyan bloğu kullanın: MySQL log_slow_verbosity satırını, MariaDB de log_slow_extra satırını tanımaz ve bilinmeyen bir seçenek yüzünden sunucu açılmaz. MySQL 8.x ve sonrası:
INI:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1
log_slow_extra = 1
MariaDB:
INI:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1
log_slow_verbosity = query_plan,explain
log_slow_extra, dosyaya yazılan her kayda diskte oluşturulan geçici tablolar gibi sorgu başına sayaçlar ekler; log_slow_verbosity=query_plan,explain ise plan ayrıntılarını ve EXPLAIN çıktısını her kaydın içine yazar. Seçenek dosyasının yeri:
- DirectAdmin: /etc/my.cnf. DirectAdmin belgelerine göre ayarlar /etc/my.cnf.d/ (RHEL ailesi) ya da /etc/mysql/conf.d/ (Debian) altındaki dosyalara da dağılmış olabilir; önce oralarda bir [mysqld] bölümü olup olmadığına bakın. systemctl restart mysqld ile yeniden başlatın (bazı kurulumlarda servis adı mariadb). /usr/local/directadmin/conf/my.cnf dosyasını düzenlemeyin: DirectAdmin bu dosyayı mysqldump çağrıları için kullanır ve düzenli olarak yeniden oluşturur. Aynı yapılandırmayı DirectAdmin'de MariaDB strict mode rehberimizde adım adım düzenliyoruz.
- cPanel: /etc/my.cnf, ardından /scripts/restartsrv_mysql.
- Debian ve Ubuntu paketleri: genellikle /etc/mysql/mysql.conf.d/mysqld.cnf (MySQL) ya da /etc/mysql/mariadb.conf.d/50-server.cnf (MariaDB).
- Emin değilseniz: mysqld --verbose --help | grep -A1 "Default options" (MariaDB 10.5 ve sonrasında mariadbd) sunucunun okuduğu dosyaları sırasıyla listeler.
Diğer ayarlar:
- long_query_time: 1 saniyeyle başlayın, en kötü sorguları düzelttikçe adım adım düşürün. İş yükünün tamamını görmek için eşiği normal trafikte sınırlı bir süre (örneğin bir saat) 0 yapın, disk alanını izleyin ve ardından eski değere dönün.
- log_queries_not_using_indexes: indeks kullanmayan her sorguyu, ne kadar hızlı olursa olsun yazar ve günlüğü küçük tablolardaki zararsız sorgularla doldurur. Yalnızca kısa süre kullanın.
- min_examined_row_limit: her kayda uygulanır; satır kilidinde 20 saniye bekleyip yalnızca üç satır inceleyen bir sorguyu da gizleyebilir. Varsayılan değeri olan 0'da bırakın.
Bir MySQL kaydı şöyle görünür (MariaDB, Thread_id, Schema ve QC_hit gibi alanlar da ekler):
Code:
# Time: 2026-10-27T07:42:15.081736Z
# User@Host: shop[shop] @ localhost [] Id: 41
# Query_time: 2.913473 Lock_time: 0.000006 Rows_sent: 20 Rows_examined: 1984213
SET timestamp=1793086932;
SELECT id, total, status, created_at FROM orders WHERE customer_id = 4821 AND status = 'shipped' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;
Önce Rows_examined ile Rows_sent değerlerini karşılaştırın: 20 satır döndürmek için 2 milyon satır okumak bir indeks sorunudur. İlk kilitleri almak için geçen süre Lock_time alanında görünür ve eşik hesabına girmez; bu yüzden metadata kilidi (metadata lock) yığılmalarını süreç listesinde canlı yakalamak daha güvenilirdir.
Günlük dosyasını diski doldurmadan önce döndürün (log rotation): dosyayı yeniden adlandırın, ardından yalnızca yavaş sorgu günlüğünü kapatıp yeniden açan FLUSH SLOW LOGS komutunu çalıştırın (FLUSH LOGS ise yeni bir ikili günlük, yani binary log dosyası da başlatır). Komut RELOAD yetkisi ister. logrotate kullanıyorsanız postrotate bölümüne mysql -e 'FLUSH SLOW LOGS' yazın; DirectAdmin'de kimlik bilgileri için mysql --defaults-extra-file=/usr/local/directadmin/conf/my.cnf -e 'FLUSH SLOW LOGS' biçimini kullanın (bu seçenek ilk sırada olmalıdır). İsterseniz dosyayı paketinizin getirdiği mevcut logrotate kuralına da ekleyebilirsiniz. SELinux'un etkin olduğu sistemlerde (AlmaLinux'un varsayılan kurulumu gibi) veri dizini dışında yeni açtığınız bir günlük dizinine yazma engellenebilir; hata günlüğünde (error log) izin hatası olup olmadığına bakın.
3. Günlüğü özetleyin ve toplam süreye göre sıralayın
En yavaş tek sorgu çoğu zaman en büyük sorun değildir. Gece bir kez 40 saniye süren bir rapor günde 40 saniyeye mal olur; saatte 12.000 kez çalışan 0,12 saniyelik bir sorgu ise her saat 24 dakikalık veritabanı süresi tüketir. Sorguları toplam süreye (ortalama × çağrı sayısı) göre sıralayın ve listenin başından başlayın. mysqldumpslow (MariaDB'de mariadb-dumpslow) aksi belirtilmedikçe ortalama süreye göre sıraladığı için -s t ekleyin:
Bash:
mysqldumpslow -s t -t 10 /var/lib/mysql/mysql-slow.log
Percona Toolkit içindeki pt-query-digest daha ayrıntılı bir rapor verir: sorguları parmak izine (fingerprint) göre gruplar ve varsayılan olarak toplam süreye göre sıralar. percona-toolkit paketini Percona deposundan kurabilir ya da yalnızca bu Perl betiğini indirebilirsiniz. Üretim sunucusunu yormamak için günlüğün bir kopyasını başka bir makinede de inceleyebilirsiniz:
Bash:
# Percona deposu tanımlıysa paket olarak:
sudo percona-release enable pt release
sudo apt-get install percona-toolkit # RHEL ailesi: sudo yum install percona-toolkit
# ya da yalnızca bu betik:
curl -LO https://www.percona.com/get/pt-query-digest
perl pt-query-digest --since 24h /var/lib/mysql/mysql-slow.log > digest.txt
Raporun başındaki genel özetin ardından bir profil tablosu gelir (long_query_time = 0 ile alınmış bir saatlik kayıttan örnek, kısaltılmış):
Code:
# Rank Query ID Response time Calls R/Call V/M Item
# ==== ============ ============== ====== ======= ===== ================
# 1 0x6A51… 1480.2 58.1% 12450 0.1189 0.02 SELECT orders
# 2 0x3C5F… 402.9 15.8% 38 10.6026 1.90 SELECT order_items
Response time toplam süreyi ve bu sürenin payını, Calls ile R/Call (çağrı başına ortalama) ise sürenin nereden geldiğini gösterir. Yüksek V/M (varyansın ortalamaya oranı) düzensiz süreler demektir ve çoğu zaman kilitlere ya da soğuk önbelleğe işaret eder. Profilin ardından her sorgu için ayrıntılı bir bölüm gelir; oradaki örnek sorguyu doğrudan EXPLAIN ile inceleyebilirsiniz.
Günlüğe erişiminiz yoksa sorguları özet (digest) bazında toplayan performance_schema'yı kullanın (MySQL'de varsayılan olarak açık; MariaDB'de performance_schema=ON ekleyip yeniden başlatın). En kullanışlı görünüm sys şemasındadır ve toplam gecikmeye göre sıralıdır:
Code:
SELECT query, exec_count, total_latency, avg_latency, rows_examined_avg
FROM sys.statement_analysis LIMIT 10;
sys.statements_with_full_table_scans görünümü tabloları baştan sona okuyan sorguları listeler. Bu sayaçlar sunucunun son yeniden başlatılmasından (ya da özet tablosunun son boşaltılmasından) bu yana tutulur.
4. EXPLAIN çıktısını tablo gibi okuyun
Sorgunun başına EXPLAIN ekleyin (dikey çıktı için sorguyu noktalı virgül yerine \G ile bitirin). Her tablo için bir satır gelir:| Sütun | Anlamı | Alarm işareti |
|---|---|---|
| type | Erişim yöntemi, iyiden kötüye: const (birincil ya da benzersiz anahtarla tek satır), eq_ref (birleştirilen her satır için benzersiz anahtarla tek satır), ref (benzersiz olmayan indekste eşitlik), range (aralık), index (indeksin tamamı), ALL (tam tablo taraması) | Büyük tabloda ALL ya da index |
| possible_keys, key | Değerlendirilen indeksler ve seçilen indeks | key değeri NULL |
| key_len | Kullanılan indeks bölümünün bayt cinsinden uzunluğu; bileşik indeksin kaç sütununun kullanıldığını gösterir | Beklenenden kısa |
| rows | İncelenecek satır sayısının tahmini | Döndürülen satır sayısının çok üstünde |
| filtered | WHERE koşulundan sonra kalacak satırların tahmini yüzdesi (MariaDB bunu EXPLAIN EXTENDED ya da ANALYZE ile gösterir) | Yüksek rows ile düşük değer |
| Extra | Satırların nasıl işlendiği | Çok satırda Using filesort ya da Using temporary |
Sık görülen Extra değerleri:
- Using where: satırlar okunduktan sonra süzülüyor. Büyük bir tabloda ALL ile birlikte görünmüyorsa normaldir.
- Using index: kapsayan indeks (covering index); tablo satırları hiç okunmuyor.
- Using index condition: WHERE koşulunun bir kısmı, tam satır okunmadan önce indeks kayıtları üzerinde kontrol ediliyor (index condition pushdown).
- Using filesort: hiçbir indeks satırları ORDER BY sırasında vermediği için ek bir sıralama yapılıyor; diskte olmak zorunda değildir ama çok satırda pahalıdır.
- Using temporary: dahili bir geçici tablo; genellikle GROUP BY ya da DISTINCT için.
- Using join buffer (hash join ya da Block Nested Loop): bu tabloda birleştirme (join) koşulu için kullanılabilir bir indeks bulunamadı; join sütununu indekslemeyi düşünün.
rows ve filtered, sorgu iyileştiricinin (optimizer) istatistiklere dayanan tahminleridir. Gerçekte ne olduğunu MySQL 8.0.18 ve sonrasında EXPLAIN ANALYZE, MariaDB'de ise r_rows ve r_filtered sütunlarını ekleyen ANALYZE SELECT … gösterir. İkisi de sorguyu gerçekten çalıştırır: 30 saniyelik bir sorgu yine 30 saniye sürer, MariaDB'deki ANALYZE UPDATE ya da ANALYZE DELETE de veriyi gerçekten değiştirir; yazma sorgularını bir kopyada deneyin. Sorguyu çalıştırmadan aynı ağaç görünümünü yalnızca tahminlerle görmek isterseniz MySQL 8.0.16 ve sonrasında EXPLAIN FORMAT=TREE kullanın. EXPLAIN ANALYZE ağacını en içteki satırdan dışarı doğru okuyun ve tahmini satır sayısını gerçek satır sayısıyla karşılaştırın; büyük bir fark çoğu zaman eskimiş istatistiklere ya da dengesiz veri dağılımına işaret eder (9. bölüm).
5. İndeks tasarım kuralları
- En soldaki önek (leftmost prefix): (a, b, c) indeksi; a, a ile b ya da a, b ve c üzerindeki koşullarda kullanılabilir. Yalnızca b üzerindeki bir koşula yardımcı olmaz (MySQL 8'deki skip scan yalnızca dar durumları kapsar).
- Önce eşitlik, sonra tek bir aralık: = ya da IN ile karşılaştırılan sütunlar başa, aralık koşulu taşıyan sütun (>, < ya da BETWEEN; LIKE 'abc%' de bir aralıktır) onların arkasına gelir. İndeks yalnızca ilk aralık sütununa kadar arama (seek) yapabilir; sonraki sütunlar süzmeye ya da kapsamaya yarar, ama taranan aralığı daraltmaz.
- Sıralamayı indekse bırakın: eşitlik sütunlarından sonraki sütunlar ORDER BY ile örtüşüyorsa satırlar indeksten sıralı gelir, filesort ortadan kalkar ve sorgu LIMIT değerine ulaşınca okumayı bırakır. Karışık yönler (a ASC, b DESC) azalan (DESC) bir indeks sütunu gerektirir.
- Sık çalışan sorgular için kapsayan indeks: sorgunun ihtiyaç duyduğu tüm sütunlar indeksteyse tablo satırları hiç okunmaz. InnoDB birincil anahtarı (primary key) her ikincil indekste saklar; id sütunu bu yüzden zaten dahildir.
- Seçicilik (selectivity): az sayıda farklı değeri olan bir sütun (status, 0/1 bayrağı) tek başına nadiren işe yarar, ama bileşik indeksin eşitlik parçası olarak iyi çalışır. Eşitlik sütunlarının sırası okunan kayıt sayısını değiştirmez; bu yüzden onları yeniden kullanıma göre sıralayın: başka sorgular yalnızca customer_id ile süzüyorsa onu başa koyun.
- Mükerrer indeks açmayın, her sütuna ayrı indeks eklemeyin: (customer_id, status, created_at) varken (customer_id) indeksi gereksizdir; sys.schema_redundant_indexes ve pt-duplicate-key-checker bu tür durumları bulur. Her indeks INSERT, UPDATE ve DELETE işlemlerini yavaşlatır, disk ve tampon havuzu belleği tüketir.
6. Uygulamalı örnek: sipariş sorgusu
Bir sipariş geçmişi sayfası her ziyarette aşağıdaki sorguyu çalıştırıyor. Tabloda yaklaşık 2 milyon satır var ve tek ikincil indeks status sütunu üzerinde:
Code:
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id INT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL,
total DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT id, total, status, created_at
FROM orders
WHERE customer_id = 4821
AND status = 'shipped'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Düzeltmeden önce EXPLAIN tam tablo taraması gösterir (ya da idx_status üzerinde ref seçer; bu da gönderilmiş siparişlerin hepsini okur). Değerler örnektir, sizinkiler farklı olacaktır:
| Plan | type | key | key_len | rows | Extra |
|---|---|---|---|---|---|
| Önce | ALL | NULL | NULL | yaklaşık 1,9 milyon | Using where; Using filesort |
| Sonra | range | idx_cust_status_created | 91 | yaklaşık 150 | filesort yok (MySQL Backward index scan gösterebilir) |
Aday indeksler:
| İndeks | Ne olur | Karar |
|---|---|---|
| (customer_id) | Müşterinin tüm siparişlerini okur, süzer, sonra sıralar | Daha iyi, ama filesort sürüyor |
| (customer_id, created_at) | En yeniden başlayarak okur, 20 sonuç bulana kadar gönderilmemiş siparişleri atlar | Siparişlerin çoğu gönderilmişse yeterli |
| (customer_id, status, created_at) | Doğrudan 1 Ocak'tan bu yana gönderilmiş siparişlere gider, sıralı okur, 20 satırda durur | En uygun seçenek |
| (created_at, customer_id, status) | Aralık ilk sütunda olduğu için tüm müşterilerin 1 Ocak'tan bu yana verdiği bütün siparişleri tarar | Yanlış sıra |
İndeksi çevrimiçi (online DDL) ekleyin:
Code:
SET SESSION lock_wait_timeout = 10;
ALTER TABLE orders
ADD INDEX idx_cust_status_created (customer_id, status, created_at),
ALGORITHM=INPLACE, LOCK=NONE;
InnoDB'de bu yöntem MySQL'de de MariaDB'de de okuma ve yazmaların sürmesine izin verir; ALGORITHM ve LOCK değerlerini açıkça yazmak, istenen yöntem desteklenmediğinde komutun sessizce kilitleyen bir yönteme geçmek yerine hata vermesini sağlar. Yine de ALTER tamamlanırken (bazen başlangıçta da) kısa süreli bir özel (exclusive) metadata kilidi gerekir; tabloda açık bir işlem (transaction) varsa ALTER onu bekler ve yeni sorgular ALTER'ın arkasında birikir. Kısa bir lock_wait_timeout değeri (varsayılan MySQL'de bir yıl, MariaDB'de bir gün) komutun siteyi dondurmak yerine vazgeçmesini sağlar. Önce INNODB_TRX tablosuna bakın, sakin bir saat seçin, geçici disk alanını ve replika gecikmesini (replica lag) hesaba katın. Çok büyük ve yoğun tablolarda pt-online-schema-change ya da gh-ost (satır tabanlı ikili günlük ister; yabancı anahtar ya da tetikleyici içeren tabloları desteklemez) aynı işi tabloyu hızı sınırlanmış parçalar hâlinde kopyalayarak yapar.
EXPLAIN komutunu yeniden çalıştırın. key_len 91, üç sütunun da kullanıldığını doğrular: INT 4 bayt, utf8mb4 karakter setindeki VARCHAR(20) 20 × 4 + 2 = 82 bayt, DATETIME ise 5 bayttır (NULL olabilen her sütun 1 bayt ekler). key_len 4 olsaydı yalnızca customer_id kullanılıyor demekti. rows sütunu tüm aralık için yapılan tahmini gösterir; sonucu EXPLAIN ANALYZE ile doğrulayın (kısaltılmış; tam metin sürüme göre değişir):
Code:
-- önce
-> Limit: 20 row(s) (actual time=… rows=20 loops=1)
-> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk (… rows=20 loops=1)
-> Filter: ((orders.customer_id = 4821) and …) (… rows=153 loops=1)
-> Table scan on orders (… rows=1.98e+6 loops=1)
-- sonra
-> Limit: 20 row(s) (actual time=… rows=20 loops=1)
-> Index range scan on orders using idx_cust_status_created over (…) (reverse) (… rows=20 loops=1)
1.984.213 satır okuyan sorgu artık yaklaşık 20 indeks kaydı ve 20 tablo satırı okuyor; yavaş sorgu günlüğünde de artık görünmüyor. Üretim boyutunda veri içeren bir kopyada ölçün ve süreleri kendiniz karşılaştırın. total sütununu da eklemek indeksi kapsayan hâle getirir; bu yalnızca çok sık çalışan sorgularda karşılığını verir.
7. İndeksi etkisiz bırakan sorgu kalıpları
| Kalıp | Sorun | Çözüm |
|---|---|---|
| DATE(created_at) = '2026-10-01' | Sütuna uygulanan fonksiyon onu indeksten gizler | created_at >= '2026-10-01' AND created_at < '2026-10-02' |
| LOWER(email) = ? | Aynı sorun; _ci ile biten harmanlamalar (collation) zaten büyük-küçük harf ayırmaz | email = ? |
| LIKE '%terim%' | Baştaki joker karakter B-tree aralığını kullanamaz ('terim%' kullanabilir) | FULLTEXT indeks ya da arama motoru |
| VARCHAR sütunda phone = 5321234567 | Örtük tür dönüşümü (implicit conversion): metin sütunu her satırda sayıya çevrilir | Değeri tırnak içinde yazın; join sütunlarında tür ve karakter setini eşleyin |
| email = ? OR phone = ? | İki sütun da indeksli değilse ya da optimizer index merge seçmezse tam tarama | Her iki sütuna indeks; plan yine taramaysa UNION ile birleştirilmiş iki sorgu |
| SELECT * | Kapsayan indeks kullanılamaz; büyük TEXT sütunları sıralamayı ve geçici tabloları ağırlaştırır | Yalnızca kullandığınız sütunları seçin |
| LIMIT 20 OFFSET 100000 | 100.000 satırı okuyup atar | Keyset sayfalama (keyset pagination) |
| ORM kaynaklı N+1 sorgular | Her satır için ayrı sorgu; her biri hızlı olduğundan yavaş sorgu günlüğü onları kaçırır | Önden yükleme (eager loading) |
MariaDB 11.1 ve sonrası, sabit bir değerle karşılaştırılan DATE(col) ve YEAR(col) ifadelerini kendiliğinden aralığa çevirir; elle yazılan aralık ise her sürümde çalışır. İfadenin sorguda kalması gerekiyorsa MySQL 8.0.13 ve sonrasında ifadeyi doğrudan indeksleyebilirsiniz (fonksiyonel indeks); MariaDB'de ise indeksli bir hesaplanan sütun (generated column) ekleyip sorguda o sütunu kullanın. Keyset sayfalama, önceki sayfanın son satırından devam eder:
Code:
-- posts tablosunda (created_at, id) indeksi
SELECT id, title, created_at FROM posts
WHERE created_at < '2026-03-14 10:22:05'
OR (created_at = '2026-03-14 10:22:05' AND id < 918273)
ORDER BY created_at DESC, id DESC
LIMIT 20;
id sütunu ikinci sıralama ölçütüdür (tie-breaker); aynı zaman damgasına sahip satırların atlanmasını ya da iki kez gelmesini önler. EXPLAIN ile filesort olmadığını kontrol edin. N+1 sorgular, long_query_time = 0 ile alınmış bir kayıttan çıkan pt-query-digest raporunda çok yüksek Calls ve çok küçük R/Call değerleriyle görünür; çözüm uygulamadadır: Laravel'de with(), Django'da select_related() ya da prefetch_related(), Rails'te includes.
8. WordPress ve forum veritabanları
WordPress, otomatik yüklenen (autoload) tüm seçenekleri her istekte yükler. WordPress 6.6'dan bu yana autoload sütununda eski yes ve no değerlerinin yanında on, auto, auto-on, off ve auto-off da bulunabilir; bu yüzden yüklenen tüm değerleri sayın:
Code:
SELECT option_name, ROUND(LENGTH(option_value)/1024) AS kb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY LENGTH(option_value) DESC LIMIT 20;
Site Sağlığı (Site Health) ekranı 6.6 sürümünden itibaren, otomatik yüklenen seçeneklerin toplam boyutu 800 KB'ı aşınca kritik bir sorun bildirir. Kaldırılmış eklentilerden kalan büyük satırlar silinebilir; ama etkin bir eklentinin her sayfada okuduğu seçeneklerde autoload'u kapatmak yalnızca sorgu sayısını artırır. Revizyonlar, geçici kayıtlar (transient) ve sahipsiz meta veriler için SQL ile WordPress veritabanı temizleme rehberimize bakın. wp_postmeta tablosu post_id üzerinde ve meta_key sütununun ilk 191 karakteri üzerinde indekslidir; meta_value ise indekssiz bir LONGTEXT sütunudur. Bu yüzden değere göre süzen meta sorguları o anahtara sahip her satırı okur. Index WP MySQL For Speed eklentisi postmeta ve diğer çekirdek tablolara daha verimli anahtarlar (indeksler) ekler (önce yedek alın); WooCommerce'te yeni mağazalar için 8.2 sürümünden beri varsayılan olan High-Performance Order Storage (HPOS) ise siparişleri posts ve postmeta tablolarından çıkarıp kendi tablolarına taşır.
Forumlarda da kurallar aynıdır: büyük XenForo forumlarında mesaj ve arama tabloları çoğu zaman en büyük tablolar arasındadır; Elasticsearch tabanlı arama, tam metin arama yükünü veritabanının üzerinden alır. Hazır bir yazılımın (WordPress, XenForo, eklentiler) tablolarına eklediğiniz her indeksi not edin; bir sürüm yükseltmesi tabloyu yeniden düzenleyebilir. Platform ne olursa olsun, üretim boyutunda veriyle bir kopyada test edin; 5.000 satırda kusursuz görünen bir indeks 5 milyon satır hakkında hiçbir şey söylemez.
9. Kazancı doğrulayın ve kalıcı kılın
- Aynı koşulları karşılaştırın: öncesi ve sonrası için eşit zaman aralıklarını özetleyin (pt-query-digest --since ve --until) ya da özet tablosunu TRUNCATE TABLE performance_schema.events_statements_summary_by_digest; ile boşaltıp aynı süre sonunda yeniden okuyun.
- Silmeden önce gizleyin: Artık hiçbir sorgunun kullanmadığını düşündüğünüz bir indeksi (bu örnekte idx_status) MySQL'de ALTER TABLE orders ALTER INDEX idx_status INVISIBLE; komutuyla gizleyin (MariaDB'de INVISIBLE yerine IGNORED). Optimizer indeksi kullanmayı bırakır ama indeks güncellenmeye devam eder; bir şey yavaşlarsa VISIBLE (MariaDB'de NOT IGNORED) ile anında geri alırsınız. sys.schema_unused_indexes aday indeksleri listeler, ancak yalnızca son yeniden başlatmadan bu yana toplanan verilere dayanır.
- İstatistikleri tazeleyin: büyük silme ya da içe aktarma işlemlerinden sonra ANALYZE TABLE orders; komutu örnekleme yaparak indeks istatistiklerini hızla günceller. MySQL 8.0.24 ve MariaDB 10.5.4 öncesinde, aynı tabloda uzun bir sorgu sürerken çalıştırılan ANALYZE TABLE sonraki sorguları "Waiting for table flush" durumunda bekletebiliyordu; eski sürümlerde sakin bir an seçin.
- İzlemeyi sürdürün: yavaş sorgu günlüğünü açık bırakın, her hafta bir özet okuyun ve SHOW GLOBAL STATUS çıktısındaki Slow_queries sayacını grafiğe dökün.
Kontrol listesi
- Darboğazın veritabanı olduğunu doğrulayın: yavaş sayfayı profilleyin, süreç listesine, CPU'ya ve diske bakın.
- Yavaş sorgu günlüğünü 1 saniyelik eşikle açın, uzun ömürlü süreçlerin yeniden bağlanmasını sağlayın, ayarları kalıcı yapın.
- Sorguları pt-query-digest ya da mysqldumpslow -s t ile toplam süreye göre sıralayın.
- Listenin başındaki her sorgu için Rows_examined ile Rows_sent değerlerini karşılaştırın, EXPLAIN çalıştırın, ardından bir kopyada EXPLAIN ANALYZE ya da ANALYZE kullanın.
- Tek bir bileşik indeks tasarlayın: önce eşitlik sütunları, sonra aralık ya da ORDER BY sütunu.
- İndeksi sakin bir saatte, kısa bir lock_wait_timeout ile çevrimiçi ekleyin.
- key, key_len ve rows değerlerini yeniden kontrol edin, filesort'un kalktığını doğrulayın; öncesi ve sonrası özetlerini karşılaştırın.
- Gereksiz indeksleri önce gizleyin, sonra silin; günlüğü döndürün ve düzenli olarak inceleyin.
Sıkça sorulan sorular
Yavaş sorgu günlüğü sunucuyu yavaşlatır mı?Eşik bir saniye ya da üzerindeyken ek yazma yükü çoğu sunucuda önemsizdir. Yoğun bir sunucuda long_query_time = 0 çok fazla veri yazar; bu ayarı kısa ölçüm aralıklarıyla sınırlı tutun.
MySQL indeksimi neden kullanmıyor?
Genellikle koşul en soldaki sütunu atlıyordur, sütun bir fonksiyon ya da tür dönüşümüyle sarılmıştır, değer o kadar çok satırla eşleşir ki tarama daha ucuzdur ya da istatistikler eskimiştir. Bir kopyada FORCE INDEX ile karşılaştırın, sonra asıl nedeni düzeltin.
EXPLAIN ANALYZE üretim sunucusunda güvenli mi?
Sorguyu baştan sona çalıştırır; ağır bir sorgu aynı maliyeti bir kez daha getirir. Düz EXPLAIN ise sorguyu çalıştırmaz.
WHERE koşulundaki her sütuna indeks eklemeli miyim?
Hayır. Tek sütunlu birkaç indeks, doğru sırada kurulmuş tek bir bileşik indeksin yerini nadiren tutar ve her biri yazmaları yavaşlatır.
Daha büyük bir sunucu sorunu çözer mi?
Yalnızca asıl sorun çalışma kümesinin belleğe sığmamasıysa. 20 satır döndürmek için 2 milyon satır okuyan bir sorgu, tablo büyüdükçe yeniden yavaşlar.
Sonuç ve sonraki adımlar
MySQL ya da MariaDB kullanan yavaş bir site sorgu sorgu düzeltilir: sorguları günlüğe alın, toplam süreye göre sıralayın, planı okuyun, planın istediği indeksi ekleyin ve yeniden ölçün. Bugün yavaş sorgu günlüğünü 1 saniyelik eşikle açın, bir gün boyunca veri toplayın ve pt-query-digest çalıştırın; profildeki ilk üç sorgu iş listenizdir. Veritabanını çalışma kümesine göre boyutlandırılmış ayrı bir sunucuya taşımak isterseniz AIOR'un sanal sunucu paketleri seçeneklerden biridir; yukarıdaki yöntem her ortamda aynı şekilde işler.When a WordPress, XenForo or in-house application slows down, the database is the usual suspect, and the first reaction is often to paste my.cnf values from a forum post or to buy a bigger server. Neither lasts. Most of the time, a handful of statements account for the bulk of database time, and each one can be fixed by measuring it, reading its plan and giving it the right index. This guide follows that path for MySQL and MariaDB: find the expensive statements, read EXPLAIN, design a composite index, add it without locking the site and measure the gain. For the wider picture (cache hit ratios, lock contention, connection pools, hardware) see our database performance overview.
Version notes
Most commands below work on both MySQL and MariaDB, but some features depend on the version; check yours with SELECT VERSION();. MySQL 8.0 reached end of life in April 2026; Oracle recommends moving to 8.4 LTS or 9.7 LTS, and new installations belong there too.| Feature | MySQL | MariaDB |
|---|---|---|
| Slow log variables | slow_query_log, long_query_time, slow_query_log_file | Same; 10.11 adds the names log_slow_query, log_slow_query_time, log_slow_query_file (old names still work) |
| Extra detail in the slow log | log_slow_extra (8.0.14+) | log_slow_verbosity |
| Plan with real execution figures | EXPLAIN ANALYZE (8.0.18+) | ANALYZE SELECT … (10.1+) |
| performance_schema, sys schema | On by default | performance_schema off by default, enabled only at startup; sys from 10.6 |
| Switch an index off without dropping it | Invisible index (8.0+) | Ignored index (10.6+) |
| Descending index columns | 8.0+ | 10.8+ |
1. Symptoms and first checks
If one page is slow and the rest of the site is fine, profile that request first (Query Monitor on WordPress, Laravel Debugbar, Django Debug Toolbar): 400 small queries need a different fix from one 3-second query. If everything is slow, look at the server while it happens:
Code:
SHOW FULL PROCESSLIST;
-- oldest open transactions
SELECT trx_mysql_thread_id, trx_started, LEFT(trx_query, 80) AS query
FROM information_schema.INNODB_TRX
ORDER BY trx_started LIMIT 10;
- Many sessions in "Waiting for table metadata lock": usually an ALTER TABLE (or other DDL) is waiting for a long transaction, and every new query on that table queues behind it. Find the transaction in INNODB_TRX; this is locking, not indexing.
- A few long-running statements and mysqld near 100% CPU: queries reading millions of rows in memory. This is the case this guide fixes.
- Low CPU, busy disk: iostat -x 1 shows high %util and high r_await/w_await (await on older sysstat versions); the working set probably does not fit in memory.
Treat the buffer pool as a clue, not a cure: if Innodb_buffer_pool_reads (reads that went to disk, from SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'
2. Enable the slow query log
Check the current values with SHOW GLOBAL VARIABLES LIKE '%slow_query%'; and SHOW GLOBAL VARIABLES LIKE '%query_time%';, then enable the log at runtime (on shared hosting, ask the provider). Use a path the mysql user can write to; the data directory is the easy choice (check it with SELECT @@datadir; and adjust the path below):
Code:
SET GLOBAL slow_query_log_file = '/var/lib/mysql/mysql-slow.log';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log = 1;
A common trap: SET GLOBAL long_query_time affects only connections opened afterwards, not existing ones and not even your own session. Ordinary PHP requests reconnect every time, but persistent connections, connection pools and long-running workers keep the old 10-second default until they reconnect; restart them if the log stays empty.
SET GLOBAL is lost on restart, so add the settings to the [mysqld] section of the option file too (MySQL 8 and later also offer SET PERSIST). Use only the block that matches your server: MySQL does not know log_slow_verbosity, MariaDB does not know log_slow_extra, and an unknown option stops the server from starting. MySQL 8.x and later:
INI:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1
log_slow_extra = 1
MariaDB:
INI:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1
log_slow_verbosity = query_plan,explain
log_slow_extra adds per-statement counters, such as temporary tables created on disk, to each entry in the log file; log_slow_verbosity=query_plan,explain writes plan details and the EXPLAIN output into each entry. Where the option file lives:
- DirectAdmin: /etc/my.cnf. DirectAdmin's documentation notes that the configuration can also be spread across files in /etc/my.cnf.d/ (RHEL family) or /etc/mysql/conf.d/ (Debian), so check those for an existing [mysqld] section first. Restart with systemctl restart mysqld (mariadb on some installations). Do not edit /usr/local/directadmin/conf/my.cnf: DirectAdmin uses it for mysqldump calls and regenerates it regularly. Our MariaDB strict mode guide for DirectAdmin edits the same configuration step by step.
- cPanel: /etc/my.cnf, then /scripts/restartsrv_mysql.
- Debian and Ubuntu packages: usually /etc/mysql/mysql.conf.d/mysqld.cnf (MySQL) or /etc/mysql/mariadb.conf.d/50-server.cnf (MariaDB).
- Not sure: mysqld --verbose --help | grep -A1 "Default options" (mariadbd on MariaDB 10.5+) lists the files the server reads, in order.
The other settings:
- long_query_time: start at 1 second and lower it in steps once the worst queries are fixed. For a full picture of the workload, set it to 0 for a limited window of normal traffic (an hour, for example), watch the free disk space, then set it back.
- log_queries_not_using_indexes: logs every query without an index lookup, however fast, and floods the log with harmless small-table queries. Use it briefly.
- min_examined_row_limit: applies to every entry, so it can hide a query that waited 20 seconds for a row lock while examining three rows. Keep it at the default of 0.
A MySQL entry looks like this (MariaDB adds fields such as Thread_id, Schema and QC_hit):
Code:
# Time: 2026-10-27T07:42:15.081736Z
# User@Host: shop[shop] @ localhost [] Id: 41
# Query_time: 2.913473 Lock_time: 0.000006 Rows_sent: 20 Rows_examined: 1984213
SET timestamp=1793086932;
SELECT id, total, status, created_at FROM orders WHERE customer_id = 4821 AND status = 'shipped' AND created_at >= '2026-01-01' ORDER BY created_at DESC LIMIT 20;
Compare Rows_examined with Rows_sent first: reading 2 million rows to return 20 is an indexing problem. Time spent acquiring the initial locks is reported as Lock_time and does not count toward the threshold, so catch metadata-lock pile-ups live in the process list.
Rotate the file before it fills the disk: rename it, then run FLUSH SLOW LOGS, which closes and reopens only the slow log (FLUSH LOGS would also start a new binary log); it needs the RELOAD privilege. With logrotate, put mysql -e 'FLUSH SLOW LOGS' in postrotate; on DirectAdmin, use mysql --defaults-extra-file=/usr/local/directadmin/conf/my.cnf -e 'FLUSH SLOW LOGS' for the credentials (that option must come first). Alternatively, add the file to the logrotate rule your package already ships. With SELinux enforcing (the default on AlmaLinux), writes to a new log directory outside the data directory may be blocked; look for a permission error in the error log.
3. Summarise the log and rank by total time
The slowest single statement is often not the biggest problem. A nightly 40-second report costs 40 seconds a day; a 0.12-second query that runs 12,000 times an hour costs 24 minutes of database time every hour. Rank by total time (average × calls) and work from the top. mysqldumpslow (mariadb-dumpslow on MariaDB) sorts by average time unless told otherwise, so add -s t:
Bash:
mysqldumpslow -s t -t 10 /var/lib/mysql/mysql-slow.log
pt-query-digest from Percona Toolkit gives a more detailed report: it groups statements by fingerprint and sorts by total time by default. Install the percona-toolkit package from Percona's repository, or download just this one Perl script. To spare the production server, you can also run it on another machine against a copy of the log:
Bash:
# as a package, once the Percona repository is set up:
sudo percona-release enable pt release
sudo apt-get install percona-toolkit # RHEL family: sudo yum install percona-toolkit
# or only this script:
curl -LO https://www.percona.com/get/pt-query-digest
perl pt-query-digest --since 24h /var/lib/mysql/mysql-slow.log > digest.txt
After an overall summary, the report shows a profile (example from a one-hour capture with long_query_time = 0, abridged):
Code:
# Rank Query ID Response time Calls R/Call V/M Item
# ==== ============ ============== ====== ======= ===== ================
# 1 0x6A51… 1480.2 58.1% 12450 0.1189 0.02 SELECT orders
# 2 0x3C5F… 402.9 15.8% 38 10.6026 1.90 SELECT order_items
Response time is the total and its share; Calls and R/Call (mean per call) explain where it comes from. A high V/M (variance-to-mean ratio) means erratic timings, which often point to locks or cold caches. Each query then gets a section with a sample statement to paste into EXPLAIN.
Without access to the log, use performance_schema, which aggregates statements by digest (on by default in MySQL; in MariaDB set performance_schema=ON and restart). The sys schema gives the most useful view, sorted by total latency:
Code:
SELECT query, exec_count, total_latency, avg_latency, rows_examined_avg
FROM sys.statement_analysis LIMIT 10;
sys.statements_with_full_table_scans lists the statements that read whole tables. These counters run from the last server restart (or the last time the digest table was truncated).
4. Read EXPLAIN as a table
Put EXPLAIN in front of the statement (end it with \G for vertical output). You get one row per table:| Column | Meaning | Red flag |
|---|---|---|
| type | Access method, best to worst: const (one row by primary or unique key), eq_ref (one row per joined row through a unique key), ref (equality on a non-unique index), range, index (whole index read), ALL (full table scan) | ALL or index on a big table |
| possible_keys, key | Indexes considered, and the one chosen | key is NULL |
| key_len | Bytes of the index in use, which shows how many columns of a composite index are used | Shorter than expected |
| rows | Estimated rows to examine | Far above the rows returned |
| filtered | Estimated share of rows left after WHERE (MariaDB shows it with EXPLAIN EXTENDED or ANALYZE) | Low value with high rows |
| Extra | How rows are processed | Using filesort or Using temporary on many rows |
Common Extra values:
- Using where: rows are filtered after reading. Normal, unless paired with ALL on a big table.
- Using index: covering index; no table rows are read.
- Using index condition: part of the WHERE is checked on index entries before full rows are read (index condition pushdown).
- Using filesort: an extra sort because no index delivers ORDER BY order; not necessarily on disk, but costly with many rows.
- Using temporary: an internal temporary table, typically for GROUP BY or DISTINCT.
- Using join buffer (hash join or Block Nested Loop): no usable index on this table for the join condition; consider indexing the join column.
rows and filtered are the optimizer's estimates, based on statistics. EXPLAIN ANALYZE (MySQL 8.0.18+) and ANALYZE SELECT … (MariaDB, which adds r_rows and r_filtered) show what really happened. Both execute the statement: a 30-second query takes 30 seconds again, and MariaDB's ANALYZE UPDATE or ANALYZE DELETE really changes data, so test writes on a copy. To see the same tree with estimates only, without running the query, use EXPLAIN FORMAT=TREE (MySQL 8.0.16+). Read the EXPLAIN ANALYZE tree from the innermost line outwards and compare estimated rows with actual rows; a large gap often points to stale statistics or skewed data (section 9).
5. Index design rules
- Leftmost prefix: an index on (a, b, c) serves conditions on a; a and b; or a, b and c. It does not help a condition on b alone (MySQL 8's skip scan covers only narrow cases).
- Equality first, then one range: columns compared with = or IN go first, the range column (>, < or BETWEEN; LIKE 'abc%' is a range too) after them. The index seeks only up to the first range column; later columns can filter or cover, but do not narrow the scan.
- Let the index deliver ORDER BY: if the columns after the equality prefix match ORDER BY, rows come out of the index already sorted, filesort disappears and the query stops reading once it reaches the LIMIT. Mixed directions (a ASC, b DESC) need a descending index column.
- Covering indexes for hot queries: when every column the query needs is in the index, table rows are never read. InnoDB stores the primary key in every secondary index, so id is included for free.
- Selectivity: a low-cardinality column (status, a 0/1 flag) is rarely useful alone but works as an equality part of a composite index. The order of equality columns does not change how many entries are read, so order them for reuse: if other queries filter by customer_id alone, put it first.
- No duplicates, no index per column: (customer_id) is redundant once (customer_id, status, created_at) exists; sys.schema_redundant_indexes and pt-duplicate-key-checker find such cases. Every index slows INSERT, UPDATE and DELETE and uses disk and buffer pool memory.
6. Worked example: an orders query
An order history page runs this query on every visit. The table holds about 2 million rows and its only secondary index is on status:
Code:
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id INT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL,
total DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT id, total, status, created_at
FROM orders
WHERE customer_id = 4821
AND status = 'shipped'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Before the fix, EXPLAIN shows a full scan (or ref on idx_status, which still reads every shipped order). Example values; yours will differ:
| Plan | type | key | key_len | rows | Extra |
|---|---|---|---|---|---|
| Before | ALL | NULL | NULL | about 1.9 million | Using where; Using filesort |
| After | range | idx_cust_status_created | 91 | about 150 | no filesort (MySQL may show Backward index scan) |
The candidates:
| Index | What happens | Verdict |
|---|---|---|
| (customer_id) | Reads all of the customer's orders, filters, then sorts | Better, still a filesort |
| (customer_id, created_at) | Reads newest first, skips unshipped orders until it has 20 | Fine if most orders are shipped |
| (customer_id, status, created_at) | Seeks straight to shipped orders since January, already sorted, stops after 20 | Best fit |
| (created_at, customer_id, status) | Range on the first column: scans every order since January for all customers | Wrong order |
Add it online:
Code:
SET SESSION lock_wait_timeout = 10;
ALTER TABLE orders
ADD INDEX idx_cust_status_created (customer_id, status, created_at),
ALGORITHM=INPLACE, LOCK=NONE;
On InnoDB this keeps reads and writes running on both MySQL and MariaDB, and naming ALGORITHM and LOCK makes the statement fail, rather than fall back to a blocking method, when the requested mode is not supported. It still needs a brief exclusive metadata lock at the end (and sometimes at the start), so an open transaction on the table makes the ALTER wait while new queries queue behind it. The short lock_wait_timeout (the default is one year in MySQL, one day in MariaDB) makes it give up instead; check INNODB_TRX first, pick a quiet hour, and allow for temporary disk space and replica lag. For very large, busy tables, pt-online-schema-change or gh-ost (which needs row-based binary logs and does not support tables with foreign keys or triggers) does the same job by copying the table in throttled chunks.
Run EXPLAIN again. key_len 91 confirms all three columns are used: INT is 4 bytes, VARCHAR(20) in utf8mb4 is 20 × 4 + 2 = 82, and DATETIME is 5 (a nullable column adds 1). A key_len of 4 would mean only customer_id is used. rows shows the estimate for the whole range, so confirm with EXPLAIN ANALYZE (abridged; the exact text varies by version):
Code:
-- before
-> Limit: 20 row(s) (actual time=… rows=20 loops=1)
-> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk (… rows=20 loops=1)
-> Filter: ((orders.customer_id = 4821) and …) (… rows=153 loops=1)
-> Table scan on orders (… rows=1.98e+6 loops=1)
-- after
-> Limit: 20 row(s) (actual time=… rows=20 loops=1)
-> Index range scan on orders using idx_cust_status_created over (…) (reverse) (… rows=20 loops=1)
The query that read 1,984,213 rows now reads about 20 index entries and 20 table rows, and it no longer appears in the slow log. Measure on a copy with production-size data and compare timings yourself. Adding total would make the index covering, which pays off only for very frequent queries.
7. Query anti-patterns that defeat indexes
| Pattern | Problem | Fix |
|---|---|---|
| DATE(created_at) = '2026-10-01' | A function hides the column from the index | created_at >= '2026-10-01' AND created_at < '2026-10-02' |
| LOWER(email) = ? | Same; _ci collations already ignore case | email = ? |
| LIKE '%term%' | A leading wildcard cannot use a B-tree range ('term%' can) | FULLTEXT index or a search engine |
| phone = 5321234567 on a VARCHAR column | The string column is converted for every row | Quote the value; match types and character sets in joins |
| email = ? OR phone = ? | A full scan unless both columns are indexed and the optimizer picks index merge | Index both columns; if the plan still scans, use two queries combined with UNION |
| SELECT * | No covering index; large TEXT columns make sorts and temporary tables heavier | Select only what you use |
| LIMIT 20 OFFSET 100000 | Reads and discards 100,000 rows | Keyset pagination |
| N+1 queries from an ORM | One query per row; each is fast, so the slow log misses them | Eager loading |
MariaDB 11.1+ rewrites DATE(col) and YEAR(col) comparisons with a constant into ranges by itself; the hand-written range works everywhere. When the expression has to stay, MySQL 8.0.13+ can index it directly (a functional index); on MariaDB, add an indexed generated column and query that column. Keyset pagination continues from the last row of the previous page:
Code:
-- index on posts (created_at, id)
SELECT id, title, created_at FROM posts
WHERE created_at < '2026-03-14 10:22:05'
OR (created_at = '2026-03-14 10:22:05' AND id < 918273)
ORDER BY created_at DESC, id DESC
LIMIT 20;
The id tie-breaker stops rows with equal timestamps from being skipped or repeated; check with EXPLAIN that there is no filesort. N+1 queries show up in a digest of a long_query_time = 0 capture as huge Calls with tiny R/Call; fix them in the application with with() in Laravel, select_related() or prefetch_related() in Django, or includes in Rails.
8. WordPress and forum databases
WordPress loads every autoloaded option on every request. Since WordPress 6.6 the autoload column can also hold on, auto, auto-on, off and auto-off, so count all the values that load:
Code:
SELECT option_name, ROUND(LENGTH(option_value)/1024) AS kb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY LENGTH(option_value) DESC LIMIT 20;
Since 6.6, Site Health reports a critical issue when the autoloaded total exceeds 800 KB. Large rows left by removed plugins can go; switching autoload off for options an active plugin reads on every page only adds queries. Our WordPress database clean-up via SQL covers revisions, transients and orphaned metadata. wp_postmeta is indexed on post_id and on the first 191 characters of meta_key, while meta_value is an unindexed LONGTEXT, so meta queries on values read every row with that key. The Index WP MySQL For Speed plugin adds better keys to postmeta and other core tables (back up first), and WooCommerce's High-Performance Order Storage, the default for new stores since 8.2, moves orders out of posts and postmeta into their own tables.
Forums follow the same rules: on large XenForo boards the post and search tables are often among the biggest, and an Elasticsearch-based search takes full-text load off the database. Keep a record of every index you add to a vendor's tables (WordPress, XenForo, plugins), since an upgrade may alter them. On any platform, test on a copy with production-size data; an index that shines on 5,000 rows proves nothing about 5 million.
9. Verify the gain and keep it fixed
- Compare like with like: run pt-query-digest over equal time windows before and after (--since and --until), or empty the digest table with TRUNCATE TABLE performance_schema.events_statements_summary_by_digest; and read it again after the same period.
- Hide before you drop: for an index you believe nothing uses any more (idx_status in this example), ALTER TABLE orders ALTER INDEX idx_status INVISIBLE; on MySQL (IGNORED instead of INVISIBLE on MariaDB) stops the optimizer from using it while it is still maintained, and VISIBLE (NOT IGNORED on MariaDB) switches it back instantly if something slows down. sys.schema_unused_indexes suggests candidates, but only from data collected since the last restart.
- Refresh statistics: after big deletes or imports, ANALYZE TABLE orders; updates index statistics quickly by sampling. Before MySQL 8.0.24 and MariaDB 10.5.4, running it while a long query was using the table could leave later queries stuck in "Waiting for table flush", so use a quiet moment on old versions.
- Keep watching: leave the slow log on, read a digest weekly and graph Slow_queries from SHOW GLOBAL STATUS.
Checklist
- Confirm the database is the bottleneck: profile the slow page, check the process list, CPU and disk.
- Enable the slow log at 1 second, reconnect long-lived workers, persist the settings.
- Rank statements by total time with pt-query-digest or mysqldumpslow -s t.
- For each top query compare Rows_examined with Rows_sent, run EXPLAIN, then EXPLAIN ANALYZE or ANALYZE on a copy.
- Design one composite index: equality columns, then the range or ORDER BY column.
- Add it online with a short lock_wait_timeout in a quiet hour.
- Re-check key, key_len, rows and filesort; compare digests before and after.
- Hide, then drop redundant indexes; rotate and review the log regularly.
Frequently asked questions
Does the slow query log slow the server down?With a threshold of one second or more the extra writes are negligible on most servers. long_query_time = 0 on a busy server writes a lot, so keep it for short captures.
Why does MySQL ignore my index?
Usually the condition skips the leftmost column, a function or type conversion wraps the column, the value matches so many rows that a scan is cheaper, or statistics are stale. Compare with FORCE INDEX on a copy, then fix the cause.
Is EXPLAIN ANALYZE safe on production?
It runs the full statement, so a heavy query costs the same again. Plain EXPLAIN does not run the statement.
Should I index every column in my WHERE clauses?
No. Several single-column indexes rarely replace one composite index in the right order, and each one slows writes.
Will a bigger server fix it?
Only if the real problem is a working set that does not fit in memory. A query that reads 2 million rows to return 20 gets slow again as the table grows.