Привет, PHP-шники!

Вам знакомо то чувство, когда ваш PHP-код отрабатывает за дофига миллисекунд, а иногда и секунд, и вы не можете понять, в чём дело и где тот говнокод, который я (или не я, а мой коллега ?‍?) когда-то написал? В этой статье хочу немного рассказать на примере профайлинга PHP-кода, как перестать гадать и начать выявлять боттлнеки, которые всё тормозят. Короче, помочь поискать говнокод. После этого у многих из вас появится понимание, как дать своему приложению прирост производительности с помощью крутых инструментов профайлинга, таких как xhprof и Buggregator.

?Для начала давайте узнаем, что такое "боттлнек" (bottleneck), или, иными словами, бутылочное горлышко или узкое место. Это место в вашем PHP-коде, где всё замедляется по разным причинам: из-за плохо написанного кода, который потребляет много ресурсов, или других внешних проблем, например, задержек в сети. Эти боттлнеки могут серьёзно снижать производительность и время отклика вашего приложения.

Давайте посмотрим на некоторые типичные ботлнеки:

  • Запросы к базе данных — если ваши запросы медленные или слишком сложные, они могут серьёзно аффектить производительность.

  • Неэффективный код — плохо написанный код (говнокод), который использует слишком много ресурсов.

  • Внешние сервисы — если ваше приложение зависит от медленных внешних сервисов или API, это также может стать большой проблемой для скорости вашего приложения.

Все это желательно найти перед запуском в прод и пофиксить, ну или хотя бы понимать, что именно необходимо пересмотреть в будущем.


Что такое профилировщик (профайлер)?

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

В этой статье мы будем использовать xhprof. Это известный профайлер, который помогает анализировать использование памяти, нагрузку на CPU, количество вызовов и строит трейс вызова всех функций.

PHP имеет несколько инструментов для профилирования, таких как xhprof gui, Blackfire и Buggregator.

В момент написания статьи я еще нашел также профилировщик у Sentry, но уже не стал еще включать в список

Xhprof gui

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

Blackfire

Blackfire выглядит классно, но 30 баксов в месяц, чтобы пару раз проанализировать свой код и понять, где наговнокодил — дохрена кмк. Обычно профилирование — это разовая задача: запустил, проанализировал трейс, оптимизнул код и всё, забыл на пару лет. Платить столько за редкое использование? Blackfire — отличное решение, если необходимо мониторить приложение в продакшене, но и за это придется заплатить 150 баксов. Вряд ли это подходит нам, простым PHP-шникам, которые пилят блоги.

Buggregator

Теперь переключимся на Buggregator. Для меня он как швейцарский нож для PHP-шников — он предлагает все необходимое для дебага PHP-приложений. Его главное преимущество — это то, что он является open-source и соответсвенно полностью бесплатен. Никаких сборов, никаких дополнительных расширений — просто запустили и как погнали дебажить ваш код.

Плюс open-source еще в том, что если нашли баг или не хватает фичи, то заходите в репозиторий сервера, оставляйте багрепорт или отправляйте pull request с новой фичей, делаем ревью, мерджм, новый релиз и инструмент еще круче.

Кстати, сам сервер написан на PHP и использует RoadRunner под капотом.

Давайте начнём с вводного видео, чтобы посмотреть как выглядит профайлер в Buggregator.

Hidden text

Это видео я делал сам, так что не осуждайте. И да, я создатель Buggregator.

В этой статье мы установим расширение для PHP - xhprof и будем использовать этот модуль (profiler) для профилирования Laravel приложения.

Поехали!


Настройка PHP приложения.

Давайте быстренько установим все необходимое для профилирования на примере Laravel приложения.

Для начала давайте установим Laravel

composer create-project laravel/laravel my-app

Теперь давайте подрубим композер-пакет, чтобы отправлять трейсы xhprof в Buggregator:

composer require --dev maantje/xhprof-buggregator-laravel

Давайте настроим env переменные для отправки трейсов в Buggregator:

PROFILER_ENDPOINT=http://buggregator:8000/api/profiler/store
XHPROF_ENABLED=true

Здесь buggregator:8000 — это адрес контейнера Buggregator в Docker.

Типа настроили!

У Buggregator, кстати, есть документация, пишу сам и сообщество помогает. Вы тоже можете помочь в написании доки. Репозиторий доки здесь.

Настройка Docker окружения

Теперь давайте докеризируем все для удобства. Вот как выглядит мой .docker/Dockerfile:

FROM php:8.3-alpine  
  
RUN apk add --no-cache \  
    curl \  
    curl-dev \  
    libzip-dev \  
    linux-headers \  
    pcre-dev \  
    ${PHPIZE_DEPS}  
  
RUN pecl install xhprof  
  
RUN docker-php-ext-configure curl  
RUN docker-php-ext-configure zip  
  
RUN docker-php-ext-install \  
    opcache \  
    curl \  
    pcntl \  
    sockets \  
    zip  
  
RUN docker-php-ext-enable \  
    xhprof \  
    opcache  
  
WORKDIR /app  
  
EXPOSE 8000  
  
CMD php artisan serve

Теперь давайте опишем все необходимые сервисы в docker-compose.yaml, который я положил в корень нашего проекта:

version: '3.1'  
  
services:  
  my-app:  
    build: "./.docker"  
    labels:  
      - "traefik.enable=true"  
      - "traefik.http.routers.my-app-http.entrypoints=web"  
      - "traefik.http.routers.my-app-http.rule=Host(`my-app.localhost`)"  
      - "traefik.http.services.my-app-http.loadbalancer.server.port=8000"  
    volumes:  
      - ./:/app  
    networks:  
      - my-network  
  
  buggregator:  
    container_name: my-buggregator  
    image: ghcr.io/buggregator/server:latest  
    labels:  
      - "traefik.enable=true"  
      - "traefik.http.routers.buggregator-http.entrypoints=web"  
      - "traefik.http.routers.buggregator-http.rule=Host(`buggregator.localhost`)"  
      - "traefik.http.services.buggregator-http.loadbalancer.server.port=8000"  
    logging:  
      driver: none  
    networks:  
      - my-network  
  
  reverse-proxy:  
    container_name: my-reverse-proxy  
    image: traefik:v2.9  
    command:  
      - "--providers.docker=true"  
      - "--providers.docker.exposedbydefault=false"  
      - "--entrypoints.web.address=:80"  
      - "--providers.file.watch=true"  
    ports:  
      - ${TRAEFIC_PORT_HTTP:-80}:80  
    volumes:  
      - /var/run/docker.sock:/var/run/docker.sock  
    logging:  
      driver: none  
    networks:  
      - my-network  
  
networks:  
  my-network:  
    ipam:  
      driver: default  
      config:  
        - subnet: 172.0.70.0/24

В этом конфиге я использую Traefik как реверс-прокси, чтобы привязать наши сервисы к DNS-адресам my-app.localhost и buggregator.localhost для удобного доступа.

На этом все. Давайте запускать!

Запускаем сервисы

docker compose up

Как только всё запустится, можно проверить, что наши сервисы работают:

  • http://my-app.localhost

  • http://buggregator.localhost

Каждый раз, когда мы обращаемся к нашему приложению по адресу http://my-app.localhost, xhprof создаёт трейс HTTP-запроса. Этот трейс будет автоматически отправлен в Buggregator, где он будет анализироваться и сохраняться.

Что умеет Buggregator

Buggregator предлгает несколько инструментов для анализа трейсов. Давайте быстренько посмотрим на них.

Call Graph

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

К примеру, Call Graph дает понять, как всё дерево вызовов устроено, кто кого вызывает, как методы взаимодействуют, сколько вызовов происходит и так далее.

Вот так это выглядит на деле. Я обновил страницу сайта несколько раз.

Как видите из скриншота выше, первая загрузка заняла ~57 мс, потому что кэш приложения еще не был прогрет, но последующие вызовы уложились в ~15 мс. Неплохо, правда?

Теперь давайте посмотрим на call graph. Включим график Wall time в переключателе метрик слева. У нас есть четыре типа метрик:

  • CPU — время, потраченное на обработку запроса процессором.

  • Wall Time (wt) — время CPU плюс время, проведенное в ожидании (сеть, диск и т.д.), включая функции вроде \sleep.

  • Memory (mu) — размер памяти, потраченный на обработку запроса.

  • Peak Memory Usage (pmu) — максимальный размер использованной памяти.

Как видите, происходит много всего, но могло бы быть и больше, если бы мы установили threshold в 0.

Оказывается, наше приложение сделало кучу вызовов, но нас интересуют не все, потому что многие из них используют совсем немного ресурсов по сравнению с теми, кто жрет больше всего. Изменяя threshold, мы можем отфильтровать неинтересные вызовы. Изменение percent  включает или выключает подсветку блоков, которые используют больше ресурсов, чем указано.

Теперь перейдем к FlameGraph.

FlameGraph

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

Здесь мы видим вызовы функций в виде колбасок. Чем больше и краснее колбаска, тем больше времени занимает её выполнение. Обычно в этом графе смотрят сверху вниз. Из графа ясно, что вызов Illuminate\Database\Connection::Illuminate\Database{closure} немного в стороне, но он всё равно самый нижний и занимает достаточно много времени относительно остальных нижних элементов.

Теперь давайте посмотрим на еще один инструмент — Топ 100 функций.

Топ 100 функций

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

Кликая на названия колонок, можно сортировать список по выбранной метрике. Список отображает общее количество ресурсов, потреблённых каждой перечисленной функцией. Постфикс excl. исключает из метрики влияние других функций.

Использование памяти и время CPU делятся на включительно и исключая.

  • Включительно — время включает продолжительность выполнения текущей функции включая вызовы всех её дочерних функций (То, что вызывалось внутри данной функции).

  • Исключая — время измеряет только продолжительность выполнения текущей функции, не включая время вызова дочерних функций.

В нашем случае, эта таблица также показывае, что Illuminate\Database\Connection::Illuminate\Database{closure} оказывает наибольшее влияние на время выполнения запроса.

Процесс профилирования

Давайте отключим этот вызов функции.

Как видно из приведенной ниже диаграммы FlameGraph, что в БД обращаются сессии при старте. А как мы знаем, по умолчанию Laravel используют для сессии БД. В этом можно убедиться, просто проверив `.env` файлик.

SESSION_DRIVER=database

Давайте переключимся на SESSION_DRIVER=file и посмотрим, что произойдет. Обновляем страницу в бразуере и смотрим на новый профиль.

Как видно, запрос к базе данных не выполнился, и время выполнения (Wall Time) упало до ~4мс. Поздравляю, мы нашли наш первый "боттлнек"! (конечно же нет, мы просто нашли место для оптимизаций, в этом и суть профилирования)

CPU перестал использовать ресурсы для обработки HTTP-запроса вообще! Это круто.

Давайте снова посмотрим на FlameGraph

Мы видим, что сессии и куки все еще потребляют ресурсы. Давайте попробуем удалить Middleware, которые отвечают за сесиии и куки вообще.

Вот что показал профайлер.

Как видно, мы сократили время до ~2мс на запрос.

?Как думаете, стоит ли использовать сесиии для хранения ошибок валидации, сообщений и т.д.?

Утечки памяти

Может мы еще и утечки памяти можем отловить? Для long running это акутально, но с нюансами!

Кстати Валентин Удальцов недавно про них говорил в своем докладе на BeerPHP https://www.youtube.com/watch?v=56I5C0NYjv8

P.S. отличный доклад, посмотрел с удовольствием.

Для начала давайте установим Laravel Octane.

composer require laravel/octane
php artisan octane:install

Всегда выбираю сервер RoadRunner, потому что это отличный Application server с кучей плагинов аля HTTP, Jobs, Temporal, gRPC и т.д. Короче швейцарский нож в мире Application серверов.

Теперь немного поправим Dockerfile.

...

CMD php artisan octane:start --host=0.0.0.0 --workers=1

И пересобирём наш образ.

docker compose build
docker compose up

Запускаем и смотрим наш профиль. После запуска видим, что потребление ресурсов увеличилось:

Как видите, добавилось несколько операций.

Работу с RoadRunner я выделил красным. Скорость можно частично оптимизировать, установив расширение protobuf для PHP.

Работу с компонентом View выделил зелёным, потому что она, кажется, неэффективна в Long running.

Теперь давайте эмулировать утечки памяти.

Начнем с контроллера.

<?php

final class HomeController extends Controller  
{  
    private array $data = [];  
  
    public function __invoke()  
    {  
        foreach (range(1, 10_000) as $i) {  
            $this->data[] = [  
                'id' => $i,  
                'name' => 'Name ' . $i,  
                'email' => 'email' . $i . '@gmail.com',  
            ];  
        }  
  
        return view('welcome');  
    }  
}

Я добавил в него код для того чтобы увеличить потребление памяти и посмотреть как это показывает профилировщик.

Как видим, первый запрос (самый нижний) потратил ~6mb (пиковое использование памяти), т.е., записал 10,000 элементов в память, и больше ничего не записывается. Получается, у нас хранится ~6mb в памяти, и больше не добавляется.

Это потому что HomeController не синглтон и не сохраняет состояние между запросами.

Теперь добавим 10,000 элементов в статическую переменную.

<?php

final class HomeController extends Controller  
{  
    private static $data = [];  
  
    public function __invoke()  
    {  
        foreach (range(1, 10_000) as $i) {  
            self::$data[] = [  
                'id' => $i,  
                'name' => 'Name ' . $i,  
                'email' => 'email' . $i . '@gmail.com',  
            ];  
        }  
  
        return view('welcome');  
    }  
}

Теперь при каждом новом запросе видим в Peak Memory Usage не 0, а ~4mb, что указывает на изменение в памяти, т.е., пиковое потребление памяти непрерывно растёт на указанное количество.

Давайте теперь посмотрим на CallGraph, метрику Memory Peak.

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

Давайте теперь посмотрим на топ функций.

Тут можно ещё отсортировать по PMU Excl., чтобы видеть, где происходят изменения.

Вот и всё, надеюсь, эта информация вам помогла. Спасибо, что прочитали!


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

Если Buggregator понравится, лайк на GitHub крайне приветствуется. Ваша поддержка имеет значение и помогает большему количеству разработчиков узнать и воспользоваться этим инструментом.

P.S. Для тек кому лень было читать и он промотал в самый низ, вот видео версия статьи:

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


  1. Cutcode
    20.06.2024 09:20
    +3

    Отличная статья и стрим посмотреть также все рекомендую


    1. mobilz
      20.06.2024 09:20

      одобряю, но пахнет кумовством )


      1. butschster Автор
        20.06.2024 09:20

        проплаченная реклама детектед(


  1. mobilz
    20.06.2024 09:20
    +2

    @butschster мужик! фарт-тайм -- мужики. rr -- мужики! темпурал вообще топ. с огромным удовольствием читаю ваши подборки php новостей в вашем чате и на телеграфе, увы, пхпшторм дневник ушёл куда-то в другую сторону


  1. CodeMaster125
    20.06.2024 09:20
    +2

    Как вашим решением можно отдебажить продакшен? Ведь часто есть проблемы именно на конкретном сервере


    1. butschster Автор
      20.06.2024 09:20

      Blackfire) Кстати есть в планах их коллектор примерить к Buggregator, если подойдет, то можно будет слать трейсы прям в багри с прода. А так если проблема на конкретном серваке, то клонируй сервак и на нем уже профилируй, если проблема именно с конкретным серверов. Но как по мне, так попахивает дебаг сервера, а не приложения, раз все работают норм, а один сервер не норм.


      1. roxblnfk
        20.06.2024 09:20

        @butschster Использование любого профилировщика на проде будет не бесплатно, в т.ч. Blackfire
        Первая ссылка в поисковике: https://github.com/ahocquard/blackfire-overhead-costs

        @CodeMaster125 может у вас какой-то более конкретный вопрос по дебажинью продакшена? Если проблема на конкретном сервере, то, наверное, дело уже не в коде и профайлер тут не поможет?


        1. CodeMaster125
          20.06.2024 09:20

          В моём кейсе - очень долгий запрос в базу, даже при простейшем Select на конкретном сервере. Локально это не отслеживается, на чистом приложении доступ к базе до 1 сек. Делаю вывод что проблема не в сети, а в приложении и нужен какой-то инструмент, чтобы отсмотреть происходит задержка


  1. yares-t
    20.06.2024 09:20

    @butschster рекомендую изучить excimer в качестве профилировщика. Excimer умеет настраивать периодичность профилировки и с помощью этого можно контролировать дополнительную нагрузку.
    Мы внедрили в skyeng excimer и это позволило профилировать прод практически бесплатно, но с низкой точностью, а на тесте можно точность и увеличить.
    Профилировка в excimer запускается внутри php кода, и её можно запускать при соблюдении каких-то условий, например, при получении особенной куки.

    P.S. Sentry бандл под капотом тоже excimer использует


    1. butschster Автор
      20.06.2024 09:20
      +1

      Да да да, уже изучали этот профайлер. https://github.com/wikimedia/mediawiki-php-excimer В планах сделать интеграцию с ним. Спасибо!