Interroger et optimiser
Ce que ce chapitre apporte 5 points
- Traduire une demande formulée en français en une requête sur un schéma en étoile.
- Écrire une requête joignant plusieurs tables, avec agrégation et filtres au bon endroit.
- Nommer les opérations d'analyse multidimensionnelle et les reconnaître dans une demande.
- Expliquer ce qui rend une requête lente sur de gros volumes.
- Choisir une clé de partitionnement et un regroupement par hachage, et dire ce que chacun apporte.
Une demande métier arrive en français : « le chiffre d'affaires par famille de produits et par trimestre, hors retours, sur les magasins de plus de mille mètres carrés ». La traduire en requête est mécanique dès que le modèle est dimensionnel, et c'est le premier objet de ce chapitre. Le second est ce qui décide du temps de réponse, et qui n'est presque jamais l'écriture de la requête : c'est la quantité de données qu'elle est obligée de lire.
Un entrepôt bien modélisé rend les requêtes prévisibles : elles ont toutes la même forme, une table de faits jointe à quelques dimensions, filtrée puis regroupée. Cette régularité est ce qui permet à un métier d'écrire ses propres analyses, et à un moteur de les optimiser.
Elle ne suffit pas. Une requête impeccable sur cinq milliards de lignes reste lente si rien n'a été prévu pour lui éviter de les lire toutes.
Traduire une demande en requête
Quatre questions, dans cet ordre, et la requête s'écrit toute seule. Quelle mesure ? Ce qu'on additionne ou compte : elle vient de la table de faits. Une moyenne s'en déduit, à partir d'une somme et d'un compte.
À quel grain de restitution ? Les mots qui suivent « par ». Ils deviennent le GROUP BY et les colonnes de dimension à joindre.
Quels filtres, et sur quoi portent-ils ? Un filtre sur une dimension va dans le WHERE. Un filtre sur la mesure agrégée va dans le HAVING, et nulle part ailleurs.
Quel tri, quelle limite ? Souvent implicites dans la demande, et toujours utiles à expliciter.
Le bloc suivant construit un petit entrepôt en étoile et pose la question de l'introduction. Il s'exécute dans la page, et se modifie.
La forme est toujours la même : la table de faits, autant de jointures que de dimensions citées, un WHERE qui filtre sur les dimensions, un GROUP BY qui reprend exactement les colonnes affichées non agrégées. C'est le SQL du chapitre sur les agrégats, les sous-requêtes et les vues, sans une clause de plus : ce qui change ici n'est pas le langage, c'est le volume auquel il s'applique et la façon dont les données sont rangées en dessous.
WHERE s'applique aux lignes avant le regroupement. HAVING s'applique aux groupes après.
Filtrer les retours dans le HAVING ne les retire pas des sommes. Écrit HAVING SUM(f.estRetour) = 0, il écarte tout groupe qui contient un retour, ventes comprises : dans le bloc ci-dessus, la ligne Peripherique du deuxième trimestre disparaît, et avec elle 378 de ventes réelles. Écrit HAVING f.estRetour = 0, il est refusé par PostgreSQL comme par MySQL, la colonne n'étant ni regroupée ni agrégée. SQLite, le moteur de la page, l'accepte et juge chaque groupe sur une ligne arbitraire du groupe : ici, aucun groupe n'est écarté, et la même ligne passe de 378 à 456, retour compris.
Filtrer un montant agrégé dans le WHERE est en revanche impossible, puisque l'agrégat n'existe pas encore : SQLite répond misuse of aggregate.
La règle est mécanique : filtre sur une valeur de ligne, donc WHERE ; filtre sur un résultat d'agrégation, donc HAVING.
Le schéma en étoile garantit qu'une clé de fait désigne une et une seule ligne de dimension, à condition de joindre sur la clé de substitution. La garantie tombe dès qu'une requête joint sur la clé naturelle une dimension qui contient plusieurs versions d'une même entité, comme la dimension historisée du chapitre 7.
Chaque fait se duplique alors autant de fois qu'il existe de versions, et les sommes sont multipliées d'autant. Le résultat reste plausible, seulement trop grand.
Le contrôle qui l'attrape en dix secondes : comparer COUNT(*) avant et après la jointure. S'il a augmenté, la jointure n'est pas de un vers un, et tout agrégat qui en découle est faux. S'il a diminué, des faits sans ligne de dimension ont disparu de la jointure, et les sommes sont trop petites.
Le même gonflement attend toute jointure entre deux tables de grains différents, sans aucune historisation : les ventes du jour jointes à l'effectif du mois répètent l'effectif d'un magasin autant de fois que le mois compte de jours de vente, et sa somme est multipliée d'autant. Le remède est d'agréger chaque table au grain commun avant de les joindre, et le chapitre sur l'entrepôt en montre le mécanisme.
1.Un filtre sur le montant total d'un groupe se place…
2.Comment repérer qu'une jointure vers une dimension fausse les sommes ?
Les opérations d'analyse
Le métier ne demande presque jamais une requête isolée : il explore, en partant d'un chiffre global et en descendant vers le détail. Ces mouvements portent des noms.
Agrégation : monter dans une hiérarchie, du mois vers le trimestre, de la ville vers la région.
Forage : descendre, du trimestre vers le mois, de la famille vers le produit.
Tranche : fixer une valeur sur une dimension, pour ne regarder que 2024.
Découpe : restreindre plusieurs dimensions à la fois, pour ne regarder que le premier trimestre dans le Nord.
Rotation : échanger les axes de lecture, passer d'un tableau produits en lignes et mois en colonnes à son transposé.
Ces cinq mouvements se traduisent tous par des variations minimes de la même requête : changer une colonne du GROUP BY, ajouter une condition au WHERE, permuter deux colonnes d'affichage. C'est cette régularité qui permet aux outils de restitution de les offrir par un clic, et c'est un bon test du modèle : si une exploration exige de réécrire la requête entièrement, le modèle est mal découpé.
Le bloc exécutable du début s'y prête. Remplacer t.trimestre par t.mois dans le SELECT, le GROUP BY et le ORDER BY est un forage : le mobilier se sépare en janvier (178), avril (249) et mai (445). Ajouter AND t.trimestre = 2 au WHERE est une tranche : il ne reste que le deuxième trimestre.
Un tableau croisé dynamique offre ces mouvements par des menus. Celui-ci part d'une table de faits déjà jointe à ses dimensions, une étoile aplatie, et montre d'abord le chiffre d'affaires par année, tous magasins confondus.
| Année | Somme de Montant (k€) |
|---|---|
| 2023 | |
| 2024 | |
| Total général |
Pointer ou choisir une case pour voir les lignes sources qui la composent et le calcul qui la donne.
Choisir Ouest dans le filtre Région : c'est une tranche. L'Ouest recule de 720 à 620, et recule à chaque trimestre, quand l'ensemble progresse.
Placer Trimestre en lignes, puis Année en colonnes : le tableau se transpose. Cette rotation ne change aucun nombre, et ses totaux restent 720, 620 et 1 340.
Aucun de ces gestes n'invente un calcul : chacun est une variante de la même requête sur la table de la figure. Le tableau de départ est SELECT annee, SUM(montant) FROM ventes GROUP BY annee. Le forage ajoute trimestre au GROUP BY, et l'agrégation l'en retire. La tranche ajoute region = 'Ouest' au WHERE, et une découpe y ajouterait une seconde condition, famille = 'Mobilier' par exemple. La rotation ne change même pas la requête, seulement la disposition de son résultat, ce qui explique que ses totaux ne bougent pas. Les opérations d'analyse ne sont donc que des changements de GROUP BY et de WHERE, et l'outil qui les offre en un clic ne fait que réécrire ces deux clauses.
Un tableau donne le chiffre d'affaires par famille et par trimestre. Le responsable demande le même chiffre par mois, pour le seul deuxième trimestre. Quels mouvements a-t-il demandés ?
Ce qui rend une requête lente
Le temps de réponse est dicté par le volume lu et déplacé, pas par la complexité écrite. Sur de gros volumes, une requête passe l'essentiel de son temps à lire des données depuis le disque ou le réseau, puis à faire circuler entre les machines celles qu'elle doit rapprocher. La longueur du texte ne compte presque pas, une jointure vers une petite dimension non plus. Une jointure entre deux grandes tables compte, par le mélange qu'elle impose : c'est l'objet du regroupement par hachage, plus bas. Ce qui compte est le nombre d'octets que le moteur est obligé de parcourir, puis de déplacer.
Trois leviers réduisent le volume lu :
Ne lire que les colonnes utiles, ce que permet un stockage en colonnes.
Ne lire que les partitions utiles, ce que permet un partitionnement aligné sur les filtres.
Ne lire que les fichiers utiles, ce que permettent les statistiques minimum et maximum conservées par fichier, à condition que les fichiers soient rangés selon la colonne filtrée.
La même requête, sur seize ventes, sous trois rangements : la figure compte les octets que le moteur lit, et ceux dont le résultat dépend. Rangée « en blocs », la table est découpée en groupes de quatre lignes qui gardent chacun le minimum et le maximum de leurs clients, comme les fichiers du programme plus bas.
SELECT SUM(montant) FROM ventes WHERE client BETWEEN 300 AND 399;
Par lignes, les colonnes d'une ligne sont écrites côte à côte : pour lire client et montant, le moteur lit chaque ligne en entier.
Choisir « par colonnes » : 192 octets lus, ceux des seules colonnes client et montant, sur les seize lignes.
Choisir « par colonnes, en blocs » : aucun bloc n'est sauté, car chacun couvre des clients de part et d'autre de 300 à 399, même le quatrième, de 214 à 830, qui n'en contient aucun.
Choisir « trié par client » : trois blocs sur quatre sont sautés, et il ne reste que 48 octets lus pour 36 utiles. Revenir par lignes ou par colonnes : le tri n'y change rien, faute de statistiques pour écarter quoi que ce soit.
Le troisième levier a une condition que les deux autres n'ont pas : des statistiques ne permettent d'écarter un fichier que si ses valeurs sont regroupées. Des fichiers écrits dans l'ordre d'arrivée sont rangés par date, pas par client, et le bloc suivant le mesure.
Sur la date, 13 fichiers sur 146 suffisent pour un mois. Sur un client, chaque fichier couvre presque tous les clients entre son minimum et son maximum, et rien n'est écarté. Trier par client avant d'écrire ramène la même requête à un seul fichier, au prix de l'ordre par date : un rangement sert un filtre, rarement deux. C'est pourquoi l'usage combine les deux : partitionner par date, puis ranger par client à l'intérieur de chaque partition.
Un quatrième levier agit en amont : ne pas recalculer ce qui l'a déjà été. Une table d'agrégats conserve le résultat d'un regroupement fréquent, par exemple le chiffre d'affaires par famille et par mois, calculé une fois à chaque alimentation. Une requête posée à ce grain, ou à un grain plus large quand la mesure s'additionne, y lit quelques milliers de lignes au lieu de plusieurs milliards. PostgreSQL l'automatise sous le nom de vue matérialisée, rafraîchie à la demande ; SQLite, le moteur de la page, n'en a pas, et une table remplie par INSERT INTO ... SELECT en tient lieu. Le prix est double : chaque alimentation doit la recalculer, et elle ne sert que les requêtes qui tombent à son grain ou au-dessus. Le chapitre 7 a montré pourquoi un cube n'est jamais matérialisé en entier : la table d'agrégats n'en garde que le grain que les tableaux de bord demandent.
Qu'est-ce qui dicte le temps de réponse d'une requête sur de gros volumes ?
Partitionner
Partitionner consiste à ranger physiquement les données dans des répertoires ou des fichiers séparés, selon la valeur d'une colonne : une partition par jour, par mois, par région.
Un filtre portant sur la colonne de partitionnement permet au moteur d'ignorer les autres partitions sans les ouvrir. C'est l'élagage de partitions, et c'est l'une des optimisations les plus rentables d'un entrepôt.
Descendre au jour semble mieux que le mois, puisque l'élagage est plus fin. Sur trois ans, cela fait pourtant 1 095 partitions, et si chacune est encore découpée par région, plusieurs dizaines de milliers de répertoires contenant chacun quelques fichiers minuscules.
On retrouve alors le problème des petits fichiers du chapitre 4 : le temps passe à ouvrir des fichiers plutôt qu'à lire des données, et le catalogue sature.
Le bloc ci-dessus le montre par un autre biais : sur une requête portant sur un trimestre, partitionner au jour ne lit pas moins de lignes que partitionner au mois. L'élagage plus fin n'apporte rien dès que le filtre couvre plusieurs partitions entières, et il ajoute mille répertoires.
Deux règles empiriques encadrent le choix. Une partition doit peser au moins quelques centaines de mégaoctets, et le nombre total de partitions doit rester de l'ordre du millier, pas de la dizaine de milliers. Un partitionnement au mois, éventuellement au jour sur des volumes très importants, couvre presque tous les cas.
Partitionner par région alors que toutes les requêtes filtrent sur le temps n'apporte rien : chaque requête lit toutes les partitions.
Pire, cela peut nuire. Une clé de partitionnement dont les valeurs sont très inégales, comme une région qui concentre la moitié de l'activité, reproduit le déséquilibre du chapitre 3 : une partition énorme et des dizaines de partitions vides.
La clé se choisit donc en regardant les filtres réellement écrits par les utilisateurs, pas la structure du métier.
Le raisonnement suivant applique cette règle à la lettre, sur une colonne que presque toutes les requêtes filtrent.
Une partition par client
Une seule étape est fausse. Désigner laquelle.
Une table de ventes de 500 Go est souvent filtrée sur l'identifiant client, qui prend valeurs. Elle est partitionnée par client, chaque partition tenant dans un fichier. Le moteur lit 1 Go par seconde, et chaque fichier ouvert lui coûte en plus 10 ms, quelle que soit sa taille. On cherche la durée d'une requête sans filtre sur le client, le chiffre d'affaires par famille par exemple, qui lit toute la table.
1.À quoi sert le partitionnement ?
2.Partitionner par jour sur cinq ans, avec vingt régions par jour…
Regrouper par hachage
Un regroupement par hachage, souvent appelé bucketing, répartit les lignes d'une partition dans un nombre fixe de fichiers, selon l'empreinte d'une colonne.
Toutes les lignes de même valeur atterrissent dans le même fichier, et deux tables regroupées sur la même colonne avec le même nombre de fichiers ont leurs valeurs alignées fichier par fichier. Le fichier d'une valeur se calcule sans rien lire : c'est l'empreinte de la valeur, modulo le nombre de fichiers.
C'est là qu'est le gain. Une jointure entre deux grandes tables oblige normalement à un mélange complet, celui du chapitre 3, avec tout le trafic réseau que cela représente. Si les deux tables sont regroupées sur la colonne de jointure avec le même nombre de fichiers, chaque fichier de l'une ne peut se joindre qu'au fichier correspondant de l'autre : la jointure se fait localement, sans mélange.
| Partitionnement | Regroupement par hachage | |
|---|---|---|
| Découpe selon | la valeur d'une colonne | l'empreinte d'une colonne |
| Nombre de morceaux | variable, une par valeur | fixe, choisi à l'avance |
| Bon pour | les filtres, par élagage | les jointures, l'échantillonnage, et les filtres d'égalité sur la colonne |
| Colonne adaptée | peu de valeurs, souvent filtrée | beaucoup de valeurs, souvent jointe |
| Risque | trop de partitions, ou déséquilibre | fichiers déséquilibrés si une valeur domine |
L'usage courant partitionne par date et regroupe par identifiant : ventes/annee=2024/mois=03/ contient 64 fichiers regroupés sur l'identifiant client.
Une requête filtrée sur mars lit une seule partition, et une jointure avec la table des clients, regroupée de la même façon, se fait fichier par fichier sans mélange.
La combinaison est efficace parce que les deux mécanismes répondent à deux questions différentes : le partitionnement à « quelles lignes puis-je ignorer », le regroupement à « comment éviter de tout déplacer ».
Que permet le regroupement par hachage que le partitionnement ne permet pas ?
Mettre en pratique
Ce qui rend une requête lente sur de gros volumes n'est presque jamais sa complexité : c'est la quantité de données qu'elle lit. Le partitionnement se juge donc sur ce seul nombre.
Mesurer ce qu'une requête lit vraiment
- 1.
Une table est partitionnée par jour sur 3 653 jours. Une requête filtre sur 30 jours. Combien de partitions le moteur lit-il ?
- 2.
Quel pourcentage de la table cela représente-t-il ?
- 3.
La même requête sur une table non partitionnée lit quel pourcentage de la table ?
- 4.
Une table d'un milliard de lignes est regroupée par hachage en 64 paquets. Combien de lignes chaque paquet contient-il en moyenne ?
Partitionner et regrouper ne répondent pas à la même question. Le partitionnement sert à ÉCARTER des données sans les lire, et il ne fonctionne que si la requête filtre sur la clé choisie. Un partitionnement par jour n'accélère en rien une requête qui filtre par client.
Le regroupement par hachage sert à rapprocher les lignes qui se joindront, pour éviter de les faire traverser le réseau. Il ne crée aucune partition : il range. Comme toutes les lignes d'une même valeur sont dans le même fichier, un filtre d'égalité sur sa colonne permet aussi à un moteur qui exploite ce rangement de n'ouvrir qu'un fichier par partition ; Spark le fait depuis sa version 2.4.
Choisir la clé de partitionnement revient donc à parier sur le filtre le plus fréquent, et ce pari s'écrit à partir des requêtes réellement posées, jamais à partir de la structure des données.
Traduire « les dix produits les plus vendus en volume au premier trimestre 2024, dans les magasins du Nord ».
Les quatre questions donnent tout.
Mesure : la quantité, donc SUM(f.quantite). Grain de restitution : le produit, donc un GROUP BY sur sa clé et son libellé, et non sur le seul libellé, qui additionnerait deux produits homonymes. Filtres : sur le temps et sur la région, deux dimensions, donc dans le WHERE. Tri et limite : décroissant sur la mesure, dix lignes.
À coller dans le bloc exécutable plus haut, à la place de sa dernière requête :
SELECT p.libelle, SUM(f.quantite) AS volume
FROM Fait_Vente f
JOIN Dim_Produit p ON p.idProduit = f.idProduit
JOIN Dim_Temps t ON t.idTemps = f.idTemps
JOIN Dim_Magasin m ON m.idMagasin = f.idMagasin
WHERE t.annee = 2024 AND t.trimestre = 1
AND m.region = 'Nord'
AND f.estRetour = 0
GROUP BY p.idProduit, p.libelle
ORDER BY volume DESC
LIMIT 10;
Le filtre sur les retours n'était pas dans la demande, et il faut le poser explicitement : « les plus vendus » signifie presque toujours hors retours, et c'est le genre de convention qu'il faut faire confirmer plutôt que supposer.
Une requête renvoie un chiffre d'affaires trois fois trop élevé depuis qu'une dimension a été historisée. Pourquoi ?
La jointure n'est plus de un vers un.
Historiser crée plusieurs lignes par entité, une par période de validité. Une jointure écrite sur la clé naturelle, sans restreindre à la version pertinente, rattache chaque fait à toutes les versions de son entité, et le duplique d'autant. Un client ayant trois versions triple ses ventes.
Deux corrections. Si les faits portent la clé de substitution, comme ils le doivent, joindre dessus et non sur la clé naturelle. Sinon, ajouter la condition de période, ou restreindre à la version courante par un indicateur.
Le contrôle qui l'attrape : comparer le nombre de lignes de la table de faits seule à celui obtenu après jointure. Il doit être identique.
Une table de 4 téraoctets est partitionnée par jour sur cinq ans, et chaque partition est encore découpée par région, avec vingt régions. Que se passe-t-il ?
1 825 jours multipliés par 20 régions donnent 36 500 partitions, pour 4 téraoctets, soit environ 110 mégaoctets par partition, souvent répartis en plusieurs fichiers plus petits encore.
Le résultat est le problème des petits fichiers : le temps de réponse est dominé par l'ouverture des fichiers et la consultation du catalogue, pas par la lecture des données. Une requête sur un mois ouvre 600 partitions au lieu d'en lire une grosse.
La correction consiste à remonter d'un cran : partitionner par mois uniquement, ce qui donne 60 partitions d'environ 67 gigaoctets, et remplacer le découpage par région par un regroupement par hachage à l'intérieur de chaque partition, si les jointures le justifient.
Deux tables de plusieurs milliards de lignes sont jointes chaque nuit sur l'identifiant client, et le traitement passe l'essentiel de son temps en transfert réseau. Que proposer ?
Regrouper les deux tables par hachage sur l'identifiant client, avec le même nombre de fichiers.
Le mélange complet disparaît : chaque fichier de l'une ne peut correspondre qu'au fichier de même rang dans l'autre, et la jointure se fait localement, sans déplacer les données.
Deux points de vigilance. Le nombre de fichiers doit être identique des deux côtés, sans quoi l'alignement n'existe pas et le mélange revient. Et un identifiant très déséquilibré, comme un client technique qui porterait la moitié des lignes, produira un fichier énorme, exactement le déséquilibre du chapitre 3, à traiter par les mêmes remèdes.
Chapitre révisé en octobre 2026 · Une erreur dans ce chapitre ?
Dans la bibliothèque d'exercices
Traduire une demande en SQL, nommer le mouvement, et le volume qu'une requête lit vraiment.
- Le chiffre d'affaires par région et par familleNiveau 2
- Nommer le mouvementNiveau 2
- Partitionner sur la mauvaise cléNiveau 2
- Le retour filtré trop tardNiveau 3
- Les plus vendus du NordNiveau 3
- Ce que la requête lit vraimentNiveau 3
- Le total qui doubleNiveau 3