Модуль: System Design · Уровень: Senior
TL;DR#
Notification Service — это асинхронный шлюз доставки сообщений пользователям по разным каналам (push, email, SMS, in-app, webhook). Ключевая идея: разделить приём запроса (быстрый, синхронный) и доставку (медленную, ненадёжную, зависящую от внешних провайдеров) через очереди. Senior-сложность сосредоточена не в «отправить письмо», а в:
- идемпотентности (один логический event не должен породить два письма даже при ретраях и at-least-once семантике брокера);
- ретраях с exponential backoff + jitter и DLQ для poison-сообщений;
- rate limiting на провайдера (APNs/SES/Twilio имеют жёсткие квоты, превышение = throttling/бан);
- fan-out (один event → миллионы получателей: топик, маркетинговая рассылка);
- user preferences / opt-out (нельзя слать туда, где пользователь отписался; compliance — CAN-SPAM, GDPR, TCPA);
- per-channel изоляции (деградация SMS-провайдера не должна останавливать push).
Базовый принцип: очередь на канал + пул воркеров + circuit breaker на провайдера + дедупликация в Redis/БД.
Требования#
Functional#
- Принять запрос на отправку нотификации:
(user_id | topic, channel, template_id, payload, dedup_key, priority). - Поддержка каналов: push (APNs/FCM), email (SES/SendGrid), SMS (Twilio/SNS), in-app, webhook.
- Шаблоны: версионируемые, с локализацией (i18n) и рендерингом из payload (variables substitution).
- Fan-out: рассылка на топик/сегмент (broadcast) и на одного пользователя со множеством устройств (multicast).
- User preferences: каналы, частота (digest vs immediate), quiet hours, глобальный opt-out, per-category подписки.
- Приоритеты: transactional (OTP, «сброс пароля») > critical alerts > marketing.
- Статусы доставки: queued → sent → delivered → opened/failed/bounced (через callbacks провайдеров).
- Идемпотентность: повторный запрос с тем же
dedup_keyне создаёт дубль.
Non-functional#
- Latency: для transactional (OTP) p99 от приёма до отправки провайдеру < 1–2 c.
- Throughput: пик ~50k notifications/s (см. оценки).
- Durability: ни одна accepted-нотификация не должна потеряться (at-least-once + дедуп → effectively-once для получателя).
- Availability: 99.95% на API приёма. Доставка — best-effort с гарантией ретраев.
- Изоляция отказов: падение одного провайдера/канала не затрагивает остальные.
- Compliance: opt-out обязателен, quiet hours, аудит (кому/когда/что отправили).
- Observability: трейсинг через все хопы, метрики per-channel/per-provider.
Оценки нагрузки#
Допущения: 100M MAU, в среднем 5 нотификаций на пользователя в день.
Объём в сутки
notifications/day = 100M users × 5 = 500M/day
average QPS = 500M / 86400 ≈ 5,800 req/sПиковый QPS. Трафик неравномерен (утренний digest, маркетинговые кампании). Peak factor ~6–10x:
peak QPS ≈ 5,800 × 9 ≈ ~50,000 notifications/sПлюс мгновенные всплески от broadcast: «акция для всех» → 100M сообщений за окно ~10 мин:
burst = 100M / 600 s ≈ 166,000 messages/s — это поглощается очередью, не API.Fan-out коэффициент.
- Multicast (1 user → N устройств): среднее 3 устройства на активного пользователя.
- Broadcast (1 event → M подписчиков): топик может иметь до 10M подписчиков. Коэффициент fan-out для broadcast — десятки миллионов; именно поэтому fan-out выносится в отдельный пайплайн.
Storage.
Templates: ~5k шаблонов × 10 локалей × ~4 KB = ~200 MB. Тривиально, держим в БД + кэш в памяти воркеров.
Delivery logs (главный потребитель места). Запись ~500 байт (ids, channel, status, timestamps, provider_msg_id):
500M/day × 500 B ≈ 250 GB/day
за 90 дней retention ≈ ~22 TB→ TTL-партиционирование по дате, горячие 7–14 дней в OLTP (Postgres/Cassandra), холодные в S3/ClickHouse для аналитики.
Dedup keys в Redis, TTL 24–72 ч:
peak in-flight ≈ 500M/day активных ключей при 24h TTL
500M × ~80 B ≈ ~40 GB в Redis (шардированный кластер)Архитектура#
┌─────────────────────────────────────┐
Producers │ Notification API │
(services, │ - validate, authz (per-category) │
schedulers, HTTP/gRPC │ - idempotency check (dedup_key) │
campaigns) ──────────────────────▶ │ - resolve user prefs / opt-out │
│ - pick template + locale │
│ - enqueue (priority-aware) │
└───────────────┬─────────────────────┘
│
┌────────────────────────────┼──────────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ Fan-out svc │ │ Preferences / │ │ Dedup store │
│ (topic→users, │ │ Identity store │ │ (Redis SETNX) │
│ segment expand)│ │ (devices,tokens)│ └─────────────────┘
└───────┬────────┘ └─────────────────┘
│ expands to per-user messages
▼
┌───────────────────────────── Message Broker (Kafka / SQS) ─────────────────────────────┐
│ topic.push.high topic.push.low topic.email topic.sms topic.webhook ... + DLQ │
└───────┬───────────────┬───────────────────┬───────────────────┬──────────────────────────┘
│ │ │ │
┌───────▼──────┐ ┌──────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Push workers │ │ Push workers │ │ Email workers │ │ SMS workers │
│ (high prio) │ │ (low prio) │ │ │ │ │
│ render + │ │ │ │ render MJML │ │ render text │
│ rate limit │ │ │ │ rate limit │ │ rate limit │
│ circuit brk │ │ │ │ circuit brk │ │ circuit brk │
└──────┬───────┘ └──────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │ │
┌──────▼────────────────▼──┐ ┌───────▼───────┐ ┌───────▼───────┐
│ APNs / FCM │ │ SES / SendGrid│ │ Twilio/SNS │
└───────────────────────────┘ └───────┬───────┘ └───────┬───────┘
│ callbacks (delivered/bounce/open)
▼
┌───────────────────────────┐
│ Callback / Status ingest │──▶ Delivery log (TS DB)
└───────────────────────────┘Компоненты#
- Notification API — синхронный приём. Делает минимум: валидация, authz, проверка dedup_key, разрешение preferences/opt-out, выбор шаблона/локали, enqueue. Никаких внешних вызовов провайдеров здесь — только постановка в очередь, чтобы держать p99 низким.
- Fan-out service — разворачивает
topic/segmentв конкретный набор(user_id, device)и публикует per-user сообщения батчами. Отдельный сервис, потому что broadcast создаёт миллионы сообщений и не должен блокировать API. - Preferences/Identity store — устройства и push-токены, каналы, opt-out, quiet hours, локаль. Hot path → кэш.
- Message broker — Kafka (партиционирование, высокий throughput, replay) или SQS (managed, проще). Очередь на канал и на приоритет.
- Channel workers — пулы воркеров на канал. Делают render шаблона, применяют rate limit на провайдера, оборачивают вызов в circuit breaker, обновляют статус, при ошибке — retry/DLQ.
- Provider adapters — унифицированный интерфейс над APNs/FCM/SES/Twilio: маппинг ошибок (retriable vs permanent), парсинг квот, нормализация message_id.
- Callback ingest — принимает вебхуки/SNS от провайдеров о delivered/bounced/opened, обновляет delivery log и preferences (hard bounce → suppress адрес).
- Delivery log — append-only хранилище статусов для дебага, аналитики, биллинга.
Ключевые решения и trade-offs#
Per-channel и per-priority очереди#
Отдельная очередь на канал изолирует отказы: throttling SMS не тормозит push. Внутри канала — отдельная high/low очередь, чтобы OTP не стоял за маркетинговой рассылкой на 10M.
- Trade-off: больше топиков/consumer-групп = операционная сложность. Альтернатива — единая очередь с приоритетным полем, но тогда low-priority head-of-line блокирует high. Для senior-ответа: физическое разделение приоритетов надёжнее, чем priority-поле внутри одной партиции Kafka (Kafka не поддерживает приоритеты внутри партиции).
Шаблоны и rendering#
- Шаблоны версионируются (
template_id@v3), хранятся в БД, кэшируются в воркерах (in-memory + invalidation по pub/sub). - Рендеринг на стороне воркера, а не API — снимает CPU с hot path и позволяет шаблону меняться между enqueue и отправкой.
- Локализация: выбор локали из preferences с fallback на default.
- Trade-off: рендеринг на воркере означает, что payload (переменные) хранится в сообщении до момента отправки → больше места в брокере. Альтернатива — pre-render на API, но теряем гибкость и грузим API.
Ретраи: exponential backoff + jitter + DLQ#
- Retriable ошибки (5xx, timeout, 429) → повтор с экспоненциальной задержкой и full jitter (иначе thundering herd при восстановлении провайдера).
- Permanent ошибки (невалидный токен, hard bounce, opt-out) → не ретраим, сразу suppress + лог.
- После N попыток → DLQ для ручного разбора / отложенного reprocess.
- Реализация задержки: либо delayed messages (SQS visibility timeout / Kafka + scheduled topic), либо отдельные retry-топики по «ступеням» (retry-1m, retry-5m, retry-30m) — классический паттерн для Kafka, у которого нет нативного per-message delay.
// Retry с exponential backoff + full jitter.
// Возвращает nil при успехе, ErrPermanent — не ретраить, ErrExhausted — в DLQ.
func sendWithRetry(ctx context.Context, p Provider, msg Message) error {
const (
maxAttempts = 5
baseDelay = 200 * time.Millisecond
maxDelay = 30 * time.Second
)
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
err := p.Send(ctx, msg)
if err == nil {
return nil
}
// Классифицируем ошибку провайдера.
switch classify(err) {
case ErrKindPermanent: // невалидный токен, opt-out, hard bounce
return fmt.Errorf("permanent: %w", err)
case ErrKindRetriable: // 5xx, timeout, 429
lastErr = err
default:
lastErr = err
}
// exp backoff: base * 2^attempt, ограничен maxDelay
backoff := baseDelay << attempt
if backoff > maxDelay {
backoff = maxDelay
}
// full jitter: случайно в [0, backoff], срезает синхронные ретраи
delay := time.Duration(rand.Int63n(int64(backoff)))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return fmt.Errorf("exhausted after %d attempts -> DLQ: %w", maxAttempts, lastErr)
}Важно: при at-least-once брокере сам ретрай воркера и redelivery брокера складываются. Поэтому ретраи обязаны быть идемпотентными (см. ниже) — иначе один сбой = несколько писем.
Идемпотентность через dedup key#
Источник истины — dedup_key (стабильный per логический event: например order_123_shipped, либо хэш от user_id+template_id+payload). На приёме делаем атомарный SETNX в Redis. Если ключ уже есть — возвращаем тот же notification_id, ничего не ставим в очередь.
// Идемпотентный приём. Возвращает (notificationID, alreadyExists, error).
func (s *Service) Accept(ctx context.Context, req Request) (string, bool, error) {
key := "dedup:" + req.DedupKey
notifID := uuid.NewString()
// SET key notifID NX EX 86400 — атомарно: создаст только если не было.
ok, err := s.redis.SetNX(ctx, key, notifID, 24*time.Hour).Result()
if err != nil {
return "", false, fmt.Errorf("dedup check: %w", err)
}
if !ok {
// Ключ уже есть — это дубликат. Возвращаем существующий id.
existing, err := s.redis.Get(ctx, key).Result()
if err != nil {
return "", false, fmt.Errorf("dedup get: %w", err)
}
return existing, true, nil // не enqueue-им повторно
}
if err := s.enqueue(ctx, notifID, req); err != nil {
// Откатываем ключ, чтобы клиент мог ретраить корректно.
s.redis.Del(ctx, key)
return "", false, fmt.Errorf("enqueue: %w", err)
}
return notifID, false, nil
}- Дедуп и на воркере тоже (defence in depth): перед вызовом провайдера проверяем «уже отправлено?» по
notification_id(флаг в Redis/БД), чтобы redelivery из брокера не дал второе письмо. В идеале — провайдеры с собственным idempotency key (SESMessageDeduplicationIdв SQS FIFO, у некоторых — header). - Trade-off: Redis SETNX — не идеальная гарантия (Redis может потерять данные при failover). Для transactional с жёсткой гарантией — дедуп в транзакционной БД (outbox + unique constraint на dedup_key). Дороже, но строже. Часто комбинируют: Redis для скорости + unique index в БД как backstop.
Rate limiting на провайдера#
Провайдеры имеют квоты: SES — N писем/с по аккаунту, Twilio — лимит на номер (~1 SMS/s per long code), APNs — connection/stream лимиты, FCM — квоты на проект. Превышение = 429/throttle/temp-бан.
- Token bucket на каждый провайдер (и часто per sub-account / per sending-number). Распределённый лимит → Redis-based token bucket или централизованный rate-limit-сервис, чтобы лимит соблюдался по всему пулу воркеров, а не per-instance.
- При исчерпании токенов воркер не дропает сообщение — переоткладывает (requeue с задержкой), сохраняя backpressure.
// Распределённый token bucket поверх Redis (упрощённо, через Lua для атомарности).
// Возвращает true, если есть токен для отправки прямо сейчас.
func (l *RateLimiter) Allow(ctx context.Context, provider string, ratePerSec, burst int) (bool, error) {
// KEYS[1]=bucket, ARGV: rate, burst, now_ms, requested=1
res, err := l.allowScript.Run(ctx, l.redis,
[]string{"rl:" + provider},
ratePerSec, burst, time.Now().UnixMilli(), 1,
).Int()
if err != nil {
return false, err
}
return res == 1, nil
}User preferences / opt-out#
- Проверка на API (быстрый отказ) и на воркере (preferences могли измениться в очереди).
- Категории подписок (
marketing,security,social): security обычно нельзя отключить, marketing — можно. - Suppression list: hard bounces, спам-жалобы, unsubscribe → адрес/токен в suppression, проверяется перед каждой отправкой (compliance).
- Quiet hours: отложить non-critical до окна пользователя по его таймзоне (delayed enqueue).
Fan-out стратегии#
- Write fan-out (push на enqueue): при event разворачиваем подписчиков в N сообщений сразу. Хорошо для умеренных топиков, плохо для «горячих» (10M подписчиков → 10M сообщений мгновенно).
- Read fan-out / pull: для in-app feed — не материализуем, читаем при запросе. Не подходит для push/email (нужен активный пуш).
- Гибрид + батчинг: fan-out service читает подписчиков страницами из БД и публикует батчами (например, по 1000), сглаживая всплеск. Для очень горячих топиков — sharding fan-out по нескольким воркерам.
- Trade-off: write fan-out даёт низкую latency доставки, но дорогой по записи/очереди; read fan-out дёшев по записи, но не умеет активный пуш и медленный на чтении при больших списках.
Приоритеты#
Физически раздельные очереди high/low (см. выше). High-prio воркеров масштабируем агрессивнее и даём им бóльшую долю провайдерских квот. Marketing-кампании дополнительно throttle-им, чтобы не выесть квоту у transactional.
Масштабирование и узкие места#
Провайдер throttling (главное узкое место)#
Внешние провайдеры — самая частая точка отказа и предел пропускной способности. Меры:
- Circuit breaker на адаптер: при росте ошибок/таймаутов размыкаем, перестаём долбить, шлём пробные запросы (half-open). Защищает и нас, и провайдера.
- Адаптивный rate limit: при 429 снижаем rate (AIMD — additive increase / multiplicative decrease).
- Мульти-провайдер failover: SES → SendGrid, Twilio → SNS. Health-based роутинг. Нюанс: failover увеличивает риск дублей → дедуп обязателен.
Hot fan-out#
- «Горячий» топик (10M+) при наивном write fan-out: мгновенный всплеск в брокере, перегрузка партиций, неравномерность.
- Решение: батчинг + страничное чтение подписчиков, отдельный пул fan-out воркеров, шардирование по
hash(user_id)для равномерного распределения по партициям, rate-cap на саму кампанию.
Очереди и backpressure#
- Если воркеры/провайдер не успевают — растёт лаг очереди. Это штатно для асинхронной системы (очередь — буфер), но нужен мониторинг consumer lag и алерты.
- Backpressure на API: при критическом лаге — мягкая деградация для low-priority (429/«поставлено в отложенную очередь»), а transactional пропускаем.
- Защита от «poison message» в hot loop: после N ретраев → DLQ, чтобы один битый payload не зацикливал воркера.
- Партиционирование Kafka: ключ партиции =
user_id(сохраняет порядок per user), но осторожно — горячий user/топик создаёт hot partition. Для broadcast лучше распределять равномерно.
Прочее#
- Idempotency at scale: Redis для дедупа — шардированный кластер; следить за TTL и памятью.
- Delivery log: 250 GB/день → партиционирование по дате + TTL + вынос в ClickHouse/S3.
- Stateless воркеры: горизонтальное масштабирование, autoscaling по consumer lag (KEDA).
Вопросы на собеседовании#
В: Почему отправку выносят в очередь, а не шлют синхронно из API? О: Внешние провайдеры медленные и ненадёжные (сотни мс — секунды, таймауты, throttling). Синхронная отправка привязала бы latency и доступность API к провайдеру и не дала бы ретраить/буферизовать всплески. Очередь развязывает приём и доставку: API быстрый и доступный, доставка — асинхронная с ретраями и backpressure.
В: Как обеспечить, что пользователь не получит дубликат при at-least-once брокере и ретраях?
О: Многоуровневая идемпотентность. На приёме — атомарный SETNX по dedup_key (стабильный per event), повтор возвращает тот же id и не ставит в очередь. На воркере — проверка «уже отправлено?» по notification_id перед вызовом провайдера (защита от redelivery). По возможности — idempotency key на стороне провайдера. Для жёстких гарантий — unique constraint на dedup_key в транзакционной БД (outbox) как backstop к Redis.
В: Чем exponential backoff с jitter лучше простого фиксированного интервала? О: Экспонента снижает нагрузку на восстанавливающегося провайдера (не долбим с постоянной частотой). Jitter (особенно full jitter) рандомизирует моменты ретраев, предотвращая thundering herd — иначе тысячи воркеров, упавших одновременно, ретраят синхронно и снова кладут провайдера. Без jitter ретраи самоорганизуются в волны.
В: Как реализовать приоритеты, если брокер (Kafka) не поддерживает приоритеты внутри партиции? О: Физически раздельные топики/очереди на приоритет (high/low) с отдельными consumer-группами и пулами воркеров. High-prio масштабируем больше и даём бóльшую долю провайдерской квоты. Priority-поле в одной очереди не работает — low-priority в голове партиции блокирует high (head-of-line blocking).
В: Топик имеет 10M подписчиков и приходит событие. Как разослать, не положив систему?
О: Не делать наивный write fan-out (10M сообщений мгновенно). Отдельный fan-out service читает подписчиков страницами из БД и публикует батчами (по ~1000), распределяя по партициям через hash(user_id) для равномерности. Шардируем fan-out по нескольким воркерам, ставим rate-cap на кампанию, чтобы не выесть провайдерскую квоту у transactional-трафика. Очередь сглаживает всплеск во времени.
В: Что делать при 429 от провайдера?
О: 429 — retriable, но «грубый» ретрай усугубит throttling. Применяем адаптивный rate limit (AIMD: при 429 множительно снижаем rate), respect-им Retry-After, переоткладываем сообщение с backoff+jitter (не дропаем). Распределённый token bucket (Redis) держит общий лимит по всему пулу воркеров. При устойчивых ошибках — circuit breaker размыкается, опционально failover на резервного провайдера.
В: Как различать retriable и permanent ошибки и почему это важно? О: Permanent (невалидный push-токен, hard bounce, opt-out, 4xx-валидация) ретраить бессмысленно и вредно — это сжигает квоту и может усиливать спам-репутацию; такие сразу идут в suppression + лог, без ретраев. Retriable (5xx, timeout, 429) ретраим с backoff. Классификация — в provider adapter (маппинг кодов ошибок). Без неё либо теряем доставляемые сообщения, либо бесконечно долбим заведомо мёртвый адрес.
В: Где и как проверять opt-out / preferences? О: Дважды: на API (быстрый отказ, не засоряем очередь) и на воркере перед отправкой (preferences могли измениться, пока сообщение лежало в очереди — особенно при quiet hours и больших лагах). Плюс suppression list (bounces/жалобы/unsubscribe) проверяется всегда — это compliance-требование (CAN-SPAM/GDPR/TCPA).
В: Зачем нужен circuit breaker, если уже есть ретраи и rate limit? О: Ретраи реагируют на единичные сбои, breaker — на системную деградацию провайдера. Когда доля ошибок/таймаутов превышает порог, breaker размыкается и быстро фейлит запросы (fail fast), не тратя воркеры на заведомо неуспешные вызовы и давая провайдеру восстановиться. Half-open пробует трафик дозированно. Это защищает и нашу пропускную способность, и провайдера от добивания.
На что копают на senior+#
- Effectively-once на практике. Кандидат должен честно сказать, что exactly-once в распределённой доставке недостижим; достигаем at-least-once + дедупликация = effectively-once для получателя. Где источник истины дедупа, что при failover Redis, нужен ли БД-backstop.
- Семантика брокера и её последствия. Понимание, что at-least-once + неидемпотентный воркер = дубли; offset commit до/после обработки; redelivery при ребалансе consumer-группы.
- Hot partition / hot key. Партиционирование по
user_idсохраняет порядок, но создаёт горячие партиции для активных пользователей/топиков. Trade-off между порядком и равномерностью. - Backpressure и graceful degradation. Что происходит при росте consumer lag; как деградировать low-priority, не трогая transactional; autoscaling по lag (KEDA), а не по CPU.
- Адаптивный rate limiting под общую квоту. Распределённый token bucket (Redis/Lua для атомарности) vs per-instance; AIMD; учёт per-sub-account / per-number лимитов (Twilio).
- Multi-provider failover и его цена. Failover повышает доступность, но множит риск дублей и усложняет дедуп; health-based роутинг; нормализация ошибок.
- Compliance как архитектурное ограничение. Quiet hours по таймзоне, suppression list, неотключаемые категории (security), аудит. Это не «фича», а инвариант, который пронизывает все каналы.
- Outbox pattern. Если producer должен атомарно «закоммитить бизнес-изменение И послать нотификацию» — transactional outbox вместо dual-write, чтобы не терять/не дублировать события.
- Стоимость fan-out. Trade-off write vs read fan-out для разных каналов (push требует write, in-app feed допускает read), батчинг, шардирование, rate-cap кампаний.
- Observability сквозная. Trace_id через API → очередь → воркер → провайдер → callback; метрики per-channel/per-provider (sent/failed/throttled/latency), consumer lag, DLQ depth как алерты.
- DLQ как процесс, а не свалка. Кто разбирает, как reprocess-ить после фикса, защита от poison-loop, алертинг на рост DLQ.