Cet article, prenant appui sur notre récent travail afin d'aider à l'optimisation de la plateforme Libération, montre quelques cas pratiques d'optimisation de PostgreSQL, au niveau système et au niveau des requêtes. Il ne s'agit pas d'un manuel complet sur l'optimisation PostgreSQL, qui prendrait des centaines de pages. Il ne remplace aucunement la lecture de la (très complète) documentation officielle, mais montre des cas concrets qui, je le pense, sont assez fréquents.
La configuration par défaut de PostgreSQL est (en général, peut-être y a-t-il des exceptions dans certaines distributions) paramétrée plutôt pour des serveurs de petite taille. Pour une base à forte volumétrie de données et de requêtes, s'exécutant sur un serveur puissant, il est nécessaire d'effectuer quelques ajustements.
En particulier, il est recommandé de fortement augmenter les paramètres shared_buffers et work_mem qui indiquent la quantité de mémoire maximale à utiliser pour un certain nombre d'opérations. L'utilitaire pg_tune peut être utilisé pour obtenir des indications sur les valeurs à utiliser, suivant la configuration matérielle (même si les valeurs exactes dépendent de l'utilisation qui est faite de la base).
Si la base utilise des index "lourds" (comme des index GIN sur des champs full-text), il est aussi recommandé d'augmenter le délai entre deux checkpoint.
PostgreSQL peut effectuer des accès disques pour différentes raisons :
Lorsqu'un serveur PostgreSQL sature au niveau des entrées/sorties (voir la commande iostat expliquée dans un article précédent, il est souvent recommandé d'utiliser des disques différents pour certaines de ces opérations. Mettre le WAL sur des disques séparés, et/ou mettre les tables temporaires sur des disques séparés permet d'éviter les effets de bord d'une grosse requête sur les requêtes qui arrivent en parallèle, et globalement d'augmenter la bande passante disque (qui dépend du nombre de disques).
Il faut bien sûr, dans ce cas, se poser la question du type de disque à utiliser (magnétique à faible vitesse -- SATA à 7200RPM --, magnétique à haute haute vitesse -- SAS à 15000RPM -- ou disque flash) suivant la volumétrie de données, le nombre d'accès et le budget disponible, et la réponse peut être différente entre les utilisations.
Une solution radicale, mais pas toujours possible ou abordable, consiste à avoir suffisamment de place en RAM pour que l'ensemble de la base soit dans le cache disque, et que les accès disques soient limités aux écritures et aux éventuelles tables temporaires.
Il faut compter en RAM la taille de la base (que l'on peut obtenir par exemple avec un du -sh /var/lib/postgresql) plus la mémoire utilisée par les applications (qui dépend des réglages de la configuration du PostgreSQL).
Si on souhaite pré-charger toute la base au démarrage de la machine, on peut le faire via une commande du type (attention le cat est nécessaire, sinon tar triche) :
tar c /var/lib/postgresl | cat > /dev/null
PostgreSQL (comme la plupart des moteurs de bases de données) permet de loguer les requêtes ayant plus pris plus d'un certain temps. Cette option se configure via l'entrée log_min_duration_statement du fichier de configuration PostgreSQL (et peut se changer dynamiquement via une commande du type (la valeur étant en milisecondes) :
set log_min_duration_statement=200;
Ce log va permettre de voir quelles sont les requêtes qui coûtent cher, afin de concentrer l'optimisation dessus.
Attention tout de même : écrire ce log demande du temps, si le seuil est trop bas, les performances peuvent s'en trouver dégradées, surtout si le serveur sature au niveau des entrées/sorties.
Il existe des outils pour analyser ces logs, extraire les requêtes les plus longues, compter les fréquences, agréger en ignorant les valeurs mais en ne regardant que le "type" de requêtes, ...
L'un de ces outils est pgfouine, un autre, développé pour nos propres besoins, est disponible ici.
Une fois les requêtes lentes identifiées, il faut les analyser. PostgreSQL fournit un outil très puissant à cette fin : EXPLAIN ANALYZE.
EXPLAIN ANALYZE va afficher le query plan utilisé pour effectuer la requête, c'est à dire toutes les étapes (chercher dans un index, parcourir une table, trier des résultats), avec à chaque fois une estimation (avant l'exécution) du temps que l'opération prendra, et le temps qu'elle a réellement pris.
Énormément d'informations peuvent être retirées du résultat de cette commande :
Le premier type d'optimisation, rapide à faire, est de créer des index composites supplémentaires.
La théorie est la suivante : il est possible, mais relativement coûteux, d'utiliser plusieurs index à la fois sur une requête. Prenons une requête du type :
SELECT * FROM table WHERE champ1 = 789 AND champ2 = 42 ORDER BY champ3 LIMIT 5
Si on créé un index sur chacun des champs, PostgreSQL aura plusieurs solutions :
Suivant les statistiques que PostgreSQL a collecté sur les index, il va choisir l'un ou l'autre des plans. Il peut parfois choisir le mauvais, mais surtout aucun plan n'est parfait.
Par contre, si on créé un index composite sur les trois champs ainsi :
CREATE INDEX table_champ1_2_3 ON table(champ1, champ2, champ3)
Il pourra alors utiliser le même index pour filtrer à la fois sur les deux champs, et trier sur le troisième.
Attention à l'ordre dans les index composites, il est extrêmement important, en raison du fonctionnement des index (pour les index de type btree, qui sont les plus courants, en tout cas). Les premiers éléments de l'index doivent être fixés à une valeur donnée pour que les éléments suivant puissent servir.
Ainsi, avec un index sur (champ1, champ2) on peut par exemple :
Mais on ne peut pas par contre :
Attention tout de même à ne pas multiplier les index, qui augmentent l'occupation disque de la base, et ralentissent les requêtes en modification (INSERT, UPDATE, DELETE).
Ce type d'optimisation découle de ce qui a été expliqué sur les index précédemment. Il est conseillé, autant que possible, d'effectuer le tri sur les mêmes critères que le filtre.
Par exemple, si on demande les articles de la section "informatique" ayant été créés (created_at) entre le 3 juin et le 4 août, triés par date de modification (modified_at), avec un LIMIT 100 parce qu'on est raisonnable, PostgreSQL ne pourra utiliser que l'un des deux critères pour son index (même un index composite ne suffirait pas, là). Par contre, si on utilise la même date pour le tri et le filtre, on peut gagner énormément.
Ce n'est bien sûr pas toujours possible d'un point de vue des fonctionnalités utilisateurs, mais parfois on peut faire en sorte que ce le soit, et le gain peut être important (un facteur 10 voir 100 n'est pas rare).
Lorsqu'on utilise un ORM (celui de Django en tout cas, mais le cas n'est sans doute pas spécifique), il arrive qu'on soit dans le cas suivant :
Dans ce cas, suivant les filtres qu'on effectue, il peut ne pas être nécessaire d'avoir la jointure entre les deux tables pour le COUNT(*). L'ORM va avoir tendance à effectuer la jointure même lorsqu'elle n'est pas nécessaire, or, c'est souvent cette jointure qui coûte le plus cher.
Considérons un cas précis : on a une table d'utilisateurs, et une table des restrictions sur les utilisateurs (par exemple, une fermeture temporaire de compte). On souhaite récupérer les 50 derniers utilisateurs n'ayant pas de restriction active. On peut effectuer la requête de deux manières :
SELECT user.* FROM user LEFT OUTER JOIN restrictions ON (restrictions.user_id = user.id AND restrictions.expiration >= NOW()) WHERE restrictions.id IS NULL ORDER BY user_id DESC LIMIT 50;
Ou :
SELECT user.* FROM user WHERE NOT EXISTS (SELECT id FROM restrictions WHERE restrictions.user_id = user.id AND restrictions.expiration >= NOW()) ORDER BY user_id DESC LIMIT 50;
Un ORM (en tout cas, celui de Django) aura tendance à utiliser la première version de la requête, alors que la deuxième version a tendance à être sensiblement plus efficace (faire une jointure et espérer ne pas trouver les données n'est pas le cas "normal" de la jointure).
La clause OFFSET (qui permet de récupérer les valeurs suivantes dans un ORDER) peut coûter très cher sur un grand nombre de résultats. Il faut envisager les solutions suivantes :
Il s'agit d'un autre type de requêtes à éviter, car très coûteuses pour la base de données. Elles peuvent souvent être éliminées en retravaillant le schéma de la base de données, en particulier, en utilisant des tableaux PostgreSQL et les opérateurs associés au lieu d'une table de jointure pour représenter un champ multivalué.
Cet article donne un certain nombre de pistes, celles qui pour nous ont été les plus utiles dans le cadre de la mise en production de la nouvelle version du site du quotidien Libération. Il y a bien sûr énormément de choses supplémentaires à dire sur le sujet, mais il existe déjà une littérature très riche pour des guides d'optimisation PostgreSQL en particulier, et des bases de données en général.
Actions sur le document