Tempos de consulta lentos para pesquisas de similaridade com índices pg_trgm

9

Adicionamos dois índices pg_trgm a uma tabela, para permitir a pesquisa difusa por endereço de email ou nome, pois precisamos encontrar usuários por nome ou endereços de email que foram digitados incorretamente durante a inscrição (por exemplo, "@ gmail.con"). ANALYZEfoi executado após a criação do índice.

No entanto, fazer uma pesquisa classificada em qualquer um desses índices é muito lento na grande maioria dos casos. ou seja, com um tempo limite aumentado, uma consulta pode retornar em 60 segundos, em ocasiões muito raras, em apenas 15 segundos, mas geralmente as consultas expiram.

pg_trgm.similarity_thresholdé o valor padrão de 0.3, mas aumentar 0.8isso não parece fazer a diferença.

Essa tabela específica possui mais de 25 milhões de linhas e é constantemente consultada, atualizada e inserida (o tempo médio para cada uma é inferior a 2ms). A configuração é o PostgreSQL 9.6.6 em execução em uma instância do RDS db.m4.large com armazenamento SSD de uso geral e parâmetros padrão mais ou menos. A extensão pg_trgm é a versão 1.3.

Consultas:

  • SELECT *
    FROM users
    WHERE email % '[email protected]'
    ORDER BY email <-> '[email protected]' LIMIT 10;
  • SELECT *
    FROM users
    WHERE (first_name || ' ' || last_name) % 'chris orr'
    ORDER BY (first_name || ' ' || last_name) <-> 'chris orr' LIMIT 10;

Essas consultas não precisam ser executadas com muita frequência (dezenas de vezes por dia), mas devem ser baseadas no estado atual da tabela e, idealmente, retornar em cerca de 10 segundos.


Esquema:

=> \d+ users
                                          Table "public.users"
          Column   |            Type             | Collation | Nullable | Default | Storage  
-------------------+-----------------------------+-----------+----------+---------+----------
 id                | uuid                        |           | not null |         | plain    
 email             | citext                      |           | not null |         | extended 
 email_is_verified | boolean                     |           | not null |         | plain    
 first_name        | text                        |           | not null |         | extended 
 last_name         | text                        |           | not null |         | extended 
 created_at        | timestamp without time zone |           |          | now()   | plain    
 updated_at        | timestamp without time zone |           |          | now()   | plain    
                  | boolean                     |           | not null | false   | plain    
                  | character varying(60)       |           |          |         | extended 
                  | character varying(6)        |           |          |         | extended 
                  | character varying(6)        |           |          |         | extended 
                  | boolean                     |           |          |         | plain    
Indexes:
  "users_pkey" PRIMARY KEY, btree (id)
  "users_email_key" UNIQUE, btree (email)
  "users_search_email_idx" gist (email gist_trgm_ops)
  "users_search_name_idx" gist (((first_name || ' '::text) || last_name) gist_trgm_ops)
  "users_updated_at_idx" btree (updated_at)
Triggers:
  update_users BEFORE UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE update_modified_column()
Options: autovacuum_analyze_scale_factor=0.01, autovacuum_vacuum_scale_factor=0.05

(Estou ciente de que nós provavelmente deve também adicionar unaccent()a users_search_name_idxe a consulta de nome ...)


Explica:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE (first_name || ' ' || last_name) % 'chris orr' ORDER BY (first_name || ' ' || last_name) <-> 'chris orr' LIMIT 10;:

Limit  (cost=0.42..40.28 rows=10 width=152) (actual time=58671.973..58676.193 rows=10 loops=1)
  Buffers: shared hit=66227 read=231821
  ->  Index Scan using users_search_name_idx on users  (cost=0.42..100264.13 rows=25153 width=152) (actual time=58671.970..58676.180 rows=10 loops=1)
        Index Cond: (((first_name || ' '::text) || last_name) % 'chris orr'::text)
        Order By: (((first_name || ' '::text) || last_name) <-> 'chris orr'::text"
        Buffers: shared hit=66227 read=231821
Planning time: 0.125 ms
Execution time: 58676.265 ms

É mais provável que a pesquisa por e-mail atinja o tempo limite do que a pesquisa por nome, mas provavelmente isso ocorre porque os endereços de e-mail são muito semelhantes (por exemplo, muitos endereços @ gmail.com).

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE email % '[email protected]' ORDER BY email <-> '[email protected]' LIMIT 10;:

Limit  (cost=0.42..40.43 rows=10 width=152) (actual time=58851.719..62181.128 rows=10 loops=1)
  Buffers: shared hit=83 read=428918
  ->  Index Scan using users_search_email_idx on users  (cost=0.42..100646.36 rows=25153 width=152) (actual time=58851.716..62181.113 rows=10 loops=1)
        Index Cond: ((email)::text % '[email protected]'::text)
        Order By: ((email)::text <-> '[email protected]'::text)
        Buffers: shared hit=83 read=428918
Planning time: 0.100 ms
Execution time: 62181.186 ms

Qual poderia ser um motivo para os tempos de consulta lentos? Algo a ver com o número de buffers sendo lidos? Não consegui encontrar muita informação sobre como otimizar esse tipo específico de consulta, e as consultas são muito semelhantes às da documentação do pg_trgm.

Isso é algo que poderíamos otimizar ou implementar melhor no Postgres, ou considerar algo como o Elasticsearch seria mais adequado para esse caso de uso específico?

Christopher Orr
fonte
11
Sua versão é pg_trgmpelo menos 1.3? Você pode verificar com "\ dx" em psql.
Jjanes
Você conseguiu reproduzir qualquer consulta top-n classificada usando o <->operador que usa um índice?
Colin 'Hart
Supondo que as configurações sejam padrão, eu jogaria com o limite de similaridade. Dessa forma, você pode ficar menor resultado, talvez por isso o custo total pode ir para baixo ...
Michał Zaborowski
@jjanes Obrigado pelo ponteiro. Sim, a versão é 1.3.
Christopher Orr
11
@ MichałZaborowski Como mencionado na pergunta, tentei isso, mas infelizmente não vi nenhuma melhoria.
Christopher Orr

Respostas:

1

Você poderá obter melhor desempenho com o gin_trgm_opsque gist_trgm_ops. O que é melhor é bastante imprevisível, é sensível à distribuição de padrões e comprimentos de texto em seus dados e em seus termos de consulta. Você apenas precisa experimentá-lo e ver como funciona para você. Uma coisa é que o método GIN será bastante sensível pg_trgm.similarity_threshold, diferentemente do método GiST. Também dependerá da versão do pg_trgm que você possui. Se você começou com uma versão mais antiga do PostgreSQL, mas a atualizou pg_upgrade, pode não ter a versão mais recente. O planejador não se sai melhor ao prever qual tipo de índice é superior ao que podemos fazer. Portanto, para testá-lo, você não pode simplesmente criar os dois, é preciso largar o outro, para forçar o planejador a usar o desejado.

No caso específico da coluna de email, é melhor dividi-los em nome de usuário e domínio e, em seguida, procurar um nome de usuário semelhante com o domínio exato e vice-versa. Então a prevalência extrema dos principais provedores de e-mail na nuvem tem menos probabilidade de poluir os índices com trigramas que adicionam pouca informação.

Finalmente, qual é o caso de uso para isso? Saber por que você precisa executar essas consultas pode levar a melhores sugestões. Em particular, por que você precisaria fazer uma pesquisa de similaridade nos e-mails, depois que eles foram verificados como entregáveis ​​e indo para a pessoa correta? Talvez você possa criar um índice parcial apenas no subconjunto de e-mails que ainda não foram verificados?

jjanes
fonte
Obrigado pela informação. Vou experimentar um índice GIN e jogar com o limite. Além disso, sim, esse é um ótimo argumento sobre ter um índice parcial para endereços não verificados. No entanto, mesmo para endereços de e-mail verificados, podem ser necessárias correspondências difusas (por exemplo, pessoas esquecendo os pontos nos endereços @ gmail.com), mas esse provavelmente é o caso de ter uma tabela separada com a parte local normalizada e as colunas de domínio, como você mencionou.
Christopher Orr