backend / fastapi
Performance — async et connection pooling
Explication
Ce que vous allez apprendre
- Comprendre dans quels cas
asyncapporte réellement un gain de performance - Paralléliser plusieurs appels réseau indépendants avec
asyncio.gather - Réutiliser un client HTTP et un pool de connexions plutôt que d'en recréer à chaque requête
- Dimensionner correctement un pool de connexions base de données
- Choisir entre un cache local et un cache partagé (Redis) selon le nombre d'instances
Dans quel contexte ?
L'endpoint GET /dashboard de app/routers/dashboard.py met 900ms à répondre alors qu'aucun de ses trois appels réseau internes (commandes, statistiques, notifications) ne dépasse 300ms individuellement. En inspectant le code, les trois appels httpx.get(...) sont enchaînés avec des await successifs plutôt qu'exécutés en parallèle. Remplacer cette séquence par un seul asyncio.gather(orders_task, stats_task, notifications_task) fait chuter le temps de réponse à environ 300ms, le temps du plus lent des trois appels.
Une idée reçue à corriger d'abord
Déclarer une route async def ne rend pas automatiquement une application plus rapide. L'asynchrone n'apporte un gain que lorsqu'il y a du temps d'ATTENTE à exploiter, pendant lequel le serveur peut s'occuper d'autre chose.
Cette leçon montre comment exploiter concrètement ce potentiel, au-delà de la simple syntaxe async/await déjà vue. Commençons par une première technique : paralléliser plutôt qu'enchaîner.
Quand une réponse dépend de plusieurs appels réseau INDÉPENDANTS les uns des autres, les enchaîner un par un additionne inutilement leurs temps de réponse. Récupérer les commandes, les statistiques et les notifications d'un utilisateur en est un bon exemple.
asyncio.gather les lance tous EN MÊME TEMPS et attend leur ensemble. Ça réduit le temps total au plus lent des trois appels, plutôt qu'à leur somme.
Une fois les appels parallélisés, une deuxième technique s'impose : réutiliser les connexions plutôt que les recréer. Ouvrir une nouvelle connexion réseau a un coût non négligeable, entre négociation TCP et parfois TLS.
Un client HTTP ou un pool de connexions DB partagé et réutilisé entre les requêtes évite de payer ce coût à chaque appel. Contrairement à la création d'un nouveau client à chaque requête, qui répète cette négociation inutilement.
Une fois ce pool en place, il reste un équilibre à trouver dans son dimensionnement. Un pool trop petit fait attendre des requêtes en file, un goulot d'étranglement réel.
Un pool trop grand, à l'inverse, surcharge inutilement la base de données de connexions ouvertes mais peu utilisées. Le bon dimensionnement dépend de la concurrence réelle attendue, pas d'un chiffre arbitraire.
Il reste un dernier levier à connaître : le cache. Recalculer ou re-récupérer une donnée coûteuse mais peu volatile à chaque requête est souvent inutile.
| Technique | Gain | Coût si mal dimensionné |
|---|---|---|
asyncio.gather | Temps = le plus lent des appels, pas leur somme | Aucun (à utiliser dès que possible) |
| Client HTTP/pool partagé | Évite une négociation TCP/TLS par requête | Fuite mémoire si jamais fermé |
| Pool de connexions DB | Réutilise des connexions déjà ouvertes | Goulot (trop petit) ou surcharge DB (trop grand) |
| Cache (local ou Redis) | Évite un recalcul/fetch coûteux répété | Données obsolètes si mal invalidé |
Piège fréquent
Un cache mémoire local (simple dict Python) suffit sur une seule instance, mais dès que l'application tourne sur plusieurs instances (plusieurs workers Uvicorn, plusieurs conteneurs), un cache partagé comme Redis devient nécessaire pour que toutes les instances voient la même donnée.
Commandes & code
Performance — async et connection pooling
# Paralléliser des appels I/O indépendants avec asyncio.gather
import asyncio
import httpx
async def get_dashboard_data(user_id: int):
async with httpx.AsyncClient() as client:
# les 3 appels partent EN MÊME TEMPS, pas l'un après l'autre
orders_task = client.get(f"https://api.example.com/users/{user_id}/orders")
stats_task = client.get(f"https://api.example.com/users/{user_id}/stats")
notifications_task = client.get(f"https://api.example.com/users/{user_id}/notifications")
orders, stats, notifications = await asyncio.gather(orders_task, stats_task, notifications_task)
return {
"orders": orders.json(),
"stats": stats.json(),
"notifications": notifications.json(),
}# Connection pooling HTTP réutilisable — un seul client partagé, pas un par requête
from contextlib import asynccontextmanager
http_client: httpx.AsyncClient | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global http_client
http_client = httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
timeout=httpx.Timeout(10.0, connect=5.0),
)
yield
await http_client.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/proxy/{path:path}")
async def proxy(path: str):
response = await http_client.get(f"https://api.example.com/{path}")
return response.json()# Pool de connexions DB dimensionné correctement — sous-dimensionné = requêtes en attente
engine = create_async_engine(
DATABASE_URL,
pool_size=20, # connexions permanentes : ~ (nb workers * concurrence attendue)
max_overflow=10, # marge temporaire sous pic de charge
pool_timeout=30, # délai d'attente avant erreur si le pool est saturé
pool_recycle=1800, # recycle les connexions après 30 min (évite les timeouts DB/firewall)
)# Cache applicatif — éviter de recalculer/re-fetcher des données coûteuses et peu volatiles
from functools import lru_cache
import time
_cache: dict[str, tuple[float, Any]] = {}
async def get_cached_or_fetch(key: str, ttl_seconds: int, fetch_fn):
now = time.time()
if key in _cache:
cached_at, value = _cache[key]
if now - cached_at < ttl_seconds:
return value
value = await fetch_fn()
_cache[key] = (now, value)
return value
@app.get("/expensive-report")
async def expensive_report():
return await get_cached_or_fetch("report", ttl_seconds=300, fetch_fn=compute_heavy_report)# Cache Redis pour un environnement multi-instances (le cache mémoire local n'est PAS partagé)
import redis.asyncio as redis
import json
redis_client = redis.from_url("redis://localhost:6379")
async def get_product_cached(product_id: int, db: AsyncSession) -> dict:
cache_key = f"product:{product_id}"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
product = await db.get(Product, product_id)
data = {"id": product.id, "name": product.name, "price": product.price}
await redis_client.set(cache_key, json.dumps(data), ex=300) # expire après 5 minutes
return data# Lancer plusieurs workers Uvicorn pour exploiter tous les cœurs CPU
uvicorn app.main:app --workers 4 --host 0.0.0.0 --port 8000Résumé
asyncio.gatherparallélise des appels I/O indépendants au lieu de les enchaîner un par un.- Un client HTTP partagé (avec pool de connexions) évite le coût d'établir une nouvelle connexion TCP par requête sortante.
- Le pool de connexions DB doit être dimensionné selon la concurrence réelle attendue, ni trop petit (goulot), ni trop grand (surcharge DB).
- Un cache local (dict/
lru_cache) suffit sur une seule instance ; un cache partagé (Redis) est requis en multi-instances.
Exercices pratiques
Mission : le dashboard qui redevient lent sous charge malgré asyncio.gather
Objectif : Diagnostiquer un goulot d'étranglement sur le pool de connexions DB et une réutilisation manquante du client HTTP, au-delà du simple gain apporté par asyncio.gather.
Contexte
GET /dashboard a déjà été corrigé avec asyncio.gather comme vu dans cette leçon, et répond en 300ms avec quelques utilisateurs. Lors d'un test de charge à 50 utilisateurs simultanés, les temps de réponse remontent à plus de 2 secondes. Le monitoring montre que le temps est désormais passé à attendre une connexion disponible dans le pool SQLAlchemy, configuré avec pool_size=5 et max_overflow=0.