Всем привет. Меня зовут Макс Хасанов, я занимаюсь веб-разработкой в АльфаСтрахование.

Очень много в последнее время слышно замечаний в адрес PHP – мол, медленный, тяжелый, неповоротливый, уже давно все микросервисы на Go/Java/(нужное подставить) пишут. В этой статье я постараюсь расписать плюсы, минусы и результаты нашей попытки ускорить проект на PHP с использованием RoadRunner.

Синхронность PHP

Итак, как мы все знаем, классический PHP синхронен. Каждый скрипт - это отдельный процесс, жизненный цикл которого всегда один – инициация, исполнение, генерация ответа, завершение.

  1. Инициация.
    Происходит загрузка модулей, загрузка расширения, загрузка конфигурации. Получение секретов, установка коннекта к БД.

  2. Выполнение.
    Это построчное исполнение инструкций скрипта, запросы во внешние системы, базы данных, какие-то операции над полученной информацией.

  3. Генерация.
    Формирование ответа, накопление его в буфере, либо передача веб-серверу.

  4. Завершение.
    Освобождение ресуров, очистка окружения.

Минусы подхода

  1. Высокая ресурсоемкость процесса.
    На инициацию нового процесса мы тратим время и процессорную мощность на инициацию. Для pet-проекта с 1-3 RPS это абсолютно незаметно, но, если у вас высоконагруженный проект с 300-500 rps, малюсенькое 0,001 секунды превращается во внушительные 0,3-0,5 секунды, загрузка процессора также растет.

  2. Условная многопоточность.
    «Из коробки» PHP работает с одним ядром процессора, и для утилизации остальных ядер необходима тонкая доработка и настройка. Многопоточность можно немного улучшить с помощью фреймворков, но в целом она все равно останется неполноценной.

Самые распространенные методы ускорения

  1. Кеширование.
    Например, Opcache, встроенный в PHP. Начиная с PHP 7.4 появилась возможность preload – то есть, если у вас подходящая версия PHP – то вы можете уменьшить время, которое теряется на стадии инициации. 
    Минус тут только один – если вдруг у нас изменяются параметры, которые были закешированы, обновить в моменте кеш мы можем только через перезагрузку всего процесса PHP.

  2. Использование библиотек многопоточности.
    Для PHP на данный момент основными библиотеками являются Parallel и pthreads (второй используется только в cli-процессах).
    Минус этого метода в том, что этот подход очень чувствителен к хорошо проработанной архитектуре и качественному коду и требует глубоких знаний и опыта в этих библиотеках, а отладка неявной ошибки превращается в великую задачу.
    Как следствие – скачкообразный рост трудозатрат на сопровождение и развитие системы. Кроме того, проблему с синхронностью этот способ решает слабо.

  3. Самый распространенный выход – наращивание мощности.
    Увеличить частоты процессора, добавить ОЗУ, поднять кластер на сотню машин и так далее.
    Минус очевиден – ресурсы имеют свои пределы – как технические, так и финансовые. Также обслуживание кластера потребует дополнительных трудозатрат.

  4. Отказаться от PHP в пользу другого языка программирования.
    Фраза «Если для тебя в PHP критичны сотые доли секунды, значит, тебе не нужен PHP» и ее вариации довольно распространены в сообществе.
    Однако «смена рельс» – это всегда проблема. Финансов, кадров, архитектуры и сроков. В итоге, это самый дорогой способ решения проблемы.

  5. Попробовать асинхронное выполнения скрипта.
    Цель - исключить дублирование операций с одними и теми же данными и результатом. И, если создание объекта класса мы можем закешировать (п.1), то, например, устанавливать соединение к базе данных нам придется на каждом запуске скрипта.
    И вот тут на помощь нам может прийти RoadRunner.

RoadRunner и что он умеет

Это PHP сервер, написанный на GoLang, и позволяющий прикоснуться к плюсам и Go и PHP, при этом без изменения ЯП на проекте. Он умеет работать с долгоживущими процессами, умеет обрабатывать статику и поддерживается современными фреймворками, такими как Laravel или Symfony.

Основной RoadRunner является использование воркеров. Это процессы, которые могут быть использованы снова, при этом они изолированы от других и не влияют друг на друга. 

Основные плюсы такого подхода следующие:

  1. Длительный цикл жизни воркеров.
    Они не прекращают свое существование после завершения операции – т.е. мы можем один раз провести стадию инициации и хранить ее результаты столько, сколько нам нужно.  

  2. Механизм graceful shutdown.
    Если нам надо провести новую инициализацию (параметры подключения изменились, например), есть возможность плавного перезапуска воркеров только после завершения текущих операций.

  3. Легкое горизонтальное масштабирование.
    При необходимости RoadRunner способен запустить дополнительные воркеры, обеспечивая тем самым горизонтальную масштабируемость.

  4. Настоящая многопоточность.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);

    }

}

Для удобства понимания скрипт разделен комментариями на три части.

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

Таким образом, установку соединения с БД, получение ключей из хранилища секретов и прочие операции мы можем исполнить один раз.

Далее – каждый запуск скрипта в уже существующем воркере просто переиспользует результаты из инициации.

Минусы подхода

  1. Требуется следить за памятью.
    Т.к. воркеры Roadrunner являются долгоживущими – вероятность появления утечек памяти значительно выше, чем в традиционном скрипте.

  2. Подход хорош только для высоконагруженных систем.
    Экономия в 0,01 секунды незаметны на 1 RPS. А на 100 RPS это уже 1 секунда. Кроме того, на малых проектах мы повышаем использование памяти, при этом не видим ощутимую экономию процессорного времени. 

  3. Высокие требования к качеству кода.
    Зачастую внедрение RoadRunner требует глубокого рефакторинга всей кодовой базы. (Единственное, что не делает этот плюс критичным, процесс перехода на RoadRunner можно встроить в текущий процесс работы с техническим долгом без значительного увеличения трудозатрат).

Немного про опыт

В исследовательских целях мы реализовали перевод на Roadrunner одного из проектов. Функционал – обеспечить возможность клиентам одного из партнеров оформлять документ без необходимости ввода каких-либо данных. То есть нажал ссылку, проверил свои персональные данные, нажал кнопку «согласен» и получил полис на почту.

Что происходило под капотом:

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

  2. Процесс расчета и возврат информации клиенту.

  3. Создание полиса во внутренней учетной системе.

  4. Генерация платежной ссылки через 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);
    }
}

Подводные камни

С какими проблемами мы столкнулись при экспериментах:

  1. Воркер не знает про суперглобальные переменные $_POST, $_GET, поэтому их придется получать методами воркера.
    Следует помнить, что RR возвращает их в небезопасном виде.

  2. RoadRunner не чистит память сам по себе.
    Важно помнить об этом, когда значение переменных класса с течением времени может стать неактуальным, в классическом PHP это не проблема, поскольку они на каждом запуске скрипта обновляются, в RoadRunner это может стать болью.

  3. Не стоит забывать про тонкие настройки RoadRunner для правильного управления памятью.
    По умолчанию воркеры живут вечно, и минимальная утечка памяти может обернуться перерасходом ресурсов вместо экономии.

  4. Не забудьте помимо перевода программного кода на RR доработать существующие тесты :-)

Результаты и цифры

  1. Трудозатраты на перевод ресурса на RoadRunner - 1 человеконеделя (на разработку проекта был затрачен ~1 человекомесяц, проект без легаси, хорошо документированный).

  2. Среднее время обработки запроса в пиковое время упало с 0.8 до 0.5 секунд

  3. Сэкономили на счете на железо – поскольку общее количество утилизируемого процессорного времени значительно уменьшилось, счета на облако стали более приятными (~30% экономии).

Выводы

RoadRunner показал себя как мощный и дешевый инструмент для улучшения производительности PHP-приложений. Правда, стоит отметить, что многое все-таки зависит от культуры кода и количества legacy-кода на проекте.

Ну и мы только начали наращивать экспертизу в этом направлении, поэтому еще вернемся с интересными кейсами.

Комментарии (4)


  1. MadridianFox
    22.01.2025 14:17

    Сомнительные плюсы. Никто не запускает веб-приложения на php как однопоточный синхронный скрипт. Подавляющее большинство проектов используют php-fpm, кто-то использует асинхронные фреймворки вроде AmPHP или ReactPHP.

    Механизм graceful shutdown.Если нам надо провести новую инициализацию (параметры подключения изменились, например) – есть возможность плавного перезапуска воркеров только после завершения текущих операций.

    В узком смысле любой нормальный сервер умеет в graceful shutdown - и nginx и php-fpm не бросают обрабатываемый запрос при перечитывании конфигурации.

    В широком смысле, такая фича на уровне приложения не очень-то и нужна, когда всё крутится в кубере и уже он обеспечивает плавное обновление подов.

    Легкое горизонтальное масштабирование.При необходимости RoadRunner способен запустить дополнительные воркеры, обеспечивая тем самым горизонтальную масштабируемость.

    Старый добрый php-fpm тоже может запускать дополнительные процессы.

    Настоящая многопоточность. RoadRunner эффективно использует все предоставленные ядра процессора, тем самым повышая утилизацию текущих ресурсов.

    Опять же, php-fpm запускает множество процессов, тем самым утилизируя больше одного ядра.
    А с точки зрения многопоточности в контексте приложения RR ничего нового не даёт - приложение продолжает синхронно работать в одном потоке. Просто как и в случае с php-fpm, у нас фактически запускается несколько практически не связанных друг с другом процессов.


    1. Vitaly48
      22.01.2025 14:17

      Сомнительные плюсы

      Плюсы в отсутствии бутстрапа приложения, плюс можно между запросами держать необходимые данные в памти, а не подгружать их каждый раз из БД, например словари или что то подобное. Плюс можно держать постоянное соединение с БД.

      На больших нагрузках это всё дает неплохой прирост производительности, либо при той же производительности мы может использовать меньше ресурсов сервера

      В узком смысле любой нормальный сервер умеет в graceful shutdown - и nginx и php-fpm не бросают обрабатываемый запрос при перечитывании конфигурации.

      При классическом запуске Nginx + PHP-FPM над этим думать не нужно, но автор рассказывает про RoadRunner, а RoadRunner как раз запускает PHP код как демон.
      Если не сделать корректную обработку сигналов, то при передеплое приложения кубер будет ждать когда приложение корректно завершиться, но после того как будет превышено значение grace period кубер просто принудительно убьёт под. И это может произойти в процессе обработки запроса.


  1. Vitaly48
    22.01.2025 14:17

    Попробовать асинхронное выполнения скрипта.Цель - исключить дублирование операций с одними и теми же данными и результатом. И если создание объекта класса мы можем закешировать (п.1), то например устанавливать соединение к базе данных нам придется на каждом запуске скрипта.И вот тут на помощь нам может прийти RoadRunner.

    Что то вы немного запутались в понятиях. Асинхронный код это про неблокирующие операции, и RoadRunner никак не связан с асинхронным кодом.
    Вы видимо хотели написать не про асинхронность, а про запуск PHP скрипта в режиме демона, тогда да, можно будет держать постоянное соединение, можно будет кешировать в памяти данные и так далее.


    1. Andreyika
      22.01.2025 14:17

      Просто ребята из альфа-страхования, вероятно, не очень разобрались с понятиями и перенесли асинхронность и многопоточность golang приложения (roadrunner) на то, что это приложение запускает (пыху). А т.к. где там эта асинхронность в пыхе получается - не понятно, то пусть асинхронность у нас будет возможностью подключаться к мемкешу один раз.