Ce que j’aurais aimé que quelqu’un me dise sur Postgres
(challahscript.com)- La documentation officielle de Postgres est excellente, mais le PDF de Postgres 17 fait 3 200 pages, ce qui rend difficile pour un débutant d’apprendre uniquement via la doc, avant la pratique, la conception de schémas, le fonctionnement de SQL et les pièges d’exploitation
- Sauf raison particulière, il faut normaliser les données ; la dénormalisation, qui consiste à dupliquer des données pour accélérer la lecture, a un coût en incohérences et en complexité d’écriture
- Les mots-clés SQL ne sont pas sensibles à la casse, mais NULL signifie plutôt « inconnu » : le comparer comme le
nulld’un langage généraliste produit des résultats contre-intuitifs - Avec
psql, le simple fait de bien utiliser le pager,\x,.psqlrc,\pset null, l’autocomplétion, les commandes en backslash et\copyaméliore nettement la lisibilité de la sortie, l’exploration et l’export CSV - Les index, les verrous, les transactions et JSONB sont puissants, mais sans compréhension du plan de requête et des contraintes d’exploitation, ils peuvent entraîner des baisses de performance ou des problèmes de disponibilité
Contexte à connaître avant d’attaquer l’immense documentation officielle
- La documentation officielle de Postgres représente, pour la version 17 actuelle, 3 200 pages en PDF au format US letter, et 3 024 pages au format A4
- Il existe beaucoup de connaissances pratiques utiles à avoir avant d’utiliser Postgres ; certaines s’appliquent aussi à d’autres SGBD SQL, mais leur portée n’est pas toujours évidente
Normaliser les données par défaut
- La normalisation est le processus consistant à supprimer les données redondantes ou inutiles dans un schéma de base de données
- Si la table
documentsstocke directementuser_email, alors quand un utilisateur change d’adresse e-mail, il faut mettre à jour toutes les lignes de document de cet utilisateur- À la place, on peut faire en sorte que chaque ligne de
documentsréférence une ligne d’une autre table, commeusers, via une clé étrangèreuser_id
- À la place, on peut faire en sorte que chaque ligne de
- Il n’est pas nécessaire de mémoriser toutes les formes normales comme la “1st normal form”, mais un processus de normalisation classique mène souvent à des schémas plus faciles à maintenir
- La dénormalisation consiste à dupliquer des données pour les lire rapidement sans les recalculer à chaque fois
- Dans une application de gestion des plannings d’employés, on peut, au lieu de recalculer à chaque fois le cumul annuel des heures travaillées en additionnant toutes les durées de shift, le calculer et le stocker périodiquement ou lors d’un changement d’horaire
- Ces données peuvent être stockées dans Postgres lui-même ou dans une couche de cache comme Redis
- La dénormalisation a presque toujours un coût ; les plus courants sont la possibilité d’incohérences de données et l’augmentation de la complexité des écritures
Les conseils « à ne pas faire » du projet Postgres
- Le wiki officiel de Postgres propose une liste « Don’t do this »
- Ce n’est pas grave si vous ne comprenez pas tous les points, et ceux que vous ne comprenez pas sont aussi ceux pour lesquels vous avez le moins de chances de faire l’erreur
- En particulier, ces conseils valent la peine d’être retenus
- utiliser le type
textpour stocker du texte - utiliser
timestampz/time with time zonepour stocker des timestamps - nommer les tables en snake_case
- utiliser le type
Comportements SQL qui prêtent facilement à confusion
-
Les mots-clés SQL n’ont pas besoin d’être en majuscules
- Les mots-clés SQL ne sont pas sensibles à la casse
- Les requêtes suivantes ont le même sens
SELECT * FROM my_table WHERE x = 1 AND y > 2 LIMIT 10; select * from my_table where x = 1 and y > 2 limit 10; SELECT * from my_table WHERE x = 1 and y > 2 LIMIT 10;- Cette propriété n’est pas propre à Postgres
-
NULL est différent du null/nil des langages généralistes
- En SQL,
NULLest plus proche de « inconnu » que dunullounildes langages de programmation classiques NULL = NULLne renvoie pastrue, maisNULL- Une comparaison dont l’un des côtés est
NULLrenvoie généralement aussiNULL - Pour comparer avec
NULL, il faut utiliser les opérations suivantesx IS NULL:truesixvautNULLx IS NOT NULL:truesixn’est pasNULLx IS NOT DISTINCT FROM y: similaire àx = y, mais traiteNULLcomme une valeur ordinairex IS DISTINCT FROM y: similaire àx != y/x <> y, mais traiteNULLcomme une valeur ordinaire
- La clause
WHEREne renvoie des lignes que lorsque la condition vauttrueSELECT * FROM users WHERE title != 'manager'ne renvoie pas les lignes oùtitlevautNULL- parce que
NULL != 'manager'renvoieNULL
COALESCErenvoie la première valeur nonNULLparmi plusieurs arguments
COALESCE(NULL, 5, 10) = 5 COALESCE(2, NULL, 9) = 2 COALESCE(NULL, NULL) IS NULL - En SQL,
Mieux utiliser psql
-
Améliorer la lisibilité de la sortie
- Si l’affichage est difficile à lire quand vous interrogez une table avec beaucoup de colonnes ou des valeurs longues, il est possible que le pager soit désactivé
- Le pager du terminal permet de faire défiler de gros volumes de texte ou des tables
psqldans la fenêtre d’affichage - Pour les tables avec beaucoup de colonnes, vous pouvez activer le mode étendu avec
\pset expandedou\x - Si vous voulez l’utiliser par défaut, ajoutez
\xdans~/.psqlrcdans votre répertoire personnel
-
Rendre l’affichage de NULL plus explicite
- La configuration par défaut ne montre pas clairement si une valeur est
NULLdans la sortie - Vous pouvez définir une chaîne d’affichage pour
NULLdanspsql
\pset null '[NULL]'- Une chaîne Unicode fonctionne aussi ; pour l’utiliser par défaut, ajoutez la même commande à
~/.psqlrc
- La configuration par défaut ne montre pas clairement si une valeur est
-
Exploiter l’autocomplétion et les commandes en backslash
psqlprend en charge l’autocomplétion comme une console interactive- En tapant une partie d’un mot-clé ou d’un nom de table puis en appuyant sur Tab, on peut compléter le reste
- Voici quelques commandes en backslash utiles
\?: liste de tous les raccourcis\d: affiche les relations, c’est-à-dire les tables et séquences, ainsi que leur propriétaire\d+: ajoute la taille et certaines métadonnées à\d\d table_name: affiche le schéma de la table, le type des colonnes, leur caractère nullable, les valeurs par défaut, les index et les contraintes de clé étrangère\e: édite la requête dans l’éditeur par défaut défini par la variable d’environnement$EDITOR\h SQL_KEYWORD: affiche la syntaxe du mot-clé SQL correspondant et un lien vers la documentation
-
Export CSV et alias dans SELECT
\copypermet d’enregistrer le résultat d’une requête en CSV
\copy (select * from some_table) to 'my_file.csv' CSV- Pour inclure les noms de colonnes sur la première ligne, ajoutez l’option
HEADER
\copy (select * from some_table) to 'my_file.csv' CSV HEADER\copypermet d’éviter les privilèges élevés requis par l’instruction plus standardCOPY- Les colonnes renvoyées par
SELECTpeuvent recevoir un alias viaAS
SELECT vendor, COUNT(*) AS number_of_backpacks FROM backpacks GROUP BY vendor ORDER BY number_of_backpacks DESC;- Dans
GROUP BYetORDER BY, on peut référencer le numéro des colonnes apparues aprèsSELECT
SELECT vendor, COUNT(*) AS number_of_backpacks FROM backpacks GROUP BY 1 ORDER BY 2 DESC;- Cette forme abrégée est pratique, mais il vaut mieux éviter de l’utiliser dans des requêtes déployées en production
Ajouter un index ne veut pas dire qu’il sera toujours utilisé
-
Index et plan de requête
- Un index est une structure de données jouant le rôle de répertoire de raccourci pour retrouver des lignes d’une table à partir de champs donnés
- L’index le plus courant est le B-tree, qui fonctionne pour les conditions d’égalité exactes comme
WHERE a = 3et les conditions de plage commeWHERE a > 5 - On ne peut pas dire directement à Postgres d’utiliser un index précis
- Postgres prédit, à partir des statistiques qu’il maintient sur chaque table, si un index sera plus rapide qu’un sequential scan lisant la table de bout en bout
- En ajoutant
EXPLAINdevantSELECT ... FROM ..., on peut voir le plan de requête que Postgres prévoit d’utiliser - Pour apprendre à lire un plan de requête, on peut consulter le guide de thoughtbot sur EXPLAIN ANALYZE, la documentation de pganalyze, la documentation officielle et explain.depesz.com
-
Petites tables et index multicolonnes
- Sur des tables avec peu de lignes, comme dans une base de développement locale, un index peut ne pas être d’une grande aide
- Avec une centaine de lignes, Postgres peut juger qu’un sequential scan est plus rapide qu’un index
- Postgres prend en charge les index multicolonnes
CREATE INDEX CONCURRENTLY ON tbl (a, b);- Une condition comme
WHERE a = 1 AND b = 2peut être plus rapide qu’avec deux index séparés, l’un suraet l’autre surb - Parce qu’un seul parcours du B-tree permet de combiner efficacement les conditions de recherche
- Un index
(a, b)accélère aussi les requêtes filtrant uniquement sura, aussi bien qu’un index suraseul - Une requête comme
WHERE b = 5peut être accélérée, mais pas forcément de façon optimale- L’index est ordonné d’abord par
a, puis parb, donc il faut parcourir toutes les valeurs deapour trouver celles deb
- L’index est ordonné d’abord par
- Si vous interrogez souvent plusieurs combinaisons de colonnes, il est fréquent d’avoir à la fois un index
(a, b)et un index surbseul - Selon le cas, on peut aussi s’appuyer sur des index séparés sur
aetb
-
Utiliser text_pattern_ops pour les correspondances par préfixe
- Avec une approche de type materialized path pour stocker un arbre de répertoires, on peut vouloir retrouver tous les descendants commençant par un certain préfixe
SELECT * FROM directories WHERE path LIKE '/1/2/3/%'- Même si vous créez un index B-tree par défaut sur la colonne
path, cette requête peut ne pas l’utiliser
CREATE INDEX CONCURRENTLY ON directories (path);- Pour permettre l’ordre caractère par caractère nécessaire aux correspondances par préfixe ou par motif, il faut spécifier une operator class
CREATE INDEX CONCURRENTLY ON directories (path text_pattern_ops);
Les problèmes d’exploitation causés par les verrous et les transactions
-
Les verrous dans Postgres
- Un verrou (lock) ou mutex est un mécanisme permettant de faire en sorte qu’une seule cliente ou un seul client exécute à la fois une opération à risque
- Dans une base de données, les mises à jour d’objets comme les lignes, tables ou vues doivent soit toutes réussir, soit toutes échouer ; pour éviter qu’une exécution concurrente n’en réussisse qu’une partie, des verrous sont pris sur les objets concernés
- Les niveaux de verrou de table dans Postgres vont du moins restrictif au plus restrictif
ACCESS SHARE:SELECTROW SHARE:SELECT ... FOR UPDATEROW EXCLUSIVE:UPDATE,DELETE,INSERTSHARE UPDATE EXCLUSIVE:CREATE INDEX CONCURRENTLYSHARE:CREATE INDEX, sansCONCURRENTLYACCESS EXCLUSIVE: de nombreuses formes deALTER TABLE,ALTER INDEX
- Sur une même table, les opérations suivantes peuvent coexister ou doivent attendre
UPDATEpendant unSELECT: possibleUPDATEpendant unCREATE INDEX CONCURRENTLY: possibleSELECTpendant unCREATE INDEX: possibleSELECTpendant unALTER TABLE: doit généralement attendreALTER TABLEpendant unSELECT: doit généralement attendre
- Certaines formes de
ALTER TABLEdemandent des verrous plus faibles ; pour les détails complets, voir la documentation officielle sur les verrous explicites et ce guide des conflits de verrous par opération
-
ALTER TABLE lent et file d’attente de verrous
- Si un
ALTER TABLEprend longtemps, il peut aussi bloquer lesSELECTqui lisent la même table - S’il s’agit d’une table centrale comme
users, consultée par toutes les requêtes d’une application web, les requêtes peuvent attendre jusqu’au timeout et renvoyer des 503 - Voici quelques causes fréquentes de lenteur d’un
ALTER TABLE- ajout d’une colonne avec une valeur par défaut non constante
- changement de type de colonne
- ajout d’une contrainte d’unicité
- Depuis Postgres 11, le problème qui rendait lentes toutes les valeurs par défaut lors de l’ajout d’une colonne a été corrigé ; les valeurs par défaut non constantes peuvent toutefois rester problématiques
- Même si
ALTER TABLEest en lui-même rapide, il ne s’exécute pas tant qu’il n’a pas obtenu le verrou- si un vieux
SELECTlent lancé depuis longtemps tourne encore sur un tableau de bord interne,ALTER TABLEdoit attendre
- si un vieux
- Les verrous Postgres forment une file d’attente, donc les requêtes ultérieures sur la même table, arrivées derrière un
ALTER TABLEen attente, peuvent elles aussi être bloquées - Le même scénario est détaillé dans Migrations and exclusive locks
- Si un
-
Les transactions longues sont aussi dangereuses
- Une transaction permet de regrouper plusieurs instructions de base de données en mode tout-ou-rien ; elle commence par
BEGINet se termine parCOMMIT - Les changements effectués pendant la transaction ne sont pas visibles des autres clientes et clients, puis deviennent publics au
COMMIT - C’est adapté aux opérations comme un virement, où la diminution du solde d’un compte et l’augmentation du solde d’un autre doivent réussir ou être annulées ensemble
- Lorsqu’une transaction obtient un verrou, elle le conserve jusqu’au
COMMIT - Si vous faites
BEGIN, puisUPDATEsur une certaine ligne avant de laisser la session ouverte, unDELETEsur cette même ligne depuis un autre client restera bloqué jusqu’au commit - Des transactions laissées ouvertes plus longtemps que nécessaire peuvent bloquer les requêtes ou mises à jour d’autres clients
- Une transaction permet de regrouper plusieurs instructions de base de données en mode tout-ou-rien ; elle commence par
JSONB est un outil tranchant
-
Problèmes de performance et de schéma avec JSONB
- JSONB est flexible, mais mal utilisé il a de gros inconvénients
- Postgres ne suit pas les statistiques d’une colonne JSONB, donc une requête d’égalité sur une seule colonne JSONB peut être bien plus lente qu’une requête équivalente sur un ensemble de colonnes classiques
- Un exemple montre un cas où JSONB est 2000 fois plus lent
- Une colonne JSONB peut contenir à peu près n’importe quoi, ce qui la rend puissante, mais offre peu de garanties sur la structure
- Avec une table classique, on peut regarder le schéma et prédire le résultat d’une requête ; avec JSONB, il n’est pas toujours clair si les noms de clé sont en camelCase ou en snake_case, ni si un état est un booléen ou un enum
- Les propriétés de typage statique des données Postgres classiques ne s’appliquent pas de la même façon à JSONB
-
Les comparaisons de type en JSONB sont maladroites
- Si vous cherchez dans la colonne JSONB
datade la tablebackpacksles lignes dont le champbrandvautJanSport, la requête suivante ne fonctionne pas
select * from backpacks where data['brand'] = 'JanSport';- Postgres s’attend à ce que le type à droite de la comparaison corresponde à celui de gauche, et la partie droite doit être un document JSON valide
- Un document JSON doit être un objet, un tableau, une chaîne, un nombre, un booléen ou null ;
JanSporttout seul n’est donc pas un JSON valide - La bonne approche consiste soit à comparer à une chaîne JSON, soit à convertir la partie gauche en
textPostgres
select * from backpacks where data['brand'] = '"JanSport"'; select * from backpacks where data['brand'] = '"JanSport"'::jsonb; select * from backpacks where data->>'brand' = 'JanSport';- Le
NULLde SQL et lenullde JSONB n’ont pas le même comportement'null'::jsonb = 'null'::jsonbrenvoietrue, alors queNULL = NULLrenvoieNULL
- JSONB dispose de nombreux opérateurs et fonctions dédiés, qu’il est difficile de mémoriser d’un coup
- Postgres propose à la fois
JSON, qui stocke la valeur JSON sous forme de texte, etJSONB, qui la convertit dans un format binaire plus efficace - JSONB présente des avantages, notamment la possibilité d’indexer, tandis que le format JSON relève plutôt de cas particuliers
- Si vous cherchez dans la colonne JSONB
2 commentaires
J’aimerais bien lire un jour ce qu’il ne faut pas faire.
Commentaires sur Hacker News
PostgreSQL est globalement sensible à la casse, mais écrire les mots-clés SQL en majuscules est surtout un moyen d’améliorer la lisibilité par reconnaissance visuelle des motifs
Ce n’est pas indispensable, mais si je devais déboguer la requête de quelqu’un d’autre, je la passerais probablement dans un prettifier pour parcourir rapidement la définition sans buter sur des détails de forme syntaxique
Comme pour le formatage du code dans d’autres langages, une structure visuelle cohérente, comme une indentation uniforme, réduit le temps passé à comprendre les parties évidentes et permet de se concentrer sur l’essentiel
En revanche, je déteste vraiment mélanger les majuscules et les minuscules dans les identifiants, comme
actuallyUsingCaseInIdentifiers, et je n’ai aucune envie de voir des colonnes qui nécessitent des guillemets doubles pour être inspectées en CLISi je tape rapidement une requête temporaire que personne ne verra et que je vais jeter ensuite, je ne fais pas attention à la casse, mais pour le SQL qui sera commit dans un dépôt, j’écris les commandes en ALL CAPS
Ce n’est plus nécessaire aujourd’hui avec la couleur, mais je n’ai pas de source, c’est juste un vieux souvenir
Cela dit, il ne faut de toute façon pas mélanger identifiants entre guillemets et non guillemetés, et l’inspection des structures internes n’est généralement pas standardisée non plus, donc ce n’est pas très important
Je viens de découvrir la rubrique « don’t do this » du wiki PostgreSQL, et elle est assez utile : https://wiki.postgresql.org/wiki/Don%27t_Do_This
Par exemple, il semblerait logique de désactiver par défaut des fonctions comme l’héritage de tables dans les nouveaux schémas, et d’exiger une configuration volontairement complexe pour les réactiver
Beaucoup de choses mentionnées ici ne concernent pas uniquement PostgreSQL
C’est le cas du comportement étrange de
NULL, de l’ordre des colonnes d’index, etc., et en particulier l’interaction entre NULL et les index/contraintes d’unicité n’est pas intuitive non plus dans MySQLPar exemple, si on a une table utilisateur où
emailne peut pas être NULL etusernamepeut l’être, et qu’on met une contrainte d’unicité sur(email, username), on peut insérer plusieurs fois le mêmeemailavecusernameà NULL. En effet, NULL n’est pas égal à un autre NULLhttps://www.postgresql.org/docs/devel/sql-createtable.html#S...
Les cas d’usage nécessitant le comportement inverse sont bien plus rares
Se contenter de dire « normalisez vos données sauf bonne raison de ne pas le faire » n’est pas suffisant
Même dans la page liée par l’auteur, il y a 11 formes normales en comptant les formes non normales ; la plupart des gens ne savent pas ce que c’est, et parmi elles, 7 ne serviront jamais
Il ne faut pas pousser les gens à partir à la chasse aux formes normales plus élevées
J’ai aussi dû corriger quelques problèmes de ce genre dans un projet que je viens de reprendre ; il y a très peu de raisons de dupliquer les données
Le premier conseil est de faire un VACUUM tous les jours
Quand j’ai débuté, je ne le savais pas, donc je n’ai jamais fait de VACUUM sur la base de données de reddit, puis un jour j’ai dû le lancer sans avoir le choix ; reddit est alors resté presque indisponible pendant une journée entière, le temps que cela se termine
À l’échelle de reddit, il est surprenant que les ID de transaction ne se soient pas épuisés avant cela
J’aimerais que les développeurs se préoccupent davantage de la normalisation et arrêtent de tout entasser dans des colonnes JSONB
Les développeurs plus expérimentés savaient que la bonne réponse consistait à ne rien dupliquer, sauf les clés, et à ne dénormaliser qu’en tout dernier recours
Puis des bases comme Mongo sont arrivées, proposant des « sortes de bases de données » où la normalisation est difficile ou dénuée de sens, ce qui a encouragé ce genre de juniors ; il en a résulté, pendant un temps, des conceptions de bases de données atroces et des amas de déchets impossibles à maintenir
Aujourd’hui, le balancier est revenu et on redécouvre les avantages des bases de données normalisées, mais les colonnes JSON restent une porte de sortie où les mauvaises pratiques peuvent encore prospérer
Premièrement, pour stocker du JSON. Quand un serveur web appelle une API tierce, enregistrer la réponse API brute dans une colonne JSONB puis la traiter à partir de là laisse une trace exploitable pour déboguer les problèmes venant de cette API
Deuxièmement, pour stocker des types somme (sum types). Le fait que SQL ne prenne pas en charge les types somme est sans doute l’un des plus gros défauts quand il s’agit de modéliser des données dans une base SQL
Il existe plusieurs contournements, et « le mettre simplement dans une colonne JSONB et le valider dans l’application » en est un, mais aucun de ces contournements n’est particulièrement brillant
Tant qu’on ne se met pas à écrire de mauvaises requêtes dans le JSONB au lieu de remonter ces valeurs dans des colonnes séparées, je ne considère pas cela comme un gros problème en soi
Du moment que les exigences de persistance sont satisfaites, il n’y a pas de forte pression pour réfléchir à une bonne conception
Je ne sais pas si construire un SGBD au-dessus d’un autre SGBD est une bonne idée, mais c’est en tout cas l’état actuel des choses
Si une nouvelle colonne détruit les performances ou pose problème, il faut pouvoir revenir en arrière
Si un outil CLI est impliqué, il faut aussi gérer le niveau de downtime acceptable, déterminer si une mise à jour synchronisée des versions à l’échelle de toute l’entreprise est possible, ou s’il faut supporter à la fois l’ancien et le nouveau schéma pendant un temps
Si la base de données ne fait pas partie du produit principal de l’équipe, il est possible que tout cela soit totalement absent
J’ai écrit ceci pour aider les débutants : https://tomcam.github.io/postgres/
Le texte est vraiment excellent, et je ne savais pas que la documentation PostgreSQL faisait 3200 pages
Je l’utilise depuis un moment déjà, en apprenant au fur et à mesure des besoins ; j’aime bien la documentation officielle, et j’aime aussi lire des articles ciblés quand un sujet précis devient pertinent
Ce serait utile pour les lecteurs si l’auteur ajoutait à https://challahscript.com/what_i_wish_someone_told_me_about_... qu’un index sur les colonnes
(b, a)fonctionne bien aussi pour les recherches surbseulC’est déjà plus ou moins suggéré quand il parle des recherches sur
aseul, mais l’expliciter ne ferait pas de malJe n’utilise presque jamais les parties JSON/JSONB, donc je ne les vois pas souvent
Quand je repense aux SQL grotesques que j’ai vus en conditions réelles, je me dis qu’il vaudrait mieux commencer par lire le papier de Codd et comprendre ce qu’est le modèle relationnel
Il ne fait que 11 pages, et rien que sa lecture réduirait la somme de souffrance dans ce monde
Presque tout ce texte s’applique aussi à d’autres bases de données MVCC comme MySQL
Les détails peuvent varier, mais MySQL souffre aussi des longues transactions et de problèmes amusants du même genre, comme les verrous de métadonnées pris pendant
ALTER