Büyük Veri Setleri için GraphQL Pagination Yöntemleri
Büyük veri setleriyle çalışırken GraphQL’in en çok baş ağrıtan konularından biri pagination’dır. REST API’lerde basit ?page=1&limit=20 parametreleriyle halledebileceğiniz şeyi, GraphQL’de doğru yapmak ciddi düşünce gerektiriyor. Yanlış tercih ettiğiniz pagination yöntemi, production’da binlerce kullanıcıya hizmet verirken veritabanınızı çökertebilir ya da frontend’e anlamsız veriler döndürebilir. Bu yazıda üç temel pagination yöntemini, gerçek dünya senaryolarıyla birlikte masaya yatıracağız.
Neden GraphQL’de Pagination Bu Kadar Önemli
Diyelim ki bir e-ticaret platformu yönetiyorsunuz. Ürün kataloğunuzda 500.000 kayıt var. Bir müşteri arama yapıp “tüm ürünleri getir” dediğinde ne olur? GraphQL’de resolver’ınız düzgün yazılmamışsa, veritabanı sorgusunda LIMIT olmadan 500.000 satır çekersiniz. Bu hem bellek açısından hem de network açısından felaket demektir.
GraphQL’in pagination konusunda özel bir söz dizimi zorunlu kılmaması, geliştiricilere özgürlük verse de aynı zamanda standart dışı uygulamaların önünü açar. Ekibinizdeki her geliştirici farklı bir yaklaşım benimseyebilir ve sonuçta tutarsız bir API ortaya çıkar.
Temel olarak üç pagination yöntemi öne çıkar:
- Offset-based pagination: Klasik sayfa numaralı yaklaşım
- Cursor-based pagination: Relay spec ile popülerleşen imleç tabanlı yaklaşım
- Keyset pagination: Yüksek performanslı büyük veri setleri için
Offset-Based Pagination
Temel Kullanım
En basit ve yaygın yöntem offset-based pagination’dır. Konsept olarak SQL’deki LIMIT ve OFFSET ile birebir örtüşür.
# GraphQL şema tanımı
type Query {
products(offset: Int, limit: Int): ProductConnection!
}
type ProductConnection {
items: [Product!]!
totalCount: Int!
hasNextPage: Boolean!
}
type Product {
id: ID!
name: String!
price: Float!
category: String!
}
Frontend’den gelen tipik bir sorgu şöyle görünür:
# Frontend'den offset-based sorgu örneği
query GetProducts($offset: Int!, $limit: Int!) {
products(offset: $offset, limit: $limit) {
items {
id
name
price
category
}
totalCount
hasNextPage
}
}
# Değişkenler:
# { "offset": 0, "limit": 20 } -> 1. sayfa
# { "offset": 20, "limit": 20 } -> 2. sayfa
# { "offset": 40, "limit": 20 } -> 3. sayfa
Node.js ve PostgreSQL kullanıyorsanız resolver tarafı şu şekilde yazılır:
# Node.js resolver implementasyonu (JavaScript)
const resolvers = {
Query: {
products: async (_, { offset = 0, limit = 20 }, { db }) => {
# Limit değerini güvenceye alıyoruz - DoS saldırılarını önlemek için
const safeLimit = Math.min(limit, 100);
const [items, totalCount] = await Promise.all([
db.query(
'SELECT * FROM products ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[safeLimit, offset]
),
db.query('SELECT COUNT(*) FROM products')
]);
return {
items: items.rows,
totalCount: parseInt(totalCount.rows[0].count),
hasNextPage: offset + safeLimit < parseInt(totalCount.rows[0].count)
};
}
}
};
Offset-Based Pagination’ın Sorunları
Offset pagination kullanışlı görünse de büyük veri setlerinde ciddi performans sorunları yaratır. PostgreSQL, OFFSET 50000 gördüğünde ilk 50.000 satırı okuyup atlar. Bu, veri okumaz anlamına gelmez; tersine, her seferinde o kadar satırı taramak zorundadır.
Bunun dışında gerçek zamanlı veri ortamlarında ciddi bir sorun daha var: veri kayması (data skew). Kullanıcı 1. sayfayı görürken yeni bir kayıt eklenirse ve kullanıcı 2. sayfaya geçerse, bir önceki sayfanın son kaydını tekrar görebilir ya da bir kayıt tamamen atlanabilir.
Cursor-Based Pagination (Relay Spec)
Relay Cursor Bağlantı Modeli
Facebook’un geliştirdiği Relay spec, GraphQL için standart bir cursor-based pagination modeli ortaya koydu. Bu model artık GraphQL ekosisteminde de facto standart haline gelmiştir.
# Relay uyumlu şema tasarımı
type Query {
products(
first: Int
after: String
last: Int
before: String
): ProductConnection!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type ProductEdge {
node: Product!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Cursor değeri genellikle Base64 encode edilmiş bir string’dir. Bu sayede client tarafı cursor’ın iç yapısını bilmeden kullanabilir:
# Cursor oluşturma ve decode etme yardımcı fonksiyonları
const encodeCursor = (id, createdAt) => {
const cursorData = JSON.stringify({ id, createdAt });
return Buffer.from(cursorData).toString('base64');
};
const decodeCursor = (cursor) => {
const decoded = Buffer.from(cursor, 'base64').toString('utf8');
return JSON.parse(decoded);
};
# Örnek cursor değeri:
# eyJpZCI6IjEyMyIsImNyZWF0ZWRBdCI6IjIwMjQtMDEtMTUifQ==
Cursor-Based Resolver Implementasyonu
# Cursor-based pagination resolver (Node.js + PostgreSQL)
const resolvers = {
Query: {
products: async (_, { first, after, last, before }, { db }) => {
const limit = first || last || 20;
const safeLimit = Math.min(limit, 100);
let query = 'SELECT * FROM products';
const params = [];
let paramIndex = 1;
if (after) {
const { id, createdAt } = decodeCursor(after);
query += ` WHERE (created_at, id) < ($${paramIndex}, $${paramIndex + 1})`;
params.push(createdAt, id);
paramIndex += 2;
}
if (before) {
const { id, createdAt } = decodeCursor(before);
query += ` WHERE (created_at, id) > ($${paramIndex}, $${paramIndex + 1})`;
params.push(createdAt, id);
paramIndex += 2;
}
# Sıralama yönü: "last" kullanılıyorsa ters sıralama
const orderDirection = last ? 'ASC' : 'DESC';
query += ` ORDER BY created_at ${orderDirection}, id ${orderDirection}`;
query += ` LIMIT $${paramIndex}`;
params.push(safeLimit + 1); # +1: sonraki sayfa varlığını kontrol etmek için
const result = await db.query(query, params);
const rows = result.rows;
const hasNextPage = rows.length > safeLimit;
const hasPreviousPage = !!after;
# Ekstra satırı kaldır
const items = hasNextPage ? rows.slice(0, safeLimit) : rows;
# last kullanıldıysa sırayı düzelt
if (last) items.reverse();
const edges = items.map(item => ({
node: item,
cursor: encodeCursor(item.id, item.created_at)
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
}
};
}
}
};
Frontend’den bu API’yi şu şekilde kullanırsınız:
# İlk sayfa sorgusu
query GetFirstPage {
products(first: 20) {
edges {
node {
id
name
price
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Sonraki sayfa sorgusu (endCursor değerini after'a geçiyoruz)
query GetNextPage($cursor: String!) {
products(first: 20, after: $cursor) {
edges {
node {
id
name
price
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Infinite Scroll Senaryosu
Sosyal medya benzeri bir uygulama geliştiriyorsanız ve infinite scroll implementasyonu yapıyorsanız, cursor-based pagination tam aradığınız yöntemdir. React ve Apollo Client kullanarak şu şekilde uygulayabilirsiniz:
# Apollo Client ile infinite scroll implementasyonu (JavaScript/React)
const PRODUCTS_QUERY = gql`
query Products($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
node {
id
name
price
imageUrl
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function ProductList() {
const { data, loading, fetchMore } = useQuery(PRODUCTS_QUERY, {
variables: { first: 20 }
});
const loadMore = () => {
if (!data.products.pageInfo.hasNextPage) return;
fetchMore({
variables: {
first: 20,
after: data.products.pageInfo.endCursor
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
products: {
...fetchMoreResult.products,
edges: [
...prev.products.edges,
...fetchMoreResult.products.edges
]
}
};
}
});
};
# Scroll event listener veya Intersection Observer ile loadMore tetiklenir
}
Keyset Pagination ile Yüksek Performans
Cursor-based pagination güçlü bir yöntem olsa da çok büyük veri setlerinde (milyonlarca kayıt) ve karmaşık sıralama kriterlerinde performans sorunları yaşanabilir. Keyset pagination, bu sorunu veritabanı indeks yapısını daha verimli kullanarak çözer.
Keyset Pagination Nasıl Çalışır
Klasik offset pagination’da veritabanı OFFSET 10000 için 10.000 satırı okumak zorundadır. Keyset pagination’da ise son görülen kaydın değerini WHERE koşuluna koyarız. Veritabanı bu sayede doğrudan ilgili index noktasına atlar.
# Keyset pagination için optimize edilmiş PostgreSQL sorgusu
# Bu yaklaşım compound index kullanımını zorunlu kılar
# Önce index'i oluşturun:
# CREATE INDEX idx_products_created_id ON products (created_at DESC, id DESC);
# Keyset pagination sorgusu:
# İlk sayfa:
SELECT * FROM products
ORDER BY created_at DESC, id DESC
LIMIT 20;
# Sonraki sayfa (son görülen: created_at='2024-01-15', id=123):
SELECT * FROM products
WHERE (created_at, id) < ('2024-01-15', 123)
ORDER BY created_at DESC, id DESC
LIMIT 20;
# Bu sorgu tam index taraması yapar, offset gibi satır atlamamaz
# EXPLAIN ANALYZE ile karşılaştırabilirsiniz:
EXPLAIN ANALYZE SELECT * FROM products
WHERE (created_at, id) < ('2024-01-15', 123)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Gerçek bir production senaryosunda 10 milyon kayıtlı bir tabloda offset=500000 ile keyset arasındaki fark çarpıcı olabilir. Offset yöntemi bu durumda 2-3 saniye alırken, keyset pagination index üzerinden direkt atlama yaptığı için 10-20 milisaniyeye kadar düşebilir.
Hangi Yöntemi Ne Zaman Kullanmalısınız
Offset Pagination Ne Zaman Uygundur
- Veri seti küçükse (100.000 kaydın altında)
- Kullanıcının belirli bir sayfaya direkt atlaması gerekiyorsa (“127. sayfaya git” gibi)
- Admin paneli gibi gerçek zamanlı veri değişiminin kritik olmadığı ortamlar
- Ekibinizde cursor kavramına alışık olmayan geliştiriciler varsa
Cursor-Based Pagination Ne Zaman Uygundur
- Infinite scroll veya “daha fazla yükle” tipi UI’larda
- Gerçek zamanlı güncellenen veri akışlarında (social feed, bildirimler)
- Relay uyumlu bir ekosistem kullanıyorsanız
- Veri tutarlılığı kritik önemdeyse
Keyset Pagination Ne Zaman Uygundur
- 1 milyonun üzerinde kayıt içeren tablolarda
- Yüksek trafikli API’lerde veritabanı yükünü minimize etmek istiyorsanız
- Sıralama kriteri değişmeyecekse ve iyi tasarlanmış composite index varsa
- Raporlama ve veri export senaryolarında
Production’da Dikkat Edilmesi Gereken Noktalar
Rate Limiting ve Güvenlik
Pagination endpoint’lerinize mutlaka rate limiting ve maksimum limit kontrolü ekleyin. Kötü niyetli bir kullanıcı limit: 999999 gibi bir değer geçerek sisteminizi zorlayabilir:
# GraphQL directive ile limit validasyonu
# Schema'ya custom directive ekleyebilirsiniz
type Query {
products(first: Int @range(min: 1, max: 100), after: String): ProductConnection!
}
# Ya da resolver içinde basit kontrol:
const validatePaginationArgs = (args) => {
const { first, last } = args;
if (first && (first < 1 || first > 100)) {
throw new UserInputError('first değeri 1-100 arasında olmalıdır');
}
if (last && (last < 1 || last > 100)) {
throw new UserInputError('last değeri 1-100 arasında olmalıdır');
}
if (first && last) {
throw new UserInputError('first ve last aynı anda kullanılamaz');
}
};
N+1 Sorunu ve DataLoader
Pagination uygularken sıkça karşılaşılan N+1 sorunu, özellikle edges içindeki node’larda ilişkili verileri çekerken baş gösterir. Her product için ayrı ayrı kategori bilgisi çekmek yerine DataLoader kullanın:
# DataLoader ile N+1 sorununu çözme
const DataLoader = require('dataloader');
const categoryLoader = new DataLoader(async (categoryIds) => {
const categories = await db.query(
'SELECT * FROM categories WHERE id = ANY($1)',
[categoryIds]
);
# DataLoader, ID sırasına göre sonuçları eşleştirmeyi bekler
const categoryMap = {};
categories.rows.forEach(cat => {
categoryMap[cat.id] = cat;
});
return categoryIds.map(id => categoryMap[id]);
});
# Resolver içinde:
const resolvers = {
Product: {
category: (product, _, { loaders }) => {
return loaders.categoryLoader.load(product.category_id);
}
}
};
Caching Stratejisi
Cursor tabanlı pagination, CDN veya uygulama katmanı cache’i için offset’e göre daha zorludur çünkü cursor değerleri dinamik olarak üretilir. Bununla birlikte, totalCount gibi pahalı aggregate sorgularını Redis’e alabilirsiniz:
# Redis ile totalCount cache'leme
const getTotalCount = async (db, redis, cacheKey) => {
const cached = await redis.get(cacheKey);
if (cached) return parseInt(cached);
const result = await db.query('SELECT COUNT(*) FROM products');
const count = parseInt(result.rows[0].count);
# 60 saniye TTL ile cache'e yaz
await redis.setex(cacheKey, 60, count.toString());
return count;
};
Sonuç
GraphQL pagination yöntemi seçimi, uygulamanızın ölçeği ve kullanım senaryosuyla doğrudan ilişkilidir. Küçük veri setleri için offset pagination yeterli ve basit bir çözüm sunarken, büyüyen sistemlerde cursor-based yaklaşım gerçek zamanlı tutarlılık ve daha iyi kullanıcı deneyimi sağlar. Milyonluk kayıt setleriyle çalışıyorsanız keyset pagination, veritabanı indekslerini en verimli şekilde kullanarak performansı ciddi ölçüde artırır.
Production ortamına geçmeden önce şu adımları atmanızı öneririm: limit değerlerine maksimum sınır koyun, N+1 sorunlarını DataLoader ile çözün, pahalı aggregate sorgularınızı cache’leyin ve seçtiğiniz pagination yöntemini tutarlı biçimde tüm ekibinize dokümante edin. GraphQL’in esnekliği bir avantaj olsa da bu esnekliği standartlara oturtmak, uzun vadede bakımı kolay ve ölçeklenebilir bir API ortaya çıkarmanın anahtarıdır.
