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 PRIVATE scope, herkese açık veriler için PUBLIC scope 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-policy ayarı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.

Bir yanıt yazın

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