Tüm yazılar
Araçlar 6 dk okuma

Önbellek Tasarımı: Yavaş API Çağrılarını Doğruluğu Bozmadan Hızlandırmak

Yavaş API çağrılarını önbellekle hızlandırırken eski veri servis etmemek için anahtar tasarımı, TTL, ETag ile koşullu istek ve stampede çözümleri.

#python #api #testing

Otomasyon betiklerinde en ucuz hız kazancı, aynı API çağrısını ikinci kez yapmamaktır. Önbellek tam olarak bunu sağlar: bir kez alınan yanıt bir süre saklanır ve tekrar eden istekler ağa çıkmadan cevaplanır. Kulağa basit gelen bu fikir, kurgusu yanlış yapıldığında sessizce eski veri servis eden bir hata kaynağına dönüşür.

Yavaş bir API'yi hızlandırırken asıl soru "ne kadar hızlandı" değil, "hangi koşulda yanlış cevap verir" sorusudur. Doğruluğu bozmayan bir önbellek, neyi anahtar saydığını, veriyi ne kadar tuttuğunu, sunucuya nasıl sorduğunu ve eskiyen kaydı nasıl düşürdüğünü kodda açıkça tanımlar.

Yazı boyunca anahtar tasarımı, TTL seçimi, ETag ve Last-Modified ile koşullu istek, negatif yanıtların önbelleklenmesi, geçersiz kılma ve önbellek damgası (stampede) sorununu çalışan Python örnekleriyle ele alıyorum. Örnekler Python 3 standart kütüphanesi ve requests paketiyle çalışır.

Önbellek anahtarı ve TTL nasıl seçilir?

Önbellek anahtarı, yanıtı etkileyen her girdinin deterministik bir özetidir; TTL ise o yanıtın eski olmasını göze alabileceğiniz en uzun süredir. Anahtara URL, sıralanmış sorgu parametreleri ve yanıtı değiştiren başlıklar girer; yanıtı değiştirmeyen izleme etiketleri girmez. TTL, kaynağın değişme sıklığına göre değil, eski verinin iş sonucuna maliyetine göre belirlenir.

Anahtar tasarımında en sık görülen hatalar birbirine benzer ve hepsi aynı belirtiyi üretir: aynı veri için birden fazla kayıt ya da farklı veri için tek kayıt.

  • Parametre sırası: ?a=1&b=2 ile ?b=2&a=1 aynı yanıtı verir; anahtar üretilirken parametreler sıralanmazsa iki ayrı kayıt oluşur ve isabet oranı düşer.
  • Gereksiz parametreler: utm_source gibi yanıtı etkilemeyen değerler anahtara girerse önbellek hiç isabet etmez.
  • Kimlik bağlamı: Kullanıcıya özel bir yanıt, anahtarında kullanıcı kimliği taşımıyorsa bir kullanıcının verisi diğerine servis edilir.
  • Şema sürümü: Yanıtı işleyen kodun sürümü anahtara eklenirse şema değiştiğinde eski kayıtlar kendiliğinden erişilmez olur.

TTL için tek doğru değer yoktur; dakikada değişen bir kur listesiyle yılda bir değişen ülke kodu listesi aynı süreyle tutulmaz. Pratik yaklaşım, her uç nokta için "eski veri en fazla ne kadar eski olabilir" sorusuna iş sahibiyle birlikte cevap vermek ve o süreyi kodda sabit olarak, gerekçesiyle yazmaktır.

Süreyi belirlerken üç soru yeterli çerçeveyi verir:

  • Eski veriyle alınan bir karar geri alınabilir mi, yoksa fatura veya bildirim gibi dışa çıkan bir sonuç mu üretir?
  • Kaynak sistem değişikliği bildiriyor mu (webhook, güncelleme zamanı alanı)? Bildiriyorsa TTL uzun tutulup geçersiz kılma olaya bağlanır.
  • Önbellek boşken sistem ayakta kalabiliyor mu? Kalamıyorsa TTL değil, aşağıdaki stampede koruması önceliklidir.

ETag ve Last-Modified ile koşullu istek nasıl çalışır?

Koşullu istek, önbellekteki kopyanın hâlâ geçerli olup olmadığını sunucuya sormanın ucuz yoludur. İstemci, önceki yanıttan sakladığı ETag değerini If-None-Match, Last-Modified değerini If-Modified-Since başlığıyla gönderir; kaynak değişmediyse sunucu gövdesiz 304 Not Modified döner. Koşullu isteklerin kuralları RFC 9110'da, önbellekleme davranışının çerçevesi RFC 9111'de tanımlıdır.

TTL dolduğunda kaydı çöpe atmak yerine koşullu istekle doğrulamak iki avantajı birleştirir: TTL içinde ağa hiç çıkılmaz, TTL bitince tam indirme yerine çoğu durumda küçük bir 304 yanıtı alınır. Aşağıdaki sınıf bu akışı bellekte tutar; kalıcılık gerekiyorsa depo sözlüğünü SQLite ile küçük ama sağlam veri boru hatları yazısındaki tablo düzenine taşımak yeterlidir.

import hashlib
import json
import time

import requests


class KosulluOnbellek:
    """ETag / Last-Modified destekli, TTL'li basit HTTP önbelleği."""

    def __init__(self, ttl_saniye=300):
        self.ttl = ttl_saniye
        self.depo = {}  # anahtar -> {"govde", "etag", "last_modified", "zaman"}

    @staticmethod
    def anahtar(url, params=None):
        ham = url + "?" + json.dumps(params or {}, sort_keys=True)
        return hashlib.sha256(ham.encode("utf-8")).hexdigest()

    def al(self, url, params=None):
        k = self.anahtar(url, params)
        kayit = self.depo.get(k)
        simdi = time.time()

        if kayit and simdi - kayit["zaman"] < self.ttl:
            return kayit["govde"], "taze"

        basliklar = {}
        if kayit and kayit.get("etag"):
            basliklar["If-None-Match"] = kayit["etag"]
        if kayit and kayit.get("last_modified"):
            basliklar["If-Modified-Since"] = kayit["last_modified"]

        yanit = requests.get(url, params=params, headers=basliklar, timeout=10)

        if yanit.status_code == 304 and kayit:
            kayit["zaman"] = simdi          # süre yenilenir, gövde indirilmez
            return kayit["govde"], "304-dogrulandi"

        yanit.raise_for_status()
        self.depo[k] = {
            "govde": yanit.json(),
            "etag": yanit.headers.get("ETag"),
            "last_modified": yanit.headers.get("Last-Modified"),
            "zaman": simdi,
        }
        return self.depo[k]["govde"], "indirildi"


if __name__ == "__main__":
    ob = KosulluOnbellek(ttl_saniye=5)
    url = "https://api.github.com/repos/python/cpython"
    for _ in range(2):
        veri, durum = ob.al(url)
        print(durum, veri.get("full_name"))
    time.sleep(6)
    veri, durum = ob.al(url)
    print(durum, veri.get("full_name"))

Sınıfın üç dönüş etiketi (taze, 304-dogrulandi, indirildi) ölçüm içindir. Bir günlük koşumda bu etiketler sayılarak önbelleğin gerçekten isabet edip etmediği görülür; isabet oranı düşükse sorun çoğu zaman anahtardadır, TTL'de değil. Sunucu ETag göndermiyorsa sınıf kendiliğinden yalnızca TTL ile çalışır.

Koşullu isteğin bir sınırı vardır: 304 yanıtı gövdeyi indirmekten kurtarır ama ağ gidiş-dönüşünü ortadan kaldırmaz. Gecikmenin asıl kaynağı bant genişliği değil gidiş-dönüş süresiyse kazanç küçük kalır; o durumda TTL'i uzatıp geçersiz kılmayı olaya bağlamak daha çok kazandırır.

Negatif yanıt, geçersiz kılma ve stampede nasıl yönetilir?

Negatif önbellekleme, 404 veya boş sonuç gibi "yok" cevaplarını da kısa süreliğine saklamaktır; geçersiz kılma (invalidation), veri değiştiğinde ilgili kaydı bilinçli olarak düşürmektir; önbellek damgası (stampede) ise süresi dolan popüler bir anahtar için aynı anda onlarca isteğin birden kaynağa yüklenmesidir. Üç sorun da kaydın yaşam döngüsünde, aynı sınıfın içinde çözülür.

Var olmayan bir kaydı önbelleklememek, her yoklamada kaynağa gitmek demektir; toplu eşleştirme işlerinde en çok tekrar eden çağrı çoğu zaman tam da "bulunamadı" çağrısıdır. Negatif yanıta ayrı ve kısa bir TTL vermek doğru dengedir: kayıt sonradan oluşursa gecikme en fazla o kısa süre kadar olur.

Geçersiz kılma için üç yöntem vardır ve sağlam bir tasarım genellikle ilk ikisini birlikte kullanır:

  • Olay tabanlı: Kaydı güncelleyen kod, aynı işlem içinde önbellekten de siler. En kesin yöntemdir; şartı, yazma yolunun tek yerden geçmesidir.
  • Sürüm tabanlı: Anahtara bir sürüm sayacı eklenir; sayaç artınca eski kayıtlar silinmeden erişilmez olur ve TTL ile kendiliğinden temizlenir.
  • Zaman tabanlı: TTL son çaredir. Tek başına kullanıldığında eski veri penceresi TTL kadar uzar; bu pencere kabul edilebilirse yeterlidir, değilse yukarıdakilerden biri şarttır.

Stampede'e karşı en basit çözüm anahtar başına kilittir: ilk gelen istek kaynağa gider, diğerleri kilidi bekler ve dolan kaydı okur. Aşağıdaki sınıf bu "tek uçuş" desenini, negatif TTL'i ve elle geçersiz kılmayı bir arada gösterir.

import threading
import time


class TekUcusOnbellek:
    """Aynı anahtar için aynı anda tek üretici çalıştırır (stampede koruması)."""

    def __init__(self, ttl_saniye=300, negatif_ttl_saniye=30):
        self.ttl = ttl_saniye
        self.negatif_ttl = negatif_ttl_saniye
        self.depo = {}
        self.kilitler = {}
        self._ana_kilit = threading.Lock()

    def _kilit(self, anahtar):
        with self._ana_kilit:
            return self.kilitler.setdefault(anahtar, threading.Lock())

    def al(self, anahtar, uret):
        kayit = self.depo.get(anahtar)
        if kayit and time.time() < kayit["son"]:
            return kayit["deger"]

        with self._kilit(anahtar):
            kayit = self.depo.get(anahtar)      # kilidi bekleyen ikinci kez bakar
            if kayit and time.time() < kayit["son"]:
                return kayit["deger"]

            deger = uret()                      # yavaş API çağrısı burada
            omur = self.ttl if deger is not None else self.negatif_ttl
            self.depo[anahtar] = {"deger": deger, "son": time.time() + omur}
            return deger

    def gecersiz_kil(self, anahtar):
        self.depo.pop(anahtar, None)


if __name__ == "__main__":
    ob = TekUcusOnbellek(ttl_saniye=60, negatif_ttl_saniye=5)
    sayac = {"cagri": 0}

    def yavas_kaynak():
        sayac["cagri"] += 1
        time.sleep(1)
        return None if sayac["cagri"] < 2 else {"id": 42}

    isler = [threading.Thread(target=ob.al, args=("musteri:42", yavas_kaynak))
             for _ in range(20)]
    for t in isler:
        t.start()
    for t in isler:
        t.join()
    print("kaynak cagrisi:", sayac["cagri"])      # 20 istek, 1 çağrı
    time.sleep(6)                                 # negatif TTL doldu
    print(ob.al("musteri:42", yavas_kaynak))      # {'id': 42}

Kilit beklerken kaynak yine hata verirse yeniden deneme mantığı devreye girer; bu mantığın önbelleğin dışında, tek bir yerde durması gerekir. Yeniden deneme ve hız limiti kurgusunu API entegrasyon desenleri yazısında ayrıntılı ele aldım; önbellek o katmanın önünde durur, yerine geçmez.

Önbellekte kişisel veri tutmak KVKK açısından ne gerektirir?

Önbellek de bir veri saklama ortamıdır; içinde kişisel veri varsa KVKK'daki amaçla sınırlılık ve saklama süresi ilkeleri diskteki her kopyaya uygulanır. Kişiye özel yanıtlar yalnızca kullanıcı kimliğini içeren anahtarla, kısa TTL ile ve mümkünse şifreli diskte tutulur; sunucu Cache-Control: private veya no-store gönderiyorsa bu talimata uyulur.

  • Cache-Control: no-store gelen yanıt hiçbir depoya yazılmaz; bellekteki geçici kopya bile istek bitince bırakılır.
  • Cache-Control: private gelen yanıt paylaşımlı bir depoya değil, yalnızca o kullanıcının bağlamına yazılır.
  • Negatif TTL ve normal TTL, veri envanterinde o veri için yazılı saklama süresini aşamaz; silme talebi geldiğinde önbellek de temizlenir.
  • Günlüğe anahtarın ham hâli değil özeti yazılır; ham anahtarda kimlik numarası veya e-posta olabilir.

Sonuç

Doğruluğu bozmayan önbellek dört kararın açıkça yazılmasıyla kurulur: anahtara ne girer, kayıt ne kadar yaşar, süresi dolunca nasıl doğrulanır ve veri değişince nasıl düşer. Bu kararlar koda geçtiğinde önbellek hız kazancı sağlarken eski veri riskini ölçülebilir bir sınırın içinde tutar.

Yeni bir önbellek katmanı eklemeden önce şu üç kontrol yeterlidir: dönüş etiketlerini sayan bir ölçüm var mı, negatif yanıt ve stampede için ayrı davranış tanımlı mı, kişisel veri içeren uç noktalar için no-store ve private başlıklarına uyuluyor mu. Üçü de evetse önbellek hızlandırır; biri hayırsa hızlandırırken yanıltır.

Otomasyonunuzdaki yavaş API çağrılarını ölçüp doğru önbellek katmanını birlikte tasarlamak isterseniz ana sayfadaki hizmet özetine göz atabilir, iletişim formundan yazabilirsiniz.