Представьте себе ситуацию, большой маркетплейс, 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 переменных переданных докер имиджу).
Кстати, размер этого docker image с учетом всего кода (привет vendor папка) всего 120Mb.
Если его запустить, то внутри контейнера будет всего 11 процессов: 1 tini (supervisor)+entrypoint, 1 master процесс, 1 manager процесс и 8 worker процессов.
Само собой, этот же имидж используется для запуска контейнеров с мессадж воркерами для обработки очереди 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.