Anaïs Sparesotto
SQL · AvancéIntermédiaire≈ 2h · 7 chapitres

SQL Avancé : optimisation des bases de données

Jointures avancées, CTE récursives, fonctions de fenêtrage, index, EXPLAIN, isolation transactionnelle. Pour écrire des requêtes qui tiennent à l'échelle.

À la fin du cours, tu sais

  • Maîtriser les jointures (LEFT, FULL, LATERAL) et les anti-jointures
  • Utiliser CTE et CTE récursives pour des requêtes lisibles
  • Exploiter les fonctions de fenêtrage (ROW_NUMBER, LAG, cumul)
  • Lire un plan EXPLAIN ANALYZE pour diagnostiquer une requête lente
  • Choisir le bon index (B-tree, partiel, composé, GIN)
  • Comprendre les niveaux d'isolation et leurs anomalies

Prérequis

  • Connaître les bases SQL (SELECT, INSERT, UPDATE, JOIN simple)
  • Avoir accès à une base PostgreSQL ou MySQL pour tester

Chapitre 1

Jointures avancées

Tu connais INNER JOIN. On attaque les cas réels : données manquantes, anti-jointures, jointures latérales.

Anti-jointure : trouver ce qui manque

-- Clients sans aucune commande (anti-jointure)
SELECT c.id, c.nom
FROM clients c
LEFT JOIN commandes o ON o.client_id = c.id
WHERE o.id IS NULL;

-- Variante avec NOT EXISTS (souvent plus rapide)
SELECT c.id, c.nom
FROM clients c
WHERE NOT EXISTS (
  SELECT 1 FROM commandes o WHERE o.client_id = c.id
);

NOT IN avec NULL : piège classique

NOT IN (sous-requête) retourne 0 ligne dès qu'un NULL apparaît dans la sous-requête, à cause de la logique trivaluée SQL. Préfère NOT EXISTS, toujours.

FULL OUTER JOIN

Garde toutes les lignes des deux côtés, NULL où il n'y a pas de correspondance. Utile pour réconcilier deux référentiels.

SELECT COALESCE(a.id, b.id) AS id, a.donnee_source, b.donnee_cible
FROM source a
FULL OUTER JOIN cible b ON a.id = b.id;

LATERAL : sous-requête corrélée performante (Postgres)

-- Pour chaque client, les 3 dernières commandes
SELECT c.id, c.nom, d.id AS commande_id, d.total
FROM clients c
LEFT JOIN LATERAL (
  SELECT id, total
  FROM commandes
  WHERE client_id = c.id
  ORDER BY cree_le DESC
  LIMIT 3
) d ON true;

Chapitre 2

CTE : sous-requêtes lisibles et récursives

Les Common Table Expressions (CTE) clarifient les requêtes complexes et permettent la récursion sur des arborescences.

CTE simple

WITH top_clients AS (
  SELECT client_id, SUM(total) AS ca
  FROM commandes
  WHERE cree_le >= '2026-01-01'
  GROUP BY client_id
  ORDER BY ca DESC
  LIMIT 10
)
SELECT c.nom, t.ca
FROM top_clients t
JOIN clients c ON c.id = t.client_id;

CTE inlinées par défaut depuis Postgres 12

Avant, les CTE étaient toujours matérialisées (calculées une fois, stockées). Aujourd'hui, Postgres les inline par défaut (optimisation possible). Pour forcer la matérialisation : WITH x AS MATERIALIZED (...).

CTE récursive : parcourir une arborescence

-- Catégories en arborescence : parent_id pointe vers la catégorie parent
WITH RECURSIVE arbre AS (
  -- Cas de base : les racines (sans parent)
  SELECT id, parent_id, nom, 1 AS niveau
  FROM categories
  WHERE parent_id IS NULL

  UNION ALL

  -- Récursion : on prend les enfants des nœuds déjà trouvés
  SELECT c.id, c.parent_id, c.nom, a.niveau + 1
  FROM categories c
  JOIN arbre a ON c.parent_id = a.id
)
SELECT * FROM arbre ORDER BY niveau, nom;

Chapitre 3

Fonctions de fenêtrage

Calculer un classement, une moyenne glissante, un cumul, sans écraser les lignes. Pouvoir énorme, syntaxe à apprivoiser.

ROW_NUMBER, RANK, DENSE_RANK

-- Classement des commandes par client
SELECT
  client_id,
  id,
  total,
  ROW_NUMBER() OVER (PARTITION BY client_id ORDER BY total DESC) AS rang
FROM commandes;
  • ROW_NUMBER : numéro séquentiel unique (1, 2, 3, 4...)
  • RANK : ex-aequo ont le même rang, sauts ensuite (1, 2, 2, 4)
  • DENSE_RANK : ex-aequo ont le même rang, sans sauts (1, 2, 2, 3)

LAG et LEAD : valeur précédente, suivante

-- Évolution du CA mois par mois
SELECT
  mois,
  ca,
  LAG(ca) OVER (ORDER BY mois) AS ca_mois_precedent,
  ca - LAG(ca) OVER (ORDER BY mois) AS variation
FROM ca_mensuel;

Cumul et moyenne mobile

-- Cumul du CA depuis le début de l'année
SELECT
  jour,
  ca_jour,
  SUM(ca_jour) OVER (ORDER BY jour) AS ca_cumule
FROM ca_journalier;

-- Moyenne glissante sur 7 jours
SELECT
  jour,
  ca_jour,
  AVG(ca_jour) OVER (
    ORDER BY jour
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS moyenne_7j
FROM ca_journalier;

Chapitre 4

GROUP BY avancé

Aller au-delà du COUNT(*) : agrégats multidimensionnels et conditionnels.

GROUPING SETS, ROLLUP, CUBE

-- Sous-totaux par région ET par produit ET total général, en une passe
SELECT region, produit, SUM(ca) AS ca
FROM ventes
GROUP BY ROLLUP (region, produit);

-- Toutes les combinaisons (region, produit, region+produit, total)
SELECT region, produit, SUM(ca) AS ca
FROM ventes
GROUP BY CUBE (region, produit);

Agrégats conditionnels avec FILTER (Postgres)

SELECT
  region,
  COUNT(*) FILTER (WHERE statut = 'PAYEE') AS payees,
  COUNT(*) FILTER (WHERE statut = 'ANNULEE') AS annulees,
  SUM(total) FILTER (WHERE statut = 'PAYEE') AS ca_paye
FROM commandes
GROUP BY region;

FILTER plutôt que CASE

Avant FILTER, on écrivait SUM(CASE WHEN ... THEN total END). C'est plus verbeux et moins lisible. FILTER est l'équivalent moderne et propre.

Chapitre 5

Indexation : le levier numéro un

Un bon index transforme une requête de 30 secondes en 3 ms. Mais un mauvais index ralentit toutes tes écritures.

Les types d'index utiles

  • B-tree : défaut, pour égalité et plages (=, <, BETWEEN, ORDER BY)
  • Index composé : sur plusieurs colonnes, respecte l'ordre du WHERE
  • Index partiel : ne couvre qu'une partie des lignes (ex : WHERE actif = true)
  • GIN (Postgres) : pour jsonb, tableaux, recherche plein-texte
  • BRIN (Postgres) : pour grosses tables triées (ex : logs par date)

Index composé : ordre des colonnes

-- Cas typique : on filtre par client et on trie par date
CREATE INDEX idx_cmd_client_date
  ON commandes (client_id, cree_le DESC);

-- Cet index accélère :
SELECT * FROM commandes WHERE client_id = 42 ORDER BY cree_le DESC;
-- ET aussi : WHERE client_id = 42 (le préfixe gauche suffit)

-- Mais PAS : WHERE cree_le > '2026-01-01' tout seul
-- (la colonne client_id manque en préfixe)

Index partiel

-- Si 99% des commandes sont 'PAYEE', un index global est inutile.
-- Mais on cherche souvent les 1% qui restent :
CREATE INDEX idx_cmd_pending
  ON commandes (cree_le)
  WHERE statut = 'EN_ATTENTE';

-- Index minuscule, ultra rapide sur les requêtes filtrées par statut.

Coût des index

Chaque index doit être mis à jour à chaque INSERT ou UPDATE de la colonne indexée. Sur une table qui écrit beaucoup, 10 index = 10× plus lent à l'écriture. Ne crée que les index dont tu as la preuve qu'ils servent (via EXPLAIN).

Chapitre 6

EXPLAIN ANALYZE : lire un plan

Diagnostiquer une requête lente, c'est lire son plan d'exécution. Une compétence qui vaut de l'or.

EXPLAIN vs EXPLAIN ANALYZE

  • EXPLAIN requête : plan estimé, sans exécution
  • EXPLAIN ANALYZE requête : plan + exécution réelle + timings
  • EXPLAIN (ANALYZE, BUFFERS) requête : ajoute le détail des pages lues
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM commandes
WHERE client_id = 42 AND cree_le > NOW() - INTERVAL '30 days';

Ce qu'il faut repérer

  • Seq Scan sur grosse table : index manquant, presque toujours à corriger
  • Index Scan : bon signe (le moteur utilise un index)
  • Bitmap Heap Scan : convenable pour des filtres qui ramènent beaucoup de lignes
  • Écart entre rows estimé et rows réel : statistiques à rafraîchir avec ANALYZE table;
  • BUFFERS : shared read = lectures disque (lent). shared hit = cache (rapide).

ANALYZE sur UPDATE/DELETE

EXPLAIN ANALYZE exécute vraiment la requête. Si c'est un UPDATE, ça modifie tes données. Encadre avec BEGIN; ... ROLLBACK; pour tester sans rien casser.

Chapitre 7

Transactions, isolation, concurrence

Quand plusieurs sessions écrivent en même temps, des anomalies peuvent apparaître. Comprendre les niveaux d'isolation, c'est éviter les bugs invisibles.

ACID : les 4 garanties

  • Atomicité : tout passe ou rien
  • Cohérence : la base reste valide (contraintes respectées)
  • Isolation : les transactions concurrentes ne se voient pas l'une l'autre
  • Durabilité : un commit survit à un crash

Les niveaux d'isolation

  • READ COMMITTED (défaut Postgres) : tu lis toujours des données committées
  • REPEATABLE READ (défaut MySQL) : deux lectures successives renvoient le même résultat dans la transaction
  • SERIALIZABLE : comme si les transactions s'exécutaient en série, sans concurrence

Verrouillage explicite : SELECT FOR UPDATE

BEGIN;
  -- Verrouille la ligne jusqu'à COMMIT/ROLLBACK
  SELECT solde FROM comptes WHERE id = 1 FOR UPDATE;
  -- Calcul...
  UPDATE comptes SET solde = solde - 100 WHERE id = 1;
COMMIT;

Write skew : le piège du READ COMMITTED

Imagine deux médecins vérifient simultanément qu'ils ne sont pas le seul de garde, puis se déclarent absents. Les deux lectures sont valides au moment t, mais le résultat final viole la règle métier. Solution : SERIALIZABLE ou verrouillage explicite.

🛠️ Exercice optionnel

Optimiser une requête lente

Tu travailles sur une base e-commerce : clients (500 k lignes), commandes (10 M lignes), lignes_commande (50 M lignes). Cette requête met 45 secondes en prod et ralentit toute l'app. À toi de la passer sous 2 secondes.

Ta mission

La requête fautive :

SELECT c.nom, COUNT(*) AS nb, SUM(lc.prix * lc.qte) AS ca
FROM clients c, commandes o, lignes_commande lc
WHERE c.id = o.client_id AND lc.commande_id = o.id
  AND o.cree_le > '2026-01-01' AND c.pays = 'FR'
GROUP BY c.nom ORDER BY ca DESC LIMIT 20;
  1. Lance EXPLAIN (ANALYZE, BUFFERS) pour identifier les Seq Scans.
  2. Réécris la requête avec des JOIN ... ON explicites (plus lisible, même résultat).
  3. Crée les index nécessaires pour transformer les Seq Scans en Index Scans.
  4. Lance à nouveau EXPLAIN ANALYZE pour comparer le temps.

Tu bloques ? Des indices, à dévoiler quand tu en as besoin.

Indice 1

Indice masqué.

Indice 2

Indice masqué.

Indice 3

Indice masqué.

✅ QCM de fin de cours

Teste tes acquis

10 questions, plusieurs réponses parfois possibles. Coche tout ce qui te semble juste, puis valide pour voir ton score et les explications.

  1. 1

    NOT IN (sous-requête) retourne souvent zéro ligne quand la sous-requête contient des NULL. Pourquoi ?

  2. 2

    ROW_NUMBER() et RANK() donnent-ils le même résultat sur des ex-aequo ?

  3. 3

    Un index sur (a, b) accélère-t-il un WHERE b = ? seul ?

  4. 4

    Pourquoi un index partiel peut-il être plus utile qu'un index complet ?

  5. 5

    EXPLAIN ANALYZE exécute vraiment la requête. Quel est le risque sur un UPDATE ?

  6. 6

    Quel est le niveau d'isolation par défaut en PostgreSQL ?

  7. 7

    Quel index est recommandé pour la recherche plein-texte sur un tsvector en Postgres ?

  8. 8

    SUM(montant) OVER (PARTITION BY client_id) renvoie combien de lignes ?

  9. 9

    Sur une grosse table triée naturellement (par exemple logs insérés en flux par created_at), quel type d'index Postgres est idéal ?

  10. 10

    FILTER (WHERE ...) remplace avantageusement quelle écriture ?

Tu peux laisser des questions sans réponse, elles compteront comme fausses.

Tu veux ce cours pour ton équipe ?

Je peux adapter et animer ce cours pour tes formateur·ices ou tes apprenant·es, en présentiel ou en distanciel. Parlons-en pendant l'audit gratuit.

Réserver un audit gratuit →