Por que esse LEFT JOIN tem um desempenho muito pior do que o LEFT JOIN LATERAL?

13

Eu tenho as seguintes tabelas (extraídas do banco de dados Sakila):

  • film: film_id is pkey
  • actor: actor_id is pkey
  • film_actor: film_id e actor_id são as chaves do filme / ator

Estou selecionando um filme em particular. Para este filme, também quero que todos os atores participem desse filme. Eu tenho duas consultas para isso: uma com a LEFT JOINe outra com a LEFT JOIN LATERAL.

select film.film_id, film.title, a.actors
from   film
left join
  (         
       select     film_actor.film_id, array_agg(first_name) as actors
       from       actor
       inner join film_actor using(actor_id)
       group by   film_actor.film_id
  ) as a
on       a.film_id = film.film_id
where    film.title = 'ACADEMY DINOSAUR'
order by film.title;

select film.film_id, film.title, a.actors
from   film
left join lateral
  (
       select     array_agg(first_name) as actors
       from       actor
       inner join film_actor using(actor_id)
       where      film_actor.film_id = film.film_id
  ) as a
on       true
where    film.title = 'ACADEMY DINOSAUR'
order by film.title;

Ao comparar o plano de consulta, a primeira consulta executa muito pior (20x) que a segunda:

 Merge Left Join  (cost=507.20..573.11 rows=1 width=51) (actual time=15.087..15.089 rows=1 loops=1)
   Merge Cond: (film.film_id = film_actor.film_id)
   ->  Sort  (cost=8.30..8.31 rows=1 width=19) (actual time=0.075..0.075 rows=1 loops=1)
     Sort Key: film.film_id
     Sort Method: quicksort  Memory: 25kB
     ->  Index Scan using idx_title on film  (cost=0.28..8.29 rows=1 width=19) (actual time=0.044..0.058 rows=1 loops=1)
           Index Cond: ((title)::text = 'ACADEMY DINOSAUR'::text)
   ->  GroupAggregate  (cost=498.90..552.33 rows=997 width=34) (actual time=15.004..15.004 rows=1 loops=1)
     Group Key: film_actor.film_id
     ->  Sort  (cost=498.90..512.55 rows=5462 width=8) (actual time=14.934..14.937 rows=11 loops=1)
           Sort Key: film_actor.film_id
           Sort Method: quicksort  Memory: 449kB
           ->  Hash Join  (cost=6.50..159.84 rows=5462 width=8) (actual time=0.355..8.359 rows=5462 loops=1)
             Hash Cond: (film_actor.actor_id = actor.actor_id)
             ->  Seq Scan on film_actor  (cost=0.00..84.62 rows=5462 width=4) (actual time=0.035..2.205 rows=5462 loops=1)
             ->  Hash  (cost=4.00..4.00 rows=200 width=10) (actual time=0.303..0.303 rows=200 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 17kB
               ->  Seq Scan on actor  (cost=0.00..4.00 rows=200 width=10) (actual time=0.027..0.143 rows=200 loops=1)
 Planning time: 1.495 ms
 Execution time: 15.426 ms

 Nested Loop Left Join  (cost=25.11..33.16 rows=1 width=51) (actual time=0.849..0.854 rows=1 loops=1)
   ->  Index Scan using idx_title on film  (cost=0.28..8.29 rows=1 width=19) (actual time=0.045..0.048 rows=1 loops=1)
     Index Cond: ((title)::text = 'ACADEMY DINOSAUR'::text)
   ->  Aggregate  (cost=24.84..24.85 rows=1 width=32) (actual time=0.797..0.797 rows=1 loops=1)
     ->  Hash Join  (cost=10.82..24.82 rows=5 width=6) (actual time=0.672..0.764 rows=10 loops=1)
           Hash Cond: (film_actor.actor_id = actor.actor_id)
           ->  Bitmap Heap Scan on film_actor  (cost=4.32..18.26 rows=5 width=2) (actual time=0.072..0.150 rows=10 loops=1)
             Recheck Cond: (film_id = film.film_id)
             Heap Blocks: exact=10
             ->  Bitmap Index Scan on idx_fk_film_id  (cost=0.00..4.32 rows=5 width=0) (actual time=0.041..0.041 rows=10 loops=1)
               Index Cond: (film_id = film.film_id)
           ->  Hash  (cost=4.00..4.00 rows=200 width=10) (actual time=0.561..0.561 rows=200 loops=1)
             Buckets: 1024  Batches: 1  Memory Usage: 17kB
             ->  Seq Scan on actor  (cost=0.00..4.00 rows=200 width=10) (actual time=0.039..0.275 rows=200 loops=1)
 Planning time: 1.722 ms
 Execution time: 1.087 ms

Por que é isso? Quero aprender a raciocinar sobre isso, para entender o que está acontecendo e prever como a consulta se comportará quando o tamanho dos dados aumentar e quais decisões o planejador tomará sob determinadas condições.

Penso: na primeira LEFT JOINconsulta, parece que a subconsulta é executada para todos os filmes no banco de dados, sem levar em conta a filtragem na consulta externa de que estamos interessados ​​apenas em um filme em particular. Por que o planejador não pode ter esse conhecimento na subconsulta?

Na LEFT JOIN LATERALconsulta, estamos 'empurrando' mais ou menos essa filtragem para baixo. Portanto, o problema que tivemos na primeira consulta não está presente aqui, portanto, o melhor desempenho.

Acho que estou procurando principalmente regra de ouro, sabedoria geral, ... então essa mágica do planejador se torna uma segunda natureza - se isso faz sentido.

atualização (1)

Reescrever o LEFT JOINseguinte, também oferece melhor desempenho (um pouco melhor que o LEFT JOIN LATERAL):

select film.film_id, film.title, array_agg(a.first_name) as actors
from   film
left join
  (         
       select     film_actor.film_id, actor.first_name
       from       actor
       inner join film_actor using(actor_id)
  ) as a
on       a.film_id = film.film_id
where    film.title = 'ACADEMY DINOSAUR'
group by film.film_id
order by film.title;

 GroupAggregate  (cost=29.44..29.49 rows=1 width=51) (actual time=0.470..0.471 rows=1 loops=1)
   Group Key: film.film_id
   ->  Sort  (cost=29.44..29.45 rows=5 width=25) (actual time=0.428..0.430 rows=10 loops=1)
     Sort Key: film.film_id
     Sort Method: quicksort  Memory: 25kB
     ->  Nested Loop Left Join  (cost=4.74..29.38 rows=5 width=25) (actual time=0.149..0.386 rows=10 loops=1)
           ->  Index Scan using idx_title on film  (cost=0.28..8.29 rows=1 width=19) (actual time=0.056..0.057 rows=1 loops=1)
             Index Cond: ((title)::text = 'ACADEMY DINOSAUR'::text)
           ->  Nested Loop  (cost=4.47..19.09 rows=200 width=8) (actual time=0.087..0.316 rows=10 loops=1)
             ->  Bitmap Heap Scan on film_actor  (cost=4.32..18.26 rows=5 width=4) (actual time=0.052..0.089 rows=10 loops=1)
               Recheck Cond: (film_id = film.film_id)
               Heap Blocks: exact=10
               ->  Bitmap Index Scan on idx_fk_film_id  (cost=0.00..4.32 rows=5 width=0) (actual time=0.035..0.035 rows=10 loops=1)
                 Index Cond: (film_id = film.film_id)
             ->  Index Scan using actor_pkey on actor  (cost=0.14..0.17 rows=1 width=10) (actual time=0.011..0.011 rows=1 loops=10)
               Index Cond: (actor_id = film_actor.actor_id)
 Planning time: 1.833 ms
 Execution time: 0.706 ms

Como podemos argumentar sobre isso?

atualização (2)

Continuei com alguns experimentos e acho uma regra interessante: aplicar a função agregada o mais alto / atrasado possível . A consulta na atualização (1) provavelmente tem um desempenho melhor porque estamos agregando na consulta externa, não mais na consulta interna.

O mesmo parece se aplicar se reescrevermos o LEFT JOIN LATERALseguinte:

select film.film_id, film.title, array_agg(a.first_name) as actors
from   film
left join lateral
  (
       select     actor.first_name
       from       actor
       inner join film_actor using(actor_id)
       where      film_actor.film_id = film.film_id
  ) as a
on       true
where    film.title = 'ACADEMY DINOSAUR'
group by film.film_id
order by film.title;

 GroupAggregate  (cost=29.44..29.49 rows=1 width=51) (actual time=0.088..0.088 rows=1 loops=1)
   Group Key: film.film_id
   ->  Sort  (cost=29.44..29.45 rows=5 width=25) (actual time=0.076..0.077 rows=10 loops=1)
     Sort Key: film.film_id
     Sort Method: quicksort  Memory: 25kB
     ->  Nested Loop Left Join  (cost=4.74..29.38 rows=5 width=25) (actual time=0.031..0.066 rows=10 loops=1)
           ->  Index Scan using idx_title on film  (cost=0.28..8.29 rows=1 width=19) (actual time=0.010..0.010 rows=1 loops=1)
             Index Cond: ((title)::text = 'ACADEMY DINOSAUR'::text)
           ->  Nested Loop  (cost=4.47..19.09 rows=200 width=8) (actual time=0.019..0.052 rows=10 loops=1)
             ->  Bitmap Heap Scan on film_actor  (cost=4.32..18.26 rows=5 width=4) (actual time=0.013..0.024 rows=10 loops=1)
               Recheck Cond: (film_id = film.film_id)
               Heap Blocks: exact=10
               ->  Bitmap Index Scan on idx_fk_film_id  (cost=0.00..4.32 rows=5 width=0) (actual time=0.007..0.007 rows=10 loops=1)
                 Index Cond: (film_id = film.film_id)
             ->  Index Scan using actor_pkey on actor  (cost=0.14..0.17 rows=1 width=10) (actual time=0.002..0.002 rows=1 loops=10)
               Index Cond: (actor_id = film_actor.actor_id)
 Planning time: 0.440 ms
 Execution time: 0.136 ms

Aqui, subimos array_agg(). Como você pode ver, esse plano também é melhor que o original LEFT JOIN LATERAL.

Dito isso, não tenho certeza se essa regra prática auto-inventada ( aplique a função agregada o mais alto / tarde possível ) é verdadeira em outros casos.

informação adicional

Violino: https://dbfiddle.uk/?rdbms=postgres_10&fiddle=4ec4f2fffd969d9e4b949bb2ca765ffb

Versão: PostgreSQL 10.4 no x86_64-pc-linux-musl, compilado pelo gcc (Alpine 6.4.0) 6.4.0, 64 bits

Ambiente: Docker: docker run -e POSTGRES_PASSWORD=sakila -p 5432:5432 -d frantiseks/postgres-sakila. Observe que a imagem no hub do Docker está desatualizada, então fiz uma compilação localmente primeiro: build -t frantiseks/postgres-sakiladepois de clonar o repositório git.

Definições da tabela:

filme

 film_id              | integer                     | not null default nextval('film_film_id_seq'::regclass)
 title                | character varying(255)      | not null

 Indexes:
    "film_pkey" PRIMARY KEY, btree (film_id)
    "idx_title" btree (title)

 Referenced by:
    TABLE "film_actor" CONSTRAINT "film_actor_film_id_fkey" FOREIGN KEY (film_id) REFERENCES film(film_id) ON UPDATE CASCADE ON DELETE RESTRICT

ator

 actor_id    | integer                     | not null default nextval('actor_actor_id_seq'::regclass)
 first_name  | character varying(45)       | not null

 Indexes:
    "actor_pkey" PRIMARY KEY, btree (actor_id)

 Referenced by:
    TABLE "film_actor" CONSTRAINT "film_actor_actor_id_fkey" FOREIGN KEY (actor_id) REFERENCES actor(actor_id) ON UPDATE CASCADE ON DELETE RESTRICT

film_actor

 actor_id    | smallint                    | not null
 film_id     | smallint                    | not null

 Indexes:
    "film_actor_pkey" PRIMARY KEY, btree (actor_id, film_id)
    "idx_fk_film_id" btree (film_id)
 Foreign-key constraints:
    "film_actor_actor_id_fkey" FOREIGN KEY (actor_id) REFERENCES actor(actor_id) ON UPDATE CASCADE ON DELETE RESTRICT
    "film_actor_film_id_fkey" FOREIGN KEY (film_id) REFERENCES film(film_id) ON UPDATE CASCADE ON DELETE RESTRICT

Dados: este é do banco de dados de amostra Sakila. Esta pergunta não é um caso da vida real, estou usando esse banco de dados principalmente como um banco de dados de exemplo de aprendizado. Fui apresentado ao SQL há alguns meses e estou tentando expandir meu conhecimento. Possui as seguintes distribuições:

select count(*) from film: 1000
select count(*) from actor: 200
select avg(a) from (select film_id, count(actor_id) a from film_actor group by film_id) a: 5.47
Jelly Orns
fonte
1
Mais uma coisa: todas as informações importantes devem entrar em questão (incluindo o link do violino). Ninguém vai querer ler todos os comentários mais tarde (ou eles serão excluídos por um certo moderador muito capaz).
Erwin Brandstetter
Fiddle é adicionado à pergunta!
Jelly Orns

Respostas:

7

Configuração de teste

Sua configuração original no violino deixa espaço para melhorias. Eu ficava pedindo sua configuração por um motivo.

  • Você tem esses índices em film_actor:

    "film_actor_pkey" PRIMARY KEY, btree (actor_id, film_id)  
    "idx_fk_film_id" btree (film_id)

    O que já é bastante útil. Mas, para melhor apoiar a sua consulta particular, você teria um índice de várias colunas em (film_id, actor_id), colunas nesta ordem. Uma solução prática: substitua idx_fk_film_idpor um índice (film_id, actor_id)- ou crie a PK (film_id, actor_id)para a finalidade deste teste, como faço abaixo. Vejo:

    Em um modo somente leitura (ou principalmente, ou geralmente quando o VACUUM pode acompanhar a atividade de gravação), também ajuda a ter um índice ativado (title, film_id)para permitir varreduras somente no índice. Meu caso de teste agora está altamente otimizado para desempenho de leitura.

  • Digite incompatibilidade entre film.film_id( integer) e film_actor.film_id( smallint). Enquanto isso funciona , torna as consultas mais lentas e pode levar a várias complicações. Também torna as restrições de FK mais caras. Nunca faça isso se puder ser evitado. Se você não tem certeza, escolher integermais smallint. Embora smallint possa salvar 2 bytes por campo (geralmente consumido pelo preenchimento de alinhamento), há mais complicações do que com integer.

  • Para otimizar o desempenho do teste em si, crie índices e restrições após a inserção em massa de muitas linhas. É substancialmente mais lento adicionar tuplas incrementalmente aos índices existentes do que criá-las do zero com todas as linhas presentes.

Não relacionado a este teste:

  • Sequências independentes e padrões de coluna em vez de colunas serial(ou IDENTITY) muito mais simples e confiáveis . Não.

  • timestamp without timestampnormalmente não é confiável para uma coluna como last_update. Use em timestamptzvez disso. E observe que os padrões da coluna não cobrem a "última atualização", estritamente falando.

  • O modificador de comprimento character varying(255)indica que o caso de teste não se destina ao Postgres, porque o comprimento ímpar é bastante inútil aqui. (Ou o autor não tem noção.)

Considere o caso de teste auditado no violino:

db <> fiddle here - desenvolvendo seu fiddle, otimizado e com consultas adicionais.

Palavras-chave:

Uma configuração de teste com 1000 filmes e 200 atores tem validade limitada. As consultas mais eficientes levam <0,2 ms. O tempo de planejamento é superior ao tempo de execução. Um teste com 100 mil ou mais linhas seria mais revelador.

Por que recuperar apenas o primeiro nome dos autores? Depois de recuperar várias colunas, você já tem uma situação um pouco diferente.

ORDER BY titlenão faz sentido ao filtrar um único título com WHERE title = 'ACADEMY DINOSAUR'. Talvez ORDER BY film_id?

E para o tempo de execução total, use EXPLAIN (ANALYZE, TIMING OFF)para reduzir o ruído (potencialmente enganoso) com sobrecarga de sub-temporização.

Responda

É difícil formar uma regra simples, porque o desempenho total depende de muitos fatores. Diretrizes muito básicas:

  • A agregação de todas as linhas em sub-tabelas carrega menos sobrecarga, mas é paga apenas quando você realmente precisa de todas as linhas (ou uma parte muito grande).

  • Para selecionar poucas linhas (seu teste!), Diferentes técnicas de consulta produzem melhores resultados. É aí que LATERALentra. Ele carrega mais sobrecarga, mas apenas lê as linhas necessárias das sub-tabelas. Uma grande vitória se apenas uma fração (muito) pequena for necessária.

Para o seu caso de teste específico, eu também testaria um construtor ARRAY na LATERALsubconsulta :

SELECT f.film_id, f.title, a.actors
FROM   film
LEFT   JOIN LATERAL (
   SELECT ARRAY (
      SELECT a.first_name
      FROM   film_actor fa
      JOIN   actor a USING (actor_id)
      WHERE  fa.film_id = f.film_id
      ) AS actors
   ) a ON true
WHERE  f.title = 'ACADEMY DINOSAUR';
-- ORDER  BY f.title; -- redundant while we filter for a single title 

Embora apenas agregue uma única matriz na subconsulta lateral, um construtor ARRAY simples executa melhor que a função agregada array_agg(). Vejo:

Ou com uma subconsulta pouco correlacionada para o caso simples:

SELECT f.film_id, f.title
     , ARRAY (SELECT a.first_name
              FROM   film_actor fa
              JOIN   actor a USING (actor_id)
              WHERE  fa.film_id = f.film_id) AS actors
FROM   film f
WHERE  f.title = 'ACADEMY DINOSAUR';

Ou, basicamente, apenas 2x LEFT JOINe depois agregue :

SELECT f.film_id, f.title, array_agg(a.first_name) AS actors
FROM   film f
LEFT   JOIN film_actor fa USING (film_id)
LEFT   JOIN actor a USING (actor_id)
WHERE  f.title = 'ACADEMY DINOSAUR'
GROUP  BY f.film_id;

Esses três parecem mais rápidos no meu violino atualizado (planejamento + tempo de execução).

Sua primeira tentativa (apenas ligeiramente modificada) geralmente é mais rápida para recuperar todos ou a maioria dos filmes , mas não para uma pequena seleção:

SELECT f.film_id, f.title, a.actors
FROM   film f
LEFT   JOIN (         
   SELECT fa.film_id, array_agg(first_name) AS actors
   FROM   actor
   JOIN   film_actor fa USING (actor_id)
   GROUP  by fa.film_id
   ) a USING (film_id)
WHERE  f.title = 'ACADEMY DINOSAUR';  -- not good for a single (or few) films!

Testes com cardinalidades muito maiores serão mais reveladores. E não generalize os resultados de ânimo leve, há muitos fatores para o desempenho total.

Erwin Brandstetter
fonte