WP_Query в WordPress: чек-лист проверки запросов
Фильтр каталога по двум мета-полям с posts_per_page => -1 кладёт страницу не из-за WordPress, а из-за структуры wp_postmeta. Разбираем, что проверять в каждом WP_Query и чем платит каждый способ фильтрации.

Типичный каталог: кастомный тип записи product, фильтр по двум мета-полям, posts_per_page => -1, потому что «так проще посчитать». Очевидное решение ломается на уровне схемы базы: каждое условие meta_query добавляет свой JOIN к wp_postmeta, а в этой таблице есть индексы по post_id и по meta_key (по первым 191 символу), но нет составного индекса по паре «ключ + значение». MySQL честно поднимает все строки с нужным meta_key и фильтрует их по значению уже в памяти. Дальше WordPress прогревает кеш мета-данных и терминов для каждой найденной записи, и PHP съедает память на объектах, из которых на странице нужны три поля. Ниже — чек-лист, по которому стоит прогонять каждый WP_Query перед релизом, и цена каждого варианта фильтрации. Всё проверялось на WordPress 6.x и PHP 8.1.
Нулевой пункт: посмотрите на сгенерированный SQL
Спорить об оптимизации запроса, которого вы не видели, бессмысленно. WP_Query складывает итоговый SQL в публичное свойство request — его можно прочитать сразу после создания объекта.
// WordPress 6.x. Что реально ушло в MySQL.
$products = new WP_Query( array(
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 24,
'paged' => max( 1, (int) get_query_var( 'paged' ) ),
) );
error_log( $products->request );
if ( $products->have_posts() ) {
while ( $products->have_posts() ) {
$products->the_post();
the_title( '<h3>', '</h3>' );
}
}
wp_reset_postdata(); // возвращаем глобальный $post на место
Для разовой диагностики этого хватает. Для системной работы включите SAVEQUERIES в wp-config.php и смотрите $wpdb->queries — там время каждого запроса и стек вызовов. На живом сайте константу выключайте: она копит все запросы в память.
WP_Query — не выборка записей, а генератор SQL. Пока вы не прочитали то, что он сгенерировал, вы обсуждаете догадки, а не запрос.
Чек-лист: что проверять в каждом запросе
- Есть ли ограничение выборки.
posts_per_pageзадан числом, а не-1. Если-1осознанный — рядом комментарий, почему выборка заведомо малая. - Нужен ли пересчёт общего количества. Без пагинации ставьте
'no_found_rows' => true. Ядро считает общее число найденных записей отдельным проходом по выборке (механизм подсчёта в ядре со временем менялся, актуальную реализацию смотрите вWP_Query::get_posts()). Для блока «последние пять новостей» этот проход — чистые накладные расходы. - Какие поля реально нужны. Если на выходе только идентификаторы,
'fields' => 'ids'избавляет от выборки всех колонокwp_postsи от создания объектов. - Прогреваются ли лишние кеши. Когда мета и термины не нужны, выключайте
update_post_meta_cacheиupdate_post_term_cache. На выборке в 100 записей это два лишних запроса и заметный объём памяти. - Фильтр через мету или через таксономию. Любое поле, по которому вы фильтруете или строите фасеты, просится в таксономию. Мета — для значений, которые вы только показываете.
- Нет ли
post__not_inна большом списке. Исключение сотни идентификаторов превращается в длинныйNOT IN. Чаще дешевле выбрать на один элемент больше и отбросить лишнее в PHP. - Чем сортируем.
'orderby' => 'meta_value_num'требует указанногоmeta_keyи сортирует по JOIN-таблице.'orderby' => 'rand'на большой таблице не кешируется и рушит план запроса — в проде ему не место. - Главный запрос или новый. Если задача — изменить то, что уже выводит архив, вы правите главный запрос через
pre_get_posts, а не создаёте второйWP_Queryповерх первого. - Есть ли
wp_reset_postdata(). После каждого собственного цикла сthe_post(). Один пропущенный сброс — и у вас в футере заголовок последнего товара вместо названия страницы. - Экранирование входных данных. Значения из
$_GETприводятся к типу ((int),sanitize_text_field(), проверка по белому списку) до попадания в аргументы.WP_Queryподготавливает SQL сам, ноmeta_keyилиorderbyиз запроса пользователя — это дыра.
Как выглядит облегчённый запрос
// Виджет «последние поступления»: нужны только заголовок и ссылка.
$latest = new WP_Query( array(
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 5,
'no_found_rows' => true, // пагинации нет, пересчёт не нужен
'update_post_meta_cache' => false, // мета в виджете не используется
'update_post_term_cache' => false, // термины тоже
'ignore_sticky_posts' => true, // «липкие» записи не нужны
) );
Мета против таксономии на том же фильтре
// Вариант 1: фильтр по мета-полю. JOIN к wp_postmeta,
// отбор по значению идёт без индекса.
$args_meta = array(
'post_type' => 'product',
'meta_query' => array(
array(
'key' => 'brand_slug',
'value' => 'acme',
),
),
);
// Вариант 2: то же через таксономию. JOIN к term_relationships
// и term_taxonomy, оба по индексированным ключам.
$args_tax = array(
'post_type' => 'product',
'tax_query' => array(
array(
'taxonomy' => 'brand',
'field' => 'slug',
'terms' => 'acme',
),
),
);
Четыре способа отфильтровать каталог и цена каждого
| Подход | Что выигрываете | Чем платите |
|---|---|---|
meta_query по одному ключу |
Ничего не переносить, поле уже есть; работает из коробки | JOIN без индекса по значению; на каждом дополнительном условии ещё один JOIN; LIKE и диапазоны деградируют быстрее всего |
Таксономия и tax_query |
JOIN по индексированным полям, готовые архивы терминов, кеш терминов | Миграция данных и двойная запись при сохранении; значения становятся справочником, произвольные числа туда не ложатся |
Свой индекс и post__in |
Полный контроль над SQL и индексами, предсказуемое время отбора | Своя таблица и её синхронизация; длина списка идентификаторов ограничена; 'orderby' => 'post__in' нужно указывать вручную |
| Внешний поисковый индекс (например, ElasticPress) | Фасеты, релевантность и агрегации, которых в MySQL нет | Ещё один сервис в инфраструктуре, лаг переиндексации, поведение фильтров расходится с базой |
Когда что выбирать
- Одно условие, выборка в пределах нескольких тысяч записей, фильтр не на главной —
meta_queryдопустим. - Два и более фильтра работают одновременно, значения конечные и повторяются — переносите в таксономии.
- Фильтр по числовым диапазонам (цена, остаток) на десятках тысяч записей — свой индекс и
post__in. - Нужны фасетные счётчики «сколько товаров в каждом варианте» — внешний индекс, в MySQL это отдельный запрос на каждый счётчик.
Главный запрос или второй WP_Query
Самая дорогая ошибка на архивных страницах — оставить главный запрос работать и рядом создать свой. База выполняет обе выборки, а пагинация считается по первой. Правильное место для изменений — pre_get_posts.
add_action( 'pre_get_posts', function ( WP_Query $query ) {
// Только фронтенд и только главный запрос.
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( ! $query->is_post_type_archive( 'product' ) ) {
return;
}
$query->set( 'posts_per_page', 24 );
$query->set( 'orderby', 'date' );
$query->set( 'order', 'DESC' );
} );
Проверка на is_main_query() и is_admin() не формальность: без неё правка прилетает в запросы виджетов, в фид и в список записей админки.
Что ломается чаще всего
- 404 на второй странице кастомного запроса. На обычной странице номер страницы лежит в
paged, на статической главной — вpage. Плюс главный запрос страницы о вашей пагинации не знает и отдаёт 404 раньше, чем дело дойдёт до цикла. - Сбитые заголовки и ссылки после цикла. Нет
wp_reset_postdata().wp_reset_query()— не замена, он относится к старомуquery_posts(), которым пользоваться не нужно. get_posts()игнорирует фильтры. У него по умолчанию'suppress_filters' => true, поэтому плагины мультиязычности, ограничения доступа и прочие надстройки на этот запрос не влияют. На мультиязычном сайте это выглядит как «на английской версии показываются русские товары».- Кеш не помогает. Начиная с WordPress 6.1 ядро кеширует результаты запросов
WP_Queryв объектном кеше*, но без постоянного бэкенда (Redis, Memcached) кеш живёт один запрос и умирает. Проверьте, что объектный кеш действительно подключён, прежде чем винить запрос. - Память кончается на
-1. Симптом — белый экран иAllowed memory size of N bytes exhaustedв логе PHP. Поднимать лимит памяти бесполезно: выборка растёт вместе с каталогом.
Что делать дальше
Поставьте Query Monitor на стейдже и пройдите по десяти самым посещаемым шаблонам: панель показывает число запросов, суммарное время и какой именно хук или компонент их породил. Дальше включите slow_query_log в MySQL с порогом около 100 мс и неделю собирайте реальные запросы с продакшена — список оптимизаций сформируется сам. Основные показатели для наблюдения: число SQL-запросов на страницу, время до первого байта на архивах и пиковая память PHP. Инвалидация и прогрев кеша фрагментов — отдельная тема, здесь мы разбирали только сам запрос.
* Объектный кеш — слой кеширования результатов запросов и мета-данных внутри WordPress. Без плагина-бэкенда работает только в рамках одного HTTP-запроса; с Redis или Memcached данные переживают запросы и делятся между процессами PHP.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: WP_Query — WordPress Developer Resources, pre_get_posts — WordPress Developer Resources
Частые вопросы
Чем get_posts() отличается от new WP_Query и что брать?
У меня WordPress 5.x, что из чек-листа не применимо?
Пагинация кастомного запроса отдаёт 404 на второй странице, что не так?
Можно ли вообще оставлять posts_per_page => -1?
После моего цикла ломается сайдбар и заголовок страницы. Почему?
Я всё сделал по чек-листу, а в проде быстрее не стало. Что мерить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.