Всем привет. Меня зовут Макс Хасанов, я занимаюсь вебразработкой в АльфаСтраховании.
Очень много в последнее время слышно замечаний в адрес 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
<?php /** Инициация */ require __DIR__ . '/vendor/autoload.php'; use Nyholm\Psr7\Response; use Nyholm\Psr7\Factory\Psr17Factory; use Spiral\RoadRunner\Worker; use Spiral\RoadRunner\Http\PSR7Worker; $worker = Worker::create(); $factory = new Psr17Factory(); $psr7 = new PSR7Worker($worker, $factory, $factory, $factory); /** Тело воркера */ while (true) { try { $request = $psr7->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
<?php namespace Alfastrah\Controllers; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; interface ControllerInterface { public function handle(ServerRequestInterface $request): ResponseInterface; }
GenericController.php
<?php namespace Alfastrah\Controllers; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Nyholm\Psr7\Response; use Twig\Environment; use Twig\Loader\FilesystemLoader; class ContractsListController implements ControllerInterface { private Environment $twig; public function __construct() { $loader = new FilesystemLoader(__DIR_TO_TEMPLATES__); $this->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 { } }
Файл воркера:
<?php use Spiral\RoadRunner\Http\PSR7Worker; use Nyholm\Psr7\Factory\Psr17Factory; use Alfastrah\Controllers\GenericController; use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\Extension\DebugExtension; require 'vendor/autoload.php'; $worker = new PSR7Worker( new Psr17Factory(), new Psr17Factory(), new Psr17Factory(), Spiral\Goridge\RPC\GORIDGE); $controller = new GenericController(); while ($req = $worker->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-кода на проекте.
Ну и мы только начали наращивать экспертизу в этом направлении, поэтому еще вернемся с интересными кейсами.
ссылка на оригинал статьи https://habr.com/ru/articles/875726/
Добавить комментарий