Python ile Redis client-side caching

Selamlar, bu yazımda Redis'in client-side caching özelliğine Python tarafından bakacağız. Konsept aslında oldukça basit ama ilk kez duyanlar için biraz sihirli görünebiliyor: uygulamanız kendi belleğinde küçük bir önbellek tutuyor, Redis ise herhangi bir anahtar değiştiğinde size haber veriyor ve siz de o anahtarı yerel önbellekten atıyorsunuz. Yani her okumada Redis'e gitmek yerine, önemli ölçüde gidip gelmeyi azaltıyorsunuz. Lafı uzatmadan başlayalım.

Client-side caching nedir?

Client-side caching (Türkçe karşılığıyla istemci tarafı önbellekleme), Redis 6 ile gelen ve RESP3 protokolüyle daha da rahatlayan bir özellik. Mantık şu: client CLIENT TRACKING ON dediğinde Redis o client'ın hangi anahtarları okuduğunu kendi tarafında bir tabloda tutuyor. O anahtarlardan biri değiştiğinde Redis size __redis__:invalidate Pub/Sub kanalı üzerinden 'şu anahtar artık değişti, yerel kopyanı at' diyor.

Bence bu özelliğin en güzel tarafı, önbellek tutarlılığını sizin yönetmek zorunda olmamanız. Genelde cache yazarken en zor mesele 'şu anahtar bayatladı mı?' sorusudur, burada o yükü Redis'in kendisi sırtlanıyor.

Iki bağlantı, neden?

Bir şeye dikkat: tracking için iki ayrı bağlantı kullanmak gerekiyor. Birincisi normal okuma/yazma için, ikincisi sadece invalidation mesajlarını dinlemek için. Redis, tracking mesajlarını başka bir client ID'ye yönlendirmenizi istiyor (REDIRECT parametresi). Aşağıda göreceksiniz.

Minimum çalışan örnek

Hadi minimum bir örnek görelim. redis-py 4.2 veya üstü gerekli:

pip install 'redis>=4.2.0'

Şimdi sınıfı yazalım:

import redis
import threading
from typing import Optional


class RedisClientCache:
    def __init__(self, host='localhost', port=6379):
        self.data = redis.Redis(host=host, port=port, decode_responses=True)
        self.inv = redis.Redis(host=host, port=port, decode_responses=True)
        self._cache: dict = {}
        self._lock = threading.Lock()
        self._setup_tracking()

    def _setup_tracking(self):
        inv_id = self.inv.client_id()
        pubsub = self.inv.pubsub()
        pubsub.subscribe('__redis__:invalidate')

        self.data.execute_command(
            'CLIENT', 'TRACKING', 'ON', 'REDIRECT', str(inv_id)
        )

        def listener():
            for msg in pubsub.listen():
                if msg['type'] != 'message':
                    continue
                payload = msg['data']
                if payload is None:
                    with self._lock:
                        self._cache.clear()
                    continue
                keys = payload if isinstance(payload, list) else [payload]
                with self._lock:
                    for k in keys:
                        self._cache.pop(k, None)

        threading.Thread(target=listener, daemon=True).start()

    def get(self, key: str) -> Optional[str]:
        with self._lock:
            if key in self._cache:
                return self._cache[key]
        value = self.data.get(key)
        if value is not None:
            with self._lock:
                self._cache[key] = value
        return value

Burada data bağlantısıyla okuyoruz, inv bağlantısıyla invalidation kanalını dinliyoruz. payload is None durumu özellikle önemli: Redis bağlantı seviyesinde 'her şeyi at' dediğinde tüm yerel önbellek sıfırlanır. Bunu kaçırmayın.

Kullanırken

cache = RedisClientCache()

print(cache.get('user:42'))   # MISS - Redis'ten gelir
print(cache.get('user:42'))   # HIT  - bellekten gelir

# Başka bir client anahtarı değiştirsin
other = redis.Redis(decode_responses=True)
other.set('user:42', 'yeni-deger')

import time
time.sleep(0.05)              # invalidation'ın gelmesini bekleyelim

print(cache.get('user:42'))   # MISS - taze veri

Burada time.sleep(0.05) test amaçlı; gerçek hayatta invalidation milisaniyeler içinde gelir, ama race condition'a karşı bayatlık toleransınızı bilerek yazın.

Sık karşılaşılan hatalar

  • Tek bağlantıyla tracking açmak: REDIRECT vermezseniz invalidation mesajları normal okuma akışınıza karışır ve redis-py bunu beklenmedik bir mesaj olarak görür. İki bağlantı mecburi.
  • Lock'u unutmak: Listener thread'i ile uygulamanın okuma yapan thread'i aynı dict'e dokunur. Lock olmadan KeyError ya da bayat veri kaçınılmaz.
  • payload is None durumunu görmezden gelmek: Redis bazen 'bütün önbelleği at' der (mesela tracking tablosu dolduğunda). Bunu yakalayıp tamamı temizlemezseniz, eski veri sessizce yaşamaya devam eder.
  • Sonsuza kadar bellek tutmak: Yerel cache'in boyutu kontrolsüz büyüyebilir. Ben olsam üzerine küçük bir LRU sarardım, cachetools.LRUCache yeter.

Kapanış

Bu yazıda Redis client-side caching'i Python tarafından nasıl kuracağımıza, neden iki bağlantı gerektiğine ve invalidation kanalını nasıl dinleyeceğimize baktık. Şahsi kanaatim, sık okunan ama nadiren değişen anahtarlarda bu desen Redis trafiğini ciddi ölçüde düşürür ve uygulamanın p99 latency'sine de iyi gelir. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.