Multi-channel notification system (push, email, SMS, in-app) handling 100M DAU with 5.8K rps avg / 50K rps peak. Producers publish events to Kafka, orchestrator loads user preferences, dedups via Redis, renders templates, then dispatches to per-channel queues (q-push, q-email, q-sms). Channel workers (push/email/sms) post to APNs/FCM/SES/Twilio with retry-backoff and DLQ. Provider webhooks update ClickHouse for delivery status analytics.
Notification system кажется вспомогательной частью продукта, но на практике это один из самых рискованных сервисов. Он стоит между бизнес-событиями и пользователем: заказ отправлен, платеж не прошел, друг написал сообщение, цена изменилась, пароль сброшен. Ошибка в таком сервисе либо молчит, и пользователь не узнает важное, либо шумит, и продукт начинает выглядеть как спам.
Кейс важен потому, что в нем сходятся event-driven architecture, очереди, retry, idempotency, rate limiting, preference management, template rendering, external provider limits и compliance. Сервис должен выдерживать сотни миллионов уведомлений в день, но при этом уважать quiet hours, unsubscribe, channel preferences, timezone и цену канала. Push дешевый, email относительно дешевый, SMS дорогой и сильно ограничен провайдерами.
Этот дизайн тренирует темы ::concept{slug="message-queues"}, ::concept{slug="retry-backoff"}, ::concept{slug="idempotency"}, ::concept{slug="rate-limiting"}, ::concept{slug="transactional-outbox"}, ::concept{slug="event-driven-architecture"} и ::concept{slug="observability"}. Главный урок: отправка уведомления не должна быть синхронной side effect внутри основного бизнес-запроса.
Мысленная модель: notification system является pipeline, а не одной функцией send(). Upstream-сервисы публикуют доменные события: order.shipped, user.signup, comment.mentioned. Notification orchestrator читает эти события, проверяет idempotency key, загружает предпочтения пользователя, выбирает каналы, рендерит шаблоны, применяет throttling и раскладывает задачи по channel queues. Дальше push, email и SMS workers независимо общаются с APNs, FCM, SES, SendGrid, Twilio или локальными провайдерами.
Критично отделить intent от delivery attempt. Intent говорит: пользователю нужно отправить уведомление такого типа. Attempt говорит: конкретный worker попытался отправить конкретный payload конкретному provider. Один intent может породить push и email, несколько retry attempts, статус delivered, bounced или opened. Поэтому data model должен хранить notification_id, user_id, event_type, template_id, dedupe_key, channel, provider_message_id, status, attempt, next_retry_at, created_at, sent_at.
Система работает в режиме at-least-once delivery внутри очередей и best-effort delivery во внешнем мире. Поэтому idempotency важнее, чем иллюзия exactly-once. Повторное событие не должно отправить два одинаковых SMS, а повторный webhook от provider не должен дважды изменить итоговую аналитику.
Диаграмма показывает producer services, Kafka events topic, notification orchestrator, preferences DB, template service, per-channel queues, workers, external providers, status service, ClickHouse и DLQ. Такое разделение нужно не ради микросервисов, а ради разных скоростей и failure modes.
Orchestrator принимает решения: можно ли отправлять, куда отправлять, каким шаблоном, в какое время. Channel queues изолируют каналы. Если Twilio деградирует, это не должно остановить push. Если email provider замедлился, SMS worker не должен простаивать. Status service принимает webhooks и пишет delivery events в аналитическое хранилище, потому что статусы часто приходят позже и в большем объеме, чем исходные send requests.
DLQ на диаграмме показывает финальный контур безопасности. После исчерпания retry message не исчезает молча: он попадает в dead-letter queue с причиной, payload hash, provider response и correlation id. Это позволяет replay после инцидента и разбор причин отказа.
Order shipped notification показывает happy path. Producer публикует событие в Kafka, orchestrator читает его, находит user preferences, видит push и email enabled, рендерит локализованный template, отправляет две задачи в разные очереди. Push worker получает 202 от APNs/FCM, provider позже присылает delivered webhook, status service сохраняет событие.
Quiet hours scenario учит, что система не просто шлет все сразу. Если у пользователя ночь в его timezone, orchestrator должен отложить non-critical notification до разрешенного окна. При этом security alerts и password reset могут иметь другой priority и обходить quiet hours.
Provider failure показывает retry with exponential backoff. 503 от SES не означает permanent failure. Worker должен зафиксировать attempt, увеличить delay, повторить позже и остановиться после лимита. Важно использовать jitter, иначе тысячи сообщений одновременно проснутся и снова ударят по provider.
Duplicate event scenario учит idempotency. Upstream может опубликовать событие дважды из-за retry в transactional outbox. Orchestrator должен проверять dedupe_key, например order_id + notification_type + user_id, и возвращать already processed без повторной отправки.
SMS cost guard показывает продуктовый trade-off. Если marketing blast пытается отправить SMS миллиону пользователей, cost policy должна потребовать approval, cap или fallback на push/email. Архитектура без cost controls быстро превращается в финансовый инцидент.
Kafka или другая durable queue между producer и notification system добавляет latency и operational complexity, но убирает coupling. Checkout API не должен ждать Twilio или SendGrid. Transactional outbox в producer помогает не потерять событие между записью бизнес-данных и публикацией в broker.
Единая очередь проще, но per-channel queues дают изоляцию и разные retry policies. Push можно retry чаще и дешевле. SMS нужно retry осторожно из-за цены и provider rate limits. Email может иметь отдельный warm-up и reputation management.
Хранить delivery status в Postgres удобно для product UI, но объем webhooks большой. Для аналитики лучше ClickHouse или другое columnar-хранилище, а в transactional DB держать только текущий агрегированный статус и последние ошибки.
Template rendering в orchestrator проще, но тяжелые personalization queries могут замедлить pipeline. Хорошая граница: template service получает ограниченный context, не делает произвольные SQL-запросы и поддерживает versioning шаблонов, чтобы повторная отправка старого notification не изменила текст неожиданно.
At-least-once queue проще и надежнее, чем попытка построить exactly-once across providers. Компенсация делается через idempotency keys, provider-side message ids и дедупликацию статусов.
APNs и FCM являются основными push providers. SES, SendGrid, Mailgun и Postmark часто используются для email. Twilio, Vonage и региональные SMS gateways закрывают SMS. Внутри компаний notification systems обычно включают preference center, template CMS, campaign scheduler, delivery analytics и compliance audit.
Uber, Amazon, банковские приложения, маркетплейсы и SaaS-продукты используют похожий pipeline. Отличаются приоритеты: банк ставит на первое место надежность security alerts, ecommerce оптимизирует промо-рассылки и unsubscribe, ride-hailing требует low latency для driver/passenger updates. Во всех случаях внешние провайдеры остаются unreliable dependency, поэтому queue, retry и DLQ обязательны.
Для больших объемов полезны Kafka/Pulsar, Redis для dedupe и rate counters, Postgres для preferences, ClickHouse для статусов, S3 для архивов payload и feature flags для аварийного отключения кампаний.
Отправлять уведомление синхронно внутри request path основного сервиса. Это связывает user-facing latency с внешним provider и делает бизнес-операцию хрупкой.
Не хранить idempotency key. Любой retry producer, consumer или provider webhook может превратиться в двойную отправку, особенно заметную для SMS и email.
Смешивать транзакционные, маркетинговые и security notifications в одной политике. У них разные SLA, consent, quiet hours и legal requirements.
Игнорировать provider rate limits. Даже если ваш сервис может отправить 50K RPS, конкретный SMS sender или email domain может иметь гораздо меньший лимит.
Не иметь preference center и unsubscribe. Это не только плохой UX, но и compliance risk.
Кэшировать preferences навсегда. Пользователь отписался, а старый cache еще сутки отправляет письма. Для таких данных нужны короткие TTL или event invalidation.
Полный notification pipeline не нужен маленькому MVP с десятками уведомлений в день. На старте можно иметь один job queue, один email provider и простую таблицу preferences. Но даже в MVP стоит оставить idempotency key и async отправку.
Этот подход не заменяет real-time messaging. Для чата, collaborative editing или live cursor нужны websocket/session infrastructure и presence, а notification system только подстраховывает offline пользователя.
Он также не должен быть event bus для всей компании. Notification orchestrator должен читать события и отправлять сообщения пользователям, а не становиться местом, где живут все бизнес-правила продукта.
Сначала разберите ::concept{slug="message-queues"} и ::concept{slug="transactional-outbox"}, чтобы понять, как события попадают в pipeline без потерь. Затем изучите ::concept{slug="idempotency"} и ::concept{slug="retry-backoff"}: они определяют поведение при повторных доставках и provider failures. Для защиты пользователей и бюджета нужны ::concept{slug="rate-limiting"} и ::concept{slug="observability"}. После этого сравните кейс с news feed: там fan-out оптимизируется под чтение ленты, а здесь fan-out оптимизируется под каналы доставки, preferences и стоимость.