- Les migrations de schéma dans Postgres sont particulièrement risquées, car les verrous, la réécriture de table et les retards de réplication peuvent provoquer des incidents en production, surtout dans les grands environnements OLTP
- Les risques se concentrent sur les opérations qui déclenchent des scans complets et de longs verrous, comme l’ajout simultané de
DEFAULT et NOT NULL, la création d’index sans CONCURRENTLY, la suppression immédiate de colonnes, les changements de type non sûrs et l’ajout de clés étrangères sans validation
- Depuis PostgreSQL 11, le coût de certains ajouts de colonnes a diminué, mais pour les index il faut toujours suivre des procédures qui réduisent l’impact en production, comme
CREATE INDEX CONCURRENTLY, et pour les clés étrangères NOT VALID puis VALIDATE CONSTRAINT
- Les modifications massives doivent être découpées en petits lots, tout en vérifiant aussi les réplicas de lecture, le retard de réplication, les objets dépendants et le fait que d’anciennes instances de l’application référencent encore la colonne
- Sur des volumes de données de taille production, il faut tester en amont et n’exécuter les opérations destructrices qu’avec un déploiement en plusieurs étapes et un plan de rollback validé
Les prérequis d’une migration de schéma
- Ici, une migration de base de données ne désigne pas un changement de SGBD, mais une modification du schéma de la base
- Les changements concernés ont trois caractéristiques
- des changements versionnés, avec un identifiant unique et une procédure d’application automatisée pour chaque modification
- des changements immuables, qu’on ne modifie plus après application en production et auxquels on n’ajoute que de nouveaux changements
- des changements incrémentaux, où le schéma de la base évolue étape par étape
- L’accent est mis sur les cas d’usage OLTP comme les applications web et mobiles, où une requête de plus d’une seconde est généralement considérée comme trop lente
- Sur une petite base de données et avec une faible activité, certains problèmes peuvent passer inaperçus, mais à l’échelle d’environ 10 TiB et sous une charge de 10⁴ à 10⁵ transactions par seconde, la plupart des problèmes peuvent apparaître
- Database Lab Engine sert au développement et aux tests à l’aide de clones légers, et permet de cloner une base de 10 TiB en moins de 10 secondes pour vérifier les risques d’un changement de schéma avant déploiement
- Le GitLab Migration Style Guide est une ressource de référence issue de l’expérience de nombreux déploiements automatisés de changements de schéma Postgres
Ajout de colonnes et réécriture de table
- Ajouter une colonne avec
DEFAULT et NOT NULL en même temps est particulièrement risqué sur les anciennes versions de PostgreSQL
- Avant PostgreSQL 11, cela nécessite une réécriture complète de la table
- Sur une grande table, cela peut prendre des heures ou des jours, avec un verrou d’écriture pendant toute la durée
- Exemple à risque
ALTER TABLE users ADD COLUMN status text DEFAULT 'active' NOT NULL;
- La procédure la plus sûre consiste à séparer l’ajout de la colonne, la mise à jour des données et l’ajout de la contrainte
- ajouter d’abord la colonne sans
NOT NULL
- mettre à jour les lignes existantes si nécessaire
- ajouter ensuite la contrainte
NOT NULL
ALTER TABLE users ADD COLUMN status text DEFAULT 'active';
-- UPDATE users SET status = 'active' WHERE status IS NULL;
ALTER TABLE users ALTER COLUMN status SET NOT NULL;
- À partir de PostgreSQL 11, l’ajout d’une colonne avec une valeur
DEFAULT non volatile ne nécessite plus de réécriture de table
Création d’index et ajout de clés étrangères
- Créer un index sans
CONCURRENTLY fait prendre à la création d’index standard un verrou exclusif sur la table
- Toutes les écritures, et parfois certaines lectures, peuvent être bloquées jusqu’à la fin de la création de l’index
- Exemple à risque
CREATE INDEX idx_users_email ON users(email);
- En production, il est plus sûr d’utiliser
CREATE INDEX CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CONCURRENTLY a toutefois des limites
- l’opération est plus longue, mais sans bloquer l’accès à la table
- elle ne peut pas être utilisée dans un bloc de transaction
- en cas d’échec, elle peut laisser un index invalide qu’il faudra supprimer
- Ajouter directement une contrainte de clé étrangère sur une grande table entraîne un scan complet de la table pour valider les données existantes et provoque de longs verrous
- Une procédure plus sûre consiste à ajouter d’abord la contrainte avec
NOT VALID, puis à la valider à un moment de faible trafic
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user_id
FOREIGN KEY (user_id) REFERENCES users(id)
NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;
Suppression de colonnes et changement de type
- Supprimer immédiatement une colonne en production peut provoquer des erreurs applicatives si le code de l’application continue à la référencer
- La suppression d’une colonne doit se faire en plusieurs étapes
- déployer d’abord le code applicatif qui n’utilise plus cette colonne
- attendre que toutes les anciennes instances de l’application aient été remplacées
- supprimer la colonne dans une migration séparée
- Changer le type d’une colonne peut provoquer une réécriture de table ou des problèmes de compatibilité
- cela peut entraîner une indisponibilité, une perte de données ou des erreurs applicatives
- Exemples problématiques
ALTER TABLE users ALTER COLUMN id TYPE bigint;
ALTER TABLE users ALTER COLUMN email TYPE varchar(100);
- Pour passer de
integer à bigint, il faut une procédure en plusieurs étapes avec une nouvelle colonne
- Lorsqu’on réduit la taille d’un
varchar, il faut d’abord vérifier les données et se demander si le changement est réellement nécessaire
Modifications massives, réplication et objets dépendants
- Il faut éviter les migrations qui modifient trop de données dans une seule transaction
- la contention sur les verrous et l’utilisation mémoire augmentent
- le temps de récupération en cas de problème s’allonge
- le retard de réplication peut s’aggraver
- Pour une migration de données à grande échelle, il est plus sûr de la découper en petits lots
- Il faut également examiner l’impact de la migration sur les réplicas de lecture et le retard de réplication
- une grosse migration peut entraîner un retard de réplication important
- elle peut affecter les performances des réplicas de lecture
- Il faut aussi vérifier les objets qui dépendent des colonnes ou des tables modifiées
- oublier des objets dépendants comme des vues, des fonctions ou des triggers peut provoquer des échecs en cascade ou nécessiter des interventions manuelles supplémentaires
Tests et plan de rollback
- Si l’on teste une migration uniquement sur un petit jeu de données de développement, il est difficile d’observer les caractéristiques de performance d’un grand jeu de données
- Il faut tester sur un clone de données à l’échelle de la production, avec des outils comme Database Lab Engine
- S’il n’existe aucun moyen d’annuler une migration quand un problème survient, un incident de production peut se transformer en indisponibilité prolongée
- En particulier pour les opérations destructrices, un plan de rollback validé est indispensable
- Les bases d’un changement de schéma sûr sont les suivantes
- tester sur des données à l’échelle de la production
- utiliser une approche en plusieurs étapes pour les opérations risquées
- tirer parti des fonctionnalités PostgreSQL comme
CONCURRENTLY et NOT VALID
- surveiller les performances et l’impact sur la réplication
- toujours préparer un plan de rollback
1 commentaires
Avis sur Hacker News
J’aime vraiment Postgres, mais la plupart de ce billet porte sur des points évitables et auxquels il faut faire attention. Cela dit, le pire dans Postgres, à mon avis, c’est la gestion des rôles
La fonctionnalité est puissante et peut être excellente si elle est bien utilisée, mais la faire fonctionner en pratique donne l’impression de pratiquer de la magie noire. Un peu partout, l’interface ressemble à des incantations obscures dont on ne sait jamais si elles vont se comporter comme prévu, et c’est une façon horrible de gérer quelque chose d’aussi important
La documentation sur ce sujet est également mince : elle indique seulement, dans les grandes lignes, comment cela est censé fonctionner pour des cas d’usage étroits. Quand ça ne se passe pas comme prévu, il faut tâtonner pour trouver ce qu’on a mal fait, sans pour autant avoir l’intuition de la bonne manière de procéder. Migrer une DB avec des droits utilisateur complexes est vraiment pénible
J’ai l’impression qu’il faudrait que je prenne environ un mois pour écrire un cookbook. Si cela peut éviter ne serait-ce qu’à une personne de s’endormir en pleurant, ça en vaudrait la peine
Pour faire un
SELECTsur une table, il fautCONNECTsur la Database etUSAGEsur le Schema, accordés implicitement au propriétaire du Schema. Il faut aussiSELECTsur la Table, accordé implicitement au propriétaire de la tablePour voir les droits, il faut comprendre les entrées ACL au format
grantee=privilege-abbreviation[]/grantor:. Les droits sur les Database se consultent avec\l+, ceux sur les Schema avec\dn+, et ceux sur les Table avec\dp+La liste des droits se trouve ici. Par exemple,
user=arwdDxt/postgressignifie que le rôlepostgresa accordé tous les droits à l’utilisateurSi la colonne
granteed’un objet est vide, cela peut désigner les droits par défaut du propriétaire, c’est-à-dire tous les droits, ou bien les droits accordés au rôlePUBLIC, qui correspond à tous les rôles existants. Exemple :=r/postgresL’utilisation du Schema
publicrend les choses encore plus confuses. Comme le Schema a le droitCREATE, si vous créez une table avec le même utilisateur que celui qui lit les données, les droits de propriétaire sont ajoutés par défaut et la lecture fonctionne immédiatementSi quelqu’un écrivait sérieusement un cookbook sur les rôles Postgres et lançait quelque chose comme un Kickstarter, je ferais probablement partie des tout premiers contributeurs
Mais une fois que ça a fonctionné, c’était vraiment magique, et les mécanismes concernés eux-mêmes étaient étonnamment assez simples
Si vous exécutez des migrations de Schema en production, vous devriez utiliser
lock_timeoutMême des changements qui semblent inoffensifs et qui se terminent presque instantanément en test, comme supprimer une table avec des clés étrangères ou supprimer une clé étrangère, peuvent rencontrer des conflits de verrouillage sur une DB de production très sollicitée, à cause de transactions existantes ou d’autovacuum
Ce
ALTERva attendre le verrou de la première transaction tout en demandant un verrouACCESS EXCLUSIVE, ce qui bloque ensuite toutes les requêtes sur la table verrouilléeQuand on exploite Postgres à une certaine échelle, ce genre de conflit n’est qu’une question de temps. En définissant
lock_timeout, la migration échoue une fois le délai dépassé, au lieu d’attendre en bloquant toutes les autres requêtesstatement_timeoutinclut aussi le temps d’attente des verrous, ce qui permet de mieux estimer l’impact sur une table très sollicitéeSi vous fixez la limite à 5 secondes, vous savez que l’interruption totale sera d’au plus 5 secondes, puis les transactions suivantes continuent. Avec seulement
lock_timeout, vous ne contrôlez pas la durée de l’opération une fois le verrou obtenu ; selon le trafic concurrent, elle peut être rapide ou lenteJe me demande s’il existe une bonne méthode pour analyser une requête et savoir quel type de verrou elle va prendre. Quand je n’en suis pas sûr, j’ai toujours fini par relire la documentation
ACCESS EXCLUSIVEavait déjà été acquis puis attendait, mais plutôt à cause de la file d’attente des verrousALTERest en attente de la libération de verrous de niveau inférieur àACCESS EXCLUSIVEALTERne s’exécute jamais. Si la table reçoit suffisamment de trafic, c’est possibleDans ce cas, si l’application peut s’en remettre, je pense que le mieux est de tuer les autres requêtes en cours qui bloquent le
ALTERJe consulte plusieurs fois par semaine le guide Safe Migrations in Ecto de Fly.io. Ecto est l’adaptateur DB d’Elixir
C’est une référence très utile pour vérifier rapidement si une migration de base suffit ou si une procédure plus complexe est nécessaire
https://fly.io/phoenix-files/safe-ecto-migrations/
Ce qui m’avait le plus surpris, à mes débuts avec les index Postgres, c’est qu’un index UNIQUE pouvait affecter les résultats de requêtes concurrentes à cause des verrous supplémentaires
Une requête comme
INSERT INTO foo (bar) (SELECT max(bar) + 1 FROM foo);, exécutée simultanément en mode par défaut, peut insérer des valeursbaren double. En effet, une transaction peut ne pas voir la nouvelle valeur maximale créée par une autre transactionOn pourrait penser qu’en ajoutant un index UNIQUE, la transaction « perdante » recevrait une erreur de contrainte, mais en réalité les deux transactions réussissent et la condition de concurrence disparaît
=# INSERT INTO foo (bar) (SELECT max(bar) + 1 FROM foo);ERROR: duplicate key value violates unique constraint "foo_bar_idx"DETAIL: Key (bar)=(2) already exists.CONCURRENTLY, puis en créant une contrainte UNIQUE non vérifiéeCette contrainte ne s’applique qu’aux nouveaux
INSERT/UPDATE. Ensuite, exécuterVALIDATEsur la contrainte en fait une contrainte UNIQUE complèteJe suis d’accord pour dire que c’est courant, mais le problème relève davantage du développement logiciel en général que de Postgres
C’est à cause de ce genre de pièges que j’ai créé Reshape [0], avec l’objectif d’automatiser les migrations de schéma sans interruption
Je ne peux pas dire que nous évitons tous les problèmes, mais nous construisons un nouveau produit qui vise cela. Si ce domaine, et en particulier Postgres, vous intéresse, j’aimerais avoir de vos nouvelles : fabian@reshapedb.com
[0] https://github.com/fabianlindfors/reshape
Une autre erreur que je vois souvent consiste à dupliquer une table en oubliant les index
CREATE TABLE SELECT * FROM WHERE <>ne fonctionne pas comme ça. Les gens font souvent cela lorsqu’ils veulent créer une table de sauvegarde ou effectuer une suppression massiveCe que je veux, c’est une copie des données déjà présente, que je n’utiliserai probablement pas, afin de ne pas avoir à restaurer depuis une sauvegarde de la DB et le WAL. Créer les index serait un gaspillage de temps serveur et d’espace disque
Si les choses tournent mal ou si j’en ai vraiment besoin, je pourrai créer ces index plus tard
La partie « Case 2. mauvaise utilisation de
IF [NOT] EXISTS» ne donne pas de bon exemple de mauvaise utilisationEt en pratique, c’est bien comme ça qu’il faut l’utiliser. C’est propre, simple, et il n’y a pas de piège caché. Quand il n’y a que quelques tables, un outil de migration de schéma est une surcharge excessive
Mettre un pansement sur de mauvaises données ne résout pas le problème, cela ne fait que le cacher. Selon le type de problème, il peut exploser plus tard d’une manière inattendue, au pire moment
Ici, les « mauvaises données » sont des tables, colonnes ou vues qui devraient exister ou ne pas exister, mais se trouvent dans l’état inverse. Pourquoi une table qui ne devrait pas encore exister existe-t-elle ? Une suppression a-t-elle échoué ? Le schéma de la table existante est-il correct ? La même migration a-t-elle été exécutée deux fois par erreur ?
Après chaque migration, le schéma doit être dans l’état exact attendu. Si une migration contient
IF [NOT] EXISTS, cela signifie qu’après la migration précédente, le schéma n’est pas resté dans l’état exact attendu. Ne pas être certain de l’état du schéma n’est pas une bonne choseQue faire si la colonne d’une table déjà existante diffère de celle que la migration essaie de créer ?
IF EXISTSfera réussir la migration, mais laissera le schéma dans un mauvais état. Dans ce genre de cas, il vaut mieux que la migration échoue rapidementPetite remarque sur l’utilisation de
int4comme clé primaire de substitutionCe qui compte, n’est-ce pas plutôt la taille de l’index que celle de la table ? La taille de la table inclut déjà un en-tête de 23 octets et du padding d’alignement, donc 4 octets de différence ont peu d’effet. En revanche, si cela permet de garder davantage d’index en mémoire, il peut y avoir un avantage. Une entrée d’index a un en-tête de 8 octets
De plus, le milliard de lignes dans l’exemple est trop proche de la valeur maximale de
int4, ce qui est inquiétantCela dit, l’article est excellent
Cela signifie-t-il qu’une page de 8 Ko sur disque peut dépasser 8 Ko en RAM ?
Cela semble surtout affecter la mémoire de travail des données de lignes de table. Cela reste important, surtout que Postgres est catastrophique pour la localité des requêtes par plage, puisque les lignes sont dans un ordre aléatoire. Mais je ne pense pas que ce soit une observation décisive
Je suis un développeur qui a été globalement protégé des problèmes liés aux DB. Dans Django, je sais créer des migrations, créer les tables de modèles et interroger via l’ORM, mais beaucoup de choses qui se passent en interne me semblent relever de la magie noire
Maintenant que je lance une entreprise, j’ai peur de devoir affronter ce genre de problèmes et les résoudre seul. Quelle approche adopter pour apprendre quoi faire dans un environnement de développement ?
J’aime Postgres, mais je déteste vraiment le fait qu’il n’existe pas de méthode intégrée pour les mises à jour/suppressions par lots
C’est la partie la plus agaçante, et chaque fois que je me heurte à ce mur, je dois réécrire un batcher presque tous les mois