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

Применим метод целиком к одной задаче — платформа уведомлений для маркетплейса (того же, что в сквозном кейсе и тимлидском разборе). Задача выбрана не случайно: в ней есть всё типовое — веер каналов, пики, чужие нестабильные API, требования «не потерять» и «не задолбать».

Шаг 1. Функциональные требования

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

Анти-требования: не строим маркетинговый рассыльщик с конструктором кампаний (отдельный продукт), не делаем SMS в первой версии, не гарантируем порядок между каналами.

Шаг 2. Нефункциональные требования

  • 10 млн пользователей, 3 млн DAU; 100 млн уведомлений/день, пики ×5 при кампаниях.
  • Латентность: транзакционные (код подтверждения, статус заказа) — секунды; кампании — минуты допустимы.
  • Не терять: транзакционное уведомление обязано быть доставлено или явно зафиксировано как недоставленное.
  • Не дублировать: повторная отправка того же события не должна давать второй push.
  • Лента: чтение истории — p99 < 200 мс; счётчик непрочитанных — на каждом экране приложения.
  • Доступность: деградация допустима (задержка кампаний), потеря — нет.

Шаг 3. Оценки на салфетке

Запись: 100 млн/день ≈ 1200/с в среднем, пик ≈ 6000/с
Хранение: ~1 КБ × 100 млн/день ≈ 100 ГБ/день; история 90 дней ≈ 9 ТБ
Лента: 3 млн DAU × 8 открытий ≈ 280 RPS чтения, пик ~1000 RPS
Счётчик непрочитанных: на каждый экран → тысячи RPS дешёвых чтений
Внешние провайдеры: push ~3000 RPS на пике — нужен rate limit и батчи

Выводы из чисел: запись пиковая и асинхронная по природе → между приёмом и доставкой обязана быть очередь; 9 ТБ истории — не для горячей OLTP-таблицы → партиционирование + холодное хранение; счётчик непрочитанных — самое горячее чтение → кеш.

Шаг 4. Контракты

POST /v1/notifications          — принять событие к доставке
  { eventId, userId, category, channels?, payload }  → 202 { notificationId }
  идемпотентность по eventId

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

API принимает событие, а не «письмо»: выбор каналов и шаблона — внутри платформы (предпочтения пользователя, категория). 202 + асинхронность — прямо из чисел шага 3. Идемпотентность по eventId — из требования «не дублировать»; это контракт с продьюсерами, не внутренняя деталь.

Шаг 5. Модель данных

СущностьХранилищеПаттерн доступа
notification (приёмка + статус)PostgreSQL, партиции по месяцуЗапись потоком; точечное чтение по id; статус
user_feed (лента)PostgreSQL, партиции; ключ (user_id, created_at)Keyset-чтение последних N по пользователю
unread_countRedis (источник — события ленты)Тысячи RPS чтения; инкремент/сброс
preferencesPostgreSQLРедкое чтение/запись по user_id
delivery_events (аналитика)ClickHouseАгрегаты по кампаниям/каналам/датам

Источник правды — PostgreSQL; Redis-счётчик восстановим пересчётом, ClickHouse — пайплайном. Выбор по паттернам, не по моде: лента — это keyset по составному ключу, никакой экзотики не нужно.

Шаг 6. Схема

Продьюсеры → API (валидация, идемпотентность, запись notification + outbox)
   → Kafka (notifications)
   → Resolver (предпочтения, выбор каналов, шаблон) → Kafka (per-channel: push / email / feed)
   → Channel workers:
        push-worker  → FCM/APNs (батчи, rate limit, ретраи)
        email-worker → ESP API
        feed-worker  → user_feed + unread_count + WebSocket-уведомление приложению
   → Статусы доставки → notification.status + delivery_events (ClickHouse)

Каждая стрелка прошла проверку sync vs async: приём — синхронный (продьюсеру нужен 202 с id), всё после — события. Per-channel топики развязывают каналы: лежащий email-провайдер не задерживает push (изоляция — bulkhead на уровне топологии). Outbox на приёме — единственное место, где «не потерять» обеспечивается транзакционно (механика).

Шаг 7. Горячие точки

Дедупликация. Два рубежа: уникальный индекс по eventId на приёме (повтор продьюсера → тот же notificationId) и идемпотентные воркеры (Kafka даёт at-least-once: ключ дедупликации — notificationId + канал, хранится у воркера). Знакомая пара рубежей — та же, что в ClickHouse-пайплайне.

Кампания на миллион получателей. Одно событие кампании нельзя развернуть в миллион строк синхронно на приёме — разворачивает отдельный fan-out воркер, порциями, с приоритетом ниже транзакционных (отдельный топик с отдельным пулом потребителей — кампании не задерживают коды подтверждения; приоритет очередями, не полями).

Счётчик непрочитанных. Redis-инкремент от feed-worker-а, сброс при прочтении, фоновая сверка с PG (Redis может потерять — счётчик должен уметь пересчитаться). Чтение — только из Redis: тысячи RPS не должны трогать PG.

Шаг 8. Отказы и деградация

ОтказПоведение
Push-провайдер недоступенРетраи с backoff из очереди; circuit breaker; копится lag — алерт; транзакционные дублируются в feed всегда
Kafka недоступнаAPI продолжает принимать: outbox копит, релей догонит — «не потерять» выполняется
Redis потерянСчётчик деградирует (показываем без числа), пересчёт фоном; лента работает — PG жива
ClickHouse-пайплайн отсталСтатистика запаздывает — задекларировано в контракте status: операционные статусы из PG, аналитика eventual
Пик ×10 (инцидент у продьюсера)Rate limit по продьюсеру на API; очередь сглаживает; кампании затормозятся первыми (приоритет)

Шаг 9. Эволюция

Защищено интерфейсом: контракт приёма (продьюсеров много, менять дорого), per-channel топики (новый канал = новый воркер, ядро не трогается — OCP на уровне системы). Отложено по YAGNI: SMS-канал (войдёт как ещё один воркер), маркетинговый конструктор, ML-приоритизация. Первая версия: API + outbox + Kafka + feed/push воркеры + лента; email и аналитика — вторым шагом.

Чему учит пример

Каждое решение схемы выводимо из шагов 2–3: очередь — из пиков и асинхронной природы; per-channel изоляция — из «email не должен мешать push»; Redis — из тысяч RPS счётчика; outbox — из «не потерять»; два рубежа дедупликации — из «не дублировать» при at-least-once. Дизайн, в котором у каждого блока есть породившее его требование, — это и есть результат метода: его можно защищать, записывать и менять при смене чисел.

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

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