Passer au contenu
DedicatedPHP Contact

Migrer le schéma de grandes tables sans bloquer les écritures

Planifiez les changements de schéma en production avec PHP : déployez par étapes, traitez les données par lots et vérifiez la concurrence avant de retirer l’ancienne structure.

Schéma d’une migration progressive de schéma avec backfill par lots, contrôle des écritures simultanées et validation avant la suppression de l’ancienne structure

Modifier une colonne peut être simple dans une petite base de données. Dans une table volumineuse et active, la même opération peut nécessiter un verrouillage, reconstruire des index ou entrer en concurrence avec les requêtes de production pour accéder aux ressources. L’effet dépend du moteur, de sa version, du type de changement et de la configuration : ne partez pas du principe qu’une instruction sera instantanée ou ne bloquera pas les opérations.

Les migrations de schéma sur de grandes tables séparent le changement structurel de la transformation des données. L’objectif est de maintenir la compatibilité entre les versions de l’application pendant la transition, de mesurer l’impact et de pouvoir interrompre le processus. Il n’existe pas de recette universelle : vérifiez les capacités du moteur et le comportement réel de l’application.

Pourquoi un petit changement peut bloquer des opérations

Pourquoi un petit changement peut bloquer des opérations — guía visual de DedicatedPHP

Élargir une colonne, ajouter une contrainte ou modifier un type peut impliquer un travail proportionnel à la taille de la table. Selon le moteur, l’opération peut maintenir des verrous, générer une charge disque, affecter les réplicas ou attendre la fin des transactions ouvertes. Même une opération considérée comme étant en ligne peut bloquer brièvement ou présenter des limitations.

Évaluez la taille et la croissance de la table, la charge de lecture et d’écriture, les transactions longues, les index et l’espace disponible. Consultez la documentation de la version concernée et effectuez des essais dans un environnement représentatif. Définissez des seuils opérationnels pour la latence, les verrous, le stockage et le retard de réplication.

Une mise à jour conventionnelle du schéma modifie les structures, par exemple en ajoutant une colonne. Une migration de données transforme ou copie les valeurs existantes, souvent ligne par ligne. Ces opérations peuvent faire partie du même changement fonctionnel, mais présentent des risques différents ; il est préférable de les exécuter et de les surveiller séparément.

Inventorier les lecteurs, les composants d’écriture et les dépendances

Recherchez qui lit et écrit la donnée : code PHP, requêtes SQL, tâches en file d’attente, commandes planifiées, imports, rapports et services externes. Vérifiez quelles versions peuvent coexister lors d’un déploiement progressif. Les requêtes dynamiques et les consommateurs extérieurs au dépôt sont également à prendre en compte.

  • Documentez les formats actuel et cible, y compris les valeurs nulles, les valeurs par défaut et les règles de conversion.
  • Identifiez les index, les clés étrangères, les contraintes et les vues dépendantes.
  • Vérifiez quels composants mettent à jour les champs partiellement et lesquels écrivent la donnée.
  • Déterminez comment détecter et corriger les valeurs non valides.

Cet inventaire détermine l’ordre du déploiement. Une ancienne version peut échouer si une colonne qu’elle interroge est supprimée. Si vous ne pouvez pas identifier tous les consommateurs, partez du principe que l’ancien code pourrait rester actif plus longtemps.

Diviser le changement en étapes compatibles

Un schéma courant consiste à étendre, puis à contracter : ajouter la nouvelle structure sans supprimer l’ancienne, déployer du code compatible, copier les données historiques et basculer les lectures. La structure ancienne n’est supprimée qu’après vérification du résultat.

  1. Étendre : ajoutez la nouvelle colonne ou table sans casser les versions en production. Envisagez de créer les index et les contraintes lors d’une opération distincte.
  2. Déployer la compatibilité : publiez des lecteurs qui tolèrent les données en attente et des composants d’écriture qui maintiennent la cohérence des deux représentations.
  3. Compléter l’historique : exécutez le backfill par lots et suivez sa progression.
  4. Basculer l’utilisation : lisez principalement la nouvelle structure et surveillez les erreurs, la latence et les écarts.
  5. Contracter : cessez d’abord les écritures dans l’ancienne structure puis, lors d’un déploiement ultérieur, supprimez-la.

Déployer du code n’oblige pas à activer immédiatement la fonctionnalité. Confirmez que chaque version fonctionne avec le schéma de chaque étape, y compris si un retour à une version précédente du code s’avère nécessaire.

Exécuter un backfill contrôlé depuis PHP

Évitez de charger toute la table en mémoire ou de maintenir une transaction globale. Une commande PHP en ligne de commande permet de contrôler la taille des lots, d’enregistrer la progression et d’interrompre le processus sans le lier au cycle d’une requête Web. Ce modèle avec PDO utilise une approche optimiste. progressStore représente un enregistrement de progression stocké dans la même base de données et validé dans la même transaction que les lignes du lot.

$limit = 200;
$maxAttempts = 5;
$cursor = (int) $progressStore->load('backfill');

// Limite supérieure initiale ; ne couvre pas à elle seule les insertions tardives.
$upperId = (int) $pdo->query('SELECT MAX(id) FROM records')->fetchColumn();

while ($cursor < $upperId) {
    $done = false;
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $select = $pdo->prepare(
            'SELECT id, old_value, version FROM records
             WHERE id > :cursor AND id <= :upper_id
             ORDER BY id LIMIT ' . (int) $limit
        );
        $select->execute([':cursor' => $cursor, ':upper_id' => $upperId]);
        $rows = $select->fetchAll(PDO::FETCH_ASSOC);
        if (!$rows) {
            $cursor = $upperId;
            $done = true;
            break;
        }

        try {
            $pdo->beginTransaction();
            $update = $pdo->prepare(
                'UPDATE records SET new_value = :value, version = version + 1
                 WHERE id = :id AND version = :version'
            );
            foreach ($rows as $row) {
                $update->execute([
                    ':value' => transform($row['old_value']),
                    ':id' => $row['id'], ':version' => $row['version'],
                ]);
                if ($update->rowCount() !== 1) {
                    throw new VersionConflict('La ligne a changé pendant le backfill');
                }
            }
            $next = (int) end($rows)['id'];
            $progressStore->saveWithinTransaction('backfill', $next);
            $pdo->commit();
            $cursor = $next;
            $done = true;
            break;
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            $retryable = $e instanceof VersionConflict
                || ($e instanceof PDOException && isRetryableDatabaseError($e));
            if (!$retryable || $attempt === $maxAttempts) throw $e;
            usleep(min(100000 * (2 ** ($attempt - 1)), 2000000));
            // Relire le lot avec le même curseur ; la progression n’a pas encore avancé.
        }
    }
    if (!$done) throw new RuntimeException('Lot en attente ; le curseur n’a pas avancé.');
}

VersionConflict doit être une exception dédiée, et non une exception générique regroupant les erreurs de transformation. Ainsi, les échecs permanents de transform() interrompent la commande afin de permettre un diagnostic. isRetryableDatabaseError() doit reconnaître uniquement les erreurs transitoires répertoriées pour le moteur et le pilote utilisés, comme les interblocages ou les délais d’attente dépassés. Les autres erreurs de base de données interrompent le processus. Le nombre maximal de tentatives et le backoff limité évitent les nouvelles tentatives indéfinies ; une fois ce nombre atteint, la commande échoue sans faire avancer le curseur. Vérifiez comment votre combinaison de PDO et de moteur renseigne rowCount().

La protection optimiste exige que tous les composants d’écriture incrémentent version dans la même transaction que celle qui met à jour les données. Ce contrat concerne le code PHP, les files d’attente, les imports et les services externes ; il doit être en place et vérifié avant de démarrer le backfill. Si un composant d’écriture ne le respecte pas, la comparaison peut ne pas détecter la concurrence : mettez-le à jour, faites-le passer par un mécanisme commun, désactivez-le temporairement ou utilisez des verrous adaptés. Ne considérez pas la protection comme valide avant d’avoir vérifié le contrat de chaque composant d’écriture.

La limite supérieure initiale réduit le travail, mais ne garantit pas l’inclusion des insertions validées tardivement. À la fin du parcours, effectuez une réconciliation qui recherche explicitement les lignes non transformées ou divergentes, sans dépendre du fait que leur ID soit supérieur au curseur. Corrigez ces enregistrements et vérifiez-les à nouveau ; répétez le parcours jusqu’à ce qu’il ne reste plus de lignes en attente, selon un critère vérifiable et avec des composants d’écriture qui maintiennent la compatibilité. Si vous ne pouvez pas détecter ni réconcilier ces lignes de manière fiable, envisagez la capture des changements ou une fenêtre de maintenance. Ne déclarez pas l’historique complet au seul motif que le curseur a atteint la limite initiale.

La transformation doit être idempotente et la progression doit être validée atomiquement avec les lignes. Si le stockage du curseur ne partage pas la transaction avec les données, concevez la reprise de manière à pouvoir répéter sans risque des lignes déjà validées. Ajoutez des options de pause, des limites de taille des lots, des journaux structurés et un code de sortie reflétant les échecs ; ajustez la taille à partir de mesures, et non d’hypothèses.

Éviter les conditions de concurrence avec les écritures simultanées

La double écriture ne suffit pas à elle seule à éviter toutes les conditions de concurrence. Le backfill peut lire une ancienne valeur ; une écriture simultanée peut mettre à jour les deux champs ; puis le backfill peut écraser le nouveau champ avec un résultat obsolète. Dans l’exemple, la condition sur la version empêche cette mise à jour si l’écriture simultanée a incrémenté la version ; le conflit est conservé, car le curseur n’avance qu’après la validation de l’ensemble du lot.

Il est également possible d’utiliser une mise à jour conditionnelle fondée sur la valeur d’origine, ou des verrous adaptés, selon les garanties offertes par le moteur et la charge de travail. Chaque solution présente des coûts et des sémantiques différents. Testez la stratégie sur le moteur concerné avec des écritures entrelacées, des interblocages et des délais d’attente dépassés, puis vérifiez les lignes affectées ainsi que la réconciliation avant d’affirmer que le processus a correctement complété l’historique.

Vérifier avant de supprimer l’ancienne structure

La fin de la commande ne prouve pas que les données sont correctes. Vérifiez qu’il ne reste aucune ligne en attente, validez les contraintes et comparez les résultats à la transformation attendue. Examinez les valeurs nulles et les cas limites, et confirmez que les lectures utilisent le nouveau champ sans dégrader le comportement fonctionnel ni les performances.

Surveillez également les processus peu fréquents, comme les rapports ou les tâches périodiques. Conservez l’ancienne structure tant qu’il existe des lecteurs ou des composants d’écriture qui en dépendent : sa présence est une mesure de compatibilité, pas la preuve que la migration est terminée.

Définir les pauses, le retour à une version précédente et la fenêtre de maintenance

Définir les pauses, le retour à une version précédente et la fenêtre de maintenance — guía visual de DedicatedPHP

Définissez des signaux de mise en pause : erreurs ou latence au-dessus du seuil convenu, verrous, retard excessif des réplicas, pression sur les ressources ou écarts croissants. Déterminez qui interrompt le processus et comment le reprendre à partir du dernier lot validé. Surveillez la durée des lots, les erreurs, le CPU, le disque et les verrous.

Revenir à une version précédente du code n’équivaut pas à revenir à une version précédente des données. Après avoir accepté des écritures uniquement au nouveau format, une transformation inverse peut entraîner une perte d’informations. La récupération peut consister à revenir à une version compatible et à conserver les deux structures, plutôt qu’à annuler les données. Une fenêtre de maintenance peut être préférable s’il est impossible de maintenir la cohérence entre les versions, si les verrous nécessaires ne sont pas acceptables ou si vous ne pouvez pas vérifier le résultat avec l’application en fonctionnement.

  • Le moteur et sa version permettent-ils le changement avec des verrouillages acceptables ?
  • Les anciens lecteurs et composants d’écriture peuvent-ils coexister avec le schéma intermédiaire ?
  • La commande PHP peut-elle être mise en pause et reprise sans dupliquer les effets ni sauter de lignes ?
  • Existe-t-il une stratégie éprouvée pour gérer les écritures simultanées, les erreurs transitoires et les écarts ?
  • L’intégrité et l’impact seront-ils mesurés avant de supprimer l’ancienne structure ?
Vous souhaitez appliquer ces idées à votre projet ?Parlons de votre plateforme PHP.
Afficher les services associés