Профилирование WordPress без плагинов
Профилирование WordPress без плагинов: этапы загрузки и поиск виновника
Сайт тормозит. Первая мысль — «поставить плагин кэширования». Но кэш лечит симптом: чтобы понять, что именно тормозит, нужно profiling — разложить загрузку страницы на этапы и посмотреть, где теряется время. Query Monitor для этого отличный, но требует работы в админке и держать его на проде постоянно не хочется.
Есть способ проще: временный mu-plugin на несколько десятков строк, который на каждом реальном запросе отмечает время ключевых этапов загрузки WordPress и пишет в лог. Ставится за минуту, снимается так же, ничего не ломает.
Как это работает
WordPress загружается по цепочке хуков. Если повесить на каждый ключевой хук отметку времени, разница между соседними отметками покажет, какой этап съедает время:
muplugins_loaded→ начало инициализации плагиновplugins_loaded→ плагины загрузилисьafter_setup_theme→ тема инициализироваласьinit(ранняя отметка) → начало обработки хука initinit(поздняя отметка, приоритет 999999) → конец обработки init — сюда попадает вся инициализация, зарегистрированная плагинами и темойwp_loaded→ WordPress полностью загруженtemplate_redirect→ выбран шаблон, начинается рендерwp_footer→ страница отрендерена
Дельта между ранним и поздним init — самое информативное место: туда попадает вся «тяжёлая» инициализация плагинов (подключения к API, проверки, сканирования). В моём случае это была фаза, съедавшая полторы секунды на каждый просмотр.
Код профилировщика
<?php
/**
* Временный профилировщик этапов загрузки WordPress.
* Ставится в wp-content/mu-plugins/, после замеров — удалить.
* Лог: /tmp/profile-<домен>.log
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
$GLOBALS['prof_t0'] = $_SERVER['REQUEST_TIME_FLOAT'];
$GLOBALS['prof_prev'] = $GLOBALS['prof_t0'];
$GLOBALS['prof_rows'] = array();
function prof_mark( $label ) {
$now = microtime( true );
$GLOBALS['prof_rows'][] = sprintf(
'%s: +%.0fms (Δ%.0fms)',
$label,
( $now - $GLOBALS['prof_t0'] ) * 1000,
( $now - $GLOBALS['prof_prev'] ) * 1000
);
$GLOBALS['prof_prev'] = $now;
}
foreach ( array(
'muplugins_loaded' => -999999,
'plugins_loaded' => -999999,
'after_setup_theme' => -999999,
'wp_loaded' => 999999,
'template_redirect' => -999999,
'wp_footer' => -999999,
) as $hook => $priority ) {
add_action( $hook, function () use ( $hook ) { prof_mark( $hook ); }, $priority );
}
// init отмечаем дважды: «init_late» — это метка внутри колбэка,
// а не хук: add_action( 'init_late', ... ) WordPress не вызовет никогда.
add_action( 'init', function () { prof_mark( 'init_early' ); }, -999999 );
add_action( 'init', function () { prof_mark( 'init_late' ); }, 999999 );
add_action( 'shutdown', function () {
$host = $_SERVER['HTTP_HOST'] ?? 'cli';
$uri = substr( $_SERVER['REQUEST_URI'] ?? '/', 0, 80 );
$line = sprintf(
"[%s] %s%s | TOTAL %.0fms | peak %.0fMB | %s\n",
date( 'H:i:s' ), $host, $uri,
( microtime( true ) - $GLOBALS['prof_t0'] ) * 1000,
memory_get_peak_usage( true ) / 1048576,
implode( ' | ', $GLOBALS['prof_rows'] )
);
file_put_contents(
'/tmp/profile-' . preg_replace( '/[^a-z0-9.-]/i', '', $host ) . '.log',
$line, FILE_APPEND
);
} );
Установка и запуск
- Сохранить код как
prof-tmp.php. - Положить в
wp-content/mu-plugins/(создать каталог, если нет). mu-plugins запускаются автоматически, включать ничего не надо. - Сделать несколько запросов к проблемным страницам: главная, тяжёлая категория, запись. Кэш страниц перед замером отключить или очистить — кэшированный ответ отдаётся до загрузки WordPress, и в логе он просто не появится.
- Читать лог:
/tmp/profile-<домен>.log. Каждая строка — один запрос с разбивкой по этапам. - После замеров удалить
prof-tmp.phpиз mu-plugins: лог пишется на каждый запрос без исключения и на живом сайте растёт быстро.
На что смотреть
Разбор реального случая: четыре сайта на одном VPS (WordPress, ~13–17 плагинов, nginx, PHP 8.5).
| Сайт | Полный рендер | Дельта init (рано → поздно) |
|---|---|---|
| Сайт №1 — блог, 2 300+ записей | 0,7–1,8 с | 0,4–1,5 с ← виновник |
| Сайт №2 — 9 000+ записей | 0,8–0,9 с | 0,5–0,7 с |
| Сайт №3 — небольшой журнал | 0,4 с | 0,07 с |
| Сайт №4 — каталог отзывов | 0,2 с | 0,07 с |
Видно сразу: у сайтов №1 и №2 раздута фаза init, у №3 и №4 она копеечная. Дальше — найти, что именно происходит в этой фазе, проще всего бисекцией: временно деактивировать подозреваемого, перемерить, включить обратно.
На сайте №1 подозреваемым был Broken Link Checker — плагин проверки битых ссылок. Контрольный замер одной и той же страницы с ним и без:
| Страница категории | TOTAL | Дельта init |
|---|---|---|
| С BLC | 1 815 мс | 1 501 мс |
| Без BLC | 666–686 мс | 401–420 мс |
Один плагин съедал ~1,1 секунды на каждом просмотре любой страницы — даже когда ничего не проверял: при инициализации он поднимает свои таблицы ссылок (десятки мегабайт накопленных данных). После отключения проблема ушла.
Дополнительный уровень: SQL-запросы
Если после этапов виновник всё ещё неясен, добавляется временная константа в wp-config.php:
define( 'SAVEQUERIES', true );
После неё $wpdb->queries накапливает каждый SQL-запрос с временем и бэктрейсом. В профилировщике на shutdown можно добавить выгрузку топ-медленных запросов с файлом-инициатором — файл вызова почти всегда указывает на плагин-виновник. После диагностики константу обязательно удалить: SAVEQUERIES удваивает память и тормозит каждый запрос.
Чем закончилось: два виновника, −1,1 секунды
Бисекция нашла виновника, а колбэк-профилировщик (расширение того же mu-plugin: таймер на каждый колбэк с атрибуцией по файлу) показал точную картину — два виновника на каждом просмотре:
- Плагин транслитерации (sanitize_title, 7–9 вызовов, 465–1433 мс): с стандартом «yandex» каждый вызов ходил синхронно в Яндекс-API за переводом заголовка — ~130 мс за вызов.
- Библиотека шрифтов WP (wp-includes/fonts.php): регистрация коллекции Google Fonts тянула удалённый JSON с s.w.org — 420–950 мс на каждом запросе.
Исправления: стандарт транслитерации переключён на локальную таблицу (мгновенно, качество то же), а запрос шрифтов — прерван mu-plugin-фильтром с суточным кэшем. Результат на той же странице категории: 754 мс → 184 мс (−76 %), TTFB главной 0,65 → 0,20 с. Обе правки не касаются кэширования HTML — правило экрана согласия не нарушено.
Предостережения
- Профилировщик пишет лог на каждый запрос, включая ботов — на живом сайте не держать дольше часа.
- Лог пишется в
/tmp— пропадёт при перезагрузке сервера; нужные строки скопируйте заранее. - mu-plugin выполняется до обычных плагинов — он не покажет, тормозит ли сам mu-plugin (он не тормозит: десяток отметок — микросекунды).
- Если на сайте включён кэш страниц, замеряйте с уникальным query-параметром (
?nocache=123) или на время отключите кэш — закэшированный ответ минует все хуки и в лог не попадёт. - Не редактируйте
advanced-cache.phpи другие dropin-файлы руками во время замеров: плагины кэша пересоздают их при первом же запросе, и «отключённый» кэш продолжит работать.
Сравнение кэширования на тех же сайтах, включая редкий случай «кэш не нужен» — в статье Кеширование WordPress: шесть режимов, гигиена базы — в Чистке базы данных.
