Асинхронный бег или драматическое ускорение с RoadRunner
Всем привет. Меня зовут Макс Хасанов, я занимаюсь вебразработкой в АльфаСтраховании.
Очень много в последнее время слышно замечаний в адрес PHP — мол, медленный, тяжелый, неповоротливый, все давно микросервисы на Go/Java/(нужное подставить) пишут. В этой статье я постараюсь расписать плюсы, минусы и результаты нашей попытки ускорить проект на PHP с использованием RoadRunner.
Синхронность PHP
Итак, как мы все знаем, классический PHP синхронен. Каждый скрипт это отдельный процесс, жизненный цикл которого всегда один — инициация, исполнение, генерация ответа, завершение.
Инициация.
Происходит загрузка модулей, загрузка расширения, загрузка конфигурации. Получение секретов, установка коннекта к БД.Выполнение.
Это построчное исполнение инструкций скрипта, запросы во внешние системы, базы данных, какие-то операции над полученной информацией.Генерация.
Формирование ответа, накопление его в буфере, либо передача веб-серверу.Завершение.
Освобождение ресуров, очистка окружения.
Минусы подхода
Высокая ресурсоемкость процесса.
Под каждый новый процесс мы тратим время и процессорную мощность на инициацию. Для pet-проекта с 1–3 RPS это абсолютно незаметно, но если у вас высоконагруженный проект с 300–500 rps — малюсенькое 0,001 секунды превращается во внушительные 0,3–0,5 секунды, загрузка процессора также растет.Условная многопоточность.
«Из коробки» PHP работает с одним ядром процессора, и для утилизации остальных ядер необходима тонкая доработка и настройка. Многопоточность можно немного улучшить с помощью фреймворков, но в целом она все равно останется неполноценной.
Самые распространенные методы ускорения
Кеширование.
Например, Opcache, встроенный в PHP. Начиная с PHP 7.4 появилась возможность preload — то есть, если у вас подходящая версия PHP — то вы можете уменьшить время которое теряется на стадии инициации.
Минус тут только один — если вдруг у нас изменяются параметры ктоторые были закешированы, обновить в моменте кеш мы можем только через перезагрузку всего процесса PHP.Использование библиотек многопоточности.
Для PHP на данный момент основными библиотеками являются Parallel и pthreads (второй используется только в cli-процессах).
Минус этого метода в том, что этот подход очень чувствителен к хорошо проработанной архитектуре и качественному коду, и требует глубоких знаний и опыта в этих библиотеках, а отладка неявной ошибки превращается в великую задачу.
Как следствие — скачкообразный рост трудозатрат на сопровождение и развитие системы. Кроме того, проблему с синхронностью этот способ решает слабо.Самый распространенный выход — наращивание мощности.
Увеличить частоты процессора, добавить ОЗУ, поднять кластер на сотню машин и так далее.
Минус очевиден — ресурсы имеют свои пределы — как технические так и финансовые. Также обслуживание кластера потребует дополнительных трудозатрат.Отказаться от PHP в пользу другого языка программирования.
Фраза «Если для тебя в PHP критичны сотые доли секунды, значит тебе не нужен PHP» и ее вариации довольно распространены в сообществе.
Однако, «смена рельс» — это всегда проблема. Финансов, кадров, архитектуры и сроков. В итоге — это самый дорогой способ решения проблемы.Попробовать асинхронное выполнения скрипта.
Цель — исключить дублирование операций с одними и теми же данными и результатом. И если создание объекта класса мы можем закешировать (п.1), то например устанавливать соединение к базе данных нам придется на каждом запуске скрипта.
И вот тут на помощь нам может прийти RoadRunner.
RoadRunner и что он умеет
Это PHP сервер написанный на GoLang, и как следствие — позволяющий прикоснуться к плюсам и Go и PHP, при этом без изменения ЯП на проекте. Он умеет работать с долгоживущими процессами, умеет обрабатывать статику и поддерживается современными фреймворками, такими как Laravel или Symfony.
Основной RoadRunner является использование воркеров. Это процессы, которые может быть переиспользованы, при этом они изолированы от других и не влияют друг на друга.
Основные плюсы такого подхода следующие:
Длительный цикл жизни воркеров.
Они не прекращают свое существование после завершения операции — т.е. мы можем один раз провести стадию инициации и хранить ее результаты столько, сколько нам нужно.Механизм graceful shutdown.
Если нам надо провести новую инициализацию (параметры подключения изменились, например) — есть возможность плавного перезапуска воркеров только после завершения текущих операций.Легкое горизонтальное масштабирование.
При необходимости RoadRunner способен запустить дополнительные воркеры, обеспечивая тем самым горизонтальную масштабируемость.Настоящая многопоточность.RoadRunner эффективно использует все предоставленные ядра процессора, тем самым повышая утилизацию текущих ресурсов.
Примеры
Давайте просто посмотрим на код. Код абсолютно простой, взят из документации и приведен для демонстрации, поскольку коммерческая разработка в компании у нас под NDA
waitRequest();
if ($request === null) {
break;
}
} catch (\Throwable $e) {
$psr7->respond(new Response(400));
continue;
}
try {
$psr7->respond(new Response(200, [], 'Hello RoadRunner!'));
} catch (\Throwable $e) {
$psr7->respond(new Response(500, [], 'Something Went Wrong!'));
$psr7->getWorker()->error((string)$e);
}
}
Для удобства понимания, скрипт разделен комментариями на три части.
Верхняя часть — инициация — будет исполнена один раз при создании воркера, после чего воркер до завершения его работы будет находиться в памяти, ожидая подключений и запуска операций.
Таким образом, мы можем установку соединения с БД, получение ключей из хранилища секретов, прочие операции исполнить один раз.
Далее — каждый запуск скрипта в уже существующем воркере просто переиспользует результаты из инициации.
Минусы подхода
Требуется следить за памятью.
Т.к. воркеры Roadrunner являются долгоживущими — вероятность появления утечек памяти значительно выше чем в традиционном скрипте.Подход хорош только для высоконагруженных систем.
Экономия в 0,01 секунды незаметны на 1 RPS. А на 100 RPS это уже 1 секунда. Кроме того, на малых проектах мы повышаем использование памяти, при этом не видим ощутимую экономию процессорного времени.Высокие требования к качеству кода.
Зачастую внедрение RoadRunner требует глубокого рефакторинга всей кодовой базы. (Единственное, что не делает этот плюс критичным — процесс перехода на RoadRunner можно встроить в текущий процесс работы с техническим долгом без значительного увеличения трудозатрат).
Немного про опыт
В исследовательских целях мы реализовали перевод на Roadrunner одного из проектов. Функционал — обеспечить возможность клиентам одного из партнеров оформлять документ без необходимости ввода каких-либо данных. Т.е. нажал ссылку — проверил свои персональные данные — нажал кнопку «согласен» — получил полис на почту.
Что происходило под капотом:
Валидация пришедших параметров, проверка подписи запроса (система проверяет что запрос был сгенерирован здесь и сейчас, конкретно вот этим пользователем, с использованием конкретной доверенной площадки).
Процесс расчета и возврат информации клиенту.
Создание полиса во внутренней учетной системе.
Генерация платежной ссылки через API банка.
Использовался MVC паттерн для минимизации кода и расширяемости — впоследствии к данному проекту подцепили еще дополнительного функционала. В итоге имеем проект с малой кодовой базой, написанный на чистом PHP, с ± чистым кодом, и высокой нагрузкой — идеальный вариант, чтобы пощупать новую технологию без больших трудозатрат.
Простейший скрипт исполняющий задачу «обработать GET-запрос и отдать view пользователю» на пальцах можно представить следующим образом:
ControllerInterface.php
GenericController.php
twig = new Environment($loader, ['debug' => true]);
/**
* иная логика инициализации - получение файла подписи,
* инициация соединения с базой
*/
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
// получаем GET-параметры, $_GET - не существует
$getParams = $request->getQueryParams();
/**
* не забудьте очистить полученные параметры,
* поскольку RR строкой выше отдаст их в небезопасном виде
*/
$data = false;
if ($this->checkFields() && $this->checkSign()) {
$service = new GenericService();
$data = $service->getData($getParams);
}
if ($data) {
$content = $this->twig->render('index.twig', array('data' => $data, 'string' => $string));
return new Response(200, [], $content);
} else {
$content = $this->twig->render('404.twig');
return new Response(404, [], $content);
}
}
protected function checkFields(): bool
{
}
protected function checkSign(): bool
{
}
}
Файл воркера:
waitRequest()) {
try {
$response = $controller->handle($req);
$worker->respond($response);
} catch (\Throwable $e) {
$response = new \Nyholm\Psr7\Response(500, [], $e->getMessage());
$worker->respond($response);
}
}
Подводные камни
С какими проблемами мы столкнулись при экспериментах:
Воркер не знает про суперглобальные переменные $_POST, $_GET — поэтому их придется получать методами воркера.
Следует помнить, что RR возвращает их в небезопасном виде.RoadRunner не чистит память сам по себе.
Важно помнить об этом, когда значение переменных класса с течением времени может стать неактуальным — в классическом PHP это не проблема, поскольку они на каждом запуске скрипта обновляются, в RoadRunner — это может стать болью.Не стоит забывать про тонкие настройки RoadRunner для правильного управления памятью.
По умолчанию воркеры живут вечно, и минимальная утечка памяти может обернуться перерасходом ресурсов вместо экономии.Не забудьте помимо перевода программного кода на RR доработать существующие тесты :-)
Результаты и цифры
Трудозатраты на перевод ресурса на RoadRunner — 1 человеконеделя (на разработку проекта был затрачен ~1 человекомесяц, проект без легаси, хорошо документированный).
Среднее время обработки запроса в пиковое время упало с 0.8 до 0.5 секунд
Сэкономили на счете на железо — поскольку общее количество утилизируемого процессорного времени значительно уменьшилось, счета на облако стали более приятными (~30% экономии).
Выводы
RoadRunner показал себя как мощный и дешевый инструмент для улучшения производительности PHP-приложений. Правда, стоит отметить, что многое все-таки зависит от культуры кода и количества legacy-кода на проекте.
Ну и мы только начали наращивать экспертизу в этом направлении, поэтому еще вернемся с интересными кейсами.