Benjamin Geer

Optimiser des requêtes PostgreSQL avec un jeu de données ouvert

Benjamin Geer
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 :

  1. Les plans d’exécution de PostgreSQL, que nous pouvons obtenir en utilisant la commande EXPLAIN.

  2. 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).

1
2
3
4
EXPLAIN ANALYZE
SELECT "MandateId"
FROM "Function"
WHERE "FunctionType" = 'MAIRE'
1
2
3
4
5
6
7
QUERY PLAN
Seq Scan on "Function"  (cost=0.00..5596.96 rows=113829 width=4)
  (actual time=0.010..17.567 rows=114192 loops=1)
  Filter: (("FunctionType")::text = 'MAIRE'::text)
  Rows Removed by Filter: 158845
Planning Time: 0.217 ms
Execution Time: 20.136 ms

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.)

1
2
3
CREATE INDEX index_function ON "Function" (
    "FunctionType"
)

Il en résulte un plan d’exécution différent :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
QUERY PLAN
Bitmap Heap Scan on "Function"  (cost=1286.60..4893.46 rows=113829 width=4)
  (actual time=3.764..11.449 rows=114192 loops=1)
  Recheck Cond: (("FunctionType")::text = 'MAIRE'::text)
  Heap Blocks: exact=822
  ->  Bitmap Index Scan on index_function
      (cost=0.00..1258.14 rows=113829 width=0)
      (actual time=3.589..3.589 rows=114192 loops=1)
        Index Cond: (("FunctionType")::text = 'MAIRE'::text)
Planning Time: 0.314 ms
Execution Time: 13.551 ms

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 :

1
2
3
4
5
6
CREATE INDEX index_function ON "Function" (
    "FunctionType"
)
INCLUDE (
    "MandateId"
)

Cela nous donne un autre plan d’exécution :

1
2
3
4
5
6
7
8
QUERY PLAN
Index Only Scan using index_function on "Function"
  (cost=0.42..4490.35 rows=115003 width=4)
  (actual time=0.027..7.354 rows=114192 loops=1)
  Index Cond: ("FunctionType" = 'MAIRE'::text)
  Heap Fetches: 0
Planning Time: 0.184 ms
Execution Time: 9.025 ms

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
SELECT "MandateId"
FROM "Function"
WHERE "FunctionType" = 'MAIRE'
FETCH FIRST 750 ROWS ONLY

SELECT "MandateId"
FROM "Function"
WHERE "FunctionType" = 'MAIRE DELEGUE'
FETCH FIRST 750 ROWS ONLY

SELECT "MandateId"
FROM "Function"
WHERE "FunctionType" = 'PRESIDENT DU CONSEIL REGIONAL'
FETCH FIRST 750 ROWS ONLY

SELECT "MandateId"
FROM "Function"
WHERE "FunctionType" = 'PREMIER ADJOINT AU MAIRE'
FETCH FIRST 750 ROWS ONLY
un diagramme à barres représentant des résultats de tests

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 :

1
2
3
4
5
EXPLAIN ANALYZE
SELECT "MandateId"
FROM "Function"
WHERE "FunctionType" = 'MAIRE'
ORDER BY "FunctionStartDate"

Sans index, la base de données indique que cette requête prend environ deux fois plus de temps que sans le tri :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
QUERY PLAN
Sort  (cost=15354.29..15644.28 rows=115995 width=8)
      (actual time=32.564..37.109 rows=114192 loops=1)
  Sort Key: "FunctionStartDate"
  Sort Method: external merge  Disk: 2016kB
  ->  Seq Scan on "Function"
      (cost=0.00..5596.96 rows=115995 width=8)
      (actual time=0.016..17.797 rows=114192 loops=1)
        Filter: (("FunctionType")::text = 'MAIRE'::text)
        Rows Removed by Filter: 158845
Planning Time: 0.398 ms
Execution Time: 40.132 ms

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 :

1
2
3
4
5
6
7
CREATE INDEX index_function ON "Function" (
    "FunctionType",
    "FunctionStartDate"
)
INCLUDE (
    "MandateId"
)
1
2
3
4
5
6
7
8
QUERY PLAN
Index Only Scan using index_function on "Function"
  (cost=0.42..4743.89 rows=115995 width=8)
  (actual time=0.052..9.744 rows=114192 loops=1)
  Index Cond: ("FunctionType" = 'MAIRE'::text)
  Heap Fetches: 0
Planning Time: 0.432 ms
Execution Time: 12.275 ms

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.

un diagramme à barres représentant des résultats de tests

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
EXPLAIN
SELECT
    func."FunctionStartDate",
    m."AreaId"
FROM 
    "Function" func,
    "Mandate" m
WHERE
    func."FunctionType" = 'MAIRE'
    AND func."MandateId" = m."MandateId"
ORDER BY func."FunctionStartDate"
FETCH FIRST 750 ROWS ONLY
1
2
3
4
5
6
7
8
9
QUERY PLAN
Limit  (cost=0.85..968.89 rows=750 width=36)
  ->  Nested Loop  (cost=0.85..147087.20 rows=113957 width=36)
        ->  Index Only Scan using index_function on "Function" func
            (cost=0.42..4664.34 rows=113957 width=8)
              Index Cond: ("FunctionType" = 'MAIRE'::text)
        ->  Index Scan using "pk_Mandat" on "Mandate" m
            (cost=0.43..1.25 rows=1 width=36)
              Index Cond: ("MandateId" = func."MandateId")

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 :

1
CREATE INDEX index_mandate ON "Mandate" ("MandateId") INCLUDE ("AreaId")

Voici le plan d’exécution qui en résulte :

1
2
3
4
5
6
7
8
9
QUERY PLAN
Limit  (cost=0.85..505.54 rows=750 width=10)
  ->  Nested Loop  (cost=0.85..76310.71 rows=113401 width=10)
        ->  Index Only Scan using index_function on "Function" func
            (cost=0.42..4643.26 rows=113401 width=8)
              Index Cond: ("FunctionType" = 'MAIRE'::text)
        ->  Index Only Scan using index_mandate on "Mandate" m
            (cost=0.43..0.63 rows=1 width=10)
              Index Cond: ("MandateId" = func."MandateId")

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.

un diagramme à barres représentant des résultats de tests

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
SELECT
    func."FunctionStartDate",
    area."AreaCode" as code_commune,
    area."AreaName" as commune,
    indiv."FirstName",
    indiv."LastName"
FROM "Function" func
JOIN "Mandate" m ON func."MandateId" = m."MandateId"
JOIN "Area" area ON m."AreaId" = area."AreaId"
JOIN "Individual" indiv ON m."IndividualId" = indiv."IndividualId"
WHERE func."FunctionType" = 'MAIRE'
ORDER BY func."FunctionStartDate"
FETCH FIRST 100 ROWS ONLY

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 :

1
2
3
CREATE INDEX index_mandate ON "Mandate" ("MandateId") INCLUDE (
    "AreaId", "IndividualId"
)

Puis nous créons des index couvrants sur Area et Individual pour notre requête :

1
2
3
4
5
CREATE INDEX index_area ON "Area" ("AreaId") INCLUDE ("AreaCode", "AreaName");

CREATE INDEX index_indiv ON "Individual" ("IndividualId") INCLUDE (
    "FirstName", "LastName"
);

Cela donne le plan d’exécution suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
QUERY PLAN
Limit  (cost=1.57..132.35 rows=100 width=38)
  ->  Nested Loop  (cost=1.57..148131.29 rows=113274 width=38)
        ->  Nested Loop  (cost=1.15..96170.50 rows=113274 width=34)
              ->  Nested Loop  (cost=0.85..82875.64 rows=113274 width=22)
                    ->  Index Only Scan using index_function on "Function" func
                        (cost=0.42..4636.71 rows=113274 width=8)
                          Index Cond: ("FunctionType" = 'MAIRE'::text)
                    ->  Index Only Scan using index_mandate on "Mandate" m
                        (cost=0.43..0.69 rows=1 width=22)
                          Index Cond: ("MandateId" = func."MandateId")
              ->  Memoize  (cost=0.30..0.32 rows=1 width=24)
                    Cache Key: m."AreaId"
                    Cache Mode: logical
                    ->  Index Only Scan using index_area on "Area" area
                        (cost=0.29..0.31 rows=1 width=24)
                          Index Cond: ("AreaId" = (m."AreaId")::text)
        ->  Index Only Scan using index_indiv on "Individual" indiv
            (cost=0.42..0.46 rows=1 width=28)
              Index Cond: ("IndividualId" = (m."IndividualId")::text)

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.

un diagramme à barres représentant des résultats de tests

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 :

un diagramme à barres représentant des résultats de tests

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
EXPLAIN
SELECT
    func."FunctionStartDate",
    area1."AreaCode" as code_commune,
    area1."AreaName" as commune,
    area2."AreaName" as departement,
    indiv."FirstName",
    indiv."LastName"
FROM "Function" func
JOIN "Mandate" m ON func."MandateId" = m."MandateId"
JOIN "Area" area1 ON m."AreaId" = area1."AreaId"
JOIN "Inclusion" incl on m."AreaId" = incl."IncludedAreaId"
JOIN "Area" area2 on incl."IncludingAreaId" = area2."AreaId"
JOIN "Individual" indiv ON m."IndividualId" = indiv."IndividualId"
WHERE func."FunctionType" = 'MAIRE'
AND area2."AreaType" = 'Département'
ORDER BY func."FunctionStartDate"
FETCH FIRST 100 ROWS ONLY
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
QUERY PLAN
Limit  (cost=35292.57..35304.24 rows=100 width=51)
  ->  Gather Merge  (cost=35292.57..35351.14 rows=502 width=51)
        Workers Planned: 2
        ->  Sort  (cost=34292.55..34293.18 rows=251 width=51)
              Sort Key: func."FunctionStartDate"
              ->  Nested Loop  (cost=30010.19..34282.96 rows=251 width=51)
                    ->  Parallel Hash Join  (cost=30009.76..34167.82 rows=251 width=47)
                          Hash Cond: (func."MandateId" = m."MandateId")
                          ->  Parallel Index Only Scan using index_function on "Function" func
                              (cost=0.42..3980.29 rows=47235 width=8)
                                Index Cond: ("FunctionType" = 'MAIRE'::text)
                          ->  Parallel Hash  (cost=29971.32..29971.32 rows=3042 width=47)
                                ->  Hash Join  (cost=2914.37..29971.32 rows=3042 width=47)
                                      Hash Cond: ((m."AreaId")::text = (area1."AreaId")::text)
                                      ->  Parallel Seq Scan on "Mandate" m
                                          (cost=0.00..24881.12 rows=572112 width=22)
                                      ->  Hash  (cost=2911.27..2911.27 rows=248 width=43)
                                            ->  Nested Loop
                                                (cost=967.16..2911.27 rows=248 width=43)
                                                  ->  Hash Join
                                                      (cost=966.88..2832.29 rows=248 width=19)
                                                        Hash Cond:
                                                        ((incl."IncludingAreaId")::text =
                                                         (area2."AreaId")::text)
                                                        ->  Seq Scan on "Inclusion" incl
                                                            (cost=0.00..1607.70
                                                             rows=98170 width=15)
                                                        ->  Hash
                                                            (cost=965.40..965.40
                                                             rows=118 width=19)
                                                              ->  Seq Scan on "Area" area2
                                                                  (cost=0.00..965.40
                                                                   rows=118 width=19)
                                                                    Filter:
                                                                    (("AreaType")::text =
                                                                    'Département'::text)
                                                  ->  Index Only Scan using index_area
                                                      on "Area" area1
                                                      (cost=0.29..0.32 rows=1 width=24)
                                                        Index Cond:
                                                        ("AreaId" =
                                                         (incl."IncludedAreaId")::text)
                    ->  Index Only Scan using index_indiv on "Individual" indiv
                        (cost=0.42..0.46 rows=1 width=28)
                          Index Cond: ("IndividualId" = (m."IndividualId")::text)

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 :

  1. Donner à l’optimiseur une meilleure option que la jointure de hachage.

  2. 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 WHERE comme func."FunctionStartDate" < DATE '1975-01-01').

  3. 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 :

1
2
3
CREATE INDEX index_inclusion ON "Inclusion" ("IncludedAreaId") INCLUDE (
    "IncludingAreaId"
);

Cela nous donne à nouveau un plan d’exécution à boucles imbriquées :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
QUERY PLAN
Limit  (cost=1002.32..10605.10 rows=100 width=51)
  ->  Nested Loop  (cost=1002.32..58907.08 rows=603 width=51)
        ->  Gather Merge  (cost=1001.89..58630.47 rows=603 width=47)
              Workers Planned: 2
              ->  Nested Loop  (cost=1.87..57560.84 rows=251 width=47)
                    ->  Nested Loop  (cost=1.58..57476.99 rows=272 width=41)
                          ->  Nested Loop  (cost=1.28..53851.27 rows=107699 width=37)
                                ->  Nested Loop  (cost=0.85..36596.43 rows=47235 width=22)
                                      ->  Parallel Index Only Scan using index_function
                                          on "Function" func
                                          (cost=0.42..3980.29 rows=47235 width=8)
                                            Index Cond: ("FunctionType" = 'MAIRE'::text)
                                      ->  Index Only Scan using index_mandate on "Mandate" m
                                          (cost=0.43..0.69 rows=1 width=22)
                                            Index Cond: ("MandateId" = func."MandateId")
                                ->  Memoize  (cost=0.43..0.46 rows=2 width=15)
                                      Cache Key: m."AreaId"
                                      Cache Mode: logical
                                      ->  Index Only Scan using index_inclusion
                                          on "Inclusion" incl
                                          (cost=0.42..0.45 rows=2 width=15)
                                            Index Cond: ("IncludedAreaId" = (m."AreaId")::text)
                          ->  Memoize  (cost=0.30..0.34 rows=1 width=19)
                                Cache Key: incl."IncludingAreaId"
                                Cache Mode: logical
                                ->  Index Scan using "pk_Territoire" on "Area" area2
                                    (cost=0.29..0.33 rows=1 width=19)
                                      Index Cond:
                                      (("AreaId")::text = (incl."IncludingAreaId")::text)
                                      Filter: (("AreaType")::text = 'Département'::text)
                    ->  Index Only Scan using index_area on "Area" area1
                        (cost=0.29..0.31 rows=1 width=24)
                          Index Cond: ("AreaId" = (m."AreaId")::text)
        ->  Index Only Scan using index_indiv on "Individual" indiv
            (cost=0.42..0.46 rows=1 width=28)
              Index Cond: ("IndividualId" = (m."IndividualId")::text)

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 :

un diagramme à barres représentant des résultats de tests

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 :

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.

Catégories :
Sujets :