Site icon AppTractor

System Design: как бы вы отправили 1 миллион уведомлений?

Чёрная пятница. Маркетинговая команда подготовила рекламную кампанию. Ровно в полночь она хочет отправить уведомления одному миллиону пользователей. Задача кажется простой.

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

Большинство инженеров сразу скажут:

Используйте Kafka.

Это хорошая отправная точка.

Но что произойдёт, если:

Разберём собеседование по системному проектированию, в ходе которого рассматриваются все эти вопросы.

Задача

Аадвик: Представим, что сегодня Чёрная пятница. Маркетинговая команда хочет немедленно отправить миллион push-уведомлений. Как бы вы спроектировали такую систему?

Сара: Прежде всего я бы отказалась от синхронной обработки. Распространённая ошибка выглядит так:

for(User user : users) {
    notificationService.send(user);
}

 

И всё это выполняется внутри обработчика запроса. При таком подходе доставка уведомлений напрямую связана с сервером приложения. Если попытаться синхронно отправить миллион уведомлений:

Аадвик: Какой подход вы бы выбрали?

Сара: Я бы разделила создание уведомлений и их фактическую отправку. Приложение должно только создавать задания на отправку уведомлений и помещать их в очередь. Рабочие службы смогут обрабатывать их асинхронно.

Аадвик: Покажите архитектуру.

Сара:

API остаётся лёгким. Его ответственность заканчивается в тот момент, когда запрос на отправку уведомлений надёжно сохранён в очереди.

Аадвик: Почему это лучше?

Сара: Потому что очереди поглощают резкие всплески нагрузки. Представим, что миллион уведомлений поступил за пять секунд. Без очереди:

Приложению пришлось бы сразу обработать весь миллион запросов.

Это создало бы огромный скачок нагрузки.

С очередью:

Очередь работает как буфер. Рабочие службы отправляют уведомления с контролируемой скоростью.

Аадвик: А если маркетинг хочет, чтобы уведомления были доставлены немедленно? Разве очередь не замедлит отправку?

Сара: Не обязательно. Очередь не делает доставку медленнее. Она делает её управляемой.

Например:

1,000 Workers

100 Notifications / Second
=
100,000 Notifications / Second

Миллион уведомлений всё равно можно доставить за несколько секунд.

Разница в том, что нагрузка распределяется между множеством рабочих служб, а не обрушивается целиком на слой приложения.

Первая ловушка

Аадвик: Допустим, у нас есть 100 воркеров. Каждый обрабатывает 100 уведомлений в секунду. Всё работает хорошо. Но затем Firebase начинает ограничивать частоту запросов. Что произойдёт?

Сара: Теперь мы столкнулись с ограничением внешней системы. Производительность определяется уже не нашими серверами, а поставщиком уведомлений.

Аадвик: Как вы будете с этим справляться?

Сара: Я бы добавила ограничение скорости отправки на уровне воркеров.

Даже если в очереди находится миллион сообщений, рабочие процессы должны отправлять их только с той скоростью, которую способен обработать поставщик.

Аадвик: Но тогда в очереди возникнет задолженность по обработке?

Сара: Да. И это совершенно нормально. Очереди как раз и существуют для того, чтобы накапливать необработанные сообщения. Растущая очередь обычно намного безопаснее, чем перегрузка внешней зависимости.

Масштабирование рабочих служб

Аадвик: Допустим, глубина очереди сильно увеличилась. Как бы вы масштабировали систему?

Сара: Парк воркеров должен масштабироваться независимо от серверов приложения. Например:

Если в очереди:

10 000 сообщений

можно запустить:

10 рабочих процессов

Если в очереди:

1 000 000 сообщений

можно запустить:

1 000 рабочих процессов

Слой приложения при этом не меняется.

Вторая ловушка

Аадвик: Рабочий процесс успешно отправил уведомление, но завершился с ошибкой до подтверждения обработки сообщения. Очередь доставляет сообщение повторно. Что теперь?

Сара: Это классическая проблема распределённых систем. Уведомление уже могло быть доставлено. Но сообщение поступило на обработку ещё раз. Возникает риск отправить пользователю дубликат.

Аадвик: Как это предотвратить?

Сара: Нужно сделать обработку уведомлений идемпотентной. У каждого уведомления должен быть уникальный идентификатор.

Например:

NOTIF-1001

Перед отправкой выполняется запрос:

INSERT INTO processed_notifications
(notification_id)
VALUES ('NOTIF-1001');

Для поля notification_id должно действовать ограничение уникальности. Если вставка прошла успешно, отправляем уведомление. Если вставка завершилась ошибкой, значит уведомление уже обрабатывалось. Его нужно пропустить.

Аадвик: Знакомый подход.

Сара: Тот же принцип применяется в платёжных системах. Повторные попытки неизбежны. Наша задача — сделать их безопасными.

Пакетная отправка

Аадвик: Допустим, для отправки одного уведомления требуется один запрос к API. Миллион уведомлений означает миллион запросов. Можно ли сделать лучше?

Сара: Конечно. Многие поставщики поддерживают пакетные API .

Вместо схемы:

1 запрос = 1 уведомление

можно использовать:

1 запрос = 500 уведомлений

Тогда:

1 000 000 уведомлений

превращается в:

2 000 запросов

вместо:

1 000 000 запросов

Это значительно уменьшает сетевые накладные расходы.

Очередь необрабатываемых сообщений

Аадвик: Что делать, если уведомление постоянно не удаётся отправить?

Например:

Нужно ли повторять попытки бесконечно?

Сара: Нет. Количество повторных попыток должно быть ограничено. Распространённая схема выглядит так:

Например:

Retry 1
Retry 2
Retry 3
Retry 4
Retry 5

После достижения максимального числа попыток сообщение перемещается в очередь необработанных сообщений — DLQ.

Позднее специалисты смогут изучить причины ошибок и устранить их.

Третья ловушка

Аадвик: А если кампания рассчитана не на миллион, а на 100 миллионов пользователей? Вы всё равно поместите 100 миллионов сообщений непосредственно в Kafka?

Сара: Скорее всего, нет. При таком масштабе я бы разделила описание кампании и создание отдельных заданий на отправку.

Вместо немедленного создания всех сообщений лучше использовать генератор:

Генератор может создавать задания на отправку постепенно. Это позволит не заполнять Kafka сотнями миллионов записей одновременно.

Отслеживание доставки

Аадвик: Маркетинговой команде нужна аналитика. Она хочет знать, сколько уведомлений:

Как это реализовать?

Сара: Поставщики уведомлений обычно отправляют обратные вызовы со статусами. Я бы обрабатывала их асинхронно в виде событий.


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

Завершение собеседования

Аадвик: Подведите итог своей архитектуры.

Сара: Моя система включала бы:

  1. асинхронную обработку через очередь;
  2. независимые парки воркеров;
  3. автоматическое масштабирование по глубине очереди;
  4. ограничение скорости с учётом возможностей поставщика;
  5. идемпотентную обработку уведомлений;
  6. пакетную отправку везде, где она поддерживается;
  7. очереди повторных попыток с экспоненциальной задержкой;
  8. очередь необрабатываемых сообщений для постоянных ошибок;
  9. отслеживание доставки через асинхронные события.

Большинство инженеров сосредотачивается на том, как быстро отправить уведомления. При проектировании крупных систем важнее отправлять их безопасно. Главная задача не в том, чтобы передать миллион сообщений. Она в том, чтобы этот миллион сообщений не вывел из строя всю платформу.

Источник

Exit mobile version