Sistem tasarımı

Bildirim sistemi

Orta~45 dkAmazonMetaGoogleMicrosoftLinkedIn
Cevaplar:

Problem

"Bir e-ticaret şirketi için bildirim sistemi tasarla. Sipariş durumu değişince kullanıcıya push, e-posta ve gerekirse SMS gitmeli; giriş için tek kullanımlık doğrulama kodu (OTP) SMS ile gönderilmeli; pazarlama ekibi de milyonlarca kullanıcıya kampanya bildirimi atabilmeli. Kullanıcılar hangi bildirimi hangi kanaldan alacaklarını seçebilmeli."

Kitap Bölüm 20'de bu soru bir paragrafla özetlendi. Bu case tam tasarımdır. Mülakatçının ölçtüğü şeyler:

  • İzolasyon: 30 milyonluk bir kampanya, giriş yapmaya çalışan kullanıcının OTP'sini geciktirmemeli.
  • Dış sağlayıcılar: Push (APNs, FCM), SMS ve e-posta sağlayıcıları yavaşlar, limit uygular, hata verir; sistem bunlara uyum sağlamalı.
  • Tekilleştirme: Aynı SMS iki kez gitmemeli (para ve kullanıcı güveni), ama hiç gitmemesi de kabul edilemez.
  • Retry ve DLQ: Hangi hata tekrar denenir, hangisi denenmez, tekrar denemenin ne zaman anlamı kalmaz?
  • Tercihler ve yasal onay: Pazarlama iletisi onaysız gitmemeli; işlem bildirimi (kargonuz yola çıktı) pazarlama tercihinden bağımsızdır.

Netleştirme soruları ve varsayımlar

SoruNeden önemliVarsayım
Hangi kanallar?Sağlayıcılar ve worker'larMobil push (APNs, FCM), e-posta, SMS, uygulama içi gelen kutusu
Bildirim türleri neler, öncelikleri farklı mı?İzolasyon ve kuyruk tasarımıOTP (saniyeler), işlem bildirimi (dakikalar), pazarlama (saatler)
Kampanya hacmi ve süresi?Fan-out ve throttling30 milyon push, 30 dakika içinde
Sağlayıcı limitleri?Çıkış hızıSMS sağlayıcısı sözleşmesi 500 SMS/s; push ve e-posta için sağlayıcı kotaları
Kullanıcı tercihleri ve sessiz saatler?Gönderim öncesi filtreKategori x kanal bazında tercih; pazarlama için 22:00-09:00 sessiz saat
Yasal onay?Pazarlama iletisinin hukuki şartıTürkiye'de ticari elektronik iletiler için alıcının onayı gerekir ve onaylar İleti Yönetim Sistemi (İYS) ile tutarlı tutulmalıdır
Teslim durumu takip edilecek mi?Callback ve depolamaEvet: gönderildi, teslim edildi, açıldı, hata
Aynı bildirimin iki kez gitmesi ne kadar kötü?Tekilleştirme sıkılığıSMS'te kabul edilmez (maliyet), push'ta nadiren tolere edilir
Çok dilli mi?Şablon sistemiTürkçe ve İngilizce

Kapsam dışı: segment oluşturma (pazarlama aracının işi, biz hazır segment alırız), A/B test platformu, bildirim içeriği öneri sistemi.

Kapasite tahmini

text
Kullanıcı: 50 milyon, push token'ı olan cihaz: 30 milyon

İşlem bildirimleri (günlük):
  push  20M, e-posta 10M, SMS 3M (OTP 2M + kritik 1M)       = 33M
Pazarlama (günlük, kampanya günlerinde):
  push  30M (30 dakikada), e-posta 20M (2 saatte)            = 50M
Toplam                                                      ≈ 83M bildirim/gün ≈ 960 /s ortalama

Tepe:
  kampanya push   : 30.000.000 / 1.800 s  ≈ 16.700 /s
  kampanya e-posta: 20.000.000 / 7.200 s  ≈ 2.800 /s
  SMS             : 3M/gün ≈ 35 /s ortalama, sabah ve kampanya saatlerinde x10 ≈ 350 /s
                    -> 500 SMS/s sözleşme limitinin altında ama pay az; OTP'ye öncelik şart

Depolama:
  bildirim kaydı  : 83M x ~500 B ≈ 42 GB/gün, 90 gün saklama ≈ 3,7 TB
  cihaz token'ları: 30M x ~300 B ≈ 9 GB
  tercihler       : 50M x ~200 B ≈ 10 GB

Teşhis:

  • Hacim yüksek ama iş başına maliyet düşük (şablon doldur, sağlayıcıya gönder). Asıl kısıt bizim CPU'muz değil, sağlayıcıların hız limitleri ve kampanyanın yarattığı ani dalgadır.
  • Kampanya push'u (~16.700/s), işlem bildirimlerinin ortalama hızının (33M/gün ≈ 380/s) 40 katından fazla bir anlık hız yaratır; aynı kuyruğu paylaşırlarsa OTP'ler dakikalarca bekler. Öncelik bazlı izolasyon tasarımın merkezidir.
  • SMS, sözleşme limitine yakın çalışan ve tek mesaj başına para ödenen kanaldır; tekilleştirme ve önceliklendirme en çok burada önemlidir.

API

http
# İç servislerin çağırdığı tekil bildirim (çoğu zaman Kafka event'i ile tetiklenir)
POST /internal/v1/notifications
{ "eventId": "order-98213-SHIPPED",            # tekilleştirme anahtarı
  "userId": 7, "type": "ORDER_SHIPPED",
  "params": { "orderNo": "98213", "carrier": "Kargo A", "trackingUrl": "https://..." },
  "priority": "TRANSACTIONAL" }
-> 202 Accepted { "notificationId": "ntf_..." }
-> 200 OK       aynı eventId daha önce alınmış (ilk kayıt döner)

# OTP: ayrı endpoint, ayrı kuyruk, kısa ömür
POST /internal/v1/otp-messages
{ "requestId": "...", "phone": "+90...", "code": "482913", "expiresInSeconds": 180 }

# Kampanya
POST /internal/v1/campaigns
{ "segmentId": "seg_kasim", "templateId": "kasim-indirim", "channels": ["PUSH"],
  "sendAt": "2026-11-11T10:00:00+03:00", "maxRatePerSecond": 17000 }
POST /internal/v1/campaigns/{id}/pause | /resume | /cancel

# Kullanıcı tarafı
GET  /api/v1/me/notification-preferences
PUT  /api/v1/me/notification-preferences    { "MARKETING": { "PUSH": false, "EMAIL": true } }
POST /api/v1/me/devices                     { "token": "...", "platform": "IOS" }
GET  /api/v1/me/notifications?cursor=...    # uygulama içi gelen kutusu

# Sağlayıcı geri bildirimleri
POST /callbacks/{provider}                  # teslim raporu, bounce, şikayet

Tekil bildirim API'si 202 Accepted döner: gönderim asenkrondur. Çağıran servisin bildirim gönderiminin sonucunu beklemesi, sipariş akışını sağlayıcı gecikmesine bağlar.

Veri modeli

sql
CREATE TABLE notification_types (
    type             VARCHAR(64) PRIMARY KEY,         -- ORDER_SHIPPED, OTP, CAMPAIGN
    category         VARCHAR(16) NOT NULL,            -- SECURITY, TRANSACTIONAL, MARKETING
    default_channels VARCHAR(64) NOT NULL,            -- PUSH,EMAIL
    ttl_seconds      INT         NOT NULL             -- bu süreden sonra gönderme
);

CREATE TABLE templates (
    type        VARCHAR(64), channel VARCHAR(16), locale VARCHAR(8), version INT,
    subject     TEXT, body TEXT, active BOOLEAN NOT NULL,
    PRIMARY KEY (type, channel, locale, version)
);

CREATE TABLE user_preferences (
    user_id BIGINT, category VARCHAR(16), channel VARCHAR(16), enabled BOOLEAN NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (user_id, category, channel)
);

CREATE TABLE devices (
    token       VARCHAR(512) PRIMARY KEY,
    user_id     BIGINT      NOT NULL,
    platform    VARCHAR(8)  NOT NULL,                 -- IOS, ANDROID, WEB
    active      BOOLEAN     NOT NULL DEFAULT true,
    last_seen   TIMESTAMPTZ NOT NULL
);
CREATE INDEX devices_user_idx ON devices (user_id) WHERE active;

CREATE TABLE notifications (
    id          UUID PRIMARY KEY,
    dedup_key   VARCHAR(200) NOT NULL UNIQUE,         -- eventId ya da OTP requestId
    user_id     BIGINT       NOT NULL,
    type        VARCHAR(64)  NOT NULL,
    params      JSONB        NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ  NOT NULL
);

CREATE TABLE deliveries (
    notification_id      UUID        NOT NULL REFERENCES notifications(id),
    channel              VARCHAR(16) NOT NULL,
    target               VARCHAR(512) NOT NULL,       -- cihaz token'ı, e-posta, telefon
    status               VARCHAR(16) NOT NULL,        -- PENDING, SENDING, SENT, DELIVERED, FAILED, SKIPPED
    attempts             INT         NOT NULL DEFAULT 0,
    provider             VARCHAR(32),
    provider_message_id  VARCHAR(128),
    last_error           VARCHAR(256),
    updated_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (notification_id, channel, target)
);
  • notifications ve deliveries tabloları günlük partition'lanır ve 90 gün sonra partition düşürülerek silinir; satır satır DELETE yapılmaz.
  • Uygulama içi gelen kutusu kullanıcı bazında okunur ("son 50 bildirim"); hacim büyüdüğünde (user_id, created_at) anahtarlı geniş sütunlu bir depoya (Cassandra benzeri) taşınabilir.
  • Tercihler ve şablonlar okuma ağırlıklıdır; servis içinde kısa TTL'li cache'te tutulur.

Yüksek seviye tasarım

diyagram yükleniyor…
flowchart LR
    OS[Sipariş ve diğer servisler] -->|outbox ile event| K1[(Kafka domain event'leri)]
    AUTH[Kimlik servisi] -->|OTP isteği| NS
    K1 --> NS[Bildirim servisi]
    MKT[Kampanya planlayıcı] -->|segment parçaları| NS
    NS --> PREF[(Tercih, onay, şablon cache'i)]
    NS --> DB[(PostgreSQL bildirim kayıtları)]
    NS --> RD[(Redis tekilleştirme)]
    NS --> Q1[(otp-sms)]
    NS --> Q2[(push-tx ve push-bulk)]
    NS --> Q3[(email-tx ve email-bulk)]
    Q1 --> WS[SMS worker'ları]
    Q2 --> WP[Push worker'ları]
    Q3 --> WE[E-posta worker'ları]
    WS -->|hız limiti| SMS[SMS sağlayıcısı]
    WP -->|hız limiti| PUSH[APNs ve FCM]
    WE -->|hız limiti| MAIL[E-posta sağlayıcısı]
    WS --> RQ[(retry topic'leri ve DLQ)]
    WP --> RQ
    WE --> RQ
    SMS -->|teslim raporu| CB[Callback alıcısı]
    MAIL -->|bounce ve şikayet| CB
    CB --> DB

Bir işlem bildiriminin yolu:

  1. Sipariş servisi OrderShipped event'ini outbox ile Kafka'ya yazar (Kitap Bölüm 13). Bildirim servisi doğrudan HTTP ile çağrılmaz; sipariş akışı bildirim servisinin ayakta olmasına bağlı kalmaz.
  2. Bildirim servisi event'i tüketir: tekilleştirme, tür ve kategori, kullanıcı tercihleri, yasal onay ve sessiz saat kontrolü, hedeflerin bulunması (cihaz token'ları, e-posta), şablonun doldurulması.
  3. Her kanal ve hedef için bir teslim kaydı yazılır ve mesaj, kanala ve önceliğe özgü topic'e konur.
  4. Kanal worker'ları sağlayıcı limitine uyarak gönderir, sonucu kaydeder; geçici hatalar retry topic'ine, kalıcı hatalar kayda ve gerekiyorsa DLQ'ya gider.
  5. Sağlayıcı teslim raporları callback alıcısı üzerinden kayıtları günceller.

Derinlemesine

Öncelik ve izolasyon: OTP asla kampanyanın arkasında beklemez

Tek bir "notifications" topic'i kullanılırsa 30 milyonluk kampanya kuyruğa girdiğinde bir OTP 30 dakika bekler; kullanıcı girişi başarısız olur. Kafka partition içinde sıralı tükettiği için "öncelikli mesaj öne geçsin" diye bir şey yoktur. Çözüm fiziksel ayrımdır:

TopicİçerikTüketici havuzuHedef gecikme
otp-smsDoğrulama kodlarıAyrı, küçük ama hep boşta kapasiteliSaniyeler
push-tx, email-tx, sms-txİşlem bildirimleriKanal başına ayrıDakikanın altında
push-bulk, email-bulkKampanyalarKanal başına ayrı, hız limiti düşük öncelikli paySaatler

Ayrım sadece topic'te değil, sağlayıcı kotasında da yapılır: 500 SMS/s'lik sözleşmenin örneğin 150'si OTP için ayrılır, kalan işlem ve pazarlama SMS'leri arasında paylaşılır. Gerekirse OTP için ikinci bir SMS sağlayıcısı yedek olarak tutulur. Bu, Kitap Bölüm 14'teki bulkhead fikrinin kuyruk ve kota seviyesindeki karşılığıdır.

Çıkışta hız limiti: sağlayıcıya saygı

Push, SMS ve e-posta sağlayıcıları kota uygular ve aşıldığında 429 ya da benzeri hatalar döner; bazıları sürekli aşan göndericiyi geçici olarak engeller. 40 worker pod'unun her biri "saniyede 500" diye çalışırsa toplam 20.000 olur. Çıkış hızı global olarak sınırlanır: sağlayıcı ve öncelik sınıfı başına Redis'te bir token bucket (bkz. rate-limiter case'i). Worker bir batch göndermeden önce o kadar token alır; token yoksa bekler ve tüketmeyi yavaşlatır (backpressure). Kafka burada tampon görevi görür: kampanya mesajları topic'te bekler, sistem çökmez.

Sağlayıcı Retry-After ile cevap verirse worker o süreyi global kovaya yansıtır; sadece kendisi değil tüm worker'lar yavaşlar.

Tekilleştirme: aynı bildirim iki kez gitmesin

Tekrarın üç kaynağı vardır:

  1. Event tekrarı: Kafka en az bir kez teslim eder; sipariş servisi aynı event'i iki kez yayınlayabilir. Çözüm: notifications.dedup_key üzerinde unique constraint. Hızlı yol olarak önce Redis'te SET dedup:{key} 1 NX EX 86400 denenir; anahtar zaten varsa iş hemen biter. Ama işleme anahtar yazıldıktan sonra, kayıt commit edilmeden yarıda kalırsa anahtar silinmelidir; aksi halde Kafka'nın yeniden teslim ettiği aynı event de atlanır ve bildirim hiç gitmez (alternatif: anahtarı commit'ten sonra yazmak). Veritabanı constraint'i son savunmadır (Redis kaybolsa da çift kayıt oluşmaz).
  2. Worker tekrarı: Worker sağlayıcıya gönderir, sonucu yazmadan çöker; mesaj tekrar tüketilir ve ikinci kez gönderilir. Bu, ödeme sistemindeki "timeout bilinmiyordur" probleminin aynısıdır. Hafifletme: göndermeden önce teslim kaydını SENDING yap; tekrar gelen mesaj SENDING görürse ve sağlayıcı mesaj kimliğiyle sorgulama destekliyorsa önce sorgular. Sağlayıcı istemci tarafı bir referans/idempotency alanı kabul ediyorsa notificationId gönderilir. Hiçbirini desteklemeyen sağlayıcıda tercih bilinçli yapılır: push için nadir bir tekrar kabul edilir, SMS için "en fazla bir kez" seçilip kayıp mesaj teslim raporlarıyla tespit edilir.
  3. İçerik tekrarı: Farklı event'ler aynı içeriği üretir (iki ayrı servis "siparişiniz kargoda" der). Çözüm: tür + kullanıcı + iş anahtarı üzerinden zaman pencereli bir anahtar (ORDER_SHIPPED:98213) ve kullanıcı başına frekans sınırı (pazarlama için günde en fazla 3 push).

Mülakatta söylenecek cümle: "Sağlayıcıya yapılan çağrı tam bir kez olamaz; kanal başına 'en az bir kez' mi 'en fazla bir kez' mi istediğime karar veririm. OTP'de en az bir kez (kullanıcı kodu almalı), pazarlama SMS'inde en fazla bir kez (maliyet ve rahatsızlık)."

Retry, gecikmeli tekrar ve DLQ

Hatalar sınıflandırılır; her hata aynı şekilde tekrar edilmez:

HataÖrnekAksiyon
GeçiciTimeout, 5xx, bağlantı hatasıExponential backoff + jitter ile tekrar
Hız limiti429, Retry-AfterGlobal kovayı yavaşlat, belirtilen süre sonra tekrar
Kalıcı, hedef geçersizPush token geçersiz/kayıtsız, e-posta kalıcı bounce, numara hatalıTekrar etme; cihazı pasifleştir, e-postayı bastırma listesine al
Kalıcı, içerik hatasıŞablon parametresi eksikTekrar etme; DLQ ve alarm (bu bir bug)
Süresi dolmuş3 dakikalık OTP 4. dakikada hâlâ gönderilmemişGönderme; kullanıcıya eski kod göndermek zararlıdır

Kafka'da mesaj başına gecikme yoktur. Gecikmeli tekrar için ayrı retry topic'leri kullanılır (push-retry-1m, push-retry-10m): worker mesajı "en erken şu zamanda işle" bilgisiyle bir sonraki retry topic'ine yazar, o topic'in tüketicisi zamanı gelmemiş mesajda bekler. Spring Kafka'nın non-blocking retry desteği bu deseni hazır sunar. Deneme sayısı bitince mesaj DLQ'ya gider; DLQ'nun boyutu için alarm vardır ve düzeltme sonrası mesajları tekrar oynatan bir araç bulunur (Kitap Bölüm 12).

Her bildirim türünün bir TTL'i vardır: OTP için dakikalar, "siparişiniz kargoda" için saatler, kampanya için kampanya bitişi. Retry mantığı TTL'i aşan mesajı göndermez, SKIPPED olarak işaretler. Push sağlayıcıları da benzer kavramlar sunar: mesaja bir son geçerlilik süresi ve "collapse" (birleştirme) anahtarı (aynı anahtarlı yeni bildirim cihazda eskisinin yerini alır) verilebilir; "kargonuz yolda"nın yerini "teslim edildi" alır, kullanıcı ikisini birden görmez.

Tercihler, yasal onay ve sessiz saatler

Gönderim kararı bir kural zinciridir:

  1. Kategori: SECURITY (OTP, şifre değişikliği) kullanıcı tercihine bakılmadan gider. TRANSACTIONAL kullanıcının kanal tercihine uyar ama pazarlama iznine bağlı değildir. MARKETING hem tercih hem yasal onay ister.
  2. Yasal onay: Pazarlama iletisi için onay kaydı (ne zaman, hangi kanaldan, hangi metinle alındı) tutulur; Türkiye'de bu kayıtların İYS ile tutarlı olması gerekir. Ret kaydı geldiğinde en kısa sürede gönderimden çıkarılmalıdır.
  3. Sessiz saat: Pazarlama bildirimi 22:00-09:00 arasına düşerse gönderilmez, kullanıcının saat dilimine göre sabaha planlanır. İşlem bildirimleri genelde bu kurala tabi değildir (ürün kararı).
  4. Frekans sınırı: Kullanıcı başına günlük pazarlama sınırı; Redis'te kullanıcı başına günlük sayaç.
  5. Kanal seçimi ve yedek kanal: Push token'ı yoksa işlem bildirimi e-postaya düşer; kritik bir bildirim push'ta teslim edilemezse SMS'e yükseltilebilir.

Tercihler her bildirimde okunur; 50 milyon kullanıcının tercih tablosu bildirim servisinin önünde cache'lenir ve tercih değişikliğinde anahtar silinir. Tercih değişikliği ile gönderim arasında birkaç saniyelik bayatlık kabul edilir; pazarlama izninin geri çekilmesi ise kampanya gönderiminden hemen önce tekrar kontrol edilir.

Şablonlar ve kişiselleştirme

  • Şablon (tür, kanal, dil, versiyon) ile tanımlanır; değişiklik yeni versiyon olarak yayına alınır, eski versiyon geri alınabilir.
  • Ne zaman render edilir? İşlem bildirimleri kabul anında render edilip kaydedilir; retry'lar aynı içeriği gönderir ve "kullanıcı ne gördü?" sorusu kesin cevaplanır. Kampanyalarda 30 milyon render edilmiş içeriği saklamak yerine şablon versiyonu ve parametreler saklanır, render gönderim anında yapılır.
  • Kullanıcı girdisi (isim, adres) şablona kaçış kurallarıyla (HTML e-postada HTML escape) yerleştirilir; e-posta şablonlarına enjeksiyon bir güvenlik açığıdır.
  • SMS'te karakter sayısı maliyeti belirler: Türkçe karakterler kodlamayı değiştirip tek SMS'e sığan karakter sayısını düşürebilir; şablonlar SMS uzunluğu için test edilir.

Kampanya fan-out'u: 30 milyon kullanıcıyı sisteme boğmadan göndermek

Kampanya planlayıcı 30 milyon satırı tek seferde oluşturmaz:

  1. Segment, kullanıcı id'sine göre sayfalanarak okunur (cursor tabanlı, OFFSET değil).
  2. Her 1.000 kullanıcılık parça bir Kafka mesajı olur: {campaignId, userIds[1000]}. 30 milyon kullanıcı = 30.000 mesaj.
  3. Bildirim servisi parçayı açar, her kullanıcı için tercih/onay/sessiz saat filtresinden geçirir ve push-bulk topic'ine yazar.
  4. Push worker'ları kampanya için belirlenen hızda (maxRatePerSecond) gönderir: 30 milyon / 17.000 ≈ 1.765 saniye, yani yaklaşık 30 dakika.
  5. İlerleme (gönderilen / toplam) kampanya kaydında tutulur; pause ve cancel worker'ların her batch öncesi kontrol ettiği bir bayraktır. Yanlış şablonla başlamış bir kampanyayı ilk dakikada durdurabilmek, bu tasarımın en değerli operasyonel özelliğidir.

Bir kampanyanın herkese aynı saniyede gitmesi, uygulamaya aynı saniyede milyonlarca kullanıcının girmesi demektir; backend'in geri kalanı da bu dalgayı karşılamak zorunda kalır. Kontrollü hız bu yüzden sadece sağlayıcı için değil, kendi sistemimiz için de bir korumadır.

Cihaz token'ları ve teslim raporları

  • Kullanıcının birden fazla cihazı olabilir; push her aktif cihaza gider.
  • Sağlayıcı bir token'ın geçersiz olduğunu bildirirse (uygulama silinmiş, token yenilenmiş) cihaz pasifleştirilir. Geçersiz token'lara göndermeye devam etmek hem kota israfıdır hem sağlayıcı nezdinde göndericinin itibarını düşürür.
  • Uygulama her açılışta token'ı ve last_seen'i günceller; aylarca görülmeyen cihazlar temizlenir.
  • SMS teslim raporları ve e-posta bounce/şikayet bildirimleri callback ile gelir; callback alıcısı bunları da tekrarlı ve sırasız kabul eder (ödeme sistemindeki webhook kuralları geçerli). Kalıcı bounce alan e-posta adresleri bastırma listesine alınır; şikayet oranı yükselen bir kampanya otomatik durdurulur.

Gözlemlenebilirlik

  • Kanal ve öncelik başına: kuyruk lag'i, gönderim hızı, hata oranı (hata tipine göre), uçtan uca gecikme (event zamanı → sağlayıcı kabulü).
  • OTP için SLO: "OTP isteklerinin %99'u 10 saniye içinde sağlayıcıya teslim edildi"; burn rate alarmı.
  • Sağlayıcı başına 429 oranı, circuit breaker durumu, DLQ boyutu, kampanya ilerlemesi.

Darboğazlar, hatalar ve trade-off'lar

SenaryoEtkiÖnlem
Kampanya başladı, OTP'ler gecikiyorKullanıcı giriş yapamıyorAyrı topic, ayrı worker havuzu, ayrılmış sağlayıcı kotası
SMS sağlayıcısı yavaşladı veya düştüSMS gecikir, worker thread'leri beklerTimeout, circuit breaker, OTP için yedek sağlayıcı
Worker gönderdi, sonucu yazamadan çöktüTekrarlı bildirimSENDING durumu, sağlayıcı referansı veya sorgusu; kanal bazında en az/en fazla bir kez kararı
Aynı event iki kez geldiÇift bildirimRedis SET NX + dedup_key unique constraint
Yanlış şablonla kampanya başladıMilyonlarca hatalı mesajPause/cancel bayrağı, önce küçük bir test segmentine gönderim, kademeli hız
Geçersiz token biriktiKota israfı, itibar kaybıSağlayıcı geri bildirimiyle pasifleştirme, periyodik temizlik
Retry fırtınasıSağlayıcı daha da yavaşlarGlobal hız limiti, backoff + jitter, Retry-After'a uyum
Bildirim servisi düştüBildirimler gecikir ama kaybolmazKafka tamponu; sipariş akışı etkilenmez

Trade-off'lar:

  • Asenkron gönderim: Çağıran servis bildirimin teslim edildiğini bilmez; bedeli durumu ayrıca sorgulamak. Kazancı sipariş akışının bildirim sağlayıcısından bağımsız olması.
  • Kanal başına teslim semantiği: OTP'de tekrar, pazarlamada kayıp tercih edildi.
  • Render zamanı: İşlem bildiriminde depolama karşılığında kesinlik, kampanyada esneklik karşılığında depolama tasarrufu.
  • Tercih cache'i: Birkaç saniyelik bayatlık kabul edildi; pazarlama onayı gönderim öncesi yeniden kontrol edildi.

Üç seviyede cevap

Soru: "30 milyonluk bir kampanya gönderilirken kullanıcıların OTP SMS'leri gecikmeye başladı. Tasarımda ne yanlış, nasıl düzeltirsin?"

Kuyruktaki consumer sayısını artırırım, böylece mesajlar daha hızlı işlenir ve OTP'ler de gecikmez.

OTP ile kampanya aynı kuyruğu paylaşıyor; Kafka partition içinde sıralı tükettiği için OTP kampanya mesajlarının arkasında bekliyor. Consumer eklemek sorunu çözmez çünkü darboğaz SMS sağlayıcısının limiti. OTP için ayrı bir topic ve ayrı consumer grubu açarım, kampanyayı ayrı topic'e alırım.

İki seviyede izolasyon eksik: kuyruk ve kota. Kuyrukta OTP'yi ayrı topic'e ve ayrı worker havuzuna alırım; ama bu tek başına yetmez, çünkü iki havuz da aynı 500 SMS/s'lik sağlayıcı kotasını tüketiyor. Global hız limitini öncelik sınıfına göre bölerim: örneğin 150 SMS/s OTP'ye ayrılmış, kampanya kalan pay içinde ve OTP boştayken bile o payı kullanamıyor. OTP için ikinci bir sağlayıcıyı yedek tutarım. Kampanya tarafında gönderim hızını sağlayıcı kotasına göre belirler, kampanyayı da yavaşlatılabilir ve durdurulabilir yaparım. Kalıcı çözümün göstergesi bir SLO olur: OTP'lerin yüzde 99'u 10 saniye içinde sağlayıcıya teslim; bu SLO'nun burn rate alarmı kampanya sırasında ilk uyarıyı verir. Bir de OTP'nin TTL'i var; 3 dakikayı geçmiş bir kodu artık göndermem.

Junior darboğazın yerini yanlış teşhis ediyor; consumer eklemek sağlayıcı limitini değiştirmez. Mid kuyruk izolasyonunu buluyor. Senior paylaşılan kaynağın sadece kuyruk değil sağlayıcı kotası olduğunu görüyor, kotayı önceliğe göre ayırıyor, yedek sağlayıcı, ölçülebilir bir SLO ve TTL ekliyor. Bu soru "bulkhead" kavramını gerçekten anlayan adayı ayırır.

Takip sorusu: OTP için ayırdığın 150 SMS/s gece boyunca boşta kalıyor. Bu kapasiteyi israf etmeden izolasyonu nasıl korursun?

Soru: "Aynı kullanıcıya aynı SMS'in iki kez gitmesini nasıl önlersin?"

Gönderdiğim mesajları bir tabloya kaydederim, göndermeden önce bu tabloda var mı diye bakarım.

Event'in id'sini tekilleştirme anahtarı yaparım; bildirim kaydında bu anahtar üzerinde unique constraint var, aynı event ikinci kez gelirse kayıt eklenemez ve gönderim yapılmaz. Hızlı yol için önce Redis'te SET NX ile kontrol ederim.

Tekrarın üç kaynağını ayırırım. Event tekrarı: dedup_key unique constraint, önünde Redis SET NX EX. İçerik tekrarı: iki farklı servisin aynı bildirimi üretmesi; iş anahtarlı bir pencere ve kullanıcı başına frekans sınırı. En zoru gönderim tekrarı: worker sağlayıcıya gönderip sonucu yazmadan çökerse mesaj tekrar tüketilir. Burada "tam bir kez" mümkün değil, çünkü sağlayıcı çağrısı ile benim veritabanım arasında transaction yok. Göndermeden önce teslim kaydını SENDING yaparım; sağlayıcı bir istemci referansı kabul ediyorsa notificationId gönderirim ki tekrarı o yakalasın, sorgulama API'si varsa tekrar gelen mesajda önce sorarım. Bunlar yoksa kanal bazında karar veririm: OTP'de en az bir kez, çünkü kodu almamak daha kötü; pazarlama SMS'inde en fazla bir kez, çünkü tekrarın maliyeti ve rahatsızlığı kaybın değerinden yüksek. Kaybolanları teslim raporlarından ölçerim.

Junior'ın "önce bak, sonra gönder" çözümü eşzamanlı iki işlemde yarışa açık ve sağlayıcı tarafındaki tekrarı görmüyor. Mid veritabanı constraint'iyle doğru mekanizmayı kuruyor. Senior tekrarın kaynaklarını ayırıyor, dış sistemle transaction olmamasının sonucunu açıkça söylüyor ve teslim semantiğini kanal başına bir iş kararı olarak veriyor. Bu, ödeme sistemi sorusundaki düşüncenin bildirim bağlamına taşınmasıdır.

Takip sorusu: Sağlayıcının ne referans alanı ne de sorgu API'si var. OTP'de "en az bir kez" seçtin; kullanıcı iki farklı kod alırsa ne olur, nasıl tasarlarsın?

Soru: "Bir bildirim gönderilemezse ne yaparsın? Retry stratejin ne?"

Gönderilemeyen bildirimi 3 kez tekrar denerim, olmazsa loglarım.

Hataları ayırırım: timeout ve 5xx geçicidir, exponential backoff ile tekrar denerim; geçersiz token ya da hatalı numara kalıcıdır, tekrar denemem, cihazı pasifleştiririm. Deneme hakkı biten mesajları DLQ'ya atarım ve DLQ için alarm koyarım. Kafka'da gecikme olmadığı için retry topic'leri kullanırım.

Önce hatayı sınıflandırırım: geçici, hız limiti, kalıcı hedef hatası, kalıcı içerik hatası ve süresi dolmuş. Geçici hatada backoff + jitter, hız limitinde Retry-After'ı global kovaya yansıtırım ki bütün worker'lar yavaşlasın, kalıcı hedef hatasında token'ı pasifleştirip e-postayı bastırma listesine alırım, içerik hatası bir bug olduğu için tekrar etmeden DLQ ve alarm. Gecikmeyi retry-1m, retry-10m gibi ayrı topic'lerle sağlarım, ana topic'i bloklamam; Spring Kafka'nın non-blocking retry'ı bu deseni hazır veriyor. En sık unutulan şey TTL: her bildirim türünün bir geçerlilik süresi var, OTP için dakikalar; süresi geçmiş bildirimi göndermek göndermemekten kötü. DLQ bir çöp kutusu değil; boyutuna alarm, mesajları inceleyip düzeltme sonrası tekrar oynatacak bir araç ve hangi hataların DLQ'ya düştüğünü gösteren bir dashboard olmalı.

Junior her hatayı aynı şekilde tekrar ediyor ve sonunda kaybediyor. Mid sınıflandırmayı, backoff'u ve DLQ'yu biliyor. Senior TTL'i ve hız limiti hatasının global etkisini ekliyor, retry'ı ana akışı bloklamayan bir yapıyla kuruyor ve DLQ'yu operasyonel bir süreç olarak tanımlıyor.

Takip sorusu: Bir sağlayıcı iki saat kapalı kaldı ve retry topic'lerinde 5 milyon mesaj birikti. Sağlayıcı geri geldiğinde ne olur, neyi kontrol edersin?

Mülakatçı için

  • Kanalları, bildirim kategorilerini ve öncelik farklarını (OTP, işlem, pazarlama) netleştirme aşamasında soruyor.
  • Darboğazın kendi CPU'su değil sağlayıcı limitleri ve kampanya dalgası olduğunu kapasite hesabıyla gösteriyor.
  • Gönderimi asenkron yapıyor; çağıran servisi bildirim sağlayıcısından ayırıyor (event veya 202).
  • Öncelik sınıflarını ayrı kuyruk/topic ve ayrı tüketici havuzlarıyla izole ediyor.
  • Sağlayıcı kotasını global ve öncelik bazlı paylaştırıyor; pod başına limitin yetmeyeceğini biliyor.
  • Tekilleştirmeyi kalıcı bir unique constraint ile yapıyor ve gönderim tekrarının neden tamamen önlenemeyeceğini açıklıyor.
  • Hataları geçici/kalıcı diye sınıflandırıp farklı ele alıyor; DLQ ve gecikmeli retry kuruyor.
  • Bildirim TTL'ini veya süresi geçmiş mesajı göndermemeyi düşünüyor.
  • Kullanıcı tercihlerini, pazarlama onayını ve sessiz saatleri gönderim kararına dahil ediyor.
  • Kampanya fan-out'unu parçalı, hız kontrollü ve durdurulabilir tasarlıyor.
  • Geçersiz cihaz token'larını ve teslim raporlarını ele alıyor.

Takip soruları:

  • Kullanıcı push'u açtığında hangi kampanyadan geldiğini nasıl ölçersin (açılma oranı)?
  • Uygulama içi gelen kutusunda okunmamış sayısını milyonlarca kullanıcı için nasıl tutarsın?
  • Bir kampanyayı kullanıcının saat dilimine göre "herkese yerel saatle 10:00'da" göndermek istiyoruz. Ne değişir?
  • E-posta sağlayıcısı bizi spam şikayetleri yüzünden kısıtladı. Hangi metrikleri önceden izlemeliydik?

Seviye beklentisi: Mid seviyede kanal başına kuyruk, retry/DLQ, tekilleştirme ve tercih kontrolü beklenir. Senior'dan öncelik izolasyonunun kota seviyesine taşınması, gönderim tekrarının semantiği, TTL, kampanya kontrolü ve operasyonel görünürlük beklenir. "Kafka'ya atarım, consumer gönderir" seviyesinde kalan aday zayıftır.

Hızlı tekrar

S: OTP'nin kampanya yüzünden gecikmesini önlemenin iki katmanı nedir?

C: Ayrı topic ve ayrı worker havuzu (kuyruk izolasyonu) ve sağlayıcı kotasının öncelik sınıfına göre ayrılması (kota izolasyonu).

S: Bildirim sisteminde tekrarın üç kaynağı nedir?

C: Event tekrarı (Kafka en az bir kez), gönderim tekrarı (worker gönderip sonucu yazamadan çöker), içerik tekrarı (farklı event'ler aynı bildirimi üretir).

S: Event tekilleştirmesinde Redis SET NX ile veritabanı unique constraint'in rolleri nedir?

C: Redis hızlı yoldur; veritabanı constraint'i son savunmadır, Redis verisi kaybolsa da çift kayıt oluşmaz.

S: Kafka'da gecikmeli retry nasıl yapılır?

C: Ayrı retry topic'leri (retry-1m, retry-10m); mesaj "en erken işlenme zamanı" ile bir sonraki topic'e yazılır, ana topic bloklanmaz. Hak bitince DLQ.

S: Geçersiz push token hatası nasıl ele alınır?

C: Kalıcı hatadır; tekrar denenmez, cihaz pasifleştirilir. Geçersiz token'lara göndermek kota israfı ve itibar kaybıdır.

S: Bildirim TTL'i neden gerekir?

C: Süresi geçmiş bildirim (eski OTP, geçmiş kampanya) göndermek göndermemekten kötüdür; retry mantığı TTL'i aşan mesajı atlar.

S: 30 milyonluk kampanya nasıl parçalanır?

C: Segment cursor ile sayfalanır, 1.000 kullanıcılık parçalar Kafka mesajı olur, filtrelerden geçip bulk topic'e yazılır, worker'lar belirlenen hızda gönderir; ilerleme izlenir, pause/cancel bayrağı her batch'te kontrol edilir.

S: Pazarlama bildirimi için gönderim öncesi hangi kontroller yapılır?

C: Kullanıcının kategori/kanal tercihi, yasal onay kaydı, sessiz saat, kullanıcı başına frekans sınırı ve bildirimin TTL'i.

Kaynaklar

  • Alex Xu, System Design Interview: An Insider's Guide (Cilt 1): "Design a Notification System" bölümü.
  • Redis: Pub/Sub: Pub/Sub'ın en fazla bir kez teslim semantiği; kalıcı kuyruk gereken yerde neden kullanılmayacağı.
  • Stripe: Receive events in your webhook endpoint: Dış sağlayıcıdan gelen callback'lerin tekrarlı ve sırasız olması, imza doğrulama ve hızlı 2xx; teslim raporu callback'leri için de geçerli ilkeler.
  • Stripe: Scaling your API with rate limiters: Kritik trafiğe kapasite ayırma (fleet usage load shedder) fikri.