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-limitgibi 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.
