La clase WP_Query es el eje central del subsistema de acceso a datos del núcleo de WordPress. A diferencia de constructores de consultas ligeros u ORMs contemporáneos (como Doctrine o Eloquent), WP_Query cumple una doble función:
$args) en una sentencia SQL compleja contra el modelo relacional de datos (wp_posts, wp_postmeta, wp_term_relationships, wp_term_taxonomy).The Loop), evalúa etiquetas condicionales de entorno (is_single(), is_archive(), is_search()) y administra el puntero de la entrada activa $post en el ámbito global.La ejecución de consultas se basa en un modelo bifásico (Two-Phase Execution / split_the_query):
ID que satisfacen las condiciones de filtrado, orden y límite (LIMIT/OFFSET). Esto permite a MySQL resolver la consulta enteramente sobre índices de cobertura (Covering Indexes), sin leer del disco las columnas extensas de texto post_content.ID obtenida. Las publicaciones, metadatos y términos vinculados que no estén en la memoria caché persistente (Persistent Object Cache) se cargan en bloque mediante _prime_post_caches() usando WHERE ID IN (...) en 1 o 2 viajes de red a Redis o MySQL, neutralizando el problema $N+1$.Desconocer las fases de compilación de SQL conduce a fallos graves en entornos de alto tráfico: bloqueos de tablas, cálculos no indexados de SQL_CALC_FOUND_ROWS, saturación del InnoDB Buffer Pool y agotamiento de la memoria de PHP.
El pipeline de WP_Query transforma requisitos de negocio abstractos en un plan de ejecución de MySQL optimizado mediante una secuencia determinista de mutaciones:
[Array entrante de $args]
│
▼
[1. WP_Query::init() & parse_query()] ── Validación de tipos y defaults
│
▼
[2. do_action_ref_array('pre_get_posts')] ── Modificación de argumentos (CPTs, Archivos)
│
▼
[3. WP_Query::get_posts()] ── Compilación de cláusulas SQL
├── Generación de JOINs (wp_term_relationships, wp_postmeta)
├── Construcción de WHERE (post_type, post_status, tax_query, meta_query)
├── Ordenación ORDER BY y límites LIMIT/OFFSET
└── apply_filters_ref_array('posts_clauses') ── Ajuste fino del SQL
│
▼
[4. Two-Phase Execution]
├── Consulta: SELECT ID FROM wp_posts WHERE ... LIMIT N
├── Comprobación de Persistent Object Cache (Redis / Memcached)
└── Priming por lotes: _prime_post_caches( $missing_ids )
│
▼
[5. Hydration & The Loop Context]
├── Llenado de $query->posts con instancias de WP_Post
├── Asignación de contadores: $query->found_posts, $query->max_num_pages
└── Entrega de control a The Loop (have_posts / the_post)Validación de parámetros, establecimiento de valores por defecto, análisis de fechas, taxonomías y meta queries.
Disparo del hook pre_get_posts. La lógica de negocio muta los parámetros antes de generar el código SQL.
Ensamblado de cláusulas con WP_Tax_Query y WP_Meta_Query. Filtrado definitivo mediante posts_clauses.
Selección de IDs numéricos mediante índices. Si no_found_rows es false, calcula el total de filas.
Carga en bloque de metadatos y términos en Object Cache; creación de instancias WP_Post e indicadores de paginación.
SQL_CALC_FOUND_ROWS: Desactivar el recuento global con 'no_found_rows' => true reduce el tiempo de consulta en tablas de más de 500.000 entradas de 1.8 s a 12 ms, evitando bloqueos de E/S en disco.OFFSET 20000 por búsqueda de ID límite (WHERE ID < :last_id ORDER BY ID DESC LIMIT 20) elimina el retardo exponencial de la base de datos en listados extensos.posts_clauses e incorporar sugerencias de indexación (FORCE INDEX) para evitar tablas temporales lentas (Using temporary; Using filesort) al filtrar simultáneamente por múltiples atributos.'fields' => 'ids', recuperando después estructuras DTO pre-serializadas directamente desde Redis sin instanciar objetos pesados WP_Post en PHP.Al diseñar sistemas de consultas a gran escala, los ingenieros senior deben configurar deliberadamente estas directivas:
'no_found_rows' => true: Desactiva SQL_CALC_FOUND_ROWS en MySQL 5.7 y el segundo COUNT(*) en MySQL 8.0+. Imprescindible para componentes donde no se requiere paginación numérica (1, 2, 3 ... 50), como carruseles o feeds infinitos.'update_post_meta_cache' => false: Desactiva la carga previa masiva de metadatos en caché. Esencial en listados que solo muestran títulos o fechas sin invocar get_post_meta().'update_post_term_cache' => false: Desactiva la precarga de términos taxonómicos.'fields' => 'ids': Devuelve un array plano de números enteros int[]. Ahorra hasta un 70% de memoria RAM en PHP en selecciones de más de 100 elementos.posts_clauses#En lugar de concatenar cadenas fragmentadas en posts_where o posts_join, el núcleo ofrece el filtro posts_clauses, que suministra un array asociativo con todos los fragmentos:
[
'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: Servicio optimizado de consultas a base de datos basado en WP_Query y paginación Keyset.
* 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
{
/**
* Ejecuta una consulta optimizada forzando límites de memoria y directivas de caché.
*
* @param array<string, mixed> $args Parámetros de WP_Query.
* @param bool $enablePagination Si se requiere el recuento total de páginas.
* @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);
// Invariantes obligatorias de rendimiento
$args['suppress_filters'] = false;
$args['no_found_rows'] = !$enablePagination;
// Evita consultas masivas no controladas
if (isset($args['posts_per_page']) && (int)$args['posts_per_page'] === -1) {
$args['posts_per_page'] = 100; // Límite seguro
}
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;
// Cabecera Server-Timing para sistemas 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),
];
}
/**
* Consulta con paginación Keyset (Seek Method).
* Opera en O(1) independientemente de la profundidad.
*
* @param string $postType Tipo de entrada.
* @param int $limit Tamaño de página.
* @param int|null $cursorId ID límite de la página previa.
* @return array<int, int> Array de IDs.
*/
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);
// Precarga en bloque de la caché de objetos para los IDs devueltos
$ids = array_map('intval', $results);
if (!empty($ids)) {
_prime_post_caches($ids, true, true);
}
return $ids;
}
}Por qué lo hacen los desarrolladores: Intentar obtener todos los registros de una categoría o catálogo en una única consulta.
Qué ocurre en producción: La consulta sin LIMIT obliga a MySQL a recorrer la tabla wp_posts al completo, prescindiendo de optimizaciones y cargando miles de objetos WP_Post en memoria. Al alcanzar decenas de miles de entradas, genera un error fatal Allowed memory size exhausted y satura los procesos de PHP-FPM, provocando respuestas HTTP 502/504 en cascada.
Forma correcta: Limitar siempre a un máximo razonable (posts_per_page => 100). Para tareas masivas (exportaciones, sincronizaciones), procesar por lotes con un cursor Keyset:
$cursorId = 0;
do {
$batch = HighPerformanceQuery::fetchKeysetPage('product', 100, $cursorId ?: null);
foreach ($batch as $postId) {
// Procesar publicación individual
}
$cursorId = !empty($batch) ? end($batch) : null;
} while (!empty($batch));Por qué lo hacen los desarrolladores: En plantillas de categorías (category.php) o archivos, definen $query = new WP_Query($args); para alterar el conteo o el orden de entradas.
Qué ocurre en producción: El núcleo de WordPress ya ejecutó la consulta principal antes de incluir el archivo de plantilla. Declarar un segundo WP_Query repite una consulta pesada a MySQL, duplicando el TTFB. Además, los enlaces de paginación (paginate_links()) continúan enlazados a la variable global $wp_query, arrojando errores 404 en la página 2.
Forma correcta: Modificar la consulta principal antes de compilarla en base de datos mediante el hook 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);
}
});Por qué lo hacen los desarrolladores: Emplear la fórmula habitual OFFSET = (Page - 1) * Limit en páginas profundas (ejemplo: página 1500 con LIMIT 15000, 10).
Qué ocurre en producción: InnoDB no puede saltar directamente a la fila 15.000. Debe leer 15.010 filas del disco, ordenarlas en memoria (Using filesort), descartar las primeras 15.000 y devolver 10. Una consulta que toma 2 ms en la página 1 requiere de 4 a 8 segundos en la 1500, saturando la CPU de la base de datos.
Forma correcta: Usar paginación Keyset (Seek Method) con ID < :last_id, recorriendo los índices B-Tree en tiempo constante $O(\log N)$ con independencia de la profundidad.
Por qué lo hacen los desarrolladores: Filtrar por metadatos arbitrarios (color = 'red' OR size = 'XL') mediante bloques meta_query.
Qué ocurre en producción: La columna meta_value en wp_postmeta es de tipo LONGTEXT y carece de indexación completa. El operador OR genera múltiples LEFT JOIN sin índice, provocando un escaneo completo de la tabla (Full Table Scan) que colapsa la base de datos en tablas con más de 1.000.000 de filas.
Forma correcta: Emplear taxonomías para atributos categóricos (el índice compuesto (object_id, term_taxonomy_id) en wp_term_relationships responde con extrema rapidez) o diseñar tablas personalizadas con índices B-Tree propios.
Análisis de argumentos entrantes, directivas de seguridad por defecto, normalización de tipos.
Configura valores por defecto para post_type, post_status y límites.
Punto de extensión exclusivo para modificar la consulta principal antes de la compilación SQL.
Verificar siempre las banderas !is_admin() && $query->is_main_query().
Generación de cadenas: WHERE, JOIN, ORDER BY, GROUP BY y LIMIT.
Usar posts_clauses para inyectar condiciones personalizadas e indicaciones de índice de forma atómica.
Selección inicial del array de IDs empleando índices de cobertura en wp_posts.
Si no_found_rows es true, la segunda consulta de COUNT(*) no se ejecuta.
Carga en bloque de publicaciones, metadatos y términos desde Object Cache en 1 solo viaje.
Instanciación de objetos WP_Post y preparación del contexto de The Loop.