MİMARİ~5 dakika okuma süresi

Redis Yazma Stratejileri ve Veri Tutarlılığı: Production'ı Çökerten Sessiz Katiller

Redis'i sadece basit bir in-memory veri deposu olarak kullanmak işin kolay kısmı. Peki ya veritabanı ile Redis arasındaki senkronizasyon koptuğunda ne olur? Bu yazıda Cache Stampede, Penetration gibi sistem katillerini ve Spring Boot ile veri tutarlılığını nasıl sağlayacağımızı derinlemesine inceliyoruz.

Redis Yazma Stratejileri ve Veri Tutarlılığı

Birkaç yıl önce, e-ticaret sektöründe çalıştığım dönemde efsanevi bir "Efsane Cuma" (Black Friday) krizi yaşamıştık. Kampanyanın en can alıcı ürünü yayına girdiği an, sistemimiz saniyeler içinde kilitlendi. Veritabanı CPU kullanımı %100'e fırladı, connection pool'lar tükendi ve API'ler ardı ardına 503 Service Unavailable dönmeye başladı.

Sorunu incelediğimizde ilginç bir detay fark ettik: Veritabanına saniyede binlerce sorgu geliyordu ama aslında biz bu ürünün detaylarını Redis'te önbelleklemiştik! Peki ne olmuştu? Ürünün Redis'teki TTL (Time-To-Live) süresi tam o saniyede dolmuştu. Cache'ten veri silindiği an, kapıda bekleyen 10.000 kullanıcı aynı anda "Cache Miss" alıp doğrudan ana veritabanına hücum etmişti.

İşte o gün, Redis'i sadece koda @Cacheable anotasyonu eklemekten ibaret sanmanın bedelini ağır ödedik.

Bir sistemi tasarlarken "hızlı okumak" madalyonun sadece bir yüzüdür. Asıl zorluk, veritabanı (source of truth) ile önbellek (cache) arasındaki veri tutarlılığını (data consistency) sağlamak ve yüksek trafikte önbellek mimarisinin çökmesini engellemektir. Bu yazıda, Redis üzerinde veriyi yönetirken karşılaştığımız o meşhur "sessiz katilleri" ve Spring Boot ile bu krizleri nasıl mimari düzeyde çözeceğimizi konuşacağız.

Mimari Bakış: Kodun Ötesini Görmek ve Veri Tutarlılığı

Geleneksel bir backend mimarisinde veriyi iki farklı yerde tutmaya başladığınız an (örneğin PostgreSQL ve Redis), "Dual-Write Problem" (Çift Yazma Problemi) ile yüzleşirsiniz. Bir kullanıcının profilini güncellediğini düşünelim. Sistemin yapması gereken iki işlem vardır:

  1. Veritabanını güncelle.
  2. Redis'teki eski veriyi güncelle (veya sil).

Ancak bu iki işlem atomik (tek bir bütün) değildir. Ağırlıklı olarak karşılaşılan kabus senaryosu şudur: Veritabanı başarıyla güncellenir, tam o sırada ağda (network) anlık bir dalgalanma olur ve Redis güncellenemez. Artık veritabanınızda kullanıcının yeni adı "Ahmet", Redis'te ise "Mehmet" olarak kalmıştır. Kullanıcı sayfayı yenilediğinde eski ismini görür ve şikayet kaydı oluşturur.

Bu problemi en aza indirmek için literatürde kabul görmüş temel prensip şudur: "Veriyi güncellerken Cache'i güncelleme, Cache'i doğrudan sil (Invalidate)."

Neden mi? Çünkü cache'i güncellemeye çalışmak (özellikle concurrent/eşzamanlı isteklerde) "Race Condition" (Yarış Durumu) yaratır. İki farklı thread aynı anda güncelleme yapmaya çalıştığında, Redis'te eski verinin kalıcı olma ihtimali çok yüksektir. Oysa veriyi sildiğinizde, bir sonraki okuma isteği (Read) mecburen gidip veritabanından en güncel halini alacak ve Redis'e yazacaktır (Cache-Aside pattern).

Production'daki "Sessiz Katiller" ve Çözümleri

Redis kullanırken altyapınızın ne kadar sağlam olduğunu, her şey yolundayken değil, ani yük (spike) anlarında anlarsınız. İşte backend mimarilerinde sıklıkla karşılaştığımız üç büyük tehlike ve mühendislik yaklaşımları:

1. Cache Penetration (Önbellek Delinmesi)

Sorun: Kötü niyetli bir kullanıcı (veya bir bot), sisteminizde asla var olmayan bir ID ile (örneğin id=-999) sürekli API'nizi çağırır. Veri Redis'te yoktur (Cache Miss). Uygulama gidip veritabanına bakar, orada da yoktur. Veri olmadığı için Redis'e hiçbir şey yazılmaz. Sonuç olarak, o geçersiz ID için gelen her bir istek, Redis'i adeta bir hayalet gibi delip geçer ve doğrudan veritabanını vurur.

Çözüm: En pratik çözüm, veritabanından null bile dönse, bu null değerini Redis'e kısa bir TTL (örneğin 30 saniye) ile yazmaktır (Caching Nulls). Daha büyük ölçekli ve milyarlarca kaydın olduğu sistemlerde ise Bloom Filter algoritmaları kullanılır. Bloom Filter, bir verinin veritabanında olup olmadığını %100'e yakın bir doğrulukla, diske hiç inmeden RAM üzerinde inanılmaz hızlı bir şekilde söyleyebilen veri yapılarıdır.

2. Cache Avalanche (Önbellek Çığı)

Sorun: Bir batch job (gece çalışan bir görev) aracılığıyla 100.000 ürünün bilgisini Redis'e tam 1 saat TTL ile yazdığınızı düşünün. Tam 1 saat sonra, 100.000 ürünün süresi aynı milisaniyede biter. Eğer o an sistemde yüksek bir okuma trafiği varsa, bir anda binlerce sorgu veritabanına yığılır ve veritabanı "çığ altında" kalır.

Çözüm: Asla sabit bir TTL vermeyin. Verileri önbelleğe alırken mutlaka rastgele bir zaman sapması (Jitter) ekleyin. Örneğin hedefiniz 60 dakika ise, 60 dakika + (0-5 dakika arası random bir süre) ayarlayın. Böylece verilerin yaşam ömrü dağılır ve çığ etkisi engellenir.

3. Cache Stampede / Breakdown (Önbellek İzdihamı)

Sorun: Girişte bahsettiğim efsane cuma hikayesindeki problemdir. Sistemin en çok okunan, en popüler ("hot key") verisinin süresi bittiği anda, o veriyi okumak isteyen binlerce thread aynı anda veritabanına koşar.

Çözüm: Bu sorunun en zarif mühendislik çözümü Distributed Lock (Dağıtık Kilit) kullanmaktır. Veri Redis'te yoksa, veritabanına sadece tek bir thread'in gitmesine izin verilir. Diğer thread'ler kapıda bekletilir. İlk giden thread veriyi DB'den alır, Redis'e yazar ve kilidi açar. Bekleyen diğer thread'ler ise uyandıklarında veriyi artık veritabanında değil, Redis'te hazır bulurlar.


Kod Örneği: Spring Boot & Redisson ile İzdihamı Önlemek

Gelin bu Distributed Lock (Dağıtık Kilit) mantığını Spring Boot ortamında, sektör standartlarından biri olan Redisson kütüphanesi ile nasıl implemente edeceğimize bakalım. Amacımız, bir ürünün detayı çekilirken oluşabilecek Cache Stampede etkisini kırmak.

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Random;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    // Redisson, Redis üzerinde atomik kilit (lock) mekanizmaları kurmamızı sağlar.
    private final RedissonClient redissonClient;

    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;

        // 1. Önce Cache'e bakıyoruz (Fast path)
        Product cachedProduct = (Product) redisTemplate.opsForValue().get(cacheKey);
        if (cachedProduct != null) {
            return cachedProduct;
        }

        // 2. CACHE MISS! Veritabanına hücumu engellemek için Lock (Kilit) alıyoruz.
        String lockKey = "lock:product:" + productId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // Lock'ı almayı dene. 10 sn boyunca bekleyebilir, alırsa 3 sn sonra otomatik bırakır (Deadlock önlemi)
            boolean isLocked = lock.tryLock(10, 3, TimeUnit.SECONDS);

            if (isLocked) {
                // LOCK BİZDE! Ancak bizden önce kilidi alan biri veriyi cache'e çoktan yazmış olabilir.
                // Buna "Double-Check Locking" denir. Tekrar cache'e bakıyoruz.
                cachedProduct = (Product) redisTemplate.opsForValue().get(cacheKey);
                if (cachedProduct != null) {
                    return cachedProduct;
                }

                // 3. Veri gerçekten yok, mecburen DB'ye gidiyoruz.
                log.info("DB'den ürün çekiliyor. ID: {}", productId);
                Product dbProduct = productRepository.findById(productId).orElse(null);

                // Cache Penetration Çözümü: Veri DB'de bile yoksa, null değeri kısa süreliğine cache'le
                if (dbProduct == null) {
                    // Null object pattern veya custom bir NullProduct entity dönebiliriz
                    redisTemplate.opsForValue().set(cacheKey, "NULL_VALUE", 30, TimeUnit.SECONDS);
                    return null;
                }

                // Cache Avalanche Çözümü: Sabit TTL yerine Jitter (Rastgele sapma) ekle
                long baseTtl = 3600; // 1 saat
                long jitter = new Random().nextInt(300); // 0-5 dakika arası rastgele saniye

                redisTemplate.opsForValue().set(cacheKey, dbProduct, baseTtl + jitter, TimeUnit.SECONDS);

                return dbProduct;
            } else {
                // Kilidi alamayan diğer thread'ler buraya düşer. DB'ye gitmek yerine biraz bekleyip cache'i tekrar kontrol ederler.
                Thread.sleep(50);
                return getProduct(productId); // Recursive retry
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Redis lock işlemi kesintiye uğradı!", e);
        } finally {
            // İşin bittiyse ve kilit hala sendeyse, kilidi serbest bırak.
            if (lock != null && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

Hata Defteri: Acı Tecrübelerden Çıkarılan Dersler

Yıllar içinde production ortamlarında Redis ile debelenirken çıkardığım, "best practice" olarak duvarıma astığım birkaç kuralı da "Hata Defteri" olarak buraya bırakıyorum:

  • Eviction Policy (Tahliye Politikası) Hayat Kurtarır: Redis'in RAM'i sonsuz değildir. Memory dolduğunda uygulamanızın patlamasını istemiyorsanız, redis.conf dosyasında mutlaka bir eviction kuralı belirleyin. Genellikle allkeys-lru (Least Recently Used - En az kullanılanı sil) veya allkeys-lfu (Least Frequently Used) en güvenli limanlardır. noeviction varsayılan ayardır ve RAM dolduğunda sistemin yazma işlemlerini reddetmesine sebep olur.
  • Aşırı Büyük Objeleri Saklamayın: Redis, single-threaded (tek iş parçacıklı) bir mimariye sahiptir. Cache'e devasa bir JSON (örneğin 5 MB) yazıp okumaya çalışırsanız, o işlem sürerken arkadaki tüm diğer ufak istekleri bloklarsınız. Olay sadece hafıza değil, CPU zamanıdır. Veriyi olabildiğince parçalayarak (veya sadece ihtiyaç duyulan DTO'ları) saklayın.
  • Anahtar İsimlendirme (Key Naming) Standartları: Cache key'lerinizi user123 gibi belirsiz vermek yerine, tenant/domain sınırlarını belirten bir hiyerarşi kurun. Örneğin: app:users:tenant_1:profile:123. Bu yapı, ileride spesifik bir gruba ait verileri kolayca invalidate etmenizi (silmenizi) sağlar.

Son Söz

Redis'i projenize docker run -p 6379:6379 redis yazıp dahil etmek sadece 10 saniyenizi alır. Ancak işin mühendislik kısmı, o veriyi ne kadar süreyle tutacağınız, veritabanı ile nasıl senkronize edeceğiniz ve kriz anlarında sistemin nasıl tepki vereceğini tasarlamaktır.

Unutmayın; iyi bir backend geliştirici ile sıradan bir kod yazarı arasındaki fark, kodun çalışıp çalışmadığında değil, o kodun saniyede 10.000 istek aldığında nasıl davrandığında ortaya çıkar. Önbellekleme stratejileri ve veri tutarlılığı, işte bu farkın en belirgin olduğu cephelerden biridir.

Sizin projelerinizde Redis kullanırken karşılaştığınız en tuhaf "cache" problemi neydi? Yorumlarda o hikayeleri okumayı sabırsızlıkla bekliyorum! 🚀

Tüm Mimari yazıları