Redis’te DEL Neden Tek Başına Yetmez, Cache Stampede ve Güvenli Lock Release
Bilgisayar bilimlerinde sadece iki zor şeyin olduğundan bahseden anekdotu duymuşsunuzdur: “cache invalidation and naming things”. Bugün onlardan biriyle ilgili bir şeyden bahsedeceğim: Cache Stampede ya da türkçesiyle önbellek yığılması.
Not: Yazı boyunca ‘cache’ ve ‘önbellek’ sözcüklerini birbiri yerine kullanacağım.
Peki ama önbellek yığılması ne anlama geliyor?
Standart bir caching mekanizması genelde şu şekilde çalışır: elimize birtakım hesaplamalar yapmamız gereken bir değer gelir, bu hesaplamaları yapmak yerine önce önbelleğe gidip bu değere karşılık düşen bir sonuç olup olmadığına bakarız. Eğer varsa o sonucu kullanırız ve bu çok daha hızlıdır, yoksa ilgili hesaplamayı yapar ve bir sonraki sefer hızlıca ulaşabilmek için sonucu önbelleğe yazarız.
Düşük yük altında hava güneşli ve her şey yolundadır. Peki aynı değer tek bir istekte değil de aynı anda gelen 100 istekte de cache’de olmasaydı ne olurdu? Bu senaryoda cache mekanizmamız işe yaramazdı. 100 isteğin 100’ü de cache miss olur, bizim için daha maliyetli olan kaynağa giderdi. Örneğimizde bu kaynak ClickHouse. Sorgu ise 30 günlük sipariş özetini hesaplayan bir SQL sorgusu.
Peki bu 100 isteğin 100’ünün de ClickHouse’a gitmesi, CPU’yu anlık pik yaptırması gerçekten gerekli mi? Aslında bakarsanız değil. Sadece ilk isteğin veritabanına gitmesi, geri kalan 99’un o sonucu beklemesi yeterli olurdu. Peki neden böyle olmuyor? Çünkü cache-aside pattern’i tek istek için yazılmıştır. 100 goroutine’in hepsi aynı anda cache’e bakar, ‘yok’ görür, hepsi DB’ye gider. Pattern eşzamanlılığı göz önünde bulundurmaz.
func GetSummary(ctx context.Context, customerID string) (Summary, error) {
if cached, ok := cache.Get(customerID); ok {
return cached, nil
}
summary, err := db.Query(ctx, customerID)
if err != nil {
return Summary{}, err
}
cache.Set(customerID, summary, ttl)
return summary, nil
}
O halde eşzamanlı cache miss’lerden sadece birinin veritabanına gitmesini nasıl sağlarız?
Bunu sağlamak için distributed lock isimli bir mekanizma kullanılıyor. Basitçe, cache miss alan her istek veritabanına gitmeden önce Redis’te belirli bir key’i yazmaya çalışır. Bu key’i yazan kazanır ve db’ye gider, cache’i doldurur. Key’i yazamayanlar kaybedenlerdir, beklerler. Teknik olarak Redis’te ayrı bir ‘lock objesi’ yoktur, lock dediğimiz şey belirli bir isimde bir keydir. Bu isim genelde korumak istediğimiz veriden türetilir ki bizim örneğimizde bu lock:monthly:{customerID}:{dateRange} gibi bir stringtir.
Kullanacağımız Redis komutu: SET NX EX yani yoksa yaz, belirli süre sonra sil.
token := uuid.NewString()
acquired, err := rdb.SetNX(ctx, lockKey, token, 20*time.Second).Result()
if err != nil {
return Summary{}, err
}
if acquired {
// kazanan: DB sorgusunu yap, cache'e yaz, release et
} else {
// kaybeden: bekle ve cache'i tekrar yokla
}
Bu komutun atomik olması yani ‘var mı’ kontrolü ile ‘yoksa yaz’ işlemlerinin tek bir işlem gibi bölünemez şekilde çalıştırılması önemli. Bu sayede araya başka işlemler giremiyor. Eğer atomik olmasa ve iki ayrı komut gibi çalıştırılsalardı, 100 goroutine de GET sonucunda bir key olmadığını görüp SET çalıştırmaya kalkardı ve pattern hiçbir şeyi engelleyememiş olurdu. Atomiklik sayesinde sadece biri başarılı olur, diğer 99’u SetNX’ten false alır yani kaybeden olduklarını öğrenirler.
Buraya kadar lock mekanizmasını kurduk ama ortada hala bir belirsizlik var: lock alamayanlar yani kaybedenler ne yapacak?
Aklınızdan geçenin “bir süre bekleyip kazanan process cache’i doldurduktan sonra cache’den okumayı tekrar denemek” olduğunu biliyorum. Burada karşımıza bir soru çıkacak: nasıl bekleyecekler? Ne kadar bekleyecekler değil. Çünkü eğer 99 kaybedenin hepsi sabit 100ms bekleyip aynı anda cache’i yoklarsa ve kazanan hala sonucu yazmadıysa bu 99 istek yine cache miss alıp ikinci bir stampede oluşturur. Yani lock mekanizması yalnızca ilk stampede’ini engeller. Retry aşaması yeni bir eşzamanlılık üretirse işe yaramaz. Sadece sorunun yeri değişmiş olur.
Çözüm retry aşamasında da oluşan eşzamanlılığı kırmaktan yani bekleme süresini rastgeleleştirmekten geçiyor. Her kaybeden örneğin 50-150ms arasında rastgele bir süre beklerse cache’e dönüş anları eşit olarak dağılmış olur. Bu rastgele sapmaya sinyal işlemeden ödünç aldığımız bir terimle jitter diyoruz.
Jitter yok: ████████████ (hepsi t = 100ms'de çakışır)
Jitter var: █ █ █ █ █ (50-150ms arasında dağılmış)
for _ = range maxRetries {
backoff := 50*time.Millisecond +
time.Duration(rand.Intn(100))*time.Millisecond
time.Sleep(backoff)
if cached, ok := cache.Get(ctx, customerID); ok {
return cached, nil
}
}
// tüm retry'lar tükendi, DB'ye düş
Burada pattern neredeyse tamamlanmış durumda. Atomik bir lock ile kazanan seçtik, jittered retry ile kaybedenleri zamana yaydık. Geriye tek ve kritik bir şey kaldı: lock release yani kilidi serbest bırakma işi.
İlk akla gelen işimiz bittiğinde lock’u release etmek için ilgili kod bloğunda defer ile DEL çağırmak. Tek bir istek için kusursuz çalışır: lock alınır, iş bitince silinir. Fakat yazının başından beri bahsettiğimiz eşzamanlılık burada da karşımıza çıkıyor. Tetiklenmesi için spesifik bir zamanlama gerekse de production’da gayet yaşanabilir.
Farz edelim ki Lock TTL’i 1 saniye. Kazanan ClickHouse sorgusu beklenenden uzun sürdü ve 2 saniyede çalıştı. Şu adımlar sırasıyla yaşanır:
t = 0s Goroutine A, SetNX ile lock:foo anahtarını alır. TTL = 1s.
t = 1s TTL bitti, Redis lock:foo'yu otomatik siler.
t = 1.1s Goroutine B, SetNX ile lock:foo'yu alır (yeni sahibi B).
t = 2s Goroutine A'nın DB sorgusu nihayet biter.
defer çalışır: rdb.Del("lock:foo").
Ama bu kilit artık B'nin! A, B'nin kilidini silmiş oldu.
t = 2.1s Goroutine C, SetNX ile lock:foo'yu alır. B hâlâ çalışıyor.
Artık aynı kaynağa iki goroutine erişiyor. Lock mekanizması çöktü.
Lock’un kime ait olduğunu kontrol etmeden, bir process'in her zaman için kendi lock’unu sileceğini varsayarak DEL komutunu kullandık ama TTL yüzünden expire olmuş ve yeni sahibine geçmiş bir lock bu varsayımımızı çürüttü.
Çözüm fikri sezgisel olarak bellidir. Lock’a bir kimlik vermek. Basitçe lock alırken bir unique token yazmak ve release esnasında bu token'a ait lock’u ,eğer hala expire olmamışsa, silmektir. Redis’te bunu yapmak için hazır bir komut yok. Peki biz bu iş için GET ile token kontrol edip sonra DEL çağırsak olmaz mı? Olmaz, çünkü daha önce bahsettiğimiz atomiklik meselesi yine devreye giriyor. Eğer bu işlem iki ayrı komut gibi çalışırsa aradaki küçük zaman diliminde yine TTL expire olabilir, başkası yeni lock'u almış olabilir, biz de onun lock’unu silmiş olabiliriz vb. Aynı bug farklı bir şekilde yeniden ortaya çıkmış olur. Redis’te birden fazla komutu tek bir atomik birim olarak çalıştırmak için Lua script’i kullanılıyor. Redis sunucusu Lua script’inin içindeki komutları hepsini tek bir işlem gibi yürütüyor. Aşağıdaki dört satırla lock release için dünyadaki en kısa ve en güvenli çözümü yazmış olacağız.
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
var releaseLockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)
func ReleaseLock(ctx context.Context, lockKey, token string) error {
return releaseLockScript.Run(ctx, rdb, []string{lockKey}, token).Err()
}
Pattern teoride tamamlandı. Ama production’da parametre seçimi de pattern kadar önemli. En kritik olanı da lock TTL’inin süresi. Eğer çok kısa tutarsak yukarıdaki senaryonun, yani TTL’in expire olup işin hala sürüyor olması durumu artar. Çok uzun tutarsak da lock sahibi olan process crash ettiğinde sistem o süre boyunca her isteği DB’ye götürür. Dengeli bir seçim için lock TTL’i p99 sorgu süresinden belirgin şekilde uzun olmalı ama bir crash sonrası DB fallback’ini tolere edebileceğin süreden uzun olmamalıdır. Query timeout’un iki katına yakın bir değerle iyi bir başlangıç yapabiliriz. Sorgumuz 3 saniyede timeout olacaksa, lock 5 saniye kadar yaşasın. Order summary service örneğimizde bu rakamlar CLICKHOUSE_TIMEOUT_SECONDS=3 ve LOCK_TTL_SECONDS=5.
Sonuç olarak cache stampede aslında teknolojiden çok desene ait bir problem. Redis, ZooKeeper, etcd, hatta PostgreSQL advisory lock, hepsi benzer bir çözümü farklı isimlerle sağlıyor. Değişmeyense üç şey var: atomik bir compare-and-set (lock almak için), TTL (sahibi crash ederse kurtulmak için) ve atomik bir compare-and-delete (lock’u güvenle bırakmak için). Bu üç primitive'i veren bir sistemin üstüne doğru desenle stampede'i her zaman çözebiliriz.
---
Bu yazıdaki pattern'in tam implementasyonunu da veren bir sipariş özet sisteminin kodlarına order-summary-service reposundan ulaşabilirsiniz. İnceleyip geri bildirim vermek isterseniz issue veya PR açmaktan çekinmeyin.