Normalisation de schéma
Renommer la table en voitures_orig. \ Ne pas la
supprimer (nous en aurons besoin plus tard).
ALTER TABLE voitures rename TO voitures_orig;
Écrire des requêtes permettant d’éclater cette table en trois
tables : \ voitures, caracteristiques et
caracteristiques_voitures. \ (La fonction
regexp_split_to_table permettra de séparer les champs de
caractéristiques.)
CREATE TABLE voitures AS
SELECT DISTINCT ON (immatriculation) immatriculation, modele
FROM voitures_orig ;
ALTER TABLE voitures ADD PRIMARY KEY (immatriculation);
CREATE TABLE caracteristiques
AS SELECT *
FROM (
SELECT DISTINCT
regexp_split_to_table(caracteristiques,', ' ) caracteristique
FROM voitures_orig)
AS tmp
WHERE caracteristique <> ' ' ;
ALTER TABLE caracteristiques ADD PRIMARY KEY (caracteristique);
CREATE TABLE caracteristiques_voitures
AS SELECT DISTINCT *
FROM (
SELECT
immatriculation,
regexp_split_to_table(caracteristiques,', ' ) caracteristique
FROM voitures_orig
)
AS tmp
WHERE caracteristique <> ' ' ;
VACUUM ANALYZE ;
\d+
Liste des relations
Schéma | Nom | Type | Propriétaire | ... | Taille | ...
--------+---------------------------+-------+--------------+-----+---------+-
public | caracteristiques | table | postgres | | 48 kB |
public | caracteristiques_voitures | table | postgres | | 3208 kB |
public | voitures | table | postgres | | 5160 kB |
public | voitures_ecv | table | postgres | | 3336 kB |
public | voitures_orig | table | postgres | | 5736 kB |
Mettre en place les contraintes d’intégrité : clé primaire sur chaque
table, et clés étrangères. \ Ne pas prévoir encore d’index
supplémentaire. \ Attention : la table de départ
contient des immatriculations en doublon !
Sur caracteristiques_voitures, la clé primaire comprend
les deux colonnes, et donc interdit qu’une même caractéristique soit
présente deux fois sur la même voiture :
ALTER TABLE caracteristiques_voitures
ADD PRIMARY KEY (immatriculation,caracteristique);
Clé étrangère de cette table vers les deux autres tables :
ALTER TABLE caracteristiques_voitures
ADD FOREIGN KEY (immatriculation)
REFERENCES voitures(immatriculation);
ALTER TABLE caracteristiques_voitures
ADD FOREIGN KEY (caracteristique)
REFERENCES caracteristiques(caracteristique);
Tenter d’insérer une Clio avec les caractéristiques « ABS »
(majusucules) et « phares LED ».
En toute rigueur il faut le faire dans une transaction :
BEGIN ;
INSERT INTO voitures VALUES ('AA-007-JB ' ,'clio ' ) ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB ' ,'ABS ' ) ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB ' ,'phares LED ' ) ;
COMMIT ;
Évidemment, cela échoue :
ERROR: insert or update on table "caracteristiques_voitures" violates foreign key
constraint "caracteristiques_voitures_caracteristique_fkey"
DÉTAIL : Key (caracteristique)=(ABS) is not present in table "caracteristiques".
ERROR: insert or update on table "caracteristiques_voitures" violates foreign key
constraint "caracteristiques_voitures_immatriculation_fkey"
DÉTAIL : Key (immatriculation)=(AA-007-JB) is not present in table "voitures".
En cas d’erreur, c’est exactement ce que l’on veut.
Pour que l’insertion fonctionne, il faut corriger la casse de « ABS »
et déclarer la nouvelle propriété :
BEGIN ;
INSERT INTO voitures VALUES ('AA-007-JB ' ,'clio ' ) ;
INSERT INTO caracteristiques VALUES ('phares LED ' ) ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB ' ,'abs ' ) ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB ' ,'phares LED ' ) ;
COMMIT ;
Comparer les performances entre les deux modèles pour une recherche
des voitures ayant un toit ouvrant.
La version la plus simple est :
SELECT * FROM voitures_orig
WHERE caracteristiques like '%toit ouvrant% ' ;
Plus rigoureusement ([[:>:]] et
[[:<:]] indiquent des frontières de mots.), on
préférera :
EXPLAIN ANALYZE
SELECT * FROM voitures_orig
WHERE caracteristiques ~ E'[[:<:]]toit ouvrant[[:>:]]' ;
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on voitures_orig (cost=0.00..1962.00 rows=8557 width=25) (actual time=0.069..121.861 rows=8358.00 loops=1)
Filter: (caracteristiques ~ '[[:<:]]toit ouvrant[[:>:]]'::text)
Rows Removed by Filter: 91642
Planning Time: 1.221 ms
Execution Time: 126.361 ms
Toute la table a été parcourue, 91 642 lignes ont été rejetées, 8358
retenues (~8 %). Les estimations statistiques sont correctes.
NB : pour la lisibilité, les plans n’utilisent pas l’option
BUFFERS d’EXPLAIN. Si on l’active, on pourra
vérifier que tous les accès se font bien dans le cache de PostgreSQL
(shared hits).
Avec le nouveau schéma on peut écrire la requête simplement avec une
simple jointure :
SELECT *
FROM voitures
INNER JOIN caracteristiques_voitures
ON ( caracteristiques_voitures.immatriculation= voitures.immatriculation)
WHERE caracteristique = 'toit ouvrant ' ;
Il n’y a pas doublement de lignes si une caractéristique est en
double car la clé primaire l’interdit. Sans cette contrainte, une autre
écriture serait nécessaire :
SELECT *
FROM voitures
WHERE EXISTS (
SELECT 1 FROM caracteristiques_voitures
WHERE caracteristiques_voitures.immatriculation= voitures.immatriculation
AND caracteristique = 'toit ouvrant '
) ;
Dans les deux cas, on obtient ce plan :
QUERY PLAN
-------------------------------------------------------------------------------
Hash Join (cost=1226.55..3128.92 rows=8389 width=41) (actual time=13.714..119.595 rows=8358.00 loops=1)
Hash Cond: (voitures.immatriculation = caracteristiques_voitures.immatriculation)
-> Seq Scan on voitures (cost=0.00..1639.89 rows=99989 width=16) (actual time=0.011..45.753 rows=99989.00 loops=1)
-> Hash (cost=1121.69..1121.69 rows=8389 width=25) (actual time=13.676..13.679 rows=8358.00 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 577kB
-> Seq Scan on caracteristiques_voitures (cost=0.00..1121.69 rows=8389 width=25) (actual time=0.010..8.928 rows=8358.00 loops=1)
Filter: (caracteristique = 'toit ouvrant'::text)
Rows Removed by Filter: 49697
Planning Time: 0.780 ms
Execution Time: 123.269 ms
Le temps d’exécution est ici plus court malgré un parcours complet de
voitures. PostgreSQL prévoit correctement qu’il ramènera
10 % de cette table, ce qui n’est pas si discriminant et justifie
fréquemment un Seq Scan, surtout que voitures
est petite. caracteristiques_voitures est aussi parcourue
entièrement : faute d’index, il n’y a pas d’autre moyen.
Les plans sont-ils les mêmes si la caractéristique recherchée
n’existe pas ?
Si on cherche une option rare ou n’existant pas, le plan change :
EXPLAIN ANALYZE
SELECT *
FROM voitures
INNER JOIN caracteristiques_voitures
ON ( caracteristiques_voitures.immatriculation= voitures.immatriculation)
WHERE caracteristique = 'ordinateur de bord ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=0.42..1130.12 rows=1 width=41) (actual time=4.991..4.994 rows=0.00 loops=1)
-> Seq Scan on caracteristiques_voitures (cost=0.00..1121.69 rows=1 width=25) (actual time=4.988..4.990 rows=0.00 loops=1)
Filter: (caracteristique = 'ordinateur de bord'::text)
Rows Removed by Filter: 58055
-> Index Scan using voitures_pkey on voitures (cost=0.42..8.44 rows=1 width=16) (never executed)
Index Cond: (immatriculation = caracteristiques_voitures.immatriculation)
Index Searches: 0
Planning Time: 0.424 ms
Execution Time: 5.020 ms
Avec un seul résultat attendu, ce qui est beaucoup plus discriminant,
l’utilisation de l’index sur voitures devient
pertinente.
Avec l’ancien schéma, on doit toujours lire la table
voitures_orig en entier.
Indexer la colonne de clé étrangère
caracteristiques_voitures.carateristique et voir ce que
devient le plan de la dernière requête.
CREATE INDEX ON caracteristiques_voitures (caracteristique) ;
Le plan d’exécution
devient foudroyant, puisque la table
caracteristiques_voitures n’est plus intégralement
lue :
EXPLAIN ANALYZE
SELECT *
FROM voitures
INNER JOIN caracteristiques_voitures
ON ( caracteristiques_voitures.immatriculation= voitures.immatriculation)
WHERE caracteristique = 'ordinateur de bord ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=0.71..16.66 rows=1 width=41) (actual time=0.036..0.038 rows=0.00 loops=1)
-> Index Scan using caracteristiques_voitures_caracteristique_idx on caracteristiques_voitures (cost=0.29..8.23 rows=1 width=25) (actual time=0.033..0.034 rows=0.00 loops=1)
Index Cond: (caracteristique = 'ordinateur de bord'::text)
Index Searches: 1
-> Index Scan using voitures_pkey on voitures (cost=0.42..8.44 rows=1 width=16) (never executed)
Index Cond: (immatriculation = caracteristiques_voitures.immatriculation)
Index Searches: 0
Planning Time: 0.575 ms
Execution Time: 0.086 ms
Avec voitures_orig, il existerait aussi des méthodes
d’indexation mais elles sont plus lourdes (index GIN…).
Rechercher une voitures possédant les 3 options ABS, toit ouvrant et
4 roues motrices, et voir le plan.
Si on recherche plusieurs options en même temps, l’optimiseur peut
améliorer les choses en prenant en compte la fréquence de chaque option
pour restreindre plus efficacement les recherches. Le plan devient :
EXPLAIN (ANALYZE , COSTS OFF )
SELECT *
FROM voitures
JOIN caracteristiques_voitures AS cr1 USING (immatriculation)
JOIN caracteristiques_voitures AS cr2 USING (immatriculation)
JOIN caracteristiques_voitures AS cr3 USING (immatriculation)
WHERE cr1.caracteristique = 'toit ouvrant '
AND cr2.caracteristique = 'abs '
AND cr3.caracteristique= '4 roues motrices ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop
-> Hash Join
Hash Cond: (cr3.immatriculation = cr1.immatriculation)
-> Bitmap Heap Scan on caracteristiques_voitures cr3
Recheck Cond: (caracteristique = '4 roues motrices'::text)
-> Bitmap Index Scan on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique = '4 roues motrices'::text)
-> Hash
-> Hash Join
Hash Cond: (cr1.immatriculation = cr2.immatriculation)
-> Bitmap Heap Scan on caracteristiques_voitures cr1
Recheck Cond: (caracteristique = 'toit ouvrant'::text)
-> Bitmap Index Scan on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique = 'toit ouvrant'::text)
-> Hash
-> Bitmap Heap Scan on caracteristiques_voitures cr2
Recheck Cond: (caracteristique = 'abs'::text)
-> Bitmap Index Scan on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique = 'abs'::text)
-> Index Scan using voitures_pkey on voitures
Index Cond: (immatriculation = cr1.immatriculation)
Ce plan parcoure deux index, joins leurs résultats, fait de même avec
le résultat de l’index pour la 3è caractéristique, puis opère la
jointure finale avec la table principale par l’index sur
immatriculation (un plan complet indiquerait une estimation
de 56 lignes de résultat, même si le résultat final est de 461
lignes).
Mais les problématiques de performances ne sont pas le plus important
dans ce cas. Ce qu’on gagne réellement, c’est la garantie que les
caractéristiques ne seront que celles existant dans la table
caractéristique, ce qui évite d’avoir à réparer la base
plus tard.
Entité-clé-valeur
Afficher toutes les caractéristiques d’une voiture au hasard (par
exemple ZY-745-KT).
SELECT * FROM voitures_ecv
WHERE entite = 'ZY-745-KT ' ;
entite | cle | valeur
-----------+-----------------------+--------
ZY-745-KT | climatisation | t
ZY-745-KT | jantes aluminium | t
ZY-745-KT | regulateur de vitesse | t
ZY-745-KT | toit ouvrant | t
Trouver toutes les caractéristiques de toutes les voitures ayant un
toit ouvrant dans voitures_ecv. Trier par immatriculation.
Quel est le plan d’exécution ?
Autrement dit : on sélectionne toutes les voitures avec un toit
ouvrant, et l’on veut toutes les caractéristiques de ces voitures. Cela
nécessite d’appeler deux fois la table.
Là encore une jointure de la table avec elle-même sur
entite serait possible, mais serait dangereuse dans les cas
où il y a énormément de propriétés. On préférera encore la version avec
EXISTS, et PostgreSQL en fera spontanément une jointure :
EXPLAIN ANALYZE
SELECT * FROM voitures_ecv
WHERE EXISTS (
SELECT 1 FROM voitures_ecv test
WHERE test.entite= voitures_ecv.entite
AND cle = 'toit ouvrant ' AND valeur = true
)
ORDER BY entite ;
QUERY PLAN
-------------------------------------------------------------------------------
Sort (cost=3435.99..3473.81 rows=15128 width=26) (actual time=124.702..136.224 rows=17782.00 loops=1)
Sort Key: voitures_ecv.entite
Sort Method: quicksort Memory: 1525kB
-> Hash Join (cost=1240.76..2385.73 rows=15128 width=26) (actual time=16.170..107.078 rows=17782.00 loops=1)
Hash Cond: (voitures_ecv.entite = test.entite)
-> Seq Scan on voitures_ecv (cost=0.00..992.55 rows=58055 width=26) (actual time=0.010..34.524 rows=58055.00 loops=1)
-> Hash (cost=1137.69..1137.69 rows=8246 width=10) (actual time=16.142..16.147 rows=8358.00 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 471kB
-> Seq Scan on voitures_ecv test (cost=0.00..1137.69 rows=8246 width=10) (actual time=0.006..10.346 rows=8358.00 loops=1)
Filter: (valeur AND (cle = 'toit ouvrant'::text))
Rows Removed by Filter: 49697
Planning Time: 0.498 ms
Execution Time: 146.760 ms
Installer l’extension hstore. \ Convertir cette table
pour qu’elle utilise une ligne par immatriculation, avec les
caractéristiques dans un champ hstore. \ Une méthode simple
est de récupérer les lignes d’une même immatriculation avec la fonction
array_agg puis de convertir simplement en champ
hstore.
hstore est normalement présente sur toutes les
installations (ou alors l’administrateur a négligé d’installer le paquet
contrib). Il suffit donc d’une déclaration.
CREATE EXTENSION hstore;
CREATE TABLE voitures_hstore
AS
SELECT entite AS immatriculation,
hstore(array_agg (cle),array_agg (valeur):: text []) AS caracteristiques
FROM voitures_ecv group by entite;
ALTER TABLE voitures_hstore ADD PRIMARY KEY (immatriculation);
Rechercher la voiture précédente.
SELECT * FROM voitures_hstore
WHERE immatriculation = 'ZY-745-KT ' \gx
-[ RECORD 1 ]----+--------------------------------------------------------------
immatriculation | ZY-745-KT
caracteristiques | "toit ouvrant"=>"true", "climatisation"=>"true",
| "jantes aluminium"=>"true", "regulateur de vitesse"=>"true"
L’accès à une caractéristique se fait ainsi (attention aux
espaces) :
SELECT immatriculation, caracteristiques -> 'climatisation '
FROM voitures_hstore
WHERE immatriculation = 'ZY-745-KT ' ;
Insérer une voiture avec les caractéristiques
couleur=>vert et phares=>LED.
INSERT INTO voitures_hstore
VALUES ('XX-4456-ZT ' , 'couleur=>vert, phares=>LED ' :: hstore) ;
Définir un index de type GiST sur ce champ hstore. \
Retrouver la voiture insérée par ses caractéristiques.
Les index B-tree classiques sont inadaptés aux types complexes, on
préfère donc un index GiST :
CREATE INDEX voitures_hstore_caracteristiques
ON voitures_hstore
USING gist (caracteristiques);
L’opérateur @> signifie « contient » :
SELECT *
FROM voitures_hstore
WHERE caracteristiques @> 'couleur=>vert ' AND caracteristiques @> 'phares=>LED ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on voitures_hstore (cost=4.32..19.24 rows=4 width=64) (actual time=0.063..0.066 rows=1.00 loops=1)
Recheck Cond: ((caracteristiques @> '"couleur"=>"vert"'::hstore) AND (caracteristiques @> '"phares"=>"LED"'::hstore))
Heap Blocks: exact=1
Buffers: shared hit=4
-> Bitmap Index Scan on voitures_hstore_caracteristiques (cost=0.00..4.32 rows=4 width=0) (actual time=0.044..0.045 rows=1.00 loops=1)
Index Cond: ((caracteristiques @> '"couleur"=>"vert"'::hstore) AND (caracteristiques @> '"phares"=>"LED"'::hstore))
Index Searches: 1
Buffers: shared hit=3
Planning:
Buffers: shared hit=20
Planning Time: 0.280 ms
Execution Time: 0.102 ms
Indexation de champs
tableau
Trouver cette fonction dans la documentation de PostgreSQL (chercher
dans les fonctions de découpage de chaîne de caractères).
La fonction est regexp_split_to_array (sa documentation
est sur https://docs.postgresql.fr/15/functions-matching.html ) :
SELECT immatriculation, modele,
regexp_split_to_array(caracteristiques,', ' )
FROM voitures_orig
LIMIT 10 ;
immatriculation | modele | regexp_split_to_array
-----------------+--------+-----------------------------------------
WW-649-AI | twingo | {"regulateur de vitesse"}
QZ-533-JD | clio | {"4 roues motrices","jantes aluminium"}
YY-854-LE | megane | {climatisation}
QD-761-QV | twingo | {""}
LV-277-QC | megane | {abs,"jantes aluminium"}
ZI-003-BQ | kangoo | {"boite automatique",climatisation}
WT-817-IK | megane | {""}
JK-791-XB | megane | {""}
WW-019-EK | megane | {""}
BZ-544-OS | twingo | {""}
La syntaxe {} est la représentation texte d’un
tableau.
Définir un index fonctionnel sur le résultat de cette fonction, de
type GIN.
CREATE INDEX idx_voitures_array ON voitures_orig
USING gin (regexp_split_to_array(caracteristiques,', ' ));
Rechercher toutes les voitures avec toit ouvrant et voir le plan.
EXPLAIN ANALYZE
SELECT * FROM voitures_orig
WHERE regexp_split_to_array(caracteristiques,', ' ) @> '{"toit ouvrant"} ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on voitures_orig (cost=23.52..711.60 rows=500 width=25) (actual time=1.259..9.499 rows=8358.00 loops=1)
Recheck Cond: (regexp_split_to_array(caracteristiques, ','::text) @> '{"toit ouvrant"}'::text[])
Heap Blocks: exact=712
-> Bitmap Index Scan on idx_voitures_array (cost=0.00..23.39 rows=500 width=0) (actual time=1.061..1.063 rows=8358.00 loops=1)
Index Cond: (regexp_split_to_array(caracteristiques, ','::text) @> '{"toit ouvrant"}'::text[])
Index Searches: 1
Planning Time: 0.264 ms
Execution Time: 14.969 ms
Noter que les estimations de statistiques sont plus délicates sur un
résultat de fonction.
Écrire une requête permettant de récupérer les 10 premiers posts de
l’article d’id_article=12, triés dans l’ordre
de id_post. Il n’y a pas d’index, la requête va être très
lente.
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE id_article = 12
ORDER BY id_post
LIMIT 10 ;
Le plan est un
parcours complet de la table, rejetant 4 999 000 lignes et en gardant
1000 lignes, suivi d’un tri :
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=153681.84..153681.86 rows=10 width=116) (actual time=843.226..843.254 rows=10.00 loops=1)
-> Sort (cost=153681.84..153684.29 rows=980 width=116) (actual time=843.220..843.232 rows=10.00 loops=1)
Sort Key: id_post
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on posts (cost=0.00..153660.66 rows=980 width=116) (actual time=4.031..842.191 rows=1000.00 loops=1)
Filter: (id_article = 12)
Rows Removed by Filter: 4999000
Planning Time: 1.197 ms
Execution Time: 843.344 ms
Créer un index permettant d’améliorer cette requête.
Un index sur id_article améliorerait déjà les choses.
Mais comme on trie sur id_post, il est intéressant de
rajouter aussi cette colonne dans l’index :
CREATE INDEX posts_id_article_id_post ON posts (id_article, id_post);
Testons cet index :
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE id_article = 12
ORDER BY id_post
LIMIT 10 ;
Le plan
devient :
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=0.43..18.26 rows=10 width=116) (actual time=0.165..0.212 rows=10.00 loops=1)
-> Index Scan using posts_id_article_id_post on posts (cost=0.43..1747.65 rows=980 width=116) (actual time=0.160..0.190 rows=10.00 loops=1)
Index Cond: (id_article = 12)
Index Searches: 1
Planning Time: 1.785 ms
Execution Time: 0.273 ms
C’est beaucoup plus rapide : l’index trouve tout de suite les lignes
de l’article cherché, et retourne les enregistrements directement triés
par id_post. On évite de parcourir toute la table, et il
n’y a même pas d’étape de tri (qui serait certes très rapide sur 10
lignes).
Utiliser les clauses LIMIT et OFFSET pour
récupérer les 10 posts suivants. Puis du post 901 au 921. Que
constate-t-on sur le plan d’exécution ?
Les posts 11 à 20 se trouvent rapidement :
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE id_article = 12
ORDER BY id_post
LIMIT 10
OFFSET 10 ;
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=18.26..36.09 rows=10 width=116) (actual time=0.094..0.124 rows=10.00 loops=1)
-> Index Scan using posts_id_article_id_post on posts (cost=0.43..1747.65 rows=980 width=116) (actual time=0.066..0.093 rows=20.00 loops=1)
Index Cond: (id_article = 12)
Index Searches: 1
Planning Time: 0.239 ms
Execution Time: 0.173 ms
Tout va bien. La requête est à peine plus coûteuse. Noter que l’index
a ramené 20 lignes et non 10.
À partir du post 900 :
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE id_article = 12
ORDER BY id_post
LIMIT 10
OFFSET 900 ;
Le plan reste
similaire :
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=1605.02..1622.85 rows=10 width=116) (actual time=2.929..2.978 rows=10.00 loops=1)
-> Index Scan using posts_id_article_id_post on posts (cost=0.43..1747.65 rows=980 width=116) (actual time=0.079..2.132 rows=910.00 loops=1)
Index Cond: (id_article = 12)
Index Searches: 1
Planning Time: 0.229 ms
Execution Time: 3.039 ms
Cette requête est 4 fois plus lente. Si une exécution unitaire ne
pose pas encore problème, des demandes très répétées poseraient
problème. Noter que l’index ramène 910 lignes ! Dans notre exemple
idéalisée, les posts sont bien rangés ensemble, et souvent présents dans
les mêmes blocs. C’est très différent dans une table qui beaucoup
vécu.
Trouver une réécriture de la requête pour trouver directement les
posts 901 à 911 une fois connu le post 900 récupéré au travers de la
pagination.
Pour se mettre dans la condition du test, récupérons l’enregistrement
900 :
SELECT id_article, id_post
FROM posts
WHERE id_article = 12
ORDER BY id_post
LIMIT 1
OFFSET 899 ;
id_article | id_post
------------+---------
12 | 12900
(La valeur retournée peut différer sur une autre base.)
Il suffit donc de récupérer les 10 articles pour lesquels
id_article = 12 et id_post > 12900 :
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE id_article = 12
AND id_post> 12900
ORDER BY id_post
LIMIT 10 ;
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=0.43..18.29 rows=10 width=116) (actual time=0.042..0.089 rows=10.00 loops=1)
-> Index Scan using posts_id_article_id_post on posts (cost=0.43..1746.56 rows=978 width=116) (actual time=0.038..0.067 rows=10.00 loops=1)
Index Cond: ((id_article = 12) AND (id_post > 12900))
Index Searches: 1
Planning Time: 0.259 ms
Execution Time: 0.134 ms
Nous sommes de retour à des temps d’exécution très faibles. Ajouter
la condition sur le id_post permet de limiter à la source
le nombre de lignes à récupérer. L’index n’en renvoie bien que 10.
L’avantage de cette technique par rapport à l’offset est que le temps
d’une requête ne variera que l’on chercher la première ou la millième
page.
L’inconvénient est qu’il faut mémoriser l’id_post où
l’on s’est arrêté sur la page précédente.
Clauses WHERE et pièges
Nous allons maintenant manipuler le champ ts (de type
timestamp) de la table posts.
La requête
SELECT * FROM posts WHERE to_char(ts,'YYYYMM')='201302'
retourne tous les enregistrements de février 2013.
Examiner son plan d’exécution.
Où est le problème ?
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE to_char (ts,'YYYYMM ' )= '201302 ' ;
Le plan est un
parcours complet de la table :
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on posts (cost=0.00..166152.00 rows=25000 width=116) (actual time=2825.917..4833.644 rows=20160.00 loops=1)
Filter: (to_char(ts, 'YYYYMM'::text) = '201302'::text)
Rows Removed by Filter: 4979840
Planning Time: 0.170 ms
Execution Time: 4846.098 ms
C’est normal : PostgreSQL ne peut pas deviner que
to_char(ts,'YYYYMM')='201302' veut dire « toutes les dates
du mois de février 2013 ». Une fonction est pour lui une boîte noire, et
il ne voit pas le lien entre le résultat attendu et les données qu’il va
lire.
Ceci est une des causes les plus habituelles de
ralentissement de requêtes : une fonction est appliquée à une
colonne, ce qui rend le filtre incompatible avec l’utilisation d’un
index.
Réécrire la clause WHERE avec une inégalité de dates
pour utiliser l’index sur ts.
C’est à nous d’indiquer une clause WHERE au moteur qu’il
puisse directement appliquer sur notre date :
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE ts >= '2013-02-01 '
AND ts < '2013-03-01 ' ;
Le plan montre que
l’index est maintenant utilisable :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using posts_ts_idx on posts (cost=0.43..1143.80 rows=23111 width=116) (actual time=0.110..21.781 rows=20160.00 loops=1)
Index Cond: ((ts >= '2013-02-01 00:00:00+01'::timestamp with time zone) AND (ts < '2013-03-01 00:00:00+01'::timestamp with time zone))
Index Searches: 1
Planning Time: 0.261 ms
Execution Time: 34.743 ms
Noter la conversion automatique du critère en
timestamp with time zone.
Plus compliqué : retourner tous les posts ayant eu lieu un dimanche,
en 2013, en passant par un index et en une seule requête.
(Indice : il est possible de générer la liste de tous les dimanches de
l’année 2013 avec :
generate_series('2013-01-06 00:00:00','2014-01-01 00:00:00', INTERVAL '7 days'))
Construisons cette requête morceau par morceau. Listons tous les
dimanches de 2013 (le premier dimanche est le 6 janvier) :
SELECT generate_series (
'2013-01-06 00:00:00 ' ,
'2013-12-31 00:00:00 ' ,
INTERVAL '7 days '
) ;
S’il faut calculer le premier dimanche de l’année, cela peut se faire
ainsi :
WITH premiersjours AS (
SELECT '2000-01-01 ' :: timestamp + i * interval '1 year ' AS jan1
FROM generate_series (1 , 30 ) i
),
dimanches AS (
SELECT jan1,
jan1
+ mod (13 - extract (dow FROM (jan1 - interval '1 day ' )):: int , 7 )
+ interval '1 day '
AS dim1
FROM premiersjours
)
SELECT jan1, dim1
FROM dimanches ;
On n’a encore que des dates à minuit. Il faut calculer les heures de
début et de fin de chaque dimanche :
SELECT i AS debut,
i + INTERVAL '1 day ' AS fin
FROM generate_series (
'2013-01-06 00:00:00 ' ,
'2013-12-31 00:00:00 ' ,
INTERVAL '7 days '
) g (i) ;
debut | fin
------------------------+------------------------
2013-01-06 00:00:00+01 | 2013-01-07 00:00:00+01
2013-01-13 00:00:00+01 | 2013-01-14 00:00:00+01
...
2013-12-29 00:00:00+01 | 2013-12-30 00:00:00+01
Il ne nous reste plus qu’à joindre ces deux ensembles. Comme clause
de jointure, on teste la présence de la date du post dans un des
intervalles des dimanches :
EXPLAIN ANALYZE
WITH dimanches AS (
SELECT i AS debut,
i + INTERVAL '1 day ' AS fin
FROM generate_series (
'2013-01-06 00:00:00 ' ,
'2013-12-31 00:00:00 ' ,
INTERVAL '7 days '
) g (i)
)
SELECT posts.*
FROM posts
JOIN dimanches
ON (posts.ts >= dimanches.debut AND posts.ts < dimanches.fin) ;
Le plan
devient :
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=0.44..1286150.50 rows=28888889 width=116) (actual time=0.127..138.875 rows=37440.00 loops=1)
-> Function Scan on generate_series g (cost=0.00..0.52 rows=52 width=8) (actual time=0.042..0.128 rows=52.00 loops=1)
-> Index Scan using posts_ts_idx on posts (cost=0.43..19178.09 rows=555556 width=116) (actual time=0.059..1.206 rows=720.00 loops=52)
Index Cond: ((ts >= g.i) AND (ts < (g.i + '1 day'::interval)))
Index Searches: 52
Planning Time: 0.906 ms
Execution Time: 173.315 ms
PostgreSQL génère les 52 lignes d’intervalles (noter qu’il ne sait
pas estimer le résultat de cette fonction), puis fait 52 appels à
l’index (noter le loops=52). C’est efficace.
Attention : des inéqui-jointures entraînent
forcément des nested loops (pour chaque ligne d’une table, on
va chercher les lignes d’une autre table). Sur de grands volumes, ce ne
peut pas être efficace. Ici, tout va bien parce que la liste des
dimanches est raisonnablement courte.
On cherche un article à peu près au tiers de la liste avec la requête
suivante. Pourquoi est-elle si lente ?
SELECT * FROM posts
WHERE id_article =
(SELECT max (id_article) * 0.333
FROM posts
) ;
Le plan est :
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on posts (cost=0.48..166152.48 rows=25000 width=116) (actual time=556.853..1835.350 rows=1000.00 loops=1)
Filter: ((id_article)::numeric = (InitPlan 2).col1)
Rows Removed by Filter: 4999000
InitPlan 2
-> Result (cost=0.46..0.48 rows=1 width=32) (actual time=0.081..0.090 rows=1.00 loops=1)
InitPlan 1
-> Limit (cost=0.43..0.46 rows=1 width=4) (actual time=0.069..0.075 rows=1.00 loops=1)
-> Index Only Scan Backward using posts_id_article_id_post on posts posts_1 (cost=0.43..142391.12 rows=5000000 width=4) (actual time=0.065..0.067 rows=1.00 loops=1)
Index Cond: (id_article IS NOT NULL)
Heap Fetches: 0
Index Searches: 1
Planning Time: 0.430 ms
Execution Time: 1836.592 ms
Ce plan indique une recherche du numéro d’article maximal (il est
dans l’index ; noter que PostgreSQL restreint à une valeur non vide),
puis il calcule la valeur correspondant au tiers et la met dans
$1. Tout ceci est rapide. La partie lente est le
Seq Scan pour retrouver cette valeur, avec un filtre et non
par l’index.
Le problème est visible sur le filtre même :
Filter: ((id_article)::numeric = (InitPlan 2).col1)
(id_article)::numeric signifie que tous les
id_article (des entiers) sont convertis en
numeric pour ensuite être comparés au résultat
(InitPlan 2).col1. Or une conversion est une fonction, ce
qui rend l’index inutilisable. En fait, notre problème est que
(InitPlan 2).col1 n’est pas un entier !
SELECT max (id_article) * 0.333
FROM posts
\gdesc
Column | Type
----------+---------
?column? | numeric
La conversion du critère en int peut se faire à
plusieurs endroits. Par exemple :
SELECT * FROM posts
WHERE id_article =
(SELECT max (id_article) * 0.333
FROM posts
):: int ;
Et l’index est donc utilisable immédiatement :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using posts_id_article_id_post on posts (cost=0.91..1799.95 rows=1009 width=116) (actual time=0.986..2.344 rows=1000.00 loops=1)
Index Cond: (id_article = ((InitPlan 2).col1)::integer)
Index Searches: 1
InitPlan 2
-> Result (cost=0.46..0.48 rows=1 width=32) (actual time=0.048..0.055 rows=1.00 loops=1)
InitPlan 1
-> Limit (cost=0.43..0.46 rows=1 width=4) (actual time=0.034..0.039 rows=1.00 loops=1)
-> Index Only Scan Backward using posts_id_article_id_post on posts posts_1 (cost=0.43..142391.12 rows=5000000 width=4) (actual time=0.031..0.033 rows=1.00 loops=1)
Index Cond: (id_article IS NOT NULL)
Heap Fetches: 0
Index Searches: 1
Planning Time: 0.288 ms
Execution Time: 3.124 ms
Si l’on avait fait le calcul avec / 3 au lieu de
* 0.333, on n’aurait pas eu le problème, car la division de
deux entiers donne un entier.
Attention donc à la cohérence des types dans vos critères. Le
problème peut se rencontrer même en joignant des int et des
bigint !