Perché questo LEFT JOIN ha prestazioni molto peggiori di LEFT JOIN LATERAL?


13

Ho le seguenti tabelle (tratte dal database Sakila):

  • film: film_id è pkey
  • attore: actor_id è pkey
  • film_actor: film_id e actor_id sono fkey per il film / attore

Sto selezionando un film particolare. Per questo film, voglio anche che tutti gli attori partecipino a quel film. Ho due domande per questo: una con a LEFT JOINe una con 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;

Quando si confronta il piano di query, la prima query ha prestazioni molto peggiori (20x) rispetto alla seconda:

 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

Perchè è questo? Voglio imparare a ragionare su questo, così posso capire cosa sta succedendo e posso prevedere come si comporterà la query quando la dimensione dei dati aumenta e quali decisioni prenderà il pianificatore in determinate condizioni.

I miei pensieri: nella prima LEFT JOINquery, sembra che la sottoquery venga eseguita per tutti i film nel database, senza tener conto del filtro nella query esterna che ci interessa solo un film in particolare. Perché il pianificatore non è in grado di avere questa conoscenza nella sottoquery?

Nella LEFT JOIN LATERALquery, stiamo "spingendo" più o meno quel filtro verso il basso. Quindi il problema che abbiamo avuto nella prima query non è presente qui, quindi le prestazioni migliori.

Immagino che sto principalmente cercando regola dei pollici, saggezza generale, ... quindi questa magia planner diventa una seconda natura - se ha senso.

aggiornamento (1)

Riscrivere LEFT JOINquanto segue offre anche prestazioni migliori (leggermente migliori di 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

Come possiamo ragionare su questo?

aggiornamento (2)

Ho continuato con alcuni esperimenti e penso che una regola empirica interessante sia: applicare la funzione aggregata il più in alto / in ritardo possibile . La query in update (1) probabilmente funziona meglio perché stiamo aggregando nella query esterna, non più nella query interna.

Lo stesso sembra valere se riscriviamo quanto LEFT JOIN LATERALsopra come segue:

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

Qui, ci siamo spostati array_agg()verso l'alto. Come puoi vedere, questo piano è anche migliore dell'originale LEFT JOIN LATERAL.

Detto questo, non sono sicuro che questa regola empirica auto-inventata ( applica la funzione aggregata il più alto / tardi possibile ) sia vera in altri casi.

Informazioni aggiuntive

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

Versione: PostgreSQL 10.4 su x86_64-pc-linux-musl, compilato da gcc (Alpine 6.4.0) 6.4.0, 64-bit

Ambiente: Docker: docker run -e POSTGRES_PASSWORD=sakila -p 5432:5432 -d frantiseks/postgres-sakila. Si noti che l'immagine sull'hub Docker è obsoleta, quindi prima ho creato una build locale: build -t frantiseks/postgres-sakiladopo aver clonato il repository git.

Definizioni delle tabelle:

film

 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

attore

 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

Dati: questo proviene dal database di esempio di Sakila. Questa domanda non è un caso reale, sto usando questo database principalmente come un database di esempio di apprendimento. Sono stato introdotto a SQL alcuni mesi fa e sto cercando di espandere le mie conoscenze. Ha le seguenti distribuzioni:

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

1
Ancora una cosa: tutte le informazioni importanti dovrebbero andare nella domanda (incluso il tuo link violino). Nessuno vorrà leggere tutti i commenti in seguito (o comunque vengono eliminati da un certo moderatore molto abile).
Erwin Brandstetter,

Il violino viene aggiunto alla domanda!
Jelly Orns,

Risposte:


7

Configurazione di prova

L'impostazione originale nel violino lascia spazio a miglioramenti. Ho continuato a chiedere la tua configurazione per un motivo.

  • Hai questi indici su film_actor:

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

    Che è già abbastanza utile. Ma per supportare al meglio la tua query particolare, avresti un indice a più (film_id, actor_id)colonne attivo su colonne in questo ordine. Una soluzione pratica: sostituisci idx_fk_film_idcon un indice attivo (film_id, actor_id)o crea il PK attivo (film_id, actor_id)ai fini di questo test, come faccio di seguito. Vedere:

    In sola lettura (o principalmente, o generalmente quando VACUUM può tenere il passo con l'attività di scrittura) aiuta anche ad avere un indice attivo (title, film_id)per consentire solo scansioni dell'indice. Il mio test case è ora altamente ottimizzato per le prestazioni di lettura.

  • Digitare la mancata corrispondenza tra film.film_id( integer) e film_actor.film_id( smallint). Mentre funziona , rende le query più lente e può portare a varie complicazioni. Inoltre rende i vincoli FK più costosi. Non farlo mai se può essere evitato. Se non si è sicuri, scegliere integersopra smallint. Mentre smallint può salvare 2 byte per campo (spesso consumato dall'imbottitura di allineamento) ci sono più complicazioni che con integer.

  • Per ottimizzare le prestazioni del test stesso, creare indici e vincoli dopo aver inserito in blocco molte righe. È sostanzialmente più lento aggiungere tuple in modo incrementale agli indici esistenti piuttosto che crearle da zero con tutte le righe presenti.

Non correlato a questo test:

  • Sequenze indipendenti più valori predefiniti di colonna anziché colonne molto più semplici e più affidabili serial(o IDENTITY). Non farlo.

  • timestamp without timestampè in genere inaffidabile per una colonna come last_update. Usa timestamptzinvece. E si noti che i valori predefiniti di colonna non coprono "l'ultimo aggiornamento", a rigor di termini.

  • Il modificatore di lunghezza in character varying(255)indica che il caso di test non è destinato a Postgres perché la lunghezza dispari qui è piuttosto inutile. (O l'autore è all'oscuro.)

Considera il caso di test verificato nel violino:

db <> fiddle qui - costruendo sul tuo violino, ottimizzato e con query aggiunte.

Relazionato:

Una configurazione di prova con 1000 film e 200 attori ha una validità limitata. Le query più efficienti richiedono <0,2 ms. Il tempo di pianificazione è superiore al tempo di esecuzione. Un test con 100k o più righe sarebbe più rivelatore.

Perché recuperare solo i nomi degli autori? Dopo aver recuperato più colonne, hai già una situazione leggermente diversa.

ORDER BY titlenon ha senso filtrando un singolo titolo con WHERE title = 'ACADEMY DINOSAUR'. Forse ORDER BY film_id?

E per un tempo di esecuzione totale, utilizzare piuttosto EXPLAIN (ANALYZE, TIMING OFF)per ridurre il rumore (potenzialmente fuorviante) con sovraccarico del sub-timing.

Risposta

È difficile formare una semplice regola empirica, perché le prestazioni totali dipendono da molti fattori. Linee guida molto basilari:

  • L'aggregazione di tutte le righe nelle sotto-tabelle comporta un sovraccarico minore ma paga solo quando in realtà sono necessarie tutte le righe (o una parte molto grande).

  • Per selezionare poche righe (il tuo test!), Diverse tecniche di query producono risultati migliori. Ecco dove LATERALentra in gioco. Trasporta più overhead ma legge solo le righe richieste dalle sotto-tabelle. Una grande vittoria se è necessaria solo una (molto) piccola frazione.

Per il tuo particolare test case, testerei anche un costruttore ARRAY nella LATERALsottoquery :

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 

Mentre aggrega solo un singolo array nella sottoquery laterale, un semplice costruttore ARRAY esegue meglio della funzione aggregata array_agg(). Vedere:

O con una subquery poco correlata per il caso semplice:

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

O, in sostanza, solo 2x LEFT JOINe quindi aggregare :

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;

Questi tre sembrano più veloci nel mio violino aggiornato (pianificazione + tempo di esecuzione).

Il tuo primo tentativo (solo leggermente modificato) è in genere più veloce per recuperare tutti o la maggior parte dei film , ma non per una piccola selezione:

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!

I test con cardinalità molto maggiori saranno più rivelatori. E non generalizzare i risultati alla leggera, ci sono molti fattori per le prestazioni totali.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.