Modifier une table critique sans panique
Article initialement publié sur inside.laruche.com et écrit par Eric Berthe.
À la suite de notre conférence sur le déploiement continu, nous avons eu pas mal de questions sur les astuces que nous employions pour mettre à jour la base de données sans maintenance.
Prenons un exemple pratique inspiré de faits réels.
Avant 2016, la Ruche qui dit Oui ne travaillait qu’avec un unique prestataire de paiement. Quelle que soit la méthode de paiement que vous employiez alors pour régler vos courses hebdomadaires (tous types de cartes bleues, de portemonnaies électroniques etc.), le traitement de l’opération bancaire proprement dite était assuré par le même PSP.
En 2016, nous avons entrepris de changer progressivement et sans interruption de service de prestataire de paiement. Un projet ambitieux, au cours duquel nous avons énormément appris et sur lequel il y a beaucoup à dire, et qui servira sans doute de base à de nombreux autres articles.
Mais pour l’heure, intéressons-nous à l’une des toutes premières étapes de ce travail de titan, un minuscule détail qui oblige déjà à se poser beaucoup de questions.
Changer de prestataire de paiement signifie stocker (en base) l’information qu’un paiement donné a été effectué avec tel ou tel prestataire.
Donc de rajouter une colonne à la table Payment, et de la remplir avec la bonne valeur.
Une table pas du tout critique sur une fonctionnalité pas du tout critique comme vous pouvez vous en douter.
Note : Dans la suite, les exemples données emploieront la syntaxe et la documentation de PostgreSQL, mais les principes généraux qu’ils démontrent sont pour la plupart directement transposables à tout autre système de base de données relationnelle.
Approche naïve
CREATE TYPE psp AS ENUM (‘old_psp’, ‘new_psp’);
— https://www.postgresql.org/docs/9.5/static/datatype-enum.htmlALTER TABLE payment ADD COLUMN psp psp NOT NULL DEFAULT ‘old_psp’;
Et là, c’est l’explosion.
postgres=# CREATE TYPE psp AS ENUM (‘old_psp’, ‘new_psp’);
CREATE TYPE
postgres=# \timing
Chronométrage activé.
postgres=# ALTER TABLE payment ADD COLUMN psp psp NOT NULL DEFAULT ‘old_psp’;
ALTER TABLE
Temps : 122385,927 ms
En effet, la table Payment comprenant tous les paiements du site, réussis, échoués ou abandonnés en cours de toute, depuis sa création, contient un certain nombre de lignes.
postgres=# SELECT COUNT(*) FROM payment;
2504147
Or, la présence d’une valeur par défaut dans cette nouvelle colonne fait que PostgreSQL va devoir parcourir l’intégralité de la table déjà existante pour l’ajouter partout.
Séquentiellement, sans optimisation aucune, en prenant donc un temps directement proportionnel au nombre total de lignes.
Bon, d’accord, c’est lent, mais est-ce vraiment un problème ?
Oh que oui, parce qu’un ALTER TABLE verrouille complètement la table.
Ce qui signifie que durant la durée totale de l’opération, personne ne pourra y accéder. Toute requête, en lecture ou en écriture, sera mise en attente, avant de très probablement échouer parce qu’elle a dépassé le temps limite qui lui est alloué.
Dans le meilleur des cas, parce que si elles commencent à s’embouteiller, c’est carrément la base qui peut partir en vrille. Ce qui aura bien sûr des conséquences en cascade, parce quand la base ne va plus bien, il est rare que le site derrière affiche une forme éblouissante.
Même sans aller jusque là, ce sont des utilisateurs qui vont essayer de payer et se manger une page d’erreur, ce qui est toujours fâcheux.
Approche moins naïve
Étape 1 : Préparer le terrain
Créons donc la colonne vide, acceptant NULL et sans valeur par défaut. Le LOCK sera toujours présent, mais nettement plus court, et sa durée ne s’allongera plus proportionnellement au nombre d’entrées déjà présentes dans la table.
postgres=# ALTER TABLE payment ADD COLUMN psp psp;
ALTER TABLE
Temps : 197,397 ms
Bon à savoir : Dans PostgreSQL, la présence d’un DEFAULT, même d’un DEFAULT NULL, force toujours le parcours linéaire. Pour un résultat final identique, il est donc largement préférable de ne pas préciser de valeur par défaut du tout lors de l’ajout d’une colonne nullable, auquel cas PostgreSQL se contentera d’une rapide opération sur les métadonnées (source : second paragraphe des notes de la documentation officielle).
C’est bien gentil, mais le code derrière, il attend une chaîne de caractère, pas “null”. Ça va encore tout casser.
Et c’est là que le déploiement continu intervient.
Dans un premier déploiement, livrons juste cette migration et exécutons-la.
Voilà, nous avons une colonne magnifiquement vide, inusitée et inutilisable telle quelle en production. Alors certes, cela ne casse rien, mais nous voilà bien avancés.
Patience.
Étape 2 : Mise à jour progressive
Pour remplir cette table, nous pouvons maintenant avoir recours à un UPDATE. L’avantage du UPDATE, c’est qu’il ne verrouille que les lignes qu’il est actuellement en train de tripoter, et n’empêche pas les autres de vivre leur vie.
Évidemment, avec une approche brutale comme « UPDATE payment SET psp=’old_psp’; », cela ne change pas grand chose par rapport au cas précédent, puisque vous vous attaquez à toutes les lignes à la fois quand même.
En revanche, prenons une requête rédigée de la sorte :
UPDATE payment SET psp=’old_psp’ WHERE id IN (SELECT id FROM payment WHERE psp IS NULL LIMIT 1000);
Avec une requête pareille, vous ne bloquez l’accès qu’à mille lignes à la fois, et durant un temps éphémère (de l’ordre du dizième de seconde). Bien sûr la table comporte bien plus que mille lignes, mais si vous emballez cette requête dans une structure conditionnelle qui l’exécute encore et encore et vous allez progressivement tout mettre à jour en douceur.
Un tel script a en plus l’avantage que vous pouvez l’arrêter en plein milieu et le reprendre plus tard comme s’il ne s’était rien passé. Beaucoup plus confortable qu’une gigantesque transaction qu’il faudra reprendre à zéro la fois suivante si cela se passe mal.
C’est l’approche que nous emploierons… Mais plus tard. Car pour l’heure, une telle requête va tourner éternellement, les utilisateurs rajoutant constamment de nouveaux paiements, avec des valeurs NULL. Il faut au préalable régler ce problème.
Étape 3 : Prévoir le futur
Intéressons-nous enfin au code applicatif. Si pour l’instant, le contenu de cette table n’est pas utilisable en lecture, car riche en valeurs NULL dont ne nous voulons pas, rien n’empêche d’y écrire dès maintenant.
L’étape suivante est donc de s’assurer que tout nouveau paiement inséré dans la base de données le soit avec un psp. Pour l’instant, la valeur sera toujours la même, mais les fondations seront déjà là pour la suite.
Note : Soyez particulièrement explicite sur le fait que la valeur peut actuellement être null, et ne devrait pas être lue pour le moment.
Exemple basique en PHP/Symfony/Doctrine :
class Payment
{
/**
* @experimental
* @write-only
* @var string|null
*
* @ORM\Column(name=”psp”, type=”psp”, nullable=true)
*/
private $psp;__construct() {
$this->psp = ‘old_psp’;
}
}
À partir du moment où ce code va être déployé en production, des valeurs utiles et des valeurs null vont cohabiter en base. Cela ne cassera rien, mais il n’y a pas de raison que cela ne dure éternellement.
Astuce : Les étapes 2 et 3 peuvent être développées en parallèle.
Étape 4 : Se défaire des NULL
Exécutez le script conçu au 2. Une fois celui-ci terminé, il ne devrait normalement plus y avoir la moindre valeur NULL dans cette colonne, et le code ne devrait plus en injecter de nouvelles. Vérifiez que tel est bien le cas, et si oui, il ne reste plus qu’à conclure.
Aussi bien en base :
ALTER TABLE payment ALTER COLUMN psp SET NOT NULL;
Que dans le code :
class Payment
{
/**
* @var string
*
* @ORM\Column(name=”psp”, type=”psp”)
*/
private $psp;__construct() {
$this->psp = ‘old_psp’;
}
}
Et voilà, la colonne a été ajoutée, correctement remplie et est proprement typée, sans que vos utilisateurs ne se soient rendus compte de rien.
Vous êtes maintenant libre de lui donner un usage pratique.
En résumé, il nous aura fallu :
- Base de données : ajouter une colonne facultative
- Code applicatif : remplir correctement cette colonne pour les nouvelles entrées
- Base : mettre à jour progressivement les anciennes entrées
- Base + code : rendre la colonne obligatoire et nettoyer les scories de l’époque où cette valeur pouvait encore être non affectée
Avec un minimum de deux déploiements (quatre si vous déployez vos modifications de base de données comme le code).
Cela peut sembler long et fastidieux, surtout si votre processus de déploiement est encore très complexe et sporadique.
Mais l’absence d’interruption de service, et avec elle des bugs en cascade issus des incohérences en base ou entre votre base et celles de vos prestataires qui suivent en général de tels interruptions, en vaut très largement la peine.