В uSTADIUM мы используем систему планирования задач для отправки тысяч push-уведомлений. Потребность в очереди задач и планировщике сначала не была очевидна. Наш сервер обрабатывал уведомления по мере необходимости во время запроса. Со временем эта нагрузка стала перегружать систему. Я не знал, как это исправить, поэтому решение этой проблемы было интересным. В этой статье я расскажу об этом методе, о том, как мы создали его с помощью Redis, и о нашем опыте масштабирования системы.

Эта проблема

Создание API не так уж и сложно, если вы понимаете основы. Мы отправляем HTTP-запросы на сервер, он выполняет некоторую работу, а затем возвращает запрошенные данные. Простой. Но что происходит, когда этот запрос требует работы, выходящей за рамки его компетенции? Например, когда я упоминаю пользователя, системе необходимо отправить push-уведомление затронутым пользователям. Обработка уведомления в течение жизненного цикла этого запроса приведет к задержке окончательного ответа. По мере того, как наша система уведомлений становилась все более сложной, стало ясно, что нам нужно больше об этом подумать.

Для обработки уведомления и последующей отправки push-уведомления требуются вызовы базы данных и внешних API. Процесс разбивается следующим образом:

  1. Происходит действие, которое требует создания уведомления.
  2. Уведомление создается и вставляется в базу данных.
  3. Это уведомление отображается на группу пользователей, которые его получат.
  4. Мы получаем список всех устройств для пользователей, которых нам нужно уведомить.
  5. Мы отправляем push-уведомление на каждое зарегистрированное у нас устройство.
  6. Мы обновляем статус отправки этого уведомления и удаляем недействительные токены устройств.

С каждым из этих 6 шагов связан как минимум один запрос к базе данных. Этот процесс может завершиться очень быстро, если на устройство одного пользователя нужно отправить одно уведомление, но если на это уходит больше времени, для запросов существует риск истечения тайм-аута. Мы должны отделить эту логику, чтобы ее можно было обрабатывать вне жизненного цикла запроса / ответа.

Очередь задач

Очереди задач управляют списком работ, которые необходимо выполнить в отдельном процессе. Одна система добавляет работу в конец очереди, а другая - сверху. Нам нужно создать объект Task, который представляет работу, описанную выше, а затем добавить его в очередь задач. Прежде чем мы могли начать, мне нужно было задать несколько фундаментальных вопросов.

1. Где будет находиться очередь задач?

Мы уже использовали Redis в качестве системы кэширования, поэтому, когда я начал искать способы создания очереди, Redis был очевидным выбором. Он не только хорошо построен для работы с этим шаблоном, но и в Интернете есть множество ресурсов, в которых обсуждается, как он построен. Для этого есть много других вариантов, и если вы используете Google App Engine, вам следует изучить очередь задач Google Cloud, которая предлагает больше встроенных функций.

2. Как мы узнаем, что элемент был добавлен в очередь?

Я потратил немного времени, пытаясь понять. Я не хотел опрашивать Redis каждые n миллисекунд на предмет новых вакансий. На моем радаре обнаружились два метода. Первая - это система Redis Pub / Sub. Для этого метода у меня была бы функция, которая подписывается на канал и получает по нему сообщения. Эти сообщения будут предупреждать меня о том, что новая задача готова к запуску. Второй - использовать простой список Redis в качестве очереди и всплывающий примитив списка блокировки BLPOP для ожидания, пока элемент не будет готов для удаления из очереди.

Во время нашей первой итерации этого дизайна мы использовали шаблон Pub / Sub, но он добавил ненужный уровень сложности. Кроме того, при масштабировании нашей системы нам пришлось проделать дополнительную работу, чтобы убедиться, что сообщение не было обработано на нескольких машинах. Поэтому мы перешли на метод List и BLPOP.

3. Что мы отправляем в очередь задач?

«Ну, мы отправили задачу, да…» - вот что вы могли подумать, но очереди поддерживают только добавление строк, поэтому мы не можем толкать объект. Надо вдавить ключ в конец. Я задам этот вопрос, чтобы сбить меня с толку больше, чем следовало бы, главным образом потому, что я не был уверен, какой метод является «лучшим». Был ли ключ основным идентификатором в нашей базе данных или должен быть ссылкой на какой-то объект в Redis? Где мы проводим черту в работе, которая должна быть выполнена? Я решил отправить идентификатор первичного ключа событий в очередь и позволить Задаче решить, как с этим справиться. Например, если пользователь проголосует за сообщение, я отправлю идентификатор действия проголосовать в vote_queue, как только он будет извлечен из очереди, служба будет знать, как с ним справиться.

Установка

Хорошо, я установил проблему и ответил на некоторые из моих вопросов (надеюсь, они ответят на некоторые из ваших), теперь давайте взглянем на общую диаграмму того, как это будет работать.

Как видно из диаграммы, на сервере работают две системы. TaskScheduler создаст новую задачу, добавит ее в базу данных, а затем поместит идентификатор задачи в конец очереди задач. TaskManager ожидает добавления задачи в очередь и обрабатывает ее соответствующим образом.

Образец кода

Файл TaskScheduler.js - это базовый пример того, как мы можем добавить задачу в базу данных, а затем поместить ее в конец очереди задач. Как только он помещен в очередь, он начнет обработку, когда TaskManager начнет прослушивание.

Суть TaskQueue.js - это базовый пример того, как реализовать его в NodeJS с помощью async / await.

Улучшения

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

Кроме того, в примере кода мы запускаем по одной задаче за раз. Это не идеально, потому что длительная задача может создать резервную копию всей очереди. Вместо этого у нас должен быть пул запущенных задач, которые добавляются и удаляются по мере необходимости. Как только пул заполнится, цикл while будет ждать нового места.

Обзор

Описанный метод не слишком сложен, но он отделяет бизнес-логику от логики приложения. Сделав это незначительное изменение, мы можем начать итерацию по производительности системы и построить более надежные очереди и службы. Мы также можем воспроизвести этот метод для обработки различных длительных процессов, таких как системы рекомендаций, обработка текста и т. Д.