Retour au cours

backend / graphql

Performance et caching niveau expert

Leçon 161 exercice

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.

ApprocheRésoutLimite
Cache Redis par resolverRecalcul coûteux répété entre requêtes différentesNe réduit pas la bande passante réseau côté client
Persisted queriesBande passante, et permet une allowlist stricte en productionNécessite une étape d'enregistrement des requêtes connues
DataLoaderN+1 au sein d'une seule requêteNe 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

python
# 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))
graphql
# 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
javascript
// 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(),
});
python
# 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}"
python
# 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é serveur
text
Checklist 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

1 disponible
1

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.

Résoudre l’exercice →