8 Fonctionnalités avancées pour la performance
Fonctionnalités avancées pour la performance
Préambule
Quelques fonctionnalités, généralement liées aux performances.
Au menu
- Tables temporaires
- Tables non journalisées
- Colonnes générées
- JIT
- Recherche Full Text
Tables temporaires
Tables temporaires : utilité
- N’existent que pendant la session
- Non journalisées : rapides !
Principe :
Sous PostgreSQL, les tables temporaires sont créées dans une session, et disparaissent à la déconnexion. Elles ne sont pas visibles par les autres sessions. Elles ne sont pas journalisées, ce qui est très intéressant pour les performances. Elles s’utilisent comme les autres tables, y compris pour l’indexation, les triggers, etc.
Les tables temporaires semblent donc idéales pour des tables de travail temporaires et « jetables ».
Tables temporaires : limites et paramétrage
- Ne pas en abuser !
- Ignorées par autovacuum
ANALYZEetVACUUMmanuels !
- Paramétrage :
temp_buffers: cache disque pour les objets temporaires, par session, à augmenter ?
Cependant, il est déconseillé d’abuser des tables temporaires. En effet, leur création/destruction permanente entraîne une fragmentation importante des tables systèmes (en premier lieu
pg_catalog.pg_class,pg_catalog.pg_attribute…), qui peuvent devenir énormes. Ce n’est jamais bon pour les performances, et peut nécessiter unVACUUM FULLdes tables système !
Le démon autovacuum ne voit pas les tables temporaires ! Les statistiques devront donc être mises à jour manuellement avec
ANALYZE, et il faudra penser à lancerVACUUMexplicitement après de grosses modifications.
Aspect technique :
Les tables temporaires sont créées dans un schéma temporaire pg_temp_…, ce qui explique qu’elles ne sont pas visibles dans le schéma public.
Physiquement, par défaut, elles sont stockées sur le disque avec les autres données de la base, et non dans base/pgsql_tmp comme les fichiers temporaires. Il est possible de définir des tablespaces dédiés aux objets temporaires (fichiers temporaires et données des tables temporaires) à l’aide du paramètre temp_tablespaces, à condition de donner des droits CREATE dessus aux utilisateurs. Le nom du fichier d’une table temporaire est reconnaissable car il commence par t. Les éventuels index de la table suivent les même règles.
Exemple :
Cache :
Dans les plans d’exécution avec BUFFERS, l’habituelle mention shared est remplacée par local pour les tables temporaires.
(Les shared hit concernent des accès aux tables systèmes pour la planification.)
En effet, leur cache disque dédié est au niveau de la session, non des shared buffers. Ce cache est défini par le paramètre temp_buffers (exprimé par session, et à 8 Mo par défaut). Ce paramètre peut être augmenté dans la session, avant la création de la table. Bien sûr, on risque de saturer la RAM en cas d’abus ou s’il y a trop de sessions, tout comme avec work_mem. Et comme la mémoire de temp_buffers n’est pas rendue avant la fin de la session, il faut éviter de maintenir inutilement ouvertes des sessions ayant créé des tables temporaires : elles bloquent de la mémoire.
À noter : ce cache n’empêche pas l’écriture des petites tables temporaires sur le disque.
Pour éviter de recréer perpétuellement la même table temporaire, une table unlogged (voir plus bas) sera sans doute plus indiquée. Le contenu de cette dernière sera aussi visible des autres sessions, ce qui est pratique pour suivre la progression d’un traitement, faciliter le travail de l’autovacuum, ou déboguer. Sinon, il est fréquent de pouvoir remplacer une table temporaire par une CTE (clause WITH) ou un tableau en mémoire.
L’extension pgtt émule un autre type de table temporaire dite « globale » pour la compatibilité avec d’autres SGBD.
Tables non journalisées (unlogged)
Tables non journalisées : utilité
- La durabilité est parfois accessoire :
- tables temporaires et de travail
- caches…
- Tables non journalisées
- non répliquées, non restaurées
- remises à zéro en cas de crash
- Respecter les contraintes
Une table unlogged est une table non journalisée. Comme la journalisation est responsable de la durabilité, une table non journalisée n’a pas cette garantie.
La table est systématiquement remise à zéro au redémarrage après un arrêt brutal. En effet, tout arrêt d’urgence peut entraîner une corruption des fichiers de la table ; et sans journalisation, il ne serait pas possible de la corriger au redémarrage et de garantir l’intégrité.
La non-journalisation de la table implique aussi que ses données ne sont pas répliquées vers des serveurs secondaires, et que les tables ne peuvent figurer dans une publication (réplication logique). En effet, les modes de réplication natifs de PostgreSQL utilisent les journaux de transactions. Pour la même raison, une restauration de sauvegarde PITR ne restaurera pas le contenu de la table. Le bon côté est qu’on allège la charge sur la sauvegarde et la réplication.
Les contraintes doivent être respectées même si la table unlogged est vidée : une table normale ne peut donc avoir de clé étrangère pointant vers une table unlogged. La contrainte inverse est possible, tout comme une contrainte entre deux tables unlogged.
À part ces limitations, les tables unlogged se comportent exactement comme les autres. Leur intérêt principal est d’être en moyenne 5 fois plus rapides à la mise à jour. Elles sont donc à réserver à des cas d’utilisation particuliers, comme :
- table de spooling/staging ;
- table de cache/session applicative ;
- table de travail partagée entre sessions ;
- table de travail systématiquement reconstruite avant utilisation dans le flux applicatif ;
- et de manière générale toute table contenant des données dont on peut accepter la perte ou dont on peut régénérer les données aisément.
Les tables unlogged ne doivent pas être confondues avec les tables temporaires (non journalisées et visibles uniquement dans la session qui les a créées). Les tables unlogged ne sont pas ignorées par l’autovacuum (les tables temporaires le sont). Abuser des tables temporaires a tendance à générer de la fragmentation dans les tables système, alors que les tables unlogged sont en général créées une fois pour toutes.
Tables non journalisées : mise en place
Une table unlogged se crée exactement comme une table journalisée classique, excepté le mot UNLOGGED.
Bascule d’une table en/depuis unlogged
- réécriture
- passage du contenu dans les WAL !
Il est possible de basculer une table à volonté de normale à unlogged et vice-versa.
Quand une table classique devient unlogged, on pourrait imaginer que PostgreSQL n’a pas besoin d’y toucher. Malheureusement, pour des raisons techniques, la table doit être réécrite (elle change de refilenode). Elle est aussi défragmentée au passage, comme lors d’un VACUUM FULL.
De plus, cette réécriture génère des journaux supplémentaires quand les sommes de contrôle sont activées, une option qui est recommandée (et activée par défaut à partir de la version 18). Ces journaux semblent inutiles, mais là encore des optimisations manquent.
À l’inverse, quand une table unlogged devient logged (journalisée), la réécriture a aussi lieu, et tout le contenu de la table est journalisé (c’est indispensable pour la sauvegarde PITR et pour la réplication notamment), ce qui génère là encore des journaux et peut prendre du temps.
Tout cela peut être long et coûteux si la table est grosse. Il n’est donc pas si fréquent qu’il soit « rentable » de basculer régulièrement une table de logged en unlogged et vice-versa en espérant économiser des journaux lors des mises à jour.
Par exemple, une table d’abord vide, alimentée et modifiée de manière répétée pendant un batch, peut être définie unlogged pour des raisons de performance, puis basculée en logged en toute fin de traitement pour pérenniser son contenu.
Colonnes générées
Colonnes générées : principe
Colonnes générées vs DEFAULT
DEFAULT- expressions très simples, modifiables
GENERATED- fonctions « immutables », ne dépendant que de la ligne
- danger sinon (ex : pas pour dates de mises à jour)
- (avant v17) difficilement modifiables
- peuvent porter des contraintes
Les valeurs par défaut sont très connues mais limitées. PostgreSQL connaît aussi les colonnes générées (ou calculées).
La syntaxe est :
Les colonnes générées sont recalculées à chaque fois que les champs sur lesquels elles sont basées changent, donc aussi lors d’un UPDATE. Ces champs calculés sont impérativement marqués ALWAYS, c’est-à-dire obligatoires et non modifiables, et STORED, c’est-à-dire stockés sur le disque (et non recalculés à la volée comme dans une vue). Ils ne doivent pas se baser sur d’autres champs calculés.
Un intérêt est que les champs calculés peuvent porter des contraintes, par exemple la clause CHECK ci-dessous, mais encore des clés étrangères ou unique.
Exemple :
Expression immutable :
Avec GENERATED, l’expression du calcul doit être « immutable », c’est-à-dire ne dépendre que des autres champs de la même ligne, n’utiliser que des fonctions elles-mêmes immutables, et rien d’autre. Il n’est donc pas possible d’utiliser des fonctions comme now(), ni des fonctions de conversion de date dépendant du fuseau horaire, ou du paramètre de formatage de la session en cours (toutes choses autorisées avec DEFAULT), ni des appels à d’autres lignes ou tables…
La colonne calculée peut être convertie en colonne « normale » :
Modifier l’expression n’est pas possible avant PostgreSQL 17, sauf à supprimer la colonne générée et en créer une nouvelle. Il faut alors recalculer toutes les lignes et réécrire toute la table, ce qui peut être très lourd.
À partir de PostgreSQL 17, l’expression est modifiable avec cette syntaxe :
Attention, la table est totalement bloquée le temps de la réécriture (verrou AccessExclusiveLock).
Utilisation d’une fonction :
Il est possible de créer sa propre fonction pour l’expression, qui doit aussi être immutable :
Attention : modifier la fonction ne réécrit pas spontanément la table, il faut forcer la réécriture avec par exemple :
et ceci dans la même transaction que la modification de fonction. On pourrait imaginer de négliger cet
UPDATEpour garder les valeurs déjà présentes qui suivraient d’anciennes règles… mais ce serait une erreur. En effet, les valeurs calculées ne figurent pas dans une sauvegarde logique, et en cas de restauration, tous les champs sont recalculés avec la dernière formule !
On préférera donc gérer l’expression dans la définition de la table dans les cas simples.
Un autre piège : il faut résister à la tentation de déclarer une fonction comme immutable sans la certitude qu’elle l’est bien (penser aux paramètres de session, aux fuseaux horaires…), sous peine d’incohérences dans les données.
Cas d’usage :
Les colonnes générées économisent la création de triggers, ou de vues de « présentation ». Elles facilitent la dénormalisation de données calculées dans une même table tout en garantissant l’intégrité.
Un cas d’usage courant est la dénormalisation d’attributs JSON pour les manipuler comme des champs de table classiques :
L’accès au champ est notablement plus rapide que l’analyse systématique du champ JSON.
Par contre, les colonnes GENERATED ne sont pas un bon moyen pour créer des champs portant la dernière mise à jour. Certes, PostgreSQL ne vous empêchera pas de déclarer une fonction (abusivement) immutable utilisant now() ou une variante. Mais ces informations seront perdues en cas de restauration logique. Dans ce cas, les triggers restent une option plus complexe mais plus propre.
Valeurs stockées ou virtuelles
STORED- valeur calculée à l’enregistrement
- prennent de l’espace disque
VIRTUAL(v18+)- recalculées à chaque appel
- ne prennent pas de place
- pas de réécriture de la table
- facilement modifiables
- mais : non indexables, pas de statistiques
À partir de la version 18, il est possible de déclarer une colonne générée virtuelle. À la différence d’une colonne générée stockée, la valeur n’est pas enregistrée. Elle est calculée à la lecture. La volumétrie de la table est donc moins importante, mais la lecture est plus lente.
Ce type de colonne se déclare ainsi :
La clause VIRTUAL permet de souligner le côté virtuel de la colonne mais il n’est pas requis. Par défaut, une colonne générée est virtuelle. Elle n’est stockée que si la clause STORED est utilisée.
La table n’a pas besoin d’être réécrite pour ajouter une colonne virtuelle, le verrou est donc bien plus court.
Si la fonction à calculer change, la modification est facile et ne pose pas de souci de cohérence.
Il existe plusieurs limitations à ce type de colonne :
- Comme pour les colonnes générées stockées, les seuls champs utilisables dans l’expression d’une colonne virtuelle sont ceux présents sur la ligne ;
- il n’est malheureusement pas possible d’utiliser une colonne virtuelle comme source d’une autre colonne virtuelle ;
- comme pour les colonnes générées stockées, les fonctions utilisées doivent être « immutables », c’est‑à‑dire ne pas dépendre d’autre chose que des champs de la ligne (donc pas des paramètres de session comme le fuseau horaire) ;
- il n’est pas possible d’utiliser des fonctions créées par les utilisateurs pour des raisons de sécurité (un superutilisateur pourrait être amené à exécuter des fonctions alors qu’il croit juste consulter une table) ;
- les colonnes virtuelles ne sont jamais répliquées par la réplication logique (alors que l’on peut le forcer dans la publication pour des colonnes stockées).
Plus gênant, les colonnes virtuelles ne peuvent pas non plus être indexées (un index fonctionnel avec la même définition stricte peut cependant répondre au besoin). Et sans index, il n’est pas possible de pointer une contrainte de clé étrangère vers une colonne virtuelle.
Enfin, une colonne virtuelle ne possède pas de statistiques dans pg_stats : PostgreSQL ne sait donc pas estimer la volumétrie renvoyée par un prédicat sur une colonne virtuelle. Il est possible de contourner en demandant la définition d’un objet statistique ou d’un index fonctionnel avec l’expression exacte du champ calculée.
JIT
JIT : la compilation à la volée
- Compilation Just In Time des requêtes
- Utilise le compilateur LLVM
- Vérifier que l’installation est fonctionnelle
- Activé par défaut
- mais souvent désactivé
Une des fonctionnalités les plus techniquement pointues de PostgreSQL est la « compilation à la volée » (Just In Time compilation, ou JIT) de certaines expressions dans les requêtes SQL.
Dans certaines requêtes, l’essentiel du temps est passé à décoder des enregistrements (tuple deforming), à analyser des clauses WHERE, à effectuer des calculs. En conséquence, l’idée du JIT est de transformer tout ou partie de la requête en un programme natif directement exécuté par le processeur.
Cette compilation est une opération lourde qui ne sera effectuée que pour des requêtes qui en valent le coup, donc qui dépassent un certain coût. Au contraire de la parallélisation, ce coût n’est pas pris en compte par le planificateur (!). La décision d’utiliser le JIT ou pas se fait une fois le plan décidé, si le coût calculé de la requête dépasse un certain seuil.
Le JIT de PostgreSQL s’appuie actuellement sur la chaîne de compilation LLVM, choisie pour sa flexibilité. L’utilisation nécessite un PostgreSQL compilé avec l’option --with-llvm et l’installation des bibliothèques de LLVM.
Sur Debian, avec les paquets du PGDG, les dépendances sont en place dès l’installation.
Sur Rocky Linux/Red Hat 8 et au-delà, l’installation du paquet dédié suffit :
Sur CentOS/Red Hat 7, installer d’abord epel-release.
Si PostgreSQL ne trouve pas les bibliothèques nécessaires, il ne renvoie pas d’erreur et continue sans tenter de JIT. Pour tester si le JIT est fonctionnel sur votre machine, il faut le chercher dans un plan quand on force son utilisation ainsi :
La documentation officielle est assez accessible : https://doc.postgresql.fr/current/jit.html
JIT : qu’est-ce qui est compilé ?
- Tuple deforming
- Évaluation d’expressions :
WHERE- agrégats,
GROUP BY
- Appels de fonctions (inlining)
- Mais pas les jointures
Le JIT ne peut pas encore compiler toute une requête. La version actuelle se concentre sur des goulots d’étranglement classiques :
- le décodage des enregistrements (tuple deforming) pour en extraire les champs intéressants ;
- les évaluations d’expressions, notamment dans les clauses
WHEREpour filtrer les lignes ; - les agrégats, les
GROUP BY…
Les jointures ne sont pas (encore ?) concernées par le JIT.
Le code résultant est utilisable plus efficacement avec les processeurs actuels qui utilisent les pipelines et les prédictions de branchement.
Pour les détails, on peut consulter notamment cette conférence très technique au FOSDEM 2018 par l’auteur principal du JIT, Andres Freund.
JIT : algorithme « naïf »
jit(défaut :on)jit_above_cost(défaut : 100 000)jit_inline_above_cost(défaut : 500 000)jit_optimize_above_cost(défaut : 500 000)- À comparer au coût de la requête… I/O comprises
- Seuils arbitraires !
De l’avis même de son auteur, l’algorithme de déclenchement du JIT est « naïf ». Quatre paramètres existent (hors débogage).
jit = on active le JIT si l’environnement technique évoqué plus haut le permet.
La compilation n’a cependant lieu que pour un coût de requête calculé d’au moins jit_above_cost (par défaut 100 000, une valeur élevée). Puis, si le coût atteint jit_inline_above_cost (500 000), certaines fonctions utilisées par la requête et supportées par le JIT sont intégrées dans la compilation. Si jit_optimize_above_cost (500 000) est atteint, une optimisation du code compilé est également effectuée. Ces deux dernières opérations étant longues, elles ne le sont que pour des coûts assez importants.
Ces seuils sont à comparer avec les coûts des requêtes, qui incluent les entrées-sorties, donc pas seulement le coût CPU. Ces seuils sont un peu arbitraires et nécessitent sans doute un certain tuning en fonction de vos requêtes et de vos processeurs. Il n’y a pas de règle magique permettant de définir les valeurs des différents paramètres.
Des contre-performances dues au JIT ont déjà été observées, notamment sur des requêtes très complexes (donc au coût élevé). Le partitionnement et l’alourdissement de LLVM au fil des versions ont aussi un impact.
Il est donc fréquent de le désactiver par défaut, comme le font souvent les fournisseurs cloud, ou de monter radicalement les seuils de déclenchement.
Un exemple de plan d’exécution sur une grosse table donne :
Le plan d’exécution est complété, à la fin, des informations suivantes :
- le nombre de fonctions concernées ;
- les temps de génération, d’inclusion des fonctions, d’optimisation du code compilé…
Dans l’exemple ci-dessus, on peut constater que ces coûts ne sont pas négligeables par rapport au temps total. Il reste à voir si ce temps perdu est récupéré sur le temps d’exécution de la requête… ce qui en pratique n’a rien d’évident.
Sans JIT, la durée de cette requête était d’environ 17 s. Ici le JIT est rentable.
Quand le JIT est-il utile ?
- Goulot d’étranglement au niveau CPU (pas I/O)
- Requêtes complexes (calculs, agrégats, appels de fonctions…)
- Beaucoup de lignes, filtres
- Assez longues pour « rentabiliser » le JIT
- Analytiques, pas OLTPs
offpar défaut ?
Vu son coût élevé, le JIT n’a d’intérêt que pour les requêtes utilisant beaucoup le CPU et où il est le facteur limitant.
Ce seront donc surtout des requêtes analytiques agrégeant beaucoup de lignes, comprenant beaucoup de calculs et filtres, et non les petites requêtes d’un ERP.
Il n’y a pas non plus de mise en cache du code compilé.
Si gain il y a, il est relativement modeste en deçà de quelques millions de lignes, et devient de plus en plus important au fur et à mesure que la volumétrie augmente, à condition bien sûr que d’autres limites n’apparaissent pas (bande passante…).
Nous conseillons soit de désactiver le JIT par défaut (quitte à le réactiver dans les sessions où il pourrait être utile), ou au moins de monter d’un facteur 10 ses seuils.
Documentation officielle : https://docs.postgresql.fr/current/jit-decision.html
Recherche plein texte
…ou FTS
Full Text Search : principe
- Recherche « à la Google » ; fonctions dédiées
- On n’indexe plus une chaîne de caractère mais
- les mots (« lexèmes ») qui la composent
- on peut rechercher sur chaque lexème indépendamment
- Les lexèmes sont soumis à des règles spécifiques à chaque langue
- notamment termes courants
- permettent une normalisation, des synonymes…
L’indexation FTS est un des cas les plus fréquents d’utilisation non-relationnelle d’une base de données : les utilisateurs ont souvent besoin de pouvoir rechercher une information qu’ils ne connaissent pas parfaitement, d’une façon floue :
- recherche d’un produit/article par rapport à sa description ;
- recherche dans le contenu de livres/documents…
PostgreSQL doit donc permettre de rechercher de façon efficace dans un champ texte. L’avantage de cette solution est d’être intégrée au SGBD. Le moteur de recherche est donc toujours parfaitement à jour avec le contenu de la base, puisqu’il est intégré avec le reste des transactions.
Le principe est de décomposer le texte en « lexèmes » propres à chaque langue. Cela implique donc une certaine forme de normalisation, et permettent aussi de tenir compte de dictionnaires de synonymes. Le dictionnaire inclue aussi les termes courants inutiles à indexer (stop words) propres à la langue (le, la, et, the, and, der, daß…).
Décomposition et recherche en plein texte utilisent des fonctions et opérateurs dédiés, ce qui nécessite donc une adaptation du code. Ce qui suit n’est qu’un premier aperçu. La recherche plein texte est un chapitre entier de la documentation officielle.
Adrien Nayrat a donné une excellente conférence sur le sujet au PGDay France 2017 à Toulouse (slides).
Full Text Search : exemple
- Décomposition :
- Recherche sur 2 mots :
- Recherche sur une phrase :
phrase_totsquery
to_tsvector analyse un texte et le décompose en lexèmes, et non en mots. Les chiffres indiquent ici les positions et ouvrent la possibilité à des scores de proximité. Mais des indications de poids sont possibles.
Autre exemple de décomposition d’une phrase :
Les mots courts et le verbe « être » sont repérés comme termes trop courants, la casse est ignorée, même l’URL est décomposée en protocole et hôte. On peut voir en détail comment la FTS a procédé :
Si l’on se trompe de langue, les termes courants sont mal repérés (et la recherche sera inefficace) :
Pour construire un critère de recherche, to_tsquery est nécessaire :
Les termes à chercher peuvent être combinés par &, | (ou), ! (négation), <-> (mots successifs), <N> (séparés par N lexèmes). @@ est l’opérateur de correspondance. Il y en a d’autres.
Il existe une fonction phraseto_tsquery pour donner une phrase entière comme critère, laquelle sera décomposée en lexèmes :
Full Text Search : dictionnaires
- Configurations liées à la langue
- basées sur des dictionnaires (parfois fournis)
- dictionnaires filtrants (
unaccent) - synonymes
- Extensible grâce à des sources extérieures
- Configuration par défaut :
default_text_search_config
Les lexèmes, les termes courants, la manière de décomposer un terme… sont fortement liés à la langue.
Des configurations toutes prêtes sont fournies par PostgreSQL pour certaines langues :
La recherche plein texte est donc directement utilisable pour le français ou l’anglais et beaucoup d’autres langues européennes. La configuration par défaut dépend du paramètre default_text_search_config, même s’il est conseillé de toujours passer explicitement la configuration aux fonctions. Ce paramètre peut être modifié globalement, par session ou par un ALTER DATABASE SET.
En demandant le détail de la configuration french, on peut voir qu’elle se base sur des « dictionnaires » pour chaque type d’élément qui peut être rencontré : mots, phrases mais aussi URL, entiers…
On peut lister ces dictionnaires :
Ces dictionnaires sont de type « Snowball », incluant notamment des algorithmes différents pour chaque langue. Le dictionnaire simple n’est pas lié à une langue et correspond à une simple décomposition après passage en minuscule et recherche de termes courants anglais : c’est suffisant pour des éléments comme les URL.
D’autres dictionnaires peuvent être combinés aux existants pour créer une nouvelle configuration. Le principe est que les dictionnaires reconnaissent certains éléments, et transmettent aux suivants ce qu’ils n’ont pas reconnu. Les dictionnaires précédents, de type Snowball, reconnaissent tout et doivent donc être placés en fin de liste.
Par exemple, la contrib unaccent permet de faire une configuration négligeant les accents. La contrib dict_int fournit un dictionnaire qui réduit la précision des nombres pour réduire la taille de l’index. La contrib dict_xsyn permet de créer un dictionnaire pour gérer une liste de synonymes. Mais les dictionnaires de synonymes peuvent être gérés manuellement. Les fichiers adéquats sont déjà présents ou à ajouter dans $SHAREDIR/tsearch_data/ (par exemple /usr/pgsql-14/share/tsearch_data sur Red Hat/CentOS ou /usr/share/postgresql/14/tsearch_data sur Debian).
Par exemple, en utilisant le fichier d’exemple $SHAREDIR/tsearch_data/synonym_sample.syn, dont le contenu est :
on peut définir un dictionnaire de synonymes, créer une nouvelle configuration reprenant french, et y insérer le nouveau dictionnaire en premier élément :
À l’usage :
Les trois versions de « PostgreSQL » ont été reconnues.
Pour une analyse plus fine, on peut ajouter d’autres dictionnaires linguistiques depuis des sources extérieures (Ispell, OpenOffice…). Ce n’est pas intégré par défaut à PostgreSQL mais la procédure est dans la documentation.
Des « thesaurus » peuvent être même être créés pour remplacer des expressions par des synonymes (et identifier par exemple « le meilleur SGBD » et « PostgreSQL »).
Full Text Search : stockage & indexation
- Stocker
to_tsvector (champtexte)- colonne mise à jour par trigger
- ou colonne générée (v12)
- Indexation GIN ou GiST
Principe :
Sans indexation, une recherche FTS fonctionne, mais parcourra entièrement la table. L’indexation est possible, avec GIN ou GiST. On peut stocker le vecteur résultat de to_tsvector dans une autre colonne de la table, et c’est elle qui sera indexée. Jusque PostgreSQL 11, il est nécessaire de le faire manuellement, ou d’écrire un trigger pour cela. À partir de PostgreSQL 12, on peut utiliser une colonne générée (il est nécessaire de préciser la configuration FTS), qui sera stockée sur le disque :
Les critères de recherche porteront sur la colonne vecteur :
Cette colonne sera ensuite indexée par GIN pour avoir des temps d’accès corrects :
Alternative : index fonctionnel
Plus simplement, il peut suffire de créer juste un index fonctionnel sur to_tsvector ('french', contenu). On épargne ainsi l’espace du champ calculé dans la table.
Par contre, l’index devra porter sur le critère de recherche exact, sinon il ne sera pas utilisable. Cela n’est donc pertinent que si la majorité des recherches porte sur un nombre très restreint de critères, et il faudra un index par critère.
Exemple complet de mise en place de FTS :
- Création d’une configuration de dictionnaire dédiée avec dictionnaire français, sans accent, dans une table de dépêches :
- Ajout d’une colonne vectorisée à la table
depeche, avec des poids différents pour le titre et le texte, ici gérée manuellement avec un trigger.
- Création de l’index associé au vecteur :
- Collecte des statistiques sur la table :
- Utilisation basique :
- Tri par pertinenence :
- Cette requête peut s’écrire aussi ainsi :
Full Text Search sur du JSON
- Vectorisation possible des JSON
Une recherche FTS est directement possible sur des champs JSON. Voici un exemple :
La décomposition par FTS donne :
Une recherche sur « papier » donne :
Plus d’information chez Depesz : Full Text Search support for json and jsonb.
Quiz
Travaux pratiques
La version en ligne des solutions de ces TP est disponible sur https://dali.bo/s80_solutions.
Tables non journalisées
But : Tester les tables non journalisées
Afficher le nom du journal de transaction courant.
Créer une base pgbench vierge, de taille 80 (environ 1,2 Go). Les tables doivent être en mode unlogged.
Afficher la liste des objets unlogged dans la base pgbench.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Passer l’ensemble des tables de la base pgbench en mode logged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Repasser toutes les tables de la base pgbench en mode unlogged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Réinitialiser la base pgbench toujours avec une taille 80 mais avec les tables en mode logged. Que constate-t-on ?
Réinitialiser la base pgbench mais avec une taille de 10. Les tables doivent être en mode unlogged.
Compter le nombre de lignes dans la table
pgbench_accounts.
Simuler un crash de l’instance PostgreSQL.
Redémarrer l’instance PostgreSQL.
Compter le nombre de lignes dans la table
pgbench_accounts. Que constate-t-on ?
Indexation Full Text
But : Tester l’indexation Full Text
Vous aurez besoin de la base textes. Si vous ne l’avez pas déjà, la base gutenberg est disponible en deux versions : complète sur https://dali.bo/tp_gutenberg (dump de 0,5 Go, table de 21 millions de lignes dans 3 Go) ou https://dali.bo/tp_gutenberg10 pour un extrait d’un dizième. Le dump peut se restaurer par exemple dans une nouvelle base, et contient juste une table nommée textes.
Ce TP utilise la version complète de la base textes basée sur le projet Gutenberg. Un index GIN va permettre d’utiliser la Full Text Search sur la table textes.
Créer un index GIN sur le vecteur du champ
contenu(fonctionto_tsvector).
Quelle est la taille de cet index ?
Quelle performance pour trouver « Fantine » (personnage des Misérables de Victor Hugo) dans la table ? Le résultat contient-il bien « Fantine » ?
Trouver les lignes qui contiennent à la fois les mots « affaire » et « couteau » et voir le plan.
Travaux pratiques (solutions)
Tables non journalisées
Afficher le nom du journal de transaction courant.
Créer une base pgbench vierge, de taille 80 (environ 1,2 Go). Les tables doivent être en mode unlogged.
Afficher la liste des objets unlogged dans la base pgbench.
Les 3 objets avec le suffixe pkey correspondent aux clés primaires des tables créées par pgbench. Comme elles dépendent des tables, elles sont également en mode unlogged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Comme l’initialisation de pgbench a été réalisée en mode unlogged, aucune information concernant les tables et les données qu’elles contiennent n’a été inscrite dans les journaux de transaction. Donc le journal de transaction est toujours le même.
Passer l’ensemble des tables de la base pgbench en mode logged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Comme toutes les tables de la base pgbench ont été passées en mode logged, une réécriture de celles-ci a eu lieu (comme pour un VACUUM FULL). Cette réécriture additionnée au mode logged a entraîné une forte écriture dans les journaux de transaction. Dans notre cas, 83 journaux de transaction ont été consommés, soit approximativement 1,3 Go d’utilisé sur disque.
Il faut donc faire particulièrement attention à la quantité de journaux de transaction qui peut être générée lors du passage d’une table du mode unlogged à logged.
Repasser toutes les tables de la base pgbench en mode unlogged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Le processus est le même que précedemment, mais, lors de la réécriture des tables, aucune information n’est stockée dans les journaux de transaction.
Réinitialiser la base pgbench toujours avec une taille 80 mais avec les tables en mode logged. Que constate-t-on ?
On constate que le temps mis par pgbench pour initialiser sa base est beaucoup plus long en mode logged que unlogged. On passe de 8,84 secondes en unlogged à 16,60 secondes en mode logged. Cette augmentation du temps de traitement est due à l’écriture dans les journaux de transaction.
Réinitialiser la base pgbench mais avec une taille de 10. Les tables doivent être en mode unlogged.
Compter le nombre de lignes dans la table
pgbench_accounts.
Simuler un crash de l’instance PostgreSQL.
Ne faites jamais un
kill -9sur un processus de l’instance PostgreSQL en production, bien sûr !
Redémarrer l’instance PostgreSQL.
Compter le nombre de lignes dans la table
pgbench_accounts. Que constate-t-on ?
Lors d’un crash, PostgreSQL remet tous les objets unlogged à zéro.
Indexation Full Text
Créer un index GIN sur le vecteur du champ
contenu(fonctionto_tsvector).
Quelle est la taille de cet index ?
La table « pèse » 3 Go (même si on pourrait la stocker de manière beaucoup plus efficace). L’index GIN est lui-même assez lourd dans la configuration par défaut :
Quelle performance pour trouver « Fantine » (personnage des Misérables de Victor Hugo) dans la table ? Le résultat contient-il bien « Fantine » ?
On constate donc que le Full Text Search est très efficace du moins pour le Full Text Search + GIN : trouver 1 mot parmi plus de 100 millions avec 300 enregistrements correspondants dure 1,5 ms (cache chaud).
Si l’on compare avec une recherche par trigramme (extension pg_trgm et index GIN), c’est bien meilleur. À l’inverse, les trigrammes permettent des recherches floues (orthographe approximative), des recherches sur autre chose que des mots, et ne nécessitent pas de modification de code.
Par contre, la recherche n’est pas exacte, « Fantin » est fréquemment trouvé. En fait, le plan montre que c’est le vrai critère retourné par to_tsquery('french','fantine') et transformé en 'fantin'::tsquery. Si l’on tient à ce critère précis il faudra ajouter une clause plus classique contenu LIKE '%Fantine%' pour filtrer le résultat après que le FTS ait « dégrossi » la recherche.
Trouver les lignes qui contiennent à la fois les mots « affaire » et « couteau » et voir le plan.
10 lignes sont ramenées en quelques millisecondes :
Noter que les pluriels « couteaux » et « affaires » figurent parmi les résultats puisque la recherche porte sur les léxèmes 'affair'' & ''couteau'.
Navigation
Pour revenir au chapitre précédent, voir le chapitre précédent. Pour poursuivre la lecture, voir le chapitre suivant.