Pesquisa de texto completo lenta devido a estimativas de linhas imprecisas

10

As consultas de texto completo nesse banco de dados (armazenando tíquetes RT ( Request Tracker )) parecem levar muito tempo para serem executadas. A tabela de anexos (contendo os dados de texto completo) tem cerca de 15 GB.

O esquema do banco de dados é o seguinte, são cerca de 2 milhões de linhas:

rt4 = # \ d + anexos
                                                    Tabela "public.attachments"
     Coluna | Tipo | Modificadores | Armazenamento | Descrição
----------------- + ----------------------------- + - -------------------------------------------------- ------ + ---------- + -------------
 id | inteiro not null default nextval ('attachments_id_seq' :: regclass) | planície |
 transactionid | inteiro não nulo | planície |
 pai | inteiro não nulo padrão 0 | planície |
 messageid | caracteres que variam (160) | | estendido |
 assunto | caracteres que variam (255) | | estendido |
 filename | caracteres que variam (255) | | estendido |
 tipo de conteúdo | caracteres variados (80) | | estendido |
 codificação de conteúdo | caracteres variados (80) | | estendido |
 conteúdo | texto | estendido |
 cabeçalhos | texto | estendido |
 criador | inteiro não nulo padrão 0 | planície |
 criado | registro de data e hora sem fuso horário | | planície |
 índice de conteúdo | tsvector | | estendido |
Índices:
    "attachments_pkey" CHAVE PRIMÁRIA, btree (id)
    btree "attachments1" (pai)
    "attachments2" btree (transactionid)
    btree "attachments3" (pai, ID da transação)
    gin "contentindex_idx" (contentindex)
Possui OIDs: não

Posso consultar o banco de dados por conta própria muito rapidamente (<1s) com uma consulta como:

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

No entanto, quando o RT executa uma consulta que deveria executar uma pesquisa de índice de texto completo na mesma tabela, geralmente leva centenas de segundos para concluir. A saída de análise da consulta é a seguinte:

Inquerir

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 resultado

                                                                             PLANO DE CONSULTA 
-------------------------------------------------- -------------------------------------------------- -------------------------------------------------- --------------
 Agregado (custo = 51210.60..51210.61 linhas = 1 largura = 4) (tempo real = 477778.806..477778.806 linhas = 1 loops = 1)
   -> Loop aninhado (custo = 0,00..51210,57 linhas = 15 largura = 4) (tempo real = 17943,986..477775.174 linhas = 4197 loops = 1)
         -> Loop aninhado (custo = 0,00..40643,08 linhas = 6507 largura = 8) (tempo real = 8,526..20610,380 linhas = 1714818 loops = 1)
               -> Varredura Seq nos tickets principais (custo = 0,00..9818,37 linhas = 598 largura = 8) (tempo real = 0,008..256,042 linhas = 96990 loops = 1)
                     Filtro: (((status) :: texto 'excluído' :: texto) AND (id = eficazid) AND ((tipo) :: texto = 'ticket' :: texto))
               -> Varredura de índice usando transações1 em transações transações_1 (custo = 0,00..51,36 linhas = 15 largura = 8) (tempo real = 0,102..0,202 linhas = 18 loops = 96990)
                     Índice Cond: (((objecttype) :: text = 'RT :: Ticket' :: text) AND (objectid = main.id))
         -> Varredura de índice usando anexos2 em anexos anexos_2 (custo = 0,00..1,61 linhas = 1 largura = 4) (tempo real = 0,266..0,266 linhas = 0 loops = 1714818)
               Cond do índice: (transactionid = transaction_1.id)
               Filtro: (contentindex @@ plainto_tsquery ('frobnicate' :: texto))
 Tempo de execução total: 477778.883 ms

Pelo que sei, o problema parece ser que ele não está usando o índice criado no contentindexcampo ( contentindex_idx), mas está fazendo um filtro em um grande número de linhas correspondentes na tabela de anexos. As contagens de linhas na saída de explicação também parecem ser muito imprecisas, mesmo depois de uma recente ANALYZE: linhas estimadas = 6507 linhas reais = 1714818.

Não tenho muita certeza de onde ir com isso.

JamesHannah
fonte
A atualização traria benefícios adicionais. Além de muitas melhorias gerais, em particular: 9.2 permite verificações apenas de índice e melhorias na escalabilidade. O próximo 9.4 trará grandes aprimoramentos para os índices GIN.
Erwin Brandstetter

Respostas:

5

Isso pode ser aprimorado de mil e uma maneiras, então deve ser uma questão de milissegundos .

Melhores consultas

Esta é apenas sua consulta reformatada com aliases e algum ruído removido para limpar o nevoeiro:

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');

A maior parte do problema com sua consulta está nas duas primeiras tabelas ticketse transactions, que estão faltando na pergunta. Estou preenchendo com palpites.

  • t.status, t.objecttypee tr.objecttypeprovavelmente não deveria ser text, mas enumou possivelmente algum valor muito pequeno fazendo referência a uma tabela de consulta.

EXISTS semi-junção

Assumindo que tickets.idé a chave primária, esse formulário reescrito deve ser muito mais barato:

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')
   );

Em vez de multiplicar linhas com duas junções 1: n, apenas para recolher várias correspondências no final count(DISTINCT id), use uma EXISTSsemi-junção, que pode parar de procurar mais assim que a primeira correspondência for encontrada e ao mesmo tempo obsoleta a DISTINCTetapa final . Por documentação:

Geralmente, a subconsulta é executada apenas o tempo suficiente para determinar se pelo menos uma linha é retornada, e não até a conclusão.

A eficácia depende de quantas transações por ticket e anexos por transação existem.

Determinar a ordem das junções com join_collapse_limit

Se você souber que seu termo de pesquisa attachments.contentindexé muito seletivo - mais seletivo do que outras condições da consulta (que provavelmente é o caso de 'frobnicate', mas não de 'problem'), você pode forçar a sequência de junções. O planejador de consultas dificilmente pode julgar a seletividade de palavras específicas, exceto as mais comuns. Por documentação:

join_collapse_limit( integer)

[...]
Como o planejador de consultas nem sempre escolhe a ordem de junção ideal, os usuários avançados podem optar por definir temporariamente essa variável como 1 e especificar a ordem de junção que desejam explicitamente.

Use SET LOCALcom a finalidade de configurá-lo apenas para a transação atual.

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;

A ordem das WHEREcondições é sempre irrelevante. Somente a ordem das junções é relevante aqui.

Ou use um CTE como o @jjanes explica na "Opção 2". para um efeito semelhante.

Índices

Índices da árvore B

Pegue todas as condições ticketsusadas de maneira idêntica à maioria das consultas e crie um índice parcial em tickets:

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

Se uma das condições for variável, elimine-a da WHEREcondição e prefira a coluna como coluna de índice.

Outro sobre transactions:

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

A terceira coluna é apenas para habilitar verificações apenas de índice.

Além disso, como você tem esse índice composto com duas colunas inteiras em attachments:

"attachments3" btree (parent, transactionid)

Este índice adicional é um desperdício completo , exclua-o:

"attachments1" btree (parent)

Detalhes:

Índice GIN

Adicione transactionidao seu índice GIN para torná-lo muito mais eficaz. Essa pode ser outra vantagem , pois potencialmente permite verificações apenas de índice, eliminando completamente as visitas à grande mesa.
Você precisa de classes de operador adicionais fornecidas pelo módulo adicional btree_gin. Instruções detalhadas:

"contentindex_idx" gin (transactionid, contentindex)

4 bytes de uma integercoluna não aumentam o índice. Felizmente para você, os índices GIN são diferentes dos índices da árvore B em um aspecto crucial. Por documentação:

Um índice GIN com várias colunas pode ser usado com condições de consulta que envolvem qualquer subconjunto das colunas do índice . Diferentemente da árvore B ou do GiST, a eficácia da pesquisa de índice é a mesma, independentemente de quais colunas de índice as condições de consulta usam.

Negrito ênfase minha. Então você só precisa do um índice de GIN (grande e um pouco caro).

Definição de tabela

Mova o integer not null columnspara a frente. Isso tem alguns efeitos positivos menores no armazenamento e no desempenho. Salva de 4 a 8 bytes por linha neste caso.

                      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
fonte
3

Opção 1

O planejador não tem conhecimento da verdadeira natureza do relacionamento entre EffectiveId e id e, portanto, provavelmente pensa na cláusula the:

main.EffectiveId = main.id

vai ser muito mais seletivo do que realmente é. Se é isso que eu acho que é, o EffectiveID quase sempre é igual ao main.id, mas o planejador não sabe disso.

Uma maneira possivelmente melhor para armazenar esse tipo de relacionamento é geralmente definir o valor NULL de EffectiveID para significar "efetivamente o mesmo que id" e armazenar algo apenas se houver uma diferença.

Supondo que você não deseja reorganizar seu esquema, tente contorná-lo reescrevendo essa cláusula como algo como:

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

O planejador pode assumir que betweené menos seletivo que uma igualdade, e isso pode ser suficiente para desviá-lo de sua armadilha atual.

opção 2

Outra abordagem é usar um CTE:

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

Isso força o planejador a usar o ContentIndex como uma fonte de seletividade. Uma vez forçado a fazer isso, as correlações enganosas da coluna na tabela Tickets não parecerão mais tão atraentes. É claro que se alguém procurar por 'problema' em vez de 'frobnicizar', isso pode sair pela culatra.

Opção 3

Para investigar mais as estimativas de linhas incorretas, você deve executar a consulta abaixo em todas as permutações 2 ^ 3 = 8 das diferentes cláusulas AND comentadas. Isso ajudará a descobrir de onde vem a estimativa ruim.

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