Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1213 minDébutant

Index SQL et EXPLAIN

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • expliquer comment un index accélère une recherche ;
  • créer un index là où il sert, et pas ailleurs ;
  • lire un plan d'exécution avec EXPLAIN ;
  • repérer ce qui empêche d'utiliser un index, et le problème N+1.

4 exercices corrigés1 schéma

Un index SQL accélère les recherches dans une table : au lieu de lire toutes les lignes, le SGBD consulte une structure triée qui le mène directement aux bonnes. Ce chapitre explique quand créer un index avec CREATE INDEX, comment vérifier avec EXPLAIN qu'une requête s'en sert, et ce qui l'en empêche. Les exemples portent sur les tables de la base d'exemple parc.

Parcours complet ou index

Sans aide, pour trouver les tickets ouverts le 5 octobre, le SGBD lit toutes les lignes de la table et teste chacune : c'est un parcours complet (full table scan). Avec 14 tickets, c'est instantané. Avec 3 millions de tickets accumulés en dix ans, chaque recherche lit 3 millions de lignes.

Un index est une structure annexe, triée sur une ou plusieurs colonnes, qui pointe vers les lignes. Comme l'index d'un livre : au lieu de feuilleter toutes les pages, tu cherches le mot dans une liste triée, qui te donne le numéro de page. InnoDB range ses index dans des arbres B+ : la racine renvoie vers des pages intermédiaires, qui renvoient vers des feuilles triées. Chaque page de 16 Kio contient des centaines de clés : pour des millions de lignes, trois ou quatre lectures de pages suffisent.

Sans index : tout lireid_ticketdate102/09203/09308/09428/08515/09622/09723/09801/10905/101012/101119/101221/101326/101427/1014 lignes lues sur 14Avec un index sur date_ouverture01/10 | 20/10racine…05/10 | 12/10…01/10 → 805/10 → 912/10 → 10branchefeuilles3 pages d'index : ticket 9puis la ligne par sa clé primaire3 ou 4 pages pour des millions de lignes
Sans index, toutes les lignes sont lues ; avec un index en arbre B+, quelques pages suffisent

Dans InnoDB, la table elle-même est rangée dans l'ordre de sa clé primaire : c'est l'index en grappe (clustered index). Les autres index, dits secondaires, stockent la valeur indexée et la clé primaire de la ligne ; une recherche par un index secondaire passe donc ensuite par la clé primaire pour lire le reste de la ligne. Un argument de plus pour des clés primaires courtes.

Les index créés automatiquement

InnoDB crée un index pour chaque clé primaire, chaque contrainte UNIQUE et chaque clé étrangère qui n'en a pas déjà un.

sql
SHOW INDEX FROM ticket;
Key_nameColumn_nameNon_unique
PRIMARYid_ticket0
fk_ticket_demandeurid_demandeur1
fk_ticket_posteid_poste1

(Extrait : SHOW INDEX affiche une quinzaine de colonnes.) Les index des clés étrangères portent le nom de la contrainte. Ils accélèrent les jointures et la vérification de l'intégrité référentielle : quand tu supprimes un employé, MySQL doit vérifier rapidement qu'aucun ticket ne le référence.

CREATE INDEX

sql
-- Index simple
CREATE INDEX idx_ticket_ouverture ON ticket (date_ouverture);

-- Index composite : l'ordre des colonnes compte
CREATE INDEX idx_ticket_statut_priorite ON ticket (statut, priorite);

-- Index unique : accélère et interdit les doublons (un technicien ne commence
-- pas deux interventions à la même minute)
CREATE UNIQUE INDEX uq_intervention_tech_date ON intervention (id_technicien, date_intervention);

-- Supprimer un index
DROP INDEX idx_ticket_ouverture ON ticket;

ALTER TABLE ticket ADD INDEX idx_ticket_ouverture (date_ouverture); est une autre écriture. Un index unique et une contrainte UNIQUE reviennent au même dans MySQL : la contrainte crée un index unique.

Un index composite est trié sur la première colonne, puis sur la deuxième à l'intérieur de chaque valeur de la première, comme un annuaire trié par nom puis par prénom. Il sert donc pour une recherche sur le début de sa liste de colonnes : l'index (statut, priorite) accélère WHERE statut = 'ouvert' et WHERE statut = 'ouvert' AND priorite = 'haute', mais pas WHERE priorite = 'haute' seul. C'est la règle du préfixe gauche. Place en premier les colonnes testées par égalité, puis celle qui sert à un intervalle ou à un tri.

Quand un index contient toutes les colonnes dont la requête a besoin, MySQL lit l'index sans lire la table : c'est un index couvrant. SELECT statut, COUNT(*) FROM ticket GROUP BY statut peut se contenter de l'index (statut, priorite).

Quand indexer, quand s'abstenir

IndexerS'abstenir
Les colonnes des clés étrangères (automatique avec InnoDB)Les petites tables, de quelques centaines de lignes
Les colonnes souvent testées dans WHERE avec des valeurs sélectives : email, date_ouvertureUne colonne qui a très peu de valeurs différentes, seule : statut, un booléen
Les colonnes de tri et de regroupement fréquentsLes tables très écrites et rarement lues, comme un journal
Les colonnes de jointure qui ne sont pas des clésToutes les colonnes « au cas où »

Chaque index a un coût : il occupe de la place et ralentit chaque INSERT, UPDATE et DELETE, qui doivent le tenir à jour. Un index se justifie par une requête fréquente et lente, constatée avec EXPLAIN ou le journal des requêtes lentes.

Lire un plan d'exécution avec EXPLAIN

EXPLAIN placé devant un SELECT (ou un UPDATE, un DELETE) affiche le plan d'exécution choisi par l'optimiseur, sans exécuter la requête.

sql
EXPLAIN SELECT * FROM ticket WHERE id_ticket = 9;

Les colonnes à lire en priorité :

ColonneCe qu'elle ditIci
typeComment la table est parcourueconst : une seule ligne, par la clé primaire
possible_keysLes index utilisablesPRIMARY
keyL'index réellement choisiPRIMARY
rowsLe nombre de lignes que MySQL estime devoir examiner1
ExtraDes précisions sur l'exécution : Using where, Using index, Using filesort…NULL, rien à signaler

Les valeurs de type, de la meilleure à la pire :

typeSignification
constAu plus une ligne, trouvée par la clé primaire ou un index unique
eq_refUne ligne par ligne de la table précédente, dans une jointure sur une clé unique
refLes lignes qui ont une valeur donnée dans un index non unique
rangeUn intervalle de l'index : BETWEEN, <, >, IN, LIKE 'abc%'
indexTout l'index est parcouru
ALLParcours complet de la table

Sur EXPLAIN SELECT * FROM ticket WHERE titre LIKE '%VPN%';, tu lis type = ALL et key = NULL : aucun index ne peut servir. Dans Extra, Using filesort signale un tri sans index et Using temporary une table temporaire, deux opérations coûteuses sur de gros volumes.

EXPLAIN ANALYZE (MySQL 8.0.18 et plus) exécute la requête et affiche, étape par étape, le temps réel et le nombre réel de lignes : c'est l'outil pour comprendre une requête lente. EXPLAIN FORMAT=TREE affiche le plan sous forme d'arbre, plus lisible pour les jointures. Ces deux formes sont propres à MySQL : MariaDB écrit ANALYZE SELECT ... (ou ANALYZE FORMAT=JSON SELECT ...) et n'a pas de format arbre.

Ce qui empêche d'utiliser un index

Un index est trié sur la valeur brute de la colonne. Tout ce qui transforme la colonne avant de la comparer l'empêche de servir.

Écriture qui bloque l'indexÉcriture qui l'utilise
WHERE YEAR(date_ouverture) = 2026WHERE date_ouverture >= '2026-01-01' AND date_ouverture < '2027-01-01'
WHERE DATE(date_ouverture) = '2026-10-05'WHERE date_ouverture >= '2026-10-05' AND date_ouverture < '2026-10-06'
WHERE titre LIKE '%VPN%'WHERE titre LIKE 'Accès VPN%' si le début est connu, sinon une recherche plein texte (index FULLTEXT et MATCH … AGAINST)
WHERE num_inventaire = 15 (nombre comparé à une chaîne)WHERE num_inventaire = 'PC-0015'
WHERE prix_achat * 1.2 > 1300WHERE prix_achat > 1300 / 1.2
WHERE LOWER(email) = 'karim.morel@sorbier.example'WHERE email = 'karim.morel@sorbier.example' (l'interclassement _ci ignore déjà la casse)

LIKE 'Accès%' utilise l'index, car le début de la chaîne est fixe : c'est un intervalle de l'ordre alphabétique. LIKE '%VPN' ne le peut pas.

Depuis MySQL 8.0.13, un index fonctionnel indexe le résultat d'une expression, quand on ne peut pas réécrire la condition : CREATE INDEX idx_ticket_annee ON ticket ((YEAR(date_ouverture)));. Note la double parenthèse. MariaDB n'a pas d'index fonctionnel : on y indexe une colonne générée, ALTER TABLE ticket ADD annee SMALLINT AS (YEAR(date_ouverture)) VIRTUAL, ADD INDEX idx_ticket_annee (annee);, puis on filtre sur annee.

Le problème N+1

La lenteur vient souvent de l'application, pas du SQL. Une page PHP affiche les tickets avec le nom du demandeur :

php
$tickets = $pdo->query('SELECT id_ticket, titre, id_demandeur FROM ticket')->fetchAll();
foreach ($tickets as $t) {
    $stmt = $pdo->prepare('SELECT nom FROM employe WHERE id_employe = ?');
    $stmt->execute([$t['id_demandeur']]);
    echo $t['titre'] . ' - ' . $stmt->fetchColumn() . PHP_EOL;
}

Une requête pour la liste, puis une requête par ticket : N + 1 requêtes. Avec 500 tickets, 501 allers-retours vers le serveur, chacun rapide, mais dont les latences s'additionnent. La correction : une seule requête avec une jointure.

php
$sql = 'SELECT t.id_ticket, t.titre, e.nom
        FROM ticket t JOIN employe e ON e.id_employe = t.id_demandeur';
foreach ($pdo->query($sql) as $t) {
    echo $t['titre'] . ' - ' . $t['nom'] . PHP_EOL;
}

Les ORM provoquent souvent ce problème sans qu'on le voie, en chargeant les objets liés un par un.

Exercices

Exercice 1 sur 4 Choisir l'index Réussi À revoir

L'outil de suivi exécute très souvent cette requête sur une table de plusieurs centaines de milliers de tickets :

sql
SELECT id_ticket, titre, date_ouverture
FROM ticket
WHERE statut = 'ouvert' AND date_ouverture >= '2026-10-01'
ORDER BY date_ouverture;

Propose un index et justifie l'ordre de ses colonnes.

Voir la réponseMasquer la réponse de l'exercice 1
sql
CREATE INDEX idx_ticket_statut_ouverture ON ticket (statut, date_ouverture);

statut est testé par égalité : il vient en premier. Dans l'index, toutes les entrées 'ouvert' sont ensuite triées par date_ouverture : MySQL lit l'intervalle à partir du 1er octobre, déjà dans l'ordre du ORDER BY, sans tri supplémentaire. L'ordre inverse (date_ouverture, statut) servirait l'intervalle de dates, mais il faudrait examiner tous les statuts de la période.

Tu avais trouvé ?

Exercice 2 sur 4 Réécrire pour l'index Réussi À revoir

La colonne date_ouverture est indexée. Réécris chaque condition pour qu'elle puisse utiliser l'index, sans changer le résultat.

  1. WHERE YEAR(date_ouverture) = 2026 AND MONTH(date_ouverture) = 9
  2. WHERE DATEDIFF('2026-10-28', date_ouverture) <= 7
  3. WHERE DATE_FORMAT(date_ouverture, '%Y-%m-%d') = '2026-10-05'
Voir la réponseMasquer la réponse de l'exercice 2
  1. WHERE date_ouverture >= '2026-09-01' AND date_ouverture < '2026-10-01'
  2. WHERE date_ouverture >= '2026-10-21'. DATEDIFF ignore les heures : un ticket du 21 octobre à 8 h donne 7 jours, il est retenu, d'où la borne au 21 octobre à minuit.
  3. WHERE date_ouverture >= '2026-10-05' AND date_ouverture < '2026-10-06'

Dans les trois cas, la colonne est seule d'un côté de la comparaison.

Tu avais trouvé ?

Exercice 3 sur 4 Lire EXPLAIN Réussi À revoir

Un collègue montre ce plan pour une requête qui liste les interventions d'un technicien :

tabletypepossible_keyskeyrowsExtra
interventionALLNULLNULL412 380Using where; Using filesort

Que t'apprend chaque colonne ? La requête est SELECT * FROM intervention WHERE YEAR(date_intervention) = 2026 AND duree_min > 60 ORDER BY date_intervention;. Que proposes-tu ?

Voir la réponseMasquer la réponse de l'exercice 3
  • type = ALL, key = NULL : parcours complet, aucun index utilisé ;
  • rows : environ 412 000 lignes examinées pour chaque exécution ;
  • Using filesort : le tri est fait à part, sans index.

YEAR(date_intervention) empêche l'utilisation d'un index sur la date. Réécris la condition en intervalle et crée l'index :

sql
CREATE INDEX idx_intervention_date ON intervention (date_intervention);

SELECT * FROM intervention
WHERE date_intervention >= '2026-01-01' AND date_intervention < '2027-01-01'
  AND duree_min > 60
ORDER BY date_intervention;

L'index fournit à la fois l'intervalle et l'ordre du tri. Relance EXPLAIN : si l'année 2026 ne représente qu'une petite partie de la table, tu devrais lire type = range et key = idx_intervention_date, sans Using filesort. Si elle en représente la plus grande part, l'optimiseur peut préférer le parcours complet suivi d'un tri, pour la même raison qu'à l'exercice suivant.

Tu avais trouvé ?

Exercice 4 sur 4 Un index de trop ? Réussi À revoir

Un développeur propose d'indexer ticket.statut seul, puisque presque toutes les requêtes filtrent sur le statut. Sur 500 000 tickets, 95 % sont au statut resolu. Qu'en penses-tu ?

Voir la réponseMasquer la réponse de l'exercice 4

Pour WHERE statut = 'resolu', l'index est inutile : l'optimiseur préfère lire la table entière plutôt que de passer par l'index pour 95 % des lignes. Pour WHERE statut = 'ouvert' (quelques milliers de lignes), il peut servir. Le gain dépend donc des requêtes réelles. Le plus souvent, un index composite qui commence par statut et continue par la colonne de tri ou d'intervalle (exercice 1) rend plus de services qu'un index sur statut seul, et l'index seul ralentirait chaque écriture pour rien.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Transactions SQL : COMMIT et ROLLBACKRevoir le sommaire du cours