Tek Sunucudan Milyonlarca Kullanıcıya: Vertical ve Horizontal Scaling, Stateless Tier
Uygulama tier'ını horizontal ölçeklemenin şartı stateless olmak: session, Data Protection key ring'i, IMemoryCache, yerel dosyalar ve zamanlanmış işler instance'ın dışına alınmalı. İki instance'lı gerçek bir denemede session'ı Redis'e taşımak tek başına hiçbir şeyi düzeltmiyor, çünkü session ve authentication cookie'leri Data Protection key'leriyle şifreli; key ring de paylaşılıp SetApplicationName verildiğinde iki sunucu tek uygulama gibi davranıyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; örnek Microsoft.Extensions.Caching.StackExchangeRedis ve Microsoft.AspNetCore.DataProtection.StackExchangeRedis 10.0.14 paketleriyle, Redis 7 üzerinde koşturuldu.
Önce Tahmin Et
Kampanya gününden sonra Bookstore ekibi ikinci bir karar alıyor: tek uygulama instance'ı yerine iki instance, önlerinde istekleri sırayla dağıtan bir load balancer. Deploy ediliyor ve destek ekibine şikâyetler gelmeye başlıyor: kullanıcılar giriş yaptıktan bir tık sonra "giriş yapın" sayfasını görüyor, sepete eklenen kitaplar bir görünüp bir kayboluyor.
Ekipten biri sebebi hemen buluyor: session bellekte tutuluyor, her instance'ın belleği ayrı. Çözüm olarak session'ı Redis'e taşıyorlar. Tahmin et: Redis'e taşıdıktan sonra kullanıcılar giriş yapmış kalıyor mu, sepet artık tutarlı mı?
Vertical Scaling: Önce Makineyi Büyüt
Tek bir sunucunun nerede yetmeyeceğini saniyedeki istek sayısıyla (QPS, queries per second) bir önceki yazıda, "Back-of-the-Envelope Estimation: QPS, Storage, Bandwidth ve Availability Hesabı"nda bulmuştuk. O noktaya geldiğinde iki yol var. Vertical scaling (scale up), aynı makineye daha fazla CPU (Central Processing Unit), bellek ve daha hızlı disk vermek. Horizontal scaling (scale out), aynı işi yapan makine sayısını artırmak.
Vertical scaling'in en büyük avantajı kodun hiç değişmemesi. Uygulama tek process olarak kalıyor, session bellekte durabiliyor, veritabanı transaction'ları tek bir sunucuda. Bulutta bir VM'in (virtual machine) boyutunu büyütmek birkaç dakikalık iş. Küçümseme: veritabanları uzun yıllar boyunca neredeyse yalnız böyle ölçeklendi ve bugün de tek bir güçlü SQL (Structured Query Language) Server, çoğu orta ölçekli şirketin bütün yükünü taşıyor.
Bedeli üç yerde çıkıyor. Bir tavanı var: en büyük makineden daha büyüğü yok ve tavana yaklaştıkça fiyat doğrusal değil, daha hızlı artıyor. Tek bir makine tek bir hata noktası (single point of failure): o makine düştüğünde ya da yeniden başlatıldığında sistem tamamen kapalı; bir önceki yazının availability hesabındaki paralel bileşen kazancı burada yok. Ve büyütmenin kendisi çoğu zaman bir yeniden başlatma, yani planlı bir kesinti istiyor.
Pratik kural: uygulama tier'ında erken horizontal'a geç, veritabanında vertical'ın sonuna kadar git. Uygulama sunucusunu çoğaltmak ucuz ve geri alınabilir; veritabanını çoğaltmak (replica, sharding) pahalı ve zor. Bunun şartı uygulama tier'ının stateless olması.
Horizontal Scaling ve Stateless Tier
Stateless bir uygulama tier'ında herhangi bir instance herhangi bir isteği karşılayabilir, çünkü istekler arasında hatırlanması gereken her şey instance'ın dışında, paylaşılan bir yerde durur. Load balancer da bu sayede isteği istediği instance'a gönderebilir, instance'lar istendiğinde eklenip çıkarılabilir.
"State yok" demek uygulamanın hiç veri tutmaması değil; veri instance'ın belleğinde ya da diskinde değil demek. Bir ASP.NET Core uygulamasında instance'a bağlı kalan tipik state'ler şunlar:
- Session: varsayılan
AddDistributedMemoryCachesession verisini instance'ın belleğinde tutar. - Data Protection key ring'i: authentication cookie'si, session cookie'si, antiforgery token'ı ve TempData bu key'lerle şifrelenir.
IMemoryCache: her instance'ın kendi cache'i; bir instance'ta invalidate ettiğin veri diğerinde eski kalır.- Yerel diske yazılan dosyalar: kullanıcının yüklediği kapak görseli yalnız onu alan instance'ta.
- Bellekteki sayaçlar: ASP.NET Core rate limiter'ı sayacı instance başına tutar, üç instance'ta limit fiilen üç katına çıkar.
- Her instance'ta çalışan zamanlanmış işler: gece 03:00'teki "sepeti boşalt" işi üç instance'ta üç kez çalışır.
Bu listenin her satırının bir "dışarı alma" karşılığı var: distributed cache (Redis), paylaşılan key storage, object storage (blob), paylaşılan bir sayaç (bu serinin "Rate Limiting Tasarımı: Token Bucket, Sliding Window ve Distributed Rate Limiter" yazısı), tek bir instance'ta çalışan ya da kilit alan job'lar. Burada ilk ikisine, açılıştaki problemin kendisine bakıyoruz.
Deneme: İki Sunucu, Bir Kullanıcı
Problemi tek bir console uygulamasında, aynı process içinde iki ayrı ASP.NET Core uygulaması başlatarak üretelim. Her biri farklı bir content root'tan, yani farklı bir klasöre deploy edilmiş gibi çalışıyor; aradaki load balancer'ın yerine de istekleri sırayla iki port'a gönderen bir HttpClient var. Uygulama üç modda kurulabiliyor:
if (mode == "shared")
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis!, "Bookstore:DataProtection-Keys")
.SetApplicationName("Bookstore.Web");
else // varsayılanın karşılığı: her sunucunun kendi key ring'i
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(contentRoot, "keys")));
if (mode == "local")
builder.Services.AddDistributedMemoryCache();
else
builder.Services.AddStackExchangeRedisCache(o => o.ConnectionMultiplexerFactory = () => Task.FromResult<IConnectionMultiplexer>(redis!));
builder.Services.AddSession();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
local modu iki instance'ın varsayılan hâli: her biri kendi key ring'ini kendi diskinde tutuyor (ASP.NET Core da key'leri varsayılan olarak makinenin yerel bir klasöründe saklar), session kendi belleğinde. redis-session modu açılıştaki ekibin çözümü: session Redis'te, key ring hâlâ yerel. shared modunda ikisi de Redis'te. Endpoint'ler basit: giriş yap, kim olduğunu sor, sepete kitap ekle, sepeti göster.
app.MapGet("/login", async (HttpContext ctx) =>
{
ClaimsIdentity identity = new([new Claim(ClaimTypes.Name, "ada")], CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(new ClaimsPrincipal(identity));
return $"{name}: signed in as ada";
});
app.MapGet("/me", (HttpContext ctx) => $"{name}: user = {ctx.User.Identity?.Name ?? "anonymous"}");
app.MapGet("/cart/add/{bookId:int}", (int bookId, HttpContext ctx) =>
{
string cart = ctx.Session.GetString("cart") is { Length: > 0 } items ? $"{items},{bookId}" : $"{bookId}";
ctx.Session.SetString("cart", cart);
return $"{name}: cart = [{cart}]";
});
app.MapGet("/cart", (HttpContext ctx) => $"{name}: cart = [{ctx.Session.GetString("cart")}]");
// round robin bir load balancer'ın yapacağı gibi istekleri sırayla iki sunucuya gönder
CookieContainer cookies = new();
using HttpClient client = new(new HttpClientHandler { CookieContainer = cookies });
string[] plan = ["5101/login", "5102/me", "5101/cart/add/42", "5102/cart/add/7", "5101/cart"];
Console.WriteLine($"== mode: {mode}");
foreach (string step in plan)
{
string body = await client.GetStringAsync($"http://localhost:{step}");
Console.WriteLine($"GET :{step,-16} -> {body}");
}
Üç modun gerçek çıktısı:
== mode: local
GET :5101/login -> server-a: signed in as ada
GET :5102/me -> server-b: user = anonymous
GET :5101/cart/add/42 -> server-a: cart = [42]
GET :5102/cart/add/7 -> server-b: cart = [7]
GET :5101/cart -> server-a: cart = []
== mode: redis-session
GET :5101/login -> server-a: signed in as ada
GET :5102/me -> server-b: user = anonymous
GET :5101/cart/add/42 -> server-a: cart = [42]
GET :5102/cart/add/7 -> server-b: cart = [7]
GET :5101/cart -> server-a: cart = []
== mode: shared
GET :5101/login -> server-a: signed in as ada
GET :5102/me -> server-b: user = ada
GET :5101/cart/add/42 -> server-a: cart = [42]
GET :5102/cart/add/7 -> server-b: cart = [42,7]
GET :5101/cart -> server-a: cart = [42,7]
local modunda beklenen oldu: Ada server-a'da giriş yaptı, server-b onu tanımadı. Sepet daha da ilginç: server-b, server-a'nın session cookie'sini okuyamadığı için yeni bir session açtı ve tarayıcıya kendi cookie'sini yazdı; son istekte server-a bu kez server-b'nin cookie'sini okuyamadı ve sepet boş döndü. 42 numaralı kitap, onu ekleyen sunucuda bile kayboldu.
Açılıştaki tahminin cevabı ikinci blokta: session'ı Redis'e taşımak hiçbir şeyi düzeltmedi, çıktı birinci blokla satır satır aynı. Sebep, session verisinin nerede durduğu değil, session cookie'sinin nasıl okunduğu. ASP.NET Core'un session dokümantasyonu bunu açıkça yazıyor: session cookie'si IDataProtector ile şifreleniyor ve her makinede okunabilmesi için Data Protection'ın doğru yapılandırılması gerekiyor. server-b, server-a'nın key'iyle şifrelenmiş cookie'yi çözemiyor, içindeki session id'ye hiç ulaşamıyor; Redis'teki veri orada duruyor ama kimse onu bulamıyor. Authentication cookie'si de aynı sebeple çözülemiyor. Ancak üçüncü modda, key ring de paylaşıldığında, iki sunucu tek bir uygulama gibi davranıyor.
Data Protection Key'lerini Paylaşmak
ASP.NET Core Data Protection iki şeye bakarak bir payload'u çözer: key ring'deki key'ler ve application discriminator. Microsoft Learn'deki "Configure ASP.NET Core Data Protection" sayfasına göre sistem, aynı fiziksel key deposunu paylaşsalar bile uygulamaları content root path'lerine göre birbirinden izole ediyor. Yani iki şart var: key'ler paylaşılan bir yerde olmalı ve iki instance SetApplicationName ile aynı adı vermeli. Container'da uygulamanın yolu her instance'ta aynı olduğu için ikinci şart kendiliğinden sağlanabiliyor; farklı klasörlere deploy edilen VM'lerde ya da slot'larda sağlanmıyor. Adı açıkça vermek, bu tesadüfe güvenmemek demek.
Key'lerin duracağı yer için seçenekler: Redis (PersistKeysToStackExchangeRedis), Azure Blob Storage, paylaşılan bir dosya sistemi ya da Entity Framework Core (EF Core) üzerinden veritabanı (PersistKeysToDbContext). İki ayrıntıyı atlama. Birincisi, key'ler at rest korunmalı: Redis'e ya da bir dosyaya düz XML (Extensible Markup Language) olarak yazılan key'ler, oraya erişen herkese cookie üretme imkânı verir; Azure Key Vault, X.509 sertifikası ya da Windows'ta DPAPI (Windows Data Protection Application Programming Interface) ile şifrelemek gerekir. İkincisi, key deposu artık kritik bir bağımlılık: Redis'i silmek ya da yeniden kurmak bütün kullanıcıların oturumunu düşürür. Key'lerin güvenliği OWASP (Open Worldwide Application Security Project) serisinin "OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi" yazısında ayrıntılı.
Session için de küçük bir not: ASP.NET Core'un session sağlayıcısı, LoadAsync çağrılmadan GetString ya da SetString kullanılırsa veriyi distributed cache'ten senkron yükler; dokümantasyon bunun ölçekte performans cezası olduğunu söylüyor. Redis'e taşıdığın session'da her istek bir network round trip; gerçek bir projede session'a ne koyduğunu küçük tut, sepeti session yerine veritabanında ya da kendi Redis key'inde tutmayı düşün.
Sticky Session Neden Asıl Çözüm Değil?
Akla gelen ilk kestirme, load balancer'a "aynı kullanıcıyı hep aynı instance'a gönder" demek: sticky session (session affinity). Azure App Service bunu ARR (Application Request Routing) affinity cookie'siyle varsayılan olarak yapıyor ve ASP.NET Core dokümantasyonu bile in-memory session için bunu bir seçenek olarak sayıyor; hemen ardından ölçeklenebilirliği etkilediğini ve güncellemeleri zorlaştırdığını, daha iyi yolun distributed cache olduğunu ekliyor.
Sticky session state'i ortadan kaldırmıyor, onu bir instance'a kilitliyor. O instance deploy için kapatıldığında ya da çöktüğünde üzerindeki bütün kullanıcıların sepeti gidiyor. Yeni eklenen instance'a eski kullanıcılar gelmiyor, yük dengesiz dağılıyor. Load balancing algoritmaları ve affinity'nin bedeli serinin bir sonraki yazısının konusu; burada akılda kalması gereken, stateless tier'ın sticky session'ı gereksiz kılması.
Mimari Evrim: Tek Kutudan Çok Bölgeye Altı Adım
Stateless tier, büyüyen bir sistemin geçtiği adımlardan yalnız biri. Alex Xu'nun System Design Interview kitabının "Scale from zero to millions of users" bölümü bu yolu adım adım anlatıyor; Bookstore'a uyarlanmış hâli şöyle:

Birinci adımda uygulama ve veritabanı aynı makinede; en ucuz ve en basit hâli bu, ama ikisi aynı CPU ve bellek için yarışıyor ve makine tek hata noktası. İkinci adım veritabanını ayrı bir sunucuya almak: artık ikisi ayrı ölçeklenebiliyor, veritabanı makinesi vertical olarak büyütülebiliyor. Üçüncü adım load balancer arkasında birden fazla stateless instance; bu yazının konusu. Burada kazanılan şey yalnız kapasite değil, availability: bir instance düştüğünde diğerleri devam ediyor.
Dördüncü adım veritabanı replikasyonu: yazmalar primary'ye, okumalar replica'lara gidiyor ve primary düşerse bir replica onun yerine geçebiliyor. Bedeli replication lag; bu serinin "Veri Katmanını Ölçeklemek: Replication, Sharding ve SQL mi NoSQL mi?" yazısının konusu. Beşinci adım cache ve CDN (Content Delivery Network): sık okunan veri Redis gibi bir cache'ten, görseller ve statik dosyalar kullanıcıya yakın bir CDN'den geliyor ve veritabanına giden okumaların büyük kısmı ortadan kalkıyor. Altıncı adımda ağır ve beklemeye dayanıklı işler (fatura PDF'i, e-posta) bir queue üzerinden worker'lara gidiyor ve sistem, kullanıcıları GeoDNS ile en yakın bölgeye yönlendiren birden fazla bölgeye yayılıyor.
Sırayı ezberleme, nedenini ezberle: her adım bir önceki adımın darboğazını çözüyor ve yeni bir karmaşıklık getiriyor. Bookstore'un bugünkü sayılarıyla (katalog aramasında saniyede 200 istek, dakikada 120 sipariş) üçüncü ve beşinci adımlar yetiyor; çok bölge, Bookstore'un "şimdilik değil" listesinde.
Sık Düşülen Tuzaklar
Session'ı Redis'e taşıyıp key ring'i unutmak. Yukarıdaki ikinci çıktı tam olarak bu: veri paylaşılıyor ama kimse onu okuyamıyor.
Key'leri container'ın içinde tutmak. Container her deploy'da yeniden yaratıldığında key ring de yeniden üretilir; her deploy'da bütün kullanıcıların oturumu düşer, hatta tek instance'lı bir sistemde bile.
IMemoryCache'i paylaşılan sanmak. Fiyat değişikliğinden sonra bir instance yeni fiyatı, diğeri eskisini gösterir; cache invalidation'ı bütün instance'lara ulaştırman ya da kısa TTL (time to live) kullanman gerekir.
Zamanlanmış işleri her instance'ta çalıştırmak. Ölçekleme sonrası "günlük rapor e-postası üç kez geldi" şikâyeti bunun belirtisi; job'ı ayrı bir worker'a ya da bir lock arkasına al.
Vertical scaling'i küçümsemek. Uygulaman 200 istek/saniye alıyorsa ve tek bir sunucu bunu rahat kaldırıyorsa, ikinci instance'ın gerekçesi kapasite değil availability'dir; bunu bilerek karar ver.
Ne Zaman Kullanma
Tek instance'lık, kısa kesintiyi kaldırabilen bir iç uygulamada (yönetim paneli, raporlama aracı) horizontal scaling ve paylaşılan key deposu gereksiz bir bağımlılık getirir; Redis'in kendisi de düşebilecek bir component'tir. Veritabanında ise vertical scaling'in sonuna gelmeden sharding'e geçme; uygulama tier'ını çoğaltmanın aksine, veritabanını bölmenin geri dönüşü çok pahalı. Stateful olması gereken sunucular da var: WebSocket bağlantısı tutan bir chat sunucusu bağlantının kendisini dışarı alamaz; onları ölçeklemek ayrı bir problem ve serinin "System Design Case Study: Chat ve Notification Sistemi" yazısında ele alınacak.
Küçük Bir Deneme
★☆☆ shared modunda SetApplicationName satırını sil ve tekrar çalıştır. İki sunucu aynı Redis key ring'ini kullandığı hâlde ne oluyor? Sebebini content root ile açıkla.
★★☆ Örneğe IMemoryCache kullanan bir /price/{bookId} endpoint'i ve fiyatı güncelleyen bir /price/{bookId}/set endpoint'i ekle. Fiyatı server-a'da güncelleyip server-b'den oku. Sorunu iki farklı yolla düzelt ve bedellerini karşılaştır.
★★★ Key ring'i Redis yerine bir SQL Server tablosunda tut (PersistKeysToDbContext) ve key'leri at rest korumak için bir X.509 sertifikası kullan (ProtectKeysWithCertificate). Sertifika iki instance'tan birinde yoksa hangi hatayı görüyorsun?
İki instance'lı Bookstore artık doğru çalışıyor, ama önlerindeki load balancer'ı hiç konuşmadık: istekleri sırayla mı dağıtmalı, en az meşgul olana mı göndermeli, yavaşlayan bir instance'ı nasıl fark etmeli? Bir sonraki yazıda load balancing'e, L4 (Layer 4) ve L7 (Layer 7) farkından health check ve sticky session'ın bedeline kadar bakıyoruz.