GraphQL'de N+1 Problemi ve DataLoader Çözümü

## GraphQL'de N+1 Problemi Nedir? GraphQL'in en büyük vaadi tek bir istekle ihtiyaç duyduğunuz kadar veriyi çekmek. Ancak bu esnek yapı, dikkat edilm...

GraphQL'de N+1 Problemi ve DataLoader Çözümü

GraphQL'de N+1 Problemi Nedir?

GraphQL'in en büyük vaadi tek bir istekle ihtiyaç duyduğunuz kadar veriyi çekmek. Ancak bu esnek yapı, dikkat edilmezse veritabanı katmanında beklenmedik bir sorgu patlamasına yol açıyor. N+1 problemi tam olarak burada ortaya çıkıyor: Bir liste döndüren alan, listenin her elemanı için ayrı bir sorgu tetikliyor.

Tipik senaryo şöyle görünür:

query {
  authors {
    id
    name
    books {
      title
    }
  }
}

authors alanı 50 yazar döndürüyorsa ve books alanı her yazar için ayrı ayrı çözümleniyorsa, veritabanına 1 (yazarlar) + 50 (her yazarın kitapları) sorgu gider. Yazar sayısı arttıkça sorgu sayısı doğrusal olarak büyür. Bu, REST dünyasındaki klasik N+1 probleminin GraphQL'e taşınmış halidir; farkı, sorunun resolver katmanında gizlenmesidir.

Problemi Neden GraphQL'de Fark Etmek Zor?

REST'te bir endpoint genellikle tek bir sorguya karşılık gelir; yavaşlığı profiler'da görmek kolaydır. GraphQL'de ise her alan kendi resolver'ına sahiptir ve resolver'lar birbirinden bağımsız çalışır. Çerçeve, hangi resolver'ın hangi veriyi zaten çektiğini kendiliğinden bilmez. Bu yüzden aynı veri defalarca istenebilir ve bunu ancak sorgu loglarına ya da bir izleme aracına bakarak fark edersiniz.

Belirti genellikle şudur: Küçük bir sorgu hızlı yanıt verirken, iç içe geçmiş alanlar eklendiğinde yanıt süresi beklenmedik şekilde sıçrar.

Çözüm 1: DataLoader ile Toplu Yükleme (Batching)

En yaygın ve taşınabilir çözüm DataLoader desenidir. Fikir basit: Aynı event loop turu içinde gelen tek tek yükleme isteklerini toplayıp tek bir toplu sorguya çevirmek.

Aşağıdaki örnek, DataLoader'ın resmi JavaScript paketinin (dataloader) genel kullanımını gösterir. Paketin sürümüne göre API ayrıntıları değişebileceğinden, kurmadan önce kendi projenizdeki sürümün dokümantasyonunu doğrulayın.

const DataLoader = require('dataloader');

// batchLoadFn: bir anahtar dizisi alır, aynı sırayla sonuç dizisi döndürür
const bookLoader = new DataLoader(async (authorIds) => {
  const books = await db.query(
    'SELECT author_id, title FROM books WHERE author_id = ANY($1)',
    [authorIds]
  );

  // Sonuçları anahtara göre grupla ve giriş sırasına göre eşle
  const grouped = new Map();
  for (const book of books) {
    if (!grouped.has(book.author_id)) grouped.set(book.author_id, []);
    grouped.get(book.author_id).push(book);
  }
  return authorIds.map((id) => grouped.get(id) || []);
});

Resolver tarafında her yazar için ayrı sorgu yerine loader çağrılır:

const resolvers = {
  Author: {
    books: (author, args, context) => context.bookLoader.load(author.id),
  },
};

Beklenen davranış: Tek bir GraphQL isteğinde 50 yazar için books alanı çözümlenirse, loader bu 50 isteği toplayıp WHERE author_id = ANY(...) ile tek sorguya indirir. Toplam sorgu sayısı 1 + 1'e düşer.

Kritik nokta: batchLoadFn'in döndürdüğü dizinin uzunluğu ve sırası, gelen anahtar dizisiyle birebir eşleşmelidir. Eksik veya kaydırılmış sonuç, yanlış verinin yanlış kayda bağlanmasına yol açar — sessiz ve tehlikeli bir hata türüdür.

Önemli uyarı: DataLoader örnekleri istek başına oluşturulmalıdır. Loader'ı global kapsamda tanımlarsanız, önbelleği istekler arasında paylaşılır ve bir kullanıcının verisi başka bir isteğe sızabilir. Bu bir doğruluk değil, güvenlik sorunudur. Loader'ı GraphQL context'ine istek başına koyun.

Çözüm 2: Sorgu Düzeyinde Toplu Çekme

Bazı durumlarda resolver bazlı toplu yükleme yerine, üst seviye sorguda ilişkili veriyi tek seferde çekmek daha basittir. Örneğin yazarları çekerken kitapları da JOIN ile alıp bellekte gruplayabilirsiniz. Bu yaklaşım, ilişki derinliği az olduğunda ve veri hacmi kontrol altındaysa pratiktir. Ancak derin iç içe sorgularda veya alan seçimi değişkenlik gösterdiğinde esnekliğini yitirir; çünkü istenmeyen alanları da çekme riski doğar.

Çözüm 3: Sorgu Maliyetini Sınırlama

N+1'i tamamen ortadan kaldırmak her zaman mümkün olmayabilir. Bu durumda ikinci savunma hattı, sorgunun maliyetini önceden sınırlamaktır. Derinlik sınırı (depth limit) ve karmaşıklık analizi (query complexity) ile istemcinin aşırı iç içe veya aşırı geniş sorgu göndermesi engellenir. Bu, hem performans hem de kötüye kullanıma karşı koruma sağlar; çünkü sınırsız iç içe sorgu, tek bir istekle sunucuyu meşgul etmenin kolay bir yoludur.

Yaygın Hatalar

  • Loader'ı global tanımlamak: İstekler arası önbellek paylaşımı, veri sızıntısına yol açar.
  • Batch fonksiyonunda sıralamayı korumamak: Sonuçlar yanlış kayıtlara bağlanır.
  • Her resolver'ı ayrı sorguyla bırakmak: Liste alanlarında N+1'in en sık kaynağı budur.
  • Önbelleği sonsuz tutmak: Aynı istek içinde tutarlılık beklenirken, uzun ömürlü önbellek bayat veri döndürür.
  • Sadece mutlu yolu test etmek: Boş sonuç, tek eleman ve çok elemanlı durumlar ayrı ayrı doğrulanmalıdır.

Doğrulama Yöntemi

Çözümü uyguladıktan sonra beklenen davranışı ölçün: Sorgu loglarını açıp aynı GraphQL isteğinin kaç veritabanı sorgusu ürettiğini sayın. Düzeltme öncesi ve sonrası sayıyı karşılaştırın. Bu, iyileştirmenin gerçekten işe yaradığını gösteren en doğrudan kanıttır.

Özet

GraphQL'de N+1 problemi, resolver'ların bağımsız çalışmasından doğar. DataLoader ile toplu yükleme, çoğu senaryoda en taşınabilir çözümdür; ancak istek başına örneklenmesi ve sonuç sıralamasının korunması şarttır. Derinlik ve karmaşıklık sınırları ise ikinci savunma hattı olarak hem performansı hem güvenliği korur.

İLGİLİ İÇERİKLER

Bunları da Okumak İsteyebilirsiniz