Migration de la base en utf8mb4

Marche à suivre pour convertir la base de production de latin1 vers utf8mb4.

Cette migration ne s’exécute pas au déploiement. Plesk tire main et ne lance aucune migration. Elle doit être lancée à la main, par le détenteur des accès à la base.

Pourquoi

La base est en latin1. Tout caractère hors cp1252 saisi par un contributeur y est remplacé par ? à l’écriture, définitivement.

saisi ce que la base en fait après migration

Sigur Rós, für Elise

intact

intact

Paweł Zadrożniak

Pawe? Zadro?niak

intact

Сергей Прокофьев

?????? ?????????

intact

🎵

?

intact

Au dernier relevé : 81 morceaux ont un titre ou un auteur détruit, 37 contributeurs sont concernés, et cinq dégâts datent de 2026. Sur 8 097 morceaux et dix-huit ans, pas un seul caractère hors cp1252 n’a survécu.

L’ordre des gestes, qui est une contrainte

Convertir les tables d’abord, changer l’encodage de connexion ensuite.

Déclarer encoding: utf8mb4 avant la conversion ferait envoyer de l’utf8mb4 vers des colonnes latin1 — le mécanisme qui détruit aujourd’hui, en pire.

livraison 1   le script et cette page          aucun effet en production
    ↓
  À LA MAIN   lancer le script                 les tables passent en utf8mb4
    ↓
livraison 2   encoding: utf8mb4 sur `all`      la connexion cesse de convertir

Entre les deux, l’application lit des tables utf8mb4 sur une connexion utf8. MySQL reconvertit, et utf8 couvre tout le plan multilingue de base : rien de l’existant ne se perd. Seuls les emoji restent refusés, et bruyamment. C’est un état sûr, dans lequel on peut rester des jours.

Avant de lancer

  1. Dump complet, et vérifier qu’il se relit. Un dump non testé n’est pas une sauvegarde. C’est le seul retour arrière : CONVERT TO CHARACTER SET latin1 ne rend rien, il re-détruirait ce que la conversion vient de sauver.

    mysqldump -u<user> -p --default-character-set=latin1 <base> > avant-utf8mb4.sql
    mysql -u<user> -p -e "CREATE DATABASE verif_dump"
    mysql -u<user> -p --default-character-set=latin1 verif_dump < avant-utf8mb4.sql
  2. Relever un échantillon accentué et ses octets. C’est contre lui qu’on vérifiera que rien n’a bougé.

    SELECT id, track_author, HEX(track_author)
      FROM post WHERE track_author REGEXP '[^ -~]' ORDER BY id LIMIT 5;

Lancer

mysql -u<user> -p <base> < src/data/migration/utf8mb4.sql

Le script commence par un contrôle préalable qui s’arrête si l’un des trois états attendus est faux : rien à convertir, pas de table post, ou du double encodage détecté. Le troisième est le décisif — voir plus bas.

Il saute les tables déjà converties : une exécution interrompue se relance sans dommage.

Après

  1. Vérifier que toutes les tables portent utf8mb4 — le script l’affiche en fin d’exécution.

  2. Lancer la commande que le script affiche pour la base elle-même. Elle ne touche aucune donnée ; elle fixe le jeu par défaut des tables créées ensuite.

    ALTER DATABASE n’est pas supporté par le protocole des requêtes préparées (erreur 1295), et le script ne connaît pas le nom de la base. D’où cette commande à part.
  3. Reconstruire l’index de recherche : la collation de post_index a changé.

    mysql -u<user> -p -e "TRUNCATE post_index;" <base>
    
    cd src && php -r 'require "config/ProjectConfiguration.class.php";
      $c = ProjectConfiguration::getApplicationConfiguration("frontend", "prod", false);
      new sfDatabaseManager($c);
      Doctrine_Core::getTable("Post")->batchUpdateIndex();'
    Le TRUNCATE n’est pas une précaution, c’est la moitié de la commande. batchUpdateIndex() est incrémental : sa requête ne sélectionne que les morceaux absents de l’index. Sur un index déjà peuplé, elle ne fait rien — 0,2 s et aucun changement. C’est précisément le cas après un changement de collation, où toutes les lignes sont présentes mais mal collationnées.

    Mesuré sur 6 155 morceaux : 14,7 s depuis un index vide, 19 Mo de pic mémoire, 28 129 entrées produites. Compter une vingtaine de secondes pour les 8 216 morceaux de la production.

    L’index local portait 28 164 entrées avant reconstruction contre 28 129 après : 35 orphelines, reliquats de morceaux supprimés ou modifiés. Vider avant de reconstruire les élimine au passage.
  4. Reprendre l’échantillon relevé avant. Le texte doit être identique, et le HEX() avoir changé — un octet latin1 devient deux octets UTF-8 :

    avant   Güyôm    HEX=47FC79F46D
    après   Güyôm    HEX=47C3BC79C3B46D
    Si le texte a bougé, la conversion a mal tourné. Restaurer le dump.
  5. Seulement alors, poser encoding: utf8mb4 sur le bloc all de config/databases.yml-dist et livrer.

  6. Vider le cache après le déploiement.

    cd src && php symfony cache:clear
    databases.yml est compilé dans cache/. Sans cette commande, la connexion continue de négocier l’ancien encodage et le changement reste sans effet. Plesk tire le code, il ne vide pas le cache.

Vérification finale, sur le site en ligne

Ces étapes closent la migration. Elles demandent le site déployé et un compte d’admin.

  1. Poster depuis l’admin un morceau au titre cyrillique et un portant un emoji, comme un contributeur le ferait. C’est le seul test qui exerce le chemin réel ; tout le reste n’en est qu’une approximation.

  2. Vérifier qu’ils sont servis intacts dans la page, le JSON, le XSPF et le max.

  3. Vérifier que la recherche les trouve — la collation de post_index a changé.

  4. Les retirer, ou les garder : mais le décider, pas l’oublier.

Le contrôle qui décide, et pourquoi

CONVERT TO CHARACTER SET réinterprète les octets comme du latin1 authentique. Si le corpus portait de l’UTF-8 rangé dans des colonnes latin1 — un double encodage — la conversion produirait du mojibake définitif.

Le contrôle cherche les formes latin1 des séquences UTF-8 les plus courantes en français : é pour é, è pour è, à ` pour `à. S’il en trouve une, il s’arrête.

Ne pas forcer. Un corpus doublement encodé demande un traitement différent, et le forcer détruit ce qui restait.

Répétition menée le 2026-08-18

Le script a été éprouvé sur quatre copies, dont une copie des données réelles de production, jamais sur la production elle-même.

Sur les données réelles

contrôle résultat

double encodage sur 8 216 morceaux

0 — la conversion fera exactement ce qu’il faut

tables converties

12 ; les quinze tables directus_* non touchées

Güyôm

47FC79F46D47C3BC79C3B46D, texte identique

variètè_Good

E8C3A8, deux fois

nombre de morceaux

8 216 avant, 8 216 après

morceaux détruits

82 avant, 82 après — ni réparés, ni aggravés

mojibake introduit

0

emoji, cyrillique, idéogrammes après conversion

acceptés

Sur des copies de structure

épreuve

résultat

[cols="2,3"]

| épreuve | résultat

| conversion d’une copie latin1 complète | 4 tables converties ; Güyôm conservé, HEX passé de 47FC79F46D à 47C3BC79C3B46D

| insertion d’un emoji après conversion | acceptée

| relance sur une base déjà convertie | arrêt avec un message explicite

| reprise après interruption à mi-parcours | 2 tables restantes converties, les 2 déjà faites intouchées

| corpus portant un double encodage | arrêt — le garde-fou mord

=== Répétée sur MariaDB, le moteur de la production

L’environnement de développement a été porté de MySQL 5.7 à MariaDB 10.11, la version de la production. La répétition a donc eu lieu sur le moteur cible, et elle a révélé un défaut du contrôle préalable que MySQL masquait.

[cols="2,3"]

| contrôle sur MariaDB 10.11 | résultat

| contrôle préalable, corpus réel | passé | tables converties | 12 ; directus_* non touchées | Güyôm | 47FC79F46D47C3BC79C3B46D | variètè_Good | E8C3A8, deux fois | morceaux | 8 216 avant, 8 216 après | morceaux détruits | 82 avant, 82 après | corpus empoisonné | arrêt — le garde-fou mord

=== Le défaut que MariaDB a révélé

La première version du contrôle cherchait LIKE '%é%'. Une chaîne littérale traverse une conversion de jeu de caractères avant d’atteindre une colonne latin1, et le résultat dépend du client. Sur MariaDB avec son client utf8mb3 par défaut, ce motif remontait 3 538 corps sur 8 216 : le contrôle se serait arrêté à tous les coups.

Un garde-fou qui se déclenche toujours ne vaut pas mieux qu’un garde-fou qui ne se déclenche jamais.

La comparaison porte désormais sur les octets, via HEX(), qui ne traverse aucune conversion. Un é doublement encodé occupe deux octets C3 A9 dans une colonne latin1 ; un é latin1 authentique en occupe un seul, E9. La distinction est nette et ne dépend d’aucun réglage de connexion — 0 sur le corpus réel, 1 dès qu’on empoisonne une ligne.

=== Un faux positif écarté

Une sonde a signalé un C3 83 après conversion, dans le corps du morceau 3062. Ce n’est pas du mojibake : ce corps porte 83 octets de contrôle avant conversion, reliquats d’un import binaire que le gabarit XSPF documente déjà et nettoie à l’affichage. La conversion les préserve fidèlement — elle n’en crée pas.

== Ce que cette migration ne fait pas

Les 81 morceaux déjà détruits le restent. Le ? a remplacé l’octet à l’écriture ; aucune conversion ne le rend, et une sauvegarde antérieure porte les mêmes ?.

La confondre avec une réparation ferait croire le problème réglé. L’inventaire de ce qui est perdu est un travail à part.