web-dev-qa-db-fra.com

Comment accélérer le tri ORDER BY lors de l'utilisation de l'index GIN dans PostgreSQL?

J'ai une table comme celle-ci:

CREATE TABLE products (
  id serial PRIMARY KEY, 
  category_ids integer[],
  published boolean NOT NULL,
  score integer NOT NULL,
  title varchar NOT NULL);

Un produit peut appartenir à plusieurs catégories. La colonne category_ids Contient une liste des identifiants de toutes les catégories de produits.

La requête typique ressemble à ceci (toujours à la recherche d'une seule catégorie):

SELECT * FROM products WHERE published
  AND category_ids @> ARRAY[23465]
ORDER BY score DESC, title
LIMIT 20 OFFSET 8000;

Pour l'accélérer, j'utilise l'index suivant:

CREATE INDEX idx_test1 ON products
  USING GIN (category_ids gin__int_ops) WHERE published;

Celui-ci aide beaucoup à moins qu'il n'y ait trop de produits dans une catégorie. Il filtre rapidement les produits qui appartiennent à cette catégorie, mais il y a ensuite une opération de tri qui doit être effectuée à la dure (sans index).

Une extension btree_gin A été installée, ce qui me permet de créer un index GIN à plusieurs colonnes comme celui-ci:

CREATE INDEX idx_test2 ON products USING GIN (
  category_ids gin__int_ops, score, title) WHERE published;

Mais Postgres ne veut pas l'utiliser pour le tri. Même lorsque je supprime le spécificateur DESC dans la requête.

Toute approche alternative pour optimiser la tâche est la bienvenue.


Information additionnelle:

  • PostgreSQL 9.4, avec extension intarray
  • le nombre total de produits est actuellement de 260 000 mais devrait croître de manière significative (jusqu'à 10 millions, il s'agit d'une plateforme de commerce électronique multi-locataire)
  • produits par catégorie 1..10000 (peut atteindre 100 000), la moyenne est inférieure à 100 mais les catégories avec un grand nombre de produits ont tendance à attirer beaucoup plus de demandes

Le plan de requête suivant a été obtenu à partir d'un système de test plus petit (4680 produits dans la catégorie sélectionnée, 200k produits au total dans le tableau):

Limit  (cost=948.99..948.99 rows=1 width=72) (actual time=82.330..82.341 rows=20 loops=1)
  ->  Sort  (cost=948.37..948.99 rows=245 width=72) (actual time=80.231..81.337 rows=4020 loops=1)
        Sort Key: score, title
        Sort Method: quicksort  Memory: 928kB
        ->  Bitmap Heap Scan on products  (cost=13.90..938.65 rows=245 width=72) (actual time=1.919..16.044 rows=4680 loops=1)
              Recheck Cond: ((category_ids @> '{292844}'::integer[]) AND published)
              Heap Blocks: exact=3441
              ->  Bitmap Index Scan on idx_test2  (cost=0.00..13.84 rows=245 width=0) (actual time=1.185..1.185 rows=4680 loops=1)
                    Index Cond: (category_ids @> '{292844}'::integer[])
Planning time: 0.202 ms
Execution time: 82.404 ms

Remarque # 1: 82 ms peut ne pas sembler si effrayant, mais c'est parce que le tampon de tri tient dans la mémoire. Une fois que j'ai sélectionné toutes les colonnes de la table des produits (SELECT * FROM ... Et dans la vie réelle, il y a environ 60 colonnes), cela passe à Sort Method: external merge Disk: 5696kB En doublant le temps d'exécution. Et ce n'est que pour 4680 produits.

Point d'action # 1 (vient de la note # 1): Afin de réduire l'empreinte mémoire de l'opération de tri et donc l'accélérer un peu, il serait sage de récupérer, trier et limiter les identifiants des produits d'abord, puis récupérer les enregistrements complets:

SELECT * FROM products WHERE id IN (
  SELECT id FROM products WHERE published AND category_ids @> ARRAY[23465]
  ORDER BY score DESC, title LIMIT 20 OFFSET 8000
) ORDER BY score DESC, title;

Cela nous ramène à Sort Method: quicksort Memory: 903kB Et ~ 80 ms pour 4680 produits. Peut encore être lent lorsque le nombre de produits atteint 100 000.

13

J'ai fait beaucoup d'expérimentations et voici mes résultats.

GIN et tri

Index GIN actuellement (à partir de la version 9.4) ne peut pas aider à la commande .

Parmi les types d'index actuellement pris en charge par PostgreSQL, seul B-tree peut produire une sortie triée - les autres types d'index renvoient des lignes correspondantes dans un ordre non spécifié, dépendant de l'implémentation.

work_mem

Merci Chris d'avoir signalé ce paramètre de configuration . La valeur par défaut est 4 Mo. Si votre jeu d'enregistrements est plus grand, il augmente work_mem à la valeur appropriée (peut être trouvé à partir de EXPLAIN ANALYSE) peut accélérer considérablement les opérations de tri.

ALTER SYSTEM SET work_mem TO '32MB';

Redémarrez le serveur pour que les modifications prennent effet, puis revérifiez:

SHOW work_mem;

Requête d'origine

J'ai rempli ma base de données avec 650k produits avec certaines catégories contenant jusqu'à 40k produits. J'ai simplifié un peu la requête en supprimant la clause published:

SELECT * FROM products WHERE category_ids @> ARRAY [248688]
ORDER BY score DESC, title LIMIT 10 OFFSET 30000;

Limit  (cost=2435.62..2435.62 rows=1 width=1390) (actual time=1141.254..1141.256 rows=10 loops=1)
  ->  Sort  (cost=2434.00..2435.62 rows=646 width=1390) (actual time=1115.706..1140.513 rows=30010 loops=1)
        Sort Key: score, title
        Sort Method: external merge  Disk: 29656kB
        ->  Bitmap Heap Scan on products  (cost=17.01..2403.85 rows=646 width=1390) (actual time=11.831..25.646 rows=41666 loops=1)
              Recheck Cond: (category_ids @> '{248688}'::integer[])
              Heap Blocks: exact=6471
              ->  Bitmap Index Scan on idx_products_category_ids_gin  (cost=0.00..16.85 rows=646 width=0) (actual time=10.140..10.140 rows=41666 loops=1)
                    Index Cond: (category_ids @> '{248688}'::integer[])
Planning time: 0.288 ms
Execution time: 1146.322 ms

Comme on peut le voir work_mem n'était pas suffisant donc nous avions Sort Method: external merge Disk: 29656kB (le nombre ici est approximatif, il a besoin d'un peu plus de 32 Mo pour le tri rapide en mémoire).

Réduisez l'empreinte mémoire

Ne sélectionnez pas les enregistrements complets pour le tri, utilisez les identifiants, appliquez le tri, le décalage et la limite, puis chargez seulement 10 enregistrements dont nous avons besoin:

SELECT * FROM products WHERE id in (
  SELECT id FROM products WHERE category_ids @> ARRAY[248688]
  ORDER BY score DESC, title LIMIT 10 OFFSET 30000
) ORDER BY score DESC, title;

Sort  (cost=2444.10..2444.11 rows=1 width=1390) (actual time=707.861..707.862 rows=10 loops=1)
  Sort Key: products.score, products.title
  Sort Method: quicksort  Memory: 35kB
  ->  Nested Loop  (cost=2436.05..2444.09 rows=1 width=1390) (actual time=707.764..707.803 rows=10 loops=1)
        ->  HashAggregate  (cost=2435.63..2435.64 rows=1 width=4) (actual time=707.744..707.746 rows=10 loops=1)
              Group Key: products_1.id
              ->  Limit  (cost=2435.62..2435.62 rows=1 width=72) (actual time=707.732..707.734 rows=10 loops=1)
                    ->  Sort  (cost=2434.00..2435.62 rows=646 width=72) (actual time=704.163..706.955 rows=30010 loops=1)
                          Sort Key: products_1.score, products_1.title
                          Sort Method: quicksort  Memory: 7396kB
                          ->  Bitmap Heap Scan on products products_1  (cost=17.01..2403.85 rows=646 width=72) (actual time=11.587..35.076 rows=41666 loops=1)
                                Recheck Cond: (category_ids @> '{248688}'::integer[])
                                Heap Blocks: exact=6471
                                ->  Bitmap Index Scan on idx_products_category_ids_gin  (cost=0.00..16.85 rows=646 width=0) (actual time=9.883..9.883 rows=41666 loops=1)
                                      Index Cond: (category_ids @> '{248688}'::integer[])
        ->  Index Scan using products_pkey on products  (cost=0.42..8.45 rows=1 width=1390) (actual time=0.004..0.004 rows=1 loops=10)
              Index Cond: (id = products_1.id)
Planning time: 0.682 ms
Execution time: 707.973 ms

Remarque Sort Method: quicksort Memory: 7396kB. Le résultat est bien meilleur.

JOIN et index B-tree supplémentaire

Comme Chris l'a conseillé, j'ai créé un index supplémentaire:

CREATE INDEX idx_test7 ON products (score DESC, title);

J'ai d'abord essayé de rejoindre comme ceci:

SELECT * FROM products NATURAL JOIN
  (SELECT id FROM products WHERE category_ids @> ARRAY[248688]
  ORDER BY score DESC, title LIMIT 10 OFFSET 30000) c
ORDER BY score DESC, title;

Le plan de requête diffère légèrement mais le résultat est le même:

Sort  (cost=2444.10..2444.11 rows=1 width=1390) (actual time=700.747..700.747 rows=10 loops=1)
  Sort Key: products.score, products.title
  Sort Method: quicksort  Memory: 35kB
  ->  Nested Loop  (cost=2436.05..2444.09 rows=1 width=1390) (actual time=700.651..700.690 rows=10 loops=1)
        ->  HashAggregate  (cost=2435.63..2435.64 rows=1 width=4) (actual time=700.630..700.630 rows=10 loops=1)
              Group Key: products_1.id
              ->  Limit  (cost=2435.62..2435.62 rows=1 width=72) (actual time=700.619..700.619 rows=10 loops=1)
                    ->  Sort  (cost=2434.00..2435.62 rows=646 width=72) (actual time=697.304..699.868 rows=30010 loops=1)
                          Sort Key: products_1.score, products_1.title
                          Sort Method: quicksort  Memory: 7396kB
                          ->  Bitmap Heap Scan on products products_1  (cost=17.01..2403.85 rows=646 width=72) (actual time=10.796..32.258 rows=41666 loops=1)
                                Recheck Cond: (category_ids @> '{248688}'::integer[])
                                Heap Blocks: exact=6471
                                ->  Bitmap Index Scan on idx_products_category_ids_gin  (cost=0.00..16.85 rows=646 width=0) (actual time=9.234..9.234 rows=41666 loops=1)
                                      Index Cond: (category_ids @> '{248688}'::integer[])
        ->  Index Scan using products_pkey on products  (cost=0.42..8.45 rows=1 width=1390) (actual time=0.004..0.004 rows=1 loops=10)
              Index Cond: (id = products_1.id)
Planning time: 1.015 ms
Execution time: 700.918 ms

En jouant avec divers décalages et nombres de produits, je n'ai pas pu faire en sorte que PostgreSQL utilise un index B-tree supplémentaire.

Je suis donc allé de façon classique et créé table de jonction:

CREATE TABLE prodcats AS SELECT id AS product_id, unnest(category_ids) AS category_id FROM products;
CREATE INDEX idx_prodcats_cat_prod_id ON prodcats (category_id, product_id);

SELECT p.* FROM products p JOIN prodcats c ON (p.id=c.product_id)
WHERE c.category_id=248688
ORDER BY p.score DESC, p.title LIMIT 10 OFFSET 30000;

Limit  (cost=122480.06..122480.09 rows=10 width=1390) (actual time=1290.360..1290.362 rows=10 loops=1)
  ->  Sort  (cost=122405.06..122509.00 rows=41574 width=1390) (actual time=1264.250..1289.575 rows=30010 loops=1)
        Sort Key: p.score, p.title
        Sort Method: external merge  Disk: 29656kB
        ->  Merge Join  (cost=50.46..94061.13 rows=41574 width=1390) (actual time=117.746..182.048 rows=41666 loops=1)
              Merge Cond: (p.id = c.product_id)
              ->  Index Scan using products_pkey on products p  (cost=0.42..90738.43 rows=646067 width=1390) (actual time=0.034..116.313 rows=210283 loops=1)
              ->  Index Only Scan using idx_prodcats_cat_prod_id on prodcats c  (cost=0.43..1187.98 rows=41574 width=4) (actual time=0.022..7.137 rows=41666 loops=1)
                    Index Cond: (category_id = 248688)
                    Heap Fetches: 0
Planning time: 0.873 ms
Execution time: 1294.826 ms

N'utilise toujours pas l'index B-tree, l'ensemble de résultats ne correspond pas à work_mem, d'où de mauvais résultats.

Mais dans certaines circonstances, avoir grand nombre de produits et petit décalage PostgreSQL décide maintenant d'utiliser l'index B-tree:

SELECT p.* FROM products p JOIN prodcats c ON (p.id=c.product_id)
WHERE c.category_id=248688
ORDER BY p.score DESC, p.title LIMIT 10 OFFSET 300;

Limit  (cost=3986.65..4119.51 rows=10 width=1390) (actual time=264.176..264.574 rows=10 loops=1)
  ->  Nested Loop  (cost=0.98..552334.77 rows=41574 width=1390) (actual time=250.378..264.558 rows=310 loops=1)
        ->  Index Scan using idx_test7 on products p  (cost=0.55..194665.62 rows=646067 width=1390) (actual time=0.030..83.026 rows=108037 loops=1)
        ->  Index Only Scan using idx_prodcats_cat_prod_id on prodcats c  (cost=0.43..0.54 rows=1 width=4) (actual time=0.001..0.001 rows=0 loops=108037)
              Index Cond: ((category_id = 248688) AND (product_id = p.id))
              Heap Fetches: 0
Planning time: 0.585 ms
Execution time: 264.664 ms

Ceci est en fait assez logique car l'index B-tree ici ne produit pas de résultat direct, il n'est utilisé que comme guide pour un scan séquentiel.

Comparons avec la requête GIN:

SELECT * FROM products WHERE id in (
  SELECT id FROM products WHERE category_ids @> ARRAY[248688]
  ORDER BY score DESC, title LIMIT 10 OFFSET 300
) ORDER BY score DESC, title;

Sort  (cost=2519.53..2519.55 rows=10 width=1390) (actual time=143.809..143.809 rows=10 loops=1)
  Sort Key: products.score, products.title
  Sort Method: quicksort  Memory: 35kB
  ->  Nested Loop  (cost=2435.14..2519.36 rows=10 width=1390) (actual time=143.693..143.736 rows=10 loops=1)
        ->  HashAggregate  (cost=2434.71..2434.81 rows=10 width=4) (actual time=143.678..143.680 rows=10 loops=1)
              Group Key: products_1.id
              ->  Limit  (cost=2434.56..2434.59 rows=10 width=72) (actual time=143.668..143.670 rows=10 loops=1)
                    ->  Sort  (cost=2433.81..2435.43 rows=646 width=72) (actual time=143.642..143.653 rows=310 loops=1)
                          Sort Key: products_1.score, products_1.title
                          Sort Method: top-N heapsort  Memory: 68kB
                          ->  Bitmap Heap Scan on products products_1  (cost=17.01..2403.85 rows=646 width=72) (actual time=11.625..31.868 rows=41666 loops=1)
                                Recheck Cond: (category_ids @> '{248688}'::integer[])
                                Heap Blocks: exact=6471
                                ->  Bitmap Index Scan on idx_products_category_ids_gin  (cost=0.00..16.85 rows=646 width=0) (actual time=9.916..9.916 rows=41666 loops=1)
                                      Index Cond: (category_ids @> '{248688}'::integer[])
        ->  Index Scan using products_pkey on products  (cost=0.42..8.45 rows=1 width=1390) (actual time=0.004..0.004 rows=1 loops=10)
              Index Cond: (id = products_1.id)
Planning time: 0.630 ms
Execution time: 143.921 ms

Le résultat de GIN est bien meilleur. J'ai vérifié avec différentes combinaisons de nombre de produits et de décalage, en aucun cas l'approche de la table de jonction n'était meilleure .

La puissance de l'indice réel

Pour que PostgreSQL utilise pleinement l'index pour le tri, tous les paramètres de requête WHERE ainsi que ORDER BY les paramètres doivent résider dans un seul index B-tree. Pour ce faire, j'ai copié les champs de tri du produit vers la table de jonction:

CREATE TABLE prodcats AS SELECT id AS product_id, unnest(category_ids) AS category_id, score, title FROM products;
CREATE INDEX idx_prodcats_1 ON prodcats (category_id, score DESC, title, product_id);

SELECT * FROM products WHERE id in (SELECT product_id FROM prodcats WHERE category_id=248688 ORDER BY score DESC, title LIMIT 10 OFFSET 30000) ORDER BY score DESC, title;

Sort  (cost=2149.65..2149.67 rows=10 width=1390) (actual time=7.011..7.011 rows=10 loops=1)
  Sort Key: products.score, products.title
  Sort Method: quicksort  Memory: 35kB
  ->  Nested Loop  (cost=2065.26..2149.48 rows=10 width=1390) (actual time=6.916..6.950 rows=10 loops=1)
        ->  HashAggregate  (cost=2064.83..2064.93 rows=10 width=4) (actual time=6.902..6.904 rows=10 loops=1)
              Group Key: prodcats.product_id
              ->  Limit  (cost=2064.02..2064.71 rows=10 width=74) (actual time=6.893..6.895 rows=10 loops=1)
                    ->  Index Only Scan using idx_prodcats_1 on prodcats  (cost=0.56..2860.10 rows=41574 width=74) (actual time=0.010..6.173 rows=30010 loops=1)
                          Index Cond: (category_id = 248688)
                          Heap Fetches: 0
        ->  Index Scan using products_pkey on products  (cost=0.42..8.45 rows=1 width=1390) (actual time=0.003..0.003 rows=1 loops=10)
              Index Cond: (id = prodcats.product_id)
Planning time: 0.318 ms
Execution time: 7.066 ms

Et c'est le pire scénario avec un grand nombre de produits dans la catégorie choisie et un grand décalage. Lorsque offset = 300, le temps d'exécution n'est que de 0,5 ms.

Malheureusement, le maintien d'une telle table de jonction nécessite un effort supplémentaire. Cela peut être accompli via des vues matérialisées indexées, mais cela n'est utile que lorsque vos données sont rarement mises à jour, car l'actualisation de cette vue matérialisée est une opération assez lourde.

Donc, je reste avec l'indice GIN jusqu'à présent, avec une augmentation de work_mem et requête de réduction de l'empreinte mémoire.

10

Voici quelques conseils rapides qui peuvent vous aider à améliorer vos performances. Je vais commencer par la pointe la plus simple, qui est presque sans effort de votre part, et passer à la pointe la plus difficile après la première.

1. work_mem

Donc, je vois tout de suite qu'une sorte signalée dans votre plan d'explication Sort Method: external merge Disk: 5696kB consomme moins de 6 Mo, mais déborde sur le disque. Vous devez augmenter votre work_mem réglage dans votre postgresql.conf le fichier doit être suffisamment grand pour que le tri puisse tenir en mémoire.

EDIT: De plus, après une inspection plus approfondie, je constate qu'après avoir utilisé l'index pour vérifier catgory_ids qui correspondent à vos critères, l'analyse d'index bitmap est forcée de devenir "avec perte" et doit revérifier la condition lors de la lecture des lignes à partir des pages de segment de mémoire pertinentes. Reportez-vous à cet article sur postgresql.org pour une explication meilleure que celle que j'ai donnée. : P Le point principal est que votre work_mem est bien trop bas. Si vous n'avez pas réglé les paramètres par défaut sur votre serveur, cela ne fonctionnera pas bien.

Cette correction ne vous prendra essentiellement pas de temps. Un changement à postgresql.conf, et c'est parti! Reportez-vous à cette page d'optimisation des performances pour plus de conseils.

2. Changement de schéma

Vous avez donc pris la décision dans votre conception de schéma de dénormaliser le category_ids dans un tableau d'entiers, ce qui vous oblige ensuite à utiliser un index GIN ou Gist pour obtenir un accès rapide. D'après mon expérience, votre choix d'un index GIN sera plus rapide pour les lectures qu'un Gist, donc dans ce cas, vous avez fait le bon choix. Cependant, GIN est un index non trié; pensez plutôt à une valeur-clé, où les prédicats d'égalité sont faciles à vérifier, mais des opérations telles que WHERE >, WHERE <, ou ORDER BY ne sont pas facilités par l'index.

Une approche décente serait de normaliser votre conception en utilisant une table de pont/table de jonction , utilisée pour spécifier plusieurs à - de nombreuses relations dans les bases de données.

Dans ce cas, vous disposez de plusieurs catégories et d'un ensemble d'entiers correspondants category_ids, et vous disposez de nombreux produits et de leur product_ids. Au lieu d'une colonne dans votre table de produit qui est un tableau entier de category_ids, supprimez cette colonne de tableau de votre schéma et créez une table en tant que

CREATE TABLE join_products_categories (product_id int, category_id int);

Ensuite, vous pouvez générer des indices B-tree sur les deux colonnes de la table de bridge,

CREATE INDEX idx_products_in_join_table ON join_products_categories (product_id);
CREATE INDEX idx_products_in_join_table ON join_products_categories (category_id);

Juste mon humble avis, mais ces changements peuvent faire une grande différence pour vous. Essayez ça work_mem changer au moins la première chose.

Bonne chance!

MODIFIER:

Créer un index supplémentaire pour faciliter le tri

Donc, si au fil du temps votre gamme de produits se développe, certaines requêtes peuvent renvoyer de nombreux résultats (des milliers, des dizaines de milliers?), Mais qui ne peuvent être qu'un petit sous-ensemble de votre gamme de produits totale. Dans ces cas, le tri peut même être assez coûteux s'il est effectué en mémoire, mais un index conçu de manière appropriée peut être utilisé pour faciliter le tri.

Voir la documentation officielle de PostgreSQL décrivant Index et ORDER BY.

Si vous créez un index correspondant à votre ORDER BY exigences

CREATE INDEX idx_product_sort ON products (score DESC, title);

postgres optimisera et décidera si l'utilisation de l'index ou l'exécution d'un tri explicite sera plus rentable. Gardez à l'esprit qu'il n'y a aucune garantie que Postgres utilisera l'index; il cherchera à optimiser les performances et à choisir entre l'utilisation de l'index ou le tri explicite. Si vous créez cet index, surveillez-le pour voir s'il est suffisamment utilisé pour justifier sa création et supprimez-le si la plupart de vos tris sont effectués explicitement.

Pourtant, à ce stade, votre plus grand rapport qualité-prix sera probablement d'utiliser plus de work_mem, mais dans certains cas, l'index peut prendre en charge le tri.

4
Chris