System Design Case Study: Chat ve Notification Sistemi
Chat sisteminde bağlantılar stateful gateway'lerde, sıra ve kayıt stateless bir chat service'te durur: mesaj önce kaydedilir, sohbet başına sunucu sayacıyla sıra numarası alır, alıcının gateway'i presence kaydıyla bulunur, çevrimdışı alıcıya push gider. İki gateway arasında Redis ile yönlendirme ve istemci tarafında seq ile sıralama, dedupe ve boşluk tespitinin gerçek çıktıları, okundu bilgisi, grup fan-out'u ve mesaj saklama.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; gateway yönlendirme örneği Redis 7.4 ve StackExchange.Redis 3.4 ile koşturuldu.
Önce Tahmin Et
Okur Topluluğu'na kitap kulüpleri için sohbet ekleniyor. İlk sürüm basit: ASP.NET Core uygulaması WebSocket bağlantılarını bellekteki bir dictionary'de tutuyor, mesaj gelince alıcının bağlantısını bulup yazıyor. Geliştirme ortamında mükemmel çalışıyor. Production'da uygulama bir load balancer'ın arkasında iki instance'a çıkıyor; Ada'nın bağlantısı birinci, Grace'inki ikinci instance'a düşüyor. Mesajları sıralamak için de istemcinin gönderdiği zaman damgası kullanılıyor.
İki soru. Ada, Grace'e bir mesaj yazdığında Grace onu görür mü? Ve Grace'in telefonunun saati iki dakika geri kalmışsa, sohbetteki mesajlar hangi sırada görünür?
Requirement'lar ve Capacity Estimation
Functional requirement'lar: bire bir ve grup sohbeti (bir kitap kulübü en fazla birkaç yüz kişi); mesajlar çevrimiçi alıcıya anında ulaşır; çevrimdışı alıcı push notification alır ve uygulamayı açtığında kaçırdıklarını görür; mesajların sent, delivered ve read durumları; kim çevrimiçi (presence). Non-functional requirement'lar: mesaj kaybolmamalı; bir sohbetteki mesajlar herkeste aynı sırada görünmeli; çevrimiçi alıcıya teslim p99'da (isteklerin %99'unun altında kaldığı süre) 500 ms'nin altında; mesaj geçmişi kalıcı. Kapsam dışı: uçtan uca şifreleme, sesli ve görüntülü arama, dosya paylaşımı.
Varsayımlar: 5 milyon DAU (Daily Active Users), kişi başı günde 40 mesaj, saklanan mesaj başına 1 KB, aynı anda DAU'nun %30'u bağlı, bir gateway node'u 100 bin eşzamanlı bağlantı taşıyor.
const double dau = 5_000_000, messagesPerUser = 40, bytesPerMessage = 1_000, peak = 3, connectionsPerNode = 100_000;
double sendsPerSec = dau * messagesPerUser / 86_400;
messages/s 2,315 (peak 6,944), storage/day 200 GB, per year 73 TB
concurrent connections if 30% of DAU are online: 1,500,000 -> 15 gateway nodes at 100,000 each
Mesaj hızı (saniyede 2.300, peak'te 7.000) tek başına ürkütücü değil. Belirleyici iki sayı var: yılda 73 TB ile büyüyen ve hiç silinmeyen bir mesaj geçmişi, ve 1,5 milyon eşzamanlı, uzun ömürlü bağlantı. İkincisi bu sistemi şimdiye kadarkilerden ayırıyor: HTTP (Hypertext Transfer Protocol) isteği milisaniyeler sürer ve biter; bir WebSocket bağlantısı saatlerce açık kalır ve bir sunucuya bağlıdır. Bağlantı başına bellek, sunucunun dosya tanıtıcı limiti ve deploy sırasında bağlantıların ne olacağı, kapasite planının parçası.
API ve Veri Modeli
İstemci ile sunucu arasında iki kanal var. WebSocket kanalı gerçek zamanlı trafik için: istemci send (clientMsgId, conversationId, text) gönderir; sunucu mesajı kaydettikten sonra ack (clientMsgId, seq) ile cevap verir ve diğer üyelere message (conversationId, seq, from, text) iter; istemci aldığı ve okuduğu mesajlar için delivered ve read (conversationId, seq) bildirir. Normal bir HTTP API (Application Programming Interface) de geçmiş için: GET /api/conversations/{id}/messages?afterSeq=120&limit=50. Uygulamayı açan ya da bağlantısı kopan istemci, gördüğü son seq'ten sonrasını buradan çeker.
Veri modeli: Conversations ve Members (üye başına LastDeliveredSeq ve LastReadSeq), ve Messages. Mesaj tablosu bu sistemin büyüyen kısmı; access pattern'i de belli: "bu sohbetin son 50 mesajı" ve "bu sohbette 120'den sonrası". Bu yüzden partition key sohbet kimliği artı bir zaman dilimi, sıralama anahtarı seq. Discord'un mühendislik blogunda anlattığı tasarım da bu fikirde: 2017'deki "How Discord Stores Billions of Messages" yazısına göre mesajlar kanal kimliği ve sabit bir zaman aralığından oluşan bir partition key ile saklanıyordu; 2023'teki "How Discord Stores Trillions of Messages" yazısı ise 177 node'luk Cassandra cluster'ındaki hot partition sorunlarını ve 72 node'luk ScyllaDB'ye geçişi anlatıyor. Zaman dilimi, çok aktif bir sohbetin tek bir partition'ı sonsuza kadar büyütmesini engelliyor.
High-Level Design

İki tür sunucu var. Gateway node'ları stateful: WebSocket bağlantılarını tutar, başka iş yapmaz. Chat service stateless: mesaja sıra numarası verir, kaydeder ve alıcıların nerede olduğunu bulup iletir. Aradaki bağ presence kaydı (presence:{user} → hangi gateway) ve her gateway'in dinlediği bir pub/sub kanalı. Alıcı hiçbir gateway'de değilse mesaj push worker'ına gider. Önce Tahmin Et'teki ilk sorunun cevabı da bu şemada: tek instance'lık tasarımda bağlantılar bir dictionary'deydi; iki instance olunca Grace'in bağlantısı Ada'nın mesajını işleyen instance'ın belleğinde yok ve mesaj sessizce kayboluyor.
Deep Dive: WebSocket ve Stateful Sunucuları Ölçeklemek
WebSocket, 2011'de RFC (Request for Comments) 6455 olarak standartlaştı. Bir HTTP isteğiyle başlar (Upgrade: websocket) ve sonra aynı TCP (Transmission Control Protocol) bağlantısı üzerinde iki yönlü bir kanala dönüşür. Serinin "Tek Sunucudan Milyonlarca Kullanıcıya: Vertical ve Horizontal Scaling, Stateless Tier" yazısında app tier'ı stateless yapmayı konuşmuştuk; WebSocket bu kuralın bilinçli istisnası. Bağlantının kendisi state'tir. Yapılacak şey state'i en küçük alana hapsetmek: gateway yalnız bağlantıyı tutar, iş mantığı ve veri stateless chat service'te kalır.
Bunun pratik sonuçları var. Load balancer uzun ömürlü bağlantıları desteklemeli ve idle timeout'u WebSocket'in ping/pong aralığından uzun olmalı; yoksa sessiz bir sohbetin bağlantısı arada kesilir. Bir gateway'i deploy etmek, üstündeki on binlerce bağlantıyı kapatmak demek; kapatılan istemcilerin hepsi aynı saniyede yeniden bağlanmaya çalışırsa kalan node'lar bir thundering herd yaşar. Bu yüzden deploy sırasında bağlantılar kademeli boşaltılır (connection draining) ve istemcinin yeniden bağlanma mantığı serinin "Reliability Pattern'leri: Timeout, Retry, Backoff + Jitter, Circuit Breaker ve Load Shedding" yazısındaki gibi jitter'lı backoff kullanır.
Yönlendirmeyi iki gateway ve bir Redis ile kurdum. Her gateway bağlanan kullanıcının presence kaydını 30 saniyelik bir TTL (Time To Live) ile yazıyor ve kendi kanalını dinliyor; chat service mesaja sıra numarası verip kaydediyor, sonra her alıcının gateway'ini bulup o kanala yayınlıyor:
public async Task<ChatMessage> SendAsync(string conversationId, string from, string[] members, string text)
{
IDatabase db = redis.GetDatabase();
long seq = await db.StringIncrementAsync($"conv:{{{conversationId}}}:seq"); // sırayı sunucu belirler
ChatMessage message = new(conversationId, seq, from, text);
await db.ListRightPushAsync($"conv:{{{conversationId}}}:log", JsonSerializer.Serialize(message)); // iletmeden önce kaydet
foreach (string to in members.Where(m => m != from))
{
RedisValue gateway = await db.StringGetAsync($"presence:{to}");
if (gateway.HasValue)
await redis.GetSubscriber().PublishAsync(RedisChannel.Literal($"gw:{gateway}"),
JsonSerializer.Serialize(new Envelope(to, message)));
else
await db.ListLeftPushAsync("push:queue", $"{to}: new message from {from}"); // çevrimdışı: push notification
}
return message;
}
Ada gw-1'de, Grace gw-2'de, Alan çevrimdışı; iki mesaj:
gw-2 -> grace : #1 ada: Chapter 3 tonight?
gw-1 -> ada : #2 grace: Yes, 9 pm
push queue : alan: new message from ada | alan: new message from grace
presence : ada=gw-1 (ttl 30 s), alan=offline
Ada'nın mesajı gw-1'e gelmesine rağmen gw-2'deki Grace'e ulaştı; Alan için iki push notification kuyruğa girdi. Redis pub/sub "fire and forget" çalışır: o an kanalı dinleyen yoksa mesaj kaybolur. Bu tasarımda sorun değil, çünkü mesaj yayınlanmadan önce kaydedildi; teslim edilemeyen mesaj, istemci yeniden bağlandığında geçmiş API'sinden gelir. Pub/sub burada yalnız hızlı yol, kalıcılık mesaj deposunda. .NET dünyasında bu yapının hazır hâli SignalR ve Redis backplane'i ya da yönetilen bir SignalR servisi; ayrıntıları ilerideki Backend kategorisindeki Real-time Uygulamalar serisinin konusu.
Deep Dive: Mesaj Sırası ve Teslim
Önce Tahmin Et'teki ikinci sorunun cevabı: istemci zaman damgasıyla sıralanan sohbette, saati iki dakika geride olan Grace'in cevapları, cevap verdiği mesajlardan önce görünür. İstemci saatlerine güvenilmez. Sırayı tek bir yer belirlemeli: sohbet başına bir sayaç (yukarıdaki INCR), ya da sohbet başına tek bir partition'a yazılan bir log. Messaging ve Event-Driven Architecture serisinin "Message Ordering ve Partitioning: Sıra Garantisi Nerede Başlar, Nerede Biter?" yazısının ana fikri burada da geçerli: global sıra pahalı ve gereksiz, sohbet başına sıra yeterli.
Sunucu sırayı belirlese de ağ, mesajları geç, iki kez ya da hiç teslim etmeyebilir: bağlantı koptuğunda gateway'in tampondaki mesajları gider, yeniden bağlanan istemci bazılarını hem push'tan hem geçmişten alır. seq bu üç durumu da istemci tarafında çözülebilir kılıyor:
public sealed class ClientInbox
{
private readonly SortedDictionary<long, ChatMessage> _pending = [];
public long LastShown { get; private set; }
public List<ChatMessage> Shown { get; } = [];
public string Receive(ChatMessage m)
{
if (m.Seq <= LastShown || _pending.ContainsKey(m.Seq)) return $"seq {m.Seq}: duplicate, dropped";
_pending[m.Seq] = m;
while (_pending.Remove(LastShown + 1, out ChatMessage? next))
{
Shown.Add(next);
LastShown = next.Seq;
}
return _pending.Count == 0
? $"seq {m.Seq}: shown up to {LastShown}"
: $"seq {m.Seq}: buffered, waiting for {LastShown + 1}";
}
}
6 erken gelsin, 4 iki kez gelsin, 5 kaybolsun:
seq 1: shown up to 1
seq 2: shown up to 2
seq 3: shown up to 3
seq 4: shown up to 4
seq 6: buffered, waiting for 5
seq 4: duplicate, dropped
seq 7: buffered, waiting for 5
gap detected: fetch seq 5 from history
seq 5: shown up to 7
shown: 1,2,3,4,5,6,7
Tekrar eden mesaj atıldı, erken gelenler bekletildi ve boşluk fark edildi; istemci 5'i afterSeq=4 ile geçmişten istedi ve sohbet doğru sırada tamamlandı. Gönderen taraftaki karşılığı clientMsgId: istemci ack alamadığı mesajı tekrar gönderdiğinde sunucu aynı clientMsgId'yi tanır ve yeni bir seq vermeden eskisini döner. Bu, Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısındaki fikrin sohbet hâli.
Teslim durumları da seq üzerinden ucuzlar. "Sent", sunucunun mesajı kaydedip ack döndüğü an. "Delivered" ve "read" için her mesaja ayrı bayrak tutmak yerine üye başına iki sayı yeter: LastDeliveredSeq ve LastReadSeq. "Grace 42'ye kadar okudu" demek, 42 ve öncesinin hepsinin okunduğu demek; 200 kişilik bir grupta mesaj başına 200 satır yerine üye başına iki sayı.
Deep Dive: Çevrimdışı Kullanıcı, Push ve Presence
Çevrimdışı kullanıcıya mobilde ulaşmanın yolu platformun push servisi: Apple cihazlar için APNs (Apple Push Notification service), Android için FCM (Firebase Cloud Messaging). Push worker bunları queue'dan okuyup çağırır; platform servislerinin kendi hız limitleri ve hata kodları olduğu için bu iş request yolunda değil, kendi retry ve backoff mantığıyla asenkron yapılır. Push bildirimi mesajın taşıyıcısı değil, bir uyandırıcıdır: içinde kısa bir önizleme olur, kullanıcı uygulamayı açınca mesajlar geçmiş API'sinden gelir. Böylece kaybolan ya da geç gelen bir push veri kaybı yaratmaz. Bir kullanıcının birden fazla cihazı olabileceği için presence ve push, kullanıcı değil cihaz düzeyinde tutulur.
Presence kaydı TTL'li: istemci her 15–20 saniyede bir heartbeat gönderir, gateway kaydı yeniler; bağlantı sessizce öldüğünde kayıt 30 saniyede kendiliğinden düşer. Asıl maliyet presence'ı yaymak. Bir kullanıcının çevrimiçi olduğunu bütün arkadaşlarına anında bildirmek, her bağlanma ve kopmada bir fan-out demek; serinin "System Design Case Study: News Feed ve Timeline" yazısındaki fan-out problemi burada da var. Çoğu ürün bunu gevşetiyor: presence yalnız açık olan sohbetin üyelerine gönderiliyor ya da istemci ekrandaki kişiler için periyodik olarak soruyor; "son görülme" birkaç saniye geç olsa kimse fark etmiyor.
Deep Dive: Grup Sohbeti ve Mesaj Saklama
Birkaç yüz kişilik bir kitap kulübünde fan-out yazma anında yapılabilir: her mesaj, üye sayısı kadar presence sorgusu ve publish demek. Daha verimli hâli, publish'i kullanıcı başına değil gateway başına yapmak: aynı gateway'de o sohbetin 40 üyesi varsa tek bir publish yeter, gateway mesajı yerelde 40 bağlantıya dağıtır. Binlerce ya da yüz binlerce üyeli kanallarda (bir yazarın duyuru kanalı gibi) herkese iletmek yerine gateway'ler kanala abone olur ve yalnız o an kanalı açık tutan istemcilere iletir; diğerleri bir sonraki açılışta geçmişten okur. Bu, News Feed'deki hibrit modelin sohbet karşılığı.
Saklama tarafında yılda 73 TB büyüyen, neredeyse hiç güncellenmeyen, hep sohbet ve zaman aralığıyla okunan bir veri var. Bu access pattern, serinin "Veri Katmanını Ölçeklemek: Replication, Sharding ve SQL mi NoSQL mi?" yazısındaki (SQL: Structured Query Language) karar lensiyle wide-column bir store'a (Cassandra, ScyllaDB) iyi oturur; ilişkisel bir veritabanında da sohbet kimliğine göre sharding ve zaman bazlı partitioning ile yapılabilir. Discord'un 2023 yazısındaki ders ise depodan bağımsız: çok popüler bir kanal tek bir partition'ı sıcak tutar. Discord, veritabanının önüne aynı anda gelen aynı sorguları tek sorguya indiren (request coalescing) bir data service katmanı koymuş; serinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısındaki request coalescing'in veritabanı önündeki hâli.
Trade-off'lar
| Karar | Seçtiğimiz | Alternatif ve bedeli |
|---|---|---|
| Bağlantı | WebSocket, stateful gateway'ler | Long polling: daha basit altyapı, ama her mesajda yeni istek ve daha yüksek latency |
| Sıra | Sohbet başına sunucu sayacı | İstemci zamanı: koordinasyonsuz ama saat kaymasında yanlış sıra; global sıra: gereksiz darboğaz |
| Yönlendirme | Presence + gateway başına pub/sub kanalı | Her gateway'e broadcast: basit ama node sayısıyla büyüyen boşa trafik |
| Teslim garantisi | Önce kaydet, at-least-once ilet, istemcide seq ile dedupe | Yalnız pub/sub: hızlı ama bağlantı kopunca mesaj kaybı |
| Okundu bilgisi | Üye başına LastReadSeq | Mesaj başına bayrak: ayrıntılı ama grup büyüdükçe satır patlaması |
Sık Düşülen Tuzaklar
Bağlantıları instance belleğinde tutup çoğaltmak
İkinci instance'ta mesaj sessizce kaybolur. Bağlantının nerede olduğu paylaşılan bir kayıtta (presence) durmalı ve mesaj oraya yönlendirilmeli.
İstemci saatine göre sıralamak
Telefon saatleri kayar. Sırayı sunucu belirler, istemci yalnız gösterir.
Önce iletip sonra kaydetmek
İletim başarılı, kayıt başarısızsa mesaj alıcıda görünür ama geçmişte yoktur. Önce kaydet, sonra ilet.
Push'u veri kanalı sanmak
Push gecikebilir ve kaybolabilir. Uyandırıcıdır; mesajlar uygulama açılınca geçmişten gelir.
Deploy'da bütün bağlantıları aynı anda kesmek
On binlerce istemcinin aynı saniyede yeniden bağlanması kalan node'ları düşürür. Kademeli boşalt, istemcide jitter'lı backoff kullan.
Küçük Bir Deneme
★☆☆ Capacity hesabını 20 milyon DAU ve aynı anda %50 bağlı kullanıcı ile tekrar yap. Kaç gateway node'u gerekir? Bir node düştüğünde yeniden bağlanan istemciler kalan node'lara ne kadar yük bindirir?
★★☆ ChatService.SendAsync'e clientMsgId ekle: aynı clientMsgId ile ikinci kez gelen mesaj yeni bir seq almasın, ilk mesajın seq'i dönsün. Redis'te bunu atomik yapmanın yolunu seç ve test et.
★★★ Publish'i kullanıcı başına değil gateway başına yapacak şekilde yönlendirmeyi değiştir: 200 üyeli bir grupta, üyeler 15 gateway'e dağılmışken mesaj başına kaç publish yapılıyor?
Şimdiye kadar trafiği öngörülebilir sistemler tasarladık. Serinin son yazısında trafiğin bir saniyede yüz katına çıktığı ve stokun sınırlı olduğu bir senaryoya bakacağız: flash sale ve konser bileti satışı.