Performances : N+1 et DataLoader
Regroupez et mettez en cache la résolution des champs pour rester rapide.
Performances : N+1 et DataLoader est une leçon PHP Academy gratuite sur CoddyKit. Ceci est la leçon 4 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage PHP Academy, et ta progression se synchronise sur le web et l'application CoddyKit. Le cours PHP Academy comprend 4 leçons au total.
Le tueur silencieux : N+1
Le plus grand piège de performance de GraphQL est le problème des requêtes N+1. Il est silencieux, car chaque résolveur semble inoffensif pris isolément — mais si vous imbriquez une liste et un champ par élément, vous exécutez une requête pour la liste, plus une requête par élément. Avec 100 éléments, cela représente 101 allers-retours. Cette leçon montre comment les réduire à quelques requêtes groupées avec DataLoader.
Voir concrètement le N+1
Considérez { posts { author { name } } }. Le résolveur de posts exécute une requête. Ensuite, pour chaque publication, le résolveur de author exécute sa propre requête. Le code naïf ci-dessous rend le coût évident.
<?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
L'idée clé : regrouper par niveau
GraphQL effectue la résolution niveau par niveau. Tous les résolveurs de author pour la liste de publications s'exécutent lors du même cycle d'exécution. Si nous pouvons différer chaque recherche d'auteur, collecter les identifiants demandés, puis exécuter une seule requête WHERE id IN (...), nous transformons N requêtes en une seule. C'est précisément ce que fournit 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 tampon/chargeur minimal
Voici l'essentiel d'un DataLoader : un tampon qui accumule les identifiants, les charge une seule fois au moyen d'une fonction de traitement par lots, puis fournit les résultats depuis un cache. Une même clé demandée deux fois n'est chargée qu'une fois — déduplication automatique.
<?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; }
}
L'intégrer à un résolveur
Le résolveur de author met l'identifiant en file d'attente et renvoie un Deferred. graphql-php exécute tous les différés après le niveau actuel ; lorsque la fermeture est exécutée, tous les identifiants d'auteur de la liste entière ont donc été mis en file d'attente. Un seul loadOnce() déclenche une requête groupée unique.
<?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']);
});
},
];
Utiliser la bibliothèque overblog/dataloader
Vous écrivez rarement cela vous-même. overblog/dataloader-php est le port établi du DataLoader de Facebook. Vous lui fournissez une fonction de traitement par lots qui reçoit un tableau de clés et doit renvoyer une promesse de valeurs dans le même ordre. Elle gère la mise en cache, la déduplication et la résolution des promesses.
composer require overblog/dataloader-phpConstruire un DataLoader
Le contrat de la fonction de traitement par lots est strict : étant donné [k1, k2, k3], elle doit se résoudre en [v1, v2, v3] dans le même ordre. Indexez les lignes de la base de données par clé et rétablissez l'ordre d'entrée lors du mappage, afin que les clés absentes deviennent 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);
Résoudre via le chargeur
Dans le résolveur, appelez simplement $loader->load($id), qui renvoie une promesse. graphql-php, par l'intermédiaire de l'adaptateur, rassemble ces promesses et déclenche automatiquement le traitement par lots à la fin du cycle. Aucun tamponnage manuel.
<?php
$authorField = [
'type' => $userType,
'resolve' => fn($post, $args, $context) =>
$context['userLoader']->load($post['author_id']),
];
La durée de vie par requête est essentielle
DataLoaders mettent les valeurs en cache par clé et doivent donc être créés à neuf pour chaque requête. Un chargeur partagé entre plusieurs requêtes fournirait des données obsolètes et provoquerait une fuite de mémoire. Construisez les chargeurs lors de l'assemblage du contexte de requête $context, puis supprimez-les lorsque la requête se 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),
];
}
Autres protections de performance
DataLoader corrige les lectures N+1, mais une requête malveillante ou imprudente peut toujours vous nuire. Ajoutez notamment :
- Limitation de la profondeur des requêtes — la règle
QueryDepthrejette les requêtes excessivement imbriquées. - Complexité des requêtes —
QueryComplexityattribue un budget de coût à chaque champ. - Requêtes persistantes — n'autorisez qu'une liste blanche connue d'opérations.
- Pagination — ne résolvez jamais de listes sans limite ; utilisez des connexions par curseur.
<?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));
Mesurer le gain
Quantifiez toujours l'amélioration. Encadrez votre couche d'accès à la base de données pour compter les requêtes dans un test, puis vérifiez que la version groupée émet un nombre limité de requêtes, quelle que soit la taille de la liste. Cela vous protège contre les régressions lorsqu'une personne ajoute un résolveur imbriqué naïf et réintroduit silencieusement le problème N+1.
<?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
Vérification rapide
Pourquoi les DataLoaders doivent-ils être créés pour chaque requête ?
Récapitulatif
Vous avez éliminé le pire piège de performance de GraphQL :
- Les champs de listes imbriqués provoquent le problème N+1 : une requête par élément.
- graphql-php effectue la résolution niveau par niveau ; différer les recherches permet donc de regrouper toutes les clés dans une seule requête
IN (...). GraphQL\Deferredest le mécanisme de base ;overblog/dataloader-phpregroupe le traitement par lots, la mise en cache par clé et la déduplication.- La fonction de traitement par lots doit renvoyer les valeurs dans le même ordre que les clés d'entrée.
- Les chargeurs sont propres à chaque requête ; ajoutez des limites de profondeur et de complexité ainsi que la pagination comme protections supplémentaires.
Questions Fréquemment Posées
La leçon « Performances : N+1 et DataLoader » est-elle gratuite ?
Oui — le texte complet de « Performances : N+1 et DataLoader » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours PHP Academy, passe à CoddyKit PRO. Le cours PHP Academy comprend 4 leçons au total.
Qu'est-ce que j'apprendrai dans « Performances : N+1 et DataLoader » ?
Regroupez et mettez en cache la résolution des champs pour rester rapide. Tu pratiques PHP Academy avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.
Dois-je avoir de l'expérience pour commencer PHP Academy ?
Aucune expérience préalable n'est requise. PHP Academy sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 4 sur 4.
Combien de temps prend la leçon « Performances : N+1 et DataLoader » ?
La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.
Peux-tu écrire et exécuter du code dans cette leçon PHP Academy ?
Oui. Chaque leçon PHP Academy inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.
Toutes les leçons de ce cours
- GraphQL ou REST
- Créer un schéma avec graphql-php
- Résolveurs, mutations et abonnements
- Performances : N+1 et DataLoader