Optimiser les requêtes MySQL sans douleur inutile
Quand une page se fige et qu’un panier s’échappe, la cause n’est pas toujours le serveur, mais la phrase SQL qui traîne. Le geste le plus payant consiste à Optimiser les requêtes MySQL pour rendre au système sa respiration, avant toute fuite en avant matérielle. Le diagnostic précis transforme une lenteur chronique en incident ponctuel.
Où se perd le temps d’une requête MySQL ?
Le temps s’évapore dans l’attente d’I/O, de verrous, de CPU, et parfois sur le réseau. Le repérer exige de découper la latence en segments concrets, mesurables, sur lesquels agir sans approximation.
Chaque requête traverse un petit théâtre technique : file d’attente côté connexion, lecture de pages depuis le disque ou le buffer pool, calcul des jointures, agrégations, tri, puis renvoi des lignes au client. Pareille à un fleuve, elle ralentit au moindre méandre : un index manquant, une cardinalité mal estimée, un tri externe, un verrou qui retarde la traversée. La compréhension vient lorsque les pièces s’alignent : un EXPLAIN qui annonce un range scan, un Performance Schema qui pointe des attentes sur le buffer pool, un slow log qui révèle un filesort non indexé. Là, l’effort se concentre, et chaque seconde reprend chair.
| Composante | Description | Observation | Ordre de grandeur |
|---|---|---|---|
| Attente I/O | Lecture/écriture de pages InnoDB | Performance Schema (wait/io), métriques buffer pool | Millisecondes à dizaines de ms |
| CPU | Calcul de jointures, agrégats, tri | Profilage CPU, EXPLAIN ANALYZE | Microsecondes à millisecondes |
| Verrous | Conflits de verrous de lignes/indice | Information_schema.innodb_locks, P_S (wait/lock) | Millisecondes à secondes |
| Réseau | Allers-retours client–serveur | Temps aller-retour, nombre de paquets | Microsecondes à ms (amortissable) |
| Tri externe | Filesort et temp tables sur disque | EXPLAIN (Using filesort), status sort_merge_passes | Millisecondes à centaines de ms |
Comment lire un plan d’exécution sans se tromper ?
Un plan parle d’ordres de grandeur : lignes estimées, type d’accès, utilisation d’index, coût d’un tri. Sa lecture cherche la ligne qui gonfle les volumes, pas la décoration autour.
Face à EXPLAIN, l’œil entraîné suit la rivière depuis la table la plus sélective. Un access type en range sonne mieux qu’un full scan, mais seul le rows estimé confirme le gain. Quand un filtre reste “post” (Using where après lecture), la table souffre d’une lecture inutile. Les “ref” bien guidés par un index composé réduisent la dérive, tandis que “ALL” annonce un balayage total à sanctionner. Sur MySQL 8, EXPLAIN ANALYZE ajoute la chair des temps réels et des lignes lues ; l’estimation croisée à la réalité dévoile les illusions statistiques, souvent dues à une distribution biaisée des valeurs.
Cardinalité et sélectivité : la boussole
La bonne clé est celle qui coupe court au flot de lignes. Un index gagnant concentre la distribution ; un index “populaire” sur une colonne quasi binaire crée un mirage.
Un pourcentage infime de valeurs distinctes sur une colonne statutaire n’aidera guère ; l’index doit viser la colonne qui élimine le plus tôt. Le moteur estime à partir d’échantillons ; si ces statistiques vieillissent, il surestime ou sous-estime, choisit un plan trop optimiste, et la requête prend l’eau. La mise à jour des stats (ANALYZE TABLE), couplée à une sélection d’ordres pertinents dans les index composés, redresse la route. La sélectivité guide aussi l’ordre des prédicats, même si l’optimiseur réécrit ; l’expérience retient qu’un index commençant par la colonne la plus discriminante limite le balayage.
Filtrage vs post-filtrage : éviter les faux gains
Le meilleur filtre s’applique dans l’index, pas après coup. Les conditions non sargables obligent à tout lire puis à trier, comme un pêcheur qui relève tout le filet pour ne garder qu’un poisson.
Une fonction sur la colonne (DATE(created_at)) ou un calcul à gauche (col + 1 = 42) casse la recherche indexée. L’écriture équivalente (created_at >= ? AND created_at < ?) rend le prédicat navigable dans l’index. De même, un LIKE '%abc' empêche l’index d’aider ; en forçant le préfixe ('abc%') avec éventuellement un index sur une colonne normalisée (lower-cased), le plan bascule d’un scanning pénible à un range ciblé. L’économie n’est pas cosmétique : elle détermine la taille du jeu intermédiaire, donc les I/O et les tris.
Quels index créer pour gagner sans surcoût ?
Un index utile réduit les lectures et sert au tri. Il coûte en écriture et en mémoire ; l’objectif reste le minimum efficace, pas la collection.
Construire des index ressemble à disposer les pas d’un sentier dans un jardin : il s’agit d’anticiper les trajectoires réelles. Les requêtes les plus fréquentes dictent les colonnes de tête, les tris prescrivent l’ordre, et la couverture épargne des allers-retours vers la table. En revanche, chaque index alourdit l’insertion et la mise à jour ; multiplier les variantes pour “toutes les éventualités” finit par saboter la cadence d’écriture. La discipline consiste à lier chaque index à un patron de requête mesuré, et à retirer ceux qui ne servent plus.
| Type d’index | Usage recommandé | Remarques |
|---|---|---|
| BTREE (par défaut) | Égalité, intervalles, tri, préfixes | Respecte l’ordre ; idéal pour WHERE + ORDER BY alignés |
| Composé | Filtres multi-colonnes et tri combiné | L’ordre des colonnes compte ; commence par la plus sélective |
| Covering (INCLUDE implicite) | Éviter le lookup table en ajoutant les colonnes lues | Réduit I/O ; attention à la taille et au cache |
| FULLTEXT | Recherche linguistique dans du texte | Précis pour mots ; pas pour préfixes arbitraires |
| HASH (moteurs spécifiques) | Égalité stricte | Pas d’ordre, pas de range ; InnoDB reste BTREE |
Index composés et ordre qui compte
La tête de l’index sert de clé de tri et de filtre. Quand WHERE et ORDER BY s’alignent, la requête traverse la B-Tree sans filesort.
Un patron classique — WHERE status = ? AND created_at >= ? ORDER BY created_at DESC — appelle un index sur (status, created_at DESC). Inversement, un ordre de colonnes mal posé mène à de grandes lectures et à un tri externe. L’acuité se renforce en mesurant le “handler read next/first” et les “sort merge passes” pour vérifier l’effet réel du choix d’ordre, au-delà de l’intuition.
Index de couverture : quand la table devient optionnelle
Si l’index contient tout ce que la requête lit, le moteur n’a plus besoin de revenir à la table. Le gain se sent dans la latence et la stabilité.
Ajouter une colonne faiblement variable pour couvrir un SELECT change parfois tout ; la B-Tree suffit, le buffer pool respire, les accès aléatoires chutent. Toutefois, la colonne de plus devient un poids en écriture. La balance s’évalue par la fréquence de la requête et son rôle métier ; un rapport hebdomadaire n’exige pas le même arsenal qu’un endpoint critique en temps réel.
LIKE, collations et casse : éviter les pièges mous
Les recherches textuelles butent sur la collation et la casse. Standardiser et indexer le même “canon” évite les surprises de tri et de filtre.
Indexer une version normalisée (email_normalized) plutôt que jouer avec des fonctions au runtime améliore la sélectivité et la prévisibilité. Les collations accent-insensitive influencent l’ordre et l’usage des index ; un choix hâtif se paie en filesort récurrents. Un refactoring léger des colonnes et de leur collation évite des temp tables invisibles mais coûteuses.
- Associer chaque index à une requête mesurée (slow log, top N)
- Aligner WHERE et ORDER BY pour éviter le filesort
- Préférer la sélectivité en tête des index composés
- Retirer les index orphelins et surveiller le coût en écriture
Comment réécrire une requête pour la rendre prédictive ?
Écrire “sargable”, réduire la projection, filtrer tôt et paginer sans trous. Ce sont des gestes simples qui fixent un plafond de travail au lieu de subir une dérive.
Une requête prévisible ressemble à une trajectoire balisée : pas de fonctions sur les colonnes filtrées, pas de wildcard en tête, pas de SELECT étoile quand trois colonnes suffisent. Les sous-requêtes corrélées, séduisantes sur petit échantillon, s’étouffent à l’échelle ; une jointure bien indexée ou une CTE matérialisée logiquement fait gagner de la constance. La pagination par offset relit toujours les mêmes pages et décale l’effort ; la pagination à curseur (WHERE id < ?) saute directement au bon endroit. Le principe s’applique à l’agrégation : grouper sur une clé indexée et réduire tôt les lignes plie la courbe de latence.
| Avant | Après | Impact attendu |
|---|---|---|
WHERE DATE(created_at)=? |
WHERE created_at >= ? AND created_at < ? |
Range indexé, plus de lookup inutile |
SELECT * FROM t ... |
SELECT id, total, status FROM t ... |
Moins d’I/O, index couvrant possible |
... ORDER BY created_at LIMIT 50 OFFSET 50000 |
... WHERE created_at < ? ORDER BY created_at DESC LIMIT 50 |
Pagination par curseur, latence stable |
| Sous-requête corrélée par ligne | JOIN avec index + agrégat groupé | Suppression des N lectures répétées |
- Projeter le strict nécessaire, viser l’index de couverture
- Filtrer par intervalles navigables, bannir les fonctions à gauche
- Remplacer OFFSET par curseur sémantique
- Pré-calculer les champs de recherche (normalisation, casse)
Que se passe-t-il côté moteur InnoDB ?
Le moteur orchestre mémoire, I/O et verrous. Comprendre ses priorités transforme une intuition SQL en amélioration systémique.
InnoDB vit par son buffer pool : tout passe par là, tant la lecture que l’écriture différée. Quand le cache est trop petit, chaque requête excave des pages depuis le disque, puis les perd aussitôt. Les flushes trop agressifs étranglent l’instant présent au nom de la durabilité. S’ajoutent les verrous de lignes, parfois élargis par une recherche non indexée, déclenchant des attentes en cascade. Une requête bien écrite, sans appui du moteur, reste vulnérable ; l’inverse aussi. Le réglage d’ensemble donne l’assise.
| Symptôme | Cause probable | Levier concret |
|---|---|---|
| Latence variable, pics I/O | Buffer pool trop petit, lecture aléatoire | Augmenter innodb_buffer_pool_size, index de couverture |
| Temps d’attente en écriture | Flush concurrent, log saturé | Ajuster innodb_flush_neighbors, taille du redo log |
| Deadlocks sporadiques | Parcours non indexé, ordre d’accès divergent | Index adéquats, ordre stable de mise à jour |
| Filesort récurrents | ORDER BY non supporté par index | Aligner index avec tri, réduire projection |
Verrous et isolation : la géographie du conflit
Le niveau d’isolation et la forme du parcours déterminent qui attend qui. Un range non indexé verrouille trop large, et c’est tout le carrefour qui bloque.
En READ COMMITTED, une requête évite certains pièges, mais un UPDATE sans index dérive en verrouillage de nombreuses lignes ; un SELECT FOR UPDATE hérite des mêmes travers. Le remède s’obtient rarement par changement d’isolation ; il vient d’un index qui localise le range et d’un ordre d’accès cohérent entre transactions sœurs. Le journal des deadlocks raconte l’histoire exacte ; on y entend la colonne manquante, l’ordre inversé, et parfois la pagination fautive qui retarde la libération.
Mémoire, temp tables et tri : l’invisible qui coûte
Les tables temporaires existent pour soulager, mais leur migration sur disque devient une enclave de lenteur. Mieux vaut les éviter que les gonfler.
Un GROUP BY sans index tombe vite en temp table, et si la mémoire allouée ne suffit pas, le disque prend le relais. Aligner la clé de groupement, réduire la projection, et, si besoin, répartir l’agrégat en étapes maîtrisées, ramène le calcul en mémoire. Les réglages globaux (tmp_table_size, max_heap_table_size) aident, mais c’est l’écriture de la requête qui arrête l’hémorragie.
Quand dénormaliser, partitionner ou mettre en cache ?
Quand la requête idéale reste lourde, l’architecture prend le relais. La décision doit être chirurgicale et mesurée, pour ne pas déplacer la dette ailleurs.
La dénormalisation condense une lecture coûteuse en un accès simple, mais elle s’accompagne d’une discipline d’écriture. Le partitionnement borne le volume scanné à la partition active, encore faut-il que la clé de partition colle aux filtres réels. Le cache applicatif coupe court au trafic répétitif ; il exige une invalidation crédible. Plutôt qu’un réflexe, ce sont des outils à saisir quand les preuves l’exigent et que la charge le justifie.
| Technique | À privilégier si | Coût / Risque |
|---|---|---|
| Dénormalisation ciblée | Lecture N→1 fréquente, peu d’états | Complexité d’écriture, cohérence à surveiller |
| Partitionnement | Filtres bornés sur date/id, tables massives | Maintenance, clé de partition figée |
| Cache applicatif | Résultats répétitifs, lecture dominante | Invalidation, mémoire externe |
| Matérialisation périodique | Rapports stables, fenêtres de temps | Fraîcheur contrôlée, pipeline ETL |
- Choisir la technique alignée sur les filtres et l’accès réel
- Documenter l’invalidation et le cycle de vie des données
- Mesurer avant/après avec le même trafic et la même période
Comment mesurer et garder le cap en production ?
Mesurer, comparer, puis budgéter une latence cible par requête critique. La performance devient un contrat, pas une espérance.
Le slow query log révèle le haut du panier à traiter ; pt-query-digest regroupe, trie, raconte l’histoire derrière le pic. Le Performance Schema expose les attentes concrètes : I/O, verrous, stages de tri. Le schéma sys synthétise les métriques en vues utiles. Pour chaque requête vitale, un budget de temps se fixe : tant pour le réseau, tant pour le plan, tant pour l’I/O. EXPLAIN ANALYZE affine la réalité, et un tableau de bord suit la médiane et le p95, parce que c’est la queue qui blesse l’expérience. Les tests de charge rejouent le trafic réel, pas un simple tir au canon, afin d’éviter les optimisations de laboratoire qui fanent au premier lundi de pointe.
| Pratique | But | Indicateurs clés |
|---|---|---|
| Slow log + agrégation | Identifier les requêtes dominantes | Temps moyen, 95e, requêtes/s |
| EXPLAIN ANALYZE | Vérifier les estimations et parcours réels | Lignes lues vs prévues, temps par nœud |
| Performance Schema | Localiser les attentes (I/O/lock) | wait/io, wait/lock, stages |
| Budgets de requête | Stabiliser la latence | p50/p95 par endpoint, erreurs |
Conclusion : faire taire le bruit, laisser parler les données
L’optimisation de requêtes n’a rien d’un tour de passe-passe. C’est un travail de montre : ouvrir, observer la danse des engrenages, ajuster l’axe, changer la dent qui accroche, puis refermer avec douceur. Les symptômes se ressemblent, mais la cause, elle, se niche dans un détail mesurable.
Quand l’écriture devient sargable, que l’indexation respecte l’usage réel, que le moteur trouve de la mémoire pour travailler et que la mesure cadence les décisions, la plate-forme cesse de lutter contre elle-même. Les pages cessent de se figer, les paniers poursuivent leur route, et le temps repris se comptabilise autant en satisfaction qu’en marge. L’essentiel, au fond, tient en deux idées : laisser les index guider la lecture, et ne jamais discuter sans mesures à la main.