Представьте: пользователь оформил заказ, и ему нужно прийти подтверждение — 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 запросов/с на пике — нужны порционная отправка и ограничение темпа
Из этих чисел сразу видно три вещи:
- Пики и асинхронная природа отправки — значит, между приёмом события и реальной доставкой обязана быть очередь.
- 9 ТБ истории — не для обычной таблицы. Нужно партиционирование и перенос старых данных в холодное хранилище.
- Счётчик непрочитанных — самое горячее чтение. Базе данных туда лезть не стоит, нужен кеш.
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 гарантирует «доставку хотя бы один раз» — воркер может получить одно и то же сообщение дважды. Поэтому нужны два рубежа:
- На приёме — уникальный индекс по
eventIdв базе. Второй запрос с тем жеeventIdвернёт уже существующийnotificationId. - В воркерах — каждый воркер хранит пары
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, партиционирование и другие компоненты из этой схемы.
- Оформление и защита дизайна — как оформить такой разбор в документ.