Представьте себе ситуацию, большой маркетплейс, 60 тыс. посетителей в сутки (600 тыс. просмотров) и это только веб, а с мобильного приложения, плюс еще 100 тыс. уникальных посетителей. С точки зрения HTTP API запросов к PHP бекенду - это порядка 13 млн. запросов (в пиковых нагрузках ~300-400 RPS). И это всё (PHP only) обрабатывает сервер с 8 vCPU (ядрами) и 32 Gb RAM и самое главное, что сервер практически не напрягается (см. КДПВ).

Как это возможно?

Вступление

Статья написана по мотивам продолжения c моей презентации (видео) на конференции PHP fwdays 21 - но уже с более реальным опытом и цифрами по использования Swoole в продакшене. Если эта статья “зайдет” читателям и нужно будет больше полезной информации, сделаю продолжение с ответами на вопросы.

Все мы помним, что PHP был, есть и будет однопоточным языком. Главное, что Swoole extension добавляет в PHP - это конкурентную (практически нативную) асинхронность. Судите сами: сейчас у нас 16 воркеров (PHP процессов), которые обрабатывают 350 запросов в секунду всего на 8 ядрах. И благодаря корутинам, в одном процессе-воркере, в один момент времени, может находиться в обработке несколько разных запросов и каждый в своей корутине. За счет асинхронного IO, который присутствует в каждом запросе (например, запросы к БД), Swoole умеет переключать контекст исполнения PHP кода в другие корутины с другими запросами. Таким образом, мы получаем механизм более плотного использования CPU и возможности реализовать более отзывчивый код. Но вернемся от теории к практике.

Проект

Наш проект - это монолит велосипед на PHP 8, собранный из множества компонентов Mezzio (как скелет), Laminas, Symfony, Doctrine, и других поменьше. Он работает только как (JSON) API бекенд-сервер для разнообразных клиентов (PWA сайт, админка, моб. приложения, микросервисы и т.д). Ну и основные stateful хранилища Postgresql / Redis / Typesense / RabbitMQ конечно же, присутствуют.

Для осознаний масштаба, размер базы данных postgresql >250Гб (с индексами) - это примерно 25 млн. товарных позиций, и более 40 млн. предложений (прайсов) от продавцов, по продажам: ~3500 заказов в день. По нагрузке на БД - это ~2k TPS:

phploc дает такую статистику по размеру кодовой базы:

  • Lines of Code (LOC):             230 648

  • Logical Lines of Code (LLOC): 37 442

Потребление CPU

До Swoole проект жил на стандартной связке Nginx + FPM (PHP 7.4 и на чуть более старых MVC компонентах Zend/Laminas). Для того, чтобы нормально держать трафик (8 FPM инстансов с pm.max_children = 75), необходимы были сервера, с общим количеством в 48 vCPU! Справедливости ради, - это были дешевые ядра. Если говорить в терминологии амазона - t3 тип инстансов, так как они обходилось нам дешевле, чем с2 инстансы, которые имеют более высокую тактовую частоту ядер. Но тесты с c2 показывали, что нагрузку держали бы всего 24 vCPU. В любом случае - это выходит всё равно в 3 раза больше, чем со Swoole сейчас (тут мы уже используем 8 vCPU именно c2 типа). Это первый и очевидный профит, который мы получили.

Потребление памяти

Скептики сразу же спросят, а что же по потреблению памяти (логично, что с новым подходом накладные расходы должны были вырасти)? К сожалению, у нас не осталось замеров потребления памяти до перехода на Swoole (но за счет большого дублирования fpm подов - оно точно не было маленьким в общей сумме). Но как оказалось со Swoole все вышло достаточно не плохо:

В итоге около ~2Gb. Тут учтено полное потребление 2 подов. То есть в каждом поде, отдельный http server - это мастер, менеджер, воркер процессы (8шт), а также сюда включен in memory shared между воркерами кеш (реализованный через Swoole Table).

Вышло, что каждый воркер процесс, примерно, потребляет 80-120Mb, и 200Mb на все остальное.

Простота инфраструктуры

Всего один докер имидж, в котором просто запускается php CLI команда, где и создается высокопроизводительный Swoole http сервер (entrypoint нужен чтобы при запуске настроить некоторые параметры: включить/отключить некоторые php экстеншены или JIT из env переменных переданных докер имиджу).

Dockerfile
Dockerfile

Кстати, размер этого docker image с учетом всего кода (привет vendor папка) всего 120Mb.

Если его запустить, то внутри контейнера будет всего 11 процессов: 1 tini (supervisor)+entrypoint, 1 master процесс, 1 manager процесс и 8 worker процессов.

processes
processes

Само собой, этот же имидж используется для запуска контейнеров с мессадж воркерами для обработки очереди RabbitMQ (мы пользуемся Symfony Messenger компонентом)

Самое главное - настройки http сервера, как и многое другое, теперь под полным контролем разработчиков:

Например, через сколько запросов надо перезапустить воркер процесс. На текущий момент 30к-40к нам хватает на 1 час - нам это подходит для array / object in memory кеша в самих воркерах, чтобы не городить сложную логику с TTL, то есть по сути - это обычные statefull сервисы, которые хранят состояние между запросами.

Connections

А вот попытки использовать (и выиграть при этом) постоянные соеденения (к БД к редису и т.д), реально принесли нам очень много боли. Стандартные подходы и советы, которые дают в этих ваших интернетах, все они выглядили так, чтобы выделить на 1 запрос каждый раз 1 коннект, в конце запроса закрыть, почистить память (особенно это касается Doctrine EnityManager который полностью statefull). Не то, чтобы эти способы были плохие - но в нашем случае это был не вариант без внешних коннекшен пулов, а при таком трафике слишком много создается коннекшенов и слишком большие на них накладные расходы. При этом добавлять сложностей в инфраструктуру не хотелось. Поэтому пришлось написать довольно много оберток для стандартный вещей (типа доктрины, для кешей, который используют редис и т.д.) для того, чтобы использовать и управлять пулом коннекшенов прямо изнутри. В свою очередь, Swoole предоставляет базовый функционал для создания пуллов и использованию каналов (аналог Chan в Go). Не скажу, что это было легко, но только лишь потому, что концептуально, это совсем другой подход и у нас просто небыло достаточно экспертизы.

В случае с доктриной был еще один не приятный момент из-за того, что PDO pgsql, который есть в PHP, сам Swoole не умеет "хукать", то есть превращать нативно этот клиент в асинхронный (pdo mysql не имеет такой проблемы). Вместо этого, он предоставлят собственную обертку-класс для работы с postgresql асинхронно. Соответсвенно нам пришлось писать свой драйвер и к Doctrine.

Производительность?

Всегда интересный вопрос в данном контексте. И ответ всегда будет зависит от вашего приложения, от оптимизации SQL запросов (и любого другого IO). Но есть очевидные моменты.

Раньше "бутстрап" нашего аппа занимал более 120мс, то есть самый простой запрос, даже 404 или пустой экшен не мог иметь TTFB меньше 120мс. Теперь тот самый "пустой" запрос занимает цикл диспатчинга в аппе всего 4мс. Это сильно снизило average response time (по сути каждый запрос полегчал на 100мс).

Можно ли было такого добиться на roadrunner? Думаю да, но с некоторыми оговорками. Некоторый выгрыш, например, мы получили тем, что убрали хранение кеша для доктрины из редиса внутрь shared in memory хранилице для всего пода - тем самым снизили накладные расходы на сетевую задержку.

Кроме того, в некоторых моментах мы сумели уменьшить response time за счет паралельных асинхронных запросов (там где один ответ не зависит от другого, например запрос на COUNT при пагинации и сам запрос на результат). В целом мониторинг TTFB говорит нам, что половина (из 35 млн) наших апи запросов (50 перцентиль) происходит быстрее 9 мс:

Выводы

Крайне важный момент: без асинхронных IO клиентов корутины НЕ имеют никакого смысла. Так как IO (по умолчанию в PHP) блокирует сам поток исполнения кода и Swoole просто не может переключить контекст исполнения в другую корутину. 

Так что, если вы не планируете использовать асинхронность на проекте (ваш код не готов к stateless, не нужна экономия CPU, не нужно параллелить IO запросы) - то вам хватит и roadrunner - как минимум, вы получите профит в ”бутстрапинге” вашего аппа.

Полная асинхронность (и использование корутин) в проекте - именно ради этого мы боролись с утечками памяти (спасибо, WeakMap), с доктриной, с разнообразными коннекшенами и их повторными использованием. И всё равно, в течении месяца после выливки в прод, мы отлавливали всякие неприятные ситуации и тюнили разнообразные параметры (которые сперва проставлялись наобум). Как пример: кол-во запросов после которых стоит перестать использовать один и тот же коннект к БД. Выяснилось, что postgresql ой как течёт по памяти, если не пересоздавать соединение (для postgresql это отдельный процесс), даже на простых SELECT-ах - 30к запросов и вот ваша БД уже упала в recovery mode от нехватки памяти…

Для себя я осознал одно: PHP стек закапывать рано. И это я имею ввиду не просто web, а именно в контексте сложных энтерпрайз проектов, которые сейчас так стремятся уйти в модные микросервисы и/или Go (с абсолютно такими же корутинами и производительностью веб-сервера). Поверьте - мы пробовали, в проекте у нас существуют несколько Go микросервисов. Главное, что надо помнить - это соблюдать чистоту кода, не говнокодить, использовать паттерны, современные сторонние решения, DDD, KISS, DRY, YAGNI и куча других страшных слов, поверьте - это не пустые слова и мы в процессе переезда на Swoole в этом не раз убеждались.

To be continued?...

P.S. Для интересующих дать ссылку посмотреть: boodmo.com.

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


  1. miksir
    21.01.2022 20:57
    +8

    По большому счету прирост вы получили не из-за асинхронности и swoole, а из-за выбрасывания тяжелого бутстрапа. Заплатив за это ровно ту стоимость, скидку на которую дает "умирающий" пхп.

    Ну и совсем немного экономии на переключении контекста процессора, если бы у вас было бы 70 однопоточных воркеров (например, с тем же роадранером, или какой-то свой менеджер соединений).

    Вот, кстати, сравнение сравнимого - многозадачности на базе мультиплексирования vs процессы, было бы интересно.

    Но при этом не раскрыта полостью тема сокращения времени бутстрапа более крассическими методами - кеш, прелоадинг, рекомендации Symfony. 120 ms намекает, что там еще большой простор.


    1. Nnnnoooo
      22.01.2022 07:52
      +4

      Могу пованговать, но скорее всего от роадраннера они получили бы такой же профит, но при значительно меньших трудозатратах.


    1. shandy Автор
      22.01.2022 15:17
      +2

      После того как все устаканилось, есть планы протестировать и буст от JIT, и вопрос мультиплексирования vs многопоточности. Благо это не сложно - отключить корутины и поднять очень много воркеров, это 2 параметра по сути.

      Ещё скоро перейдем на 8.1, тоже потестим.

      Насчёт роадранера или оптимизаций для fpm я поясню, решение про переход на swoole принималось в дополнение к другим факторам: на тот момент проекту исполнилось 5 лет (и я напомню что это самопис): за это время там накопилось очень много легаси (причем речь не про бизнес код, а про сторонние пакеты), все это мешало апгрейдам (на тот же 8.0), мешало применять бест практис.

      То есть кроме Swoole, мы перешли на 8.0, убрали все легаси пакеты (доктрины, симфони, убрали старые zend framework пакеты), переделали структуру проекта по DDD канонам и перешли на мидлвари и хттп хендлеры (PSR стандарты).

      Поэтому решение использовать Swoole вместо Roadrunner (на тот момент эта была ещё первая версия) была принята осознано для того чтобы из коробки получить асинхронное IO.

      В планах в дополнение к JSON API попробовать gRPC и общение клиентов по Websocket'ам. И текущая архитектура к этому уже готова (то есть Swoole это даст из коробки).


  1. NiceDay
    21.01.2022 21:53
    +7

    Для осознаний масштаба, размер базы данных postgresql >250Гб (с индексами)

    ну 250гб это не много, у нас в марии 8тб лежат, есть таблица весом 45гб с индексом на неё 50гб. и даже это как-то язык не поворачивается назвать масштабом.

    Раньше «бутстрап» нашего аппа занимал более 120мс

    это очень странные показатели. такое ощущение что это без опкеша вообще.

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

    ну это можно было и в php-fpm сделать, через тот же shmop. может у вас там редис был в другом полушарии с каналом 1мбит и весь выигрыш случился из-за этого.

    в общем да, сложно о чем-то судить. такое ощущение что оптимизацией не занимались вообще, будто крутился php-fpm, где в дефолтных конфигах поменяли только количество воркеров.
    описание утилизации ресурсов до переезда на swoole выглядит так, будто вы там Множество Мандельброта считали на каждый запрос.


    1. shandy Автор
      22.01.2022 15:02
      +1

      Newrelic показывал что процессорное время тратилось на такие банальные вещи как композеровский автолоадинг (было около 400 пакетов), на доктриновскую гидрацию (из всего времени php эти 2 вещи 60% времени занимали) и только потом вся бизнес логика и сверху ещё IO (который при бустрапинге тоже был - например чтение файлов кеша с диска).

      Но соглашусь, кроме самого переезда код ещё серьезно рефакторился в процессе (при той же функциональности). Опять же замена 7.4 на 8.0 тоже дал свой буст. Переезд длился целый год, за этот момент многое успело устареть в старом монолите.


  1. Roquie
    22.01.2022 00:59
    +3

    Есть возможность выложить драйвер для доктрины в опенсорс?


    1. shandy Автор
      22.01.2022 14:54
      +4

      Да, это в планах. В течении 1-2 месяца оформим код в композер пакет.