<

Cache Stampede: Kampanya Anında Redis'i Diz Çöktüren 30 Saniye

14 Nisan akşamı, saat tam 20:00'de bahar kampanyamız başladı. Ana sayfaya push bildirimi gitti, trafik altı katına çıktı ve 20:00:07'de checkout servisimizin p99 gecikmesi 80 milisaniyeden 4,2 saniyeye fırladı. Grafana'da Redis paneline baktığımda gördüğüm şey mantığa aykırıydı: cache hit oranımız %99,7'den %91'e düşmüştü ama Redis CPU'su gayet sakindi. Asıl yangın MySQL tarafındaydı; saniyede 38 bin sorgu, connection pool'lar dolu, sorgular kuyrukta. Otuz saniye sonra her şey kendiliğinden normale döndü. O otuz saniyede sepete ekleme oranımız %72 düştü ve kaba hesapla yedi haneli bir ciro anlık olarak buharlaştı. Bu yazı, o otuz saniyenin otopsisi.

Suçlu Sandığımız Üç Şüpheli

İlk refleksimiz klasikti: bot saldırısı. Kampanya anları scalper botların bayramıdır, WAF loglarını açtık. Trafiğin coğrafi ve davranışsal dağılımı organikti; gerçek insanlar, gerçek telefonlarla push bildirimine tıklamıştı. İkinci şüpheli connection pool ayarlarıydı. Pool'u büyütsek sorun çözülür mü diye staging'de denedik; hayır, sadece MySQL'in üstüne daha kalabalık bir güruh gönderiyorduk. Üçüncü şüpheli Redis'in kendisiydi: "Belki node'lardan biri failover yaşadı" dedik. Redis loglarında tek satır anormallik yoktu. Redis çalışıyordu; sorun bizim onu kullanma şeklimizdeydi.

War room'da asıl kırılma, olayı saniye saniye yeniden kurduğumuz anda yaşandı. Load balancer loglarını, Redis'in keyspace miss istatistiklerini ve MySQL slow query logunu aynı zaman eksenine dizdik. Tablo netti: 20:04:59.2'de tek bir anahtarın miss sayısı sıfırdan on binlere sıçramış, 20:05:00 ile 20:05:29 arasında aynı SQL cümlesi 11.800 kez çalıştırılmış ve bu sorguların %96'sı timeout ile ölmüştü. Yani veritabanı 30 saniye boyunca aynı cevabı 11.800 kez üretmeye çalışmıştı. Postmortem dokümanına o cümleyi aynen yazdık, çünkü problemi bundan iyi özetleyen bir şey yok: aynı sorunun cevabını binlerce kez aramak, cevabı hiç bulamamanın en garantili yoludur.

Aynı Anahtara Koşan On İki Bin İstek

Gerçek sebebi access loglarını saniye bazında gruplandırınca gördük. Kampanya vitrinini besleyen tek bir anahtar vardı: kampanya ürün listesi, 300 saniye TTL ile cache'lenmişti. Kampanya 20:00'de başladığı için bu anahtar 19:59'da ısıtılmış, 20:04:59'da da hep birlikte ölmüştü. Anahtarın öldüğü o milisaniyede vitrin endpoint'ine saniyede 12 binin üzerinde istek geliyordu. Hepsi cache miss aldı, hepsi "veriyi ben üreteyim" dedi ve hepsi birden aynı 900 milisaniyelik aggregate sorgusunu MySQL'e gönderdi. Buna literatürde cache stampede ya da dog-piling deniyor: cache'in koruduğu kaynak, cache'in yenilenme anında korumasız kalıyor ve normalde göğüsleyeceği yükün yüzlerce katını tek seferde yiyor.

İşin sinsi tarafı şu: hit oranı %99,7 olan bir cache size güvende olduğunuzu söyler. Ama o %0,3'lük miss'in zamana yayılıp yayılmadığını kimse sormaz. Bizim miss'lerimiz yayılmıyordu; beş dakikada bir, hepsi aynı milisaniyede geliyordu. MySQL 30 saniye boyunca diz çöktü, sorgular timeout aldı, timeout alan uygulama pod'ları retry yaptı ve retry'lar yangına benzin taşıdı. Sistem ancak ilk başarılı sorgu cache'i doldurunca nefes alabildi.

Retry katmanı bu hikâyede masum bir figüran değil, suç ortağıydı. Vitrin servisimizin HTTP client'ında iki denemeli, bekleme süresi sabit bir retry politikası vardı; timeout alan her istek 200 milisaniye sonra aynısını tekrar deniyordu. Yani 12 bin isteklik dalga, veritabanı gözünden 30 binlik bir dalgaya büyümüştü. Olaydan sonra bütün servislerde retry'ları exponential backoff artı jitter'a çevirdik ve vitrin sorgusunun önüne bir circuit breaker koyduk: hata oranı eşiği aşınca devre açılıyor, istekler veritabanına hiç gitmeden önceden hazırlanmış statik bir kampanya listesine düşüyor. Devre açıkken müşteri kişiselleştirilmemiş ama dolu bir vitrin görüyor; boş sayfa asla.

Kilit, Zar ve Bayat Veri

Çözümü üç katman halinde kurduk. İlk katman kilitli yenileme: bir anahtar miss aldığında her pod veritabanına koşmuyor; önce Redis'te SET lock:kampanya NX PX 3000 ile üç saniyelik bir kilit almayı deniyor. Kilidi alan tek pod veriyi üretip cache'e yazıyor, alamayanlar 50'şer milisaniye aralıkla cache'i tekrar yokluyor. On iki bin eşzamanlı istek, veritabanı gözünden tek isteğe indi.

İkinci katman, kilide hiç gerek bırakmamak üzerine: probabilistic early expiration. XFetch diye bilinen yaklaşımı uyguladık; cache'e değeri yazarken yanına üretim maliyetini (delta, bizde 900 ms) ve yazılma zamanını da koyuyoruz. Her okuyan istemci şimdi - delta * beta * log(rand()) formülüyle küçük bir zar atıyor ve TTL dolmadan kısa süre önce, istemcilerden biri olasılıksal olarak "bu değeri ben tazeleyeyim" diyor. Anahtar hiçbir zaman gerçekten expire olmuyor; yenilenme anı istatistiksel olarak öne çekilip tekilleştiriliyor. Sıcak anahtarlar için bu, kilitten bile zarif bir çözüm çünkü bekleme diye bir kavram kalmıyor.

Üçüncü katman bayat veriye tahammül: stale-while-revalidate. Değerin içine mantıksal bir TTL gömdük, fiziksel Redis TTL'ini bunun iki katına çektik. Mantıksal süre dolduğunda istemci bayat veriyi anında dönüyor, tazelemeyi arka planda tek bir iş yapıyor. Bir kampanya vitrini için 20 saniye bayat liste göstermek, 4 saniyelik boş sayfa göstermekten ölçülebilir biçimde iyidir; A/B testinde bunu rakamla da doğruladık.

Redis'in önüne bir de süreç içi L1 cache koyduk: her pod, en sıcak 500 anahtarı kendi belleğinde 5 saniyelik mikro TTL ile tutuyor. Bu katman stampede'e doğrudan çare değil ama Redis'e giden okuma trafiğini %64 azaltarak ağdaki her türlü türbülansa tampon oluşturuyor. Tutarlılık için anahtarlar güncellendiğinde Redis pub/sub üzerinden bir invalidation mesajı yayınlanıyor ve pod'lar kendi kopyalarını düşürüyor. Beş saniyelik bir vitrin bayatlığının ürün tarafında hiçbir metriğe dokunmadığını, ölçmeden kabul etmedik; kültür haline gelen alışkanlık bu oldu.

TTL'e Jitter, Kampanyaya Prova

Bunların yanına iki operasyonel alışkanlık ekledik. Birincisi, aynı anda yazılan anahtar ailelerine rastgele ±%15 jitter vermek; deploy sonrası veya kampanya açılışında toplu ısıtılan yüzlerce anahtarın beş dakika sonra senkron ölmesini bu kadar basit bir dokunuş engelliyor. İkincisi, kampanya öncesi cache warmup'ı bir job'a bağlamak ve load testinde TTL'leri bilerek kısaltıp stampede senaryosunu provaya dahil etmek. Mayıs kampanyasında aynı trafik profiliyle p99 gecikmemiz 110 milisaniyeyi hiç aşmadı; MySQL'de vitrin sorgusu saniyede 2'nin altında seyretti. On iki binden ikiye.

İzleme tarafına da bu olaya özel bir metrik ekledik: anahtar başına eşzamanlı miss sayısı. Uygulama, bir anahtarda üretim başlattığında kaç isteğin aynı üretimi beklediğini histogram olarak yayıyor; Grafana'da bu histogramın p99'u 5'i aştığında düşük öncelikli bir uyarı düşüyor. Bu, stampede'in erken öksürüğü: felakete dönüşmeden haftalar önce hangi anahtarın riskli olduğunu gösteriyor. Nitekim iki ay sonra bu uyarı sayesinde, kimsenin sıcak olduğunu düşünmediği bir kargo ücreti konfigürasyon anahtarını yakaladık; kampanyadan önce probabilistic yenilemeye aldık ve o cephede hiç haber olmadı. En iyi incident, grafiğine kimsenin zoom yapmak zorunda kalmadığıdır.

Hit Oranı Bir Ortalama, Felaket Bir An

Bu vakadan aklımda kalan ders şu: cache metriklerinin neredeyse tamamı ortalamadır, ama sistemleri öldüren şey ortalamalar değil, eşzamanlılıktır. "Hit oranımız yüksek" cümlesi, miss'lerin zamansal dağılımı hakkında hiçbir şey söylemez. Tasarım toplantılarında artık soruyu şöyle soruyoruz: "Bu anahtar öldüğü milisaniyede kaç istek onu bekliyor olacak ve o isteklerin kaçı veritabanına dokunacak?" Cevap birden büyükse o cache tasarımı eksiktir. Kilit mi, olasılıksal erken tazeleme mi, bayat veri mi kullanacağınız veriye ve ürüne bağlı; ama "hiçbiri" artık bizim için geçerli bir cevap değil.

Benzer bir kampanya anı çöküşünü yaşadıysanız ya da vitrininiz her beş dakikada bir gizlice titriyorsa, iletişim sayfasından yazın; loglarınızdaki o otuz saniyeyi birlikte okuyalım.

📅 Yayınlanma:  ·  Yakup Zengin