SpringBatch-ETL : migrer 73 millions de lignes en 40 minutes
ETL Java 21 / Spring Batch pour la migration de PharmaGDD v1 vers v2 : 73 M de lignes MySQL à ~1,8 M lignes/min, colonnes PHP sérialisées décodées par un daemon Swoole en TCP, rejoué chaque nuit par GitHub Actions.
- Contexte
- La refonte de PharmaGDD (v1 Symfony vers v2 Sylius) demande de migrer toute la base, dont des colonnes stockées au format sérialisé de PHP.
- Mon rôle
- Conception et développement de l'ETL, puis de son pipeline : images Docker, exécution nocturne sur une VM cloud, contrôles d'intégrité.
- Résultat
- 73 millions de lignes migrées en 40 minutes (~1,8 M lignes/min), rejouées chaque nuit sur le dump de prod.
Le contexte
PharmaGDD passe de sa v1 (Symfony, legacy) à une v2 sous Sylius. Les schémas ne sont pas les mêmes, et une partie des données est stockée dans des colonnes sérialisées au format PHP (serialize()), illisibles telles quelles depuis Java.
Il fallait un outil capable de migrer 73 millions de lignes en un temps raisonnable, et de le refaire autant de fois que nécessaire avant la bascule.
Décoder du PHP depuis Java
C’est la contrainte qui a guidé l’architecture.
- Un script PHP par ligne, lancé en ligne de commande : écarté tout de suite. Le démarrage de l’interpréteur à chaque appel rend l’approche inutilisable à cette échelle.
- Une API HTTP en PHP : c’était ma première idée. Mais pour des millions de petits appels, le coût de HTTP (en-têtes, connexions) pèse plus lourd que la désérialisation elle-même.
- Un daemon Swoole en TCP : c’est la solution retenue. Un conteneur PHP tourne en permanence, garde la connexion ouverte et répond en JSON. Java lui envoie la donnée brute et récupère une structure exploitable.
Le pipeline
L’ETL est écrit en Java 21 avec Spring Batch, qui découpe le travail en chunks transactionnels.
- Une chaîne de processeurs : le premier envoie la donnée au daemon Swoole et récupère le JSON, les suivants s’occupent du mapping des colonnes et des conversions de locales.
- Plusieurs tables en parallèle grâce à un pool de threads (
ThreadPoolTaskExecutor). - Un job générique : les tables et les renommages de colonnes (par exemple
owner_id→product_id) sont déclarés dansapplication.yml, et le job se construit au démarrage. Ajouter une table ne demande pas de code. - Réglages MySQL :
innodb_buffer_pool_sizeetmax_allowed_packetajustés pour encaisser les écritures en masse.
Résultat : environ 1,8 million de lignes par minute, soit 40 minutes pour l’ensemble.
Rejoué chaque nuit
Une migration ne se joue pas une seule fois. Pour arriver à la bascule avec un outil éprouvé, l’ETL tourne toutes les nuits sur une VM cloud, orchestré par GitHub Actions sur un runner self-hosted :
- Téléchargement du dump de prod du jour depuis un bucket S3 OVH, puis import dans une MySQL source.
- Lancement de l’ETL à partir des images Docker (ETL Java et daemon Swoole), construites par la CI et poussées dans le registry OVH.
- Contrôles d’intégrité sur la base cible : des requêtes comptent les lignes orphelines (clés étrangères sans parent). Un résultat non nul fait échouer le run.
- Nettoyage des conteneurs et des données.
Ces contrôles attrapent par exemple une table parente oubliée dans la configuration : les enfants sont migrés, le parent non, et l’erreur remonte dès le lendemain au lieu d’apparaître des jours plus tard.
Ce que j’en retiens
- Le choix du protocole compte autant que le code : passer de HTTP à TCP a été le vrai déblocage.
- Une migration de données se traite comme un logiciel : versionnée, packagée, rejouée et vérifiée automatiquement.
- Rendre le job générique dès le départ a évité d’écrire un job par table.