Сначала все работало на REST и работало хорошо, но начался рост…
Skyeng постоянно взаимодействует с учениками: периодически нам надо позвонить — чтобы подтвердить запись на пробный урок или, например, уточнить, все ли хорошо, если человек пропустил занятие.
На заре школы работа со звонками велась вручную, но бизнесу быстро захотелось как-то автоматизировать и анализировать работу операторов. Это помог сделать Voximplant. Мы до сих пор пользуемся их технологиями — удобно.
Чтобы операторы не слушали гудки, не тратили время на прозвон недоступных номеров и так далее, у ребят есть PDS (predictive dialing system) — система автоматического дозвона. Она берет два пула — операторов и клиентов, и по ходу прозвона вычисляет контактность базы, задавая скорость дальнейшего набора номеров. Идея в том, чтобы операторы и клиенты как можно меньше ждали на линии.
Долгое время все работало по примерно такой схеме.
Например, мы загружаем PDS список из 1000 номеров — и знаем, что сейчас у нас 50 операторов. Начинаем прозванивать первые 100.
- Номер недоступен — обрабатываем сценарий на JS, отправляем на endpoint номер и его статус, пишем в базу факт неудачного звонка.
- Срабатывает автоответчик: все то же самое, но со статусом «автоответчик»?.
- Происходит дозвон до клиента — он берет трубку и говорит «Алло»?. В это время из пула операторов срочно подыскивается первый подходящий: мы знаем, что если держать человека «на проводе»? больше двух секунд, он просто положит трубку.
При росте начались сложности. PDS вычисляет контактность базы — и набирает еще сколько-то номеров. А параллельно считает длительность текущих разговоров и обновляет статистику, чтобы балансировать время ожидания операторов и клиентов. Упор всегда делается на удобство клиента (те самые пара секунд), но время простоя оператора то не должно затягиваться. Нормально, если это 30-40 секунд. На практике случалось, что утилизация времени операторов достигала почти 30% — они долго сидели в ожидании, это было критично.
Также мы стали сталкиваться с кейсами двойного прозвона. С утра мы собирали большой колл-лист на тысячи номеров и загружали его в систему. Но параллельно через сайт приходили новые потенциальные ученики — опять же, опытным путем бизнес установил, что их надо прозвонить в приоритетном порядке в ближайшие часы после заявки. Опции «докинуть номеров»? у PDS не было. Поэтому мы останавливали ее, обновляли лист и снова запускали его в работу. Но на момент остановки часть номеров могла уйти в набор — а вот их статусы в базу еще не пришли. Они не помечались как прозвоненные и, бывало, что с человеком поговорили, а через две минуты ему набирает следующий оператор… Мы не могли пометить, что номер отдан в прозвон, на своей стороне: так как работали сразу с тысячами номеров, это вызывало большие тормоза на нашей базе.
Тогда ребята из Voximplant дали нам нам прототип своей новой PDS — более продвинутого решения, которое получило название PDS2. И нам надо было как-то подключиться к нему.
Почему выбрали gRPC? И почему не подошел клиент на Go
Ох, у gRPC много классных фич:
- Protobuf как инструмент описания сериализации типов данных — мы описываем протокол в протофайл, это быстро.
Вот типичный protobuf-файл — очень похож на JSON, всё достаточно просто:
syntax = "proto3" ;
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3
}
- Есть gRPC-плагин, который позволяет сгенерировать весь необходимый код из протофайла — сервис, типы, PDS-клиент, в нашем случае. Единственное, что он не генерит, это серверную часть на PHP.
- HTTP/2 в качестве транспорта — можно прекратить выполнение запроса на сервере, а еще переиспользовать один cокет для нескольких параллельных запросов.
- Вместо набора «domain.com/сервис/коллекция/ресурс/запрос? параметр=значение», как в REST, есть только сервис. Все остальное описывается через Protobuf в терминах нашей модели и ее событий.
Однако, выбрали не из-за фич: просто выбора не было — PDS2 общалась только по gRPC.
А вот с Go мы попробовали. Прототип клиента от разработчиков из Voximplant какое-то время крутился в проде, но его тяжело было поддерживать. PHP основной язык в Skyeng, на нем написано почти все. И мы поняли, что надо тащить в Go-клиент много кода, а затем поддерживать его и в клиенте, и PHP-части. Например, у нас были проблемы с таймзонами — и было решение, но на PHP. И это всё приходилось уносить на тот Go-клиент.
Долго обсуждали это с тимлидом, и в итоге решили, что проще затянуть все на PHP. Спустя месяцы эксплуатации понимаю, что это было верное решение.
Что делать, если PHP не завезли? Написать свое решение — это (почти) просто
Я следовал рекомендациям с gRPC.io для PHP. В принципе, там описано все, что нужно.
Был лишь один забавный нюанс. Пару дней искал решение, как сгенерировать код с неймспейсами в нашем протофайле. Все сгенерил, все нормально, только их не хватает. В итоге засел перечитывать всю документацию. Оказалось, оно называется packages.
Так что, если тоже зададитесь вопросом, всё достаточно просто: пишем в файле
package foo.bar;
message MyMessage {}
И это сгенерирует вот такой namespace.
Foo\Bar\MyMessage
Детали по ссылке.
Как это работает. При генерации мы задаем необходимые параметры подключения к Voximplant, стартуем, и у нас получается бесконечный цикл, который постоянно слушает наш стрим. Наш клиент — по факту, обычный демон.
Вот пример от Voximplant. Наш бандл показать не могу: он сильно разросся за счет сложной и специфичной для нас логики.
Что в итоге
Наш клиент вместе с supervisorD крутится на проде с января, он стабилен. В сочетании c супервизором это почти демон — если что, супервизор поднимет и запишет падение к себе.
Проблемы роста мы решили.
Благодаря демону мы отгружаем номера по запросу, динамически, маленькими порциями — по 50 за раз. И теперь, если у нас появляются какие-то «горячие» номера с морды сайта, он уже знает, что эти номера имеют самый высокий приоритет — когда от Voximplamt приходит новый запрос, отправляет их. У нас появилась гибкость.
А еще, время ожидания операторов сократилось примерно до 20 секунд, — но это уже чисто за счет лучших алгоритмов самой PDS2, которую писали не мы.
Tatikoma
Сомнительное преимущество, по крайней мере в такой формулировке.
Голый TCP стрим позволяет всё тоже самое — писать в стрим можно сколько угодно, так что конкурентные (не параллельные, кстати!) запросы есть сразу из коробки. Прекратить выполнение запроса на сервере — если сервер поддерживает, то можно и в рамках отдельной PDU иметь такой функционал.
Кажется HTTP здесь только добавляет оверхэд.