Bir web projesi ilk yayına alındığında her şey kusursuz görünür. Tablolar boştur ve veritabanı sorguları birkaç milisaniyede yanıt verir. Ancak projeniz başarılı olup trafik artmaya başladığında ve veriler milyonlarca satıra ulaştığında, sitenizin yavaşladığını ve sunucu CPU kullanımının tavan yaptığını fark edersiniz.

Bu durumda çoğu geliştirici sunucunun RAM'ini veya işlemcisini yükselterek "Dikey Ölçeklendirme" (Vertical Scaling) yoluna gider. Ancak donanıma para harcamak sorunu sadece gizler. Gerçek çözüm, veritabanındaki darboğazları bulup sorgu ve mimari seviyesinde düzeltmektir.

Bu rehberde yüksek trafikli sistemlerde en büyük farkı yaratan teknikleri ele alıyoruz: EXPLAIN ile yavaş sorguları bulmak, N+1 sorgularını ortadan kaldırmak, doğru indekslemek, büyük tabloları verimli şekilde sayfalamak ve sık okunan verileri eskimiş içerik sunmadan Redis'te önbelleğe almak.

1. Önce Ölçün: EXPLAIN ile Yavaş Sorguları Bulun

Ölçüm yapmadan optimizasyon tahminden ibarettir. Herhangi bir şeyi değiştirmeden önce hangi sorguların gerçekten yavaş olduğunu bulun.

  • Yavaş sorgu logunu (slow query log) açın: MySQL/MariaDB'de (örneğin long_query_time = 1) canlı ortamda bir saniyeden uzun süren sorguları yakalayın.
  • Framework'ünüzün debug toolbar'ını kullanın: geliştirme sırasında bir sayfanın kaç sorgu çalıştırdığını ve her birinin ne kadar sürdüğünü görün.
  • Şüpheli bir sorguda EXPLAIN çalıştırın: veritabanının sorguyu nasıl çalıştırmayı planladığını görün.
EXPLAIN
SELECT id, title
FROM posts
WHERE category_id = 12
ORDER BY created_at DESC
LIMIT 20;

Çıktıda şu kolonlara dikkat edin: type = ALL tam tablo taraması anlamına gelir, key = NULL hiçbir indeksin kullanılmadığını gösterir, yüksek bir rows değeri çok sayıda satırın incelendiğini, Using filesort ise sonucun ayrı bir adımda sıralandığını gösterir. Önce optimize edilmesi gereken sorgular bunlardır.

2. Sessiz Katil: N+1 Sorgu Problemi

N+1 problemi, özellikle ORM (Object-Relational Mapping) ve framework kullanırken sunucuyu en yaygın ve en hızlı tüketen tuzaktır. Her sorgu tek başına hızlıdır; sorun, kaç tanesinin çalıştığıdır.

Ekranda 50 blog yazısını listelemek ve her yazının kategorisini göstermek istediğinizi varsayalım. İlişkiyi bir döngü içinde tembel yükleme (lazy loading) ile çağırırsanız sistem şöyle çalışır:

  1. Yazıları getirmek için 1 ana sorgu çalışır: SELECT * FROM posts LIMIT 50; (1 sorgu)
  2. Döngü içinde, dönen 50 yazının her biri için kategoriler tablosuna ayrı bir sorgu gönderilir: SELECT * FROM categories WHERE id = ?; (50 sorgu)

Toplamda 51 sorgu çalışır. Bir sayfada 1.000 kayıt listelerseniz, tek bir sayfa yüklemesinde 1.001 sorgu gönderirsiniz. Çözüm, eager loading kullanmak ya da Query Builder ile JOIN yapmaktır.

// ❌ KÖTÜ KULLANIM (N+1 problemi oluşturur)
$posts = $postModel->findAll();
foreach ($posts as $post) {
    // Döngünün her adımında yeni bir sorgu çalışır!
    echo $categoryModel->find($post->category_id)->name;
}

// ✅ İYİ KULLANIM (CodeIgniter 4 Query Builder ile JOIN)
$posts = $postModel->select('posts.*, categories.name AS category_name')
                   ->join('categories', 'categories.id = posts.category_id')
                   ->findAll();

foreach ($posts as $post) {
    // Veritabanına ekstra gidiş yok; veri zaten yüklendi.
    echo $post->category_name;
}

JOIN pratik değilse alternatif, önce ID'leri toplayıp ilişkili kayıtları tek bir WHERE id IN (...) sorgusuyla yüklemektir. Her iki durumda da kaç satır gösterirseniz gösterin sorgu sayısı sabit kalır.

3. SQL İndekslemenin Gücü ve Maliyeti

Veritabanınızda 2 milyon kullanıcı olduğunu düşünün. Bir kullanıcı giriş yapmaya çalıştığında WHERE email = 'test@codeblanks.com' sorgusunu çalıştırırsanız, indeks yoksa veritabanı motoru (MySQL/PostgreSQL) eşleşme bulana kadar 2 milyon satırın hepsini taramak zorunda kalır. Buna tam tablo taraması (full table scan) denir ve disk I/O ile CPU üzerinde ağır bir yük oluşturur.

email kolonuna bir indeks eklerseniz, veritabanı o kolon için alfabetik bir rehbere benzeyen sıralı bir B-Tree (ikili ağaç değil, dengeli bir ağaç yapısı) tutar. Milyonlarca satırı okumak yerine ağacın birkaç seviyesinden inerek kaydı neredeyse anında bulur. Arama süreleri saniyelerden milisaniyelere düşer.

-- Sık aranan bir kolona indeks ekleyin
CREATE INDEX idx_users_email ON users (email);

Kompozit İndeksler ve Kolon Sırası

Gerçek sorguların çoğu bir kolona göre filtreler, başka bir kolona göre sıralar. Bir kompozit indeks ikisini birden karşılayabilir, ancak kolon sırası önemlidir: indeks yalnızca en soldaki kolonundan itibaren kullanılabilir ("en soldaki önek" kuralı).

-- Eşleşir: WHERE category_id = ? ORDER BY created_at DESC
CREATE INDEX idx_posts_category_created ON posts (category_id, created_at);

Bu indeks, category_id'ye göre filtreleyen sorgulara ve category_id'ye göre filtreleyip created_at'e göre sıralayan sorgulara yardımcı olur. Sadece created_at'e göre filtreleyen bir sorguya yardımcı olmaz.

İndeks Kullanamayan Sorgular

  • Kolonu bir fonksiyonla sarmak: WHERE YEAR(created_at) = 2026. Bunun yerine aralık kullanın: WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'.
  • Baştaki joker karakterler: LIKE '%kelime' normal bir B-Tree indeksi kullanamaz. Arama için full-text indeks düşünün.
  • Metin tipindeki bir kolonu sayıyla karşılaştırmak; bu, her satırda tip dönüşümüne yol açar.

⚠️ İndekslerin maliyeti: her kolonu indekslemeyin. İndeksler okumayı (SELECT) hızlandırır ama yazmayı (INSERT/UPDATE/DELETE) yavaşlatır; çünkü veri her değiştiğinde her indeksin de güncellenmesi gerekir ve ekstra disk alanı ile bellek kullanırlar. WHERE, ORDER BY ve JOIN ifadelerinde sık kullandığınız kolonları indeksleyin ve sonucu EXPLAIN ile doğrulayın.

4. Büyük Tabloları OFFSET Kullanmadan Sayfalayın

LIMIT 20 OFFSET 100000 ile yapılan klasik sayfalama zararsız görünür, ancak veritabanı yine de ilk 100.000 satırı okuyup atmak zorundadır. Tablo büyüdükçe ilerideki sayfalar giderek yavaşlar.

Keyset sayfalama (cursor sayfalama olarak da bilinir), bir önceki sayfanın nerede bittiğini hatırlayarak bu sorunu çözer:

-- ❌ İleriki sayfalarda yavaş
SELECT id, title FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000;

-- ✅ Keyset sayfalama: gösterdiğiniz son id'den devam edin
SELECT id, title FROM posts
WHERE id < :last_seen_id
ORDER BY id DESC
LIMIT 20;

Sıralama kolonunda indeks olduğunda her sayfa ilk sayfa kadar hızlıdır. Bedeli ise kullanıcıların doğrudan 5.000. sayfaya atlayamamasıdır; bu da akışlar, sonsuz kaydırma ve API'ler için nadiren gerekir.

5. Diskten Belleğe Geçiş: Redis Caching Stratejileri

Sorgularınızı ne kadar optimize ederseniz edin, sık okunan ama nadiren değişen veriler için aynı sorguyu tekrar tekrar çalıştırmak boşa harcanan iştir. Tipik örnekler ana sayfadaki son yazılar, site ayarları, çeviri metinleri ve kategori listeleridir. Bellekten okumak, SSD bile olsa diskten okumaktan çok daha hızlıdır. İşte burada bellek içi (in-memory) bir veri deposu olan Redis devreye girer.

En yaygın desen cache-aside'dır: önce önbelleğe bakın, veritabanına sadece önbellekte yoksa gidin. CodeIgniter 4, Redis'i önbellek sürücüsü olarak hazır destekler:

$cache    = \Config\Services::cache();
$cacheKey = 'homepage_latest_posts';

// ADIM 1: Veri önbellekte var mı kontrol et
if (! $posts = $cache->get($cacheKey)) {

    // ADIM 2: Önbellekte yok, veritabanından oku
    $posts = $postModel->where('status', 'published')
                       ->orderBy('created_at', 'DESC')
                       ->findAll(10);

    // ADIM 3: Sonucu Redis'te 1 saat (3600 saniye) sakla
    $cache->save($cacheKey, $posts, 3600);
}

return view('home', ['posts' => $posts]);

Önbelleği Geçersiz Kılma (Cache Invalidation)

Asıl mesele veriyi önbelleğe yazmak değil, onu doğru tutmaktır. Yönetim panelinden yeni bir yazı yayınlarsanız ne olur? Ziyaretçiler onu bir saate kadar göremez. Çözüm, ilgili veri her değiştiğinde ilgili önbellek anahtarını silmektir:

// AdminController.php: yeni bir yazı eklendiğinde
$postModel->insert($newPostData);

// Veri değiştiği için eski önbellek kaydını sil.
// Sonraki ziyaretçi yeni bir sorgu tetikler ve önbellek yeniden oluşur.
\Config\Services::cache()->delete('homepage_latest_posts');

TTL Seçimi ve Cache Stampede'den Kaçınmak

  • TTL'yi verinin ne kadar güncel olması gerektiğine göre seçin. Ayarlar saatlerce önbellekte kalabilir; bir stok sayısı saniyeler sürebilir ya da hiç önbelleğe alınmamalıdır.
  • Açık ve sürümlü anahtarlar kullanın: posts:latest:tr:v2 gibi; böylece sürümü değiştirerek bütün bir grubu geçersiz kılabilirsiniz.
  • Cache stampede'e dikkat edin. Popüler bir anahtarın süresi dolduğunda yüzlerce istek aynı anda veritabanına yüklenebilir. TTL'ye küçük rastgele bir değer ekleyin ya da değeri sadece bir isteğin yeniden oluşturmasına izin verirken diğerlerinin kısa süreliğine eski değeri sunmasını sağlayın.
  • Her şeyi önbelleğe almayın. Kullanıcıya özel ya da nadiren okunan verileri önbelleğe almak çoğu zaman sorgulamaktan daha pahalıdır.

6. Tek Veritabanı Sunucusu Yetmediğinde

Sorgular, indeksler ve önbellek iyi durumdaysa sonraki adımlar mimaridir:

  • Okuma replikaları (read replicas): yoğun okuma trafiğini bir veya daha fazla replikaya gönderin, yazmaları ana sunucuda tutun.
  • Arka plan işleri: e-posta gönderme, rapor oluşturma veya görsel boyutlandırma gibi yavaş işleri istekten çıkarıp bir kuyruğa taşıyın.
  • Önceden hesaplanmış veri: sayaçları ve toplamları (örneğin yorum sayıları) her sayfa görüntülemesinde COUNT(*) ile hesaplamak yerine saklayın.

Hızlı Kontrol Listesi

  • Yavaş sorgu logu açık mı ve düzenli olarak inceliyor musunuz?
  • Herhangi bir sayfa döngü içinde sorgu çalıştırıyor mu (N+1)?
  • WHERE, JOIN ve ORDER BY içinde kullanılan kolonların EXPLAIN ile doğrulanmış uygun indeksleri var mı?
  • Sorgular SELECT * yerine sadece ihtiyaç duyulan kolonları mı seçiyor?
  • Büyük listeler derin OFFSET yerine keyset sayfalama mı kullanıyor?
  • Sık okunan veriler önbelleğe alınıyor ve veri değiştiğinde önbellek temizleniyor mu?

Sonuç

Yüksek trafikli sistemler kaba kuvvetle (daha fazla RAM, daha fazla CPU) değil, akıllı ve sürdürülebilir bir mimariyle yönetilir. Önce ölçün, N+1 sorgularını ortadan kaldırın, doğru kolonları indeksleyin, verimli sayfalayın ve sık okunan verileri net bir geçersiz kılma stratejisiyle Redis'te önbelleğe alın. Bu adımlar, tek bir sunucunun beklediğinizden çok daha fazla trafiği kaldırmasını sağlar. Okunabilir sorgular yazmak için temiz kodun altın kuralları rehberimize de göz atabilirsiniz.