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 | | 4952 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=8419 width=25)
(actual time=0.030..92.226 rows=8358 loops=1)
Filter: (caracteristiques ~ '[[:<:]]toit ouvrant[[:>:]]'::text)
Rows Removed by Filter: 91642
Planning Time: 0.658 ms
Execution Time: 92.512 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=1225.80..3102.17 rows=8329 width=16)
(actual time=6.307..31.811 rows=8358 loops=1)
Hash Cond: (voitures.immatriculation = caracteristiques_voitures.immatriculation)
-> Seq Scan on voitures (cost=0.00..1613.89 rows=99989 width=16)
(actual time=0.019..10.432 rows=99989 loops=1)
-> Hash (cost=1121.69..1121.69 rows=8329 width=10)
(actual time=6.278..6.279 rows=8358 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 577kB
-> Seq Scan on caracteristiques_voitures
(cost=0.00..1121.69 rows=8329 width=10)
(actual time=0.004..5.077 rows=8358 loops=1)
Filter: (caracteristique = 'toit ouvrant'::text)
Rows Removed by Filter: 49697
Planning Time: 0.351 ms
Execution Time: 32.155 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=16)
(actual time=4.849..4.850 rows=0 loops=1)
-> Seq Scan on caracteristiques_voitures (cost=0.00..1121.69 rows=1 width=10)
(actual time=4.848..4.848 rows=0 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)
Planning Time: 0.337 ms
Execution Time: 4.872 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.83..16.78 rows=1 width=16)
(actual time=0.010..0.011 rows=0 loops=1)
-> Index Scan using caracteristiques_voitures_caracteristique_idx
on caracteristiques_voitures
(cost=0.41..8.35 rows=1 width=10)
(actual time=0.010..0.010 rows=0 loops=1)
Index Cond: (caracteristique = 'ordinateur de bord'::text)
-> Index Scan using voitures_pkey on voitures (cost=0.42..8.44 rows=1 width=16)
(never executed)
Index Cond: (immatriculation = caracteristiques_voitures.immatriculation)
Planning Time: 0.268 ms
Execution Time: 0.035 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: (cr2.immatriculation = cr1.immatriculation)
-> 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)
-> Hash
-> Hash Join
Hash Cond: (cr1.immatriculation = cr3.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 cr3
Recheck Cond: (caracteristique =
'4 roues motrices'::text)
-> Bitmap Index Scan
on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique =
'4 roues motrices'::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=3468.93..3507.74 rows=15527 width=25)
(actual time=29.854..30.692 rows=17782 loops=1)
Sort Key: voitures_ecv.entite
Sort Method: quicksort Memory: 2109kB
-> Hash Join (cost=1243.09..2388.05 rows=15527 width=25)
(actual time=6.915..23.964 rows=17782 loops=1)
Hash Cond: (voitures_ecv.entite = test.entite)
-> Seq Scan on voitures_ecv (cost=0.00..992.55 rows=58055 width=25)
(actual time=0.006..4.242 rows=58055 loops=1)
-> Hash (cost=1137.69..1137.69 rows=8432 width=10)
(actual time=6.899..6.899 rows=8358 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 471kB
-> Seq Scan on voitures_ecv test
(cost=0.00..1137.69 rows=8432 width=10)
(actual time=0.005..5.615 rows=8358 loops=1)
Filter: (valeur AND (cle = 'toit ouvrant'::text))
Rows Removed by Filter: 49697
Planning Time: 0.239 ms
Execution Time: 31.321 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
---------------------------------------------------------------------
Index Scan using voitures_hstore_caracteristiques on voitures_hstore
(cost=0.28..2.30 rows=1 width=55) (actual time=0.033..0.033 rows=1 loops=1)
Index Cond: ((caracteristiques @> '"couleur"=>"vert"'::hstore)
AND (caracteristiques @> '"phares"=>"LED"'::hstore))
Buffers: shared hit=4
Planning Time: 0.055 ms
Execution Time: 0.047 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=8.87..387.37 rows=500 width=25)
(actual time=0.707..2.756 rows=8358 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..8.75 rows=500 width=0)
(actual time=0.631..0.631 rows=8358 loops=1)
Index Cond: (regexp_split_to_array(caracteristiques, ','::text)
@> '{"toit ouvrant"}'::text[])
Planning Time: 0.129 ms
Execution Time: 3.028 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=153694.51..153694.53 rows=10 width=115)
(actual time=500.525..500.528 rows=10 loops=1)
-> Sort (cost=153694.51..153696.95 rows=979 width=115)
(actual time=500.524..500.525 rows=10 loops=1)
Sort Key: id_post
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on posts (cost=0.00..153673.35 rows=979 width=115)
(actual time=1.300..500.442 rows=1000 loops=1)
Filter: (id_article = 12)
Rows Removed by Filter: 4999000
Planning Time: 0.089 ms
Execution Time: 500.549 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=115)
(actual time=0.043..0.053 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1745.88 rows=979 width=115)
(actual time=0.042..0.051 rows=10 loops=1)
Index Cond: (id_article = 12)
Planning Time: 0.204 ms
Execution Time: 0.066 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=115)
(actual time=0.020..0.023 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1745.88 rows=979 width=115)
(actual time=0.017..0.021 rows=20 loops=1)
Index Cond: (id_article = 12)
Planning Time: 0.061 ms
Execution Time: 0.036 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.04..1622.86 rows=10 width=115)
(actual time=0.216..0.221 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1745.88 rows=979 width=115)
(actual time=0.018..0.194 rows=910 loops=1)
Index Cond: (id_article = 12)
Planning Time: 0.062 ms
Execution Time: 0.243 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=115)
(actual time=0.018..0.024 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1743.02 rows=976 width=115)
(actual time=0.016..0.020 rows=10 loops=1)
Index Cond: ((id_article = 12) AND (id_post > 12900))
Planning Time: 0.111 ms
Execution Time: 0.039 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..187728.49 rows=50000 width=269)
(actual time=0.380..14163.371 rows=18234 loops=1)
Filter: (to_char(ts, 'YYYYMM'::text) = '201302'::text)
Rows Removed by Filter: 9981766
Total runtime: 14166.265 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..998.95 rows=20165 width=115)
(actual time=0.050..5.907 rows=20160 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))
Planning Time: 0.095 ms
Execution Time: 6.526 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..17086517.00 rows=555555556 width=115)
(actual time=0.038..12.978 rows=37440 loops=1)
-> Function Scan on generate_series g (cost=0.00..10.00 rows=1000 width=8)
(actual time=0.016..0.031 rows=52 loops=1)
-> Index Scan using posts_ts_idx on posts
(cost=0.43..11530.95 rows=555556 width=115)
(actual time=0.009..0.168 rows=720 loops=52)
Index Cond: ((ts >= g.i) AND (ts < (g.i + '1 day'::interval)))
Planning Time: 0.131 ms
Execution Time: 14.178 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..166135.48 rows=25000 width=115)
(actual time=333.363..1000.696 rows=1000 loops=1)
Filter: ((id_article)::numeric = $1)
Rows Removed by Filter: 4999000
InitPlan 2 (returns $1)
-> Result (cost=0.46..0.48 rows=1 width=32)
(actual time=0.016..0.017 rows=1 loops=1)
InitPlan 1 (returns $0)
-> Limit (cost=0.43..0.46 rows=1 width=4)
(actual time=0.012..0.014 rows=1 loops=1)
-> Index Only Scan Backward using posts_id_article_id_post
on posts posts_1
(cost=0.43..142352.43 rows=5000000 width=4)
(actual time=0.012..0.012 rows=1 loops=1)
Index Cond: (id_article IS NOT NULL)
Heap Fetches: 0
Planning Time: 0.097 ms
Execution Time: 1000.753 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 = $1)
(id_article)::numeric
signifie que tous les
id_article
(des entiers) sont convertis en
numeric
pour ensuite être comparés au $1
. Or
une conversion est une fonction, ce qui rend l’index inutilisable. En
fait, notre problème est que $1
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..1796.42 rows=1007 width=115)
(actual time=0.031..0.198 rows=1000 loops=1)
Index Cond: (id_article = ($1)::integer)
InitPlan 2 (returns $1)
-> Result (cost=0.46..0.48 rows=1 width=32) (...)
InitPlan 1 (returns $0)
-> Limit (cost=0.43..0.46 rows=1 width=4) (...)
-> Index Only Scan Backward using posts_id_article_id_post
on posts posts_1 (...)
Index Cond: (id_article IS NOT NULL)
Heap Fetches: 0
Planning Time: 0.105 ms
Execution Time: 0.245 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
!