Migrer MySQL 5.5 vers 8 en production : ce qui casse en silence
Aurélien Piskorski — dev senior PHP/Symfony
Sommaire
Quand on planifie la modernisation d’une application PHP 5, toute l’attention va à PHP : les fonctions supprimées, les paliers de version, Rector, PHPStan. MySQL, lui, passe pour la partie facile — « on monte la version du serveur et on verra bien ».
Et c’est vrai que l’essentiel du travail est côté PHP. Mais les mauvaises surprises — celles qu’on découvre en production des semaines plus tard — viennent presque toutes de la base. J’ai mené cette double migration (PHP 5.6 / MySQL 5.5 → PHP 8.2 / MySQL 8) sur une plateforme en production, sans interruption de service, et le constat tient en une phrase : PHP casse fort, MySQL casse en silence. Une fonction PHP supprimée produit une erreur fatale, visible dans les logs à la première requête. Une requête SQL dont le comportement a changé entre 5.5 et 8 ne produit souvent… rien. Elle renvoie un résultat différent, et personne ne le sait.
Cet article est le complément base de données de la méthode générale de modernisation : tout ce qui casse entre MySQL 5.5 et MySQL 8, classé en deux familles — ce qui casse franchement (tant mieux) et ce qui casse sans bruit (le vrai danger) — puis la méthode pour basculer sans coupure.
Ce qui casse franchement — la partie facile
Ces ruptures produisent des erreurs explicites. Elles font peur, mais ce sont les meilleures : elles se détectent en recette et se corrigent une par une.
- Le mode strict par défaut. Depuis MySQL 5.7,
sql_modeinclutSTRICT_TRANS_TABLES,NO_ZERO_DATEetERROR_FOR_DIVISION_BY_ZERO. Tout ce que 5.5 acceptait en tronquant silencieusement — une chaîne trop longue, une date invalide, un entier hors plage — devient une erreur d’insertion. Sur quinze ans de données, comptez des milliers de lignes contenant des dates0000-00-00: elles devront être nettoyées avant la migration. ONLY_FULL_GROUP_BY, activé par défaut lui aussi : toutes les requêtes qui sélectionnent des colonnes hors agrégat et horsGROUP BY— une tolérance dont le code de 2010 abuse systématiquement — sont rejetées. C’est du travail mécanique, mais volumineux.- Les mots réservés.
RANK,GROUPS,ROW,CUME_DIST… sont devenus des mots-clés avec les fonctions de fenêtrage de MySQL 8. La colonnerankcréée en 2009 provoque une erreur de syntaxe neuf ans plus tard. Un inventaire du schéma contre la liste officielle prend une heure et évite des surprises. - L’authentification. MySQL 8 utilise
caching_sha2_passwordpar défaut. Les vieux connecteurs (PHP 5, anciensmysqlnd, vieux drivers JDBC/ODBC des outils internes) ne savent pas s’y connecter du tout. C’est un point de coordination avec la migration PHP : l’ordre des opérations n’est pas libre. - La gestion des utilisateurs.
GRANTne crée plus de compte implicitement — il faut unCREATE USERexplicite. Tous les scripts d’exploitation écrits « à l’ancienne » cassent.
Ce qui casse en silence — le vrai danger
Rien de ce qui suit ne produit d’erreur. C’est pire que si ça plantait : la seule façon de le détecter, c’est de comparer les résultats avant/après.
Le GROUP BY ne trie plus. Jusqu’à MySQL 5.7, GROUP BY triait implicitement les résultats sur les colonnes de regroupement. MySQL 8 a supprimé ce tri. Toute requête qui comptait dessus sans ORDER BY explicite renvoie les mêmes lignes… dans un ordre quelconque. Sur un export comptable ou un listing métier, l’écran affiche « autre chose » qu’hier, et aucun log ne le signale.
-- MySQL 5.5 : résultats triés par client_id, « gratuitement »
SELECT client_id, SUM(montant_ht)
FROM factures
GROUP BY client_id;
-- MySQL 8 : même requête, ordre non garanti.
-- Le correctif est trivial, encore faut-il trouver
-- les requêtes concernées :
SELECT client_id, SUM(montant_ht)
FROM factures
GROUP BY client_id
ORDER BY client_id;
La collation par défaut change tout. MySQL 8 crée les nouvelles tables en utf8mb4_0900_ai_ci, quand l’existant est en latin1 ou utf8_general_ci. Trois conséquences concrètes : des tris et comparaisons différents sur les accents et la casse ; des erreurs Illegal mix of collations sur les jointures entre anciennes et nouvelles tables ; et — le plus vicieux — des jointures qui fonctionnent mais n’utilisent plus les index, parce que MySQL doit convertir une des deux colonnes à la volée. La requête est juste, le résultat est juste, et le temps de réponse passe de 20 ms à 4 secondes. Un EXPLAIN avant/après sur les jointures entre tables de générations différentes fait partie de la recette :
EXPLAIN SELECT c.nom, f.montant_ht
FROM factures f -- table 2024, utf8mb4_0900_ai_ci
JOIN clients c -- table 2009, latin1_swedish_ci
ON c.reference = f.reference_client;
-- type: ALL, key: NULL → l'index sur clients.reference est ignoré,
-- MySQL convertit la colonne à chaque ligne. Full scan silencieux.
Le query cache n’existe plus. Supprimé purement et simplement en 8.0. Une application legacy qui répète les mêmes SELECT à chaque page — c’est-à-dire à peu près toutes les applications legacy — bénéficiait de ce cache sans le savoir. Après migration, ces pages rament sans qu’une seule ligne de code ait changé. La réponse se trouve côté application (cache applicatif, Redis, ou simplement corriger les requêtes répétées), et elle doit être anticipée, pas découverte en production.
Les TIMESTAMP implicites. explicit_defaults_for_timestamp est activé par défaut en 8.0 : la première colonne TIMESTAMP d’une table ne reçoit plus automatiquement DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP. Les tables créées après la migration se comportent différemment de leurs aînées — le genre de bug qu’on découvre des mois plus tard.
utf8 ne veut pas dire UTF-8. Dans MySQL, utf8 est un alias historique d’utf8mb3 : trois octets maximum, donc pas d’emoji ni de caractères hors plan multilingue de base. La migration est le bon moment pour passer réellement en utf8mb4 — mais c’est une conversion de données à part entière, pas un renommage.
La méthode : basculer sans coupure
Le principe est le même que pour le code applicatif : des étapes petites, réversibles, validées par le trafic réel. Appliqué à la base :
-
Inventorier les requêtes réelles. Le slow log avec seuil à zéro (ou le general log par échantillons) sur quelques jours de production donne la liste des requêtes qui existent vraiment — pas celles qu’on croit avoir. C’est la matière première de toutes les vérifications suivantes.
-
Durcir avant de migrer. Presque tout le travail peut se faire sur l’ancien serveur : activer le
sql_modecible côté application (connexion par connexion, donc réversible), nettoyer les dates zéro, renommer les colonnes aux noms réservés, corriger lesGROUP BY. Chaque correctif est déployé et observé sur la 5.5. Le jour de la bascule, il ne reste presque rien à découvrir.-- Sur l'ancien serveur, à l'ouverture de connexion de l'application : SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; -- Réversible en une ligne si un écran casse — c'est tout l'intérêt. -
Monter par paliers, par réplication. On ne saute pas de 5.5 à 8.0 : la réplication n’est supportée qu’entre versions adjacentes. On chaîne donc des réplicas 5.6 → 5.7 → 8.0. À chaque palier,
mysql_upgrade/mysqlsh util.checkForServerUpgradesignale les incompatibilités de schéma. -
Rejouer et comparer. C’est l’étape qui attrape les casses silencieuses : rejouer les requêtes inventoriées à l’étape 1 contre l’ancien et le nouveau serveur, et comparer les résultats —
pt-upgrade(Percona Toolkit) fait exactement ça, résultats et plans d’exécution compris. Chaque différence est soit un bug à corriger, soit un changement à assumer, mais plus jamais une surprise.# Rejoue le trafic réel contre les deux serveurs et rapporte chaque différence # (résultats, erreurs, temps d'exécution) pt-upgrade slow-prod.log h=mysql55.interne h=mysql8.interne -
Basculer petit. Une fois le réplica 8.0 à jour et validé, la bascule elle-même tient en quelques secondes de gel des écritures, en heures creuses. Le plan de retour doit exister avant (sauvegarde + binlogs), car la réplication inverse 8 → 5.5 n’existe pas : après quelques jours d’écritures sur le nouveau serveur, on ne revient plus. C’est la seule étape réellement irréversible — raison de plus pour que tout le reste ait été fait avant.
-
Observer. Une à deux semaines de comparaison des slow logs avant/après, à la recherche des régressions de plan d’exécution et des pages orphelines du query cache.
Le cas MyISAM
Beaucoup de bases de cette génération mélangent InnoDB et MyISAM. MySQL 8 tolère encore MyISAM, mais tout pousse à convertir : les tables partitionnées MyISAM ne sont plus supportées du tout, et la recherche full-text existe sur InnoDB depuis la 5.6. La conversion est simple (ALTER TABLE … ENGINE=InnoDB) et généralement bénéfique — verrous par ligne au lieu de verrous de table — avec une exception notable : SELECT COUNT(*) sans condition, instantané sur MyISAM, devient un vrai parcours sur InnoDB. Si un tableau de bord l’utilise sur une table de dix millions de lignes, il faut le savoir avant.
Par où commencer
Si votre base est en 5.5 ou 5.6, l’ordre est toujours le même : inventaire des requêtes réelles, activation progressive du mode strict sur l’existant, nettoyage des données, puis la chaîne de réplicas et le rejeu comparé. Les trois premières étapes ne touchent pas au serveur de production et éliminent l’essentiel du risque — c’est le point de départ sans regret.
C’est exactement le type de chantier que je mène en freelance, en général couplé à la montée de version PHP. Si vous voulez un avis sur votre situation, 30 minutes suffisent pour un premier diagnostic honnête.