Django Performans Optimizasyon İpuçları 2: Select Related & Prefetch Related

Kader Miyanyedi
4 min readApr 28, 2022

--

Herkese selamlar! Django optimizasyon ipuçlarında ikinci yazıma hoş geldiniz. Serinin ilk yazısına buradan ulaşabilirsiniz.

Yazımıza başlamadan önce django ORM kullanırken sıklıkla karşılaştığımız/duyduğumuz N+1 problemini inceleyelim. Product isimli bir model hazırlayalım.

class Product(models.Model):
name = models.CharField(_("Name"), max_length=32)
seller = models.ForeignKey(User, verbose_name=_("Seller"), on_delete=models.CASCADE)

def product_seller_name():
products = Product.objects.all()
return [product.seller.username for product in products]

3 adet product nesnemizin olduğunu düşünürsek yukarıdaki yöntem için kaç veritabanı sorgusu çalışır ?

Cevap: 4 adet. 1 adet product nesnelerini çekmek için + 3(N) adet product satıcısının ismini almak için veritabanı sorgusu çalışır.

Seller alanı aslında veritabanı seviyesinde User tablosu ile ilişkili bir id bilgisidir. Bu sebeple product nesnelerini çektiğimizde elimizde seller alanı için bir nesne değil bir id bulunmaktadır. User tablosundan bir alanı kullanmak istediğimizde django yeni bir veritabanı isteği oluşturur. Bu durum N+1 problemine sebep olmaktadır.

Çözüm: select_related & prefetch_related

Öncelikle yazı boyunca kullanacağımız modellerimizi hazırlayalım. Product ve Country isimli iki model oluşturdum.

class Product(models.Model):
code = models.SlugField(_("Code"), unique=True)
name = models.CharField(_("Name"), max_length=32)
country = models.ManyToManyField(to="address.Country", verbose_name=_("Country"))
seller = models.ForeignKey(User, verbose_name=_("Seller"), on_delete=models.CASCADE)
is_active = models.BooleanField(_("Is active"), default=True)
***class Country(models.Model):
code = models.CharField(_("Code"), unique=True, max_length=2)
name = models.CharField(_("Name"), max_length=32)
is_active = models.BooleanField(_("Is active"), default=True)

Select Related

ForeignKey veya OneToOne ile bağlı alanlar için select related fonksiyonu kullanılır. Veritabanı seviyesinde tablolar arası join işlemi gerçekleştirir, bu sayede ek bir veritabanı sorgusuna ihtiyacımız olmaz.

Yukarıdaki örneğimizi select_related kullanarak tekrar inceleyelim.

def product_seller_name_with_select_related():
products = Product.objects.all().select_related("seller")
return [product.seller.username for product in products]

select_related ile birlikte filter, exists gibi diğer ORM fonksiyonlarını kullanabilirsiniz. Kullanım sırasının bir önemi yoktur. Aşağıdaki iki yöntem aynı sayıda veritabanı sorgusu çalıştırır.

Product.objects.filter(is_active=True).select_related("seller")Product.objects.select_related("seller").filter(is_active=True)

Zincirleme select_related sorguları yazabilirsiniz.

Product.objects.select_related("foo").select_related("bar")Product.objects.select_related("foo", "bar")

Yukarıdaki iki yöntem aynı sayıda veritabanı sorgusu çalıştırır.

Prefetch Related

ManyToMany ile bağlı alanlarda veya reverse relation işlemlerinde kullanılır.
Prefetch related; ilişkili olan alanların tablolarını da ayrı bir veritabanı sorgusunda çeker, daha sonra python seviyesinde bu tablolara bir join işlemi uygulanır.

Ülkelere ait ürünlerin getirildiği bir method yazalım.

def country_products():
countries = Country.objects.all()
for country in countries:
print(country.product_set.all())
Performans düşük
def country_products_with_prefetch_related():
countries= Country.objects.all().prefetch_related("product_set")
for country in countries:
print(country.product_set.all())
Performans daha yüksek

Prefetch related kullanımı sonrası N+1 probleminin çözüldüğünü gözlemleyebiliriz.

prefetch_related ile çekilen bir nesne üzerinde filter, exists gibi diğer ORM fonksiyonlarını kullanırsanız django ekstra bir veritabanı sorgusu çalıştırır.

country=Country.objects.get(pk=1).prefetch_related("product_set")country.product_set.filter(is_active=True) # ekstra sorgu

prefetch_related ve select_related birlikte kullanılabilirler.

Bir ülkenin ürünlerine eriştikten sonra ürünün satıcısına ulaşmak istersek ek bir veritabanı isteği oluşur.

country = Country.objects.prefetch_related("product_set").get(pk=1)
products = country.product_set.all()
for product in products:
print(product.seller)
Performans düşük

Yukarıdaki örnekte ülkeleri çekmek için 1 istek, ürünleri çekmek için 1 istek ve satıcıyı çekmek için 1 istek atılmıştır.
Prefetch related ve select related birlikte kullanmak için Prefetch sınıfını import ettim.

from django.db.models import Prefetchcountry = Country.objects.prefetch_related(
Prefetch("product_set",
queryset=Product.objects.select_related("seller"))
).get(pk=1)
products = country.product_set.all()
for product in products:
print(product.seller)

Prefetch related ve select related birlikte kullanıldığında ürünleri çekerken User tablosu ile join işlemi yapılacaktır ve satıcı için ek bir veritabanı isteği oluşturulmayacaktır.

Sonuç olarak; select_related & prefetch_related ile birlikte N+1 problemini çözebilir ve daha hızlı sorgulara sahip olabiliriz. Yazı boyunca örneklerimizi shell üzerinden inceledik. Eğer API üzerinden sorgularınızı incelemek isterseniz django-debug-toolbar paketini kullanabilirsiniz.

Yazı boyunca kullanılan kaynak kodlara buradan ulaşabilirsiniz. Keyifli ve öğretici bir yazı olmuştur umarım. Bir sonraki yazıda görüşmek üzere!

Kaynaklar:
https://docs.djangoproject.com/en/4.0/ref/models/querysets/#select-related

https://buildatscale.tech/fixing-n1-query-problem-in-django/

--

--