Musa Yılmaz
·4 dk okuma

Binlerce Kayıtta Filtreleme: PostgreSQL ve Django ORM Performansı

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:

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.

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. İndeks soldan sağa kullanılabilir:

  • is_published ile filtreleme → indeks kullanılır
  • is_published + city → kullanılır
  • Sadece city ile filtreleme → kullanılmaz

Bu yüzden en seçici ve her sorguda bulunan alanı başa koyun. Yukarıdaki örnekte is_published her listeleme sorgusunda vardır, bu yüzden ilk sıradadır.

İ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 baştan joker karakter içerdiği için indeks kullanamaz:

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.

Trigram indeksi — yazım hatalarına toleranslı, "benzer" eşleşme:

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"])]

Türkçe içerikte varsayılan english sözlüğü kök bulma yapamaz. 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
page = (
    Listing.objects
    .filter(created_at__lt=last_created_at)
    .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.

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';

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

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

print(queryset.explain(analyze=True))

Çı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.