open atlas
↑ К треку
Разборы System Design SDC · 02 · 01

Спроектируй систему уведомлений

Спроектируй push, SMS и email: fan-out от одного события к миллионам устройств, интеграция с APNs/FCM, rate limiting по каналам, дедуп, настройки пользователя, ретраи с DLQ и трекинг доставки — части, о которых забывают до шторма дублей в 3 ночи.

SDC Senior ◷ 30 min
Уровень
ОсновыJuniorMiddleSenior

Команда роста выкатила push «твой друг только что присоединился». Баг в ретраях консьюмера переотправлял каждое сообщение, провалившее хендшейк с APNs, а APNs таймаутил девяносто секунд. Когда он восстановился, бэклог хлынул разом: пользователи получили один и тот же push четыре, пять, девять раз. Отзывы в сторе рухнули за ночь, Apple задушила APNs-соединение приложения за злоупотребление, а дежурный инженер обнаружил, что нельзя понять, какие отправки реально дошли до телефона — система логировала «поставлено в очередь», а не «доставлено». Ничего из этого не было проблемой контента уведомления. Это было отсутствие скучной механики: идемпотентных отправок, ограниченной политики ретраев, dead-letter queue и трекинга доставки. Этот урок проектирует эту механику.

Требования

Зафиксируй скоуп до рисования квадратиков — требования решают архитектуру.

Функциональные: принять событие («пользователя X упомянули»), разрешить его в получателей, отрендерить контент по каналам и доставить через push (мобильный + веб), SMS и email. Соблюдать настройки пользователя (какие каналы, какие категории, тихие часы). Поддержать транзакционные отправки (сброс пароля — обязан дойти, малый объём) и массовые (маркетинговая кампания — большой объём, best-effort). Отдавать статус доставки в продуктовые поверхности и аналитику.

Нефункциональные: транзакционные уведомления целятся в секунды end-to-end задержки; массовые могут идти минутами. Доставка at-least-once с дедупом — потерять сброс пароля недопустимо, но и дубль для пользователя недопустим, так что нужны и путь ретраев, и страж идемпотентности. Система должна гасить всплески (вирусный пост с упоминанием знаменитости fan-out на миллионы) без потери событий и без расплавления провайдеров. И она должна быть наблюдаемой: для любой отправки ответить «что произошло и когда».

Самое жёсткое ограничение, спрятанное в этом списке: сторонние провайдеры (APNs, FCM, SMS-агрегатор, email-провайдер) ненадёжны, ограничены по rate и вне твоего контроля. Дизайн в основном про изоляцию от них.

Оценка

Прикидка показывает, где давление. Возьми 100M пользователей, каждый получает ~10 уведомлений/день по каналам: 10^9 уведомлений/день. Делим на ~10^5 секунд/день, выходит ~10 000 уведомлений/секунду в среднем, и трафик всплесковый (запуск продукта или событие знаменитости его подбрасывает), так что провижинь под ~5x пик, около 50 000/сек.

Разбивка по каналам: push доминирует (~70%), email ~20%, SMS ~10% (SMS стоит реальных денег за сообщение, потому ограничивается). Знаменитость с 50M подписчиков, постящая раз, — это одно событие с fan-out на 50M отправок; это одно событие, обработанное наивно в пути запроса, — самоустроенный сбой. Хранилище: записи доставки по ~200 байт, умножить на 10^9/день, на 30 дней хранения, выходит около 6 ТБ горячих данных статуса, что заставляет брать time-series или wide-column хранилище, а не одну реляционную таблицу.

Вывод из салфетки: усиление fan-out (одно событие в миллионы отправок) и посекундный пик оба говорят, что отправка должна быть асинхронной и буферизованной очередью — никогда синхронной в API, принявшем событие.

Высокоуровневый дизайн

В пайплайне пять стадий, каждая — буферный шов против отказа следующей.

  1. API события валидирует и персистит событие, затем ставит в очередь и сразу возвращает. Вызывающий никогда не ждёт доставки.
  2. Очередь событий развязывает продюсеров (любой сервис, эмитящий события) и пайплайн уведомлений.
  3. Fan-out воркер разрешает событие в получателей и взрывает его в работу на получателя. Здесь живёт проблема знаменитости.
  4. Фильтр настроек + дедуп отбрасывает каналы, отключённые пользователем, соблюдает тихие часы и штампует каждую отправку ключом идемпотентности, чтобы дубли схлопывались.
  5. Очереди каналов + воркеры, каждый владеет одним отношением с провайдером, со своими rate limit, политикой ретраев и DLQ (dead-letter queue — карантинная очередь для исчерпавших ретраи отправок). Сбой push копит очередь push, не трогая email.

Вместе эти пять стадий означают, что каждый медленный или флапающий провайдер изолирован в своей дорожке — без шага 4, стража идемпотентности, весь механизм ретраев разваливается в шторм дублей при первой же икоте провайдера.

Глубокое погружение

Fan-out и проблема знаменитости

Fan-out — шаг, превращающий одно событие в N отправок, и N может быть 50M. Делать это inline в API события держало бы запрос открытым минутами и исчерпало соединения — потому fan-out это своя асинхронная стадия. Воркер читает событие, ищет набор получателей (подписчики, подписанные на topic, явный список) и пишет по сообщению на получателя в очереди каналов. Для огромных наборов он обязан пагинировать и чекпоинтить: обрабатывать список подписчиков пачками, скажем, по 10 000, фиксируя прогресс, чтобы крах воркера возобновлялся, а не рестартовал (и не переотправлял первый миллион).

Есть две формы fan-out, зеркало урока про ленту. Fan-out on write (толкнуть отправку жадно каждому получателю сейчас) верен для уведомлений, ведь действие и есть доставка — нет «прочитать позже». Но для события знаменитости подмешивают доставку по topic: вместо материализации 50M отдельных строк публиковать в topics FCM/APNs (одна публикация, которую провайдер сам разворачивает на все устройства, подписанные на topic), оставляя отправки на получателя для персонализированных или чувствительных к настройкам уведомлений. Гибрид держит частый случай дешёвым, а персональный — корректным.

Почему это работает

Почему не просто пройтись циклом по получателям в API события и звать провайдера напрямую? Три причины складываются. Первая — задержка: API блокировался бы на весь fan-out, и запрос продюсера таймаутит. Вторая — радиус поражения отказа: икота провайдера в середине цикла оставляет тебя наполовину отправленным без записи, где остановился — перезапуск шлёт первую половину дважды. Третья — backpressure: у синхронного цикла нет места погасить всплеск, так что событие знаменитости насыщает соединения с провайдером и морит голодом транзакционные отправки (сброс пароля встаёт за миллион маркетинговых push). Очередь между fan-out и воркерами каналов — это то, что позволяет событию на миллион получателей сливаться устойчивым темпом, пока сброс пароля, на своей более приоритетной очереди, проскакивает вперёд.

Интеграция провайдеров: APNs, FCM, SMS, email

Каждый канал оборачивает внешнего провайдера с очень разной семантикой, и работа воркера канала — нормализовать их.

  • Push. APNs от Apple и FCM от Google основаны на токенах: устройство регистрируется и отдаёт твоему бэкенду токен устройства, который ты хранишь и обязан держать свежим. Токены истекают или становятся невалидными (приложение удалено, токен ротирован); провайдер сообщает это в ответе, и ты обязан вычищать мёртвые токены, иначе будешь тратить отправки и спотыкаться об abuse-лимиты. APNs использует долгоживущие HTTP/2 соединения — ты переиспользуешь их, а не переподключаешься на каждую отправку. FCM предлагает topics для широковещания.
  • SMS идёт через агрегатор (Twilio, Sinch и подобные), стоит центы за сообщение, ограничен по rate на номер отправителя, и подтверждение доставки приходит асинхронно через вебхук («delivered», «failed», «undelivered») — так что воркер не может знать исход в момент отправки; он пишет «submitted» и обновляет по квитанции.
  • Email идёт через SMTP-релей или API-провайдера; bounce и жалобы тоже приходят вебхуком, и ты обязан их соблюдать (hard bounce значит прекратить слать на этот адрес, иначе репутация отправителя падает и всё уходит в спам).

Объединяющий дизайн: интерфейс адаптера канала (send, parse-response, classify-error), так что логика воркера — rate limit, ретрай, запись — одинакова по каналам, и лишь адаптер знает причуды провайдера. Ошибки классифицируются на ретраебельные (таймаут, 429, 5xx) и постоянные (невалидный токен, hard bounce, отписка) — постоянные ошибки не ретраить; они идут прямо в очистку.

Rate limiting, дедуп, ретраи и DLQ

Четыре куска механики держат пайплайн честным под стрессом.

Rate limiting идёт по провайдеру и по получателю. По провайдеру: соблюдай квоты APNs/FCM/агрегатора (token bucket на каждом воркере канала), чтобы тебя не задушили и не забанили — приложение из hook задушили на APNs за долбёжку. По получателю: ограничь, сколько уведомлений один пользователь получает в окно (никаких «у тебя 200 новых лайков» как 200 отдельных push — схлопни их).

Дедуп / идемпотентность. Каждая отправка несёт ключ идемпотентности (id события + id получателя + канал). Перед отправкой воркер проверяет быстрое хранилище (Redis с TTL) на этот ключ; если есть — пропустить. Это спасло бы hook: даже при шторме ретраев каждая уникальная отправка срабатывает раз. Доставка at-least-once плюс страж идемпотентности дают тебе effectively-once так, как это переживает пользователь.

Ретраи с backoff и джиттером. Ретраебельные отказы ретраятся с экспоненциальным backoff и джиттером — не тугим циклом (он становится DDoS на восстанавливающегося провайдера) и не фиксированными задержками (они синхронизируют всех клиентов в thundering herd). Ограничь число попыток (скажем, 5).

Dead-letter queue. Когда ретраи исчерпаны или сообщение битое (poison message), оно идёт в DLQ вместо вечного ретрая или тихого сброса. DLQ инспектируется дежурным, алертится и есть разница между «мы потеряли уведомления и не знаем» и «47 отправок провалились навсегда, вот они». У hook не было DLQ — отказы зациклились.

Частая ошибка

Классический сбой уведомлений — шторм ретраев из hook: провайдер моргает, отправки падают, консьюмер ретраит агрессивно (без backoff, без потолка), и когда провайдер восстанавливается, весь бэклог хлынет разом — переотправляя дубли и долбя только что восстановившегося провайдера до повторного удушения. Починка — три вещи вместе, и пропуск любой переоткрывает дыру: (1) экспоненциальный backoff с джиттером, чтобы ретраи растекались, а не синхронизировались; (2) ограниченное число попыток, питающее DLQ, чтобы ничего не ретраилось вечно; и (3) ключ идемпотентности, проверяемый перед отправкой, чтобы даже законно переотправленные сообщения не дублировались на устройстве. Команды часто добавляют ретраи и останавливаются — ретраи без дедупа превращают каждую икоту провайдера в шторм дублей.

Трекинг доставки

«Поставлено в очередь» — не «доставлено». Каждая отправка пишет запись статуса по id отправки, проходя через queued, submitted, delivered/failed, причём терминальное состояние часто приходит позже через вебхук квитанции провайдера (SMS/email) или известно сразу (push, в основном). Поскольку объём огромен, а паттерн доступа — «пиши раз, читай по недавнему окну времени / по получателю», храни статус в wide-column или time-series хранилище с TTL, а не в одной горячей реляционной таблице. Это позволяет дежурному ответить «дошёл ли сброс пароля?» — и питает продуктовую аналитику (open rate, эффективность каналов) и rate limiter на получателя (он читает недавнюю историю отправок).

Викторина

После восстановления провайдера пользователи получают один и тот же push 5–9 раз. Ретраи уже на месте. Какой части не хватает и почему одни ретраи делают хуже?

Викторина

Знаменитость с 40M подписчиков триггерит одно событие уведомления. Как обработать fan-out, чтобы он ни не стопорил API, ни не морил голодом транзакционные отправки?

Закончи аналогию

Когда ретраи исчерпывают своё ограниченное число попыток или сообщение битое, оно маршрутизируется в _______ — придерживающую очередь, которую дежурный инспектирует и по которой алертится, так что проваленные отправки видимы и восстановимы, а не ретраятся вечно или сброшены тихо.

Узкие места и компромиссы

Связывающее ограничение — провайдеры, а не твой компьют — они ограничены по rate, флапают и тарифицируются за сообщение (особенно SMS), так что весь дизайн существует ради изоляции от них. Ключевые компромиссы:

  • At-least-once + дедуп vs exactly-once. Exactly-once через внешнего провайдера недостижим (он может доставить, а потом провалить ack). Ты выбираешь at-least-once доставку и платишь за хранилище идемпотентности; цена — Redis-лукап на отправку, выигрыш — нет штормов дублей.
  • Очереди по каналам vs одна очередь. Раздельные очереди изолируют медленного провайдера в его канал (сбой push не блокирует email) и дают тюнить rate/ретраи независимо — ценой большего числа движущихся частей. Стоит того: изоляция отказа и есть смысл.
  • Push-on-write vs широковещание по topic. Отправки на получателя дают настройки/персонализацию на пользователя, но стоят N записей; topics провайдера дают дешёвое широковещание, но без логики на пользователя. Гибрид (topics для широковещания, на получателя для персонального) — стандартный ответ.
  • Хранение статуса vs стоимость. Полный трекинг доставки на этом объёме — терабайты; ты ограничиваешь хранение (например, 30 дней горячих, потом архив или сброс) и хранишь в оптимизированном на запись хранилище с TTL, а не в реляционной таблице, которая прогнётся.

Глубочайший компромисс — приоритет: транзакционный сброс пароля и массовый маркетинговый залп делят инфраструктуру, но не должны делить судьбу. Раздели приоритетные дорожки (очереди), чтобы срочный, малообъёмный, обязанный дойти трафик никогда не застревал за массовым best-effort потопом.

Вспомните перед уходом
  1. 01
    Пройди пять стадий пайплайна и от чего каждая буферизует.
  2. 02
    Почему at-least-once + ключ идемпотентности — верная модель доставки, а не exactly-once?
  3. 03
    Как происходит шторм ретраев и какие три вещи вместе его предотвращают?
  4. 04
    Как удержать событие знаменитости на 40M подписчиков от обрушения системы?
Итог

Система уведомлений — это асинхронный пайплайн fan-out, чья реальная работа — изоляция от сторонних провайдеров (APNs, FCM, SMS-агрегаторы, email), которые флапают, ограничены по rate и тарифицируются за сообщение. API события валидирует, персистит и ставит в очередь — никогда не доставляет inline — а fan-out воркер взрывает одно событие в отправки на получателя, пагинируя с чекпоинтингом для огромных наборов и используя topics провайдера для широковещания знаменитостей (гибрид). Фильтр настроек + дедуп отбрасывает отключённые каналы и штампует ключ идемпотентности; очереди по каналам изолируют медленного провайдера в его дорожку. Каждый воркер канала оборачивает провайдера за общим адаптером, классифицирует ошибки на ретраебельные vs постоянные, ограничивает rate по провайдеру и по получателю, ретраит с backoff + джиттером до потолка и шлёт исчерпанные или poison-сообщения в DLQ — три куска (джиттер, ограниченные ретраи, идемпотентность), что вместе предотвращают шторм ретраев из hook. Наконец, трекинг доставки пишет жизненный цикл каждой отправки (queued, submitted, delivered/failed, терминальное состояние часто вебхуком) в оптимизированное на запись хранилище с TTL, так что дежурный отвечает «дошло ли?», а rate limiter на получателя читает недавнюю историю. Глубочайшее решение — приоритетные дорожки: транзакционный обязанный дойти трафик никогда не должен делить судьбу с массовым best-effort потопом. Теперь, когда встретишь шторм дублей после восстановления провайдера, ты сразу спросишь: есть ли ключ идемпотентности, ограничены ли ретраи, есть ли DLQ — если хоть одного нет, дыра открыта.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.