← назад к разделу

Представьте: пользователь оформил заказ, и ему нужно прийти подтверждение — push на телефон, письмо на почту и сообщение в ленте приложения. Кто это делает? Как оно не теряется? Как не отправляется дважды, если что-то упало? Разберём всё это на конкретной системе — маркетплейс с 10 млн пользователей.

Зачем вообще нужна отдельная система уведомлений

Можно ведь отправить письмо прямо из кода заказа — sendEmail(user, "Заказ принят"). Сначала так и делают. Потом появляется push, потом требование «не дублировать», потом кампании на миллион получателей, потом пользователь хочет отписаться от части категорий, потом оказывается, что почтовый провайдер лежит и заказы встают в очередь.

Вот почему уведомления выносят в отдельную платформу с единым API. Продуктовые команды говорят «отправь событие», а платформа сама решает: каким каналом, по каким предпочтениям пользователя, что делать при отказе провайдера.

Что платформа умеет

Зафиксируем задачу до того, как рисовать схемы:

  • Продуктовые команды отправляют уведомления через единый API: push, email, в приложении (колокольчик).
  • Пользователь управляет подписками — может отключить определённые категории на определённых каналах.
  • Пользователь видит ленту уведомлений и счётчик непрочитанных.
  • Продукт видит статус доставки.

Чего не делаем в первой версии: маркетинговый конструктор кампаний, SMS, гарантию порядка между каналами.

Сколько всего и почему это важно

Прежде чем рисовать схему, прикинем числа:

10 млн пользователей, 3 млн активны в день
100 млн уведомлений/день → примерно 1200/с в среднем, пик ×5 при кампаниях → 6000/с
История за 90 дней: ~1 КБ × 100 млн × 90 ≈ 9 ТБ
Лента: 3 млн × 8 открытий ≈ 280 запросов/с, пик ~1000
Счётчик непрочитанных: тысячи лёгких запросов/с (на каждый экран приложения)
Push-провайдер: ~3000 запросов/с на пике — нужны порционная отправка и ограничение темпа

Из этих чисел сразу видно три вещи:

  1. Пики и асинхронная природа отправки — значит, между приёмом события и реальной доставкой обязана быть очередь.
  2. 9 ТБ истории — не для обычной таблицы. Нужно партиционирование и перенос старых данных в холодное хранилище.
  3. Счётчик непрочитанных — самое горячее чтение. Базе данных туда лезть не стоит, нужен кеш.

API: принимаем событие, а не «письмо»

Ключевое решение: API принимает событие, а не готовое сообщение. Продьюсер говорит «произошло вот это для пользователя X», а платформа сама выбирает каналы по предпочтениям пользователя и формирует текст по шаблону.

POST /v1/notifications
  тело: { eventId, userId, category, payload }
  ответ: 202 { notificationId }

GET  /v1/users/{id}/feed?cursor   — лента с постраничной навигацией
GET  /v1/users/{id}/unread-count  — счётчик непрочитанных
PUT  /v1/users/{id}/preferences   — подписки по категориям и каналам
GET  /v1/notifications/{id}/status — статус доставки по каналам

Ответ 202 (принято, но не обработано) — прямое следствие чисел: обработка асинхронная, синхронно мы только сохраняем событие.

eventId — идентификатор от продьюсера. Если он пришлёт то же событие дважды (например, при повторе после ошибки сети), мы вернём тот же notificationId и не отправим второе уведомление. Это называется идемпотентность — «один и тот же запрос несколько раз даёт один и тот же результат».

Где что хранится

ЧтоГдеПочему
Событие и статус доставкиPostgreSQL, партиции по месяцуЗапись потоком, точечное чтение по id
Лента пользователяPostgreSQL, ключ (user_id, created_at)Постраничное чтение последних N записей
Счётчик непрочитанныхRedisТысячи чтений в секунду, инкремент и сброс
Предпочтения пользователяPostgreSQLРедкое чтение по user_id
Аналитика доставкиClickHouseАгрегаты по кампаниям, каналам, датам

PostgreSQL — источник правды. Redis-счётчик можно восстановить пересчётом. ClickHouse наполняется из событий доставки.

Схема: как событие превращается в уведомление

Продьюсер
  → API-сервис (валидация, идемпотентность по eventId, сохранение в БД + outbox)
  → Kafka (топик notifications)
  → Resolver (читает предпочтения, выбирает каналы, заполняет шаблон)
  → Kafka (отдельные топики: push / email / feed)
  → Воркеры:
      push-worker  → FCM / APNs (порциями, с ограничением темпа, повторами)
      email-worker → почтовый провайдер
      feed-worker  → лента в PostgreSQL + счётчик в Redis + WebSocket-уведомление
  → Статусы доставки → PostgreSQL + ClickHouse

Зачем отдельные топики для каждого канала? Если почтовый провайдер лежит — очередь email копится, но push-уведомления уходят без задержки. Каналы изолированы друг от друга.

Outbox на приёме — это таблица в той же БД, куда вместе с событием атомарно пишется задача «переложить в Kafka». Отдельный процесс читает её и публикует в Kafka. Если приложение упадёт между сохранением и публикацией — задача останется в outbox и будет опубликована при восстановлении. Это единственное место, где «не потерять» обеспечивается транзакционно.

Дедупликация: не отправить дважды

Kafka гарантирует «доставку хотя бы один раз» — воркер может получить одно и то же сообщение дважды. Поэтому нужны два рубежа:

  1. На приёме — уникальный индекс по eventId в базе. Второй запрос с тем же eventId вернёт уже существующий notificationId.
  2. В воркерах — каждый воркер хранит пары notificationId + канал, которые уже обработал. Перед отправкой проверяет: не было ли уже?

Эта пара рубежей — стандартный подход для любого пайплайна, где доставка «хотя бы один раз».

Кампании на миллион получателей

Транзакционное уведомление — один пользователь. Кампания — потенциально весь маркетплейс.

Разворачивать миллион строк синхронно на приёме нельзя — это займёт минуты и заблокирует всё. Поэтому кампания — это одно событие, которое попадает в отдельный топик с меньшим приоритетом. Специальный fan-out воркер читает его и порциями создаёт индивидуальные уведомления.

Главное правило: кампании не должны задерживать транзакционные уведомления. Отдельный топик с отдельным пулом потребителей — код подтверждения заказа не ждёт, пока разошлись промо-письма.

Счётчик непрочитанных

Приложение показывает число непрочитанных на каждом экране — это тысячи запросов в секунду. Лезть в PostgreSQL при каждом открытии экрана — слишком дорого.

Схема простая:

  • feed-worker при добавлении записи в ленту делает INCR user:{id}:unread в Redis;
  • при прочтении пользователем — SET user:{id}:unread 0;
  • раз в несколько минут фоновая задача сверяет Redis с PostgreSQL (Redis может потерять данные при перезапуске — счётчик должен уметь пересчитаться).

Чтение всегда из Redis. PostgreSQL в этой схеме не участвует.

Что будет при отказах

Что сломалосьЧто происходит
Push-провайдер недоступенВоркер повторяет с паузами, при длительном отказе отключает попытки (circuit breaker); транзакционные уведомления дублируются в ленту
Kafka недоступнаAPI принимает события, outbox копит; когда Kafka поднялась — события доливаются
Redis потерянСчётчик не показывается (или показывается без числа), лента работает — PostgreSQL жива
Аналитика отсталаСтатистика кампании запаздывает — это задекларировано в контракте; операционные статусы из PostgreSQL в порядке
Пик в 10× (инцидент у продьюсера)Ограничение темпа по продьюсеру на API; очередь сглаживает пик; кампании тормозятся первыми

Коротко

  • Уведомления выносят в отдельную платформу, чтобы продуктовые команды не думали о каналах, предпочтениях и отказах провайдеров.
  • API принимает событие, а не «письмо» — канал и текст выбирает платформа по предпочтениям пользователя.
  • Между приёмом и доставкой всегда очередь (Kafka) — пики, асинхронность, изоляция каналов.
  • Outbox на приёме — единственное место, где «не потерять» гарантируется транзакционно.
  • Два рубежа дедупликации: уникальный индекс по eventId на входе + проверка в каждом воркере.
  • Кампании на миллион — в отдельный топик с меньшим приоритетом, чтобы не задерживать транзакционные уведомления.
  • Счётчик непрочитанных — только Redis: тысячи запросов в секунду, пересчёт из PostgreSQL при восстановлении.
  • При отказе провайдера: повторы с паузой → circuit breaker → деградация (без числа на значке), но ничего не теряется.

Что почитать дальше

  • Метод системного дизайна — пошаговый процесс, который применялся здесь.
  • Строительные блоки — Kafka, Redis, партиционирование и другие компоненты из этой схемы.
  • Оформление и защита дизайна — как оформить такой разбор в документ.