Sipariş listesi sayfanız 20 kayıt gösteriyor ama 4 saniyede açılıyor. Yavaş sorgu günlüğüne bakıyorsunuz, hiçbir sorgu 1 saniyeyi geçmiyor. Sunucunun CPU'su boş, disk rahat, indeksler yerinde. İşte bu tablo, neredeyse her zaman tek bir şeyi anlatır: N+1 sorgu problemi. Tek başına hiçbiri yavaş olmayan yüzlerce küçük sorgu, üst üste binerek sayfayı boğar ve klasik performans araçlarının hiçbiri bunu "yavaş sorgu" olarak işaretlemez.
Bu yazıda N+1 sorgu probleminin tam olarak nasıl oluştuğunu somut kodla göstereceğim; ardından uygulamanızda var olup olmadığını nasıl ölçeceğinizi, üç farklı çözüm yolunu (ön yükleme, tek JOIN, toplu sorgu + uygulama tarafında eşleme), sayaç alanlarının yarattığı gizli N+1 varyantını ve ön yüklemenin kendisinin nasıl yeni bir soruna dönüşebileceğini anlatacağım. Örnekler ORM bağımsız SQL üzerinden, kalıplar ise yaygın çerçevelerin gerçek metotlarıyla verilecek.
N+1 Tam Olarak Nedir#
Bir sipariş listesi düşünün: her siparişin yanında müşterinin adını da göstermek istiyorsunuz. Kod büyük olasılıkla şöyle çalışır: önce siparişler çekilir (bu 1 sorgudur), sonra döngü içinde her siparişin müşterisi tek tek istenir (bu N sorgudur). Toplam N+1. 20 sipariş için 21 sorgu, 200 sipariş için 201 sorgu.
-- 1. sorgu: liste
SELECT id, musteri_id, tutar, olusturma_tarihi
FROM siparisler
ORDER BY olusturma_tarihi DESC
LIMIT 20;
-- Ardından döngü içinde 20 kez, her seferinde farklı id ile:
SELECT id, ad, eposta FROM musteriler WHERE id = 4821;
SELECT id, ad, eposta FROM musteriler WHERE id = 1907;
-- ... 18 kez daha
Bu sorguların her biri birincil anahtar üzerinden çalışır ve ölçüldüğünde belki 0,4 ms sürer. Sorun süre değil, tekrar sayısı ve ağ gidiş-dönüşüdür. Uygulama sunucusu ile veritabanı arasında her sorgu için ayrı bir tur atılır; aynı makinede bile bu tur 0,2-0,5 ms, farklı makinelerde 1-3 ms tutar. Aşağıdaki tablo neden hızla kontrolden çıktığını gösteriyor:
| Liste boyutu | Sorgu sayısı | Tur başına 0,5 ms | Tur başına 2 ms |
|---|---|---|---|
| 20 satır | 21 | ~10 ms | ~42 ms |
| 100 satır | 101 | ~50 ms | ~202 ms |
| 500 satır | 501 | ~250 ms | ~1 sn |
| 500 satır + 3 ilişki | 1501 | ~750 ms | ~3 sn |
Son satır asıl felakettir: her siparişin müşterisi, ürünleri ve kargo kaydı da çekiliyorsa çarpan üçe katlanır. Üstelik veritabanı tarafında bu, "1500 hızlı sorgu" olarak görünür ve hiçbir alarm çalmaz.
Uygulamanızda Var Mı, Nasıl Anlarsınız#
N+1'in en sinsi yanı, gözle görülmemesidir; çünkü sorguları yazan siz değil ORM'dir. Bu yüzden ölçmek şart. En hızlı yol, geliştirme ortamında bir istek boyunca çalışan sorguları saydırmaktır. Çoğu çerçevede sorgu günlüğünü açmak birkaç satırdır; hiçbiri yoksa doğrudan veritabanına saydırabilirsiniz:
-- İsteğin başında sayaç
SHOW SESSION STATUS LIKE 'Questions';
-- ... sayfayı yükle ...
-- İsteğin sonunda tekrar bak, farkı al
SHOW SESSION STATUS LIKE 'Questions';
Daha sağlam yöntem, genel sorgu günlüğünü kısa süreliğine açıp aynı desenin kaç kez tekrarlandığına bakmaktır. Üretimde kesinlikle kapalı tutun, yalnızca test ortamında kullanın:
# Test ortamında genel sorgu günlüğünü aç
mysql -e "SET GLOBAL general_log_file='/var/log/mysql/genel.log'; SET GLOBAL general_log=ON;"
# Sayfayı bir kez yükledikten sonra kapat
mysql -e "SET GLOBAL general_log=OFF;"
# Aynı desenin kaç kez tekrarlandığını say
grep -oE "SELECT .* FROM musteriler WHERE id = [0-9]+" /var/log/mysql/genel.log | wc -l
Üretim ortamında ise performance_schema özet tablosu aynı bilgiyi günlük açmadan verir. Burada bakılacak sütun toplam süre değil, count_star'dır:
SELECT digest_text, count_star AS calisma_sayisi,
ROUND(avg_timer_wait/1000000, 3) AS ort_ms,
ROUND(sum_timer_wait/1000000000000, 2) AS toplam_sn
FROM performance_schema.events_statements_summary_by_digest
ORDER BY count_star DESC
LIMIT 15;
Listenin tepesinde WHERE id = ? biçiminde, ortalama süresi mikroskobik ama çalışma sayısı milyonlarca olan bir sorgu görüyorsanız N+1'i yakaladınız demektir. Yavaş sorgu günlüğü bu deseni asla göstermez; onun yöntemi ve sınırları için MySQL yavaş sorgu bulma yazısına bakın.
Çözüm 1: Ön Yükleme (Eager Loading)#
En yaygın ve çoğu durumda en doğru çözüm, ORM'e "bu ilişkiyi de baştan getir" demektir. ORM o zaman N ayrı sorgu yerine tek bir toplu sorgu çalıştırır ve nesneleri bellekte eşleştirir. Ürettiği SQL şuna benzer:
-- 1. sorgu: liste
SELECT id, musteri_id, tutar FROM siparisler ORDER BY id DESC LIMIT 20;
-- 2. sorgu: tüm ilişkili müşteriler tek seferde
SELECT id, ad, eposta FROM musteriler WHERE id IN (4821,1907,3355, /* ... */);
21 sorgu 2'ye indi. Yaygın çerçevelerdeki karşılıkları şunlar:
| Çerçeve | Ön yükleme çağrısı | Ürettiği desen |
|---|---|---|
| Laravel (Eloquent) | Siparis::with('musteri')->get() | Ayrı IN (...) sorgusu |
| Django | .prefetch_related('musteri') | Ayrı IN (...) sorgusu |
| Django (tek-e-bir) | .select_related('musteri') | Tek JOIN |
| Rails (ActiveRecord) | Siparis.includes(:musteri) | Duruma göre IN ya da JOIN |
| Doctrine | DQL içinde JOIN FETCH | Tek JOIN |
Buradaki ince ayrım önemlidir: bire-bir ya da çoktan-bire ilişkilerde JOIN tabanlı yükleme (select_related) genelde daha hızlıdır; bire-çok ilişkilerde ise IN (...) tabanlı ikinci sorgu (prefetch_related) tercih edilir, çünkü JOIN satırları çoğaltır. Bir siparişin 10 kalemi varsa JOIN o siparişin bilgisini 10 kez döndürür; buna kartezyen şişme denir ve ağ üzerinden gereksiz megabaytlar taşır.
Ön yüklemede en sık atlanan nokta iç içe ilişkilerdir. Sipariş → kalemler → ürün zincirinde yalnızca kalemleri ön yüklerseniz, ürün tarafında yeni bir N+1 doğar. Zinciri baştan belirtin: with('kalemler.urun'), prefetch_related('kalemler__urun') gibi.
Çözüm 2: Tek Sorguda JOIN ve Yalnızca Gereken Kolonlar#
Bazı durumlarda ORM'in nesne haritasına hiç ihtiyacınız yoktur; ekranda gösterilecek dört kolon vardır ve tek bir sorgu yeterlidir. Özellikle raporlama ve liste ekranlarında bu, en hızlı yoldur:
SELECT s.id, s.tutar, s.olusturma_tarihi,
m.ad AS musteri_adi, m.eposta AS musteri_eposta
FROM siparisler s
JOIN musteriler m ON m.id = s.musteri_id
WHERE s.durum = 'tamamlandi'
ORDER BY s.olusturma_tarihi DESC
LIMIT 20;
Bu yaklaşımın iki büyük kazancı var: tek gidiş-dönüş ve yalnızca ihtiyaç duyulan kolonların taşınması. SELECT * yerine kolon listesi yazmak, geniş tablolarda ağ trafiğini birkaç kat düşürür. Ancak JOIN'in de bir bedeli vardır: sorgu planı karmaşıklaşır ve yanlış indeksle iki büyük tablonun birleşimi ön yüklemeden daha yavaş olabilir. Bu yüzden planı mutlaka kontrol edin:
EXPLAIN SELECT s.id, m.ad
FROM siparisler s JOIN musteriler m ON m.id = s.musteri_id
WHERE s.durum = 'tamamlandi' LIMIT 20;
Çıktıda musteriler satırında type: eq_ref ve key: PRIMARY görmek istersiniz; type: ALL görüyorsanız birleştirme kolonunda indeks yok demektir. Yabancı anahtar kolonlarına indeks koymak bu tür sorunların yarısını daha doğarken bitirir:
-- Yabancı anahtar kolonu indekssizse JOIN her zaman yavaştır
ALTER TABLE siparisler ADD INDEX idx_musteri (musteri_id);
ALTER TABLE siparis_kalemleri ADD INDEX idx_siparis (siparis_id);
ORM ile ham SQL arasındaki tercihi hangi durumda nasıl yapacağınızı ORM mi raw SQL mi yazısında ayrıntılı tartıştım; liste ve rapor ekranları, ham SQL'in en meşru olduğu yerlerin başında gelir.
Çözüm 3: Toplu Sorgu ve Uygulama Tarafında Eşleme#
Üçüncü yol, ORM'in ön yükleme yeteneğine güvenmeden aynı işi elle yapmaktır. Karmaşık, koşullu ya da birden çok kaynaktan beslenen ilişkilerde bu yöntem hem daha okunur hem daha kontrollü olur. Adımlar şöyledir:
- Ana listeyi tek sorguyla çekin.
- İlişkili kimlikleri listeden toplayın ve tekilleştirin.
- Tek bir
IN (...)sorgusuyla ilgili kayıtları getirin. - Gelen kayıtları kimliğe göre bir haritaya (dictionary/map) çevirin.
- Döngüde haritadan okuyun; veritabanına bir daha dokunmayın.
-- 2 ve 3. adımın SQL karşılığı
SELECT id, ad, eposta
FROM musteriler
WHERE id IN (4821, 1907, 3355, 5120);
Bu desende dikkat edilecek tek şey IN listesinin boyutudur. Binlerce kimlik içeren bir liste, hem sorgu metnini şişirir hem de bazı sürücülerde paket boyutu sınırına takılır. Pratik kural: listeyi 500-1000'lik parçalara bölün ve döngüde parça parça sorgulayın. Ayrıca liste boşsa sorguyu hiç çalıştırmayın; WHERE id IN () bazı motorlarda sözdizimi hatası verir, bazılarında ise gereksiz bir tur maliyeti olur.
Sayaçlar, Toplamalar ve Gizli N+1#
En çok gözden kaçan varyant, ilişki verisinin kendisi değil sayısıdır. "Her müşterinin kaç siparişi var?" diye ekranda gösteriyorsanız ve bunu döngü içinde count() ile hesaplıyorsanız, klasik N+1'in tam olarak aynısını yaşıyorsunuz — üstelik bu kez sorgular birincil anahtar araması değil, gerçek toplama işlemleri olduğu için çok daha pahalı.
Doğru yol, toplamayı da tek sorguda yapmaktır:
-- Döngüde N kez COUNT yerine tek sorguda gruplama
SELECT m.id, m.ad, COUNT(s.id) AS siparis_sayisi, COALESCE(SUM(s.tutar), 0) AS toplam
FROM musteriler m
LEFT JOIN siparisler s ON s.musteri_id = m.id
WHERE m.id IN (4821, 1907, 3355)
GROUP BY m.id, m.ad;
Çok yüksek trafikli sayfalarda bu bile yetmeyebilir. O durumda iki seçenek var: sayacı tabloda tutmak (musteriler.siparis_sayisi gibi bir sütunu tetikleyici ya da uygulama katmanında güncellemek) veya sonucu önbelleğe almak. İkinci yol genelde daha esnektir ve uygulanışını veritabanı önünde cache katmanı yazısında adım adım anlattım. Önbellekleme N+1'i çözmez, gizler; önce sorgu sayısını düşürün, sonra kalanı önbelleğe alın.
Sık Yapılan Hatalar ve Tuzaklar#
Her ilişkiyi ön yüklemek. N+1 korkusuyla modelin tüm ilişkilerini varsayılan olarak yüklemek, bu kez gereksiz veri taşınmasına yol açar. Bir listede kullanılmayan üç ilişkiyi yüklemek, sayfayı N+1'den bile yavaşlatabilir. Ön yüklemeyi model üzerinde global değil, çağrı yerinde yapın.
JOIN ile satır çoğaltmayı fark etmemek. Bire-çok ilişkide JOIN kullanınca ana kaydın alanları her alt satırda tekrarlanır. 100 sipariş × 10 kalem = 1000 satır ve siparişin tüm kolonları 10'ar kez ağdan geçer. Bire-çokta IN (...) tabanlı ikinci sorgu neredeyse her zaman daha ucuzdur.
Sayfalama ile JOIN'i karıştırmak. Bire-çok JOIN üzerine LIMIT 20 koyduğunuzda 20 sipariş değil 20 satır alırsınız; sonuç eksik listedir. Sayfalamayı ana tabloya uygulayın, ilişkileri ikinci sorguda getirin.
Ölçmeden optimize etmek. "Burada N+1 olabilir" diyerek kodu karmaşıklaştırmadan önce sorgu sayısını ölçün. Çoğu zaman asıl maliyet başka yerdedir ve gereksiz erken optimizasyon kodu okunmaz hale getirir.
Serileştirme katmanını unutmak. API yanıtı üretirken JSON serileştirici, model üzerinde daha önce yüklenmemiş bir ilişkiye dokunduğu anda tembel yükleme tetiklenir. Yani denetleyicide her şey doğruyken N+1 serileştirmede doğabilir. Geliştirme ortamında tembel yüklemeyi hata verecek şekilde ayarlamak (çoğu ORM'de bir seçenek vardır) bunu daha kodu yazarken yakalatır.
Sıkça Sorulan Sorular#
N+1 sorgu problemi sadece ORM kullananların sorunu mu#
Hayır. ORM bu deseni kolaylaştırdığı için oralarda daha sık görülür, ama ham SQL yazarken de döngü içinde sorgu çalıştırırsanız aynı şey olur. Sorun aracın kendisi değil, "veriyi tek tek iste" davranışıdır. ORM'siz kodda da her foreach içindeki sorgu çağrısı bir N+1 adayıdır.
Kaç sorgu bir sayfa için normal kabul edilir#
Kesin bir sayı yok ama pratik bir eşik verebilirim: bir web isteği için tek haneli sorgu sayısı çok iyi, 10-20 arası kabul edilebilir, 50'nin üzeri neredeyse her zaman incelenmesi gereken bir işarettir. Sorgu sayısının sayfadaki kayıt sayısıyla birlikte artıyor olması ise sayıdan bağımsız olarak doğrudan N+1 belirtisidir.
Eager loading her zaman doğru çözüm mü#
Hayır. Ön yükleme, ilişkili veriyi gerçekten kullanacaksanız doğrudur. Kullanmayacağınız ilişkileri yüklemek gereksiz veri taşır ve belleği şişirir. Ayrıca çok büyük ilişki kümelerinde IN (...) listesi devasa büyüyebilir. Kural şu: gösterdiğiniz veriyi yükleyin, göstermediğinizi yüklemeyin.
N+1 problemini önbellek ile çözebilir miyim#
Önbellek semptomu hafifletir, sorunu çözmez. Önbellek ısınana kadar, önbellek boşaldığında ya da veri değiştiğinde aynı yük geri gelir; üstelik önbelleğe alınan her küçük sorgu ayrı bir anahtar demektir ve önbellek trafiği de bedavaya gelmez. Doğru sıra önce sorgu sayısını düşürmek, sonra kalan pahalı sonucu önbelleğe almaktır.
Sorgu sayısını üretimde nasıl izlerim#
En pratik yöntem, performance_schema.events_statements_summary_by_digest tablosunu count_star sütununa göre sıralamak ve zaman içindeki değişimi izlemektir. Uygulama tarafında ise istek başına sorgu sayısını bir metrik olarak toplayıp izleme sisteminize göndermek çok işe yarar; bu sayı bir dağıtımdan sonra iki katına çıktıysa, yeni gelen kod bir N+1 getirmiş demektir.
JOIN mi yoksa ayrı IN sorgusu mu daha hızlı#
İlişkinin türüne bağlıdır. Çoktan-bire ilişkide (her siparişin bir müşterisi) JOIN genelde daha hızlıdır, çünkü satır çoğalmaz. Bire-çok ilişkide (her siparişin çok kalemi) ayrı bir IN (...) sorgusu daha iyidir, çünkü JOIN ana kaydı tekrarlayarak taşınan veriyi büyütür. Şüphedeyseniz iki yolu da yazıp EXPLAIN ile gerçek veride ölçün.
Kapanış#
N+1, veritabanının değil uygulamanın ürettiği bir performans sorunudur ve bu yüzden veritabanı metriklerinde saklanır. Aklınızda kalması gereken dört alışkanlık şu: istek başına sorgu sayısını ölçün, kayıt sayısıyla birlikte artan sorgu sayısını alarm sayın, ilişkileri çağrı yerinde ön yükleyin ve bire-çok ilişkilerde JOIN yerine toplu IN sorgusunu tercih edin. Yabancı anahtar kolonlarına indeks koymayı da bunlara ekleyin; çözümlerin hepsi bunun üzerine kuruluyor.
Uygulamanız bu düzeltmelerden sonra hâlâ veritabanı tarafında sıkışıyorsa darboğaz gerçekten kaynaklarda olabilir: daha fazla bellek ve hızlı NVMe disk için VDS ve bulut sunucu paketlerimize, yoğun sepet ve katalog trafiği olan projeler için e-ticaret hosting çözümümüze bakabilirsiniz. Sorgu profilleme ve veritabanı ayarlarını bizim üstlenmemizi isterseniz sunucu yönetimi hizmetimiz bu işi kapsıyor.