Aller au contenu principal

Exploiter une base : index, droits, injection et sauvegardes

Ce que ce chapitre apporte 5 points
  • Lire le plan d'exécution d'une requête, et dire combien de lignes elle lit avant et après la création d'un index.
  • Choisir les colonnes d'un index et leur ordre d'après les conditions d'une requête, et repérer une condition qui l'empêche de servir.
  • Donner à chaque application un compte limité à ce dont elle a besoin, par GRANT, REVOKE et une vue, et vérifier ses droits avec SHOW GRANTS.
  • Écrire en Python une requête paramétrée, et montrer qu'une saisie piégée n'en change pas le résultat.
  • Sauvegarder une base avec mysqldump, la restaurer dans une base de copie, et chiffrer ce qu'une politique de sauvegarde fait perdre en données et en temps.

La base de l'école sort du bac à sable. Le portail des étudiants l'interroge à chaque visite, la scolarité y saisit les notes, un tableau de bord compte chaque soir les pages de cours consultées. Quatre ennuis attendent une base en service. Le journal des consultations grossit de milliers de lignes par semaine, et la requête qui en lisait dix en lit bientôt des millions. Le portail se connecte en root, et une faute dans son code peut vider une table. Un formulaire colle dans une requête ce qui y est tapé, et une adresse bien choisie fait afficher les notes de toute l'école. Un disque lâche, et la sauvegarde rangée sur ce disque part avec lui. Ce chapitre traite ces quatre ennuis : l'index et le plan d'exécution, les comptes et les droits, la requête paramétrée, la sauvegarde et sa restauration. Il s'appuie sur les requêtes et les vues des chapitres 5 et 6, sur les transactions du chapitre 7, et sur le serveur installé au chapitre 2.

Ce qui rend une requête lente : l'index

Pour afficher les étudiants de Lyon, le moteur n'a qu'un moyen tant que rien n'est prévu : lire toutes les lignes de la table Etudiant, une à une, et garder celles dont la ville convient. Sur dix lignes, cela ne se voit pas ; sur dix millions, la page attend. Un index est une structure rangée à part, triée sur une colonne, qui mène directement aux lignes portant une valeur, comme l'index d'un livre mène aux pages où un mot apparaît.

Le chemin que le moteur choisit s'appelle le plan d'exécution. Placé devant une requête, EXPLAIN QUERY PLAN l'affiche sans exécuter la requête. C'est l'écriture de SQLite, le moteur de la page ; MySQL écrit EXPLAIN, et sa sortie est présentée plus bas.

requete.sql
Résultat
>_ Prêt à exécuter…

Le premier plan dit SCAN Etudiant : la table entière est lue. Le second dit SEARCH Etudiant USING INDEX idx_etudiant_ville (ville=?) : le moteur cherche « Lyon » dans l'index, puis ne lit que les lignes qu'il désigne. Le dernier tableau est celui qu'aurait rendu la requête sans index, Diallo, Lefebvre et Nguyen : un index change le chemin, jamais la réponse. Un plan se lit dans sa dernière colonne, detail ; les trois premières sont à l'usage du moteur.

Définitions

Un index est une structure triée sur une ou plusieurs colonnes d'une table, que le moteur tient à jour à chaque écriture, et qui permet de trouver les lignes portant une valeur sans lire les autres. Il se crée par CREATE INDEX nom ON Table (colonnes) et se supprime par DROP INDEX nom (sous MySQL, DROP INDEX nom ON Table).

Le plan d'exécution est la suite d'opérations que le moteur choisit pour exécuter une requête : quelles tables lire, dans quel ordre, par quel index. SCAN lit une table en entier, SEARCH n'en lit que ce qu'un index désigne.

Ce plan n'est pas une notion nouvelle : c'est l'arbre du chapitre sur l'algèbre relationnelle, où le moteur a décidé, pour chaque feuille, comment la lire. Le choix ne se fait pas dans la requête : elle ne nomme aucun index, et le moteur prend de lui-même celui qui convient, s'il en existe un.

Vérification rapideon peut se reprendre

Après CREATE INDEX idx_etudiant_ville ON Etudiant (ville), que change la requête des étudiants de Lyon ?

Mesurer sur une table qui grossit

Dix lignes ne départagent rien. Le journal des consultations de l'espace numérique de l'école en compte bien davantage : une ligne chaque fois qu'un étudiant ouvre la page d'un cours, avec le cours, le jour, numéroté depuis la rentrée, et le temps passé sur la page. Le bloc suivant en fabrique 6 300 sans les écrire une à une. WITH RECURSIVE, le WITH du chapitre sur le SQL avancé quand il se rappelle lui-même, produit les nombres de 1 à 6 300, et chacun devient une ligne. % est le reste de la division : i % 7 + 1 fait tourner le cours de 1 à 7, i % 300 + 1 le jour de 1 à 300, et i % 41 + 2 la durée de 2 à 42 minutes. La première ligne du bloc, -- sur la base du cours, garde les tables du cours à côté de la nouvelle, dont la clé étrangère désigne Cours.

requete.sql
Résultat
>_ Prêt à exécuter…
À manipuler
Retirer la ligne CREATE INDEX et relancer : le dernier plan redevient SCAN Consultation, et les nombres ne bougent pas.
Remettre l'index, puis remplacer jour = 42 par jour BETWEEN 40 AND 49 dans la requête qui compte et dans le dernier plan : 210 consultations, et le plan dit SEARCH Consultation USING INDEX idx_consultation_jour (jour>? AND jour<?). Les entrées de l'index sont rangées par jour : dix jours qui se suivent sont dix groupes voisins.
Écrire enfin WHERE jour + 0 = 42 dans le dernier plan : la condition désigne les mêmes 21 lignes que jour = 42, et le plan redevient SCAN Consultation.

La table compte 6 300 lignes, dont 21 pour le jour 42, d'une durée moyenne de 20,7 minutes. Sans index, le plan dit SCAN Consultation : pour trouver ces 21 lignes, le moteur en lit 6 300, et 6 279 ne servent à rien. Avec l'index, il dit SEARCH Consultation USING INDEX idx_consultation_jour (jour=?) : le moteur lit les 21 entrées du jour 42 dans l'index, puis les 21 lignes qu'elles désignent. Le journal d'une vraie école compte des millions de lignes, et l'écart se compte alors en millions.

Un index n'est pas une liste triée qu'il faudrait parcourir depuis son début. Ses entrées sont rangées dans des pages de quelques centaines, et ces pages forment un arbre : la page racine dit dans quelle page du niveau suivant chercher, et ainsi de suite jusqu'à la page qui contient la valeur. Avec cent entrées par page, deux niveaux couvrent dix mille entrées, trois en couvrent un million : trouver une valeur parmi un million demande de lire trois pages, là où le parcours de la table en lirait des milliers. Cette structure s'appelle un arbre B ; MySQL et SQLite y rangent leurs index, et leurs tables elles-mêmes.

La page ne mesure aucune durée, et ce n'est pas un oubli. Une durée dépend de la machine, de ce qu'elle fait au même moment, de ce qui se trouve déjà en mémoire, et deux essais de suite ne donnent pas le même nombre. Le plan et le nombre de lignes lues ne dépendent que de la requête et des tables : ce sont eux qui expliquent une durée, et eux qui se comparent d'une machine à l'autre.

Sous MySQL, EXPLAIN rend une ligne par table lue, et trois de ses colonnes disent l'essentiel : type vaut ALL pour une table lue en entier, ref ou range pour une recherche par un index ; key nomme l'index choisi, et reste vide sans index ; rows donne le nombre de lignes que le moteur s'attend à lire, une estimation tirée de ses statistiques. EXPLAIN ANALYZE, depuis MySQL 8.0.18, exécute la requête et rapporte les lignes réellement lues. Ces commandes ne s'exécutent pas dans la page.

SQL
-- Dans MySQL : le plan prévu, puis le plan mesuré en exécutant la requête
EXPLAIN SELECT AVG(minutes) FROM Consultation WHERE jour = 42;
EXPLAIN ANALYZE SELECT AVG(minutes) FROM Consultation WHERE jour = 42;

Pour y fabriquer la même table, une précaution : MySQL arrête par défaut une récursion au-delà de 1 000 tours (le réglage cte_max_recursion_depth), et il faut d'abord écrire SET cte_max_recursion_depth = 10000;.

Ce que coûte un index

Un index se paie à chaque écriture. Un INSERT ajoute une entrée dans chaque index de la table, un DELETE en retire une, un UPDATE d'une colonne indexée déplace l'entrée à sa nouvelle place : une table qui porte trois index écrit quatre fois pour chaque ligne ajoutée, dans la table et dans ses trois index. Il occupe aussi de la place, sur le disque et en mémoire. Les colonnes à indexer sont donc celles sur lesquelles les requêtes fréquentes filtrent, joignent ou trient, et elles seules : un journal où le portail écrit sans cesse, et que le tableau de bord lit une fois par soir, ne gagne rien à dix index.

Certains index existent d'office. Une clé primaire et une colonne UNIQUE ont le leur, qui sert aussi à refuser les doublons. MySQL en crée un pour chaque clé étrangère qui n'en a pas, SQLite non : dans la page, chercher les cours d'un enseignant, WHERE idEnseignant = 2, lit toute la table Cours.

Un index sur plusieurs colonnes

Le tableau de bord demande aussi les consultations d'un cours un jour donné. Un index peut porter sur plusieurs colonnes : ses entrées sont alors rangées par la première, puis, à première égale, par la deuxième, comme un annuaire rangé par nom, puis par prénom. L'ordre des colonnes décide des requêtes qu'il sert.

requete.sql
Résultat
>_ Prêt à exécuter…
À manipuler
Lire les trois plans : les deux premiers disent SEARCH, avec (idCours=? AND jour=?) puis (idCours=?) ; le troisième dit SCAN Consultation.
Échanger les deux colonnes de l'index, (jour, idCours), et relancer : le premier plan dit toujours SEARCH, avec (jour=? AND idCours=?), le deuxième passe à SCAN Consultation, et le troisième à SEARCH avec (jour=?). L'ordre des colonnes n'a pas supprimé la requête que l'index ne sert pas : il l'a déplacée.

La première requête descend dans l'index jusqu'au cours 3, puis, parmi ses entrées, jusqu'au jour 42, et lit 3 lignes. La deuxième s'arrête au cours 3 et lit ses 900 lignes. La troisième ne peut pas entrer dans l'index : les entrées du jour 42 y sont dispersées en sept groupes, un par cours, comme les Léa d'un annuaire rangé par nom sont dispersées à toutes les pages. Un index sert les conditions posées sur ses premières colonnes, dans l'ordre et sans en sauter : (idCours, jour) sert idCours seul, ou idCours et jour, mais pas jour seul.

Une condition d'intervalle arrête la descente. Sur (idCours, jour), idCours = 3 AND jour BETWEEN 40 AND 49 se sert des deux colonnes ; sur (jour, idCours), la même condition ne peut descendre que par l'intervalle des jours, et l'égalité sur le cours ne sert plus qu'à écarter, une à une, les entrées des six autres cours. La colonne d'une égalité se place donc en tête, celle d'un intervalle après elle.

SQLite et MySQL 8 savent parfois contourner l'ordre : quand leurs statistiques leur apprennent que la première colonne n'a que quelques valeurs, ils cherchent le jour 42 sous chacune d'elles, sept petites recherches au lieu d'une. Dans la page, l'ordre ANALYZE, qui calcule ces statistiques, le fait voir, et le plan écrit alors ANY(idCours). C'est un recours du moteur, pas une façon de concevoir un index.

La clé primaire d'Inscription en donne l'exemple sans rien créer. Son index porte sur (idEtudiant, idCours) : il sert les cours d'un étudiant, et pas les inscrits d'un cours.

Exercice : écrire puis vérifier

L'index qui sert la liste d'un cours

La page d'un cours affiche ses inscrits et leurs notes, par la requête SELECT idEtudiant, note FROM Inscription WHERE idCours = 3. La table a déjà un index, celui de sa clé primaire, (idEtudiant, idCours), et pourtant le plan de cette requête dit SCAN Inscription.

Écrire l'ordre qui crée l'index idx_inscription_cours, de sorte que le plan passe à SEARCH. La vérification lit le plan de la requête après le script : seule sa dernière colonne, detail, est à lire.

« Exécuter » joue le script et montre l'état de la base qu'il laisse ; « Vérifier » compare cet état à celui attendu, sur 1 cas.

Quand l'index ne sert pas

Un index range les valeurs de sa colonne telles qu'elles sont écrites. Une condition qui porte sur autre chose que la colonne nue, ou qui ne dit pas par où entrer dans cet ordre, oblige le moteur à tout lire, même quand l'index existe.

  • Une fonction ou un calcul appliqués à la colonne : strftime('%Y', dateNaissance) = '2004' dans la page, YEAR(dateNaissance) = 2004 sous MySQL, ou jour + 0 = 42. L'index range des dates, pas des années. La même condition se réécrit sur la colonne nue, par un intervalle.
  • Un motif qui commence par % : nom LIKE '%on' cherche une fin de nom, et l'ordre alphabétique ne dit rien des fins. LIKE 'Di%' désigne au contraire un intervalle de cet ordre : MySQL s'en sert, la page non, parce que son LIKE ignore les majuscules et que son index les respecte.
  • Un OR entre deux colonnes : avec un index sur le seul jour, jour = 42 OR minutes = 5 lit toute la table, puisque l'index ne trouve pas les lignes de la seconde condition.
requete.sql
Résultat
>_ Prêt à exécuter…

Le premier plan dit SCAN Etudiant, le second SEARCH Etudiant USING INDEX idx_etudiant_naissance (dateNaissance>? AND dateNaissance<?), et les deux conditions désignent les cinq mêmes étudiants nés en 2004 : Martin, Dupond, Lefebvre, Moreau et Nguyen. MySQL 8 sait aussi indexer une expression, CREATE INDEX idx_annee ON Etudiant ((YEAR(dateNaissance))), mais un tel index ne sert que cette expression exacte. Écrire la condition sur la colonne nue reste le premier réflexe.

Vérification rapideon peut se reprendre

1.Un index porte sur (idCours, jour). Laquelle de ces conditions ne peut-il pas servir ?

2.Un index existe sur dateNaissance. Pourquoi WHERE YEAR(dateNaissance) = 2004 fait-il lire toute la table ?

Les comptes et les droits

Le portail de l'école se connecte à la base pour afficher l'annuaire des étudiants. Le plus simple serait de lui donner le compte root : tout marcherait du premier coup. Le portail pourrait aussi lire les notes de chacun, supprimer une table sur une faute de frappe, créer d'autres comptes, et quiconque lirait son fichier de configuration aurait la base entière. Le chapitre sur l'installation a créé un compte de travail limité à une base ; une base en service va plus loin.

Définition

Le principe du moindre privilège veut que chaque compte reçoive les droits dont il a besoin, sur les seuls objets dont il a besoin, et aucun autre. Il en découle un compte par application et par usage, et jamais root dans un programme : une application détournée, par un mot de passe volé ou par l'injection présentée plus bas, ne fait alors que ce que son compte permet.

Pour la base ecole, cela donne quatre comptes.

CompteCe qu'il faitSes droits
portailafficher l'annuaire, enregistrer chaque consultationlire la vue Annuaire, ajouter des lignes à Consultation
saisiesaisir et corriger les noteslire, ajouter et modifier les lignes d'Inscription
tableaules statistiques du soirlire des vues de statistiques, sans rien écrire
sauvegardel'export de la nuitlire toute la base, sans rien écrire

Le portail ne doit voir qu'une partie de la table Etudiant : le nom, le prénom et la classe des étudiants actifs, sans leur adresse, leur date de naissance ni leurs notes. Une vue le dit exactement.

requete.sql
Résultat
>_ Prêt à exécuter…

Huit étudiants sur dix y figurent : Durand et Petit, inactifs, n'y sont pas. Le reste se règle sous MySQL, en root, par trois ordres que la page ne connaît pas, puisqu'elle n'a ni serveur ni comptes.

SQL
-- En root : le compte du portail, puis ses deux droits
CREATE USER 'portail'@'localhost' IDENTIFIED BY 'Choisir-un-mot-de-passe-long';
GRANT SELECT ON ecole.Annuaire TO 'portail'@'localhost';
GRANT INSERT ON ecole.Consultation TO 'portail'@'localhost';
-- Vérifier ce que le compte a le droit de faire
SHOW GRANTS FOR 'portail'@'localhost';

SHOW GRANTS rend trois lignes :

GRANT USAGE ON *.* TO `portail`@`localhost`
GRANT SELECT ON `ecole`.`Annuaire` TO `portail`@`localhost`
GRANT INSERT ON `ecole`.`Consultation` TO `portail`@`localhost`

La première, USAGE, ne donne aucun droit : elle dit que le compte existe, et figure toujours. Chacune des suivantes donne des droits sur un objet, ici une vue et une table. Connecté en portail, SELECT * FROM Annuaire passe ; SELECT email FROM Etudiant est refusé, avec le message SELECT command denied ; DELETE FROM Consultation l'est aussi : le portail écrit dans le journal, il ne l'efface pas.

Le portail lit la vue sans aucun droit sur Etudiant ni sur Classe, parce qu'une vue s'exécute par défaut avec les droits de son auteur, le compte qui l'a créée. C'est le troisième usage d'une vue qu'annonçait le chapitre sur le SQL avancé : n'accorder à un compte que la vue, sans la table entière.

Un droit se retire par REVOKE, qui s'écrit comme GRANT, avec FROM à la place de TO. La scolarité saisissait les notes avec les quatre droits sur les lignes d'Inscription ; elle n'a plus à supprimer d'inscription, ce que fait désormais une procédure de fin d'année.

SQL
-- Le compte de la saisie des notes, avec les quatre droits sur les lignes d'Inscription
CREATE USER 'saisie'@'localhost' IDENTIFIED BY 'Un-autre-mot-de-passe-long';
GRANT SELECT, INSERT, UPDATE, DELETE ON ecole.Inscription TO 'saisie'@'localhost';
-- La scolarité ne supprime plus d'inscription : le droit se retire
REVOKE DELETE ON ecole.Inscription FROM 'saisie'@'localhost';
SHOW GRANTS FOR 'saisie'@'localhost';
GRANT USAGE ON *.* TO `saisie`@`localhost`
GRANT SELECT, INSERT, UPDATE ON `ecole`.`Inscription` TO `saisie`@`localhost`

Trois précautions complètent les droits. La partie qui suit l'arobase dit d'où le compte peut se connecter : 'localhost', la machine elle-même ; '%', n'importe quelle machine, à réserver aux comptes qui en ont vraiment besoin. Le mot de passe d'une application ne s'écrit pas dans son code, que d'autres liront ou publieront, mais dans un fichier de configuration que seul le programme lit. Et MySQL 8 sait regrouper des droits dans un rôle (CREATE ROLE), que plusieurs comptes reçoivent d'un coup.

SQLite, le moteur de la page, n'a aucun compte : une base SQLite est un fichier, et quiconque peut lire le fichier lit toute la base. Ses droits se règlent sur le fichier lui-même, comme ceux du chapitre Droits d'accès et partages de fichiers du parcours Administration système.

Vérification rapideon peut se reprendre

1.Le portail n'affiche que l'annuaire des étudiants actifs. Quel droit lui donner ?

2.SHOW GRANTS FOR 'portail'@'localhost' commence par GRANT USAGE ON *.*. Que donne cette ligne ?

Une saisie qui réécrit la requête

Un outil de la scolarité retrouve les notes d'un étudiant d'après son adresse électronique, saisie dans un formulaire. Son programme fabrique la requête en collant la saisie dans le texte, entre deux apostrophes : pour lea.martin@ecole.fr, il envoie la première requête du bloc. Si la saisie est x' OR '1'='1, le texte collé devient la seconde.

requete.sql
Résultat
>_ Prêt à exécuter…

La première requête rend les trois notes de Léa Martin, la seconde les 25 notes de l'école. L'apostrophe saisie a fermé la chaîne que le programme avait ouverte, OR '1'='1' a ajouté une condition toujours vraie, et l'apostrophe finale du programme a refermé la dernière chaîne : la requête est valide, et elle fait autre chose que ce que son auteur avait écrit. Une saisie qui finit par -- met en commentaire tout ce que le programme écrit après elle : une autre condition, un tri, le contrôle d'un mot de passe. Le même procédé, sur un UPDATE ou un DELETE, modifie ou efface des lignes que personne n'a désignées.

Définitions

Une injection SQL est une saisie qu'un programme colle dans le texte d'une requête, et que le moteur lit comme du SQL : elle change ce que fait la requête.

Une requête paramétrée envoie au moteur le texte de la requête, où chaque valeur attendue est marquée par un ?, et les valeurs à part. Le moteur analyse la requête avant de recevoir les valeurs, et une valeur reste une valeur, quoi qu'elle contienne.

Retirer les apostrophes de la saisie ne protège pas sûrement. Une adresse ou un nom en contiennent sans aucune malice, comme lou.o'neil@ecole.fr ; une valeur numérique n'en a pas besoin pour détourner la requête, comme le montre le bloc Python plus bas ; et chaque moteur a ses façons d'écrire une chaîne. La requête paramétrée ne cherche rien dans la saisie : elle ne lui donne jamais l'occasion d'être lue comme du SQL. Le compte limité de la section précédente borne les dégâts d'une faute qui resterait : connecté avec un compte qui ne lit que l'annuaire, l'outil détourné ne lirait que l'annuaire.

Vérification rapideon peut se reprendre

1.Un programme colle la saisie x' OR '1'='1 dans WHERE email = '...'. Que rend la requête ?

2.Quelle parade protège sûrement de l'injection ?

Interroger une base depuis un programme

Le moteur de la page ne reçoit qu'un texte : il ne connaît pas les paramètres. Les programmes, si. Une application parle à sa base par un module de son langage ; en Python, sqlite3, livré avec Python, pilote une base SQLite, et les modules qui pilotent MySQL, comme mysql-connector-python, suivent les mêmes gestes. Il y en a quatre : ouvrir une connexion, envoyer des requêtes avec leurs paramètres, valider ou annuler, fermer. Le bloc suivant les fait tous, sur une petite table d'inscriptions créée en mémoire.

main.py
Sortie
>_ Prêt à exécuter…
À manipuler
Lire la troisième ligne affichée : la saisie collée compte 3 lignes, la table entière, et la même saisie en paramètre n'en compte aucune. Aucune apostrophe n'a servi : collé après idCours =, le texte 3 OR 1 = 1 ajoute une condition toujours vraie.
Remplacer la saisie par "3" et relancer : 1 ligne de chaque côté, l'inscription de l'étudiant 1 au cours 3. Une saisie honnête rend le même résultat par les deux chemins.
Placer un # devant base.rollback() et relancer : la dernière ligne affiche 16.0. L'ordre refusé n'a annulé que lui-même, et la modification de la note attend toujours dans la transaction ouverte.

Chaque geste a sa ligne. connect ouvre la connexion. execute envoie une requête, et son second argument est le tuple des valeurs, une par ?, dans l'ordre : (cours,), avec sa virgule, est un tuple d'un seul élément. executemany envoie la même requête pour chaque tuple d'une liste. Le module ouvre de lui-même une transaction avant le premier INSERT, UPDATE ou DELETE, comme un BEGIN ; commit la valide, rollback l'annule, et close ferme la connexion en perdant ce qui n'a pas été validé. C'est la leçon du chapitre sur les transactions : une erreur n'annule que l'ordre fautif, et c'est au programme d'attraper l'exception et d'envoyer rollback. Son exercice « Le programme qui annule » fait écrire ce try ; celui-ci fait écrire la requête paramétrée.

Les modules qui pilotent MySQL marquent les valeurs par %s au lieu de ? : l'écriture change, pas le principe. Ce %s n'est pas l'opérateur % de Python qui colle du texte dans une chaîne : la requête et le tuple des valeurs restent deux arguments séparés d'execute.

L'exercice suppose les fonctions du chapitre sur les fonctions du parcours Python.

Exercice : écrire puis vérifier

Un relevé de notes que la saisie ne détourne pas

L'outil de la scolarité retrouve les notes d'un étudiant d'après son adresse électronique. La base base, créée par le code fourni, reprend trois étudiants de la base du cours, Martin, Diallo et Lefebvre, avec leurs cours et leurs notes, et une nouvelle inscrite, Lou O'Neil. Écrire notes_de(email), qui renvoie la liste des couples (intitulé, note) de l'étudiant dont l'adresse est email, rangés par intitulé, et une liste vide si aucune adresse ne correspond.

La fonction de départ colle la saisie dans le texte de la requête. Elle passe trois vérifications et échoue sur les trois autres : la réécrire avec un paramètre ?, que execute remplit sans jamais le lire comme du SQL.

Déjà écrit, à utiliser sans le modifier
À compléter

Écrire la fonction, puis « Vérifier » : 6 vérifications sur des cas qu'on sait faire de tête.

Sauvegarder et restaurer

Le chapitre sur les transactions s'arrêtait sur une promesse : une transaction validée survit à une coupure de courant, parce que le moteur l'a écrite dans son journal, sur le disque, avant d'annoncer la validation. La promesse a une limite, et c'est le disque lui-même. Si le disque lâche, si un DROP TABLE part sur la mauvaise base, si un rançongiciel chiffre le serveur, la base et son journal disparaissent ensemble, ou deviennent faux ensemble. Seule une copie rangée ailleurs les fait revenir.

Deux journaux tournent dans MySQL. Le journal de reprise d'InnoDB rejoue, après une coupure, les transactions validées que les fichiers de la base n'avaient pas encore reçues : c'est la durabilité du chapitre 7. Le journal binaire, actif par défaut depuis MySQL 8.0, consigne chaque transaction validée pour pouvoir la rejouer ailleurs. Aucun des deux n'est une sauvegarde : ils sont sur le disque du serveur. Un disque en miroir ou une réplique ne le sont pas davantage : ils copient la base en continu, et copient de même l'ordre qui l'efface.

Exporter, puis restaurer pour de bon

L'outil mysqldump, installé avec MySQL, écrit un fichier SQL qui recrée les tables et les vues et y remet leurs lignes. Copier les fichiers du serveur pendant qu'il tourne donnerait au contraire une copie incohérente. Ces commandes se tapent dans un terminal, pas à l'invite mysql>.

# Exporter la base ecole dans un fichier, sans bloquer les écritures
mysqldump -u root -p --single-transaction ecole > ecole.sql

# Restaurer dans une base de copie : la créer, puis y rejouer le fichier
mysql -u root -p -e "CREATE DATABASE ecole_copie"
mysql -u root -p ecole_copie < ecole.sql

L'option --single-transaction ouvre une transaction en lecture répétable avant d'exporter : l'export lit la photographie de la base prise à son début, celle du chapitre 7, et les inscriptions saisies pendant ce temps ne s'y mêlent pas. Sans elle, mysqldump verrouille les tables pendant l'export, et le portail attend. Le fichier contient les CREATE TABLE, les CREATE VIEW et les INSERT, mais pas l'ordre qui crée la base : elle doit exister avant la restauration, faute de quoi MySQL répond Unknown database. Sous Windows, taper ces commandes dans l'invite de commandes (cmd) plutôt que dans PowerShell, qui n'accepte pas le < de la restauration. Les droits des comptes, eux, ne sont pas dans l'export de la base : ils vivent dans la base système mysql, qui se sauvegarde à part.

Restaurer dans une base de copie permet de vérifier la sauvegarde sans toucher à l'original. Une sauvegarde qui n'a jamais été restaurée n'est qu'un espoir : le fichier peut être vide, tronqué par un disque plein, ou illisible par la version du serveur qui devra le relire. Le test de restauration la rejoue pour de bon, compare ce qu'elle redonne à l'original, et chronomètre l'opération.

SQL
-- Dans MySQL, après la restauration : les deux bases comptent-elles les mêmes lignes ?
SELECT (SELECT COUNT(*) FROM ecole.Inscription) AS original,
(SELECT COUNT(*) FROM ecole_copie.Inscription) AS copie;

Ce qu'une sauvegarde doit permettre

Ce qu'une sauvegarde doit permettre se dit en deux nombres, que le métier fixe et que la technique doit tenir. Le RPO est la quantité de données qu'il est acceptable de perdre, mesurée en temps : avec un export chaque nuit à 2 h, une panne à 17 h fait perdre quinze heures de saisie. Le RTO est la durée acceptable pour repartir, comptée depuis l'incident : le constater, trouver la sauvegarde, la restaurer, vérifier. Restaurer un export de mysqldump rejoue chaque INSERT et reconstruit chaque index : sur une grosse base, cela se compte en heures, et un RTO ne se promet qu'après avoir été mesuré.

Diminuer le RPO, c'est sauvegarder plus souvent, ou garder ce qui s'est passé depuis la dernière sauvegarde. C'est le rôle du journal binaire : après la restauration de l'export, l'outil mysqlbinlog relit le journal et renvoie ses transactions au serveur, entre la position que l'export a notée (option --source-data de mysqldump) et l'instant qui précède l'incident, ou le DELETE malheureux. La base revient à la minute près. À une condition : que le journal ait survécu, donc qu'il soit copié hors du serveur, souvent et automatiquement.

La copie hors du serveur tient en une règle, dite 3-2-1 : trois exemplaires des données, sur deux supports différents, dont un hors du site. Le chapitre sur la sauvegarde et la reprise d'activité du parcours Administration système la détaille, avec les sauvegardes complètes, incrémentales et différentielles, et ce qu'un support illisible emporte avec lui.

Vérification rapideon peut se reprendre

1.Une transaction validée est garantie durable. Pourquoi ne survit-elle pas à la perte du disque ?

2.Que vérifie un test de restauration ?

Les erreurs d'un débutant

Un raisonnement qui paraît tenir, sur une sauvegarde soignée. Une seule étape est fausse : la trouver, puis donner une valeur qui la met en défaut.

Un journal qui devait tout rattraper

Une seule étape est fausse. Désigner laquelle.

La base de la scolarité est exportée chaque nuit à 2 h par mysqldump, et l'export est copié aussitôt sur une autre machine. Le journal binaire de MySQL, actif par défaut, consigne chaque transaction validée dans le dossier de données du serveur, sur le même disque que la base. Ce disque lâche à hh heures, le même jour. Un collègue prévoit combien d'heures de saisie seront perdues.

D'autres erreurs reviennent, qu'aucun message ne signale.

  • Indexer chaque colonne, au cas où. Chaque écriture paie tous les index, et la plupart ne servent aucune requête.
  • Créer un index sans relire le plan. Une fonction sur la colonne, ou un ordre de colonnes mal choisi, le laissent inutile, et rien ne le dit.
  • Coller une saisie dans une requête, même pour un essai, même pour un nombre.
  • Brancher une application sur root, ou sur un compte partagé par plusieurs applications : le jour où l'une est détournée, toutes le sont.
  • Garder la sauvegarde sur le serveur qu'elle protège, et ne jamais la restaurer.

La méthode

Devant une base qui passe en service.

  1. Partir des requêtes réelles. Relever celles que les applications envoient souvent, et celles qui sont lentes : un index se décide sur elles, pas sur le schéma.
  2. Lire le plan avant de créer quoi que ce soit : EXPLAIN QUERY PLAN dans SQLite, EXPLAIN dans MySQL. SCAN, ou type: ALL, sur une grosse table désigne la requête à soigner.
  3. Indexer les colonnes des conditions, celles d'égalité en tête et celle d'un intervalle après elles, et écrire chaque condition sur la colonne nue.
  4. Vérifier que le plan a changé, et que la requête rend les mêmes lignes ; supprimer l'index qui ne sert aucune requête.
  5. Ne jamais coller une saisie dans une requête : chaque valeur venue de l'extérieur passe par un paramètre, ? ou %s selon le module.
  6. Donner un compte à chaque application, avec ses seuls droits, et une vue quand elle ne doit voir qu'une partie d'une table ; relire SHOW GRANTS après chaque changement.
  7. Fixer avec le métier le RPO et le RTO, puis en déduire la fréquence des exports et celle des copies du journal binaire.
  8. Ranger les copies hors du serveur, et les restaurer pour de bon dans une base de copie, à intervalles réguliers, en comparant les comptes de lignes et en chronométrant.

Exercices type

Choisir l'index d'une requête

Une agence cherche, plusieurs fois par jour, les employés qui ont passé plus de 50 heures sur un projet donné : SELECT idEmploye FROM TravailleSur WHERE idProjet = 1 AND heures > 50. La table a pour clé (idEmploye, idProjet). Quel index créer, et dans quel ordre ses colonnes ?

Afficher la correction

L'index de la clé ne sert pas : sa première colonne est idEmploye, que la requête ne filtre pas. La requête pose une égalité sur idProjet et un intervalle sur heures : l'égalité en tête, l'intervalle après.

requete.sql
Résultat
>_ Prêt à exécuter…

Le plan passe de SCAN TravailleSur à SEARCH TravailleSur USING INDEX idx_travaille_projet_heures (idProjet=? AND heures>?), et la requête rend les employés 1 et 2. Le piège : l'index (heures, idProjet) contient les deux mêmes colonnes, et son plan dit encore SEARCH, mais avec (heures>?) seulement. Il descend par l'intervalle des heures, lit chaque ligne de plus de 50 heures, tous projets confondus, et n'écarte qu'ensuite celles des autres projets. Un plan se lit jusqu'au bout de sa parenthèse.

Les droits du tableau de bord

Le tableau de bord de la direction affiche chaque soir, pour chaque cours, le nombre d'inscrits et la moyenne des notes. Il ne doit lire ni les noms, ni les notes individuelles. Écrire ce qu'il faut créer, et prévoir ce que rend SHOW GRANTS pour son compte.

Afficher la correction

Une vue qui ne livre que les agrégats, puis un compte qui ne lit qu'elle. La vue s'exécute dans la page :

requete.sql
Résultat
>_ Prêt à exécuter…

Le compte et son droit se créent sous MySQL, en root :

SQL
CREATE USER 'tableau'@'localhost' IDENTIFIED BY 'Encore-un-mot-de-passe-long';
GRANT SELECT ON ecole.StatCours TO 'tableau'@'localhost';
SHOW GRANTS FOR 'tableau'@'localhost';

SHOW GRANTS rend deux lignes : GRANT USAGE ON *.*, puis GRANT SELECT ON ecole.StatCours. Le compte n'a aucun droit sur Inscription ni sur Etudiant : la vue s'exécute avec les droits de son auteur.

Le piège est dans les données. La vue rend sept cours, et la ligne Statistiques compte deux inscrits, mais une seule note : sa moyenne, 13,5, est la note de Lefebvre, que tout lecteur du tableau de bord connaît désormais. Une moyenne calculée sur une ou deux notes est une note individuelle. La vue gagne à n'afficher que les cours qui en comptent assez, par un HAVING COUNT(i.note) >= 3 placé après son GROUP BY : Réseaux et Statistiques disparaissent alors du tableau de bord.

Une politique de sauvegarde pour la scolarité

La scolarité saisit des notes de 8 h à 19 h. Elle accepte de perdre au plus une heure de saisie, et de rester au plus quatre heures sans base. L'export de la base dure quarante minutes, sa restauration deux heures et demie. Proposer une politique, et dire ce qu'elle fait perdre pour une panne du disque à 16 h 50.

Afficher la correction

Un export chaque nuit, à 2 h, hors des heures de saisie, copié aussitôt sur une autre machine ; le journal binaire copié hors du serveur toutes les trente minutes, à l'heure pile et à la demie. Le RPO visé, une heure, est tenu avec de la marge.

Pour une panne à 16 h 50, la dernière copie du journal date de 16 h 30 : l'export de 2 h, puis le journal rejoué jusqu'à 16 h 30, ramènent la base à cet instant, et vingt minutes de saisie sont perdues. Pour le RTO, la restauration prend déjà deux heures et demie sur les quatre : il reste une heure et demie pour constater la panne, décider, rejouer le journal et vérifier. C'est tenable, à condition que ces durées aient été mesurées par un test de restauration, et non estimées. Si la base grossit, la restauration par mysqldump sera la première à déborder : il faudra alors un outil de sauvegarde physique, qui copie les fichiers de la base sans rejouer chaque INSERT, ou un serveur de secours déjà chargé.

À calculer soi-même

Ce que lit le moteur, et ce que perd une panne

  • 1.

    Le journal des consultations de toute l'école compte 2 000 000 de lignes, réparties également sur 400 jours. Sans index sur le jour, combien de lignes le moteur lit-il pour trouver les consultations d'un jour ?

  • 2.

    Avec un index sur le jour, combien de lignes de la table lit-il ?

  • 3.

    Un index range ses entrées dans des pages de 100, et chaque niveau de l'arbre multiplie par 100 le nombre d'entrées atteintes. Combien de niveaux faut-il pour qu'il en couvre 1 000 000 ?

  • 4.

    L'export part chaque nuit à 2 h, et le journal binaire est copié hors du serveur toutes les quinze minutes, à l'heure pile, puis à 15, 30 et 45. Le disque du serveur lâche à 17 h 50. Combien de minutes de saisie sont perdues ?

Synthèse

  • Un index est une structure triée qui mène aux lignes d'une valeur sans lire les autres ; il change le chemin du moteur, jamais le résultat, et se paie à chaque écriture.
  • Le plan d'exécution se lit avant et après : SCAN lit toute la table, SEARCH ... USING INDEX n'en lit que ce que l'index désigne. Il se compare en lignes lues, pas en durées, qui varient d'une machine à l'autre.
  • Un index ne sert que les conditions posées sur ses premières colonnes, dans l'ordre, l'égalité avant l'intervalle, et sur la colonne nue : ni fonction, ni calcul, ni motif qui commence par %.
  • Le moindre privilège donne à chaque application son compte et ses seuls droits (GRANT, retirés par REVOKE), une vue quand elle ne doit voir qu'une partie des données, et SHOW GRANTS le vérifie.
  • Une injection SQL est une saisie collée dans une requête et lue comme du SQL ; la requête paramétrée envoie les valeurs à part, et aucune saisie ne change plus ce que fait la requête.
  • Un programme ouvre une connexion, envoie ses requêtes avec leurs paramètres, valide par commit ou annule par rollback quand un ordre échoue, puis ferme.
  • Une transaction validée survit à une coupure de courant, pas à la perte du disque : seule une sauvegarde rangée hors du serveur fait revenir la base, et elle ne vaut qu'une fois restaurée pour de bon.
  • Le RPO, les données qu'il est acceptable de perdre, se règle par la fréquence des exports et des copies du journal binaire ; le RTO, le temps pour repartir, par une restauration mesurée.

Et ensuite

Le parcours se referme sur deux pages d'entraînement : l'atelier d'algèbre relationnelle, où chaque expression écrite est vérifiée sur plusieurs cas, et le recueil d'exercices corrigés, qui reprend la modélisation, l'algèbre et le SQL. Le thème Exploiter une base fait choisir un index d'après le plan, compter les lignes qu'une requête lit, lire les droits d'un compte, et écrire en Python une connexion qu'aucune saisie n'ouvre.

Une base qui ne tient plus sur une seule machine est l'objet du parcours Big data : son chapitre sur l'interrogation et l'optimisation y reprend la question de ce chapitre, la quantité de données qu'une requête est obligée de lire, avec des partitions et des fichiers à la place des index.

Chapitre révisé en octobre 2026 · Une erreur dans ce chapitre ?

Mettre en pratique

Créer l'index qui sert deux conditions, compter les lignes qu'une requête lit selon ses index, lire les droits d'un compte, écrire la vue que voit le portail, puis une connexion qu'aucune saisie n'ouvre.

Tous les exercices sur exploiter une base