backend / graphql
Performance et caching niveau expert
Explication
Ce que vous allez apprendre
- Mettre en cache le résultat d'un resolver coûteux dans Redis, avec une clé dépendant des arguments
- Comprendre le principe des persisted queries et leur intérêt pour la bande passante et la sécurité
- Expliquer pourquoi le cache CDN classique ne s'applique pas nativement à GraphQL (POST par défaut)
- Distinguer le batching de DataLoader (au niveau resolver) du batching de requêtes HTTP (au niveau client)
- Utiliser le tracing par champ pour localiser précisément un resolver lent
Dans quel contexte ?
Une API GraphQL en production sert des dizaines de milliers de requêtes par minute. Certains champs, comme produitsPopulaires, recalculent une agrégation coûteuse en base de données à chaque appel, alors que le résultat ne change que toutes les quelques minutes. L'équipe backend doit identifier précisément où va le temps de réponse, et réduire la charge sans sacrifier la fraîcheur des données.
D'abord, le réflexe le plus direct : mettre en cache un resolver coûteux
Un cache applicatif classique, comme Redis, s'applique très bien à un resolver précis : avant de recalculer une agrégation, on vérifie si le résultat existe déjà en cache pour cette combinaison exacte d'arguments, avec un TTL raisonnable. C'est un cache par resolver, indépendant du reste de la requête GraphQL qui l'englobe.
Une fois ce cache posé, un problème plus large de bande passante apparaît
Chaque requête GraphQL envoie son texte complet à chaque appel, ce qui peut représenter plusieurs kilo-octets répétés à chaque requête, en plus des variables. Les persisted queries inversent le problème : le client envoie d'abord un simple hash du texte de la requête, et le serveur, s'il connaît déjà ce hash, exécute directement la requête associée sans que le texte complet ne retraverse le réseau.
| Approche | Résout | Limite |
|---|---|---|
| Cache Redis par resolver | Recalcul coûteux répété entre requêtes différentes | Ne réduit pas la bande passante réseau côté client |
| Persisted queries | Bande passante, et permet une allowlist stricte en production | Nécessite une étape d'enregistrement des requêtes connues |
| DataLoader | N+1 au sein d'une seule requête | Ne résout rien entre deux requêtes différentes |
Ensuite, une limitation structurelle à bien comprendre : le cache CDN classique ne fonctionne pas nativement
Un CDN met en cache des réponses GET en se basant sur l'URL. GraphQL utilise POST par défaut, où le texte de la requête est dans le corps, invisible pour un cache HTTP classique. Combiner les persisted queries avec une requête en GET (l'URL contient alors le hash) permet de retrouver un cache CDN traditionnel, mais demande un effort d'architecture supplémentaire.
Il reste une distinction subtile à ne pas confondre : deux formes de "batching"
DataLoader regroupe des appels .load(id) internes au serveur, pendant l'exécution d'une seule requête, pour résoudre le N+1 vu plus tôt dans ce cours. Le batching de requêtes HTTP, lui, est différent : il regroupe plusieurs opérations GraphQL distinctes, envoyées par le client, en un seul aller-retour réseau — un tableau d'opérations en entrée, un tableau de réponses en sortie, exécutées en parallèle côté serveur.
Prérequis
Cette leçon suppose une bonne maîtrise de DataLoader (leçon 10) : elle en distingue explicitement le principe d'un autre type de batching, au niveau du transport plutôt que du resolver.
Bonne pratique
Instrumente ton serveur GraphQL avec un tracing par champ (Apollo Tracing ou OpenTelemetry) dès la mise en production. Sur une requête qui traverse dix resolvers imbriqués, seul un tracing précis permet de savoir lequel des dix est réellement responsable d'une latence anormale, plutôt que de deviner à l'aveugle.
Piège fréquent
Activer un cache Redis avec un TTL trop long sur un champ qui affiche des données sensibles au temps (stock, prix) peut afficher des informations obsolètes aux utilisateurs après un changement en base. Le choix du TTL est toujours un compromis entre fraîcheur des données et charge évitée sur la base de données : documente ce choix, ne le laisse jamais implicite.
Ce cours GraphQL est maintenant complet : du pourquoi face à REST jusqu'à la performance niveau expert, en passant par les resolvers, la sécurité et la fédération de schémas. La suite logique est de mettre ces briques en pratique sur un vrai projet, en commençant petit puis en introduisant DataLoader, la pagination cursor-based et la sécurité au fur et à mesure que le besoin réel se présente.
Commandes & code
Performance et caching niveau expert
# Cache au niveau du resolver, avec clé dépendant des arguments (comme n'importe quel cache applicatif)
import strawberry
from functools import lru_cache
import hashlib, json
async def cache_resolver(cle: str, ttl: int, calcul):
valeur = await redis.get(cle)
if valeur is not None:
return json.loads(valeur)
resultat = await calcul()
await redis.set(cle, json.dumps(resultat), ex=ttl)
return resultat
@strawberry.type
class Query:
@strawberry.field
async def produits_populaires(self, categorie_id: strawberry.ID) -> list[Produit]:
cle = f"produits_populaires:{categorie_id}"
return await cache_resolver(cle, ttl=300, calcul=lambda: db.produits_populaires(categorie_id))# Persisted queries : le client envoie un hash au lieu du texte complet de la requête
# -> réduit la bande passante et permet une allowlist stricte des requêtes autorisées en prod// Automatic Persisted Queries (Apollo) : le client envoie d'abord le hash, puis le texte si inconnu
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const persistedQueriesLink = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({
link: persistedQueriesLink.concat(httpLink),
cache: new InMemoryCache(),
});# Response caching HTTP : GraphQL utilise POST par défaut donc pas de cache CDN natif
# Solution : GET pour les requêtes persistées + en-têtes cache-control par champ
import strawberry
from strawberry.extensions import Extension
class CacheControlExtension(Extension):
def on_execute(self):
yield
# Chaque type peut déclarer son propre TTL de cache, le plus restrictif l'emporte
max_age = min(
(t.get("cacheControl", {}).get("maxAge", 0) for t in self.execution_context.result.data.values()),
default=0,
)
self.execution_context.context["response"].headers["Cache-Control"] = f"public, max-age={max_age}"# Batching de requêtes HTTP : regrouper plusieurs opérations GraphQL en un seul aller-retour réseau
# (distinct du DataLoader, qui batch des RESOLVERS, ici on batch des REQUÊTES clientes)
import strawberry
@strawberry.type
class Query:
@strawberry.field
async def produit(self, id: strawberry.ID) -> Produit: ...
# Le endpoint accepte un tableau d'opérations dans le corps de la requête POST
# [{ "query": "...", "variables": {...} }, { "query": "...", "variables": {...} }]
# et retourne un tableau de réponses correspondant, exécutées en parallèle côté serveurChecklist performance niveau expert :
| Levier | Effet |
|------------------------------|-------------------------------------------------------------|
| DataLoader | élimine le N+1 au niveau resolver |
| Cache Redis par resolver | évite de recalculer des agrégations coûteuses |
| Persisted queries | réduit la bande passante, permet une allowlist stricte |
| Limitation profondeur/coût | protège contre les requêtes pathologiques |
| Tracing (Apollo/OpenTelemetry) | identifie précisément quel resolver ralentit une requête |Résumé
- DataLoader résout le N+1 par requête ; un cache Redis par resolver évite le recalcul entre requêtes différentes.
- Les persisted queries réduisent la bande passante et permettent une allowlist stricte des opérations en production.
- GraphQL utilisant POST par défaut, le cache CDN classique ne s'applique pas sans travail supplémentaire (GET + hash).
- Le tracing par champ (Apollo Tracing, OpenTelemetry) est indispensable pour localiser un resolver lent dans une requête complexe.
Exercices pratiques
Mission : réduire la charge d'une API GraphQL à fort trafic sans perdre en fraîcheur
Objectif : Choisir et implémenter la bonne combinaison de leviers de performance pour un champ coûteux, en distinguant les deux formes de batching du cours.
Contexte
Le champ produitsPopulaires recalcule une agrégation coûteuse en base de données à chaque appel, alors que le résultat ne change que toutes les quelques minutes, sur une API qui sert des dizaines de milliers de requêtes par minute. Le tracing montre que ce seul champ représente la majorité du temps de réponse total de l'API.