Клас WP_Query є центральною ланкою підсистеми даних ядра WordPress. На відміну від легковажних Query Builder або ORM сучасних фреймворків (Doctrine, Eloquent), WP_Query виконує подвійну роль:
$args) у синтаксично складний SQL-запит до реляційної моделі даних (wp_posts, wp_postmeta, wp_term_relationships, wp_term_taxonomy).The Loop), обчислює умовні теги оточення (is_single(), is_archive(), is_search()) та керує вказівником поточного запису $post у глобальній області видимості.Архітектура виконання запиту побудована на двофазній моделі (Two-Phase Execution / split_the_query):
ID записів, що відповідають умовам фільтрації, сортування та ліміту (LIMIT/OFFSET). Це дозволяє MySQL виконувати вибірку виключно за покриваючими індексами (Covering Indexes), не читаючи важкі текстові поля post_content з диска.ID. Записи, метадані та прив'язані терміни, відсутні у внутрішньому об'єктному кеші (Persistent Object Cache), підвантажуються пакетним запитом (_prime_post_caches()) через оператори WHERE ID IN (...) за 1–2 мережевих раунди до бази даних або Redis, запобігаючи класичній проблемі $N+1$.Нерозуміння внутрішніх етапів компіляції SQL призводить до деградації високонавантажених проєктів: блокування таблиць, генерації неоптимізованих SQL_CALC_FOUND_ROWS, переповнення пулу буферів InnoDB (Buffer Pool) та вичерпання оперативної пам'яті PHP.
Конвеєр WP_Query транслює абстрактні бізнес-вимоги у виконуваний план запиту MySQL через послідовний ланцюжок мутацій:
[Вхідний масив $args]
│
▼
[1. WP_Query::init() & parse_query()] ── Валідація типів та значень
│
▼
[2. do_action_ref_array('pre_get_posts')] ── Точка мутації аргументів (Архіви, CPT)
│
▼
[3. WP_Query::get_posts()] ── Компіляція клауз SQL
├── Генерація JOIN (wp_term_relationships, wp_postmeta)
├── Побудова WHERE (post_type, post_status, tax_query, meta_query)
├── Сортування ORDER BY & Ліміти LIMIT/OFFSET
└── apply_filters_ref_array('posts_clauses') ── Низькорівнева правка SQL
│
▼
[4. Two-Phase Execution]
├── Вибірка: SELECT ID FROM wp_posts WHERE ... LIMIT N
├── Перевірка Persistent Object Cache (Redis / Memcached)
└── Пакетний праймінг: _prime_post_caches( $missing_ids )
│
▼
[5. Hydration & The Loop Context]
├── Заповнення $query->posts масивом об'єктів WP_Post
├── Встановлення прапорів: $query->found_posts, $query->max_num_pages
└── Передача управління в The Loop (have_posts / the_post)Валідація вхідних параметрів, встановлення значень за замовчуванням, парсинг дат, таксономій та мета-запитів.
Спрацьовування хука pre_get_posts. Бізнес-логіка змінює параметри вибірки до генерації SQL-рядка.
Збирання SQL-клауз через WP_Tax_Query та WP_Meta_Query. Фінальна фільтрація через posts_clauses.
Вибірка масиву ID за індексами. Якщо no_found_rows=false, виконується розрахунок total rows.
Пакетне завантаження мета-даних і термінів в Object Cache, ініціалізація об'єктів WP_Post та прапорів пагінації.
SQL_CALC_FOUND_ROWS: вимкнення підрахунку загальної кількості записів через 'no_found_rows' => true прискорює запити на таблицях понад 500 000 постів з 1.8 с до 12 мс, запобігаючи блокуванню дискового I/O в MySQL.OFFSET 20000 на пошук за граничним ID (WHERE ID < :last_id ORDER BY ID DESC LIMIT 20) усуває квадратичну деградацію продуктивності СУБД при глибокому скролі стрічок новин або маркетплейсів.posts_clauses та примусове вказування індексів (FORCE INDEX) для пригнічення повільних тимчасових таблиць (Using temporary; Using filesort) при одночасній фільтрації за 5+ характеристиками.'fields' => 'ids' з подальшим вилученням серіалізованих DTO-структур безпосередньо з Redis повз створення важких PHP-об'єктів WP_Post.'no_found_rows' => true: вимикає директиву SQL_CALC_FOUND_ROWS у MySQL 5.7 / COUNT(*) у MySQL 8.0+. Обов'язковий для всіх блоків (схожі пости, слайдери, віджети, нескінченні скроли), де не потрібна нумерація сторінок виду 1, 2, 3 ... 50.'update_post_meta_cache' => false: вимикає автоматичне завантаження всіх метаданих постів в об'єктний кеш. Критично важливо, якщо вибірка виводить лише заголовки або дати і не звертається до get_post_meta().'update_post_term_cache' => false: вимикає пакетне завантаження термів таксономій.'fields' => 'ids': повертає плоский масив цілих чисел int[]. Економить до 70% оперативної пам'яті PHP на вибірках від 100+ елементів.Замість небезпечної конкатенації сирих рядків SQL через фільтри posts_where або posts_join, ядро надає фільтр posts_clauses. Він передає асоціативний масив фрагментів запиту:
[
'where' => ' AND wp_posts.post_type = "product" ...',
'groupby' => '',
'join' => ' LEFT JOIN wp_postmeta ON ...',
'orderby' => ' wp_posts.post_date DESC',
'distinct' => '',
'fields' => ' wp_posts.*',
'limits' => ' LIMIT 0, 10',
]<?php
/**
* Plugin Name: Fullstack Agentic Lab — High-Performance Query Service
* Description: Оптимізований сервіс виконання запитів до бази даних на базі WP_Query.
* Version: 2.1.0
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Core\Database;
use WP_Query;
use InvalidArgumentException;
if (!defined('ABSPATH')) {
exit;
}
final readonly class HighPerformanceQuery
{
/**
* Виконує безпечний запит з примусовою оптимізацією прапорів пам'яті та кешування.
*
* @param array<string, mixed> $args Параметри запиту WP_Query.
* @param bool $enablePagination Чи потрібен розрахунок сторінок (no_found_rows).
* @return array{posts: array<int, mixed>, total: int, execution_time_ms: float}
*/
public static function execute(array $args, bool $enablePagination = false): array
{
$startTime = microtime(true);
// Примусові інваріанти продуктивності
$args['suppress_filters'] = false;
$args['no_found_rows'] = !$enablePagination;
// Запобігання катастрофічним необмеженим вибіркам
if (isset($args['posts_per_page']) && (int)$args['posts_per_page'] === -1) {
$args['posts_per_page'] = 100; // Безпечна стеля вибірки
}
if (!isset($args['update_post_meta_cache'])) {
$args['update_post_meta_cache'] = true;
}
if (!isset($args['update_post_term_cache'])) {
$args['update_post_term_cache'] = true;
}
$query = new WP_Query($args);
$executionTime = (microtime(true) - $startTime) * 1000.0;
// Відправлення Server-Timing для систем APM
if (!headers_sent() && defined('WP_DEBUG') && WP_DEBUG) {
header(sprintf('Server-Timing: wp_query;desc="WP_Query Execution";dur=%.2f', $executionTime), false);
}
return [
'posts' => $query->posts,
'total' => $enablePagination ? (int)$query->found_posts : count($query->posts),
'execution_time_ms' => round($executionTime, 2),
];
}
/**
* Вибірка з курсорною пагінацією (Keyset Seek Method).
* Працює за O(1) незалежно від глибини прокрутки.
*
* @param string $postType Тип запису.
* @param int $limit Розмір сторінки.
* @param int|null $cursorId ID останнього запису з попередньої сторінки.
* @return array<int, int> Масив ID записів.
*/
public static function fetchKeysetPage(string $postType, int $limit = 20, ?int $cursorId = null): array
{
global $wpdb;
if ($limit < 1 || $limit > 100) {
throw new InvalidArgumentException('Limit must be between 1 and 100');
}
if ($cursorId === null) {
$sql = $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type = %s AND post_status = 'publish'
ORDER BY ID DESC LIMIT %d",
$postType,
$limit
);
} else {
$sql = $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type = %s AND post_status = 'publish' AND ID < %d
ORDER BY ID DESC LIMIT %d",
$postType,
$cursorId,
$limit
);
}
/** @var array<int, string> $results */
$results = $wpdb->get_col($sql);
// Пакетний праймінг кешу об'єктів WordPress для знайдених ID
$ids = array_map('intval', $results);
if (!empty($ids)) {
_prime_post_caches($ids, true, true);
}
return $ids;
}
}Чому розробники так роблять: Прагнення отримати «всі записи» довідника або каталогу для обробки в циклі чи вивантаження у випадаючий список.
Що відбувається в продакшені: Запит LIMIT 0, 18446744073709551615 змушує MySQL прочитати всі рядки таблиці wp_posts, вимикає оптимізації кешу та завантажує тисячі об'єктів WP_Post у пам'ять PHP. При зростанні каталогу до 20 000+ постів один такий запит споживає понад 512 МБ RAM, призводить до фатальної помилки Fatal error: Allowed memory size exhausted та «вішає» воркери PHP-FPM, викликаючи каскадну помилку HTTP 502/504 для всіх користувачів.
Як правильно: Завжди обмежувати вибірку безпечним максимумом (posts_per_page => 100). Для масової обробки даних (експорт, міграції, cron) використовувати чанкінг (batching / chunking) через курсор ID:
$cursorId = 0;
do {
$batch = HighPerformanceQuery::fetchKeysetPage('product', 100, $cursorId ?: null);
foreach ($batch as $postId) {
// Ізольована обробка запису
}
$cursorId = !empty($batch) ? end($batch) : null;
} while (!empty($batch));Чому розробники так роблять: У шаблонах категорій (category.php), архівів CPT (archive-cars.php) або пошукової видачі розробники вручну оголошують $query = new WP_Query($args); для зміни кількості постів або сортування.
Що відбувається в продакшені: Ядро WordPress вже виконало головний запит до бази даних до підключення файлу шаблону. Створюючи другий екземпляр WP_Query, розробник ініціює повторний важкий SQL-запит, подвоюючи час TTFB сторінки. Крім того, пагінація (paginate_links()) продовжує посилатися на глобальний $wp_query, що призводить до помилок 404 при переході на 2-гу сторінку.
Як правильно: Модифікувати основний запит до його виконання через хук pre_get_posts:
add_action('pre_get_posts', function(WP_Query $query): void {
if (!is_admin() && $query->is_main_query() && $query->is_category('electronics')) {
$query->set('posts_per_page', 24);
$query->set('no_found_rows', false);
}
});Чому розробники так роблять: Використання стандартної пагінації OFFSET = (Page - 1) * Limit для сторінок каталогу або API (наприклад, сторінка №1500: LIMIT 15000, 10).
Що відбувається в продакшені: Двигун InnoDB не може миттєво переміститися на 15000-й рядок. Він зобов'язаний прочитати з диска 15 010 рядків, відсортувати їх в оперативній пам'яті (Using filesort), відкинути перші 15 000 і повернути останні 10. Запит, що виконується за 2 мс на 1-й сторінці, на 1500-й сторінці виконується за 3.5–8.0 секунд, перевантажуючи CPU бази даних.
Як правильно: Використовувати курсорну навігацію (Seek Method / Keyset Pagination) за первинним ключем ID < :last_id, яка виконується за стабільний час $O(\log N)$ незалежно від глибини вибірки.
Чому розробники так роблять: Фільтрація за довільними полями (color = 'red' OR size = 'XL') через асоціативний масив meta_query.
Що відбувається в продакшені: Поле meta_value у таблиці wp_postmeta має тип LONGTEXT і не може бути повністю проіндексоване. Умова OR породжує множинні LEFT JOIN wp_postmeta, знищуючи ефективність індексів MySQL. База даних переходить у режим повного сканування таблиці (Full Table Scan), що при 1 000 000+ рядків у wp_postmeta призводить до повної недоступності СУБД.
Як правильно: Для фільтрацій за зумовленими атрибутами використовувати кастомні таксономії (таблиці wp_term_relationships мають складені числові індекси (object_id, term_taxonomy_id) і працюють у сотні разів швидше) або виносити фасетні поля в окремі кастомні таблиці БД з B-Tree індексами.
Розбір вхідних параметрів, встановлення дефолтних прапорів безпеки, санітарія типів.
Встановлюються значення за замовчуванням для post_type, post_status та лімітів.
Єдина легітимна точка зміни головного запиту ядра до компіляції SQL.
Обов'язково перевіряйте прапори !is_admin() && $query->is_main_query().
Збирання рядкових виразів WHERE, JOIN, ORDER BY, GROUP BY та LIMIT.
Використовуйте фільтр posts_clauses для атомарної підстановки кастомних умов та хінтів індексів.
Первинна вибірка плоского масиву ідентифікаторів ID за індексами таблиці wp_posts.
Якщо no_found_rows=true, другий запит COUNT(*) не генерується.
Пакетне читання відсутніх постів, мета-полів та термів із Persistent Object Cache за 1 раунд.
Ініціалізуються інстанси WP_Post та встановлюється глобальний контекст The Loop.