Perché i piani sono diversi se le query sono logicamente simili?


19

Ho scritto due funzioni per rispondere alla prima domanda dei compiti del Day 3 da Seven D Database in Seven Weeks .

Crea una procedura memorizzata in cui puoi inserire il titolo di un film o il nome dell'attore che ti piace e restituirà i primi cinque suggerimenti basati sui film in cui l'attore ha recitato o su film con generi simili.

Il mio primo tentativo è corretto ma lento. Possono essere necessari fino a 2000 ms per restituire un risultato.

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;

Il mio secondo tentativo è corretto e veloce. L'ho ottimizzato spingendo il filtro verso il basso dal CTE in ogni parte del raccordo.

Ho rimosso questa riga dalla query esterna:

WHERE entity_term = query

Ho aggiunto questa riga alla prima query interna:

WHERE actors.name = query

Ho aggiunto questa riga alla seconda query interna:

WHERE movies.title = query

La seconda funzione impiega circa 10ms per restituire lo stesso risultato.

Nulla differisce nel database a parte le definizioni delle funzioni.

Perché PostgreSQL produce piani così diversi per queste due query logicamente equivalenti?

Il EXPLAIN ANALYZEpiano della prima funzione è simile al seguente:

                                                                                       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

Il EXPLAIN ANALYZEpiano della seconda query è simile al seguente:

 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

Risposte:


21

Nessun pushdown predicato automatico per CTE

PostgreSQL 9.3 non esegue il pushdown predicato per CTE.

Un ottimizzatore che esegue il pushdown predicato può spostare le clausole nelle query interne. L'obiettivo è filtrare i dati irrilevanti il ​​prima possibile. Finché la nuova query è logicamente equivalente, il motore recupera comunque tutti i dati rilevanti, quindi produce il risultato corretto, solo più rapidamente.

Lo sviluppatore principale Tom Lane allude alla difficoltà di determinare l'equivalenza logica sulla mailing list delle prestazioni pgsql .

I CTE sono anche trattati come recinzioni di ottimizzazione; questo non è un limite tanto all'ottimizzatore quanto a mantenere sana la semantica quando il CTE contiene una query scrivibile.

L'ottimizzatore non distingue i CTE di sola lettura da quelli scrivibili, quindi è eccessivamente conservativo quando si considerano i piani. Il trattamento "recinto" impedisce all'ottimizzatore di spostare la clausola where all'interno del CTE, sebbene possiamo vedere che è sicuro farlo.

Possiamo aspettare che il team PostgreSQL migliori l'ottimizzazione CTE, ma per ora per ottenere buone prestazioni devi cambiare il tuo stile di scrittura.

Riscrivi per prestazioni

La domanda mostra già un modo per ottenere un piano migliore. La duplicazione della condizione del filtro codifica essenzialmente l'effetto del pushdown del predicato.

In entrambi i piani, il motore copia le righe dei risultati su un piano di lavoro in modo da poterle ordinare. Maggiore è il piano di lavoro, più lenta sarà la query.

Il primo piano copia tutte le righe nelle tabelle di base sul piano di lavoro e le scansiona per trovare il risultato. Per rendere le cose ancora più lente, il motore deve scansionare l'intero piano di lavoro perché non ha indici.

È una quantità ridicola di lavoro inutile. Legge due volte tutti i dati nelle tabelle di base per trovare la risposta, quando ci sono solo 5 righe corrispondenti stimate su 19350 righe stimate nelle tabelle di base.

Il secondo piano utilizza gli indici per trovare le righe corrispondenti e copia solo quelle sul tavolo di lavoro. L'indice ha efficacemente filtrato i dati per noi.

A pagina 85 di The Art of SQL, Stéphane Faroult ci ricorda le aspettative degli utenti.

In larga misura, gli utenti finali adattano la loro pazienza al numero di file che si aspettano: quando chiedono un ago, prestano poca attenzione alle dimensioni del pagliaio.

Il secondo piano si ridimensiona con l'ago, quindi è più probabile che i tuoi utenti siano felici.

Riscrivi per manutenibilità

La nuova query è più difficile da mantenere perché è possibile introdurre un difetto modificando l'epxressione di un filtro ma non l'altra.

Non sarebbe bello se potessimo scrivere tutto solo una volta e ottenere comunque buone prestazioni?

Noi possiamo. L'ottimizzatore prevede il pushdown per le query secondarie.

Un esempio più semplice è più facile da spiegare.

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;

Questo crea due tabelle ognuna con una colonna indicizzata. Insieme contengono un milione di 1secondi, un milione di 2secondi e uno 3.

Puoi trovare l'ago 3usando una di queste domande.

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

Il piano per il CTE è lento. Il motore esegue la scansione di tre tabelle e legge circa quattro milioni di righe. Ci vogliono quasi 1000 millisecondi.

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

Il piano per la subquery è veloce. Il motore cerca solo ogni indice. Ci vuole meno di un millisecondo.

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

Vedi SQLFiddle per una versione interattiva.


0

I piani sono gli stessi in Postgres 12

La domanda posta su Postgres 9.3. Cinque anni dopo, quella versione è obsoleta, ma cosa è cambiato?

PostgreSQL 12 ora incorpora CTE come questi.

Query INlined (espressioni di tabella comuni)

Le espressioni di tabella comuni (ovvero WITHquery) possono ora essere automaticamente incorporate in una query se a) non sono ricorsive, b) non hanno effetti collaterali ec) sono referenziate una sola volta in una parte successiva di una query. Ciò rimuove un "recinto di ottimizzazione" che esiste dall'introduzione della WITHclausola in PostgreSQL 8.4

Se necessario, è possibile forzare la materializzazione di una query WITH utilizzando la clausola MATERIALIZED, ad es

WITH c AS MATERIALIZED ( SELECT * FROM a WHERE a.x % 4 = 0 ) SELECT * FROM c JOIN d ON d.y = a.x;
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.