Как делать пуш-уведомления в мобильном приложении и зачем
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Всем привет, меня зовут Павел Тополь, я тимлид команды веб-разработки. Уже больше двух лет работаю над сайтом поиска работы Work.ua. В двух словах о проекте: это highload на PHP, кроме сайта, мы развиваем мобильное приложение для поиска работы. Думаю, это все, что нужно знать для понимания данной статьи. А в ней я хочу рассказать историю того, как мы делали у себя пуши.
Путь к пуш-уведомлениям
Зачем нам пуши и почему именно сейчас
Еще до того, как наше приложение попало в сторы, мы точно знали, что нам будут нужны пуши. Ведь какое современное приложение обходится без них? Оно и понятно, пуши — это весьма эффективный способ доставки информации пользователю, который еще и помогает вернуть его в приложение. Чего нельзя сказать о почте.
Почта — это больше про отложенное общение. Если вы пришлете пользователю письмо, маловероятно, что он тут же на него отреагирует. А если и отреагирует, то для этого ему нужно будет:
- Уйти из вашего приложения.
- Прочитать (в лучшем случае по диагонали) ваше письмо.
- Найти в нем ссылку.
- Перейти по ней.
Только после этого пользователь вернется в приложение. Какой-то слишком длинный путь проходит сообщение о том, что работодатель ответил на отклик соискателя.
Но просто сделать пуш — не вариант: нужно место, где будет храниться история уведомлений, полученных пользователем. Ведь если пользователь смахнет уведомление, у него должна остаться возможность увидеть то, о чем вы хотели его оповестить, позже. Так у нас в приложении появился центр уведомлений.
Выбор сервиса отправки
Мобильное приложение Work.ua присутствует сразу на двух платформах — Android и iOS, и это сыграло свою роль при выборе сервиса для отправки пуш-уведомлений.
Изначально мы рассматривали вариант нативной отправки. Мы посмотрели на APNs, на web-пуши и, когда дело дошло до Android, обнаружили, что Firebase, по сути, и является той самой нативной платформой для отправки пушей для него. К тому же различные библиотеки пакета Firebase уже использовались в нашем мобильном приложении. Оказалось, что Firebase поддерживает мультиплатформенную отправку пушей. С одной стороны, это замечательно: отправляешь запрос в Firebase, даешь ему токен устройства, на которое нужно доставить пуш, и все. Дальше Firebase все сделает сам. Но поначалу нам показалось, что все слишком уж хорошо. Нас пугало то, что все это бесплатно.
Так как мы планировали отправлять много пушей (среднее значение сейчас — это приблизительно двести тысяч пушей в день), то если Firebase вдруг решит брать деньги за отправку уведомлений на сторонние платформы, это может обойтись нам в круглую сумму.
Мы посмотрели на разные сервисы, поддерживающие мультиплатформенную отправку, но их было еще страшнее использовать. Помимо проблем, аналогичных проблемам с Firebase, возникает большая вероятность получить неправильно написанный механизм доставки уведомлений. В худшем случае это может закончиться тем, что на устройствах пользователей будет очень быстро садиться аккумулятор, а за это приложение могут даже забанить.
Взвесив все за и против, обсудив всевозможные варианты, мы пришли к тому, что в случае, если Firebase станет платным, для нас не будет большой проблемой поменять сервис для рассылки, например, на нативный для каждой из платформ. Так что остановились на Firebase.
Наш первый пуш
Выбор тематики уведомления
Первое, с чем мы должны были определиться: каким будет наш первый пуш. Основными критериями при выборе первого пуша были:
- Массовость — нам нужно было сразу увидеть, какую пропускную способность мы можем обеспечить. Не хотелось бы попасть в ситуацию, где при отправке одного пуша все шло нормально, а при массовых рассылках мы просто положили серверы из-за нагрузки.
- Наличие посадочной страницы. Не все наши уведомления имели экраны, которые можно было бы показать пользователю при переходе из пуша.
- Полезность — мы подумали, что хоть уведомление об архивации резюме может быть полезно некоторым пользователям, большинство его не оценит. Пуш не должен был разочаровать пользователей, не должно было возникнуть желания сразу отписаться от пуш-уведомлений.
Таким образом нам удалось выделить три подходящих кандидата. И победителем из этой тройки вышел пуш «Новые рекомендуемые вакансии». Он был достаточно массовым, нам было куда вести пользователя при тапе на уведомление, ну и, конечно же, на наш взгляд, это уведомление было весьма полезным.
Проблемы внешних зависимостей
Для того чтобы не перенагружать и без того нагруженные серверы в дневное время, у нас принято запускать массовые рассылки ночью или рано утром, когда серверы еще не под сильной нагрузкой. Но нам бы не хотелось будить пользователей своими уведомлениями. Поэтому нам стоило придумать, как сделать рассылку днем и при этом не «положить» серверы.
Основная нагрузка у нас приходилась на серверы баз данных. Мы используем БД-кластер, состоящий из двух серверов. Один сервер — основной, второй — резервный, реплицирующийся с помощью drbd. И поскольку 99,9 процента времени резервный сервер простаивает, мы решили задействовать его в рассылке нашего пуша.
Первым делом мы запустили на нем sphinx и расчет дельта-индексов. Выделили для него IP-адрес, который всегда указывает на slave-сервер БД. Это нужно для того, чтобы мы могли спокойно поменять ротацию серверов, slave сделать мастером, а мастер — слейвом, и все продолжило бы работать. В случае если сервер недоступен, мы просто прерываем рассылку.
Архитектурные решения и итеративный подход
Любые HTTP-соединения к сторонним сервисам лучше всего делать асинхронно, иначе время выполнения вашего кода будет напрямую зависеть от времени ответа сервиса. В нашем случае для асинхронности мы использовали наш сервер очередей. Мы добавили воркер, задача которого — отправлять пуш-уведомления. И хотя Firebase API дает возможность отправлять пуш-уведомления не по одному за раз, а целыми «пачками» по 500 штук, в первой версии мы этого делать не стали.
Мы рассматривали пуш-уведомления как новый канал связи с пользователями. Было понятно, что через время пушей станет в разы больше, и просто собирать их в «пачки» в скриптах рассылки — это не лучший вариант. Как только появятся пуши по событию, скорость их рассылки будет неуклонно падать, потому что очередь может забиваться отправками по одному пушу. Единственный выход из этой ситуации — постоянно увеличивать количество воркеров, но в нашем случае это не вариант — сервера ведь не резиновые.
Все написанное выше продолжало оставаться для нас теорией, и хотелось проверить на практике, с какой скоростью мы сможем отправлять пуши в самом простом варианте: одна задача воркера — один пуш с отдельным подключением к Firebase. Мы сделали MVP — кроновый скрипт, который выбирал пользователей, подходящих по условиям для рассылки, и воркер, который отправлял пуш. И приступили к тестированию.
Первая рассылка
Наша первая рассылка выглядела следующим образом:
Шаг 1. Запускающийся по крону скрипт. Единственной его задачей был выбор всех пользователей, которым мы хотим отправить пуш-уведомление, и добавление задачи в очередь на просчет данных, необходимых для пуша, для каждого из них.
Шаг 2. Воркер, рассчитывающий необходимые для пуша данные. После расчета он создает наш внутренний объект пуша, сериализует его и добавляет в очередь задачу на отправку пуш-уведомления.
Шаг 3. Воркер, десериализующий объект пуша, трансформирующий его в необходимую для Firebase JSON-структуру и отправляющий пуш-уведомление в Firebase. Если Firebase нам отвечает, что токен устройства невалиден, этот воркер создает задачу на удаление невалидного токена из БД.
Шаг 4. Воркер, удаляющий токен из БД.
Для мониторинга всего процесса мы добавили логирование практически всего, что могло оказаться интересным. Мы следили за тем, сколько пользователей попало в выборку на первом шаге, для кого из этих пользователей у нас были данные для отправки пуша, статусы всех отправленных пушей и еще много различной информации. Это сильно нам помогло понимать, что именно происходит с нашей рассылкой.
Первое, что мы заметили в логах, — иногда Firebase возвращал 5XX код ответа.
В документации в этом случае рекомендуется повторить запрос спустя какое-то время. Изначально мы не предусмотрели этот случай, поэтому пуши, на которые мы получали такой ответ, мы попросту не доставляли пользователю. Но после добавления ретраев, мы с этой проблемой не сталкивались.
Показатели производительности
На протяжении следующей недели мы наблюдали, как быстро растет количество пользователей, которым мы отправляем пуши, и как при этом увеличивается временной разрыв между генерацией пуша и фактической его отправкой. Если в день запуска это были какие-то секунды, то уже через неделю это были минуты.
При этом пропускная способность этого решения составила приблизительно 40 пушей в секунду. Может показаться, что это весьма неплохой результат, но на практике задержка между подготовкой данных и отправкой пуша могла достигать 15 минут, что для нас было неприемлемо. Это означало, что, когда пуш был фактически отправлен из очереди, данные, которые мы в нем отправляли, скорее всего, уже успели стать неактуальными. Нам нужно было либо замедлять этап подготовки данных, чего мы делать не хотели, потому что это растянуло бы рассылку, а нагрузка на сервер и так была небольшой, либо ускорять отправку, но даже максимальное количество воркеров не позволило бы нам сделать это так, чтобы пуши рассылались сразу после генерации данных. Поэтому нам нужно было придумать другой способ.
Ускорение отправки пушей
Трудности с legacy
Для того чтобы менеджить все воркеры, мы используем утилиту под названием GearmanManager. Утилита неплохая, со своей работой справлялась. Но для реализации нашей идеи ее возможностей не хватало. Большим минусом для нас стала негибкая реализация. Воркер — это либо колбек, либо класс с определенным именем и структурой (подробней об этом можно почитать в описании утилиты). Сам цикл обработки зашит в библиотеку, и без ее изменения нет возможности на него повлиять. А наша идея как раз заключалась в том, чтобы вмешаться в этот цикл.
Поиск и выбор решения
Вариантов было несколько. Сделать какой-то обходной путь для того, чтобы в файлах воркеров можно было как-то влезть в такой нужный нам цикл и создать на это PR. Но судя по активности, библиотека уже умерла. Можно было, конечно, пропатчить все это у себя локально, но мы решили, что это путь в никуда. Выбрали, на наш взгляд, самый правильный, но не самый простой вариант. Мы решили полностью отказаться от GearmanManager и заменить его на что-то более универсальное и актуальное. Наш взгляд пал на Supervisor.
Сложность варианта заключалась в том, что в перспективе мы хотели полностью отказаться от GearmanManager в пользу Supervisor. А это означало, что нам нужно было сразу продумать все абстракции: для того, чтобы в будущем с минимальными затратами перевести все существующие воркеры на использование Supervisor в качестве менеджера процессов. В итоге мы декомпозировали работу GearmanManager. Ту часть, которая отвечала за мониторинг процессов, мы отдали Supervisor. Нам осталось реализовать работу с Gearman-сервером и конфигурирование количества запускаемых воркеров для каждого из серверов.
Обновленная отправка
Проанализировав результаты рассылок, мы поняли, что большая часть времени при отправке пуша тратится на издержки при HTTP-соединении. Нам нужно было как-то их уменьшить, и это можно было сделать. Напомню, Firebase API позволяет передавать на отправку не по одному пушу, а «пачками» до 500 штук. Не нужно быть математиком, чтобы понять, что даже если сама задержка на ответ от Firebase будет немного больше, чем при отправке одного пуша, то мы все равно получим колоссальный прирост в скорости рассылки.
Идея заключалась в том, чтобы по-прежнему добавлять по одному сообщению в очередь, а воркеры по необходимости собирали бы их в «пачки» и отправляли на серверы Firebase. Таким образом мы получаем механизм, который в пиковые моменты собирает пачки по 500 пуш-уведомлений, а в обычное время может отправлять и по одному пушу.
Как оказалось, Gearman из коробки позволяет это сделать. Вся сила заключается в двух методах: setTimeout и wait. Мы определяем какое-то время, приемлемое для того, чтобы воркер придержал у себя уже готовый к отправке пуш. Для нас это было ≈25 секунд. Чтобы достичь этого времени, нам нужно было установить таймаут в 50 миллисекунд (25 / 500 * 1000). Таким образом, максимальная разница между получением задачи на отправку первого пуша и пятисотого будет ≤25 секунд.
В итоге у нас механизм, который получает задачу на отправку пуша, складывает пуш в «пачку», ожидает до 50 миллисекунд задачу от сервера. Если задача не появилось, срабатывает таймаут и мы отправляем все, что успели сложить в «пачку». Если же задача появилась, мы кладем в «пачку» и этот пуш и опять начинаем ждать. И так до тех пор, пока в «пачке» не оказывается 500 пушей.
Подход показывает себя отлично. Когда на сайте небольшая активность, мы получаем задержку в 50 миллисекунд перед отправкой пуша. В пиковые моменты мы практически не ждем — все отправляется без задержек.
Показатели производительности
Сказать что стало лучше — это ничего не сказать. Первая же рассылка показала колоссальный прирост в производительности. Пропускная способность нового подхода оказалась приблизительно на 800% выше. Разрыв между подготовкой данных для пуша и его отправкой сократился практически до 0. Теперь рассылка упиралась только во время, затраченное на подготовку данных для пуша. И такие показатели держатся до сих пор. Вот как рассылка выглядит на свежих графиках:
Выводы
Исследование рынка помогло нам понять, какие инструменты мы хотим использовать в работе. А изучение потребностей наших пользователей — определиться с тематикой первого пуш-уведомления. Мы поняли, что для нас в приоритете сделать MVP, а не потратить время на разработку идеального продукта.
В ходе работы мы также оценили промежуточные результаты и определились с метриками, за которыми наблюдали. Это помогло нам понять, где было «узкое горлышко», и устранить его. Финальное решение отправлять пуши «пачками», дало значительный прирост в скорости отправки. Надеюсь кейс, рассмотренный в данной статье, будет вам полезен.
33 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів