N+1 sorgu problemi ve ORM'lerin sessiz maliyeti
N+1 yavaş sorgu üretmez, çok sayıda hızlı sorgu üretir; bu yüzden yavaş sorgu loglarına hiç düşmez. Kod incelemesi de yakalamaz. Üretime çıkmadan yakalamanın tek güvenilir yolu testte sorgu saymaktır.
N+1, yavaş sorgu problemi değil. Sorguların hepsi hızlı; sayıları fazla. Bu ayrım önemli, çünkü N+1 yavaş sorgu loglarına düşmez. Her biri 0.4 ms süren 300 sorgu, eşiği 100 ms olan bir log filtresinde görünmez. Endpoint 700 ms sürer, kimse nedenini bilmez, "veritabanı yavaşlamış" denir.
Bu yazının cevapladığı soru şu: N+1'i üretime çıkmadan önce nasıl yakalarsınız ve eager loading gerçekten çözer mi?
Problem, ORM'in tembelliğinin görünür olmaması
Klasik biçim:
for siparis in Siparis.objects.filter(durum="acik"):
print(siparis.musteri.ad)
İlk satır bir sorgu atar. Döngünün her turunda siparis.musteri erişimi bir sorgu daha atar. 200 sipariş, 201 sorgu. Kodda hiçbir yerde "sorgu at" yazmıyor — nokta operatörü var, o kadar. ORM'in sattığı kolaylık tam olarak budur ve maliyeti de buradadır: veritabanı erişimi, dil seviyesinde bir alan erişiminden ayırt edilemez hale gelir.
Aynı şey Prisma'da:
const siparisler = await prisma.siparis.findMany({ where: { durum: "acik" } })
for (const s of siparisler) {
const m = await prisma.musteri.findUnique({ where: { id: s.musteriId } })
}
Burada en azından await var, yani bir I/O olduğu görülüyor. Django ve ActiveRecord'da o ipucu bile yok.
Eager loading çözer, ama iki farklı şey yapar
select_related ve prefetch_related aynı problemi çözüyor gibi görünse de ürettikleri sorgu tamamen farklı:
# TEK sorgu: JOIN
Siparis.objects.select_related("musteri")
# İKİ sorgu: ikincisi WHERE id IN (...)
Siparis.objects.prefetch_related("kalemler")
select_related yalnız ileri yönlü tekil ilişkilerde (ForeignKey, OneToOne) çalışır, çünkü JOIN satır sayısını değiştirmez. Çoklu ilişkide JOIN kullanırsanız satırlar çarpılır — 200 siparişin her birinde 5 kalem varsa veritabanından 1000 satır çeker, sipariş alanlarını 5'er kez tekrarlarsınız. Bu yüzden prefetch_related ayrı bir sorgu atıp birleştirmeyi Python tarafında yapar. Yavaş görünen bu yaklaşım, geniş satırlarda genelde JOIN'den daha ucuzdur.
İki eager loading'i iç içe kullanırken de sırayla dikkat gerekiyor:
Siparis.objects
.select_related("musteri")
.prefetch_related("kalemler__urun")
kalemler__urun yazılmazsa, kalemler tek sorguda gelir ama her kalemin urun erişimi yeni bir N+1 açar. İç içe N+1, dış N+1 çözüldüğünde ortaya çıkar. İlk düzeltmeden sonra ölçmeyi bırakmanın en yaygın bedeli budur.
Eager loading'in çözmediği durum
Toplama yapıyorsanız eager loading yanlış araçtır:
for musteri in Musteri.objects.all():
toplam = musteri.siparisler.count() # her turda bir COUNT sorgusu
Burada prefetch_related("siparisler") sorgu sayısını 2'ye indirir ama tüm sipariş satırlarını belleğe çeker — 50 bin sipariş için felaket. Doğrusu toplamayı veritabanına bırakmak:
Musteri.objects.annotate(siparis_sayisi=Count("siparisler"))
Genel kural: satırlara ihtiyacınız varsa eager loading, sayıya ihtiyacınız varsa annotate/aggregate. İkisi birbirinin yerine kullanılınca sorgu sayısı düşer, bellek patlar.
GraphQL'de problem yapısal
REST'te N+1 bir dikkatsizlik; GraphQL'de mimarinin varsayılanı. Resolver'lar alan başına çalıştığı için siparisler { musteri { ad } } sorgusu, her sipariş için müşteri resolver'ını ayrı ayrı çağırır. Resolver'ın içinde eager loading yapacak bir yer yoktur, çünkü resolver kendisinden kaç kez çağrılacağını bilmez.
Çözüm eager loading değil, batching. DataLoader aynı tick içindeki tekil çağrıları biriktirip tek sorguya çevirir:
const musteriLoader = new DataLoader(async (idler) => {
const kayitlar = await prisma.musteri.findMany({ where: { id: { in: idler } } })
const harita = new Map(kayitlar.map((m) => [m.id, m]))
return idler.map((id) => harita.get(id) ?? null)
})
Dönen dizinin girdi dizisiyle aynı sırada ve aynı uzunlukta olması zorunlu; findMany sırayı korumaz ve bulunamayan kayıtları atlar. Yukarıdaki map bu iki tuzağı birlikte kapatıyor. Loader'ı istek başına yeniden oluşturmak da şart — modül seviyesinde tek bir loader, kullanıcılar arası veri sızdıran bir önbelleğe dönüşür.
Önbellek, N+1'in çözümü değil
Sık görülen bir refleks, ilişkili kayıtları Redis'e almak. Sorgu sayısı düşmez; yalnız veritabanına giden 300 çağrı Redis'e giden 300 çağrıya dönüşür. Her biri daha ucuzdur, dolayısıyla ölçüm iyileşir ve problem çözülmüş görünür. Sonra önbellek soğuk başladığında ya da geçersizleştiğinde aynı endpoint eski haline döner.
Önbellek, tek ve pahalı bir sorguyu ortadan kaldırmak için doğru araç. Çok sayıda ucuz sorguyu azaltmak için değil — orada yapılacak iş sorguyu birleştirmek.
Yakalamanın tek güvenilir yolu: testte sorgu saymak
Kod incelemesi N+1 yakalamaz. Döngü ile ORM erişimi çoğu zaman farklı dosyalarda olur — serializer'ın içindeki bir property, template'teki bir döngü, bir __str__ metodu. Gözle bulunmaz.
Bulunabilir tek yer test. Django'da hazır geliyor:
def test_siparis_listesi_sorgu_sayisi(self):
with self.assertNumQueries(3):
self.client.get("/api/siparisler/")
Bu assertion, N+1'i regresyon olarak yakalar. Birisi serializer'a yeni bir ilişkili alan eklediğinde test kırılır ve kırılma mesajı doğrudan sorgu sayısını gösterir. Endpoint başına tek bir böyle test, üretimdeki sürprizlerin çoğunu keser.
Node tarafında Prisma'nın event log'u aynı işi görüyor:
let sayac = 0
prisma.$on("query", () => sayac++)
test("sipariş listesi sabit sayıda sorgu atar", async () => {
sayac = 0
await siparisListesi()
expect(sayac).toBeLessThanOrEqual(3)
})
toBe yerine toBeLessThanOrEqual kullanmak tartışmalı — kesin sayı daha katıdır ama her ufak değişiklikte testi günceller. Pratikte üst sınır, ekibin testi devre dışı bırakmasını engelliyor.
Sayı sabit mi, veriye bağlı mı?
Asıl kriter mutlak sorgu sayısı değil. Sorgu sayısı veri hacminden bağımsız olmalı. 3 sipariş için 5 sorgu atan endpoint sorunlu değildir; 3 sipariş için 5, 300 sipariş için 305 sorgu atan endpoint sorunludur.
Testi bu şekilde kurmak daha iyi sinyal veriyor:
def test_sorgu_sayisi_veri_hacminden_bagimsiz(self):
self._siparis_uret(3)
with CaptureQueriesContext(connection) as az:
self.client.get("/api/siparisler/")
self._siparis_uret(30)
with CaptureQueriesContext(connection) as cok:
self.client.get("/api/siparisler/")
self.assertEqual(len(az), len(cok))
Bu test, sorgu sayısının kendisi hakkında hiçbir varsayım yapmadan N+1'in tanımını doğrudan kontrol ediyor.
Yapılacak şey
Bir sonraki oturumda en çok çağrılan üç endpoint'i seçin, her birine yukarıdaki "veri hacminden bağımsız" testini yazın ve kırılanları düzeltin. Sorgu logu okumak, APM aracına bakmak ya da yavaş sorgu eşiğini düşürmek bu problemi bulmuyor — çünkü N+1 hiçbir zaman yavaş bir sorgu üretmiyor.
- ORM
- veritabanı
- performans
- test