Cache Stampede, Penetration ve Avalanche: Üretimde Cache'i Çökerten Üç Sorun
Üç sorun da aynı yükü veritabanına indirir ama çözümleri ayrı: 500 VU'lu k6 koşusunda stampede'i HybridCache 223 sorgudan 3'e, Redis kilidi 1'e indirdi; benzersiz ISBN saldırısında negative caching 49.980 sorguyu durduramadı, ISBN doğrulaması ve Bloom filter 9'a indirdi; %20 TTL jitter'ı avalanche tepesini 1.627'den 247 sorgu/sn'ye çekti; Redis düşünce FailFast ve kısa timeout p95'i 6 sn'den 7,3 ms'ye indirdi.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; örnekler Entity Framework Core (EF Core) 10.0.14 (SQLite), Microsoft.Extensions.Caching.Hybrid 10.11.0, StackExchange.Redis 3.4.31, Redis 8.2 ve k6 2.3 ile koşturuldu.
Önce Tahmin Et
Bookstore'un API'si (Application Programming Interface) üç instance'ta çalışıyor ve hepsi aynı Redis'i L2 (level 2, process dışı ortak cache) olarak kullanıyor. Kategori satış raporu HybridCache ile cache'leniyor. Bir deploy sonrası cache boş ve k6 ile 500 VU (virtual user, sanal kullanıcı) aynı anda rapor endpoint'ine toplam 2.000 istek atıyor. Rapor sorgusu veritabanında kaç kez çalışır?
İkinci soru: kitap detayı ISBN (International Standard Book Number) ile okunuyor ve HybridCache.GetOrCreateAsync bulunamayan kitap için null dönüyor. Bir bot, biçimi geçerli ama veritabanında olmayan ISBN'lerle 50.000 istek atıyor. Bot hep aynı 5.000 ISBN'i dönerse veritabanına kaç sorgu gider? Her istekte yeni bir ISBN üretirse kaç?
Üç Sorun, Üç Ayrı Mekanizma
Üçü de aynı sonucu doğuruyor, cache'in koruduğu yük bir anda veritabanına iniyor, ama sebepleri farklı. Stampede, tek bir popüler key'in süresi dolunca onu bekleyen bütün isteklerin aynı anda factory'yi çalıştırması. Penetration, hiç var olmayan key'lerin cache'i delip geçmesi: cache yalnız bulunanı tuttuğu için her istek veritabanına gider. Avalanche ise çok sayıda key'in aynı anda ölmesi; ya aynı saniyede aynı TTL (Time to Live) ile yazıldıkları için ya da cache sunucusu düştüğü için.
Stampede'in teorisini, yani request coalescing'i, TTL'e jitter eklemeyi ve XFetch ile olasılıksal erken yenilemeyi System Design serisinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısı simülasyon sayılarıyla anlattı; burada tekrar etmeyeceğiz. Bu yazı aynı sorunları .NET'te gerçek bir Redis ve k6 ile ölçüyor. Sorguları serinin ilk yazısındaki EF Core interceptor'ı sayıyor, her instance kendi sayısını /stats'tan veriyor. k6'nın kendisi ve senaryo tasarımı ilerideki Performance ve Load Testing serisinin konusu.
Stampede: Instance İçinde Coalescing
Önce Web API serisinin "ASP.NET Core Web API'de Caching: IMemoryCache, Redis ve Response Caching" yazısındaki klasik cache-aside'ı Redis üzerinde yazalım. Rapor sorgusu 50.000 kitabı kategoriye göre toplayan bir GROUP BY, tek başına 15 ms civarında sürüyor:
app.MapGet("/api/reports/category-sales/naive", async (CatalogDb db, IDistributedCache cache, CancellationToken ct) =>
{
string? json = await cache.GetStringAsync("v1:report:category-sales:naive", ct);
if (json is not null) return JsonSerializer.Deserialize<List<CategorySales>>(json)!;
List<CategorySales> report = await Reports.CategorySalesAsync(db, ct);
await cache.SetStringAsync("v1:report:category-sales:naive", JsonSerializer.Serialize(report),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct);
return report;
});
app.MapGet("/api/reports/category-sales", (CatalogDb db, HybridCache cache, CancellationToken ct) =>
cache.GetOrCreateAsync("v1:report:category-sales",
async token => await Reports.CategorySalesAsync(db, token), reportOptions, cancellationToken: ct));
k6 senaryosu soğuk cache'i taklit ediyor: 500 VU aynı anda başlıyor ve istekleri üç instance'a sırayla dağıtıyor:
import http from 'k6/http';
import { summaryLine } from './summary.js';
// soğuk cache: 500 VU aynı anda aynı raporu istiyor, istekler 3 instance'a dağılıyor
export const options = {
scenarios: { cold: { executor: 'shared-iterations', vus: 500, iterations: 2000, maxDuration: '120s' } },
};
const hosts = __ENV.HOSTS.split(',');
export default function () {
http.get(`http://${hosts[__VU % hosts.length]}${__ENV.ENDPOINT}`);
}
export function handleSummary(data) { return { stdout: summaryLine(data) }; }
run_stampede.sh her koşudan önce Redis'i FLUSHALL ile boşaltıyor, üç instance'ı yeniden başlatıyor (böylece L1 (level 1, process içi cache) de boş), k6'yı grafana/k6 container'ında çalıştırıyor ve sonunda her instance'ın sorgu sayısını topluyor:
$ ./run_stampede.sh /api/reports/category-sales/naive
reqs=2000 rps=869 p50=89.6ms p95=1584.4ms failed=0.00%
instance 17540 queries=89
instance 17541 queries=79
instance 17542 queries=55
total queries=223
$ ./run_stampede.sh /api/reports/category-sales
reqs=2000 rps=6923 p50=30.5ms p95=194.1ms failed=0.00%
instance 17540 queries=1
instance 17541 queries=1
instance 17542 queries=1
total queries=3
Klasik cache-aside'da 2.000 isteğin 223'ü cache'i boş bulup raporu kendisi hesapladı; aynı komutun ikinci koşusunda bu sayı 117 çıktı, çünkü kaç isteğin aynı pencereye düştüğü VU'ların ne kadar eşzamanlı başladığına bağlı. HybridCache ile sayı 3: Microsoft Learn'ün yazdığı gibi factory'yi aynı key için instance başına yalnız bir çağrı çalıştırıyor, diğerleri onun sonucunu bekliyor. İlk tahminin cevabı bu yüzden 1 değil 3; on instance'lı bir dağıtımda aynı anda on rapor hesaplanır.
Output cache'te aynı korumanın adı resource locking ve varsayılan olarak açık (SetLocking(false) kapatır); ayrıntısı serinin altıncı yazısında. O da yalnız kendi instance'ı içinde koruyor.
Instance'lar Arasında Tek Hesaplama: Redis Kilidi
Rapor pahalıysa (saniyeler süren bir aggregation, dış bir servise ücretli çağrı) instance başına bir hesaplama da fazla. Çözüm, factory'nin önüne instance'lar arası bir kilit koymak: kilidi alan hesaplar ve sonucu Redis'e yazar, alamayanlar sonucu bekler. StackExchange.Redis'in LockTakeAsync ve LockReleaseAsync method'ları bunu zaten SET NX ve token karşılaştırmalı silme ile yapıyor:
// Aynı key için bütün instance'larda tek bir yeniden hesaplama: kazanan hesaplar, diğerleri sonucu bekler
public sealed class RedisSingleFlight(IConnectionMultiplexer redis, ILogger<RedisSingleFlight> logger)
{
private static readonly TimeSpan LockTtl = TimeSpan.FromSeconds(10);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan MaxWait = TimeSpan.FromSeconds(3);
public async Task<T> GetOrComputeAsync<T>(
string key, Func<CancellationToken, Task<T>> factory, TimeSpan ttl, CancellationToken ct)
{
IDatabase db = redis.GetDatabase();
if (await TryReadAsync<T>(db, key) is { } hit) return hit.Value;
string lockKey = $"{key}:lock";
string token = Guid.NewGuid().ToString("N");
if (await db.LockTakeAsync(lockKey, token, LockTtl))
{
try
{
// kilidi alana kadar başka biri yazmış olabilir
if (await TryReadAsync<T>(db, key) is { } late) return late.Value;
T value = await factory(ct);
await db.StringSetAsync(key, JsonSerializer.Serialize(value), ttl);
return value;
}
finally
{
await db.LockReleaseAsync(lockKey, token);
}
}
// kilit başkasında: sonucu bekle, ama sonsuza kadar değil
long deadline = Environment.TickCount64 + (long)MaxWait.TotalMilliseconds;
while (Environment.TickCount64 < deadline)
{
await Task.Delay(PollInterval, ct);
if (await TryReadAsync<T>(db, key) is { } awaited) return awaited.Value;
}
logger.LogWarning("Lock holder for {Key} did not finish in {MaxWait}; computing locally", key, MaxWait);
return await factory(ct);
}
private static async Task<Hit<T>?> TryReadAsync<T>(IDatabase db, string key)
{
RedisValue raw = await db.StringGetAsync(key);
return raw.HasValue ? new Hit<T>(JsonSerializer.Deserialize<T>(raw.ToString())!) : null;
}
private sealed record Hit<T>(T Value);
}
Hit<T> wrapper'ı bilinçli: T bir value type ya da meşru olarak null olabilen bir tip olduğunda "cache'te yok" ile "cache'teki değer null" birbirinden ayrılmalı. Endpoint'te iki katmanı üst üste koyuyoruz: HybridCache instance içindeki 500 isteği tek çağrıya indiriyor, o tek çağrı da kilitle instance'lar arasında yarışıyor. L2'yi bu key için kapatıyoruz, çünkü ortak kopya artık RedisSingleFlight'ın key'inde duruyor:
// L1 coalescing HybridCache'ten, instance'lar arası tek hesaplama Redis kilidinden
app.MapGet("/api/reports/category-sales/locked", (CatalogDb db, HybridCache cache, RedisSingleFlight singleFlight, CancellationToken ct) =>
cache.GetOrCreateAsync("v1:report:category-sales:l1",
async token => await singleFlight.GetOrComputeAsync("v1:report:category-sales:shared",
t => Reports.CategorySalesAsync(db, t), TimeSpan.FromMinutes(5), token),
new HybridCacheEntryOptions
{
LocalCacheExpiration = TimeSpan.FromMinutes(1),
Flags = HybridCacheEntryFlags.DisableDistributedCache
},
cancellationToken: ct));
$ ./run_stampede.sh /api/reports/category-sales/locked
reqs=2000 rps=7064 p50=34.2ms p95=142.7ms failed=0.00%
instance 17540 queries=0
instance 17541 queries=0
instance 17542 queries=1
total queries=1
Üç instance, 2.000 istek, tek sorgu. Bu bir efficiency kilidi: kilit TTL'i dolup iki instance aynı anda hesaplasa bile sonuç yine doğru, yalnız iş iki kez yapılmış olur. Distributed Systems Temelleri serisinin "Distributed Lock, Lease ve Fencing Token: Kilit Neden Yetmez?" yazısı bu ayrımı ve tek Redis node'unun neden efficiency için yeterli, correctness için yetersiz olduğunu anlatıyor; lease süresinin factory süresinden uzun olması gerektiği de oradan geliyor. Bu kodu kendin yazmak istemezsen FusionCache 2.9'un WithDistributedLocker ve ZiggyCreatures.FusionCache.Locking.Distributed.Redis paketindeki RedisDistributedLocker aynı işi yapıyor. Süre dolmadan arka planda yenilemek için de FusionCache'te EagerRefreshThreshold var; HybridCache'te XFetch benzeri bir erken yenileme yok.
Penetration: Olmayan Key'lerin Saldırısı
Klasik cache-aside bulunamayan kaydı cache'e yazmaz; normal trafikte bu doğru davranış. Saldırı senaryosunda ise kapıyı açık bırakır. k6 senaryosu biçimi geçerli ama veritabanında olmayan ISBN'ler üretiyor; POOL verilirse aynı 5.000 ISBN'i dönüyor, verilmezse her istekte yeni bir tane:
import http from 'k6/http';
import { sleep } from 'k6';
import { summaryLine } from './summary.js';
// var olmayan ama biçimi geçerli ISBN'ler; POOL=0 ise her istek yeni bir ISBN
export const options = {
scenarios: { attack: { executor: 'shared-iterations', vus: 500, iterations: 50000, maxDuration: '300s' } },
};
http.setResponseCallback(http.expectedStatuses(200, 404));
const pool = parseInt(__ENV.POOL || '0');
function isbn(n) {
const first12 = '9782' + String(n).padStart(8, '0');
let sum = 0;
for (let i = 0; i < 12; i++) sum += Number(first12[i]) * (i % 2 === 0 ? 1 : 3);
return first12 + ((10 - (sum % 10)) % 10);
}
export default function () {
// bağlantılar ilk 2 saniyeye yayılsın (macOS'ta listen backlog'u taşmasın)
if (__ITER === 0) sleep(Math.random() * 2);
const n = pool > 0 ? Math.floor(Math.random() * pool) : Math.floor(Math.random() * 1e8);
http.get(`http://${__ENV.HOST}${__ENV.ENDPOINT}/${isbn(n)}`, { tags: { name: 'book-by-isbn' } });
}
export function handleSummary(data) { return { stdout: summaryLine(data) }; }
İlk denemede sleep satırı yoktu: 500 bağlantı aynı anda açılınca isteklerin yüzde 0,63'ü "connection refused" aldı (büyük olasılıkla macOS'un listen backlog sınırı). URL'deki (Uniform Resource Locator) her yeni ISBN de k6'da ayrı bir metric serisi açıyordu; name tag'i onları tek seride topluyor. İki satır da ölçümü düzeltiyor, uygulamayı değil. İlk iki koşu, null'ı cache'lemeyen IDistributedCache kodu ile HybridCache'i karşılaştırıyor:
app.MapGet("/negative/books/{isbn}", async (string isbn, CatalogDb db, HybridCache cache, CancellationToken ct) =>
{
BookDto? book = await cache.GetOrCreateAsync($"v1:book:{isbn}",
async token => await Books.LoadAsync(db, isbn, token),
Ttl.WithJitter(bookTtl, ttlJitter), cancellationToken: ct);
return book is null ? Results.NotFound() : Results.Ok(book);
});
$ ./run_pen.sh /naive/books 5000
== /naive/books POOL=5000
reqs=50000 rps=15317 p50=15.8ms p95=67.5ms failed=0.00%
db queries: {"queries":50000}
redis keys: 0
$ ./run_pen.sh /negative/books 5000
== /negative/books POOL=5000
reqs=50000 rps=25016 p50=8.9ms p95=18.4ms failed=0.00%
db queries: {"queries":5000}
redis keys: 5000
$ ./run_pen.sh /negative/books 0
== /negative/books POOL=0
reqs=50000 rps=12462 p50=17.4ms p95=102.1ms failed=0.00%
db queries: {"queries":49980}
redis keys: 49980
İlk gözlem şaşırtıcı olabilir: HybridCache factory'nin döndürdüğü null'ı da cache'liyor. Redis'teki v1:book:9782123456786 key'inin payload'ı null ile bitiyordu ve key 144 byte tutuyordu. Yani negative caching, "bu kayıt yok" bilgisini cache'lemek, kendiliğinden geliyor ve aynı 5.000 ISBN'i dönen botta 50.000 sorgu 5.000'e indi. İkinci tahminin ikinci yarısının cevabı ise acı: her istekte yeni ISBN üreten botta 49.980 sorgu gitti (20 ISBN rastgele tekrar etti) ve Redis'e 49.980 işe yaramaz key yazıldı. Saldırgan artık hem veritabanını hem cache belleğini dolduruyor.
Doğrulama, Bloom Filter ve Kısa Negatif TTL
Savunma üç katmanlı: biçimi bozuk key'i hiçbir yere sokma, var olmadığı kesin olan key'i cache'e bile sorma, geriye kalan küçük kısmı kısa süre negatif cache'le:
app.MapGet("/api/books/{isbn}", async (string isbn, CatalogDb db, HybridCache cache, BloomFilter filter, CancellationToken ct) =>
{
// 1. biçim: bozuk key ne cache'e ne veritabanına gider
if (!Isbn13.IsValid(isbn)) return Results.BadRequest(new { error = "Invalid ISBN-13" });
// 2. varlık filtresi: "kesin yok" diyorsa cache'e bile bakmaya gerek yok
if (!filter.MightContain(isbn)) return Results.NotFound();
// 3. negative caching: yanlış pozitifle gelen key kısa süre "yok" olarak cache'te kalır
bool loaded = false;
BookDto? book = await cache.GetOrCreateAsync($"v1:book:{isbn}", async token =>
{
loaded = true;
return await Books.LoadAsync(db, isbn, token);
}, Ttl.WithJitter(bookTtl, ttlJitter), cancellationToken: ct);
if (book is null)
{
// .NET 10'da factory içinden TTL değiştirilemiyor; "yok" sonucunu kısa TTL ile yeniden yaz
if (loaded) await cache.SetAsync<BookDto?>($"v1:book:{isbn}", null, negativeOptions, cancellationToken: ct);
return Results.NotFound();
}
return Results.Ok(book);
});
Birinci katman Isbn13.IsValid: 13 hane, 978 ya da 979 ön eki ve kontrol hanesi. Rastgele 13 haneli bir sayının bile onda dokuzu kontrol hanesinde elenir. Doğrulanmamış girdiyi key yapmak, serinin ikinci yazısındaki bellek DoS'unun (Denial of Service) da kapısı. Üçüncü katmandaki loaded bayrağı .NET 10'un bir sınırını aşıyor: entry option'ları factory çağrılmadan önce verildiği için "bulunamadıysa TTL 60 saniye olsun" diyemiyorsun. Factory'yi gerçekten çalıştıran çağrı, sonucu kısa TTL ile yeniden yazıyor; coalescing ile bekleyen diğer çağrılarda bayrak false kaldığı için Redis'e gereksiz yazma yapılmıyor. .NET 11'de factory içinden option ayarlamaya izin veren overload'lar geliyor, serinin onuncu yazısında.
İkinci katman bir Bloom filter: bir key için "kesin yok" ya da "belki var" diyen, yanlış negatif vermeyen ve yanlış pozitif oranı ayarlanabilen bir bit dizisi:
// "Kesin yok" ya da "belki var" der; yanlış negatif vermez, yanlış pozitif oranı ayarlanabilir
public sealed class BloomFilter
{
private readonly long[] _bits;
private readonly int _bitCount;
private readonly int _hashCount;
public BloomFilter(int expectedItems, double falsePositiveRate)
{
double ln2 = Math.Log(2);
_bitCount = (int)Math.Ceiling(-expectedItems * Math.Log(falsePositiveRate) / (ln2 * ln2));
_hashCount = Math.Max(1, (int)Math.Round(_bitCount / (double)expectedItems * ln2));
_bits = new long[(_bitCount + 63) / 64];
}
public int SizeInBytes => _bits.Length * sizeof(long);
public int HashCount => _hashCount;
public void Add(string key)
{
(uint h1, uint h2) = Hash(key);
for (int i = 0; i < _hashCount; i++)
{
int bit = (int)((h1 + (uint)i * h2) % (uint)_bitCount);
Interlocked.Or(ref _bits[bit >> 6], 1L << (bit & 63));
}
}
public bool MightContain(string key)
{
(uint h1, uint h2) = Hash(key);
for (int i = 0; i < _hashCount; i++)
{
int bit = (int)((h1 + (uint)i * h2) % (uint)_bitCount);
if ((Volatile.Read(ref _bits[bit >> 6]) & (1L << (bit & 63))) == 0)
{
return false;
}
}
return true;
}
// tek 64 bit hash'ten iki hash türetmek (Kirsch–Mitzenmacher)
private static (uint, uint) Hash(string key)
{
ulong hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(key));
return ((uint)hash, (uint)(hash >> 32) | 1);
}
}
Uygulama açılışta veritabanındaki bütün ISBN'leri filtreye ekliyor; XxHash3 için System.IO.Hashing paketi gerekiyor. 100.000 kapasite ve yüzde 1 hedefle filtre 117 KB tutuyor ve 7 hash kullanıyor (açılış log'u: Bloom filter: 117 KB, 7 hashes). Aynı saldırı, bu kez korumalı endpoint'e:
$ ./run_pen.sh /api/books 0
== /api/books POOL=0
reqs=50000 rps=24994 p50=6.5ms p95=13.5ms failed=0.00%
db queries: {"queries":9}
redis keys: 9
50.000 benzersiz ISBN'den 9'u filtreyi yanlış pozitifle geçti, veritabanına gitti ve 60 saniyelik negatif entry olarak cache'e yazıldı. Oran yüzde 1'in çok altında, çünkü filtre 100.000 için boyutlandı ama içinde 50.000 ISBN var; formülle beklenen oran yüzde 0,025, yani 50.000 istekte 12,5 civarı. Yeni kitap eklendiğinde filtreye de eklenmesi gerekiyor:
$ curl -s localhost:17540/api/books/12345
{"error":"Invalid ISBN-13"} 400
$ curl -s localhost:17540/api/books/9790000000018
404
$ curl -s -X POST localhost:17540/api/books -H "Content-Type: application/json" -d '{"isbn":"9790000000018","title":"Caching in Practice","price":42.5,"categoryId":7}'
{"id":50001,"isbn":"9790000000018","title":"Caching in Practice","price":42.5} 201
$ curl -s localhost:17540/api/books/9790000000018
{"id":50001,"isbn":"9790000000018","title":"Caching in Practice","price":42.5} 200
$ curl -s localhost:17540/stats
{"queries":2}
İlk 404 veritabanına hiç gitmedi; iki sorgu, INSERT ve ilk okuma. POST endpoint'i kaydı yazdıktan sonra filter.Add ve olası bir negatif entry için cache.RemoveAsync çağırıyor. Çok instance'lı ortamda tuzak şu: filtre process içinde; yalnız kitabı ekleyen instance'ınki güncelleniyor, diğerleri "kesin yok" demeye devam ediyor. Filtreyi periyodik olarak veritabanından yeniden kurmak ya da Redis 8'in yerleşik Bloom filter komutlarıyla ortak bir filtre tutmak gerekiyor; ikincisi ilerideki Veritabanı › Redis serisinin konusu. Saldırının kendisini yavaşlatmak için de rate limiting var, Web API serisinin ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" yazısında.
Avalanche: Aynı Anda Ölen TTL'ler
Avalanche'ın en yaygın sebebi masum bir warm-up: uygulama açılışta en çok satan 5.000 kitabı cache'e yüklüyor, hepsi aynı saniyede yazılıyor ve hepsi aynı TTL'i alıyor. Jitter, her entry'nin TTL'ine küçük bir rastgelelik ekliyor; warm-up ise Redis'i boğmamak için paralelliği sınırlıyor:
public static class Ttl
{
// her entry'ye ±spread oranında rastgele süre: aynı anda yazılanlar aynı anda ölmez
public static HybridCacheEntryOptions WithJitter(TimeSpan baseTtl, double spread)
{
TimeSpan ttl = baseTtl * (1 + (Random.Shared.NextDouble() * 2 - 1) * spread);
return new HybridCacheEntryOptions { Expiration = ttl, LocalCacheExpiration = ttl };
}
}
public static class CacheWarmer
{
// açılışta en çok satanları yükle; Redis'i boğmamak için en fazla 16 paralel yazma
public static async Task WarmAsync(CatalogDb db, HybridCache cache, int count, TimeSpan ttl, double jitter)
{
long start = Environment.TickCount64;
List<BookDto> top = await db.Books.AsNoTracking()
.OrderByDescending(b => b.Sold).ThenBy(b => b.Id).Take(count)
.Select(b => new BookDto(b.Id, b.Isbn, b.Title, b.Price))
.ToListAsync();
await Parallel.ForEachAsync(top, new ParallelOptions { MaxDegreeOfParallelism = 16 }, async (book, ct) =>
await cache.SetAsync<BookDto?>($"v1:book:{book.Isbn}", book, Ttl.WithJitter(ttl, jitter), cancellationToken: ct));
Console.WriteLine($"Warm-up: {top.Count} books in {Environment.TickCount64 - start} ms (ttl {ttl.TotalSeconds}s, jitter {jitter:P0})");
}
}
Expiration ile LocalCacheExpiration aynı süreyi alıyor, yoksa L1 ve L2 ayrı ayrı ölür. Deneyde TTL 60 saniye; k6 bu 5.000 ISBN'e 100 saniye boyunca saniyede 2.000 istek atıyor (constant-arrival-rate, 500 VU havuzu), küçük bir BackgroundService de saniyedeki SQL (Structured Query Language) komutu sayısını dosyaya yazıyor. Biri jitter'sız, biri yüzde 20 jitter'lı iki koşu:
Warm-up: 5000 books in 116 ms (ttl 60s, jitter %0)
reqs=200003 rps=2000 p50=0.4ms p95=0.7ms failed=0.00%
Warm-up: 5000 books in 124 ms (ttl 60s, jitter %20)
reqs=200005 rps=2000 p50=0.4ms p95=0.8ms failed=0.00%
$ ./qps_compare.sh
second no-jitter jitter-20%
56 0 191
57 0 213
58 0 199
59 0 193
60 87 199
61 1627 192
62 1054 247
63 771 216
64 461 197
65 325 227
66 235 207
67 136 219
68 103 215
69 77 204
70 49 198
71 23 196
72 14 213
qps_jitter_0.log: peak 1627/s at 61s, total 5000
qps_jitter_0.2.log: peak 247/s at 62s, total 5018
Jitter'sız koşuda veritabanı 59 saniye boyunca tek sorgu görmedi, sonra 61. saniyede 1.627 sorgu geldi: o saniyedeki isteklerin neredeyse hepsi cache'i boş buldu. Yüzde 20 jitter ile TTL'ler 48 ile 72 saniye arasına yayıldı ve tepe saniyede 247 sorguya indi. Toplam neredeyse aynı (5.000 ve 5.018; fazlası kısa TTL ile yeniden yüklenip pencere içinde bir kez daha ölen key'ler): jitter işi azaltmıyor, zamana yayıyor. p95'in 1 ms'nin altında kalması yanıltmasın; yerel SQLite bu tepeyi rahat kaldırdı, gerçek bir veritabanında aynı tepe connection pool'u doldurur. Avalanche'ı veritabanının saniyelik yüküyle ölç.
Redis Erişilemezken Ayakta Kalmak
Avalanche'ın ikinci hâli cache sunucusunun kendisinin gitmesi. Bütün kitapları (L1'in çoğunlukla soğuk olduğu 50.000 key) okuyan 100 VU'luk bir senaryo çalışırken Redis'i docker stop ile durdurduk. Redis bağlantısı tek bir multiplexer'dan geliyor ve ayarları configuration'dan okunuyor:
// tek multiplexer: IDistributedCache, HybridCache'in L2'si ve kilit aynı bağlantıyı paylaşır
ConfigurationOptions redisOptions = ConfigurationOptions.Parse(builder.Configuration["Redis"]!);
if (builder.Configuration.GetValue("RedisFailFast", false))
{
// bağlantı yokken komutları backlog'da bekletme, hemen hata ver
redisOptions.BacklogPolicy = BacklogPolicy.FailFast;
}
IConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync(redisOptions);
builder.Services.AddSingleton(redis);
builder.Services.AddStackExchangeRedisCache(o => o.ConnectionMultiplexerFactory = () => Task.FromResult(redis));
builder.Services.AddHybridCache();
Üç koşu: varsayılan ayarlar (localhost:17550,abortConnect=false), kısa timeout'lar (connectTimeout=1000,syncTimeout=250,asyncTimeout=250) ve kısa timeout'lara ek olarak BacklogPolicy.FailFast. Her koşunun başında klasik cache-aside endpoint'ine tek bir curl atıyoruz:
== Redis stopped, config: localhost:17550,abortConnect=false
500 5.268785s
reqs=401 rps=17 p50=5961.0ms p95=6024.3ms failed=0.00%
db queries: {"queries":398}
== Redis stopped, config: localhost:17550,abortConnect=false,connectTimeout=1000,syncTimeout=250,asyncTimeout=250
500 0.335138s
reqs=2141 rps=103 p50=998.6ms p95=1009.1ms failed=0.00%
db queries: {"queries":2097}
== Redis stopped, config: localhost:17550,abortConnect=false,connectTimeout=1000,syncTimeout=250,asyncTimeout=250 --RedisFailFast=true
500 0.023238s
reqs=662423 rps=33120 p50=2.2ms p95=7.3ms failed=0.00%
db queries: {"queries":50000}
Klasik cache-aside her ayarda 500 döndü: IDistributedCache'in exception'ı doğrudan endpoint'e çıkıyor. HybridCache ise hiçbir koşuda hata vermedi, çünkü L2 okuma hatasını miss sayıp veritabanına gidiyor, yazma hatasını da log'layıp geçiyor. Log'da her istek için şu iki satır vardı:
fail: Microsoft.Extensions.Caching.Hybrid.HybridCache[6]
Cache backend read failure.
StackExchange.Redis.RedisConnectionException: The message timed out in the backlog attempting to send because no connection became available (5000ms) - Last Connection Exception: SocketClosed on localhost:17550/Interactive, ...
fail: Microsoft.Extensions.Caching.Hybrid.HybridCache[7]
Cache backend write failure.
Hata yok ama varsayılan ayarlarla uygulama fiilen durdu: p95 6 saniye, saniyede 17 istek. StackExchange.Redis'in varsayılan BacklogPolicy'si bağlantı yokken komutu kuyrukta tutuyor ve asyncTimeout (varsayılanı syncTimeout, 5.000 ms) dolana kadar bekletiyor. Timeout'u 250 ms'ye indirince p50 250 ms değil tam 1 saniye oldu: dokümana göre async timeout'lar heartbeat'te, varsayılan olarak saniyede bir kontrol ediliyor. FailFast ile komut hiç kuyruğa girmiyor; p95 7,3 ms'ye indi ve uygulama L1 artı veritabanıyla saniyede 33.120 isteğe çıktı. Bağlantının açık görünüp cevap gelmediği docker pause denemesinde de ilk timeout'lardan sonra bağlantı sağlıksız işaretlendi ve p95 6,7 ms oldu.
FusionCache bu konuda daha ileri gidiyor: SetFailSafe(true, ...) ile süresi dolmuş değeri factory ya da L2 başarısız olduğunda bir süre daha servis ediyor, DistributedCacheSoftTimeout ile de L2'yi beklemeyi sınırlıyor. Karşılaştırması serinin dördüncü yazısında.
Üretimde Dikkat
Bu yazıdaki bütün sayıları tek tabloda toplarsak:
| Senaryo | Önce | Sonra |
|---|---|---|
| Stampede, 3 instance, 500 VU, 2.000 istek | 223 sorgu, p95 1.584 ms | HybridCache 3, kilitle 1 sorgu |
| Penetration, 5.000 tekrar eden ISBN, 500 VU | 50.000 sorgu | 5.000 sorgu |
| Penetration, 50.000 benzersiz ISBN, 500 VU | 49.980 sorgu, 49.980 Redis key'i | 9 sorgu, 9 key |
| Avalanche, 5.000 key, 2.000 istek/sn | Tepe 1.627 sorgu/sn | Tepe 247 sorgu/sn |
| Redis durduruldu, 100 VU | p95 6.024 ms, 17 istek/sn | p95 7,3 ms, 33.120 istek/sn |
Bunları üretimde görmek için veritabanı sorgu sayısını cache metric'leriyle yan yana koy. Stampede, sorgu sayısında deploy ya da TTL anına denk gelen kısa bir sıçrama olarak görünür; penetration ise cache miss oranının artmasına rağmen cache entry sayısının da artması olarak. Hit ratio, miss ve eviction metric'lerinin nasıl toplanacağı bir sonraki yazının konusu.
Redis için iki ayar her ortamda bilinçli seçilmeli: abortConnect=false (Redis yokken uygulama açılabilsin; varsayılanı Azure dışında true) ve iş yüküne göre kısa bir asyncTimeout. Kısa timeout normal yükteki birkaç yavaş cevabı da hataya çevirir; ölçüp seç. HybridCache her L2 hatasında iki fail satırı yazıyor: kesintide 50.000 istek 100.000 log satırı demek. Bu kategoriye bir log filtresi koy; log yönetimi ilerideki Logging serisinde.
Bu çözümlerin hiçbiri bedava değil. Kilidi alamayan her istek 50 ms'lik polling gecikmesi öder. Bloom filter her instance'ta bellek tutar ve yeniden kurulmalıdır. Jitter'da kabul edilebilir eskiliği üst sınırla hesapla: TTL 10 dakika ve jitter yüzde 20 ise değer 12 dakika eski kalabilir.
Sık Düşülen Tuzaklar
HybridCache'in stampede korumasının instance'lar arası olduğunu sanmak. Koruma instance başına; on pod'lu bir dağıtımda aynı anda on factory çalışır.
Negative caching'i uzun TTL ile yapmak. HybridCache null'ı pozitif değerle aynı TTL ile tutar; yeni eklenen kitap, ekleme yolu invalidation yapmıyorsa 10 dakika boyunca 404 döner. Negatif TTL'i kısa tut ve yazma yolunda key'i sil.
Negative caching'i penetration'a karşı tek savunma sanmak. Benzersiz key'lerle saldıran biri onu bir bellek tüketme aracına çevirir; biçim doğrulaması ve varlık filtresi önce gelir.
Kilit TTL'ini factory süresinden kısa tutmak. Factory 12 saniye sürerken kilit 10 saniyede düşerse ikinci bir instance da hesaplamaya başlar ve kilidin anlamı kalmaz.
Redis bağlantısını varsayılan timeout'larla bırakmak. Hata vermeyen ama her isteği 5 saniye bekleten bir cache, hata veren bir cache'ten daha zor fark edilir.
Küçük Bir Deneme
★☆☆ run_stampede.sh'yi klasik cache-aside endpoint'i için üç kez çalıştır. Sorgu sayısı koşudan koşuya ne kadar değişiyor? k6 senaryosunu her VU'nun ilk isteğinden önce rastgele 0–2 saniye bekleyecek şekilde değiştirince ne oluyor?
★★☆ RedisSingleFlight'ta LockTtl'ı 1 saniyeye indir ve rapor factory'sine 2 saniyelik bir Task.Delay ekle. Toplam sorgu sayısı kaça çıkıyor ve log'da hangi uyarıyı görüyorsun?
★★★ Bloom filter'ı ikinci bir instance'ta da çalıştır, birinden yeni bir kitap ekle ve ötekinden oku. 404'ü gör, sonra filtreyi her 5 dakikada bir veritabanından yeniden kuran bir BackgroundService yaz. Yeniden kurma sırasında filtre değişirken gelen isteklerin yanlış "yok" almaması için ne yaparsın?
Bu yazıda cache'i üretimde çökerten üç sorunu ölçerek çözdük ve her çözümün bedelini sayılarla gördük. Bir sonraki yazıda bu sayıları kalıcı hâle getiriyoruz: hit ratio, miss ve eviction metric'leri, Redis'in kendi istatistikleri, cache'in gerçekten fayda sağladığını kanıtlamak ve expiration ile invalidation'ı test eden testler.