Xdebug и профилировщик: как я нашёл узкие места в своих PHP-скриптах
Сайт тормозит, а почему — непонятно. Знакомо?
У меня был скрипт импорта товаров, который работал четыре минуты. Четыре! Клиент ждал, я смотрел в потолок, и оба злились. Я уже гуглил «как ускорить PHP на всём сразу»: менял версии, крутил кеши, молился. А потом я открыл профилировщик и за десять минут нашёл строчку, которая съедала девяносто процентов времени. Скрипт стал работать одиннадцать секунд. Вот про этот путь — от «кажется, тормозит» до «вот строка-виновник» — и поговорим.
Сразу оговорюсь: я не гуру профилирования, я обычный разработчик, который устал гадать. Инструменты, о которых расскажу, — Xdebug и его профилировщик, плюс пара способов побыстрее, когда Xdebug ставить некуда. Всё проверено на своих задачах, с цифрами и шишками.
Что такое Xdebug и почему он не только про отладку
Про Xdebug все знают как про отладчик: поставил точку останова, смотришь переменные. Я сам годами использовал только эту часть. А в Xdebug спрятан профилировщик — режим, в котором расширение записывает, сколько времени заняла каждая функция вашего скрипта. Не «примерно», а точно, с количеством вызовов и временем в каждом.
Результат пишется в файл формата cachegrind, который открывается визуализаторами: KCacheGrind в Linux, QCacheGrind на других системах, или web-морда Webgrind, если лень ставить софт. Открываешь файл — и видишь свой скрипт как на рентгене: какие функции съели время, кто их звал, сколько раз.
Первый раз, когда я открыл такой файл, я почувствовал себя детективом. Вот моя функция, вот внутри неё вызов, который я считал мелочью, а он — 60% времени. Всё остальное было гаданием, а это — факт.
Установка: пять минут и один нюанс
Ставится Xdebug из стандартных пакетов. На моём Debian с PHP 8.3 это выглядело так:
sudo apt install php8.3-xdebug
Дальше — конфиг. Мой рабочий минимум в /etc/php/8.3/mods-available/xdebug.ini:
zend_extension=xdebug xdebug.mode=profile xdebug.output_dir=/tmp/xdebug xdebug.trigger_value=PROFILE
Нюанс, на котором я обжигался дважды: режим. Если включить xdebug.mode=debug и оставить его на боевом сервере, каждый запрос будет ждать подключения IDE и висеть. Я однажды так «уложил» тестовый стенд для всей команды. С тех пор правило: профилирование — отдельный режим, включается по триггеру, на постоянку не живёт.
С триггером профилируется не всё подряд, а только запросы с нужным значением. Для веба — cookie или заголовок XDEBUG_TRIGGER: PROFILE. Для CLI — переменная окружения. Так можно профилировать конкретную страницу, не замедляя весь сайт.
Первый профиль: что я увидел в цифрах
Запускаю мой мучительный импорт с триггером, открываю файл в QCacheGrind. Картинка такая: общее время 240 секунд. Из них 210 — функция, которую я писал за вечер и считал безобидной. Внутри неё — запрос к базе в цикле. Один товар — один запрос. Пятнадцать тысяч товаров — пятнадцать тысяч запросов.
Я переписал этот кусок на пакетную вставку — и время упало до 40 секунд. Дальше профилировщик показал уже другую картину: теперь главными стали парсинг файлов и запись логов. Поправил и их. Итог — 11 секунд вместо 240. Я сидел и улыбался монитору, как дурак.
Самое ценное в этом опыте даже не ускорение, а смена подхода. Раньше я оптимизировал то, что казалось медленным. Теперь я сначала измеряю. Оказалось, моя интуиция в оптимизации ошибается в девяти случаях из десяти. Тормозит почти никогда не то, что кажется.
Момент Икс: профилировщик на проде — как не уронить сайт
Однажды меня попросили найти тормоза на боевом магазине. Я поставил Xdebug, включил профилирование на всю витрину — и сайт лёг под наплывом. Профилировщик замедляет каждый вызов функции, а на проде вызовов миллионы. К вечеру меня вызвали на разговор, и он был неприятным.
Правильный способ, к которому я пришёл: на проде профилируем только конкретный запрос по триггеру, в тихий час, и снимаем режим сразу после снятия профиля. Ещё лучше — снять копию базы и профилировать на стенде. С тех пор у меня железное правило: на проде Xdebug живёт минуты, не часы. А лучше — не живёт вообще, всё тестируем на копии.
Для продакшена есть более щадящие пути. Первый — встроенный в PHP OPcache с его статистикой: видно, какие скрипты тяжёлые, без замедления. Второй — лог медленных запросов MySQL: если тормозит база, он покажет запросы без всякого Xdebug. Третий — APM-системы вроде New Relic, но это уже деньги, и для моих задач хватало первых двух.
Как читать профиль: мои три приёма
Файл cachegrind пугает объёмом, но читать его просто, если знать три приёма. Приём первый: смотрите не на общее время функции, а на «само время» (self). Функция может числиться наверху только потому, что вызывает тяжёлых детей. Виновник — тот, у кого большое self-время.
Приём второй: сортируйте по количеству вызовов. Функция, которую позвали сто тысяч раз, — почти всегда кандидат на оптимизацию, даже если каждый вызов быстрый. Мой классический грех — вызов функции конфига внутри цикла. Вынес — стало легче.
Приём третий: сравнивайте профили до и после. Сделал изменение — снял профиль ещё раз, открыл оба файла. Цифры не дадут себе наврать. Так я научился отличать «кажется, стало быстрее» от «стало быстрее в 3,2 раза». Второе, заметьте, звучит гораздо убедительнее в отчёте для заказчика.
Xdebug и WordPress: мой домашний рецепт
С WordPress профилирование работает так же, но есть нюанс с плагинами. Тормозить может не ядро, а какой-нибудь виджет из плагина. Профилировщик это показывает честно: я однажды нашёл плагин, который на каждой странице делал запрос к своему серверу обновлений. Отключил его вызов — витрина стала открываться вдвое быстрее, и я даже обновляться не торопился.
Мой рецепт для WordPress-сайтов такой: копия сайта на стенде, Xdebug в режиме профиля, триггер на нужную страницу. Смотрю топ по self-времени, ищу плагин-виновник, иду в его код или в настройки. Про ускорение самой админки я отдельно писал в статье про Redis object cache для WordPress — там другая история, про кеш объектов.
А если совсем лень ставить расширение, есть простой самодельный способ: обернуть подозрительный участок в microtime(true) до и после, посчитать разницу. Грубо, зато работает везде, где PHP. Я такие замеры до сих пор втыкаю в cron-скрипты — там это быстрее, чем городить профиль.
Что я вынес из месяца с профилировщиком
Месяц осознанного профилирования изменил мои привычки. Первое: я больше не оптимизирую наугад. Сначала измерение, потом правка, потом снова измерение. Второе: я перестал бояться «тяжёлых» фреймворков и библиотек — в профиле почти всегда виноват мой код, а не инструменты. Третье: узкие места живут не там, где страшно, а там, где скучно. Циклы, запросы в цикле, сериализация, логи.
И последнее, самое приятное: профилирование — это честно. Заказчику я теперь показываю не «мне кажется, стало лучше», а два файла профиля с цифрами. Доверие к таким отчётам совсем другое. Попробуйте на своём тормозящем скрипте — уверен, найдёте свой «импорт товаров».
CLI-скрипты и cron: профилируем без браузера
Отдельная песня — консольные скрипты. Мой мучимый импорт товаров работал из cron, и браузерных триггеров ему не повесишь. Тут всё проще: переменная окружения. Запуск выглядит так:
XDEBUG_TRIGGER=PROFILE php import.php
Файл профиля падает в ту же папку, и дальше всё как с вебом. Так я профилирую все свои cron-задачи, которые живут дольше минуты. Бэкап, кстати, я тоже гонял через профилировщик — и обнаружил, что треть времени уходит на подсчёт контрольных сумм с настройками по умолчанию. Сменил алгоритм на более быстрый — бэкап закончился раньше, чем я успел налить чай.
Для совсем длинных задач есть тонкость: профиль за час работы весит гигабайты и открывается мучительно. Мой обходной путь — профилировать не весь запуск, а типовой кусок: пять минут работы скрипта скажут о его характере всё. Долгие хвосты обычно повторяют начало по структуре.
Отладчик заодно: почему я оставил режим debug в IDE
Пока настраивал профилирование, я вернул себе и отладчик. Схема у меня такая: локально в IDE включён режим debug с триггером, профилирование — отдельным режимом по необходимости. Переключение — одна строчка в конфиге, и путаницы нет. В IDE ставлю точку останова на подозрительном месте, смотрю, что реально приходит в функцию. Половина «тормозов» после такого взгляда оказывается не тормозами, а неожиданными данными: пустой массив, который обрабатывается десятью тысячами итераций, — это я про реальный случай из своей практики.
Для браузера удобно поставить расширение Xdebug helper: щелчок по иконке включает триггер, и не надо возиться с куками руками. Я поначалу ставил куки через консоль разработчика, забывая про домены поддоменов, а с расширением вопрос закрылся навсегда.
И про версии пару слов. Xdebug дружит не со всеми сборками PHP сразу: после обновления PHP проверяйте, что расширение пересобралось под новую версию, иначе режимы молча отключатся. У меня однажды после обновления пропал именно профилировщик, а отладка работала, и я долго не мог понять, в чём фокус. Оказалось, пакет для старой версии PHP лежал рядом, а новый я просто не доустановил. Проверяйте php -m после каждого обновления интерпретатора.
Если вкратце
- Xdebug в режиме profile записывает время каждой функции — ставится одним пакетом.
- Профилируйте по триггеру, а не всё подряд: иначе замедлится весь сайт.
- На проде — только отдельный запрос в тихий час; лучше вообще на копии базы.
- Смотрите на self-время и количество вызовов, а не на общее время функции.
- Сравнивайте профили до/после — цифры вместо ощущений.
- Для продакшена без Xdebug: лог медленных запросов MySQL и статистика OPcache.
- Грубый замер microtime(true) до/после — рабочий способ для cron-скриптов.
Мой импорт с 240 секунд стал 11. Сколько сэкономит вам один вечер с профилировщиком — проверьте сами.
И да, начните с самого неприятного скрипта. Того, на который вы уже смотрели с ненавистью. Именно на нём эффект будет самым наглядным, а метод — самым быстрым для освоения. Удачи в охоте на узкие места, и пусть ваш чай остывает по уважительной причине.