¿Por qué los planes son diferentes si las consultas son lógicamente similares?

19

Escribí dos funciones para responder la primera pregunta de tarea del Día 3 de Seven Databases en Seven Weeks .

Cree un procedimiento almacenado en el que pueda ingresar el título de una película o el nombre del actor que desee, y le devolverá las cinco sugerencias principales en función de las películas que el actor ha protagonizado o películas con géneros similares.

Mi primer intento es correcto pero lento. Puede tomar hasta 2000 ms para devolver un resultado.

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;

Mi segundo intento es correcto y rápido. Lo optimicé empujando el filtro hacia abajo desde el CTE hacia cada parte de la unión.

Eliminé esta línea de la consulta externa:

WHERE entity_term = query

Agregué esta línea a la primera consulta interna:

WHERE actors.name = query

Agregué esta línea a la segunda consulta interna:

WHERE movies.title = query

La segunda función tarda unos 10 ms para devolver el mismo resultado.

Nada difiere en la base de datos aparte de las definiciones de funciones.

¿Por qué PostgreSQL produce planes tan diferentes para estas dos consultas lógicamente equivalentes?

El EXPLAIN ANALYZEplan de la primera función se ve así:

                                                                                       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

El EXPLAIN ANALYZEplan de la segunda consulta se ve así:

 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 Élder
fuente

Respuestas:

21

Sin pushdown predicado automático para CTE

PostgreSQL 9.3 no hace pushdown de predicado para CTE.

Un optimizador que hace pushdown de predicados puede mover las cláusulas a consultas internas. El objetivo es filtrar datos irrelevantes lo antes posible. Mientras la nueva consulta sea lógicamente equivalente, el motor aún obtiene todos los datos relevantes, por lo que produce el resultado correcto, solo que más rápido.

El desarrollador principal Tom Lane alude a la dificultad de determinar la equivalencia lógica en la lista de correo pgsql-performance .

Los CTE también se tratan como vallas de optimización; Esto no es tanto una limitación del optimizador como para mantener la semántica sana cuando el CTE contiene una consulta de escritura.

El optimizador no distingue los CTE de solo lectura de los que se pueden escribir, por lo que es demasiado conservador al considerar los planes. El tratamiento de 'cerca' impide que el optimizador mueva la cláusula where dentro del CTE, aunque podemos ver que es seguro hacerlo.

Podemos esperar a que el equipo de PostgreSQL mejore la optimización de CTE, pero por ahora para obtener un buen rendimiento, debe cambiar su estilo de escritura.

Reescribe para el rendimiento

La pregunta ya muestra una forma de obtener un mejor plan. Duplicar la condición del filtro esencialmente codifica el efecto del empuje del predicado.

En ambos planes, el motor copia las filas de resultados en una mesa de trabajo para poder ordenarlas. Cuanto más grande es la mesa de trabajo, más lenta es la consulta.

El primer plan copia todas las filas de las tablas base en la mesa de trabajo y las escanea para encontrar el resultado. Para hacer las cosas aún más lentas, el motor debe escanear toda la mesa de trabajo porque no tiene índices.

Esa es una cantidad ridícula de trabajo innecesario. Lee todos los datos en las tablas base dos veces para encontrar la respuesta, cuando solo hay un estimado de 5 filas coincidentes de un estimado de 19350 filas en las tablas base.

El segundo plan usa los índices para encontrar las filas coincidentes y copia solo aquellos en la mesa de trabajo. El índice efectivamente filtró los datos por nosotros.

En la página 85 de The Art of SQL, Stéphane Faroult nos recuerda las expectativas de los usuarios.

En gran medida, los usuarios finales ajustan su paciencia al número de filas que esperan: cuando piden una aguja, prestan poca atención al tamaño del pajar.

El segundo plan se escala con la aguja, por lo que es más probable que mantenga contentos a sus usuarios.

Reescribir para mantener

La nueva consulta es más difícil de mantener porque puede introducir un defecto cambiando una epxresión de filtro pero no la otra.

¿No sería genial si pudiéramos escribir todo una sola vez y aún así obtener un buen rendimiento?

Podemos. El optimizador hace un empuje de predicado para subqeries.

Un ejemplo más simple es más fácil de explicar.

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;

Esto crea dos tablas cada una con una columna indexada. Juntos contienen un millón 1s, un millón 2sy uno 3.

Puede encontrar la aguja 3utilizando cualquiera de estas consultas.

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

El plan para el CTE es lento. El motor escanea tres tablas y lee alrededor de cuatro millones de filas. Tarda casi 1000 milisegundos.

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

El plan para la subconsulta es rápido. El motor solo busca cada índice. Tarda menos de un milisegundo.

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

Ver SQLFiddle para una versión interactiva.

Iain Samuel McLean Élder
fuente
0

Los planes son los mismos en Postgres 12

La pregunta sobre Postgres 9.3. Cinco años después, esa versión es obsoleta, pero ¿qué ha cambiado?

PostgreSQL 12 ahora integra CTE como estos.

Consultas en línea CON (expresiones de tabla comunes)

Las expresiones de tabla comunes (también conocidas como WITHconsultas) ahora se pueden insertar automáticamente en una consulta si a) no son recursivas, b) no tienen efectos secundarios yc) solo se hace referencia una vez en una parte posterior de una consulta. Esto elimina una "valla de optimización" que ha existido desde la introducción de la WITHcláusula en PostgreSQL 8.4

Si es necesario, puede forzar una consulta WITH para que se materialice utilizando la cláusula MATERIALIZED, p. Ej.

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 Élder
fuente