Redis Cache Eklerken Yaptığım Üç Hata
Stampede, TTL ve invalidation aynı anda kırılınca

İlk bakışta problem basitti: ürün detay endpoint'i her istekte aynı sorguyu çalıştırıyordu. Redis'i araya koyunca p95 düştü, dashboard yeşil kaldı, ben de işi kapattım. Ertesi sabah kampanya trafiği gelince aynı endpoint hem Redis'i hem Postgres'i hem de kendi kendini kilitledi.
Hata 'Redis yavaş' değildi. Üç ayrı varsayımı aynı anda yanlış kurmuştum: cache miss'lerin tek tek doldurulması, TTL'nin her key için aynı olması, invalidation'ın 'key silmek' sanılması. Bu yazı o üç varsayımı ayırıyor.
1. Cache stampede'i 'bir kere daha dene' sanmak
TTL dolunca on bin istek aynı anda miss gördü. Her biri veritabanına indi, her biri aynı JSON'u tekrar yazmaya çalıştı. Redis CPU'su yükseldi, connection pool doldu, asıl sorgu ikinci kez yavaşladı. Retry eklemek bunu büyüttü; çünkü retry da aynı miss yolunu izliyordu.
Tek instance'da lock işe yarıyor gibi duruyor. İki pod olunca bellekteki kilit görünmez. Semaforu Redis'e taşımak stampede'i durdurur; her key için doğru olduğu anlamına gelmez. Sıcak key'lerde lock, soğuk key'lerde doğrudan load daha ucuz.
public async Task GetAsync(string id, CancellationToken ct)
{
var cached = await cache.GetStringAsync(id, ct);
if (cached is not null)
return JsonSerializer.Deserialize(cached);
var gate = await cache.LockTakeAsync($"lock:{id}", TimeSpan.FromSeconds(5), ct);
try
{
cached = await cache.GetStringAsync(id, ct);
if (cached is not null)
return JsonSerializer.Deserialize(cached);
var product = await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(ct);
if (product is not null)
await cache.SetStringAsync(id, JsonSerializer.Serialize(product), ttl, ct);
return product;
}
finally
{
if (gate) await cache.LockReleaseAsync($"lock:{id}", ct);
}
}
Bu kod 'her miss'te kilitle' demiyor. Çift kontrol var: lock aldıktan sonra cache'e bir kez daha bakıyorum. İlk istek yükler, sonrakiler kilitten sonra hit görür. Lock süresini 30 saniye yapmak ayrı bir hata; kilit kalan istekleri kuyruğa yığar.
2. TTL'yi tek sayı sanmak
Hepsine 10 dakika vermiştim. Katalog gecesi boyunca aynı anda drop oldu. Jitter ekleyince drop yayıldı; ama asıl mesele jitter değil, veri sınıfı. Fiyat 30 saniye, açıklama 30 dakika, stok belki hiç TTL değil — invalidation ile gidiyor.
Kör TTL, invalidation yazmayı ertelemenin bahanesine dönüşüyor. 'Nasıl olsa 10 dakikada düşer' cümlesi, admin panelinden fiyat değiştiren biri için 10 dakikalık yanlış fiyat demek. Kampanyada bu süre uzun, gece düşük trafikte ise gereksiz yere DB'ye inmek kısa.
3. Silmeyi invalidation sanmak
Ürün güncellenince DEL product:{id} yetiyor gibi duruyor. Liste endpoint'i product:list:home altında duruyorsa o key duruyor. Tag veya prefix olmadan silmek, okuyan endpoint sayısı kadar unutulan yer bırakır.
- Tek kayıt key'i: güncellemede silinebilir.
- Liste ve arama key'leri: kayıt değişince onlar da eski kalır.
- Aggregate (sayım, facet): silinmezse filtre yalan söyler.
Version'lı key ({id}:{updatedAt}) bazı yerlerde daha dürüst. Eski key TTL ile ölür, yeni key miss ile dolar. Bellek biraz şişer; yanlış veri süresi kısalır. Her domain'de değil, özellikle liste+detay çiftinde işe yarıyor.
Ne zaman cache aside durmalı
Bu üç hatayı düzeltmek cache aside'ı 'doğru' yapmaz. Aside, okuma ağırlıklı ve biraz eski verinin kabul edildiği yerde durur. Yazma anında kesinlikle taze olması gereken stok için aside tek başına zayıf. Orada yazma yolunu kaynak, okuma yolunu cache yapmak yetmez; yazanın cache'i de bilmesi gerekir.
Ölçmeden 'Redis ekledik' demeyi bıraktım. Hit oranı, miss'te DB süresi, lock bekleme süresi. Bu üçü olmadan p95'in düşmesi tesadüf olabilir. Tesadüf de ilk kampanyada biter.
Yorumlar
Yorumlar (0)
Yorumlar üyelere açık. Üye ol · Giriş yap