Dlaczego to LEFT JOIN działa o wiele gorzej niż LEFT JOIN LATERAL?

13

Mam następujące tabele (zaczerpnięte z bazy danych Sakila):

  • film: film_id jest kluczem
  • aktor: actor_id to pkey
  • film_actor: film_id i actor_id to klucze do filmu / aktora

Wybieram konkretny film. W tym filmie chcę też, aby wszyscy aktorzy uczestniczyli w tym filmie. Mam na to dwa pytania: jedno z LEFT JOINa drugie z 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;

Podczas porównywania planu zapytań pierwsze zapytanie działa znacznie gorzej (20x) niż drugie:

 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

Dlaczego to? Chcę nauczyć się rozumować na ten temat, aby móc zrozumieć, co się dzieje, i przewidzieć, jak zachowa się zapytanie, gdy wzrośnie rozmiar danych i jakie decyzje planista podejmie w określonych warunkach.

Moje przemyślenia: w pierwszym LEFT JOINzapytaniu wygląda na to, że podzapytanie jest wykonywane dla wszystkich filmów w bazie danych, bez uwzględnienia filtrowania w zewnętrznym zapytaniu, że jesteśmy zainteresowani tylko jednym konkretnym filmem. Dlaczego planista nie może mieć tej wiedzy w podzapytaniu?

W LEFT JOIN LATERALzapytaniu mniej więcej „popychamy” to filtrowanie w dół. Problem, który mieliśmy w pierwszym zapytaniu, nie jest tutaj obecny, stąd lepsza wydajność.

Chyba przede wszystkim szukam zasady kciuka, ogólnej mądrości ... więc magia planisty staje się drugą naturą - jeśli to ma sens.

aktualizacja (1)

Przepisanie w LEFT JOINnastępujący sposób również daje lepszą wydajność (nieco lepszą niż 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

Jak możemy o tym myśleć?

aktualizacja (2)

Kontynuowałem niektóre eksperymenty i myślę, że interesująca zasada jest następująca: zastosuj funkcję agregującą tak wysoko / późno, jak to możliwe . Zapytanie w aktualizacji (1) prawdopodobnie działa lepiej, ponieważ agregujemy w zapytaniu zewnętrznym, a nie wewnętrznym.

To samo wydaje się obowiązywać, jeśli przepisujemy LEFT JOIN LATERALpowyższe w następujący sposób:

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

Tutaj ruszyliśmy w array_agg()górę. Jak widać, ten plan jest również lepszy niż oryginał LEFT JOIN LATERAL.

To powiedziawszy, nie jestem pewien, czy ta wymyślona przez ciebie zasada ( zastosuj funkcję agregującą tak wysoko / późno, jak to możliwe ) jest prawdziwa w innych przypadkach.

Dodatkowe informacje

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

Wersja: PostgreSQL 10.4 na x86_64-pc-linux-musl, skompilowany przez gcc (Alpine 6.4.0) 6.4.0, 64-bit

Środowisko: Docker: docker run -e POSTGRES_PASSWORD=sakila -p 5432:5432 -d frantiseks/postgres-sakila. Pamiętaj, że obraz w Docker Hub jest nieaktualny, więc najpierw wykonałem kompilację lokalnie: build -t frantiseks/postgres-sakilapo klonowaniu repozytorium git.

Definicje tabel:

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

aktor

 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

aktor filmowy

 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

Dane: pochodzi z przykładowej bazy danych Sakila. To pytanie nie jest prawdziwym przypadkiem, używam tej bazy danych głównie jako przykładowej bazy danych do nauki. Kilka miesięcy temu zapoznałem się z SQL i staram się poszerzać swoją wiedzę. Ma następujące dystrybucje:

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
źródło
1
Jeszcze jedno: wszystkie ważne informacje powinny przejść do pytania (łącznie z linkiem do skrzypiec). Nikt nie będzie chciał później czytać wszystkich komentarzy (lub są one usuwane przez pewnego bardzo sprawnego moderatora).
Erwin Brandstetter,
Fiddle jest dodany do pytania!
Jelly Orns,

Odpowiedzi:

7

Konfiguracja testowa

Twoja oryginalna konfiguracja w skrzypcach pozostawia miejsce na ulepszenia. Ciągle pytałem o twoją konfigurację z jakiegoś powodu.

  • Masz te indeksy na film_actor:

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

    Co już jest dość pomocne. Ale do najlepszej obsługi określonej kwerendy, można mieć indeks wielokolumnowej on (film_id, actor_id), kolumny w tej kolejności. Praktyczne rozwiązanie: zamień idx_fk_film_idz indeksem na (film_id, actor_id)- lub utwórz PK na (film_id, actor_id)potrzeby tego testu, tak jak poniżej. Widzieć:

    W trybie tylko do odczytu (lub przeważnie lub ogólnie, gdy VACUUM może nadążyć za działaniami związanymi z zapisem), pomocne jest także włączenie indeksu, (title, film_id)aby umożliwić skanowanie tylko indeksu. Mój przypadek testowy jest teraz wysoce zoptymalizowany pod kątem wydajności odczytu.

  • Wpisz niezgodność między film.film_id( integer) a film_actor.film_id( smallint). Chociaż to działa , spowalnia zapytania i może prowadzić do różnych komplikacji. Zwiększa także ograniczenia FK. Nigdy tego nie rób, jeśli można tego uniknąć. Jeżeli nie jesteś pewien, pick integernad smallint. Chociaż smallint można zapisać 2 bajty na pole (często zużywane przez wypełnienie wyrównania), jest więcej komplikacji niż z integer.

  • Aby zoptymalizować wydajność samego testu, utwórz indeksy i ograniczenia po masowym wstawieniu wielu wierszy. Znacznie wolniej jest dodawać krotki przyrostowo do istniejących indeksów, niż tworzyć je od zera ze wszystkimi obecnymi wierszami.

Niepowiązane z tym testem:

  • Wolnostojące sekwencje plus ustawienia domyślne kolumn zamiast znacznie prostszych i bardziej niezawodnych serial(lub IDENTITY) kolumn. Nie rób

  • timestamp without timestampjest zwykle zawodny w przypadku kolumny podobnej do last_update. Użyj timestamptzzamiast tego. I zwróć uwagę, że ustawienia domyślne kolumny nie obejmują „ostatniej aktualizacji”, ściśle mówiąc.

  • Modyfikator długości character varying(255)wskazuje, że przypadek testowy nie jest przeznaczony dla Postgres, ponieważ nieparzysta długość jest tutaj raczej bezcelowa. (Lub autor nie ma pojęcia.)

Rozważ zbadany przypadek testowy w skrzypcach:

db <> skrzypce tutaj - budowanie na skrzypcach, zoptymalizowane i z dodanymi zapytaniami.

Związane z:

Konfiguracja testowa z 1000 filmów i 200 aktorów ma ograniczoną ważność. Najbardziej wydajne zapytania trwają <0,2 ms. Czas planowania to więcej niż czas realizacji. Test z 100k lub więcej rzędami byłby bardziej odkrywczy.

Po co pobierać tylko imiona autorów? Po pobraniu wielu kolumn masz już nieco inną sytuację.

ORDER BY titlenie ma sensu podczas filtrowania pojedynczego tytułu za pomocą WHERE title = 'ACADEMY DINOSAUR'. Może ORDER BY film_id?

A dla całkowitego czasu działania raczej używaj EXPLAIN (ANALYZE, TIMING OFF)do zmniejszania (potencjalnie wprowadzającego w błąd) hałasu przy obciążeniu sub-timingiem.

Odpowiedź

Trudno jest sformułować prostą zasadę, ponieważ całkowita wydajność zależy od wielu czynników. Bardzo podstawowe wytyczne:

  • Agregacja wszystkich wierszy w podtabelach niesie narzut, ale płaci tylko wtedy, gdy faktycznie potrzebujesz wszystkich wierszy (lub bardzo dużej części).

  • Aby wybrać kilka wierszy (twój test!), Różne techniki zapytań dają lepsze wyniki. Tam właśnie LATERALpojawia się. To niesie narzut, ale czyta tylko wymagane wiersze z pod-tabel. Duża wygrana, jeśli potrzebna jest tylko (bardzo) niewielka część.

W przypadku konkretnego przypadku testowego przetestowałbym również konstruktor ARRAY w LATERALpodzapytaniu :

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 

Podczas gdy agreguje tylko jedną tablicę w podzapytaniu bocznym, prosty konstruktor ARRAY działa lepiej niż funkcja agregująca array_agg(). Widzieć:

Lub z mało skorelowanym podzapytaniem dla prostego przypadku:

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

Lub, w zasadzie, tylko 2x, LEFT JOINa następnie agreguj :

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;

Te trzy wydają się najszybsze w moim zaktualizowanym skrzypcach (planowanie + czas wykonania).

Twoja pierwsza próba (tylko nieznacznie zmodyfikowana) jest zazwyczaj najszybsza w celu odzyskania wszystkich lub większości filmów , ale nie w przypadku niewielkiego wyboru:

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!

Testy ze znacznie większymi licznościami będą bardziej odkrywcze. I nie uogólniaj lekko wyników, istnieje wiele czynników wpływających na całkowitą wydajność.

Erwin Brandstetter
źródło