Применим метод целиком к одной задаче — платформа уведомлений для маркетплейса (того же, что в сквозном кейсе и тимлидском разборе). Задача выбрана не случайно: в ней есть всё типовое — веер каналов, пики, чужие нестабильные 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_count | Redis (источник — события ленты) | Тысячи RPS чтения; инкремент/сброс |
preferences | PostgreSQL | Редкое чтение/запись по 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.