
Web trafiğinin çoğu saate göre şekillenir. Sınav trafiği ise takvime göre. Sistem haftalarca kapasitesinin küçük bir kısmıyla çalışır; sonra her sınava giriş belgesinde yazan başlangıç saatinde on binlerce öğrenci aynı sınavı aynı dakika içinde açar. Aylık ortalama o dakika hakkında neredeyse hiçbir şey söylemez ve herkesin hatırladığı tek dakika da odur.
Gozine2'de, sınav zirvelerinde 45.000 ile 70.000 arası eşzamanlı kullanıcıyı taşıyan bir Redis Sentinel yüksek erişilebilirlik yapısı tasarlayıp deploy ettim. Bu not, böyle bir yapının arkasındaki mantık hakkında: Sentinel size ne sağlar, Redis Cluster ne zaman daha uygundur, .NET istemcisi nasıl yapılandırılır ve topolojiden daha önemli olan önbellekleme ve test alışkanlıkları nelerdir.
Sorunun biçimi
Sınav platformlarını sıra dışı kılan iki özellik var. Ani yük artışı senkronizedir: sınavın bir başlangıç saati olduğu için kullanıcılar birlikte gelir. Ve öngörülebilirdir: tarihi haftalar önceden bilirsiniz; dolayısıyla önbellekleri ısıtabilir, yük testleri çalıştırabilir ve riskli deploy'ları dondurabilirsiniz.
Önbelleği zorlayan şey bu senkronize geliştir. İlk dakikada herkes aynı birkaç şeyi ister: sınav tanımı, bölümleri, soru seti, süre kuralları. Bu anahtarlar soğuksa ya da süreleri aynı anda dolarsa, her istek aynı anda SQL Server'a düşer. Redis primary'si de tam bu aralıkta çökerse, kimse connection string düzenlemeden bir replikanın saniyeler içinde görevi devralması gerekir.
Sentinel ne yapar, ne yapmaz
Redis Sentinel, Redis sunucularınızın yanında çalışan ayrı bir süreçtir. Tipik bir kurulumda bir primary, bir veya daha fazla replika ve bağımsız makinelerde en az üç Sentinel bulunur. Burada dört görevi önemlidir:
- İzleme. Her Sentinel primary'ye ve replikalara ping atar. Primary
down-after-millisecondssüresinden daha uzun süre yanıt vermezse, o Sentinel onu subjectively down (öznel olarak çökmüş) olarak işaretler. - Uzlaşma. Yeterli sayıda Sentinel hemfikir olduğunda (yapılandırdığınız quorum), primary objectively down (nesnel olarak çökmüş) kabul edilir.
- Otomatik failover. Sentinel'ler bir lider seçer; lider en uygun replikayı primary'ye yükseltir ve diğer replikaları ona yönlendirir. Seçim için tüm Sentinel'lerin çoğunluğu gerekir; pratikte alt sınırın üç olmasının nedeni de budur.
- Servis keşfi. İstemciler primary'nin adresini asla koda gömmez. Bir servis adı için primary'nin hangisi olduğunu Sentinel'lere sorar ve yanıt değiştiğinde yeni yanıtı izler.
Sentinel'in yapmadığı şey ise Redis'i kalıcı (durable) hâle getirmektir. Replikasyon asenkrondur; bu yüzden eski primary'nin onayladığı bir yazma, yükseltilen replikaya hiç ulaşmayabilir. Redis dokümantasyonu bunu açıkça söyler. Redis'teki her şey asıl veri kaynağından yeniden oluşturulabildiği sürece bu, bir önbellek için kabul edilebilir. Bir yazmanın kaybolması zarar verecekse, o yazma önce SQL Server'a gitmelidir.
Sentinel mi, Cluster mı?
Redis Cluster başka bir sorunu çözer. Keyspace'i 16.384 hash slot kullanarak birkaç primary arasında shard'lara böler; her primary'nin kendi replikaları vardır. Böylece tek bir makinenin sunabileceğinden daha fazla bellek ve yazma throughput'u elde edersiniz. Bedeli bir dizi kısıttır: çok anahtarlı komutlar, transaction'lar ve Lua script'leri yalnızca tüm anahtarları aynı slot'a düştüğünde çalışır; bu yüzden anahtarlar {exam:42}:sections gibi hash tag'ler etrafında tasarlanır. Yalnızca 0 numaralı veritabanı vardır ve resharding, ekibinizin anlaması gereken bir operasyona dönüşür.
Benim pratik kuralım şu: working set tek bir makinenin belleğine rahatça sığıyorsa ve tek bir primary komut hızını kaldırabiliyorsa, Sentinel daha basit ve daha güvenli seçimdir. Sınav önbellekleri çoğu zaman bu profile uyar. Sıcak veri, çok sayıda kişinin okuduğu sınırlı sayıda sınavın içeriğidir ve yük, yazma ağırlıklı olmaktan çok okuma ağırlıklıdır. Ölçümler tek bir primary'nin sınır olduğunu gösterdiğinde Cluster'a geçin. CommandFlags.PreferReplica ile replikadan okumak, hafif bayatlığın zararsız olduğu veriler için bir ara adımdır.
StackExchange.Redis'i Sentinel için yapılandırmak
StackExchange.Redis'te Redis sunucularını değil, Sentinel endpoint'lerini listeler ve sentinel.conf içindeki servis adını verirsiniz. ServiceName ayarlandığında istemci Sentinel moduna geçer: güncel primary'yi Sentinel'lere sorar, ona bağlanır ve bir failover olduğunda onu takip eder.
using StackExchange.Redis;
var options = new ConfigurationOptions
{
ServiceName = "exam-cache", // matches "sentinel monitor exam-cache ..." in sentinel.conf
AbortOnConnectFail = false, // keep retrying in the background instead of failing startup
ConnectTimeout = 5000,
SyncTimeout = 2000,
};
options.EndPoints.Add("sentinel-1", 26379);
options.EndPoints.Add("sentinel-2", 26379);
options.EndPoints.Add("sentinel-3", 26379);
var redis = ConnectionMultiplexer.Connect(options);
redis.ConnectionFailed += (_, e) =>
Log.Warning("Redis connection failed: {EndPoint} {FailureType}", e.EndPoint, e.FailureType);
redis.ConnectionRestored += (_, e) =>
Log.Information("Redis connection restored: {EndPoint}", e.EndPoint);
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);
Multiplexer bir kez oluşturulup paylaşılmak üzere tasarlanmıştır, bu yüzden singleton'dır; her istek için bir tane oluşturmak, soketleri tüketmenin klasik yoludur. AbortOnConnectFail = false, başlangıç sırasında yaşanan bir Redis kesintisinin tüm servisi çökertmesini engeller (güncel 3.x istemcide; 2.x'te Sentinel ile, hiçbir Sentinel yanıt vermezse ilk bağlantı yine de exception fırlatabilir). Bağlantı olaylarını loglamak (benim durumumda Serilog ile), daha sonra bir failover sırasında ne olduğunu sorduğunuzda elinize bir zaman çizelgesi verir. Kimlik doğrulama ve TLS de aynı options nesnesine eklenir; değerleri yapılandırmadan okunur.
Ardından failover'ı bilerek test edin. Bir yük testi çalışırken staging'de primary'yi durdurun ve sonraki birkaç saniyeyi izleyin. Geçiş sırasında bazı istekler başarısız olacaktır. Önbellek okumaları bir Redis hatasını 500 döndürmek yerine miss olarak ele almalıdır; ama artık her miss bir veritabanı sorgusu demektir, hem de tam veritabanının korunmaya ihtiyaç duyduğu anda.
Stampede olmadan cache-aside
Yaygın desen cache-aside'dır: Redis'ten okursunuz; miss durumunda veritabanından yükler ve değeri bir TTL ile geri yazarsınız. Zayıf nokta miss'tir. Popüler bir anahtarın süresi dolduğunda, miss'i gören her istek aynı anda veritabanını sorgular. Üç savunma birlikte iyi çalışır:
- Kilitleme. Anahtarı tek bir çağıran yeniden oluşturur; diğerleri kısa bir süre bekleyip sonucu okur.
- Erken yenileme. Sıcak anahtarlar, süreleri dolmadan kısa bir süre önce, bir arka plan işiyle ya da okuma sırasında olasılıksal olarak yenilenir; böylece hiç soğumazlar.
- Jitter. Her TTL'ye küçük, rastgele bir miktar eklenir; böylece birlikte yazılan anahtarların süresi birlikte dolmaz.
Birinci ve üçüncü fikrin kompakt bir uygulaması:
public sealed class CacheAside(IConnectionMultiplexer redis)
{
private readonly IDatabase _db = redis.GetDatabase();
public async Task<T> GetOrLoadAsync<T>(
string key, TimeSpan ttl, Func<CancellationToken, Task<T>> load, CancellationToken ct)
{
var cached = await _db.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached.ToString())!;
var lockKey = $"lock:{key}";
var token = Guid.NewGuid().ToString("N");
if (await _db.LockTakeAsync(lockKey, token, TimeSpan.FromSeconds(10)))
{
try
{
cached = await _db.StringGetAsync(key); // another caller may have filled it
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached.ToString())!;
var value = await load(ct);
await _db.StringSetAsync(key, JsonSerializer.Serialize(value), WithJitter(ttl));
return value;
}
finally
{
await _db.LockReleaseAsync(lockKey, token);
}
}
for (var i = 0; i < 20; i++) // someone else is loading: wait for it
{
await Task.Delay(50, ct);
cached = await _db.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached.ToString())!;
}
return await load(ct); // stop waiting and load directly
}
private static TimeSpan WithJitter(TimeSpan ttl) =>
ttl + TimeSpan.FromMilliseconds(Random.Shared.Next(0, (int)(ttl.TotalMilliseconds * 0.1)));
}
Kilit yalnızca yükü azaltır. Bir uç durumda başarısız olursa iki çağıran aynı veriyi yükler ve hiçbir şey bozulmaz; çalıştığında ise bin özdeş sorgu teke iner. Yeni bir bağımlılık ekleyebiliyorsanız, HybridCache (.NET 9 ile duyurulan ve Mart 2025'ten beri kararlı olan Microsoft.Extensions.Caching.Hybrid paketi; .NET 8'de de çalışır) size süreç başına stampede koruması ve Redis'in önünde yerel bir bellek katmanı sağlar.
En sıcak anahtarlar için süre dolmasına hiç bel bağlamamayı tercih ederim. Bir sınav yarın sabah başlıyorsa, bir arka plan işi sınavın tanımını ve soru setini bu gece yükleyebilir ve sınav her yeniden yayımlandığında bunları yenileyebilir. Böylece süre dolması yalnızca o iş başarısız olursa önem taşır.
TTL'ler ve önbelleğe alınmaması gerekenler
Bir TTL, ne kadar yanılmaya ve ne kadar süreyle yanılmaya razı olduğunuza dair bir beyandır. Sınav tanımları ve soru metinleri gibi yalnızca yayımlandığında değişen içerik, uzun bir TTL ve buna ek olarak açıkça geçersiz kılma (invalidation) ile yönetilebilir. Periyodik olarak yeniden hesaplanan toplamlar ve sıralamaların süresi, yeniden hesaplama aralığında dolmalıdır. Küçük referans verileri uzun süre yaşayabilir; ideal olarak Redis'in önünde süreç içi bir bellek önbelleğiyle, çünkü ağ üzerinden bir gidiş-dönüş yine de bir gidiş-dönüştür.
Bazı şeyleri önbelleğin dışında tutarım ya da önbellekteki kopyayı asla tek kopya saymam:
- Gönderilmiş cevaplar, puanlar, finansal olan her şey. Bir failover son yazmaları kaybedebilir. Asıl kayıt veritabanıdır; önbellek bir kopyadır.
- Zamana bağlı kritik kararlar. Bir sınav penceresinin açık olup olmadığı ya da bir öğrencinin ne kadar süresi kaldığı, sunucu saatinden ve kalıcı durumdan gelmelidir.
- Az tekrar kullanılan veriler. Kullanıcı başına bir kez okunan değerler çoğunlukla bellek harcar ve hit oranını aşağı çeker.
- Büyük değerler. Redis komutları teker teker yürütür; bu yüzden büyük bir değer diğer tüm istemcileri bekletir ve bant genişliğinin en kıt olduğu anda onu tüketir.
Zirvenin provasını yapın
Zirve öngörülebilir olduğu için provası yapılabilir. Gozine2'de darboğazları zirveler gelmeden bulmak için Locust ile yük testleri kullandım. Locust senaryoları sade Python'dur; yazması ve gözden geçirmesi kolaydır ve sınavlar için önemli olan "herkes aynı anda gelir" biçimini üretebilirler. Bir staging API'sine karşı minimal bir senaryo:
import random
from locust import HttpUser, task, between
class ExamTaker(HttpUser):
wait_time = between(5, 20) # students read before they answer
def on_start(self):
resp = self.client.post("/api/exams/demo/sessions", name="start session")
self.session_id = resp.json()["sessionId"]
@task(1)
def exam_info(self):
self.client.get("/api/exams/demo", name="exam info")
@task(5)
def next_question(self):
self.client.get(f"/api/sessions/{self.session_id}/next", name="next question")
@task(5)
def answer(self):
self.client.post(f"/api/sessions/{self.session_id}/answers",
json={"choice": random.randint(1, 4)}, name="submit answer")
locust -f exam_day.py --headless --host https://staging.example.com \
-u 5000 -r 500 --run-time 20m --csv exam_day
Endpoint'ler ve sayılar yer tutucudur; gerçek değerleri hazırlandığınız sınavın kayıt sayısından türetin ve tek bir makine yeterli yükü üretemediğinde Locust'u dağıtık çalıştırın (--master, --worker). Spawn rate en az kullanıcı sayısı kadar önemlidir, çünkü tehlikeli an yükün tırmandığı evredir: önbellekler soğuktur ve bağlantı havuzları hâlâ büyümektedir. Ortalamalara değil, yüzdeliklere (percentile) bakın ve test sırasında Redis ile SQL Server'ı izleyin. Ortalama, tam da öğrencilerin sayfayı yenilemesine yol açan yavaş istekleri gizler ve her yenileme ek yük demektir.
SQL Server hâlâ hot path üzerinde
Redis okumaların yükünü SQL Server'dan alır, ama yazmalar yine oraya gider, miss'ler yine oraya düşer ve bir failover'ın hemen ardından okuma yükünü kısa bir süre tek başına veritabanı taşır. Gozine2'deki Redis çalışmasının yanı sıra SQL Server sorgularını, önbellekleme yollarını ve yoğun kullanılan API'leri optimize ettim. Yöntem pek değişmez: yoğun kullanılan endpoint'lerin arkasındaki sorguları bulun (başlamak için en kolay yer Query Store'dur), her birine onu kapsayan bir indeks verin ve isteğin ihtiyaç duymadığı işi kaldırın.
EF Core tarafında, sık yapılan okumalarda entity'lerin tamamını yüklemek yerine küçük DTO'lara projeksiyon yapın; salt okunur bir iş için entity yüklediğinizde de AsNoTracking ekleyin. Her istekte çalışan sorgular için compiled query işe yarar:
private static readonly Func<ExamDbContext, int, Task<ExamSummary?>> GetExamSummary =
EF.CompileAsyncQuery((ExamDbContext db, int examId) =>
db.Exams
.Where(e => e.Id == examId)
.Select(e => new ExamSummary(e.Id, e.Title, e.StartsAtUtc, e.DurationMinutes))
.FirstOrDefault());
EF Core sorgu çevirisini zaten önbelleğe alır, ama önbellekteki planı bulmak için her çağrıda expression tree'yi yine de inceler; compiled query bu adımı atlar. İstek başına kazanç küçüktür, ama her öğrencinin aynı dakikada çağırdığı bir endpoint'te küçük kazançlar birikir. Öncesini ve sonrasını ölçün. Profil, sorgu hazırlığını hiçbir zaman bir maliyet olarak göstermediyse kodu basit tutun.
Sisteminizde böyle takvime bağlı bir zirve varsa ve buna bir de başka bir gözün bakmasını istiyorsanız, bu tam da üstlendiğim türden bir backend ve performans işi. Bir sonraki not, önbellekten öğrencilerin gerçekte yaptığı şeye geçiyor: her bir sonraki soruyu sunucunun seçtiği uyarlamalı testler.
Bu not İngilizce ve Farsça olarak da yayımlandı: İngilizce · Farsça.
