Üretim ortamında bir veritabanı sunucusunun CPU kullanımı aniden %90’a fırladığında, ilk bakacağın yer büyük ihtimalle sorgu logları olur. Ama asıl sorun çoğu zaman daha derinlerde yatar: önbellekleme stratejisi ya yanlış seçilmiştir ya da hiç uygulanmamıştır. Redis’i sadece “bir şeyleri kaydet, sonra oku” mantığıyla kullanmak, onu gerçek potansiyelinin çok altında kullanmak demektir. Cache-Aside ve Write-Through stratejileri, bu potansiyeli açığa çıkaran iki temel yaklaşımdır ve hangisini ne zaman kullanacağını bilmek, gecenin üçünde pagerdan uyanıp uyanmaman arasındaki fark olabilir.
Önbellekleme Neden Bu Kadar Kritik?
Bir e-ticaret sitesi düşün. Ürün katalog sayfası her saniye yüzlerce kullanıcı tarafından ziyaret ediliyor. Her istek veritabanına gidip aynı sorguyu çalıştırıyorsa, bu hem veritabanı için gereksiz bir yük hem de kullanıcı için gereksiz bir bekleme süresi demektir. Redis gibi bir in-memory store kullanarak bu sorguların sonuçlarını önbelleğe alırsın ve veritabanı yükünü dramatik biçimde düşürürsün.
Ama işin püf noktası şu: veriyi nasıl önbelleğe aldığın ve güncellemelerle nasıl başa çıktığın, sistemin tutarlılığını ve performansını doğrudan etkiler. Cache-Aside ve Write-Through, bu problemi farklı açılardan ele alan iki köklü stratejidir.
Cache-Aside (Lazy Loading) Stratejisi
Cache-Aside, adından da anlaşıldığı gibi önbelleği “kenara koyma” yaklaşımıdır. Bu stratejide uygulama, önce cache’e bakar. Veri oradaysa doğrudan döner (cache hit). Yoksa (cache miss) veritabanına gider, veriyi alır, cache’e yazar ve uygulamaya döner.
Cache-Aside Nasıl Çalışır?
Akış şu şekildedir:
- Uygulama Redis’te ilgili anahtarı arar
- Anahtar varsa veriyi Redis’ten döner
- Anahtar yoksa veritabanından veriyi çeker
- Çekilen veriyi Redis’e yazar (TTL ile birlikte)
- Veriyi uygulamaya döner
Bu strateji lazy loading olarak da bilinir çünkü veri, ancak istendiğinde önbelleğe alınır. Hiç sorgulanmayan veriler önbelleğe girmez, bu da bellek kullanımını optimize eder.
Python ile Cache-Aside Örneği
Gerçek dünyaya yakın bir senaryo üzerinden gidelim. Bir kullanıcı profil servisi düşün:
import redis
import json
import psycopg2
from functools import wraps
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def get_db_connection():
return psycopg2.connect(
host="localhost",
database="myapp",
user="appuser",
password="secretpassword"
)
def get_user_profile(user_id: int) -> dict:
cache_key = f"user:profile:{user_id}"
# Adim 1: Cache'e bak
cached_data = redis_client.get(cache_key)
if cached_data:
print(f"Cache HIT: {cache_key}")
return json.loads(cached_data)
# Adim 2: Cache miss, veritabanindan cek
print(f"Cache MISS: {cache_key}")
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"SELECT id, username, email, created_at FROM users WHERE id = %s",
(user_id,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row is None:
return None
user_data = {
"id": row[0],
"username": row[1],
"email": row[2],
"created_at": str(row[3])
}
# Adim 3: Cache'e yaz (TTL: 1 saat)
redis_client.setex(cache_key, 3600, json.dumps(user_data))
return user_data
Bu basit ama etkili bir implementasyon. İlk istek her zaman veritabanına gidecek, sonraki istekler Redis’ten karşılanacak.
Cache-Aside’da Invalidation
Cache-Aside’ın en kritik parçası veri güncellendiğinde cache’i geçersiz kılmaktır:
def update_user_email(user_id: int, new_email: str) -> bool:
conn = get_db_connection()
cur = conn.cursor()
try:
# Veritabanini guncelle
cur.execute(
"UPDATE users SET email = %s WHERE id = %s",
(new_email, user_id)
)
conn.commit()
# Cache'i invalidate et
cache_key = f"user:profile:{user_id}"
redis_client.delete(cache_key)
print(f"Cache invalidated: {cache_key}")
return True
except Exception as e:
conn.rollback()
print(f"Hata: {e}")
return False
finally:
cur.close()
conn.close()
Burada dikkat edilmesi gereken nokta şu: Cache’i güncellemek yerine siliyoruz. Bu kasıtlı bir tercih. Güncelleme sırasında yarış koşulu (race condition) oluşabilir, silmek daha güvenlidir.
Cache-Aside’ın Güçlü ve Zayıf Yanları
Güçlü yanları:
- Sadece gerçekten talep edilen veriler önbelleğe alınır, bellek israfı yoktur
- Cache çökmesi durumunda sistem çalışmaya devam eder, sadece yavaşlar
- Okuma ağırlıklı iş yükleri için mükemmel performans sağlar
- Uygulama kodu üzerinde tam kontrol sunar
Zayıf yanları:
- İlk istek her zaman yavaş olur (cache miss cezası)
- Veri tutarsızlığı penceresi vardır: güncelleme ile invalidation arasında stale data okuma riski
- Her servisin kendi cache mantığını yönetmesi kod tekrarına yol açabilir
- Cache stampede problemi: Aynı anda birçok istek cache miss yaşarsa hepsi veritabanına gider
Cache Stampede Koruması
Yüksek trafikli ortamlarda cache stampede ciddi bir problem. Redis’in SET NX (set if not exists) özelliğiyle bunu çözebilirsin:
import time
import threading
def get_user_profile_safe(user_id: int) -> dict:
cache_key = f"user:profile:{user_id}"
lock_key = f"lock:user:profile:{user_id}"
# Cache'e bak
cached_data = redis_client.get(cache_key)
if cached_data:
return json.loads(cached_data)
# Lock almaya calis (5 saniye timeout)
lock_acquired = redis_client.set(lock_key, "1", nx=True, ex=5)
if lock_acquired:
try:
# Veritabanindan veri cek
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"SELECT id, username, email FROM users WHERE id = %s",
(user_id,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row:
user_data = {"id": row[0], "username": row[1], "email": row[2]}
redis_client.setex(cache_key, 3600, json.dumps(user_data))
return user_data
finally:
redis_client.delete(lock_key)
else:
# Lock alinamazsa kisa sure bekle ve tekrar dene
time.sleep(0.1)
return get_user_profile_safe(user_id)
return None
Write-Through Stratejisi
Write-Through tamamen farklı bir bakış açısına sahip. Bu stratejide her yazma işlemi hem cache’e hem de veritabanına eş zamanlı olarak yapılır. Uygulama asla sadece veritabanına yazıp cache’i atlamaz. Sonuç olarak cache her zaman güncel kalır.
Write-Through Nasıl Çalışır?
- Uygulama veri yazmak istediğinde önce cache’e yazar
- Ardından (veya eş zamanlı olarak) veritabanına yazar
- Okuma isteklerinde cache her zaman güncel veri içerir
- Cache miss teorik olarak yalnızca ilk yüklemede yaşanır
Write-Through Implementasyonu
class UserProfileCache:
def __init__(self):
self.redis = redis.Redis(
host='localhost',
port=6379,
db=0,
decode_responses=True
)
self.cache_ttl = 7200 # 2 saat
def _get_cache_key(self, user_id: int) -> str:
return f"user:profile:{user_id}"
def write(self, user_id: int, user_data: dict) -> bool:
"""Write-Through: Hem cache hem DB'ye yaz"""
conn = get_db_connection()
cur = conn.cursor()
try:
# Once veritabanina yaz
cur.execute("""
INSERT INTO users (id, username, email)
VALUES (%s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET username = %s, email = %s
""", (
user_id,
user_data['username'],
user_data['email'],
user_data['username'],
user_data['email']
))
conn.commit()
# Basarili olduysa cache'e yaz
cache_key = self._get_cache_key(user_id)
self.redis.setex(
cache_key,
self.cache_ttl,
json.dumps(user_data)
)
print(f"Write-Through basarili: user_id={user_id}")
return True
except Exception as e:
conn.rollback()
print(f"Write-Through hatasi: {e}")
return False
finally:
cur.close()
conn.close()
def read(self, user_id: int) -> dict:
"""Cache-first okuma"""
cache_key = self._get_cache_key(user_id)
cached = self.redis.get(cache_key)
if cached:
return json.loads(cached)
# Nadir durumda DB'den yukle
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"SELECT id, username, email FROM users WHERE id = %s",
(user_id,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row:
user_data = {"id": row[0], "username": row[1], "email": row[2]}
self.redis.setex(cache_key, self.cache_ttl, json.dumps(user_data))
return user_data
return None
Pipeline Kullanarak Write-Through Performansını Artırma
Redis pipeline ile network round-trip sayısını azaltabilirsin:
def batch_write_through(users: list) -> bool:
"""Toplu Write-Through islemi pipeline ile"""
conn = get_db_connection()
cur = conn.cursor()
try:
# Toplu DB yazimi
user_values = [
(u['id'], u['username'], u['email'])
for u in users
]
cur.executemany("""
INSERT INTO users (id, username, email)
VALUES (%s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET username = EXCLUDED.username,
email = EXCLUDED.email
""", user_values)
conn.commit()
# Pipeline ile toplu cache yazimi
pipe = redis_client.pipeline()
for user in users:
cache_key = f"user:profile:{user['id']}"
pipe.setex(cache_key, 3600, json.dumps(user))
pipe.execute()
print(f"{len(users)} kullanici basariyla yazildi")
return True
except Exception as e:
conn.rollback()
print(f"Batch write hatasi: {e}")
return False
finally:
cur.close()
conn.close()
Write-Through’nun Güçlü ve Zayıf Yanları
Güçlü yanları:
- Cache her zaman güncel kalır, stale data riski minimumdur
- Okuma işlemleri her zaman hızlıdır, cache miss nadirdir
- Yazma ve okuma tutarlılığı sağlamak daha kolaydır
- Kritik verilerin her zaman önbellekte olduğu garanti altındadır
Zayıf yanları:
- Her yazma işlemi iki yere yazıldığı için yazma gecikmesi artar
- Hiç okunmayan veriler de cache’e yazılır, bellek israfı oluşabilir
- Cache ve DB arasında atomik yazma garantisi sağlamak zorlaşabilir
- Dağıtık sistemlerde tutarlılık sorunları daha karmaşık hale gelir
Gerçek Dünya Senaryosu: E-Ticaret Ürün Kataloğu
İki stratejiyi gerçek bir senaryoda karşılaştıralım. Bir e-ticaret platformunda ürün kataloğu var:
class ProductCatalogService:
def __init__(self, strategy='cache_aside'):
self.redis = redis.Redis(
host='localhost',
port=6379,
db=0,
decode_responses=True,
socket_timeout=2 # 2 saniye timeout
)
self.strategy = strategy
self.stats = {"hits": 0, "misses": 0}
def get_product(self, product_id: int) -> dict:
cache_key = f"product:{product_id}"
# Cache kontrol
cached = self.redis.get(cache_key)
if cached:
self.stats["hits"] += 1
return json.loads(cached)
self.stats["misses"] += 1
# DB'den cek
product = self._fetch_from_db(product_id)
if product and self.strategy == 'cache_aside':
# Cache-Aside: sadece sorgulandiktan sonra cache'e al
ttl = 1800 if product['stock'] > 0 else 300
self.redis.setex(cache_key, ttl, json.dumps(product))
return product
def update_product_price(self, product_id: int, new_price: float) -> bool:
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE products SET price = %s, updated_at = NOW() WHERE id = %s",
(new_price, product_id)
)
conn.commit()
cache_key = f"product:{product_id}"
if self.strategy == 'cache_aside':
# Cache-Aside: invalidate et
self.redis.delete(cache_key)
elif self.strategy == 'write_through':
# Write-Through: guncel veriyi cache'e yaz
product = self._fetch_from_db(product_id)
if product:
self.redis.setex(cache_key, 3600, json.dumps(product))
return True
except Exception as e:
conn.rollback()
return False
finally:
cur.close()
conn.close()
def _fetch_from_db(self, product_id: int) -> dict:
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"SELECT id, name, price, stock FROM products WHERE id = %s",
(product_id,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row:
return {
"id": row[0],
"name": row[1],
"price": float(row[2]),
"stock": row[3]
}
return None
def get_cache_stats(self) -> dict:
total = self.stats["hits"] + self.stats["misses"]
hit_rate = (self.stats["hits"] / total * 100) if total > 0 else 0
return {
"hits": self.stats["hits"],
"misses": self.stats["misses"],
"hit_rate": f"{hit_rate:.2f}%"
}
Redis Üzerinde Cache İzleme
Üretimdeki cache performansını izlemek için kullanabileceğin Redis komutları:
# Redis bellek kullanimi ve istatistikler
redis-cli info memory | grep -E "used_memory_human|maxmemory_human"
# Cache hit/miss oranlarini goster
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"
# Belirli bir prefix ile eslesen anahtarlari say
redis-cli --scan --pattern "user:profile:*" | wc -l
# Buyuk anahtarlari bul (100 ornek al)
redis-cli --bigkeys -i 0.1
# Belirli bir anahtarin TTL'ini kontrol et
redis-cli TTL "user:profile:12345"
# Canli komut izleme (dikkatli kullan, yuk olusturur)
redis-cli monitor | grep "user:profile"
Hangisini Ne Zaman Kullanmalısın?
Bu sorunun cevabı, uygulamanın okuma/yazma oranına, veri tutarlılığı gereksinimlerine ve gecikme toleransına bağlıdır.
Cache-Aside şu durumlarda tercih edilmeli:
- Okuma işlemleri yazma işlemlerinden çok daha fazlaysa
- Veri sık sık güncellenmiyorsa ve stale data bir süre kabul edilebiliyorsa
- Farklı kullanıcıların farklı veri setlerine eriştiği ve her verinin cache’e alınmasının anlamsız olduğu durumlarda
- Cache’in bağımsız bir bileşen olarak çalışması isteniyorsa ve cache çökmesi durumunda sistemin yavaşlayarak da olsa ayakta kalması gerekiyorsa
Write-Through şu durumlarda tercih edilmeli:
- Okuma ve yazma işlemleri dengeli dağılmışsa
- Stale data kesinlikle kabul edilemiyorsa (finans uygulamaları, stok yönetimi)
- Yazma sonrası hemen okuma yapılan iş akışları varsa
- Cache miss maliyeti çok yüksekse ve her okuma işleminin hızlı olması kritikse
Karma Strateji: İkisini Birlikte Kullanmak
Gerçek üretim sistemlerinde genellikle her iki stratejiyi de farklı veri türleri için birlikte kullanırsın:
class HybridCacheService:
"""
Kullanici profili: Cache-Aside (seyrek guncelleme)
Stok bilgisi: Write-Through (kritik veri)
Urun detayi: Cache-Aside + uzun TTL (nadiren degisir)
Fiyat bilgisi: Write-Through (anlık tutarlilik gerekir)
"""
def update_inventory(self, product_id: int, quantity: int):
"""Stok Write-Through ile guncellenir"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE inventory SET quantity = %s WHERE product_id = %s RETURNING *",
(quantity, product_id)
)
row = cur.fetchone()
conn.commit()
if row:
inventory_data = {
"product_id": product_id,
"quantity": quantity,
"updated_at": str(row[2]) if len(row) > 2 else None
}
# Write-Through: Anlik tutarlilik kritik
redis_client.setex(
f"inventory:{product_id}",
300, # 5 dakika TTL
json.dumps(inventory_data)
)
finally:
cur.close()
conn.close()
def get_user_recommendations(self, user_id: int) -> list:
"""Oneriler Cache-Aside ile: hesaplamasi pahali, seyrek degisir"""
cache_key = f"recommendations:{user_id}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Pahali hesaplama
recommendations = self._compute_recommendations(user_id)
# 30 dakika TTL ile cache'e al
redis_client.setex(cache_key, 1800, json.dumps(recommendations))
return recommendations
def _compute_recommendations(self, user_id: int) -> list:
# Karmasik ML/sorgu mantigi burada
return []
Sonuç
Cache-Aside ve Write-Through, aynı problemi farklı trade-off’larla çözen iki stratejidir. Cache-Aside sana esneklik ve bellek verimliliği sunar; Write-Through sana tutarlılık ve öngörülebilir okuma performansı. Birini seçip diğerini görmezden gelme.
Üretim ortamında en sağlam yaklaşım, veri türüne ve kritiklik seviyesine göre strateji seçmektir. Kullanıcı profili gibi seyrek güncellenen veriler için Cache-Aside yeterlidir. Stok sayısı veya fiyat gibi anlık doğruluğun kritik olduğu veriler için Write-Through daha güvenlidir.
Son olarak şunu söyleyelim: Hangi stratejiyi seçersen seç, TTL değerlerini ve cache invalidation mantığını ihmal etme. Yanlış TTL değerleri, en iyi stratejiyi bile işe yaramaz hale getirir. Redis’in INFO stats çıktısını düzenli olarak izle, keyspace_hits ve keyspace_misses değerlerinin sana söylediklerine kulak ver. Cache hit rate’in %80’in altına düştüğünde, birinin seninle konuşmak istediğini bil.