Bildirim sistemi
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
| Soru | Neden önemli | Varsayım |
|---|---|---|
| Hangi kanallar? | Sağlayıcılar ve worker'lar | Mobil 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 throttling | 30 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 filtre | Kategori 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 depolama | Evet: 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 sistemi | Tü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
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
# İç 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
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)
);
notificationsvedeliveriestabloları günlük partition'lanır ve 90 gün sonra partition düşürülerek silinir; satır satırDELETEyapı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
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 --> DBBir işlem bildiriminin yolu:
- Sipariş servisi
OrderShippedevent'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. - 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ı.
- Her kanal ve hedef için bir teslim kaydı yazılır ve mesaj, kanala ve önceliğe özgü topic'e konur.
- 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.
- 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 | İçerik | Tüketici havuzu | Hedef gecikme |
|---|---|---|---|
otp-sms | Doğrulama kodları | Ayrı, küçük ama hep boşta kapasiteli | Saniyeler |
push-tx, email-tx, sms-tx | İşlem bildirimleri | Kanal başına ayrı | Dakikanın altında |
push-bulk, email-bulk | Kampanyalar | Kanal başına ayrı, hız limiti düşük öncelikli pay | Saatler |
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:
- 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'teSET dedup:{key} 1 NX EX 86400denenir; 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). - 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ı
SENDINGyap; tekrar gelen mesajSENDINGgörürse ve sağlayıcı mesaj kimliğiyle sorgulama destekliyorsa önce sorgular. Sağlayıcı istemci tarafı bir referans/idempotency alanı kabul ediyorsanotificationIdgö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. - İç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 | Örnek | Aksiyon |
|---|---|---|
| Geçici | Timeout, 5xx, bağlantı hatası | Exponential backoff + jitter ile tekrar |
| Hız limiti | 429, Retry-After | Global kovayı yavaşlat, belirtilen süre sonra tekrar |
| Kalıcı, hedef geçersiz | Push 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 eksik | Tekrar 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:
- Kategori:
SECURITY(OTP, şifre değişikliği) kullanıcı tercihine bakılmadan gider.TRANSACTIONALkullanıcının kanal tercihine uyar ama pazarlama iznine bağlı değildir.MARKETINGhem tercih hem yasal onay ister. - 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.
- 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ı).
- 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ç.
- 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:
- Segment, kullanıcı id'sine göre sayfalanarak okunur (cursor tabanlı,
OFFSETdeğil). - Her 1.000 kullanıcılık parça bir Kafka mesajı olur:
{campaignId, userIds[1000]}. 30 milyon kullanıcı = 30.000 mesaj. - Bildirim servisi parçayı açar, her kullanıcı için tercih/onay/sessiz saat filtresinden geçirir ve
push-bulktopic'ine yazar. - Push worker'ları kampanya için belirlenen hızda (
maxRatePerSecond) gönderir: 30 milyon / 17.000 ≈ 1.765 saniye, yani yaklaşık 30 dakika. - İ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
429oranı, circuit breaker durumu, DLQ boyutu, kampanya ilerlemesi.
Darboğazlar, hatalar ve trade-off'lar
| Senaryo | Etki | Önlem |
|---|---|---|
| Kampanya başladı, OTP'ler gecikiyor | Kullanıcı giriş yapamıyor | Ayrı 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 bekler | Timeout, circuit breaker, OTP için yedek sağlayıcı |
| Worker gönderdi, sonucu yazamadan çöktü | Tekrarlı bildirim | SENDING durumu, sağlayıcı referansı veya sorgusu; kanal bazında en az/en fazla bir kez kararı |
| Aynı event iki kez geldi | Çift bildirim | Redis SET NX + dedup_key unique constraint |
| Yanlış şablonla kampanya başladı | Milyonlarca hatalı mesaj | Pause/cancel bayrağı, önce küçük bir test segmentine gönderim, kademeli hız |
| Geçersiz token birikti | Kota israfı, itibar kaybı | Sağlayıcı geri bildirimiyle pasifleştirme, periyodik temizlik |
| Retry fırtınası | Sağlayıcı daha da yavaşlar | Global hız limiti, backoff + jitter, Retry-After'a uyum |
| Bildirim servisi düştü | Bildirimler gecikir ama kaybolmaz | Kafka 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?"
seviye: İleri etiketler: [messaging, priority, bulkhead] kaynak: Tahmini
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?"
seviye: Orta etiketler: [idempotency, deduplication] kaynak: Tahmini
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?"
seviye: Orta etiketler: [retry, dlq] kaynak: Tahmini
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.