Musa Yılmaz
·6 dk okuma

Binlerce Kayıtta Filtreleme: PostgreSQL ve Django ORM

postgresqldjangoperformanssql

Bir ilan sitesinde kullanıcılar şehre, kategoriye, fiyat aralığına ve metne göre filtreleme yapar. Birkaç yüz kayıtla her şey hızlı görünür. Kayıt sayısı binleri geçtiğinde sayfa yavaşlamaya başlar ve sorun genelde tek bir yerde değildir.

Bu yazıda, listeleme sayfalarının yavaşlamasının en yaygın dört nedenini ve her birinin çözümünü anlatıyorum.

1. N+1 sorgusu neden oluşur, nasıl önlenir?

En sık ve en pahalı hata budur. Şablonda ilişkili bir alana eriştiğinizde Django her kayıt için ayrı sorgu atar:

listings = Listing.objects.filter(city=city)  # 1 sorgu
 
for listing in listings:
    print(listing.category.name)  # her kayıt için 1 sorgu daha

50 kayıt varsa 51 sorgu çalışır. Çözüm ilişkileri önceden çekmektir:

listings = (
    Listing.objects
    .filter(city=city)
    .select_related("category", "city")      # ForeignKey / OneToOne → JOIN
    .prefetch_related("images", "features")  # ManyToMany / ters FK → ayrı sorgu
)

İkisi arasındaki fark önemlidir:

  • select_related SQL JOIN kullanır. Tek yönlü ilişkilerde (ForeignKey) uygundur.
  • prefetch_related ikinci bir sorgu atıp Python tarafında eşleştirir. Çoklu ilişkilerde JOIN satır sayısını katladığı için bu daha verimlidir.

Geliştirme sırasında sorguları görmek için (liste yalnız DEBUG = True iken dolar):

from django.db import connection
print(len(connection.queries))

Daha kalıcı bir çözüm django-debug-toolbar kurmaktır — her sayfada çalışan sorguları ve sürelerini gösterir.

Aynı sorun Django admin'in liste sayfasında da yaşanır: list_display'de bir ilişkinin içine uzanan girdi (örneğin obj.city.region okuyan özel bir metod) her satır için bir sorgu daha atar. Düz ForeignKey sütunlarını Django sizin yerinize halleder (list_select_related varsayılandayken list_display'de ForeignKey varsa select_related uygulanır); daha derinine inen her şey için açıkça list_select_related tanımlamak ya da get_queryset'i ezmek gerekir. Django admin'i CMS'e çevirmek o liste görünümünü kullanılabilir yapan diğer ayarları anlatıyor.

2. Hangi alanlara indeks eklenmeli?

İndeks olmadan PostgreSQL tabloyu baştan sona tarar. Sık filtrelenen alanlara indeks ekleyin:

class Listing(models.Model):
    city = models.ForeignKey(City, on_delete=models.PROTECT)
    category = models.ForeignKey(Category, on_delete=models.PROTECT)
    price = models.DecimalField(max_digits=12, decimal_places=2)
    is_published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
 
    class Meta:
        indexes = [
            models.Index(fields=["is_published", "city", "-created_at"]),
            models.Index(fields=["price"]),
        ]

Bileşik indekste alan sırası kritiktir. B-tree indeks, sorgu en soldaki alanları kısıtladığında en verimlidir:

  • is_published ile filtreleme → verimli
  • is_published + city → verimli
  • Sadece city ile filtreleme → planlayıcı indeksi yine de kullanabilir ama tamamını taraması gerekir, bu nadiren iyi bir plandır

Pratik kural: eşitlikle karşılaştırdığınız alanları başa, sıraladığınız ya da aralıkla filtrelediğiniz alanı sona koyun. Yukarıda is_published ve city eşitlik filtresi, -created_at ise sıralamadır; böylece "bu şehrin en yeni ilanları" sorgusu ayrı bir sıralama adımı olmadan tek indeksle karşılanır. Django her ForeignKey için zaten tek sütunlu bir indeks oluşturur, yani city tek başına zaten kapsanıyor; bileşik indeks birleşik sorguda işe yarar.

İndeksin bedeli de vardır: her INSERT ve UPDATE işleminde güncellenmesi gerekir ve disk alanı kaplar. Gerçekten sorgulanmayan alanlara indeks eklemeyin.

3. Metin araması için hangi yöntem kullanılmalı?

icontains kullanmak kolaydır ama desen joker karakterle başladığı için sıradan bir B-tree indeks işe yaramaz:

Listing.objects.filter(title__icontains=term)
# SQL: WHERE UPPER(title) LIKE UPPER('%term%')

Küçük tablolarda sorun değildir. Büyüdükçe iki alternatif vardır. Yukarıdaki SQL'e dikkat: trigram indeksi icontains'e ancak aynı ifade üzerine kurulduysa yardım eder (Upper("title")); yani çıplak title sütunu üzerindeki indeks bu sorgu için yetmez.

Trigram indeksi — yazım hatalarına toleranslı, "benzer" eşleşme (pg_trgm, indeks ifadesi eşleştiğinde LIKE '%terim%' sorgularını da hızlandırabilir):

from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.operations import TrigramExtension
 
class Migration(migrations.Migration):
    operations = [
        TrigramExtension(),  # pg_trgm eklentisini kurar
    ]
class Meta:
    indexes = [
        GinIndex(fields=["title"], name="listing_title_trgm",
                 opclasses=["gin_trgm_ops"]),
    ]

Tam metin arama — kelime köklerini anlayan, alaka sırasına göre sonuç veren:

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
 
vector = SearchVector("title", weight="A") + SearchVector("description", weight="B")
query = SearchQuery(term)
 
results = (
    Listing.objects
    .annotate(rank=SearchRank(vector, query))
    .filter(rank__gte=0.1)
    .order_by("-rank")
)

weight="A" başlıktaki eşleşmeyi açıklamadakinden daha değerli sayar.

Her sorguda vektörü yeniden hesaplamak pahalıdır. Üretimde vektörü bir sütunda saklayıp indeksleyin:

search_vector = SearchVectorField(null=True, editable=False)
 
class Meta:
    indexes = [GinIndex(fields=["search_vector"])]

Saklanan sütun kendiliğinden güncellenmez: save() içinde, bir veritabanı tetikleyicisiyle ya da periyodik bir işle yenileyin; yoksa arama sonuçları eskir.

SearchVector ve SearchQuery'ye aynı config'i verin; vermezseniz PostgreSQL'in default_text_search_config ayarı belirler ve iki tarafın uyuşmaması tuhaf sonuç verir. Yerleşik sözlüğü olmayan dillerde İngilizce yapılandırma yanlış kök bulur. PostgreSQL'in simple sözlüğü kök bulmaz ama en azından yanlış kök bulmaz — çoğu durumda daha iyi bir başlangıçtır.

4. OFFSET ile sayfalama neden yavaşlar?

Django'nun Paginator sınıfı LIMIT/OFFSET üretir. OFFSET 10000 demek, veritabanının 10.000 satırı okuyup atması demektir — sayfa numarası büyüdükçe sorgu yavaşlar.

Sonsuz kaydırma veya "daha fazla yükle" tarzı arayüzlerde imleç (cursor) tabanlı sayfalama çok daha hızlıdır:

# İlk sayfa
page = Listing.objects.order_by("-created_at", "-id")[:20]
 
# Sonraki sayfa: son kaydın değerlerinden devam et
from django.db.models import Q
 
page = (
    Listing.objects
    .filter(
        Q(created_at__lt=last_created_at)
        | Q(created_at=last_created_at, id__lt=last_id)
    )
    .order_by("-created_at", "-id")[:20]
)

Sıralamaya id gibi benzersiz bir alan eklemek önemlidir. Aynı created_at değerine sahip iki kayıt varsa, sıralama kararsız olur ve bazı kayıtlar atlanabilir veya tekrar edebilir — filtrenin zaman damgaları eşitken id'yi karşılaştırmasının nedeni de budur.

Klasik sayfa numaralı arayüz gerekiyorsa ve toplam sayı çok büyükse, COUNT(*) sorgusu da yavaşlar. Yaklaşık sayı yeterliyse PostgreSQL'in istatistiklerinden okumak çok daha hızlıdır:

SELECT reltuples::bigint FROM pg_class WHERE relname = 'listings';

Gerçek tablo adını kullanın (Django varsayılanı uygulama_model). Değer en son ANALYZE ya da autovacuum kadar günceldir; hiç analiz edilmemiş tabloda -1 döner.

Bu endpoint bir Next.js Server Component'ten başka bağımsız verilerle birlikte çağrılıyorsa — listeleme ile ayrı bir facet-sayım isteği gibi — bunları art arda await etmek yerine Promise.all ile birlikte başlatın. Şelale olmadan veri çekmek bu hatanın nereden geldiğini anlatıyor.

EXPLAIN ANALYZE çıktısı nasıl okunur?

Tahmin etmek yerine ölçün. Django'da:

print(queryset.explain(analyze=True))

analyze=True sorguyu gerçekten çalıştırır; yavaş ya da yan etkisi olan sorgularda dikkatli olun.

Çıktıda aranacak birkaç şey:

Seq Scan — tablo baştan sona taranıyor. Küçük tablolarda normaldir; büyük tabloda görürseniz indeks eksiktir.

Index Scan veya Bitmap Index Scan — indeks kullanılıyor, istenen budur.

rows=... actual rows=... — tahmin ile gerçek arasındaki fark. Büyük sapma varsa tablo istatistikleri eskimiştir:

ANALYZE listings;

Nested Loop — küçük veri setlerinde hızlıdır, ama iki büyük tabloda görülüyorsa sorgu planlayıcı yanlış seçim yapmış olabilir.

Ölçmeden optimize etmeyin

Bu tekniklerin hepsi karmaşıklık ekler. Trigram indeksi eklemek, tam metin arama kurmak veya imleç tabanlı sayfalamaya geçmek kodu daha zor anlaşılır hale getirir.

Sıralama şu olmalı:

  1. Yavaş olduğunu ölçün (hangi sayfa, kaç ms)
  2. Nedenini bulun (EXPLAIN ANALYZE, sorgu sayısı)
  3. En büyük tek nedeni düzeltin
  4. Tekrar ölçün

Pratikte listeleme sayfalarının çoğu, sadece select_related/prefetch_related eklemek ve iki doğru indeks tanımlamakla kabul edilebilir hıza kavuşur. Diğer teknikler ancak bunlar yetmediğinde gerekir.