Модуль: 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 (SES MessageDeduplicationId в 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.