N+1 Sorunu ve DataLoader ile GraphQL Performans Optimizasyonu

GraphQL ile uygulama geliştirirken bir noktada fark edersin ki her şey yavaşlamış, veritabanı sorguları patlamış ve neden böyle olduğunu anlamak için saatler harcıyorsun. İşte bu noktada N+1 sorunuyla karşı karşıyasın demektir. Bu sorun, GraphQL’e özgü değil aslında; ORM kullanan her sistemde karşılaşılabilir. Ama GraphQL’in esnek yapısı, özellikle nested sorgular nedeniyle bu sorunu çok daha belirgin hale getirir. Bu yazıda N+1 sorununu derinlemesine inceleyeceğiz, nasıl tespit edeceğimizi göreceğiz ve DataLoader ile nasıl çözeceğimizi pratik örneklerle anlatacağız.

N+1 Sorunu Nedir?

N+1 sorunu, bir liste sorgulandığında her bir öğe için ek bir veritabanı sorgusu yapılması durumudur. Adını da buradan alır: 1 ana sorgu + N adet ek sorgu.

Somut bir senaryo düşünelim. Bir blog uygulaması yapıyorsun. Kullanıcılar ve onlara ait gönderiler var. GraphQL’de şu sorguyu çalıştırıyorsun:

# GraphQL Sorgusu
query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

Bu sorgu arka planda şu şekilde çalışır:

# Veritabanında gerçekte çalışan sorgular
SELECT * FROM users;                         -- 1 sorgu (100 kullanıcı geldi)
SELECT * FROM posts WHERE user_id = 1;       -- 2. sorgu
SELECT * FROM posts WHERE user_id = 2;       -- 3. sorgu
SELECT * FROM posts WHERE user_id = 3;       -- 4. sorgu
# ... 100 kullanıcı için 100 sorgu daha
# Toplam: 101 sorgu!

100 kullanıcı için 101 sorgu yapılıyor. 1000 kullanıcı olsaydı 1001 sorgu olurdu. Bu, uygulamanı kolayca çökertebilir.

Neden GraphQL’de Bu Kadar Yaygın?

REST API’lerde bu sorunu genellikle endpoint tasarımıyla kontrol altına alırsın. Bir endpoint bir iş yapar, JOIN kullanırsın ve bitirirsin. Ama GraphQL’de resolver yapısı nedeniyle bu sorun kaçınılmaz hale gelir.

Her alan için ayrı bir resolver yazarsın ve bu resolver’lar birbirinden habersiz çalışır. User resolver’ı kullanıcıları çeker, sonra her kullanıcı için posts alanının resolver’ı devreye girer ve her biri kendi sorgusunu çalıştırır.

# Tipik bir GraphQL resolver yapısı (Node.js/Express-GraphQL)
const resolvers = {
  Query: {
    users: async () => {
      return await db.query('SELECT * FROM users');
    }
  },
  User: {
    posts: async (parent) => {
      # Her kullanıcı için ayrı sorgu - N+1 sorununa davetiye!
      return await db.query(
        'SELECT * FROM posts WHERE user_id = $1',
        [parent.id]
      );
    }
  }
};

İşte sorun tam da burada. parent.id ile her kullanıcı için ayrı sorgu yapılıyor.

Sorunu Tespit Etme

Sorunu çözmeden önce tespit etmek gerekiyor. Birkaç yöntem var:

Logging ile tespit: PostgreSQL veya MySQL’de slow query log’ları aktifleştir. Aynı sorgunun defalarca tekrar ettiğini görürsen N+1 durumundasın.

# PostgreSQL'de sorgu loglarını aktifleştirme
# postgresql.conf dosyasını düzenle
log_min_duration_statement = 100    # 100ms'den uzun sorguları logla
log_destination = 'stderr'
logging_collector = on

# Logları izle
tail -f /var/log/postgresql/postgresql-14-main.log | grep "SELECT"

Apollo Studio veya benzeri araçlar: Apollo Server kullanıyorsan, Apollo Studio’daki trace’lere bak. Her resolver’ın ne kadar süre harcadığını ve kaç sorgu çalıştırdığını görebilirsin.

# Node.js için basit bir query counter middleware
let queryCount = 0;

const countingDb = {
  query: async (sql, params) => {
    queryCount++;
    console.log(`Query #${queryCount}: ${sql}`);
    return originalDb.query(sql, params);
  }
};

# Her request başında sıfırla
app.use((req, res, next) => {
  queryCount = 0;
  next();
});

DataLoader: Çözüm Burada

Facebook, bu sorunu çözmek için DataLoader kütüphanesini geliştirdi. DataLoader’ın yaptığı şey aslında oldukça zekice: tek tek gelen sorguları biriktirip toplu olarak çalıştırmak. Buna batching denir.

DataLoader’ın iki temel özelliği var:

  • Batching: Aynı event loop tick’inde gelen tüm istekleri toplar ve tek bir sorguyla çalıştırır
  • Caching: Aynı istek tekrar gelirse cache’den döner, yeni sorgu çalıştırmaz

Kurulum basit:

# Node.js projesi için kurulum
npm install dataloader

# Ya da yarn ile
yarn add dataloader

# Python için (Strawberry veya Graphene kullanıyorsan)
pip install strawberry-graphql[debug-server]
# veya
pip install aiodataloader

Şimdi temel bir DataLoader örneği yazalım:

# dataloader/userPostsLoader.js
const DataLoader = require('dataloader');
const db = require('./db');

# Batch fonksiyonu - birden fazla kullanıcı ID'sini alıp tek sorguda çalıştırır
const batchLoadPosts = async (userIds) => {
  console.log(`Batching ${userIds.length} kullanıcı için sorgu`);
  
  # Tek bir IN sorgusu - N+1 yerine tek sorgu!
  const posts = await db.query(
    'SELECT * FROM posts WHERE user_id = ANY($1)',
    [userIds]
  );
  
  # Her kullanıcı için postları grupla
  const postsByUserId = {};
  userIds.forEach(id => {
    postsByUserId[id] = [];
  });
  
  posts.forEach(post => {
    if (postsByUserId[post.user_id]) {
      postsByUserId[post.user_id].push(post);
    }
  });
  
  # Aynı sırayla döndür (DataLoader zorunlu kılar)
  return userIds.map(id => postsByUserId[id] || []);
};

const createPostsLoader = () => new DataLoader(batchLoadPosts);

module.exports = { createPostsLoader };

Dikkat et: DataLoader’ın batch fonksiyonu, gelen ID dizisinin sırasıyla aynı sırada sonuç döndürmek zorundadır. Bu çok önemli bir detay.

DataLoader’ı GraphQL Resolver’lara Entegre Etme

DataLoader’ı oluşturdun, şimdi resolver’lara entegre etmek gerekiyor. Önemli bir nokta: DataLoader her request için yeni bir instance oluşturulmalı. Aksi halde farklı kullanıcıların verileri birbirine karışabilir (cache sebebiyle).

# server.js - Context ile DataLoader oluşturma
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const { createPostsLoader } = require('./dataloader/userPostsLoader');
const { createUserLoader } = require('./dataloader/userLoader');

const app = express();

app.use('/graphql', graphqlHTTP((req) => ({
  schema: schema,
  context: {
    # Her request için yeni DataLoader instance'ları
    loaders: {
      posts: createPostsLoader(),
      users: createUserLoader(),
    },
    # Auth bilgileri de context'e eklenebilir
    user: req.user,
  },
  graphiql: true,
})));

Şimdi resolver’ları DataLoader kullanacak şekilde güncelle:

# resolvers/index.js - DataLoader ile güncellenmiş resolver'lar
const resolvers = {
  Query: {
    users: async (_, __, context) => {
      # Bu sorgu hala direkt çalışır
      return await db.query('SELECT * FROM users');
    },
    
    user: async (_, { id }, context) => {
      # Tekil kullanıcı için de DataLoader kullan (cache avantajı)
      return await context.loaders.users.load(id);
    }
  },
  
  User: {
    posts: async (parent, _, context) => {
      # Artık her kullanıcı için ayrı sorgu YOK
      # DataLoader tüm istekleri toplayıp tek sorgu yapacak
      return await context.loaders.posts.load(parent.id);
    }
  },
  
  Post: {
    author: async (parent, _, context) => {
      # Post'un yazarını da DataLoader ile çek
      return await context.loaders.users.load(parent.user_id);
    }
  }
};

Gerçek Dünya Senaryosu: E-Ticaret Uygulaması

Daha karmaşık bir örnek üzerinden gidelim. Bir e-ticaret uygulamasında siparişler, ürünler ve müşteriler var.

# E-ticaret için kapsamlı DataLoader örneği
# dataloader/index.js

const DataLoader = require('dataloader');
const db = require('./db');

# Ürün loader'ı
const createProductLoader = () => new DataLoader(async (productIds) => {
  const products = await db.query(
    'SELECT * FROM products WHERE id = ANY($1::int[])',
    [productIds]
  );
  
  const productMap = {};
  products.forEach(p => { productMap[p.id] = p; });
  
  return productIds.map(id => productMap[id] || null);
});

# Sipariş kalemleri loader'ı (order_items)
const createOrderItemsLoader = () => new DataLoader(async (orderIds) => {
  const items = await db.query(`
    SELECT 
      oi.*,
      p.name as product_name,
      p.price as unit_price
    FROM order_items oi
    JOIN products p ON p.id = oi.product_id
    WHERE oi.order_id = ANY($1::int[])
  `, [orderIds]);
  
  const itemsByOrder = {};
  orderIds.forEach(id => { itemsByOrder[id] = []; });
  items.forEach(item => {
    itemsByOrder[item.order_id].push(item);
  });
  
  return orderIds.map(id => itemsByOrder[id]);
});

# Müşteri loader'ı - caching özelliğini kullanıyoruz
const createCustomerLoader = () => new DataLoader(async (customerIds) => {
  const customers = await db.query(
    'SELECT id, name, email, tier FROM customers WHERE id = ANY($1::int[])',
    [customerIds]
  );
  
  const customerMap = {};
  customers.forEach(c => { customerMap[c.id] = c; });
  
  return customerIds.map(id => customerMap[id] || null);
}, {
  # Cache boyutunu sınırla (büyük uygulamalarda önemli)
  maxBatchSize: 100,
  cache: true
});

module.exports = {
  createProductLoader,
  createOrderItemsLoader,
  createCustomerLoader
};

Bu yapıyla artık şu gibi karmaşık sorgular bile N+1 sorunu yaşatmaz:

# Artık sorunsuz çalışan GraphQL sorgusu
query {
  orders(status: "completed") {
    id
    total
    customer {
      name
      email
      tier
    }
    items {
      quantity
      product_name
      unit_price
    }
  }
}

Arka planda artık sadece 3 sorgu çalışır:

  • Siparişleri çek
  • Tüm müşterileri tek sorguda çek
  • Tüm sipariş kalemlerini tek sorguda çek

Cache Yönetimi ve Güvenlik

DataLoader’ın cache özelliği güçlü ama dikkatli kullanılmalı. Özellikle mutation sonrasında cache’i temizlemek gerekebilir.

# Cache yönetimi
const resolvers = {
  Mutation: {
    updateProduct: async (_, { id, input }, context) => {
      # Ürünü güncelle
      const updated = await db.query(
        'UPDATE products SET name = $1, price = $2 WHERE id = $3 RETURNING *',
        [input.name, input.price, id]
      );
      
      # DataLoader cache'ini temizle
      # Aksi halde eski veri dönebilir
      context.loaders.products.clear(id);
      
      return updated[0];
    },
    
    deleteOrder: async (_, { id }, context) => {
      await db.query('DELETE FROM orders WHERE id = $1', [id]);
      
      # İlgili tüm cache'leri temizle
      context.loaders.orderItems.clear(id);
      
      return { success: true };
    }
  }
};

Önemli güvenlik notu: DataLoader cache’i request bazlı tutulduğundan kullanıcılar arası veri sızıntısı riski yok. Ama eğer context’i yanlışlıkla request’ler arasında paylaşırsan ciddi sorunlar çıkabilir. Her request için mutlaka yeni context ve yeni DataLoader instance’ları oluştur.

Performans Testi ve Karşılaştırma

Gerçekten ne kadar fark yaratıyor? Bunu ölçmek için basit bir benchmark kurabilirsin:

# benchmark.sh - Basit performans testi
#!/bin/bash

echo "=== DataLoader Olmadan ==="
time curl -s -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -d '{"query": "{ users { id name posts { id title } } }"}' 
  > /dev/null

echo ""
echo "=== DataLoader İle ==="
time curl -s -X POST http://localhost:4001/graphql 
  -H "Content-Type: application/json" 
  -d '{"query": "{ users { id name posts { id title } } }"}' 
  > /dev/null

# Veritabanı sorgu sayısını logdan kontrol et
echo ""
echo "=== Sorgu Sayıları ==="
grep "Query #" /var/log/app/queries.log | tail -20

Tipik bir sonuç şöyle görünür: 100 kullanıcı için DataLoader olmadan 101 sorgu ve 800ms bekleme süresi, DataLoader ile 3 sorgu ve 45ms bekleme süresi. Bu fark production’da çok daha belirgin hale gelir.

Alternatif Yaklaşımlar

DataLoader her zaman tek çözüm değil. Duruma göre başka yaklaşımlar da işe yarayabilir:

  • JOIN Query Yaklaşımı: Resolver seviyesinde JOIN kullanarak tüm veriyi tek sorguda çekmek. Basit senaryolarda iyi çalışır ama esnekliği azaltır.
  • Persisted Queries: Karmaşık ve bilinen sorgular için önceden optimize edilmiş SQL sorguları hazırlamak.
  • Query Complexity Limiting: Çok derin nested sorguları engellemek. graphql-depth-limit gibi kütüphaneler bu işi yapar.
  • Relay Connection Spesifikasyonu: Sayfalama ile veri miktarını sınırlamak, N+1’in etkisini azaltır.
# graphql-depth-limit kurulumu ve kullanımı
npm install graphql-depth-limit

# server.js içinde
const depthLimit = require('graphql-depth-limit');

app.use('/graphql', graphqlHTTP({
  schema,
  validationRules: [
    depthLimit(5)  # Maksimum 5 seviye derinliğe izin ver
  ]
}));

İzleme ve Gözlemlenebilirlik

Production’da DataLoader’ın ne kadar etkili olduğunu izlemek için custom instrumentation ekleyebilirsin:

# Ölçümlü DataLoader wrapper'ı
const createInstrumentedLoader = (name, batchFn) => {
  return new DataLoader(async (ids) => {
    const start = Date.now();
    const results = await batchFn(ids);
    const duration = Date.now() - start;
    
    # Prometheus, Datadog veya benzeri bir sisteme gönder
    metrics.histogram('dataloader_batch_duration_ms', duration, {
      loader: name
    });
    metrics.counter('dataloader_batch_size', ids.length, {
      loader: name
    });
    
    console.log(`[DataLoader:${name}] ${ids.length} item, ${duration}ms`);
    
    return results;
  });
};

# Kullanımı
const createPostsLoader = () => 
  createInstrumentedLoader('posts', batchLoadPosts);

Bu şekilde hangi loader’ların ne kadar sorgu birleştirdiğini, ne kadar süre harcadığını ve sistemin genel sağlığını takip edebilirsin.

Sonuç

N+1 sorunu, GraphQL uygulamalarında performansı sessiz sedasız mahvedebilen ve fark edilmesi zor bir sorun. Özellikle düşük veri hacminde geliştirme ortamında bu sorunu göremeyebilirsin ama production’da binlerce kullanıcıyla karşılaştığında faturası ağır olur.

DataLoader, bu sorunu zarif ve etkili bir şekilde çözüyor. Temel prensip basit: tek tek gelen sorguları topla, batch’le çalıştır, cache’le. Ama uygulaması doğru yapılmazsa yeni sorunlar çıkabilir. Her request için yeni instance, mutation sonrası cache temizleme ve güvenli context yönetimi bu noktaların başında geliyor.

Pratik olarak şunu tavsiye ederim: Yeni bir GraphQL projesi başlatırken DataLoader’ı en baştan entegre et, sonradan eklemeye çalışmak daha zahmetli olur. Mevcut projelerde ise önce sorunu logging ile tespit et, en çok N+1 yaşatan resolver’ları bul ve oradan başla. Küçük adımlarla giderek tüm resolver’larını DataLoader’a geçirebilirsin. Sonunda hem veritabanın nefes alacak hem de kullanıcıların mutlu olacak.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir