Recherche plein texte lente en raison d'estimations de lignes extrêmement inexactes

10

Les requêtes en texte intégral sur cette base de données (stockage de tickets RT ( Request Tracker )) semblent prendre beaucoup de temps à s'exécuter. Le tableau des pièces jointes (contenant les données de texte intégral) est d'environ 15 Go.

Le schéma de la base de données est le suivant, c'est environ 2 millions de lignes:

rt4 = # \ d + pièces jointes
                                                    Tableau "public.attachments"
     Colonne | Type | Modificateurs | Stockage | La description
----------------- + ----------------------------- + - -------------------------------------------------- ------ + ---------- + -------------
 id | entier | non nul par défaut nextval ('attachments_id_seq' :: regclass) | ordinaire |
 transactionid | entier | non nul | ordinaire |
 parent | entier | non nul par défaut 0 | ordinaire |
 messageid | caractère variable (160) | | étendu |
 sujet | caractère variable (255) | | étendu |
 nom de fichier | caractère variable (255) | | étendu |
 type de contenu | caractère variable (80) | | étendu |
 codage de contenu | caractère variable (80) | | étendu |
 contenu | texte | | étendu |
 en-têtes | texte | | étendu |
 créateur | entier | non nul par défaut 0 | ordinaire |
 créé | horodatage sans fuseau horaire | | ordinaire |
 contentindex | tsvector | | étendu |
Index:
    "attachments_pkey" CLÉ PRIMAIRE, btree (id)
    "attachments1" btree (parent)
    "attachments2" btree (transactionid)
    "attachments3" btree (parent, transactionid)
    Gin "contentindex_idx" (contentindex)
Possède des OID: non

Je peux interroger la base de données d'elle-même très rapidement (<1s) avec une requête telle que:

select objectid
from attachments
join transactions on attachments.transactionid = transactions.id
where contentindex @@ to_tsquery('frobnicate');

Cependant, lorsque RT exécute une requête qui est censée effectuer une recherche d'index de texte intégral sur la même table, cela prend généralement des centaines de secondes. La sortie d'analyse de la requête est la suivante:

Requete

SELECT COUNT(DISTINCT main.id)
FROM Tickets main
JOIN Transactions Transactions_1 ON ( Transactions_1.ObjectType = 'RT::Ticket' )
                                AND ( Transactions_1.ObjectId = main.id )
JOIN Attachments Attachments_2 ON ( Attachments_2.TransactionId = Transactions_1.id )
WHERE (main.Status != 'deleted')
AND ( ( ( Attachments_2.ContentIndex @@ plainto_tsquery('frobnicate') ) ) )
AND (main.Type = 'ticket')
AND (main.EffectiveId = main.id);

EXPLAIN ANALYZE production

                                                                             PLAN DE DEMANDE 
-------------------------------------------------- -------------------------------------------------- -------------------------------------------------- --------------
 Agrégat (coût = 51210.60..51210.61 lignes = 1 largeur = 4) (temps réel = 477778.806..477778.806 lignes = 1 boucles = 1)
   -> Boucle imbriquée (coût = 0,00..51210,57 lignes = 15 largeur = 4) (temps réel = 17943,986..477775,174 lignes = 4197 boucles = 1)
         -> Boucle imbriquée (coût = 0,00..40643,08 lignes = 6507 largeur = 8) (temps réel = 8,526..20610,380 lignes = 1714818 boucles = 1)
               -> Seq Scan sur les tickets principaux (coût = 0,00..9818,37 lignes = 598 largeur = 8) (temps réel = 0,008..256,042 lignes = 96990 boucles = 1)
                     Filtre: ((((status) :: text 'supprimé' :: text) AND (id = effectiveid) AND ((type) :: text = 'ticket' :: text))
               -> Index Scan en utilisant transactions1 sur transactions transactions_1 (coût = 0,00..51,36 lignes = 15 largeur = 8) (temps réel = 0,102..0,202 lignes = 18 boucles = 96990)
                     Index Cond: (((objecttype) :: text = 'RT :: Ticket' :: text) AND (objectid = main.id))
         -> Index Scan en utilisant les pièces jointes2 sur les pièces jointes attachments_2 (coût = 0,00..1,61 lignes = 1 largeur = 4) (temps réel = 0,266..0,266 lignes = 0 boucles = 1714818)
               Index Cond: (transactionid = transactions_1.id)
               Filtre: (contentindex @@ plainto_tsquery ('frobnicate' :: text))
 Durée d'exécution totale: 477778,883 ms

Pour autant que je sache, le problème semble être qu'il n'utilise pas l'index créé sur le contentindexchamp ( contentindex_idx), mais plutôt qu'il filtre un grand nombre de lignes correspondantes dans la table des pièces jointes. Le nombre de lignes dans la sortie d'explication semble également extrêmement inexact, même après une récente ANALYZE: lignes estimées = 6507 lignes réelles = 1714818.

Je ne sais pas vraiment où aller ensuite avec ça.

JamesHannah
la source
La mise à niveau apporterait des avantages supplémentaires. Outre de nombreuses améliorations générales, en particulier: 9.2 permet des analyses d'index uniquement et des améliorations de l'évolutivité. La prochaine version 9.4 apportera des améliorations majeures aux index GIN.
Erwin Brandstetter

Réponses:

5

Cela peut être amélioré de mille et une façons, alors cela devrait être une question de millisecondes .

Meilleures requêtes

Ceci est juste votre requête reformatée avec des alias et du bruit supprimé pour effacer le brouillard:

SELECT count(DISTINCT t.id)
FROM   tickets      t
JOIN   transactions tr ON tr.objectid = t.id
JOIN   attachments  a  ON a.transactionid = tr.id
WHERE  t.status <> 'deleted'
AND    t.type = 'ticket'
AND    t.effectiveid = t.id
AND    tr.objecttype = 'RT::Ticket'
AND    a.contentindex @@ plainto_tsquery('frobnicate');

La plupart du problème avec votre requête réside dans les deux premières tables ticketset transactions, qui manquent dans la question. Je remplis de suppositions éclairées.

  • t.status, t.objecttypeet tr.objecttypene devrait probablement pas l'être text, mais enumou peut-être une très petite valeur référençant une table de recherche.

EXISTS semi-jointure

En supposant que tickets.idc'est la clé primaire, cette forme réécrite devrait être beaucoup moins chère:

SELECT count(*)
FROM   tickets t
WHERE  status <> 'deleted'
AND    type = 'ticket'
AND    effectiveid = id
AND    EXISTS (
   SELECT 1
   FROM   transactions tr
   JOIN   attachments  a ON a.transactionid = tr.id
   WHERE  tr.objectid = t.id
   AND    tr.objecttype = 'RT::Ticket'
   AND    a.contentindex @@ plainto_tsquery('frobnicate')
   );

Au lieu de multiplier les lignes avec deux jointures 1: n count(DISTINCT id), utilisez uniquement une EXISTSsemi-jointure pour réduire plusieurs correspondances à la fin , ce qui peut arrêter de chercher plus loin dès que la première correspondance est trouvée et en même temps obsolète la dernière DISTINCTétape. Par documentation:

La sous-requête ne sera généralement exécutée que suffisamment longtemps pour déterminer si au moins une ligne est renvoyée, pas jusqu'à la fin.

L'efficacité dépend du nombre de transactions par ticket et des pièces jointes par transaction.

Déterminer l'ordre des jointures avec join_collapse_limit

Si vous savez que votre terme de recherche pour attachments.contentindexest très sélectif - plus sélectif que les autres conditions de la requête (ce qui est probablement le cas pour «frobniquer», mais pas pour «problème»), vous pouvez forcer la séquence de jointures. Le planificateur de requêtes peut difficilement juger de la sélectivité de certains mots, à l'exception des mots les plus courants. Par documentation:

join_collapse_limit( integer)

[...]
Étant donné que le planificateur de requêtes ne choisit pas toujours l'ordre de jointure optimal, les utilisateurs avancés peuvent choisir de définir temporairement cette variable sur 1, puis spécifier explicitement l'ordre de jointure souhaité.

Utilisez SET LOCALdans le but de le définir uniquement pour la transaction en cours.

BEGIN;
SET LOCAL join_collapse_limit = 1;

SELECT count(DISTINCT t.id)
FROM   attachments  a                              -- 1st
JOIN   transactions tr ON tr.id = a.transactionid  -- 2nd
JOIN   tickets      t  ON t.id = tr.objectid       -- 3rd
WHERE  t.status <> 'deleted'
AND    t.type = 'ticket'
AND    t.effectiveid = t.id
AND    tr.objecttype = 'RT::Ticket'
AND    a.contentindex @@ plainto_tsquery('frobnicate');

ROLLBACK; -- or COMMIT;

L'ordre des WHEREconditions est toujours sans importance. Seul l'ordre des jointures est pertinent ici.

Ou utilisez un CTE comme @jjanes explique dans "Option 2". pour un effet similaire.

Index

Index B-tree

Prenez toutes les conditions ticketsqui sont utilisées de manière identique avec la plupart des requêtes et créez un index partiel sur tickets:

CREATE INDEX tickets_partial_idx
ON tickets(id)
WHERE  status <> 'deleted'
AND    type = 'ticket'
AND    effectiveid = id;

Si l'une des conditions est variable, supprimez-la de la WHEREcondition et ajoutez plutôt la colonne comme colonne d'index.

Un autre sur transactions:

CREATE INDEX transactions_partial_idx
ON transactions(objecttype, objectid, id)

La troisième colonne sert uniquement à activer les analyses d'index uniquement.

De plus, puisque vous avez cet index composite avec deux colonnes entières sur attachments:

"attachments3" btree (parent, transactionid)

Cet index supplémentaire est un déchet complet , supprimez-le:

"attachments1" btree (parent)

Détails:

Indice GIN

Ajoutez transactionidà votre index GIN pour le rendre beaucoup plus efficace. Cela peut être une autre solution miracle , car il permet potentiellement des analyses d'index uniquement, éliminant complètement les visites à la grande table.
Vous avez besoin de classes d'opérateur supplémentaires fournies par le module supplémentaire btree_gin. Instructions détaillées:

"contentindex_idx" gin (transactionid, contentindex)

4 octets d'une integercolonne ne rendent pas l'index beaucoup plus grand. De plus, heureusement pour vous, les index GIN sont différents des index B-tree dans un aspect crucial. Par documentation:

Un index GIN multicolonne peut être utilisé avec des conditions de requête qui impliquent n'importe quel sous-ensemble des colonnes de l'index . Contrairement à B-tree ou GiST, l' efficacité de la recherche d'index est la même quelle que soit la ou les colonnes d' index utilisées par les conditions de requête.

Accentuation sur moi. Donc, vous avez juste besoin du seul indice GIN (gros et quelque peu coûteux).

Définition de table

Déplacez le integer not null columnsvers l'avant. Cela a quelques effets positifs mineurs sur le stockage et les performances. Enregistre 4 à 8 octets par ligne dans ce cas.

                      Table "public.attachments"
         Column      |            Type             |         Modifiers
    -----------------+-----------------------------+------------------------------
     id              | integer                     | not null default nextval('...
     transactionid   | integer                     | not null
     parent          | integer                     | not null default 0
     creator         | integer                     | not null default 0  -- !
     created         | timestamp                   |                     -- !
     messageid       | character varying(160)      |
     subject         | character varying(255)      |
     filename        | character varying(255)      |
     contenttype     | character varying(80)       |
     contentencoding | character varying(80)       |
     content         | text                        |
     headers         | text                        |
     contentindex    | tsvector                    |
Erwin Brandstetter
la source
3

Option 1

Le planificateur n'a aucune idée de la vraie nature de la relation entre EffectiveId et id, et pense donc probablement la clause:

main.EffectiveId = main.id

va être beaucoup plus sélectif qu'il ne l'est réellement. Si c'est ce que je pense, EffectiveID est presque toujours égal à main.id, mais le planificateur ne le sait pas.

Une meilleure façon de stocker ce type de relation est généralement de définir la valeur NULL de EffectiveID pour signifier "effectivement la même chose que id", et d'y stocker quelque chose uniquement s'il y a une différence.

En supposant que vous ne vouliez pas réorganiser votre schéma, vous pouvez essayer de le contourner en réécrivant cette clause comme quelque chose comme:

main.EffectiveId+0 between main.id+0 and main.id+0

Le planificateur peut supposer que l' betweenest moins sélective qu'une égalité, et cela pourrait suffire à le faire sortir de son piège actuel.

Option 2

Une autre approche consiste à utiliser un CTE:

WITH attach as (
    SELECT * from Attachments 
        where ContentIndex @@ plainto_tsquery('frobnicate') 
)
<rest of query goes here, with 'attach' used in place of 'Attachments'>

Cela oblige le planificateur à utiliser ContentIndex comme source de sélectivité. Une fois qu'il est obligé de le faire, les corrélations de colonnes trompeuses sur la table Tickets ne seront plus aussi attrayantes. Bien sûr, si quelqu'un recherche «problème» plutôt que «frobniquer», cela pourrait se retourner.

Option 3

Pour approfondir les estimations des mauvaises lignes, vous devez exécuter la requête ci-dessous dans toutes les permutations 2 ^ 3 = 8 des différentes clauses AND commentées. Cela vous aidera à déterminer d'où vient la mauvaise estimation.

explain analyze
SELECT * FROM Tickets main WHERE 
   main.Status != 'deleted' AND 
   main.Type = 'ticket' AND 
   main.EffectiveId = main.id;
jjanes
la source