Optimiser des requêtes PostgreSQL avec un jeu de données ouvert
Table des matières
Aperçu #
Ce billet illustre brièvement quelques techniques d’optimisation des bases de données, en utilisant la BRÉF (Base Révisée des Élu·es de France), qui contient des données sur les représentant·es élu·es en France de 1948 à 2020. La BRÉF est basée sur des données publiques, dans lesquelles une équipe de chercheurs a corrigé beaucoup d’erreurs et d’incohérences. Elle est publiée sous la forme d’une base de données PostgreSQL qui comprend plusieurs tables et offre beaucoup de possibilités de jointures et d’optimisations.
J’utiliserai ici cette base de données pour développer et optimiser une requête SQL comprenant plusieurs jointures, pour obtenir des informations sur les maires et les communes où elles et ils ont été élu·es. Je construirai la requête à partir d’éléments plus simples, que j’optimiserai au fur et à mesure. Dans le cadre de cet exercice, je pars du principe que nous ne pouvons pas modifier le schéma de la base de données et que nous sommes prêts à payer un certain coût, en espace de stockage et en termes de performances de mise à jour, pour améliorer les performances des requêtes. Je me concentrerai donc sur ce que nous pouvons faire en créant des index. Pour identifier et tester des optimisations possibles, nous aurons deux sources d’information :
-
Les plans d’exécution de PostgreSQL, que nous pouvons obtenir en utilisant la commande
EXPLAIN. -
Des tests de performance exécutés par mon outil sqlstopwatch, qui exécute des requêtes à maintes reprises et indique le temps de réponse moyen de chaque requête.
Optimiser une requête simple #
Dans la terminologie du BRÉF,
le mandat correspond à la position élective (député, sénateur, etc.) tandis que le terme fonction recouvre une fonction exécutive dans le cadre d’un mandat. Ainsi, une personne peut être élue à la position de conseiller·e municipal·e, puis à celle de maire au sein de ce conseil municipal.
Il y a donc des tables qui s’appellent Mandate et Function et nous
commencerons par celles-ci.
Trouvons d’abord toutes les fonctions de type MAIRE. Nous utiliserons
EXPLAIN ANALYZE pour
que Postgres exécute la requête, puis affiche le plan d’exécution (EXPLAIN) en
indiquant le temps nécessaire à son exécution (ANALYZE).
|
|
|
|
Le plan d’exécution est un arbre et chaque node a un coût
estimé
dont les unités sont arbitraires. Il faut noter que « le coût d’un nœud père
comprend le coût de tous ses fils ». Seq Scan veut dire que la base de données
parcourt toute la table Function à la recherche de chaînes correspondantes.
Pour éviter cela, créons un
index sur la
colonne FunctionType, puis exécutons EXPLAIN ANALYZE à nouveau avec la même
requête. (Dans cet exercice nous nous limiterons à utiliser des index de type
B-Tree, que la commande CREATE INDEX utilise par défaut.)
|
|
Il en résulte un plan d’exécution différent :
|
|
Postgres a effectué un parcours de bitmap, que Tom Lane, un des développeurs de Postgres, a expliqué dans ce message publié sur une liste de diffusion.
Un parcours de bitmap récupère tous les pointeurs de n-uplets de l’index en une seule fois, les trie en mémoire à l’aide d’une structure de données appelée « bitmap », puis visite les n-uplets de la table dans l’ordre physique de leur emplacement… Si le bitmap devient trop volumineux, nous le convertissons en bitmap « avec perte », dans lequel nous ne notons que les pages contenant des n-uplets correspondants au lieu de noter chaque n-uplet individuellement. Dans ce cas-là, quand nous lisons ces pages de la table, il faut examiner chaque n-uplet de chaque page lue et revérifier la condition du parcours pour déterminer les n-uplets à renvoyer.
Ceci est plus efficace qu’un parcours de table, mais nous payons toujours le
prix de l’accès à la table pour récupérer les n-uplets (c’est-à-dire les lignes)
et celui de la revérification de la condition du parcours. Comme il ne nous faut
que la colonne MandateId, nous pouvons la mettre elle aussi dans l’index, pour
créer un index
couvrant pour
cette requête :
|
|
Cela nous donne un autre plan d’exécution :
|
|
Maintenant Postgres peut faire un parcours d’index seul, parce que toutes les colonnes qu’il nous faut sont dans l’index. Cela semble être une amélioration, mais faisons un test de performance pour vérifier. Testons plusieurs requêtes qui cherchent différentes fonctions, y compris celle de maire. Nous ferons le test d’abord sans index, puis avec chacun des index ci-dessus.
|
|
Il est clair qu’il vaut mieux avoir un index que ne pas en avoir. Par rapport à
l’index qui ne contient que FunctionType, celui qui comprend aussi MandateId
(et permet donc un parcours d’index seul) ne change pas grand-chose pour
certaines valeurs de FunctionType, mais il est plus de deux fois plus rapide
pour MAIRE.
Trier #
Nous aimerions obtenir des résultats triés par date de début de fonction :
|
|
Sans index, la base de données indique que cette requête prend environ deux fois plus de temps que sans le tri :
|
|
Il est intéressant de noter que si nous utilisons l’index de la section
précédente avec cette requête, cela ne change pas le plan d’exécution cette
fois-ci. Postgres a décidé que notre index n’apportera probablement pas
d’avantage à cette requête. Pour que l’index vaille la peine d’être utilisé ici,
ajoutons-y la colonne FunctionStartDate, pour que les entrées pour chaque type
de fonction soient triées par date de début dans l’index :
|
|
|
|
Cela nous donne à nouveau un parcours d’index seul, qui semble plus efficace.
Faisons un test de performance pour comparer l’index précédent à celui-ci. Nous
utiliserons les requêtes de la section précédente, en ajoutant ORDER BY "FunctionStartDate" à chacune d’elles.
Il semble que plus il y a de lignes à trier, plus il soit avantageux d’utiliser
un index qui est déjà trié dans l’ordre demandé par la clause ORDER BY. Cet
avantage est très important dans le cas de MAIRE (114 192 lignes) et PREMIER ADJOINT AU MAIRE (37 511 lignes) et moins important dans les autres cas, qui
renvoient beaucoup moins de lignes.
Ajouter des jointures #
Maintenant que nous avons MandateId, nous pouvons récupérer des clés
étrangères sur la ligne correspondante de la table Mandate, puis effectuer des
jointures pour obtenir plus d’informations sur la personne qui a été élue et la
commune où l’élection a eu lieu. Commençons par la commune. La table Area
contient des données sur différents niveaux géographiques, y compris les
communes. Avant de pouvoir nous renseigner sur la commune, nous devons obtenir
son identifiant dans la table Mandate. Regardons d’abord le plan d’exécution
d’une requête qui fait cela, sans l’exécuter :
|
|
|
|
La base de données fait toujours un Index Only Scan pour la table Fonction,
mais elle fait un Index Scan pour la table Mandate, en utilisant l’index de
clé primaire de cette table. Cela veut dire qu’elle parcourt l’index jusqu’à ce
qu’elle trouve le MandateId correspondant, puis récupère la ligne
correspondante de la table Mandate pour obtenir l’AreaId. Nous pouvons créer
un index qui contient AreaId pour effectuer un parcours d’index seul :
|
|
Voici le plan d’exécution qui en résulte :
|
|
Il semble que le coût ait baissé. Faisons un test de performance, encore une
fois avec différentes valeurs pour FunctionType, pour comparer l’index de clé
primaire à l’index couvrant. Dans les deux cas nous garderons notre index le
plus performant sur Function.
L’index couvrant ne fait pas une grande différence, mais semble toutefois utile.
Maintenant que nous avons l’AreaId de la commune, nous pouvons obtenir son nom
et son code géographique (qui
n’est pas la même chose que le code postal). Nous aimerions aussi avoir le nom
de la personne qui a été élue.
|
|
Comme il y a plusieurs jointures, j’ai utilisé le mot-clé JOIN pour que la
requête soit un peu plus facile à lire. (Cela ne change pas le plan
d’exécution.) Et comme la requête renvoie maintenant davantage de colonnes, j’ai
demandé moins de lignes, pour que les résultats du test de performance ne soient
pas dominés par le temps qu’il faut pour renvoyer les résultats de la requête.
Pour la jointure entre Mandate et Individual, nous ajoutons IndividualId à
notre index sur Mandate :
|
|
Puis nous créons des index couvrants sur Area et Individual pour notre
requête :
|
|
Cela donne le plan d’exécution suivant :
|
|
Toutes les données viennent des index, les tables n’étant pas utilisés du tout.
Si nous ne créons pas les deux derniers index, il y a des Index Scan plutôt
que des Index Only Scan sur Area et Individual. Comparons la performance
de cette requête avec et sans ces deux index.
Il y a très peu de différence, mais garderons les index.
Comparons maintenant la performance de cette dernière requête sans aucun index (à part les index de clé primaire qui ont été créés par défaut) et avec tous les index que nous avons créés :
Cette requête est 14 à 24 fois plus rapide avec les index que sans eux.
Encore plus de jointures #
Essayons de récupérer le nom du département auquel la commune appartient. Nous
pouvons ajouter une jointure sur la table Inclusion pour trouver l’Area qui
représente le département :
|
|
|
|
Qu’est-ce qui s’est passé ? Postgres a décidé qu’une jointure de hachage serait
plus efficace que l’ajout d’une autre boucle imbriquée. Plutôt que d’utiliser
notre index sur la table Mandate, il compte lire environ 500 000 lignes et les
mettre dans une table de hachage en mémoire. Il utilise l’index trié par date
sur Fonction, mais comme une table de hachage n’est pas triée, il doit ensuite
trier les résultats à nouveau. En utilisant EXPLAIN ANALYZE, nous pouvons
constater que la requête prend à peu près 600 ms. Le simple fait d’obtenir le
nom du département a ralenti notre requête de plusieurs centaines de fois.
Nous avons plusieurs options :
-
Donner à l’optimiseur une meilleure option que la jointure de hachage.
-
Optimiser la jointure de hachage en demandant moins de colonnes (ce qui n’est pas vraiment possible dans notre cas) ou moins de lignes (nous pourrions, par exemple, ajouter une condition
WHEREcommefunc."FunctionStartDate" < DATE '1975-01-01'). -
Effectuer la jointure dans l’application. Dans le cas présent, comme il n’y a que 101 départements en France et qu’ils changent très rarement, l’application pourrait les lire au démarrage. Le code géographique de chaque commune (que nous avons déjà obtenu avec la requête précédente) contient le code du département. Il serait donc facile pour l’application de chercher le département en mémoire.
Essayons la première option. Nous pouvons ajouter cet index :
|
|
Cela nous donne à nouveau un plan d’exécution à boucles imbriquées :
|
|
Le coût reste pourtant assez élevé, et EXPLAIN ANALYZE indique que la requête
prend 12 ms environ. Un test de performance indique qu’elle peut être 10 à 20
fois plus lente que notre requête précédente :
Il serait plus efficace de garder la requête précédente et d’effectuer la jointure dans l’application.
Conclusion #
Cette exercice a démontré quelques points importants sur l’optimisation des bases de données :
-
À chaque fois que nous modifions une requête, il faut revérifier le plan d’exécution pour connaître l’effet de la modification sur les performances.
-
Des requêtes différentes peuvent avoir besoin d’index différents.
-
Un parcours d’index seul peut être le meilleur moyen d’améliorer la performance d’une requête dans certains cas, mais il faut vérifier cette hypothèse en la testant.
-
Parfois la meilleure optimisation est de demander moins de données.
Tous les scripts et les fichiers de configuration que j’ai utilisés pour cet exercice se trouvent dans ce dépôt git.
Pour en savoir plus sur ce sujet, je recommande le site web Use the Index, Luke! de Markus Winand, ainsi que son livre SQL : Au cœur des performances.