Önbellek geçersiz kılma: iki zor problemden biri
TTL vermek bir strateji değil, gecikmeli bir özür. Yazmada silme, sürüm sayacı, etiket kümesi ve stampede kilidi — hangisini ne zaman seçmeli, çalışan kod örnekleriyle.
Önbellek geçersiz kılma sorununun zor olmasının sebebi algoritma değil. Zorluk, "bu veri artık eski" bilgisinin sistemin yanlış katmanında doğması. Veri değişikliği veritabanında olur, önbellek ise ondan habersiz başka bir yerdedir ve ikisini bağlayan şey her zaman elle yazdığınız bir satırdır. O satırı yazmayı unuttuğunuz gün hata sessizce başlar.
Bu yazı tek bir soruya cevap veriyor: bir yazma işleminden sonra önbelleği ne zaman, nasıl geçersiz kılmalı?
TTL bir strateji değil, gecikmeli özür
En yaygın yaklaşım anahtara bir yaşam süresi vermek:
await redis.set(`urun:${id}`, JSON.stringify(urun), { EX: 300 })
Bu kod hiçbir zaman yanlış veri döndürmemeyi garanti etmez. Yalnızca yanlış verinin en fazla beş dakika yaşayacağını söyler. Fiyat listesi için beş dakikalık eskilik kabul edilebilir; yetki kontrolü için değildir.
TTL'in gerçek işlevi doğruluk değil, sızıntı kontrolü: geçersiz kılmayı unuttuğunuz anahtarların sonsuza kadar yaşamasını engeller. Ana strateji olarak değil, ağ olarak kullanın.
Yazmada silmek doğru refleks, ama sırası önemli
İkinci yaklaşım, veri değiştiğinde anahtarı silmek. Doğru olan sıra şu:
async function urunGuncelle(id, alanlar) {
await db.urun.update({ where: { id }, data: alanlar })
await redis.del(`urun:${id}`) // önce yaz, sonra sil
}
Ters sırayı — önce sil, sonra yaz — seçerseniz aradaki pencerede gelen bir okuma, veritabanından eski değeri okur ve önbelleğe geri yazar. Yazma biter, önbellekte eski veri kalır ve TTL dolana kadar orada durur. Bu hata üretimde günlerce fark edilmez çünkü tek bir kaydı etkiler.
Önce-yaz-sonra-sil de kusursuz değil; silme işlemi başarısız olursa yine eski veri kalır. Silmeyi yazma işlemiyle aynı hata yoluna bağlayın, sessizce yutmayın:
try {
await redis.del(`urun:${id}`)
} catch (e) {
logger.error({ e, id }, 'onbellek silinemedi')
metrics.increment('cache.invalidate.failed')
}
Bu catch bloğu sorunu çözmez ama sorunu görünür kılar. Sessiz başarısızlık, önbellek hatalarının teşhisini haftalara yayan şeydir.
Silmek yerine sürüm sayacı
Tek anahtar silmek kolay, ilgili on anahtarı silmek zordur. Bir ürün değiştiğinde urun:42, kategori:5:liste, anasayfa:vitrin anahtarlarının hepsi eskir. Hepsini elle silmeye çalışmak, listeyi güncellemeyi unutmakla biter.
Alternatif: anahtarı silmek yerine anahtar adını sürümlemek.
async function anahtar(kapsam, id) {
const s = await redis.get(`surum:${kapsam}`) ?? '0'
return `${kapsam}:v${s}:${id}`
}
async function gecersizKil(kapsam) {
await redis.incr(`surum:${kapsam}`) // tek işlem, tüm kapsam eskir
}
INCR çağrıldığı anda o kapsamdaki bütün anahtar adları erişilemez hale gelir. Eski anahtarlar diskte kalır ama kimse onları istemez; TTL zamanla temizler. Bu yöntemin bedeli bellek, kazancı ise doğruluk — ve bir listeyi güncellemeyi unutma ihtimalinin ortadan kalkması.
Etiketleme, sürümlemenin daha ince ayarlı hali
Redis'te doğrudan etiket desteği yok, ama bir küme ile taklit edilebilir:
async function etiketle(etiket, anahtar) {
await redis.sAdd(`etiket:${etiket}`, anahtar)
}
async function etiketiTemizle(etiket) {
const anahtarlar = await redis.sMembers(`etiket:${etiket}`)
if (anahtarlar.length) await redis.del(anahtarlar)
await redis.del(`etiket:${etiket}`)
}
Bu yapı sürümlemeden daha isabetli çalışır: yalnızca gerçekten etkilenen anahtarlar düşer. Bedeli, her önbellek yazımında ikinci bir yazma işlemi ve kümenin kendisinin büyümesi. Kümeye de TTL vermeyi unutmayın, yoksa silinmiş anahtarların adları orada birikir.
Aynı anda boşalan önbellek: stampede
Geçersiz kılmanın az konuşulan yan etkisi, popüler bir anahtarın düştüğü anda onlarca isteğin aynı anda veritabanına gitmesi. Çözüm, ilk isteğin kilidi almasıdır:
async function getir(anahtar, uret) {
const v = await redis.get(anahtar)
if (v) return JSON.parse(v)
const kilit = await redis.set(`kilit:${anahtar}`, '1', { NX: true, PX: 5000 })
if (!kilit) {
await new Promise(r => setTimeout(r, 50))
return getir(anahtar, uret) // kısa bekle, tekrar dene
}
try {
const taze = await uret()
await redis.set(anahtar, JSON.stringify(taze), { EX: 300 })
return taze
} finally {
await redis.del(`kilit:${anahtar}`)
}
}
PX: 5000 burada kritik: kilidi alan süreç çökerse kilit beş saniye içinde kendiliğinden düşer. Süresiz kilit, önbellek sorununu kilitlenme sorununa çevirir.
Süreç içi önbellek, geçersiz kılmanın kör noktası
Redis'i doğru kurup sorunu çözdüğünü sanan ekiplerin çoğu bir katmanı atlıyor: uygulama sürecinin kendi belleğindeki Map. Performans için eklenen o küçük yerel önbellek, Redis'e gönderilen DEL komutundan haberdar değil. Sekiz kopya çalışıyorsa, sekiz ayrı eski kopyanız var demektir ve hangisinin cevap vereceği yük dengeleyicinin keyfine kalmış durumda.
Belirti tanıdıktır: sayfayı yenileyince veri bazen güncel geliyor, bazen eski. Yerel önbellek kullanacaksanız ya süresini saniyelerle sınırlayın ya da geçersiz kılmayı bir pub/sub kanalıyla tüm kopyalara yayın. Ortası yok — "sadece küçük bir Map" diyerek eklenen katman, sistemin en zor teşhis edilen tutarsızlık kaynağı.
Karar sırası
Uygulamada şu sırayla ilerlemek işe yarıyor:
Veri bayatladığında ne olur? Cevap "hiçbir şey" ise TTL yeterli, daha ileri gitmeyin. Cevap "kullanıcı yanlış fiyat görür" ise yazmada geçersiz kılın.
Bir değişiklik kaç anahtarı etkiliyor? Bir tanesini etkiliyorsa DEL yeterli. Birden fazlasını etkiliyorsa ve liste zamanla büyüyecekse, sürüm sayacı ya da etiket kümesi kurun — çünkü büyüyen listeyi elle güncellemeyi er ya da geç unutursunuz.
Anahtar ne kadar sıcak? Saniyede yüzlerce istek alan bir anahtar için kilit yazın. Günde birkaç kez okunan anahtar için kilit gereksiz karmaşıklıktır.
Son olarak: geçersiz kılma başarısızlıklarını ölçün. Ölçmediğiniz sürece önbelleğinizin doğru çalıştığını değil, kimsenin şikâyet etmediğini biliyorsunuz.
- önbellek
- redis
- performans
- dağıtık sistemler