Sorgu Sonuçlarını Önbellekleme: Apollo Server Cache Kullanımı
GraphQL API’larınızın performansını artırmak söz konusu olduğunda, önbellekleme konusu kaçınılmaz olarak karşınıza çıkıyor. Özellikle yoğun trafik altında çalışan sistemlerde her sorgunun veritabanına gidip gelmesi ciddi bir yük oluşturuyor. Apollo Server’ın sunduğu önbellekleme mekanizmaları bu sorunu çözmek için oldukça güçlü araçlar sağlıyor. Bu yazıda Apollo Server’da sorgu sonuçlarını nasıl önbelleğe alabileceğinizi, hangi stratejileri kullanabileceğinizi ve gerçek dünya senaryolarında bunları nasıl uygulayacağınızı detaylıca inceleyeceğiz.
Apollo Server’da Önbellekleme Neden Önemli?
Bir e-ticaret platformu düşünün. Binlerce kullanıcı aynı anda ürün listelerini, kategorileri ve kampanya bilgilerini sorguluyor. Bu sorguların her biri veritabanına giderse hem veritabanı sunucunuz bunalır hem de kullanıcılar yavaş yanıtlar alır. Önbellekleme burada devreye giriyor ve tekrarlayan sorguları bellekten karşılayarak hem performansı artırıyor hem de altyapı maliyetlerini düşürüyor.
Apollo Server, önbellekleme için birkaç farklı katman sunuyor:
- Full Response Cache: Tam sorgu yanıtlarını önbelleğe alır
- Partial Query Cache: Resolver düzeyinde kısmi önbellekleme
- HTTP Cache Headers: CDN ve tarayıcı önbelleklemesi için header yönetimi
- DataLoader Pattern: N+1 problemini çözen batch önbellekleme
Temel Kurulum ve Bağımlılıklar
Önce gerekli paketleri kuralım. Apollo Server 4 ile çalışıyoruz ve @apollo/server-plugin-response-cache paketini kullanacağız.
npm install @apollo/server @apollo/server-plugin-response-cache @apollo/utils.keyvadapter keyv keyv-redis graphql
Redis tabanlı bir önbellek için ek paket:
npm install @keyv/redis ioredis
Temel Apollo Server kurulumuna önbellekleme eklentisini entegre edelim:
# server.js - Temel Apollo Server kurulumu
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
# Redis bağlantısı
const redisCache = new Keyv('redis://localhost:6379', {
namespace: 'apollo-cache',
ttl: 300000 # 5 dakika varsayılan TTL (milisaniye)
});
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new KeyvAdapter(redisCache),
plugins: [
responseCachePlugin({
# Oturum açmış kullanıcılar için ayrı cache key
sessionId: async (requestContext) => {
const userId = requestContext.request.http?.headers.get('user-id');
return userId || null;
}
})
]
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 }
});
console.log(`Server hazir: ${url}`);
Schema Direktifleri ile Cache Kontrolü
Apollo Server’ın en güçlü özelliklerinden biri, schema seviyesinde önbellekleme direktifleri kullanabilmektir. @cacheControl direktifi ile her tip ve alan için farklı önbellekleme stratejileri tanımlayabilirsiniz.
# schema.graphql - Cache direktifleri ile zenginleştirilmiş schema
type Query {
# 10 dakika public cache
products: [Product] @cacheControl(maxAge: 600, scope: PUBLIC)
# 30 saniye özel cache
userProfile(id: ID!): User @cacheControl(maxAge: 30, scope: PRIVATE)
# Hiç cache'lenmesin
realtimeStockPrice(symbol: String!): StockPrice @cacheControl(maxAge: 0)
# 1 saat CDN'de cache'lensin
staticContent(slug: String!): Content @cacheControl(maxAge: 3600, scope: PUBLIC)
}
type Product {
id: ID!
name: String!
price: Float! @cacheControl(maxAge: 60) # Fiyatlar daha sık değişir
description: String @cacheControl(maxAge: 3600)
stock: Int @cacheControl(maxAge: 0, inheritMaxAge: false) # Stok anlık
category: Category @cacheControl(maxAge: 1800)
}
type User {
id: ID!
email: String! @cacheControl(maxAge: 0, scope: PRIVATE)
preferences: UserPreferences @cacheControl(maxAge: 300, scope: PRIVATE)
}
type Category {
id: ID!
name: String! @cacheControl(maxAge: 86400) # Kategoriler nadiren değişir
products: [Product] @cacheControl(maxAge: 300)
}
Burada dikkat edilmesi gereken nokta, bir yanıttaki en düşük maxAge değerinin tüm yanıt için geçerli olmasıdır. Yani bir resolver’ınızda maxAge: 0 olan bir alan varsa, tüm yanıt önbelleğe alınmaz.
Resolver Seviyesinde Cache Kontrolü
Bazen schema direktifleri yeterli olmaz ve resolver içinde dinamik olarak önbellekleme kararları vermeniz gerekir. Bunu cacheControl nesnesi üzerinden yapabilirsiniz.
# resolvers.js - Dinamik cache kontrolü
const resolvers = {
Query: {
products: async (_, { category, limit }, context) => {
# Kategoriye göre farklı cache süresi
if (category === 'flash-sale') {
# Flaş satışlar için çok kısa cache
context.cacheControl.setCacheHint({ maxAge: 10, scope: 'PUBLIC' });
} else if (category === 'seasonal') {
context.cacheControl.setCacheHint({ maxAge: 3600, scope: 'PUBLIC' });
} else {
context.cacheControl.setCacheHint({ maxAge: 300, scope: 'PUBLIC' });
}
return await productService.getProducts({ category, limit });
},
userDashboard: async (_, __, context) => {
if (!context.user) {
throw new AuthenticationError('Giris yapmaniz gerekiyor');
}
# Kullanici verileri her zaman PRIVATE
context.cacheControl.setCacheHint({
maxAge: 60,
scope: 'PRIVATE'
});
return await dashboardService.getData(context.user.id);
},
siteSettings: async (_, __, context) => {
# Site ayarları nadiren değişir, agresif cache
context.cacheControl.setCacheHint({
maxAge: 86400,
scope: 'PUBLIC',
staleWhileRevalidate: 3600 # CDN için
});
return await settingsService.getAll();
}
},
Product: {
# Alt resolver'larda da cache kontrolü yapılabilir
relatedProducts: async (parent, _, context) => {
context.cacheControl.setCacheHint({ maxAge: 900, scope: 'PUBLIC' });
return await productService.getRelated(parent.id);
}
}
};
DataLoader ile Batch Önbellekleme
N+1 problemi GraphQL’in en yaygın performans sorunlarından biridir. DataLoader bu sorunu hem batch’leme hem de önbellekleme yaparak çözer. Gerçek dünya senaryosunda bir sipariş listesi çektiğinizde her siparişin kullanıcı bilgisi için ayrı veritabanı sorgusu yapılmasını önler.
# dataloader.js - Kullanici ve urun DataLoader'lari
import DataLoader from 'dataloader';
import { pool } from './database.js';
# Kullanici batch loader
export const createUserLoader = () => new DataLoader(
async (userIds) => {
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(',');
const result = await pool.query(
`SELECT * FROM users WHERE id IN (${placeholders})`,
userIds
);
# ID sırasını koruyarak döndür
const userMap = new Map(result.rows.map(user => [user.id, user]));
return userIds.map(id => userMap.get(id) || null);
},
{
# DataLoader kendi içinde istek başına cache tutar
cache: true,
# Batch boyutunu sınırla
maxBatchSize: 100,
# Cache key'i özelleştir
cacheKeyFn: (key) => String(key)
}
);
# Urun loader - Redis destekli uzun sureli cache ile
export const createProductLoader = (redisClient) => new DataLoader(
async (productIds) => {
# Once Redis'te ara
const cacheKeys = productIds.map(id => `product:${id}`);
const cachedValues = await redisClient.mget(cacheKeys);
const missingIds = [];
const results = new Array(productIds.length).fill(null);
cachedValues.forEach((val, idx) => {
if (val) {
results[idx] = JSON.parse(val);
} else {
missingIds.push({ idx, id: productIds[idx] });
}
});
# Eksik olanları veritabanindan getir
if (missingIds.length > 0) {
const ids = missingIds.map(item => item.id);
const placeholders = ids.map((_, i) => `$${i + 1}`).join(',');
const dbResult = await pool.query(
`SELECT * FROM products WHERE id IN (${placeholders})`,
ids
);
const productMap = new Map(dbResult.rows.map(p => [p.id, p]));
# Redis'e yaz ve results'a ekle
const pipeline = redisClient.pipeline();
missingIds.forEach(({ idx, id }) => {
const product = productMap.get(id) || null;
results[idx] = product;
if (product) {
pipeline.setex(`product:${id}`, 600, JSON.stringify(product));
}
});
await pipeline.exec();
}
return results;
},
{ cache: false } # Redis zaten cache'liyor, DataLoader cache'ini kapat
);
# Context'te kullan
export const createContext = async ({ req }) => ({
user: await getUser(req.headers.authorization),
loaders: {
user: createUserLoader(),
product: createProductLoader(redisClient)
}
});
Cache Invalidation Stratejileri
Önbelleklemenin en zor kısmı, eski verileri temizlemektir. Bir ürün fiyatı güncellendiğinde ilgili önbellek girişlerinin temizlenmesi gerekir.
# cache-invalidation.js - Kapsamli cache invalidation
class CacheInvalidationService {
constructor(redisClient, apolloServer) {
this.redis = redisClient;
this.apollo = apolloServer;
}
# Urun guncellendiginde ilgili cache'leri temizle
async onProductUpdated(productId, categoryId) {
const keys = [
`product:${productId}`,
`category:${categoryId}:products`,
`products:featured`,
`products:recent`
];
# Spesifik key'leri sil
await this.redis.del(...keys);
# Pattern ile toplu silme (dikkatli kullanin!)
const patternKeys = await this.redis.keys(`product:${productId}:*`);
if (patternKeys.length > 0) {
await this.redis.del(...patternKeys);
}
console.log(`Cache temizlendi: ${keys.join(', ')}`);
}
# Kategori guncellendiyse tum kategori cache'lerini temizle
async onCategoryUpdated(categoryId) {
const pipeline = this.redis.pipeline();
pipeline.del(`category:${categoryId}`);
pipeline.del(`categories:all`);
pipeline.del(`category:${categoryId}:products`);
await pipeline.exec();
}
# Tag-based invalidation - daha esnek yaklasim
async invalidateByTag(tag) {
const tagKey = `tag:${tag}`;
const members = await this.redis.smembers(tagKey);
if (members.length > 0) {
const pipeline = this.redis.pipeline();
pipeline.del(...members);
pipeline.del(tagKey);
await pipeline.exec();
console.log(`Tag '${tag}' ile ${members.length} cache girisini temizlendi`);
}
}
# Cache'e yazarken tag ekle
async setWithTags(key, value, ttl, tags = []) {
const pipeline = this.redis.pipeline();
pipeline.setex(key, ttl, JSON.stringify(value));
tags.forEach(tag => {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl + 60); # Tag biraz daha uzun yasasin
});
await pipeline.exec();
}
}
# Mutation resolver'larda kullan
const mutationResolvers = {
Mutation: {
updateProduct: async (_, { id, input }, context) => {
const product = await productService.update(id, input);
# Cache'i temizle
await cacheService.onProductUpdated(id, product.categoryId);
# Ya da tag-based temizleme
await cacheService.invalidateByTag(`product-${id}`);
await cacheService.invalidateByTag(`category-${product.categoryId}`);
return product;
}
}
};
HTTP Cache Headers ve CDN Entegrasyonu
Apollo Server’ın ürettiği Cache-Control headerları CDN’ler tarafından kullanılabilir. Bu sayede Cloudflare, Fastly veya AWS CloudFront gibi servisler sorgu yanıtlarını edge’de önbelleğe alabilir.
# http-cache.js - CDN entegrasyonu icin yapilandirma
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
# CDN'e gonderilecek header'lari ozellestir
useHttpHeadersInsteadOfStore: false,
# Belirli operasyonlar icin cache'i atla
shouldReadFromCache: async (requestContext) => {
const operationName = requestContext.request.operationName;
# Mutation'lari hic cache'leme
if (requestContext.document) {
const hasSubscription = requestContext.document.definitions.some(
def => def.kind === 'OperationDefinition' && def.operation === 'subscription'
);
if (hasSubscription) return false;
}
# Admin isteklerini cache'leme
const isAdmin = requestContext.request.http?.headers.get('x-admin') === 'true';
if (isAdmin) return false;
return true;
},
shouldWriteToCache: async (requestContext) => {
# Hata olan yanıtları cache'leme
if (requestContext.errors?.length > 0) return false;
# Sadece basarili tam yanitlari cache'le
return requestContext.response.body.kind === 'single' &&
!requestContext.response.body.singleResult.errors;
}
})
]
});
# Express ile kullanirken ek header'lar ekle
app.use('/graphql', expressMiddleware(server, {
context: async ({ req, res }) => {
# Response nesnesini context'e ekle
return { req, res, user: await getUser(req) };
}
}));
# CDN header middleware
app.use('/graphql', (req, res, next) => {
# GET isteklerini CDN'in cache'lemesine izin ver
if (req.method === 'GET') {
res.set('Vary', 'Accept-Encoding, Accept-Language');
}
next();
});
Üretim Ortamı: İzleme ve Metrikler
Önbelleklemenin ne kadar etkili çalıştığını ölçmek çok önemlidir. Cache hit/miss oranlarını izleyen bir sistem kuralım.
# cache-metrics.js - Prometheus metrikleri ile cache izleme
import { register, Counter, Histogram, Gauge } from 'prom-client';
const cacheHitCounter = new Counter({
name: 'graphql_cache_hits_total',
help: 'Toplam cache hit sayisi',
labelNames: ['operation', 'type']
});
const cacheMissCounter = new Counter({
name: 'graphql_cache_misses_total',
help: 'Toplam cache miss sayisi',
labelNames: ['operation', 'type']
});
const cacheResponseTime = new Histogram({
name: 'graphql_cache_response_duration_seconds',
help: 'Cache sorgu yanit suresi',
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1]
});
const cacheSize = new Gauge({
name: 'graphql_cache_size_bytes',
help: 'Mevcut cache boyutu'
});
# Izleme plugin'i
const cacheMonitoringPlugin = {
async requestDidStart() {
const startTime = Date.now();
return {
async willSendResponse(requestContext) {
const duration = (Date.now() - startTime) / 1000;
const operationName = requestContext.request.operationName || 'anonymous';
# Cache hit/miss belirleme
const cacheHit = requestContext.overallCachePolicy?.maxAge > 0 &&
requestContext.metrics?.responseCacheHit;
if (cacheHit) {
cacheHitCounter.inc({ operation: operationName, type: 'full' });
} else {
cacheMissCounter.inc({ operation: operationName, type: 'full' });
}
cacheResponseTime.observe(duration);
# Her 100 istekte bir cache boyutunu guncelle
if (Math.random() < 0.01) {
const info = await redisClient.info('memory');
const match = info.match(/used_memory:(d+)/);
if (match) cacheSize.set(parseInt(match[1]));
}
}
};
}
};
# Metrik endpoint'i
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
Gerçek Dünya Senaryosu: E-ticaret API Optimizasyonu
Tüm bu bilgileri bir araya getiren kapsamlı bir örnek:
# production-setup.js - Uretim ortami yapilandirmasi
import { ApolloServer } from '@apollo/server';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';
# Birincil Redis - cache icin
const primaryCache = new Keyv({
store: new KeyvRedis('redis://redis-primary:6379'),
namespace: 'gql-cache',
ttl: 600000
});
# Fallback: Redis'e ulasamazsa bellek cache
const fallbackCache = new Keyv();
# Cache saglikli mi kontrol et
primaryCache.on('error', (err) => {
console.error('Redis cache hatasi, fallback kullaniliyor:', err.message);
});
# Akillica cache adapter - hata durumunda fallback
class ResilientCacheAdapter extends KeyvAdapter {
async get(key) {
try {
return await super.get(key);
} catch (err) {
console.warn('Primary cache get hatasi:', err.message);
return undefined; # Cache miss gibi davran
}
}
async set(key, value, ttl) {
try {
await super.set(key, value, ttl);
} catch (err) {
console.warn('Primary cache set hatasi:', err.message);
# Fallback'e yaz
await fallbackCache.set(key, value, ttl);
}
}
}
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new ResilientCacheAdapter(primaryCache),
plugins: [
responseCachePlugin({
sessionId: async ({ request }) => {
const token = request.http?.headers.get('authorization');
if (!token) return null;
try {
const payload = verifyToken(token);
return `user-${payload.userId}`;
} catch {
return null;
}
},
shouldReadFromCache: async ({ request, contextValue }) => {
# Canli fiyat sorgulari cache'lenmesin
const query = request.query || '';
if (query.includes('stockPrice') || query.includes('liveInventory')) {
return false;
}
return true;
}
}),
cacheMonitoringPlugin
],
formatError: (err) => {
# Hassas bilgileri gizle
console.error('GraphQL Hatasi:', err);
return {
message: err.message,
code: err.extensions?.code
};
}
});
console.log('Apollo Server uretim modunda baslatildi');
Önbellekleme Stratejisi Seçimi
Hangi veriyi nasıl önbelleğe alacağınızı belirlerken şu kriterleri değerlendirin:
- Veri değişim sıklığı: Site ayarları için yüksek TTL (86400s), stok bilgisi için düşük veya sıfır TTL kullanın
- Kullanıcıya özgü veri: Kişiselleştirilmiş içerikler için
PRIVATEscope, herkese açık veriler içinPUBLICscope tercih edin - Veri boyutu: Büyük veri setlerini önbelleğe almak Redis belleğini hızla tüketir, seçici olun
- Invalidation karmaşıklığı: Tag-based invalidation büyük sistemlerde daha yönetilebilirdir
- Cache hit oranı beklentisi: Çok az tekrar eden sorgular için önbellekleme overhead’i avantajı geçebilir
Bunun yanı sıra şu noktalara dikkat edin:
- TTL değerlerini iş gereksinimlerine göre belirleyin: Teknik olarak mümkün olan ile iş açısından kabul edilebilir olan farklı olabilir
- Cache’i asla güvenlik katmanı olarak kullanmayın: Yetkilendirme kontrollerini her zaman resolver’larda yapın
- Staging ortamında test edin: Önbellekleme ile ilgili hatalar üretimde fark edilmesi güç sorunlara yol açabilir
- Memory limitlerini izleyin: Redis için
maxmemory-policyayarını doğru yapılandırın
Sonuç
Apollo Server’da önbellekleme, doğru uygulandığında API performansınızı dramatik biçimde artırabilir. Temel @cacheControl direktiflerinden Redis tabanlı dağıtık önbelleğe, DataLoader’dan CDN entegrasyonuna kadar geniş bir araç seti mevcuttur. Önemli olan tek bir strateji seçmek değil, farklı veri türleri için farklı katmanlı bir yaklaşım benimsemektir.
Gerçek dünya uygulamalarında en büyük kazanımları genellikle şu kombinasyon sağlar: DataLoader ile N+1 sorununu çözmek, sık değişmeyen veriler için HTTP cache headerları ve response cache’i aktif etmek, sık güncellenen veriler için akıllı invalidation stratejileri geliştirmek. Bunu yaparken metrik toplamayı ihmal etmeyin. Cache hit oranlarınızı ve yanıt sürelerinizi sürekli izleyerek neyin işe yaradığını ve neyin ayarlanması gerektiğini görebilirsiniz. İyi yapılandırılmış bir önbellekleme sistemi, altyapı maliyetlerinizi düşürürken kullanıcı deneyimini önemli ölçüde iyileştirir.
