Rendimiento: N+1 y DataLoader
Agrupe y almacene en caché la resolución de campos para mantener la velocidad
Rendimiento: N+1 y DataLoader es una lección gratuita de PHP Academy en CoddyKit. Esta es la lección 4 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de PHP Academy, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de PHP Academy incluye 4 lecciones en total.
El asesino silencioso: N+1
La mayor trampa de rendimiento de GraphQL es el problema de consultas N+1. Es silencioso porque cada resolver parece inofensivo por separado, pero al anidar una lista y un campo por elemento, se ejecuta una consulta para la lista más una consulta por cada elemento. Con 100 elementos, son 101 viajes de ida y vuelta. En esta lección verá cómo reducirlos a unas pocas consultas agrupadas con DataLoader.
Ver N+1 en la práctica
Considere { posts { author { name } } }. El resolver de posts ejecuta una consulta. Después, para cada publicación, el resolver de author ejecuta su propia consulta. El código ingenuo de abajo hace evidente el coste.
<?php
// 1 query for posts...
$posts = [['id'=>1,'author_id'=>7],['id'=>2,'author_id'=>7],['id'=>3,'author_id'=>9]];
$queries = 1;
foreach ($posts as $post) {
// ...then 1 query PER post to fetch its author
$queries++;
// SELECT * FROM users WHERE id = $post['author_id']
}
echo "Total DB queries: {$queries}\n"; // Total DB queries: 4
La idea clave: agrupar por nivel
GraphQL resuelve nivel por nivel. Todos los resolvers de author de la lista de publicaciones se ejecutan en el mismo ciclo de ejecución. Si podemos posponer cada búsqueda de autor, recopilar los ids solicitados y después ejecutar un único WHERE id IN (...), convertimos N consultas en una. Eso es exactamente lo que proporciona GraphQL\Deferred.
<?php
$ids = [7, 7, 9];
$unique = array_values(array_unique($ids));
// One query instead of three:
echo 'SELECT * FROM users WHERE id IN (' . implode(',', $unique) . ")\n";
// SELECT * FROM users WHERE id IN (7,9)
Un búfer/loader mínimo
Esta es la esencia de un DataLoader: un búfer que acumula ids, los carga una sola vez mediante una función por lotes y ofrece los resultados desde una caché. La misma clave solicitada dos veces se carga una sola vez: deduplicación automática.
<?php
class UserLoader {
private array $queue = [];
private array $cache = [];
public function __construct(private \Closure $batchFn) {}
public function add(int $id): void { $this->queue[$id] = true; }
public function loadOnce(): void {
$missing = array_diff(array_keys($this->queue), array_keys($this->cache));
if ($missing) {
foreach (($this->batchFn)(array_values($missing)) as $id => $row) {
$this->cache[$id] = $row;
}
}
$this->queue = [];
}
public function get(int $id): mixed { return $this->cache[$id] ?? null; }
}
Integrarlo en un resolver
El resolver de author pone el id en cola y devuelve un Deferred. graphql-php ejecuta todos los deferreds después del nivel actual, así que cuando se ejecuta la clausura ya se han puesto en cola todos los ids de autor de la lista completa. Un loadOnce() activa una única consulta por lotes.
<?php
use GraphQL\Deferred;
$authorField = [
'type' => $userType,
'resolve' => function ($post, $args, $context) {
/** @var UserLoader $loader */
$loader = $context['userLoader'];
$loader->add($post['author_id']);
return new Deferred(function () use ($loader, $post) {
$loader->loadOnce(); // batches across all posts
return $loader->get($post['author_id']);
});
},
];
Usar la biblioteca overblog/dataloader
Rara vez tendrá que implementarlo desde cero. overblog/dataloader-php es la adaptación consolidada de DataLoader de Facebook. Se le proporciona una función por lotes que recibe un array de claves y debe devolver una promesa de valores en el mismo orden. La biblioteca gestiona la caché, la deduplicación y la resolución de promesas.
composer require overblog/dataloader-phpConstruir un DataLoader
El contrato de la función por lotes es estricto: dada [k1, k2, k3], debe resolverse como [v1, v2, v3] manteniendo las posiciones. Indexe las filas de la base de datos por clave y vuelva a mapear el orden de entrada para que las claves inexistentes se conviertan en null.
<?php
use Overblog\DataLoader\DataLoader;
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter;
$adapter = new WebonyxGraphQLSyncPromiseAdapter(new SyncPromiseAdapter());
$userLoader = new DataLoader(function (array $ids) use ($adapter, $db) {
$rows = $db->usersByIds($ids); // SELECT ... WHERE id IN (...)
$byId = [];
foreach ($rows as $r) { $byId[$r['id']] = $r; }
// MUST return values in the SAME ORDER as $ids
$ordered = array_map(fn($id) => $byId[$id] ?? null, $ids);
return $adapter->createFulfilled($ordered);
}, $adapter);
Resolver mediante el loader
En el resolver solo tiene que llamar a $loader->load($id), que devuelve una promesa. graphql-php, a través del adaptador, recopila estas promesas y activa el lote automáticamente al final del ciclo. No hay que gestionar ningún búfer manualmente.
<?php
$authorField = [
'type' => $userType,
'resolve' => fn($post, $args, $context) =>
$context['userLoader']->load($post['author_id']),
];
La duración por solicitud es fundamental
Los DataLoaders almacenan en caché por clave, por lo que deben crearse desde cero para cada solicitud. Un loader compartido entre solicitudes devolvería datos obsoletos y provocaría fugas de memoria. Cree los loaders al ensamblar el $context de la solicitud y descártelos cuando esta termine.
<?php
// Per request: brand new loaders, attached to context
function buildContext($db, $currentUser): array {
return [
'db' => $db,
'user' => $currentUser,
'userLoader' => makeUserLoader($db), // fresh, not a singleton
'postLoader' => makePostLoader($db),
];
}
Otras protecciones de rendimiento
DataLoader corrige las lecturas N+1, pero una consulta maliciosa o descuidada aún puede perjudicarle. Añada varias capas de protección:
- Límite de profundidad de consultas — la regla
QueryDepthrechaza consultas con anidamiento patológico. - Complejidad de consultas —
QueryComplexityasigna un presupuesto de coste por campo. - Consultas persistentes — permita únicamente operaciones incluidas en una lista de permitidas conocida.
- Paginación — nunca resuelva listas sin límite; use conexiones basadas en cursores.
<?php
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\DocumentValidator;
DocumentValidator::addRule(new QueryDepth(10));
DocumentValidator::addRule(new QueryComplexity(200));
Medir la mejora
Cuantifique siempre la mejora. Envuelva la capa de base de datos para contar las consultas en una prueba y compruebe que la versión agrupada emite un número acotado de consultas independientemente del tamaño de la lista. Esto protege contra regresiones en las que alguien añade un resolver anidado ingenuo y reintroduce N+1 silenciosamente.
<?php
class CountingDb {
public int $queries = 0;
public function usersByIds(array $ids): array {
$this->queries++; // one batched call
return array_map(fn($id) => ['id' => $id], $ids);
}
}
$db = new CountingDb();
$db->usersByIds([7, 9, 11, 13]); // 4 authors
echo "Queries for 4 authors: {$db->queries}\n"; // Queries for 4 authors: 1
Comprobación rápida
¿Por qué deben crearse los DataLoaders por solicitud?
Resumen
Ha eliminado la peor trampa de rendimiento de GraphQL:
- Los campos de listas anidados provocan N+1: una consulta por elemento.
- graphql-php resuelve nivel por nivel, por lo que posponer permite agrupar todas las claves en una consulta
IN (...). GraphQL\Deferredes la primitiva;overblog/dataloader-phpincluye agrupación, caché por clave y deduplicación.- La función por lotes debe devolver los valores en el mismo orden que las claves de entrada.
- Los loaders tienen ámbito de solicitud; añada límites de profundidad y complejidad, además de paginación, como protecciones adicionales.
Preguntas frecuentes
¿La lección «Rendimiento: N+1 y DataLoader» es gratis?
Sí — el texto completo de «Rendimiento: N+1 y DataLoader» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de PHP Academy, actualiza a CoddyKit PRO. El curso de PHP Academy incluye 4 lecciones en total.
¿Qué aprenderé en «Rendimiento: N+1 y DataLoader»?
Agrupe y almacene en caché la resolución de campos para mantener la velocidad Practicas PHP Academy con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.
¿Necesito experiencia previa para empezar PHP Academy?
No se requiere experiencia previa. PHP Academy en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 4 de 4.
¿Cuánto tiempo toma la lección «Rendimiento: N+1 y DataLoader»?
La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.
¿Puedo escribir y ejecutar código en esta lección de PHP Academy?
Sí. Cada lección de PHP Academy incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.
Todas las lecciones de este curso
- GraphQL frente a REST
- Creación de un esquema con graphql-php
- Resolvers, mutaciones y suscripciones
- Rendimiento: N+1 y DataLoader