Sohbet ve mesajlaşma sistemi
Problem
"WhatsApp ya da Slack benzeri bir mesajlaşma sistemi tasarla: bire bir ve grup sohbetleri (en fazla 500 üye), mesajların gönderildi / teslim edildi / okundu durumları, çevrimiçi bilgisi, kullanıcı çevrimdışıyken gelen mesajlar ve aynı hesabın telefon ve bilgisayarda aynı anda açık olması."
Bu soru, HTTP istek-cevap dünyasından çıkıp kalıcı bağlantılarla çalışan, durumlu (stateful) bir sistemi tasarlayıp tasarlayamadığını ölçer. Mülakatçının dinlemek istediği konular: WebSocket bağlantılarının hangi sunucuda olduğunun takibi, mesaj sırasının saatlere değil sıra numaralarına dayanması, en az bir kez teslim + istemci tarafında tekilleştirme, çevrimdışı ve çoklu cihaz senkronizasyonu, okundu bilgisinin mesaj başına değil kullanıcı başına tutulması ve mesaj deposunun partition tasarımı.
Netleştirme soruları ve varsayımlar
| Soru | Neden önemli | Varsayım |
|---|---|---|
| Kaç günlük aktif kullanıcı, kişi başı kaç mesaj? | Yazma yükü ve depolama | 50 milyon DAU, kişi başı günde 40 mesaj |
| Grup boyutu sınırı? | Fan-out stratejisi | 500 üye; binlerce üyeli kanallar kapsam dışı (sonda tartışılacak) |
| Mesaj geçmişi ne kadar saklanır? | Depolama | Süresiz (kullanıcı silene kadar) |
| Çoklu cihaz var mı? | Senkronizasyon modeli | Evet: telefon + web/masaüstü, aynı anda |
| Teslim ve okundu bilgisi grupta da gösterilecek mi? | Veri modeli | Bire birde tikler; grupta "N kişi okudu" |
| Uçtan uca şifreleme? | Sunucunun içeriği görüp göremeyeceği, arama | Kapsam dışı; sunucu içeriği şifreli saklar ama okuyabilir |
| Medya? | Depolama ve bant genişliği | Medya nesne deposunda, mesajda referans |
| Mesaj teslim gecikmesi hedefi? | Mimari | Çevrimiçi alıcıya p99 birkaç yüz ms |
Kapasite tahmini
Mesaj:
50M DAU x 40 mesaj = 2 milyar mesaj/gün
2.000.000.000 / 86.400 ≈ 23.000 mesaj/s ortalama, tepe (x3) ≈ 70.000 /s
Teslim (fan-out):
mesajların %70'i bire bir (1 alıcı), %30'u grup (ortalama 20 alıcı varsayımı)
1,4 milyar x 1 + 0,6 milyar x 20 = 13,4 milyar teslim/gün ≈ 155.000 /s, tepe ≈ 465.000 /s
her teslim için bir "teslim edildi" onayı ve büyük kısmı için "okundu" -> trafik ~2-3 katına çıkar
Depolama (mesaj konuşma başına bir kez saklanır, alıcı başına değil):
ortalama ~200 B (metin + meta) -> 2 milyar x 200 B = 400 GB/gün ≈ 146 TB/yıl
replikasyon faktörü 3 -> ≈ 440 TB/yıl
Bağlantılar:
tepe anda eşzamanlı çevrimiçi ≈ DAU'nun %20'si = 10 milyon WebSocket
sunucu başına ~100.000 bağlantı (temkinli) -> ~100 gateway sunucusu
bağlantı başına ~50 KB (TLS ve tamponlar dahil) -> sunucu başına ~5 GB bellek
30 saniyede bir heartbeat -> 10M / 30 ≈ 333.000 küçük çerçeve/s
Teşhis:
- Teslim sayısı mesaj sayısının yaklaşık 7 katı (grup fan-out'u). Onaylarla birlikte gerçek mesajlaşma trafiği mesaj sayısının onlarca katıdır; onayları toplu ve kümülatif yapmak önemlidir.
- Mesajlar alıcı başına değil konuşma başına saklanır; aksi halde depolama grup boyutuyla çarpılır.
- Heartbeat bile ciddi bir yük. 10 milyon bağlantı, mesaj olmasa bile saniyede yüz binlerce çerçeve demektir.
- Yazma ağırlıklı, büyüyen ve zamana göre okunan veri: "Bu konuşmanın son 50 mesajı" sorgusu baskın. Bu desen geniş sütunlu, partition anahtarıyla dağıtılan bir depoya (Cassandra/ScyllaDB) çok uyar.
API
İstemci iki kanal kullanır: mesajlaşma için WebSocket, geçmiş, konuşma listesi ve medya için REST.
WebSocket çerçeveleri (JSON ya da protobuf):
-> { "type": "SEND", "clientMsgId": "c-8f2...", "conversationId": "cv_42", "body": "Merhaba" }
<- { "type": "SENT", "clientMsgId": "c-8f2...", "messageId": "1849...", "seq": 1017 }
<- { "type": "MESSAGE", "conversationId": "cv_42", "messageId": "1849...", "seq": 1017,
"senderId": 7, "body": "Merhaba", "sentAt": "..." }
-> { "type": "ACK_DELIVERED", "conversationId": "cv_42", "upToSeq": 1017 }
-> { "type": "ACK_READ", "conversationId": "cv_42", "upToSeq": 1017 }
<- { "type": "RECEIPT", "conversationId": "cv_42", "userId": 9, "deliveredUpTo": 1017, "readUpTo": 1015 }
-> { "type": "PING" } <- { "type": "PONG" }
GET /api/v1/conversations?updatedSince=... # konuşma listesi ve okunmamış sayıları
GET /api/v1/conversations/{id}/messages?beforeSeq=1000&limit=50 # geçmiş
GET /api/v1/sync?cursor=... # çevrimdışı dönüşte kaçırılanlar
POST /api/v1/media/upload-url # imzalı yükleme URL'i
POST /api/v1/conversations # grup oluşturma
clientMsgIdistemcinin ürettiği tekil kimliktir; bağlantı koptuğunda aynı mesaj tekrar gönderilirse sunucu bunu tanır ve ikinci bir mesaj oluşturmaz.- Onaylar kümülatiftir (
upToSeq): "1017'ye kadar her şeyi aldım" tek çerçevedir; her mesaj için ayrı onay gönderilmez.
Veri modeli
-- Mesajlar (Cassandra / ScyllaDB, CQL)
CREATE TABLE messages (
conversation_id bigint,
bucket int, -- zaman kovası, ör. 10 günlük dilim
seq bigint, -- konuşma içi monoton sıra numarası
message_id bigint, -- global tekil, zamana göre sıralı (Snowflake benzeri)
sender_id bigint,
client_msg_id text,
body blob, -- şifreli içerik
media_ref text,
created_at timestamp,
PRIMARY KEY ((conversation_id, bucket), seq)
) WITH CLUSTERING ORDER BY (seq DESC);
-- Konuşma üyeleri ve her üyenin okuma durumu (konuşma başına üye satırı)
CREATE TABLE conversation_members (
conversation_id bigint,
user_id bigint,
role text,
delivered_up_to bigint,
read_up_to bigint,
PRIMARY KEY ((conversation_id), user_id)
);
-- Kullanıcının konuşma listesi (sohbetler ekranı)
CREATE TABLE user_conversations (
user_id bigint,
conversation_id bigint,
last_seq bigint,
last_message_at timestamp,
PRIMARY KEY ((user_id), conversation_id)
);
Bağlantı kaydı (Redis): session:{userId} → {deviceId: gatewayId} hash'i; heartbeat ile yenilenen TTL. Hangi kullanıcının hangi gateway sunucusunda bağlı olduğunu söyler.
Sıra sayacı: seq:{conversationId} için konuşma başına tek yazıcı (aşağıda).
Partition tasarımının gerekçesi: "Bir konuşmanın son 50 mesajı" sorgusu tek bir partition'dan, sıralı okunur. Ama bir konuşma sonsuza kadar büyüyebilir; tek partition'da tutmak partition'ı sınırsız büyütür. Discord, mesajlarını (channel_id, bucket) ile bölüp partition'ları yaklaşık 10 günlük kovalarla 100 MB'ın altında tutmayı hedeflediğini anlatır; büyük partition'lar compaction ve küme genişlemesinde ağır GC baskısı yaratır. Kova, mesaj zamanından türetilebildiği için en yeni mesajlar için hangi kovaya bakılacağı bellidir; az aktif konuşmalarda birkaç kova geriye gitmek gerekebilir.
Yüksek seviye tasarım
flowchart LR
A[İstemci cihazları] -->|WebSocket| LB[L4 load balancer]
LB --> GW[Chat gateway sunucuları]
A -->|REST| API[API servisi]
GW --> SR[(Redis oturum kaydı)]
GW --> CS[Mesaj servisi]
CS --> SEQ[Sıra atayıcı]
CS --> MDB[(Mesaj deposu Cassandra)]
CS --> K[(Kafka konuşma bazlı partition)]
K --> FO[Teslim worker'ları]
FO --> SR
FO -->|gateway'e yönlendirme| GW
FO -->|alıcı çevrimdışı| NS[Bildirim servisi]
API --> MDB
API --> OBJ[(Medya deposu ve CDN)]
PR[Presence servisi] --> SRGönderim: İstemci mesajı bağlı olduğu gateway'e gönderir. Gateway mesaj servisine iletir; mesaj servisi clientMsgId ile tekrarı kontrol eder, konuşma içi seq atar, mesajı depoya yazar ve gönderene SENT döner. Kalıcı yazma tamamlanmadan SENT dönülmez: tek tik "sunucu mesajı sakladı" demektir.
Teslim: Mesaj servisi MessageStored event'ini konuşma id'siyle anahtarlanmış Kafka topic'ine yazar. Teslim worker'ları konuşma üyelerini bulur, her üyenin bağlı cihazlarını oturum kaydından okur ve mesajı o cihazların bağlı olduğu gateway'e iletir. Çevrimdışı cihazlar için bildirim servisi push gönderir; mesajın kendisi zaten depodadır, cihaz bağlandığında senkronizasyonla alır.
Derinlemesine
Bağlantı katmanı: WebSocket ve durumlu gateway'ler
Seçenekler: kısa polling (gereksiz istek, gecikme), long polling (her mesajdan sonra yeni istek, sunucu tarafında bekleyen istek yönetimi), Server-Sent Events (sadece sunucudan istemciye), WebSocket (çift yönlü, tek kalıcı bağlantı). Mesajlaşma çift yönlü ve düşük gecikmeli olduğu için WebSocket seçilir; REST geçmiş ve medya için kalır.
Gateway sunucuları durumludur: her biri on binlerce bağlantıyı tutar. Bunun sonuçları:
- Oturum kaydı: Hangi kullanıcının hangi gateway'de olduğunu bilmek gerekir. Gateway, bağlantı kurulunca
session:{userId}hash'inedeviceId → gatewayIdyazar, heartbeat'lerle TTL'i yeniler. Gateway çökerse TTL dolunca kayıt temizlenir. - Yük dengeleme: Bağlantılar uzun yaşar; yeni bir gateway eklendiğinde mevcut bağlantılar kendiliğinden dağılmaz. Yeni bağlantılar az yüklü sunuculara yönlendirilir; gerekirse sunucular bağlantılarını kademeli kapatıp istemcilerin yeniden bağlanmasını sağlar.
- Deploy: Gateway'i yeniden başlatmak on binlerce bağlantıyı koparır. Graceful shutdown: yeni bağlantı kabul etme, mevcut istemcilere "yeniden bağlan" çerçevesi gönder, rastgele gecikmeyle (jitter) yeniden bağlanmalarını sağla; aksi halde 100.000 istemci aynı saniyede başka sunuculara saldırır (thundering herd).
- Heartbeat: Mobil ağlarda bağlantı sessizce ölebilir (NAT zaman aşımı). Uygulama seviyesinde periyodik ping, ölü bağlantıları tespit eder ve presence bilgisini besler.
Mesaj sırası: saat değil sıra numarası
Cihaz saatlerine güvenilemez; iki sunucunun saati de milisaniyelerce farklı olabilir. Sıra, konuşma başına tek bir yazıcının atadığı monoton bir sıra numarasıyla (seq) belirlenir:
- Kafka'da konuşma id'si anahtar olursa aynı konuşmanın mesajları aynı partition'a düşer ve tek bir tüketici tarafından sırayla işlenir;
seqo tüketicide atanabilir. - Daha basit bir alternatif: Redis'te
INCR seq:{conversationId}. Atomiktir ve hızlıdır; ama failover'da asenkron replikasyon yüzünden son artışlar kaybolabilir ve aynıseqiki kez verilebilir. Bu yüzden depoya yazarken(conversation_id, bucket, seq)çakışması kontrol edilir (Cassandra'daIF NOT EXISTSpahalı olduğu için tercihen tek yazıcı modeli). seqile birlikte global, zamana göre sıralı birmessage_idde atanır (Snowflake benzeri); referans ve tekilleştirme içindir.
seq'in ikinci faydası boşluk tespitidir: istemci 1015'i aldıktan sonra 1017 gelirse 1016'yı kaçırdığını bilir ve GET .../messages?afterSeq=1015 ile ister. Böylece teslim kanalındaki kayıplar senkronizasyonla kapanır.
Neyin sırası garanti edilir? Bir konuşma içinde, sunucunun kabul ettiği sıra. İki kullanıcının aynı anda yazdığı mesajların "gerçek" sırası diye bir şey yoktur; sunucunun atadığı sıra herkes için aynıdır, önemli olan budur.
Teslim semantiği: en az bir kez + tekilleştirme
Mesajın her adımı tekrar edebilir: istemci SENT gelmeden bağlantı koparsa mesajı tekrar gönderir; teslim worker'ı mesajı gateway'e iletir ama onay almadan çöker ve tekrar iletir. Kural:
- Sunucu tarafında:
clientMsgId(gönderen + istemci kimliği) üzerinden tekilleştirme. AynıclientMsgIdikinci kez gelirse mevcutmessageIdveseqileSENTdönülür. - İstemci tarafında: Alınan mesajlar
messageIdile tekilleştirilir; aynı mesaj iki kez gelirse ikinci kez gösterilmez. - Kaybın kapatılması: Gerçek zamanlı teslim kanalı "en iyi çaba"dır; kalıcılık depoda, eksiksizlik senkronizasyondadır (
seqboşlukları ve yeniden bağlanmada cursor).
Bu ayrım önemli bir tasarım özgürlüğü verir: gateway'ler arası yönlendirmede Redis Pub/Sub gibi en fazla bir kez teslim eden bir mekanizma bile kullanılabilir. Redis dokümantasyonu Pub/Sub'ın en fazla bir kez semantiğine sahip olduğunu, abone mesajı işleyemezse mesajın kaybolduğunu açıkça söyler; ama mesaj zaten kalıcı depoda olduğu ve istemci boşluğu fark edip isteyeceği için bu kayıp kalıcı değildir. Redis 7.0'daki sharded Pub/Sub, cluster modunda mesajın sadece ilgili shard içinde yayılmasını sağlayarak cluster veri yolundaki trafiği azaltır.
sequenceDiagram
participant A as Gönderen cihaz
participant G1 as Gateway 1
participant M as Mesaj servisi
participant D as Mesaj deposu
participant W as Teslim worker'ı
participant G2 as Gateway 2
participant B as Alıcı cihaz
A->>G1: SEND clientMsgId
G1->>M: mesajı ilet
M->>D: seq ata ve yaz
M-->>G1: SENT messageId seq
G1-->>A: SENT, tek tik
M->>W: MessageStored event
W->>G2: oturum kaydından gateway bul ve ilet
G2->>B: MESSAGE seq
B->>G2: ACK_DELIVERED upToSeq
G2->>M: teslim durumunu güncelle
M-->>A: RECEIPT, çift tikÇevrimdışı ve çoklu cihaz senkronizasyonu
Her cihaz, her konuşma için en son aldığı seq'i bilir. Senkronizasyon modeli "cihaz kaçırdığı şeyleri ister" üzerine kuruludur, "sunucu her cihaz için ayrı kuyruk tutar" üzerine değil:
- Cihaz bağlandığında
GET /sync?cursor=...ile çağrı yapar. Cursor, cihazın en son gördüğü genel güncelleme noktasıdır. - Sunucu
user_conversationstablosundanlast_seq'i cihazın bildiğinden büyük olan konuşmaları döner (okunmamış rozetleri için de bu yeterli). - Cihaz, açtığı konuşmalar için eksik
seqaralığını mesaj deposundan sayfa sayfa çeker.
Çoklu cihazda gönderilen mesaj, gönderenin diğer cihazlarına da teslim edilir (teslim worker'ı alıcı listesine gönderenin diğer cihazlarını da ekler). Bir cihazda okunan konuşma read_up_to güncellemesiyle diğer cihazlarda da okunmuş görünür.
Çevrimdışı alıcıya push bildirimi gider; push, mesajın kendisini taşımak zorunda değildir ("Ahmet: yeni mesaj"). Kullanıcı uygulamayı açtığında senkronizasyon gerçek içeriği getirir. Bildirim sistemi case'indeki öncelik ve tekilleştirme kuralları burada da geçerlidir.
Teslim ve okundu bilgisi: mesaj başına değil, kullanıcı başına
Her mesaj ve her alıcı için bir "okundu" satırı tutmak, günde 13,4 milyar teslim için 13,4 milyar satır demektir. Bunun yerine her üyenin her konuşmadaki durumu iki sayıdır: delivered_up_to ve read_up_to. Bir mesajın durumu bu sayılarla karşılaştırılarak türetilir:
- Bire birde: alıcının
read_up_to >= mesaj.seqise mavi tik. - Grupta: "okuyanlar" listesi,
read_up_to >= mesaj.seqolan üyelerdir; 500 üyeli grupta bile tek konuşma partition'ından okunur.
Onaylar kümülatif ve istemcide toplu gönderilir (kullanıcı 30 mesajı birden okursa tek ACK_READ upToSeq). Gönderene giden RECEIPT çerçeveleri de birleştirilir ve kısa aralıklarla gönderilir. Okundu bilgisini kapatan kullanıcının read_up_to'su yine tutulur (okunmamış sayısı için), sadece başkalarına yayınlanmaz.
Presence: çevrimiçi bilgisi pahalıdır
Naif yaklaşım: kullanıcı çevrimiçi/çevrimdışı olduğunda bütün kişilerine haber vermek. 10 milyon çevrimiçi kullanıcının her biri ortalama 200 kişiye sahipse, her durum değişikliği 200 mesaj demektir; mobil ağlarda bağlantı sık koptuğu için bu bir fırtınaya dönüşür. Önlemler:
- Durum sadece o an ilgili ekranı açık olan kullanıcılara gönderilir (konuşma ekranı açıkken abone ol, kapanınca abonelikten çık).
- Kısa kopmalar yutulur: bağlantı koptuktan sonra 30 saniye içinde geri gelirse "çevrimdışı" yayınlanmaz.
- "Son görülme" heartbeat'ten türetilir ve dakika hassasiyetinde tutulur.
- Büyük gruplarda üyelerin presence bilgisi gösterilmez ya da sadece sayı olarak gösterilir.
Grup sohbeti ve büyük kanallar
500 üyeli gruplarda fan-out on write uygundur: mesaj bir kez saklanır, teslim worker'ı 500 üyenin bağlı cihazlarına iletir. On binlerce üyeli kanallarda (Slack/Discord tarzı) her mesajı her üyeye itmek yerine kanal aboneliği kullanılır: gateway, o kanalı o an görüntüleyen bağlantıları bilir ve mesajı sadece onlara iletir; diğer üyeler kanalı açtıklarında senkronizasyonla okur ve sadece okunmamış sayısı güncellenir. Bu, haber akışındaki "dev hesap" problemiyle aynı fikirdir.
Depolama ve sıcak partition'lar
Discord'un mesaj deposu deneyimi bu tasarımın risklerini somut gösterir: büyük ve çok aktif kanallarda partition'lar sıcaklaşıp hem o node'un hem quorum okuma/yazma nedeniyle kümenin gecikmesini artırdı; JVM GC duraklamaları ve tombstone birikimi operasyonel yük yarattı. Discord, mesaj kümesini 177 Cassandra node'undan 72 ScyllaDB node'una taşıdığını, geçmiş mesaj okumada p99'un 40-125 ms'den 15 ms'ye, yazmada 5-70 ms'den sabit 5 ms'ye indiğini raporlar. Ayrıca API ile veritabanı arasına konan veri servisleri, aynı satırı aynı anda isteyen kullanıcıların isteklerini tek veritabanı sorgusunda birleştirir (request coalescing) ve istekleri kanal id'siyle tutarlı hash'leyerek aynı servis örneğine yönlendirir. Mülakatta bu deneyimi "sıcak partition için request coalescing ve tutarlı yönlendirme" olarak özetlemek güçlü bir sinyaldir.
Diğer depolama kararları:
- Silme: Mesaj silme, satır silmek yerine "silindi" işaretiyle yapılır; Cassandra'da her silme bir tombstone üretir ve yoğun silme okumayı yavaşlatır.
- Medya: İstemci imzalı URL ile nesne deposuna yükler; mesajda referans durur; indirme CDN üzerinden.
- Arama: Mesaj içinde arama ayrı bir arama indeksi ister; uçtan uca şifrelemede sunucu tarafı arama mümkün değildir (istemci tarafında yapılır).
Darboğazlar, hatalar ve trade-off'lar
| Senaryo | Etki | Önlem |
|---|---|---|
| Gateway sunucusu çöker | On binlerce bağlantı kopar | İstemcide jitter'lı yeniden bağlanma; oturum kaydı TTL ile temizlenir; senkronizasyonla kayıp yok |
| Gateway deploy'u | Yeniden bağlanma fırtınası | Kademeli boşaltma, "yeniden bağlan" çerçevesi, rastgele gecikme |
| Teslim kanalında mesaj kaybı (pub/sub) | Alıcı mesajı gerçek zamanlı görmez | seq boşluk tespiti ve senkronizasyon |
| Sıra sayacı failover'da geri gider | Aynı seq iki mesaja | Konuşma başına tek yazıcı; depoya yazmada çakışma kontrolü |
| Çok aktif grup (sıcak partition) | Node gecikmesi, quorum yüzünden küme gecikmesi | Zaman kovaları, request coalescing, büyük kanallar için abonelik modeli |
| Presence fırtınası | Gereksiz fan-out | Sadece ilgili ekrana yayın, kısa kopmaları yutmak |
| Okundu onayı trafiği | Teslim trafiğinin katları | Kümülatif upToSeq onayları, toplu gönderim |
| Mesaj deposu yavaşlar | SENT gecikir | Yazma gecikmesi alarmı; istemcide "gönderiliyor" durumu; geri basınç |
Trade-off'lar:
- Durumlu gateway'ler: Düşük gecikme için bağlantı durumu tutuyoruz; bedeli deploy, ölçekleme ve yük dengelemede karmaşıklık.
- Gerçek zamanlı kanal + senkronizasyon: Teslim kanalını basit ve kayıplı olabilir tutup eksiksizliği senkronizasyonla sağladık.
- Kullanıcı başına okuma durumu: Mesaj başına durum yerine iki sayı; bedeli "bu mesajı tam olarak saat kaçta okudu" bilgisinin olmaması (gerekirse ayrı ve örneklenmiş tutulur).
- Zaman kovaları: Partition boyutu sınırlandı; bedeli seyrek konuşmalarda birden fazla kovaya bakmak.
Üç seviyede cevap
Soru: "Bir konuşmada mesaj sırasını nasıl garanti edersin?"
seviye: Orta etiketler: [ordering, sequence-id] kaynak: Tahmini
Her mesaja gönderildiği zamanın timestamp'ini koyarım, mesajları timestamp'e göre sıralayarak gösteririm.
Cihaz saatlerine güvenemem, farklı olabilir. Sunucu mesajı aldığında zaman damgası ya da Snowflake benzeri sıralı bir id atar. Kafka'da konuşma id'sini anahtar yaparım ki aynı konuşmanın mesajları aynı partition'da sıralı işlensin.
Sırayı saate değil, konuşma başına tek bir yazıcının atadığı monoton bir sıra numarasına bağlarım. Kafka'da konuşma id'si anahtar olduğu için aynı konuşma tek partition'da, tek tüketicide işlenir; seq orada atanır ve mesajla birlikte saklanır. Sunucu saatleri bile milisaniyelerce farklı olabildiği için zaman damgası sadece gösterim için. seq'in ikinci işi boşluk tespiti: istemci 1015'ten sonra 1017'yi görürse 1016'yı ister; böylece teslim kanalındaki kayıplar senkronizasyonla kapanır. Redis INCR ile seq atamak da mümkün, ama failover'da asenkron replikasyon yüzünden sayaç geri gidebilir ve aynı numara iki mesaja verilebilir; bu yüzden tek yazıcı modelini tercih ederim. Garanti ettiğim şey, sunucunun kabul ettiği sıranın herkes için aynı olması; aynı anda yazan iki kişinin "gerçek" sırası diye bir şey yok.
Junior cihaz saatine güveniyor; saatleri farklı iki telefonda sohbet anlamsızlaşır. Mid sunucu tarafı sıralamayı ve partition anahtarını biliyor. Senior sıra numarasını hem sıralama hem boşluk tespiti için kullanıyor, Redis sayacının failover riskini görüyor ve "hangi sıra garanti ediliyor" sorusunu felsefi olarak da doğru cevaplıyor.
Takip sorusu: Konuşmanın partition'ını işleyen tüketici yeniden dengeleme sırasında değişti. seq ataması nasıl kesintisiz ve tekrarsız kalır?
Soru: "Kullanıcı çevrimdışıyken gelen mesajları ve çoklu cihazı nasıl ele alırsın?"
seviye: İleri etiketler: [sync, multi-device] kaynak: Tahmini
Kullanıcı çevrimdışıysa mesajları bir kuyrukta tutarım, bağlanınca kuyruktaki mesajları gönderirim.
Mesajlar zaten veritabanında saklanıyor. Kullanıcı bağlanınca son aldığı mesajdan sonrakileri veritabanından çeker. Çevrimdışıyken push bildirimi gönderirim. Çoklu cihazda her cihaz kendi son okuduğu noktayı tutar.
Sunucuda cihaz başına teslim kuyruğu tutmam; cihazlar kaçırdıklarını ister. Her cihaz konuşma başına en son aldığı seq'i bilir. Bağlandığında senkronizasyon çağrısı, last_seq'i cihazın bildiğinden büyük konuşmaları döner; cihaz açtığı konuşmanın eksik aralığını sayfalı çeker. Bu modelde yeni bir cihaz eklemek ya da bir cihazın bir hafta kapalı kalması özel durum değil. Gönderilen mesaj gönderenin diğer cihazlarına da teslim edilir; okuma durumu read_up_to ile konuşma bazında tutulduğu için bir cihazda okunan diğerinde de okunmuş görünür. Çevrimdışı cihaza push gider ama push içerik taşımak zorunda değil; gerçek içerik senkronizasyonla gelir. Bu yaklaşımın bir faydası da gerçek zamanlı teslim kanalının kayıplı olabilmesi: kalıcılık depoda, eksiksizlik senkronizasyonda.
Junior cihaz başına sunucu kuyruğu öneriyor; çoklu cihaz ve uzun çevrimdışı sürelerde bu kuyrukların yönetimi zorlaşır. Mid doğru modele (depodan çekme) yakın. Senior senkronizasyonu cursor ve seq üzerine kuruyor, çoklu cihazı özel durum olmaktan çıkarıyor ve bu kararın gerçek zamanlı kanalı basitleştirdiğini görüyor.
Takip sorusu: Kullanıcı 300 konuşmalı bir hesabı yeni bir cihaza kurdu. İlk senkronizasyon nasıl hızlı ve sunucuyu boğmadan yapılır?
Soru: "500 üyeli bir grupta 'okundu' bilgisini nasıl tutarsın?"
seviye: İleri etiketler: [read-receipts, fan-out] kaynak: Tahmini
Her mesaj için okuyan kullanıcıların listesini tutarım; biri okuyunca listeye eklerim.
Mesaj başına liste çok büyür: günde milyarlarca teslim var. Bunun yerine her üye için konuşmada en son okuduğu mesajın numarasını tutarım. Bir mesajı okuyanlar, son okuduğu numara o mesajınkinden büyük veya eşit olan üyelerdir.
Mesaj başına durum tutmak teslim sayısı kadar satır demek, günde 13 milyarın üzerinde. Her üye için konuşma başına iki sayı tutarım: delivered_up_to ve read_up_to; mesajın durumu bunlarla karşılaştırılarak türetilir. 500 üyeli grupta okuyanlar listesi tek konuşma partition'ından okunur. Onaylar istemciden kümülatif ve toplu gelir, kullanıcı 30 mesajı birden okursa tek çerçeve. Gönderene giden okundu güncellemelerini de birleştiririm; büyük grupta her okumayı anında yaymak yerine birkaç saniyede bir özet gönderirim, kimse 500 kişinin okuma anını milisaniye hassasiyetinde beklemiyor. Okundu bilgisini kapatan kullanıcının sayısını yine tutarım çünkü okunmamış rozeti ona lazım, sadece başkalarına yayınlamam.
Junior veri hacmini düşünmüyor. Mid kilit fikri (kullanıcı başına yüksek su işareti) buluyor. Senior bunu hacimle gerekçelendiriyor, onay trafiğini ve yayın frekansını optimize ediyor ve gizlilik ayarını veri modeline doğru yansıtıyor.
Takip sorusu: Gönderen, mesajını kimin hangi saatte okuduğunu görmek istiyor (mesaj bilgisi ekranı). Modeli nasıl genişletirsin?
Mülakatçı için
- Bire bir/grup boyutu, çoklu cihaz, geçmiş saklama ve okundu bilgisi gereksinimlerini netleştiriyor.
- Teslim sayısının grup fan-out'u yüzünden mesaj sayısının katları olduğunu hesaplıyor; mesajı alıcı başına değil konuşma başına saklıyor.
- Kalıcı bağlantı için WebSocket'i alternatiflerle karşılaştırarak seçiyor.
- Durumlu gateway'lerin sonuçlarını (oturum kaydı, deploy, yeniden bağlanma fırtınası) ele alıyor.
- Mesaj sırasını cihaz saatine değil, sunucunun atadığı konuşma içi sıra numarasına bağlıyor.
- En az bir kez teslim +
clientMsgId/messageIdile tekilleştirme kuruyor. - Çevrimdışı ve çoklu cihazı cursor/sıra numarası tabanlı senkronizasyonla çözüyor.
- Okundu bilgisini kullanıcı başına yüksek su işaretiyle tutuyor ve onayları kümülatif yapıyor.
- Mesaj deposunun partition anahtarını ve partition büyüklüğü/sıcaklığı problemini tartışıyor.
- Presence maliyetini fark edip sınırlıyor.
Takip soruları:
- 50.000 üyeli bir duyuru kanalı eklendi. Neler değişir?
- Uçtan uca şifreleme eklersek sunucu tarafında hangi özellikler zorlaşır?
- Mesaj düzenleme ve "herkesten sil" özelliği nasıl modellenir?
- Gateway sunucusu başına bağlantı sayısını neyin sınırladığını nasıl ölçersin?
Seviye beklentisi: Mid seviyede WebSocket, oturum kaydı, sunucu tarafı sıralama, depoda kalıcılık ve çevrimdışıda push beklenir. Senior'dan sıra numarası ile boşluk tespiti, en az bir kez + tekilleştirme, cursor tabanlı senkronizasyon, kullanıcı başına okuma durumu ve partition tasarımının gerekçeli anlatımı beklenir.
Hızlı tekrar
S: Mesaj sırası neden saatle değil sıra numarasıyla belirlenir?
C: Cihaz ve sunucu saatleri farklıdır. Konuşma başına tek yazıcının atadığı monoton seq herkes için aynı sırayı verir ve boşluk tespitini sağlar.
S: seq boşluğu istemciye ne söyler?
C: Arada bir mesaj kaçırıldığını; istemci eksik aralığı depodan ister. Gerçek zamanlı kanaldaki kayıplar böyle kapanır.
S: Gateway'ler arası yönlendirmede neden Redis Pub/Sub'ın en fazla bir kez semantiği kabul edilebilir?
C: Mesaj zaten kalıcı depoda; kayıp mesaj, istemcinin seq boşluğunu fark edip senkronizasyonla istemesiyle tamamlanır.
S: Okundu bilgisi nasıl tutulur?
C: Her üye için konuşma başına read_up_to (ve delivered_up_to); mesajın durumu read_up_to >= seq karşılaştırmasıyla türetilir. Onaylar kümülatiftir.
S: Mesaj tablosunun partition anahtarı neden (conversation_id, bucket)?
C: Konuşmanın son mesajları tek partition'dan sıralı okunur; zaman kovası partition'ın sınırsız büyümesini önler.
S: Gateway deploy'unda yeniden bağlanma fırtınası nasıl önlenir?
C: Kademeli boşaltma, istemciye "yeniden bağlan" çerçevesi ve istemcide rastgele gecikmeli (jitter) yeniden bağlanma.
S: Çoklu cihaz senkronizasyonunun temel fikri nedir?
C: Sunucuda cihaz başına kuyruk yok; her cihaz konuşma başına son seq'ini bilir ve kaçırdıklarını ister.
S: Presence yükü nasıl sınırlanır?
C: Durum sadece ilgili ekranı açık olanlara yayınlanır, kısa kopmalar yutulur, büyük gruplarda gösterilmez.
Kaynaklar
- Alex Xu, System Design Interview: An Insider's Guide (Cilt 1): "Design a Chat System" bölümü.
- Discord: How Discord Stores Billions of Messages:
(channel_id, bucket)partition anahtarı, Snowflake ile sıralama ve partition boyutu. - Discord: How Discord Stores Trillions of Messages: Sıcak partition'lar, ScyllaDB'ye geçiş, request coalescing ve ölçülen gecikmeler.
- Redis: Pub/Sub: En fazla bir kez teslim ve Redis 7.0 sharded Pub/Sub.
- Martin Kleppmann, Designing Data-Intensive Applications: Sıralama, mantıksal saatler ve tek yazıcılı log'lar.