WP_Query — рабочая лошадка WordPress: вывод записей в теме, блоки, виджеты, кастомные списки в админке — почти всё в итоге превращается в SQL к таблицам wp_posts, wp_postmeta, wp_terms и связанным с ними.
Проблема в том, что один неосторожный параметр превращает аккуратный запрос в монстра с множеством JOIN, сортировкой по строкам и перебором метаданных. Итог узнаваем: «wordpress search slow», высокая нагрузка на MySQL/MariaDB и всплески времени ответа.
Ниже — практичный разбор: как WP_Query становится SQL, какие параметры реально дорогие, как приручить meta_query, где помогают индексы, и как кешировать результаты через transients без ловушек с протуханием кеша.
Как WP_Query превращается в SQL и где появляются тормоза
На уровне базы данных типичный запрос WP_Query обычно раскладывается на несколько частей:
- основной набор записей из
wp_postsпоpost_type,post_status, датам, автору и т. п.; - фильтрация и сортировка по таксономиям — JOIN к
wp_term_relationships,wp_term_taxonomy,wp_terms; - фильтрация и сортировка по метаполям — JOIN к
wp_postmeta(часто несколько раз); - поиск —
LIKEпоpost_title,post_content,post_excerpt(в зависимости от условий).
Самые частые причины деградации производительности:
- Сложный
meta_query: несколько условий,OR, сравнения по строкам, отсутствие индексов под ваш паттерн запросов. - Сортировка по метаполю (
orderby=meta_valueилиmeta_value_num): базе приходится присоединятьwp_postmetaи сортировать большой набор. - Большие
offset: пагинация «пропусти 20000 строк и отдай 20» почти всегда боль. - Поиск по контенту (
s): на крупных сайтах без полнотекстового подхода становится узким местом. - Лишние данные: выбираете посты, а потом WordPress прогревает meta/terms — растёт время и память, а иногда появляется эффект N+1 по дополнительным запросам.
Минимально здоровые параметры WP_Query для списков
Если нужен просто список ID (например, для дальнейшей логики), не заставляйте WordPress тащить целые объекты постов и прогревать кеши:
$q = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 50,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'ignore_sticky_posts' => true,
]);
Почему это важно:
fieldsсо значениемidsснижает объём данных и стоимость создания объектов.no_found_rowsубирает подсчёт общего количества строк для пагинации (это отдельная нагрузка на БД).update_post_meta_cacheиupdate_post_term_cacheотключают прогрев кеша, если он не нужен в шаблоне.
Пагинация без offset: keyset-подход
offset удобен, но дорого стоит на больших таблицах: база всё равно проходит первые N строк, чтобы их «пропустить». Для больших лент лучше идти «от последнего увиденного» по дате или ID.
$q = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 20,
'orderby' => 'date',
'order' => 'DESC',
'date_query' => [
[
'before' => gmdate('Y-m-d H:i:s', $last_seen_ts),
'inclusive' => false,
]
],
'no_found_rows' => true,
]);
Если объём небольшой, можно жить с paged. Но на контентных проектах keyset почти всегда стабильнее по времени ответа.

Таксономии обычно дешевле меты: tax_query vs meta_query
Частая архитектурная ошибка — хранить фильтры в postmeta, хотя по смыслу это таксономии (категории, теги, «параметры»). Таксономии лучше индексируются штатной схемой WordPress, а запросы по ним предсказуемее и часто дешевле.
Пример фильтра по термам:
$q = new WP_Query([
'post_type' => 'product',
'posts_per_page' => 24,
'tax_query' => [
[
'taxonomy' => 'brand',
'field' => 'slug',
'terms' => ['acme'],
]
],
]);
Если вы сейчас фильтруете «бренд» через meta_key и meta_value — это хороший сигнал к рефакторингу модели данных.
Почему meta_query тормозит и как сделать его терпимым
meta_query — главный генератор тяжёлых JOIN к wp_postmeta. Несколько условий обычно означает несколько JOIN (или один JOIN, но с усложнённой логикой). Дополнительно meta_value</code хранится как строка, и даже числа часто оказываются строками: это влияет и на сравнения, и на сортировку.</p>
<h3>Избегайте OR и широких сравнений</h3>
<p>Самая дорогая комбинация: несколько условий с <code>relation равным OR плюс LIKE по meta_value. Если это фильтр каталога, чаще всего выгоднее пересмотреть структуру:
- категориальные признаки переносить в таксономии;
- числовые атрибуты для фильтрации и сортировки хранить нормализованно (иногда — в отдельной таблице);
- сложные вычисления делать предрасчётом (например, отдельное поле «сортировочный рейтинг»).
Если без meta_query никак, старайтесь:
- использовать точные сравнения:
=,IN,BETWEENвместоLIKE; - не мешать множество разных
meta_keyв «широкие» условия, если можно сузить выборку другими фильтрами; - по возможности не сортировать по
meta_value, если хватает сортировки поpost_dateили по заранее рассчитанному полю.
Числа в meta: type=NUMERIC и meta_value_num
Если храните цену, рейтинг, счётчик в мета, используйте type равный NUMERIC и orderby равный meta_value_num. Это не отменяет JOIN и сортировку, но сравнения станут корректнее, а план иногда — проще.
$q = new WP_Query([
'post_type' => 'product',
'posts_per_page' => 20,
'meta_key' => 'price',
'orderby' => 'meta_value_num',
'order' => 'ASC',
'meta_query' => [
[
'key' => 'price',
'value' => [1000, 5000],
'compare' => 'BETWEEN',
'type' => 'NUMERIC',
]
],
]);
Сначала сузить набор, потом дорогие фильтры
WP_Query не всегда строит «идеальный» план, а оптимизатор БД не всегда угадывает лучший порядок. Поэтому помогает простая стратегия: максимально рано сужайте набор по post_type, post_status, датам и таксономиям, и только потом добавляйте мета-условия. Это не магия, но часто реально снижает число строк, которые попадают в JOIN к wp_postmeta.
Индексы: где реально помогает и как не навредить
По умолчанию у wp_postmeta обычно есть индексы по post_id и meta_key, но под нагруженные фильтры этого может быть недостаточно. Отсюда и вечный запрос «wp_query indexes»: куда добавить индекс, чтобы перестало «умирать».
Правило безопасности: сначала EXPLAIN, потом индекс
Индексы ускоряют чтение, но замедляют запись: обновления мета, импорт, автосейвы, фоновые задачи. Поэтому индекс должен быть ответом на конкретный медленный запрос, а не «на всякий случай».
Рабочий подход:
- Воспроизведите медленный запрос (по slow log или через отладчик запросов).
- Сделайте
EXPLAINи посмотрите, где идёт full scan и какие ключи не используются. - Добавляйте индекс только под повторяющийся паттерн запросов, который критичен для сайта.
Пример: составной индекс для meta_key + post_id
Частый сценарий: вы выбираете посты по конкретному meta_key, а затем присоединение идёт по post_id. Тогда иногда помогает составной индекс (если его нет в вашей схеме). Пример DDL (адаптируйте префикс таблиц):
ALTER TABLE wp_postmeta ADD INDEX meta_key_post_id (meta_key(191), post_id);
Почему (191): для совместимости с utf8mb4 и ограничениями длины индекса в некоторых конфигурациях. Конкретное значение зависит от версии MySQL/MariaDB и настроек.
Индексировать meta_value почти всегда плохая идея
Индекс по meta_value редко оправдан: поле длинное, строковое, данные разнородные. Если вам нужна быстрая фильтрация или сортировка по значению — это признак, что мета используется как псевдо-таблица. В таких местах лучше:
- вынести критичный атрибут в отдельную таблицу или отдельный столбец (если вы контролируете схему);
- использовать таксономию для категориального признака;
- предрасчитать нормализованное значение (например, числовое) и работать с ним.
WordPress search slow: что делать со штатным поиском
Параметр s в WP_Query запускает поиск, который в базовой реализации использует LIKE. На маленьких сайтах это терпимо. На крупных — становится источником нагрузки, особенно если поиск идёт по post_content.
Минимальные меры без смены механизма поиска
- Ограничьте
post_typeи статусы: не ищите «по всему подряд». - Исключите тяжёлые сущности (ревизии, вложения) через
post_typeи настройки. - Отключайте подсчёт общего количества (
no_found_rows), если пагинация не нужна.
Пример аккуратного поиска:
$q = new WP_Query([
's' => $term,
'post_type' => ['post', 'page'],
'post_status' => 'publish',
'posts_per_page' => 10,
'ignore_sticky_posts' => true,
'no_found_rows' => true,
]);
Старайтесь не комбинировать поиск s и тяжёлый meta_query в одном запросе. Часто быстрее сделать двухэтапный подход: сначала «дешёвый» поиск по постам, затем дополнительная фильтрация по ID (или через отдельную модель данных).
Transients: кешируем результаты WP_Query правильно
transients удобны для кеширования результатов вычислений (например, списка ID), чтобы не выполнять один и тот же тяжёлый запрос на каждом хите. Важно помнить: transients живут с TTL, а хранение зависит от наличия object cache. Без него значения часто попадают в wp_options, и это тоже нужно учитывать в нагрузке.
Если вы строите кеши и хотите стабильности под нагрузкой, полезно использовать внешний object cache (например, Redis или Memcached) и правильно настроить PHP. По теме кеширования на уровне приложений посмотрите материал про кеш PHP с Redis/Memcached.
Что именно кешировать: IDs, а не объекты
Объекты постов тяжёлые в сериализации, могут меняться, а инвалидация сложнее. В большинстве кейсов достаточно кешировать массив ID, а затем получить посты стандартными функциями.
$cache_key = 'ff_home_featured_ids_v1';
$ids = get_transient($cache_key);
if ($ids === false) {
$q = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 12,
'meta_key' => 'featured',
'meta_value' => '1',
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
$ids = $q->posts;
set_transient($cache_key, $ids, 10 * MINUTE_IN_SECONDS);
}
$posts = get_posts([
'post__in' => $ids,
'orderby' => 'post__in',
'posts_per_page' => count($ids),
]);
Инвалидация кеша: не полагайтесь только на TTL
TTL — страховка, но лучше сбрасывать кеш при изменениях. Минимальный вариант — очищать transient на событиях сохранения поста или обновления меты. В хуке не делайте тяжёлую логику: просто удаляйте ключ.
add_action('save_post', function($post_id) {
if (wp_is_post_revision($post_id)) {
return;
}
delete_transient('ff_home_featured_ids_v1');
});
Если ключей много, используйте версионирование (суффикс вроде _v2) или «глобальную версию» в option, чтобы массово инвалидировать кеш без перебора всех ключей.
Отладка WP_Query: как быстро понять, что именно тормозит
Оптимизация без измерений обычно превращается в угадайку. Что реально помогает в работе администратора и разработчика:
- включить лог медленных запросов в MySQL/MariaDB и собрать конкретные SQL;
- на стенде включить
SAVEQUERIESи сравнить список запросов по времени; - проверить планы выполнения через
EXPLAINдля самых дорогих SQL; - посмотреть, не раздувается ли количество запросов из-за подтягивания меты и термов.
Если база уже на грани по CPU или I/O, иногда проще вынести WordPress на более производительный VDS и параллельно довести запросы до ума: «железо» не заменяет оптимизацию, но даёт запас, чтобы спокойно исправлять архитектуру без постоянных падений.
Типовые красные флаги в параметрах
orderbyравныйrand— почти всегда плохо на больших таблицах.offsetс большими значениями — дорого и нестабильно по времени ответа.- Сложный
meta_queryсOR— дорого. - Поиск
sплюс мета-фильтры в одном запросе — часто очень дорого.

Практические рецепты: как переписать запрос «как было / как стало»
Рецепт 1: «Нужен блок записей, пагинация не нужна»
Если вы выводите фиксированное число записей и не показываете «страницы», не заставляйте WP_Query считать общее число результатов.
$q = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 10,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
Рецепт 2: «Фильтр по атрибуту, который живёт в meta»
Если атрибут категориальный (бренд, тип, серия) — переносите в таксономию. Если атрибут числовой и по нему сортируют или фильтруют — рассмотрите отдельную таблицу или хотя бы нормализацию данных и точечные индексы.
WP_Query хорош, пока вы используете его как «запрос к постам». Когда вы строите на postmeta полноценную реляционную модель, кеши и индексы перестают быть панацеей — нужно менять подход к данным.
Рецепт 3: «Поиск по сайту медленный»
Сначала ограничьте область поиска и уберите лишние условия. Дальше — кешируйте популярные поисковые запросы по нормализованному ключу через transients с коротким TTL, чтобы сгладить пики. И только потом выбирайте, нужен ли отдельный механизм поиска.
Чек-лист перед тем, как оптимизировать WP_Query
- Сформулировать цель: нужен список постов, IDs, или агрегаты.
- Убрать лишнее:
no_found_rows,fields, отключить прогрев кешей, если он не используется. - Переоценить модель данных: таксономии вместо меты для категориальных признаков.
- Найти самые дорогие запросы через slow log и отладку, затем проверить их
EXPLAIN. - Добавлять индексы точечно под конкретный паттерн запросов.
- Кешировать результат (обычно IDs) через
transients, продумать инвалидацию. - Проверить эффект по метрикам: время ответа, нагрузка на БД, количество запросов.
Если пройти этот путь системно, даже сложные выборки на WP_Query перестанут быть лотереей: вы будете понимать, где именно экономите время — на объёме данных, на типе условий, на индексе или на кешировании.


