Senior Go Interview Prep - Core Go: https://go.vbloher.org/docs/01-core-go/ - Механика defer в Go: https://go.vbloher.org/docs/01-core-go/defer/ - Встраивание структур и интерфейсов (Embedding): https://go.vbloher.org/docs/01-core-go/embedding/ - Ошибки в Go: error, wrapping, errors.Is/As/Join: https://go.vbloher.org/docs/01-core-go/errors/ - Дженерики в Go (1.18+): https://go.vbloher.org/docs/01-core-go/generics/ - Интерфейсы в Go: https://go.vbloher.org/docs/01-core-go/interfaces/ - Устройство map в Go: https://go.vbloher.org/docs/01-core-go/maps/ - panic / recover: механика, раскрутка стека и runtime-паники: https://go.vbloher.org/docs/01-core-go/panic-recover/ - Указатели в Go: https://go.vbloher.org/docs/01-core-go/pointers/ - Рефлексия в Go (reflect): https://go.vbloher.org/docs/01-core-go/reflection/ - Внутреннее устройство слайсов в Go: https://go.vbloher.org/docs/01-core-go/slices/ - Строки, руны и байты в Go: https://go.vbloher.org/docs/01-core-go/strings-runes-bytes/ - Система типов Go: defined types, alignment, memory layout: https://go.vbloher.org/docs/01-core-go/type-system/ - Concurrency: https://go.vbloher.org/docs/02-concurrency/ - sync/atomic: https://go.vbloher.org/docs/02-concurrency/atomic/ - Буферизованные vs небуферизованные каналы: https://go.vbloher.org/docs/02-concurrency/buffered-unbuffered/ - Канал vs Mutex: когда что выбрать: https://go.vbloher.org/docs/02-concurrency/channel-vs-mutex/ - Каналы: устройство hchan: https://go.vbloher.org/docs/02-concurrency/channels/ - Утечки горутин, дедлоки, livelock, starvation: https://go.vbloher.org/docs/02-concurrency/common-leaks-deadlocks/ - sync.Cond: https://go.vbloher.org/docs/02-concurrency/cond/ - context: https://go.vbloher.org/docs/02-concurrency/context/ - Горутины: жизненный цикл, стоимость, стек: https://go.vbloher.org/docs/02-concurrency/goroutines-lifecycle/ - sync.Mutex и sync.RWMutex: https://go.vbloher.org/docs/02-concurrency/mutex-rwmutex/ - sync.Once: https://go.vbloher.org/docs/02-concurrency/once/ - Паттерны конкурентности: https://go.vbloher.org/docs/02-concurrency/patterns/ - Race Detector (гонки данных и -race): https://go.vbloher.org/docs/02-concurrency/race-detector/ - Планировщик GMP: https://go.vbloher.org/docs/02-concurrency/scheduler-gmp/ - select: https://go.vbloher.org/docs/02-concurrency/select/ - sync.WaitGroup: https://go.vbloher.org/docs/02-concurrency/waitgroup/ - Runtime и память: https://go.vbloher.org/docs/03-runtime-memory/ - Паттерны аллокаций и снижение давления на GC: https://go.vbloher.org/docs/03-runtime-memory/allocation-patterns/ - Escape Analysis: когда переменная убегает в кучу: https://go.vbloher.org/docs/03-runtime-memory/escape-analysis/ - Сборщик мусора Go: concurrent tri-color mark-sweep: https://go.vbloher.org/docs/03-runtime-memory/gc/ - Тюнинг GC: GOGC и GOMEMLIMIT: https://go.vbloher.org/docs/03-runtime-memory/gogc-gomemlimit/ - GOMAXPROCS: параллелизм планировщика и проблема контейнеров: https://go.vbloher.org/docs/03-runtime-memory/gomaxprocs/ - Утечки горутин (goroutine leaks): https://go.vbloher.org/docs/03-runtime-memory/goroutine-leaks/ - Утечки памяти в Go (несмотря на GC): https://go.vbloher.org/docs/03-runtime-memory/memory-leaks/ - Модель памяти Go (Go Memory Model): happens-before и синхронизация: https://go.vbloher.org/docs/03-runtime-memory/memory-model/ - pprof: профилирование CPU, памяти и блокировок в Go: https://go.vbloher.org/docs/03-runtime-memory/pprof/ - Execution Tracer и runtime/trace: тайминги вместо агрегатов: https://go.vbloher.org/docs/03-runtime-memory/runtime-tracing/ - Стек vs Куча: где живут данные в Go: https://go.vbloher.org/docs/03-runtime-memory/stack-vs-heap/ - Тестирование: https://go.vbloher.org/docs/04-testing/ - testify, assert/require и golden files: https://go.vbloher.org/docs/04-testing/assertions-testify/ - Бенчмарки в Go: https://go.vbloher.org/docs/04-testing/benchmarks/ - Покрытие, -race и флаки-тесты: https://go.vbloher.org/docs/04-testing/coverage-race/ - Нативный fuzzing в Go (1.18+): https://go.vbloher.org/docs/04-testing/fuzzing/ - Интеграционные тесты, testcontainers-go, TestMain: https://go.vbloher.org/docs/04-testing/integration-testcontainers/ - Моки, стабы и тестируемость: https://go.vbloher.org/docs/04-testing/mocks/ - Table-driven тесты, subtests и параллельность: https://go.vbloher.org/docs/04-testing/table-driven/ - Backend: https://go.vbloher.org/docs/05-backend/ - Аутентификация и авторизация: AuthN/AuthZ, сессии vs токены, RBAC/ABAC, API keys, mTLS, секреты: https://go.vbloher.org/docs/05-backend/auth-authz/ - Graceful Shutdown HTTP/gRPC сервера в Go: https://go.vbloher.org/docs/05-backend/graceful-shutdown/ - gRPC: типы RPC, интерсепторы, контекст, метаданные, error model: https://go.vbloher.org/docs/05-backend/grpc/ - JWT (JSON Web Token): https://go.vbloher.org/docs/05-backend/jwt/ - Middleware-паттерн в Go: https://go.vbloher.org/docs/05-backend/middleware/ - net/http: Server, Handler, ServeMux, таймауты, Client и контекст: https://go.vbloher.org/docs/05-backend/net-http/ - OAuth2: роли, grant types, OIDC, токены и типовые ошибки: https://go.vbloher.org/docs/05-backend/oauth2/ - OpenAPI/Swagger, code generation, contract-first vs code-first, валидация: https://go.vbloher.org/docs/05-backend/openapi/ - Protocol Buffers: схемы, wire format, эволюция и совместимость: https://go.vbloher.org/docs/05-backend/protobuf/ - REST: принципы, версионирование, идемпотентность, статусы, пагинация, ошибки: https://go.vbloher.org/docs/05-backend/rest/ - Сети и протоколы: https://go.vbloher.org/docs/06-networking/ - Пулы соединений: http.Transport, БД, утечки: https://go.vbloher.org/docs/06-networking/connection-pooling/ - DNS: записи, резолвинг, кэширование, DNS в Go: https://go.vbloher.org/docs/06-networking/dns/ - Версии HTTP: 1.1, 2, 3: https://go.vbloher.org/docs/06-networking/http-versions/ - TCP/IP: модель, транспорт и что важно бэкендеру: https://go.vbloher.org/docs/06-networking/tcp-ip/ - TLS: handshake, сертификаты, mTLS, производительность: https://go.vbloher.org/docs/06-networking/tls/ - UDP и надёжность поверх UDP: https://go.vbloher.org/docs/06-networking/udp/ - WebSocket: upgrade, фреймы, масштабирование: https://go.vbloher.org/docs/06-networking/websocket/ - Базы данных: https://go.vbloher.org/docs/07-databases/ - Пул соединений к PostgreSQL в Go: database/sql, pgx, pgxpool, PgBouncer: https://go.vbloher.org/docs/07-databases/connection-pooling-pgx/ - Взаимоблокировки (Deadlocks) в PostgreSQL: https://go.vbloher.org/docs/07-databases/deadlocks/ - Индексы в PostgreSQL: https://go.vbloher.org/docs/07-databases/indexes/ - Уровни изоляции транзакций в PostgreSQL: https://go.vbloher.org/docs/07-databases/isolation-levels/ - MVCC в PostgreSQL: версии строк, видимость, VACUUM и bloat: https://go.vbloher.org/docs/07-databases/mvcc/ - Обзор NoSQL и Redis: https://go.vbloher.org/docs/07-databases/nosql-redis/ - Партиционирование таблиц в PostgreSQL: https://go.vbloher.org/docs/07-databases/partitioning/ - Архитектура PostgreSQL: https://go.vbloher.org/docs/07-databases/postgresql-architecture/ - Планирование и оптимизация запросов в PostgreSQL: https://go.vbloher.org/docs/07-databases/query-planning/ - Репликация в PostgreSQL: https://go.vbloher.org/docs/07-databases/replication/ - Шардирование (горизонтальное масштабирование): https://go.vbloher.org/docs/07-databases/sharding/ - Транзакции в PostgreSQL и Go (database/sql, pgx): https://go.vbloher.org/docs/07-databases/transactions/ - Распределённые системы: https://go.vbloher.org/docs/08-distributed-systems/ - CAP теорема: https://go.vbloher.org/docs/08-distributed-systems/cap-theorem/ - Circuit Breaker: https://go.vbloher.org/docs/08-distributed-systems/circuit-breaker/ - Консенсус и Raft: репликация состояния в присутствии отказов: https://go.vbloher.org/docs/08-distributed-systems/consensus-raft/ - Модели согласованности: https://go.vbloher.org/docs/08-distributed-systems/consistency/ - Гарантии доставки сообщений: at-most-once / at-least-once / exactly-once: https://go.vbloher.org/docs/08-distributed-systems/delivery-guarantees/ - Eventual Consistency: https://go.vbloher.org/docs/08-distributed-systems/eventual-consistency/ - Идемпотентность в распределённых системах: https://go.vbloher.org/docs/08-distributed-systems/idempotency/ - Apache Kafka: https://go.vbloher.org/docs/08-distributed-systems/kafka/ - Transactional Outbox: https://go.vbloher.org/docs/08-distributed-systems/outbox/ - RabbitMQ: AMQP 0-9-1, маршрутизация, надёжность доставки и сравнение с Kafka: https://go.vbloher.org/docs/08-distributed-systems/rabbitmq/ - Ретраи: backoff, jitter, budgets и идемпотентность: https://go.vbloher.org/docs/08-distributed-systems/retries/ - Saga Pattern: https://go.vbloher.org/docs/08-distributed-systems/saga/ - Observability: https://go.vbloher.org/docs/09-observability/ - Grafana: https://go.vbloher.org/docs/09-observability/grafana/ - Метрики: RED, USE, Golden Signals: https://go.vbloher.org/docs/09-observability/metrics/ - OpenTelemetry: https://go.vbloher.org/docs/09-observability/opentelemetry/ - Prometheus: https://go.vbloher.org/docs/09-observability/prometheus/ - SLI / SLO / SLA: https://go.vbloher.org/docs/09-observability/slo-sli/ - Структурированное логирование (slog): https://go.vbloher.org/docs/09-observability/structured-logging/ - Distributed Tracing: https://go.vbloher.org/docs/09-observability/tracing/ - System Design: https://go.vbloher.org/docs/10-system-design/ - Analytics Pipeline: https://go.vbloher.org/docs/10-system-design/analytics-pipeline/ - Chat System: https://go.vbloher.org/docs/10-system-design/chat/ - Фреймворк System Design интервью: https://go.vbloher.org/docs/10-system-design/framework/ - Notification Service: https://go.vbloher.org/docs/10-system-design/notification-service/ - Order Service: https://go.vbloher.org/docs/10-system-design/order-service/ - Payment Service: https://go.vbloher.org/docs/10-system-design/payment-service/ - Rate Limiter: https://go.vbloher.org/docs/10-system-design/rate-limiter/ - URL Shortener: https://go.vbloher.org/docs/10-system-design/url-shortener/ - DevOps: https://go.vbloher.org/docs/11-devops/ - CI/CD: пайплайны, стадии, стратегии деплоя: https://go.vbloher.org/docs/11-devops/cicd/ - Облака (AWS / GCP) для бэкендера: https://go.vbloher.org/docs/11-devops/cloud-aws-gcp/ - Docker для Go-разработчика: https://go.vbloher.org/docs/11-devops/docker/ - GitHub Actions и GitLab CI: https://go.vbloher.org/docs/11-devops/github-gitlab-ci/ - Kubernetes для Go-разработчика: https://go.vbloher.org/docs/11-devops/kubernetes/ - Terraform / Infrastructure as Code: https://go.vbloher.org/docs/11-devops/terraform/ - Алгоритмы: https://go.vbloher.org/docs/12-algorithms/ - Типовые алгоритмические задачи и паттерны: https://go.vbloher.org/docs/12-algorithms/common-problems/ - Асимптотическая сложность (Big-O): https://go.vbloher.org/docs/12-algorithms/complexity/ - Структуры данных в Go: https://go.vbloher.org/docs/12-algorithms/data-structures/ - Специфика live-coding на Go: https://go.vbloher.org/docs/12-algorithms/go-specifics/ - Behavioral: https://go.vbloher.org/docs/13-behavioral/ - Конфликты, разногласия и работа со стейкхолдерами: https://go.vbloher.org/docs/13-behavioral/conflicts/ - Как проходит senior-интервью: этапы, оценка, оффер: https://go.vbloher.org/docs/13-behavioral/interview-flow/ - Лидерство и менторство: https://go.vbloher.org/docs/13-behavioral/leadership-mentoring/ - Типовые поведенческие вопросы для Senior: https://go.vbloher.org/docs/13-behavioral/senior-questions/ > Модуль: 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. ```go // 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`, ничего не ставим в очередь. ```go // Идемпотентный приём. Возвращает (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. ```go // Распределённый 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.