Dlaczego plany są różne, jeśli zapytania są logicznie podobne?

19

Napisałem dwie funkcje, aby odpowiedzieć na pierwsze zadanie domowe z trzeciego dnia z Siedmiu baz danych w siedmiu tygodniach .

Utwórz procedurę składowaną, w której możesz wpisać tytuł filmu lub nazwisko aktora, które ci się podoba, a zwróci pięć najlepszych sugestii na podstawie filmów, w których aktor występował, lub filmów o podobnych gatunkach.

Moja pierwsza próba jest poprawna, ale powolna. Zwrócenie wyniku może zająć do 2000 ms.

CREATE OR REPLACE FUNCTION suggest_movies(IN query text, IN result_limit integer DEFAULT 5)
  RETURNS TABLE(movie_id integer, title text) AS
$BODY$
WITH suggestions AS (

  SELECT
    actors.name AS entity_term,
    movies.movie_id AS suggestion_id,
    movies.title AS suggestion_title,
    1 AS rank
  FROM actors
  INNER JOIN movies_actors ON (actors.actor_id = movies_actors.actor_id)
  INNER JOIN movies ON (movies.movie_id = movies_actors.movie_id)

  UNION ALL

  SELECT
    searches.title AS entity_term,
    suggestions.movie_id AS suggestion_id,
    suggestions.title AS suggestion_title,
    RANK() OVER (PARTITION BY searches.movie_id ORDER BY cube_distance(searches.genre, suggestions.genre)) AS rank
  FROM movies AS searches
  INNER JOIN movies AS suggestions ON
    (searches.movie_id <> suggestions.movie_id) AND
    (cube_enlarge(searches.genre, 2, 18) @> suggestions.genre)
)
SELECT suggestion_id, suggestion_title
FROM suggestions
WHERE entity_term = query
ORDER BY rank, suggestion_id
LIMIT result_limit;
$BODY$
LANGUAGE sql;

Moja druga próba jest poprawna i szybka. Zoptymalizowałem go, popychając filtr z CTE do każdej części związku.

Usunąłem ten wiersz z zewnętrznego zapytania:

WHERE entity_term = query

Dodałem tę linię do pierwszego wewnętrznego zapytania:

WHERE actors.name = query

Dodałem tę linię do drugiego zapytania wewnętrznego:

WHERE movies.title = query

Druga funkcja zwraca około 10 ms, aby uzyskać ten sam wynik.

Nic nie różni się w bazie danych poza definicjami funkcji.

Dlaczego PostgreSQL tworzy tak różne plany dla tych dwóch logicznie równoważnych zapytań?

EXPLAIN ANALYZEPlan pierwszej funkcji wygląda następująco:

                                                                                       Limit  (cost=7774.18..7774.19 rows=5 width=44) (actual time=1738.566..1738.567 rows=5 loops=1)
   CTE suggestions
     ->  Append  (cost=332.56..7337.19 rows=19350 width=285) (actual time=7.113..1577.823 rows=383024 loops=1)
           ->  Subquery Scan on "*SELECT* 1"  (cost=332.56..996.80 rows=11168 width=33) (actual time=7.113..22.258 rows=11168 loops=1)
                 ->  Hash Join  (cost=332.56..885.12 rows=11168 width=33) (actual time=7.110..19.850 rows=11168 loops=1)
                       Hash Cond: (movies_actors.movie_id = movies.movie_id)
                       ->  Hash Join  (cost=143.19..514.27 rows=11168 width=18) (actual time=4.326..11.938 rows=11168 loops=1)
                             Hash Cond: (movies_actors.actor_id = actors.actor_id)
                             ->  Seq Scan on movies_actors  (cost=0.00..161.68 rows=11168 width=8) (actual time=0.013..1.648 rows=11168 loops=1)
                             ->  Hash  (cost=80.86..80.86 rows=4986 width=18) (actual time=4.296..4.296 rows=4986 loops=1)
                                   Buckets: 1024  Batches: 1  Memory Usage: 252kB
                                   ->  Seq Scan on actors  (cost=0.00..80.86 rows=4986 width=18) (actual time=0.009..1.681 rows=4986 loops=1)
                       ->  Hash  (cost=153.61..153.61 rows=2861 width=19) (actual time=2.768..2.768 rows=2861 loops=1)
                             Buckets: 1024  Batches: 1  Memory Usage: 146kB
                             ->  Seq Scan on movies  (cost=0.00..153.61 rows=2861 width=19) (actual time=0.003..1.197 rows=2861 loops=1)
           ->  Subquery Scan on "*SELECT* 2"  (cost=6074.48..6340.40 rows=8182 width=630) (actual time=1231.324..1528.188 rows=371856 loops=1)
                 ->  WindowAgg  (cost=6074.48..6258.58 rows=8182 width=630) (actual time=1231.324..1492.106 rows=371856 loops=1)
                       ->  Sort  (cost=6074.48..6094.94 rows=8182 width=630) (actual time=1231.307..1282.550 rows=371856 loops=1)
                             Sort Key: searches.movie_id, (cube_distance(searches.genre, suggestions_1.genre))
                             Sort Method: external sort  Disk: 21584kB
                             ->  Nested Loop  (cost=0.27..3246.72 rows=8182 width=630) (actual time=0.047..909.096 rows=371856 loops=1)
                                   ->  Seq Scan on movies searches  (cost=0.00..153.61 rows=2861 width=315) (actual time=0.003..0.676 rows=2861 loops=1)
                                   ->  Index Scan using movies_genres_cube on movies suggestions_1  (cost=0.27..1.05 rows=3 width=315) (actual time=0.016..0.277 rows=130 loops=2861)
                                         Index Cond: (cube_enlarge(searches.genre, 2::double precision, 18) @> genre)
                                         Filter: (searches.movie_id <> movie_id)
                                         Rows Removed by Filter: 1
   ->  Sort  (cost=436.99..437.23 rows=97 width=44) (actual time=1738.565..1738.566 rows=5 loops=1)
         Sort Key: suggestions.rank, suggestions.suggestion_id
         Sort Method: top-N heapsort  Memory: 25kB
         ->  CTE Scan on suggestions  (cost=0.00..435.38 rows=97 width=44) (actual time=1281.905..1738.531 rows=43 loops=1)
               Filter: (entity_term = 'Die Hard'::text)
               Rows Removed by Filter: 382981
 Total runtime: 1746.623 ms

EXPLAIN ANALYZEPlan drugiego zapytania wygląda następująco:

 Limit  (cost=43.74..43.76 rows=5 width=44) (actual time=1.231..1.234 rows=5 loops=1)
   CTE suggestions
     ->  Append  (cost=4.86..43.58 rows=5 width=391) (actual time=1.029..1.141 rows=43 loops=1)
           ->  Subquery Scan on "*SELECT* 1"  (cost=4.86..20.18 rows=2 width=33) (actual time=0.047..0.047 rows=0 loops=1)
                 ->  Nested Loop  (cost=4.86..20.16 rows=2 width=33) (actual time=0.047..0.047 rows=0 loops=1)
                       ->  Nested Loop  (cost=4.58..19.45 rows=2 width=18) (actual time=0.045..0.045 rows=0 loops=1)
                             ->  Index Scan using actors_name on actors  (cost=0.28..8.30 rows=1 width=18) (actual time=0.045..0.045 rows=0 loops=1)
                                   Index Cond: (name = 'Die Hard'::text)
                             ->  Bitmap Heap Scan on movies_actors  (cost=4.30..11.13 rows=2 width=8) (never executed)
                                   Recheck Cond: (actor_id = actors.actor_id)
                                   ->  Bitmap Index Scan on movies_actors_actor_id  (cost=0.00..4.30 rows=2 width=0) (never executed)
                                         Index Cond: (actor_id = actors.actor_id)
                       ->  Index Scan using movies_pkey on movies  (cost=0.28..0.35 rows=1 width=19) (never executed)
                             Index Cond: (movie_id = movies_actors.movie_id)
           ->  Subquery Scan on "*SELECT* 2"  (cost=23.31..23.40 rows=3 width=630) (actual time=0.982..1.081 rows=43 loops=1)
                 ->  WindowAgg  (cost=23.31..23.37 rows=3 width=630) (actual time=0.982..1.064 rows=43 loops=1)
                       ->  Sort  (cost=23.31..23.31 rows=3 width=630) (actual time=0.963..0.971 rows=43 loops=1)
                             Sort Key: searches.movie_id, (cube_distance(searches.genre, suggestions_1.genre))
                             Sort Method: quicksort  Memory: 28kB
                             ->  Nested Loop  (cost=4.58..23.28 rows=3 width=630) (actual time=0.808..0.916 rows=43 loops=1)
                                   ->  Index Scan using movies_title on movies searches  (cost=0.28..8.30 rows=1 width=315) (actual time=0.025..0.027 rows=1 loops=1)
                                         Index Cond: (title = 'Die Hard'::text)
                                   ->  Bitmap Heap Scan on movies suggestions_1  (cost=4.30..14.95 rows=3 width=315) (actual time=0.775..0.844 rows=43 loops=1)
                                         Recheck Cond: (cube_enlarge(searches.genre, 2::double precision, 18) @> genre)
                                         Filter: (searches.movie_id <> movie_id)
                                         Rows Removed by Filter: 1
                                         ->  Bitmap Index Scan on movies_genres_cube  (cost=0.00..4.29 rows=3 width=0) (actual time=0.750..0.750 rows=44 loops=1)
                                               Index Cond: (cube_enlarge(searches.genre, 2::double precision, 18) @> genre)
   ->  Sort  (cost=0.16..0.17 rows=5 width=44) (actual time=1.230..1.231 rows=5 loops=1)
         Sort Key: suggestions.rank, suggestions.suggestion_id
         Sort Method: top-N heapsort  Memory: 25kB
         ->  CTE Scan on suggestions  (cost=0.00..0.10 rows=5 width=44) (actual time=1.034..1.187 rows=43 loops=1)
 Total runtime: 1.410 ms
Iain Samuel McLean Elder
źródło

Odpowiedzi:

21

Brak automatycznego przekazywania predykatów dla CTE

PostgreSQL 9.3 nie wykonuje przepychania predykatów dla CTE.

Optymalizator, który wykonuje przepychanie predykatów, może przenosić klauzule do zapytań wewnętrznych. Celem jest jak najwcześniejsze odfiltrowanie nieistotnych danych. Tak długo, jak nowe zapytanie jest logicznie równoważne, silnik nadal pobiera wszystkie odpowiednie dane, więc generuje poprawny wynik, tylko szybciej.

Główny programista, Tom Lane, nawiązuje do trudności w określeniu logicznej równoważności na liście mailingowej pgsql .

CTE są również traktowane jako ogrodzenia optymalizacyjne; jest to nie tyle ograniczenie optymalizatora, co utrzymanie rozsądnej semantyki, gdy CTE zawiera zapisywalne zapytanie.

Optymalizator nie rozróżnia CTE tylko do odczytu od zapisywalnych, więc jest zbyt konserwatywny, jeśli chodzi o plany. Obróbka „ogrodzenia” powstrzymuje optymalizator przed przesunięciem klauzuli where wewnątrz CTE, chociaż widzimy, że jest to bezpieczne.

Możemy poczekać, aż zespół PostgreSQL ulepszy optymalizację CTE, ale na razie, aby uzyskać dobrą wydajność, musisz zmienić styl pisania.

Przepisz dla wydajności

Pytanie pokazuje już jeden sposób na uzyskanie lepszego planu. Powielenie warunku filtru zasadniczo utrwala efekt przepychania predykatu.

W obu planach silnik kopiuje wiersze wyników do stołu roboczego, aby je posortować. Im większy stół roboczy, tym wolniejsze jest zapytanie.

Pierwszy plan kopiuje wszystkie wiersze w tabelach podstawowych do stołu roboczego i skanuje je, aby znaleźć wynik. Aby wszystko działało jeszcze wolniej, silnik musi przeskanować cały stół roboczy, ponieważ nie ma indeksów.

To niedorzeczna ilość niepotrzebnej pracy. Odczytuje wszystkie dane w tabelach podstawowych dwa razy, aby znaleźć odpowiedź, gdy jest tylko 5 pasujących wierszy z szacowanych 19350 wierszy w tabelach podstawowych.

Drugi plan wykorzystuje indeksy do znajdowania pasujących wierszy i kopiuje tylko te do stołu roboczego. Indeks skutecznie przefiltrował dla nas dane.

Na stronie 85 „The Art of SQL” Stéphane Faroult przypomina nam o oczekiwaniach użytkowników.

W bardzo dużym stopniu użytkownicy końcowi dostosowują swoją cierpliwość do oczekiwanej liczby rzędów: gdy proszą o jedną igłę, nie zwracają uwagi na rozmiar stogu siana.

Drugi plan skaluje się za pomocą igły, więc bardziej prawdopodobne jest, że użytkownicy będą zadowoleni.

Przepisz na potrzeby konserwacji

Nowe zapytanie jest trudniejsze do utrzymania, ponieważ można wprowadzić defekt, zmieniając epxresję jednego filtra, ale nie drugi.

Czy nie byłoby wspaniale, gdybyśmy mogli napisać wszystko tylko raz i nadal osiągać dobre wyniki?

Możemy. Optymalizator przewiduje przepychanie dla podkwerend.

Prostszy przykład jest łatwiejszy do wyjaśnienia.

CREATE TABLE a (c INT);

CREATE TABLE b (c INT);

CREATE INDEX a_c ON a(c);

CREATE INDEX b_c ON b(c);

INSERT INTO a SELECT 1 FROM generate_series(1, 1000000);

INSERT INTO b SELECT 2 FROM a;

INSERT INTO a SELECT 3;

Tworzy to dwie tabele, każda z indeksowaną kolumną. Razem zawierają milion 1s, milion 2si jeden 3.

Możesz znaleźć igłę 3za pomocą jednego z tych zapytań.

-- CTE
EXPLAIN ANALYZE
WITH cte AS (
  SELECT c FROM a
  UNION ALL
  SELECT c FROM b
)
SELECT c FROM cte WHERE c = 3;

-- Subquery
EXPLAIN ANALYZE
SELECT c
FROM (
  SELECT c FROM a
  UNION ALL
  SELECT c FROM b
) AS subquery
WHERE c = 3;

Plan CTE jest powolny. Silnik skanuje trzy tabele i odczytuje około czterech milionów wierszy. Zajmuje to prawie 1000 milisekund.

CTE Scan on cte  (cost=33275.00..78275.00 rows=10000 width=4) (actual time=471.412..943.225 rows=1 loops=1)
  Filter: (c = 3)
  Rows Removed by Filter: 2000000
  CTE cte
    ->  Append  (cost=0.00..33275.00 rows=2000000 width=4) (actual time=0.011..409.573 rows=2000001 loops=1)
          ->  Seq Scan on a  (cost=0.00..14425.00 rows=1000000 width=4) (actual time=0.010..114.869 rows=1000001 loops=1)
          ->  Seq Scan on b  (cost=0.00..18850.00 rows=1000000 width=4) (actual time=5.530..104.674 rows=1000000 loops=1)
Total runtime: 948.594 ms

Plan podkwerendy jest szybki. Silnik szuka tylko każdego indeksu. Zajmuje to mniej niż milisekundę.

Append  (cost=0.42..8.88 rows=2 width=4) (actual time=0.021..0.038 rows=1 loops=1)
  ->  Index Only Scan using a_c on a  (cost=0.42..4.44 rows=1 width=4) (actual time=0.020..0.021 rows=1 loops=1)
        Index Cond: (c = 3)
        Heap Fetches: 1
  ->  Index Only Scan using b_c on b  (cost=0.42..4.44 rows=1 width=4) (actual time=0.016..0.016 rows=0 loops=1)
        Index Cond: (c = 3)
        Heap Fetches: 0
Total runtime: 0.065 ms

Zobacz SQLFiddle dla interaktywnej wersji.

Iain Samuel McLean Elder
źródło
0

Plany są takie same w Postgres 12

Pytanie zadane na temat Postgres 9.3. Pięć lat później ta wersja jest przestarzała, ale co się zmieniło?

PostgreSQL 12 teraz wstawia takie CTE.

Wstawione Z zapytaniami (wspólne wyrażenia tabelowe)

Typowe wyrażenia tabelowe (zwane także WITHzapytaniami) można teraz automatycznie wstawiać w zapytaniu, jeśli a) nie są rekurencyjne, b) nie mają żadnych skutków ubocznych ic) są przywoływane tylko raz w późniejszej części zapytania. Usuwa to „płot optymalizacyjny”, który istniał od czasu wprowadzenia WITHklauzuli w PostgreSQL 8.4

W razie potrzeby możesz zmusić zapytanie WITH do zmaterializowania się za pomocą klauzuli MATERIALIZED, np

WITH c AS MATERIALIZED ( SELECT * FROM a WHERE a.x % 4 = 0 ) SELECT * FROM c JOIN d ON d.y = a.x;
Iain Samuel McLean Elder
źródło