Sistem tasarımı

Sohbet ve mesajlaşma sistemi

Zor~60 dkMetaMicrosoftAmazonGoogleLinkedIn
Cevaplar:

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

SoruNeden önemliVarsayım
Kaç günlük aktif kullanıcı, kişi başı kaç mesaj?Yazma yükü ve depolama50 milyon DAU, kişi başı günde 40 mesaj
Grup boyutu sınırı?Fan-out stratejisi500 üye; binlerce üyeli kanallar kapsam dışı (sonda tartışılacak)
Mesaj geçmişi ne kadar saklanır?DepolamaSüresiz (kullanıcı silene kadar)
Çoklu cihaz var mı?Senkronizasyon modeliEvet: telefon + web/masaüstü, aynı anda
Teslim ve okundu bilgisi grupta da gösterilecek mi?Veri modeliBire birde tikler; grupta "N kişi okudu"
Uçtan uca şifreleme?Sunucunun içeriği görüp göremeyeceği, aramaKapsam dışı; sunucu içeriği şifreli saklar ama okuyabilir
Medya?Depolama ve bant genişliğiMedya nesne deposunda, mesajda referans
Mesaj teslim gecikmesi hedefi?MimariÇevrimiçi alıcıya p99 birkaç yüz ms

Kapasite tahmini

text
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.

text
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" }
http
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
  • clientMsgId istemcinin ü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

sql
-- 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

diyagram yükleniyor…
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] --> SR

Gö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'ine deviceId → gatewayId yazar, 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; seq o 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ı seq iki kez verilebilir. Bu yüzden depoya yazarken (conversation_id, bucket, seq) çakışması kontrol edilir (Cassandra'da IF NOT EXISTS pahalı olduğu için tercihen tek yazıcı modeli).
  • seq ile birlikte global, zamana göre sıralı bir message_id de 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ı clientMsgId ikinci kez gelirse mevcut messageId ve seq ile SENT dönülür.
  • İstemci tarafında: Alınan mesajlar messageId ile 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 (seq boş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.

diyagram yükleniyor…
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:

  1. Cihaz bağlandığında GET /sync?cursor=... ile çağrı yapar. Cursor, cihazın en son gördüğü genel güncelleme noktasıdır.
  2. Sunucu user_conversations tablosundan last_seq'i cihazın bildiğinden büyük olan konuşmaları döner (okunmamış rozetleri için de bu yeterli).
  3. Cihaz, açtığı konuşmalar için eksik seq aralığı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.seq ise mavi tik.
  • Grupta: "okuyanlar" listesi, read_up_to >= mesaj.seq olan ü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

SenaryoEtkiÖnlem
Gateway sunucusu çökerOn binlerce bağlantı koparİstemcide jitter'lı yeniden bağlanma; oturum kaydı TTL ile temizlenir; senkronizasyonla kayıp yok
Gateway deploy'uYeniden 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örmezseq boşluk tespiti ve senkronizasyon
Sıra sayacı failover'da geri giderAynı seq iki mesajaKonuş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 gecikmesiZaman kovaları, request coalescing, büyük kanallar için abonelik modeli
Presence fırtınasıGereksiz fan-outSadece ilgili ekrana yayın, kısa kopmaları yutmak
Okundu onayı trafiğiTeslim trafiğinin katlarıKümülatif upToSeq onayları, toplu gönderim
Mesaj deposu yavaşlarSENT gecikirYazma 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?"

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?"

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?"

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/messageId ile 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.