Herkese merhabalar,
Yakın zamanda hayli keyifli bir teknik sohbete katıldım. Sohbet esnasında konuştuğumuz konulardan birisi de n+1 problemiydi. Bu problem hakkında daha detaylı fikir sahibi olmak için araştırma yaptığımda hayli kısıtlı Türkçe kaynak olduğunu farkettim. Aslında birçok projede karşılaştığımız, bilerek ya da bilmeyerek üstesinden geldiğimiz bir ORM sorunu olan N+1 problemine gelin birlikte göz atalım…
N+1 Problemi Nedir?
Öncelikle bu derde sahip olmak için ORM kullanıyor olmak gerekli. Çünkü sorunun temel sebebi yapmak istediğimiz veritabanı isteklerinin birbirine bağlı olduğu bilgisinin ORM tarafından algılanamıyor oluşu. Hemen bir örnekle somutlaştıralım, Diyelim ki elimizde bir blog var ve bu blog içerisindeki yazıların da altında yorumları mevcut;
# models.py
from django.db import models
class Articles(models.Model):
title = models.CharField(max_length=255)
content = models.TextField()
def __str__(self):
return self.title
class Comments(models.Model):
article = models.ForeignKey(Articles, on_delete=models.CASCADE)
comment = models.TextField()
Eğer bir view içerisinde for döngüsü ile blog yazılarını ve yorumlarını çekmek istersem düz mantıkla şöyle bir kod ortaya çıkarırım;
# blog/views.py
def index(request):
titles = []
for comment in Comments.objects.all():
titles.append(comment.article.title)
return JsonResponse({"titles": list(set(titles))})
Aslında kodda yanlış olan birşey yok. Test ettiğinizde sonucu dönmekte. Ama eğer bu isteği debug edersek, birşeylerin çokta yolunda gitmediğini görebiliriz. Django-debug-tool bu noktada tam da istediğimiz işi yerine getiriyor. Çağırdığınız sayfanın tüm isteklerini, DB sorgularını, kullandığı CPU miktarını ve daha çok daha fazlasını tab ler halinde sunuyor(Nasıl yükleneceği ve nasıl kullanılacağı hakkında detaylı bilgi için sitesi ve github reposuna göz atabilirsiniz).
Anasayfamız olan index’in toolbar’daki SQL çıktısına baktığımızda karşımıza çokca sorgu yaptığı ve bu nedenle de 0.96 ms de yüklendiğini görüyoruz:

Her Derdin Bir Dermanı Vardır!
Elbette bunu ilk kez ben keşfetmedim. Bunu performans problemi yaşadığı için kıvrım kıvrım kıvranırken debug yapan ve gönderilen bu sürekli isteklerin birşeylerin yanlış gittiğine delalet ettiğine karar veren birisi keşfetti. Ve tabii ki bunu python’a ekledi 🙂
Çare “select_related“/”prefetch_related“. 2017 yılında gelen Django 2.0 güncellemesi ile birlikte, “select_related” ve/veya “prefetch_related” komutları bu problemi tek aşamada çözmek mümkün(ilgili güncellemenin release notu için tık). Ancak çözümüne geçmeden önce kısa bir beyin fırtınasıyla bu fonksiyonun aslında nasıl çalıştığına göz atarsak, problemin de çözümünü ezberlemek yerine anlamış oluruz.
# Derman #
Eğer bu sorguyu biz ORM üzerinden değil de Raw-SQL üzerinden gönderecek olsaydık ne yapardık? “Her bir yorumun bağlı olduğu makalenin başlıklarını getirmek istiyorum, o halde JOIN ile tek seferde bu cevaba ulaşabilirdim” derdik. İşte select_related fonksiyonu burada tam olarak koyu şekilde yazdığım kısmı koda, kod henüz bir sonraki satıra gelip ilk satırdaki SQL Query gönderdikten sonra tekrar bir SQL Query yapmadan önce “bak senden bu değerleri bekliyorum ama beraberinde şu değerlere de ihtiyacım olacak.” demiş oluyor. Şimdi efektif halini yazarsak;
# blog/views.py
def index_for_efficient_way(request):
titles = []
for comment in Comments.objects.select_related('article').all():
titles.append(comment.article.title)
return JsonResponse({"titles": list(set(titles))})
Farkedebileceğiniz üzere, sorgumu daha gönderirken, “select_related” fonksiyonuyla ORM’e “benim bu sorguya bağlı alt değerlerden ‘şunlara’ da ihtiyacım olacak” demiş oluyorum. Peki bu bize ne kadar iyileştirme sağlar? Hemen toolbar’a bakıyoruz;

Yani toplamda 5 satırdan oluşan bir veritabanı için bile fark 2 katından daha fazla.
Karmaşık Dertlerin Birden Fazla Dermanı Vardır!
Bir başka çözüm olan “prefetch_related” ise, kodun akışına göre ters çağırma yaptığınızda yani “reverse_relation” a ihtiyaç duyduğunuzda kullanmanız üzere tasarlanmış durumda. Bu fonksiyonu farklı kılan, makalelerden yola çıkarak yorumları almak istediğimizde, bu seferde bir makale birden çok yorum aldığından bu birden-çok’a(one-to-many) ilişkisi, django’nun optimizasyonu nedeniyle, select_related fonksiyonunda olduğu gibi her defasında SQL isteği atarak performans kaybına sebebiyet veriyor:
# blog/views.py
def comments(request):
tmp_comments = []
for article in Articles.objects.all():
for row_comment in article.comments_set.all():
tmp_comments.append(row_comment.comment)
return JsonResponse({"Comments": list(set(tmp_comments))})

Toolbar select_related fonksiyonunda olduğu gibi bizi uyarıyor. Bu kodu prefetch_related ile yazmak istersek;
# blog/views.py
def efficient_comments(request):
tmp_comments = []
for article in Articles.objects.prefetch_related('comments_set').all():
for row_comment in article.comments_set.all():
tmp_comments.append(row_comment.comment)
return JsonResponse({"Comments": list(set(tmp_comments))})

Aradaki fark 0.93 ms – 0.58 ms = 0.35 ms !
Sonuç
ORM’ler bizleri Raw-SQL yazma zorunluluğundan kurtaran muhteşem icatlar olsalar bile, kullanırken performans sorunları yaşamamak için debug edip, olası performans zaafiyetlerinin önüne geçmemiz gerekmekte. N+1 sorunu özelindeyse, yaptığımız ORM sorgusu içerisinde, sonrasında kullanacağımız değerleri select_related veya prefect_related fonksiyonlarını kullanarak bu zaafiyetin önüne geçip, efektif bir şekilde işlemlerimizi yapabiliyoruz.
Makale esnasında kullandığım kodlara oluşturduğum Github reposundan ulaşabilirsiniz. Soru ve sorunlarınız için başlık altında sormayı tercih ederseniz, bir sonraki okuyucu için yeni bir bilgi bırakılmasına da imkan sağlamış olursunuz.
Sonraki yazım da görüşmek üzere 🙂
Kaynakça:
https://thenewstack.io/finding-and-fixing-django-n1-problems/
https://docs.djangoproject.com/en/4.0/ref/models/querysets/#select-related
https://medium.com/@haydarhaman/django-ve-django-rest-framework-ile-hızlı-sorgular-yazmak-72ee52b1067a
https://github.com/thetarby/django-auto-related
https://scoutapm.com/blog/django-and-the-n1-queries-problem
https://docs.djangoproject.com/en/4.0/topics/db/examples/many_to_one/
