¿Por qué este LEFT JOIN funciona mucho peor que LEFT JOIN LATERAL?

13

Tengo las siguientes tablas (tomadas de la base de datos Sakila):

  • película: film_id es pkey
  • actor: actor_id es pkey
  • film_actor: film_id y actor_id son fkeys para película / actor

Estoy seleccionando una película en particular. Para esta película, también quiero que todos los actores participen en esa película. Tengo dos consultas para esto: una con a LEFT JOINy otra 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;

Al comparar el plan de consulta, la primera consulta funciona mucho peor (20x) que la segunda:

 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

¿Por qué es esto? Quiero aprender a razonar sobre esto, para poder entender lo que está sucediendo y predecir cómo se comportará la consulta cuando aumente el tamaño de los datos y qué decisiones tomará el planificador bajo ciertas condiciones.

Mis pensamientos: en la primera LEFT JOINconsulta, parece que la subconsulta se ejecuta para todas las películas en la base de datos, sin tener en cuenta el filtrado en la consulta externa de que solo estamos interesados ​​en una película en particular. ¿Por qué el planificador no puede tener ese conocimiento en la subconsulta?

En la LEFT JOIN LATERALconsulta, estamos más o menos "empujando" ese filtrado hacia abajo. Entonces, el problema que tuvimos en la primera consulta no está presente aquí, de ahí el mejor rendimiento.

Supongo que estoy buscando principalmente la regla general, las sabidurías generales, ... así que esta magia planificadora se convierte en una segunda naturaleza, si eso tiene sentido.

actualización (1)

Reescribir lo LEFT JOINsiguiente también ofrece un mejor rendimiento (un poco mejor que el 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

¿Cómo podemos razonar sobre esto?

actualización (2)

Continué con algunos experimentos y creo que una regla práctica interesante es: aplicar la función de agregado lo más alto / tardío posible . La consulta en la actualización (1) probablemente funciona mejor porque estamos agregando en la consulta externa, ya no en la consulta interna.

Lo mismo parece aplicarse si reescribimos lo LEFT JOIN LATERALanterior de la siguiente manera:

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

Aquí, nos movimos array_agg()hacia arriba. Como puede ver, este plan también es mejor que el original LEFT JOIN LATERAL.

Dicho esto, no estoy seguro de si esta regla empírica auto-inventada ( aplicar la función de agregado lo más alto / tardío posible ) es cierta en otros casos.

Información Adicional

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

Versión: PostgreSQL 10.4 en x86_64-pc-linux-musl, compilado por gcc (Alpine 6.4.0) 6.4.0, 64 bits

Medio Ambiente: acoplable: docker run -e POSTGRES_PASSWORD=sakila -p 5432:5432 -d frantiseks/postgres-sakila. Tenga en cuenta que la imagen en Docker Hub está desactualizada, así que primero hice una compilación local: build -t frantiseks/postgres-sakiladespués de clonar el repositorio git.

Definiciones de tabla:

película

 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

actor

 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

Actor de películas

 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

Datos: esto es de la base de datos de muestra Sakila. Esta pregunta no es un caso de la vida real, estoy usando esta base de datos principalmente como una base de datos de muestra de aprendizaje. Me presentaron a SQL hace unos meses y estoy tratando de ampliar mi conocimiento. Tiene las siguientes distribuciones:

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
fuente
1
Una cosa más: toda la información importante debe ir a la pregunta (incluido su enlace de violín). Nadie querrá leer todos los comentarios más tarde (o un moderador muy capaz los eliminará de todos modos).
Erwin Brandstetter
Fiddle se agrega a la pregunta!
Jelly Orns

Respuestas:

7

Configuración de prueba

Su configuración original en el violín deja margen de mejora. Seguí preguntando por su configuración por una razón.

  • Tienes estos índices en film_actor:

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

    Lo cual ya es bastante útil. Sin embargo, para mejor soporte a su consulta en particular, tendría un índice de múltiples de (film_id, actor_id)columnas en este orden. Una solución práctica: reemplazar idx_fk_film_idcon un índice activado (film_id, actor_id), o crear el PK activado (film_id, actor_id)para el propósito de esta prueba, como lo hago a continuación. Ver:

    En un modo de solo lectura (o mayormente, o generalmente cuando VACUUM puede mantenerse al día con la actividad de escritura), también ayuda tener un índice activado (title, film_id)para permitir escaneos de índice solamente. Mi caso de prueba ahora está altamente optimizado para el rendimiento de lectura.

  • Escriba falta de coincidencia entre film.film_id( integer) y film_actor.film_id( smallint). Si bien eso funciona , hace que las consultas sean más lentas y puede provocar varias complicaciones. También hace que las restricciones de FK sean más caras. Nunca haga esto si se puede evitar. Si no está seguro, elija integermás smallint. Si bien smallint puede guardar 2 bytes por campo (a menudo consumido por el relleno de alineación), hay más complicaciones que con integer.

  • Para optimizar el rendimiento de la prueba en sí, cree índices y restricciones después de la inserción masiva de muchas filas. Es sustancialmente más lento agregar tuplas de forma incremental a los índices existentes que crearlas desde cero con todas las filas presentes.

No relacionado con esta prueba:

  • Secuencias independientes más valores predeterminados de columna en lugar de columnas mucho más simples y más confiables serial(o IDENTITY). No lo hagas

  • timestamp without timestampnormalmente no es confiable para una columna como last_update. Usar en su timestamptzlugar. Y tenga en cuenta que los valores predeterminados de la columna no cubren la "última actualización", estrictamente hablando.

  • El modificador de longitud en character varying(255)indica que el caso de prueba no está destinado a Postgres para comenzar porque la longitud impar es bastante inútil aquí. (O el autor no tiene idea).

Considere el caso de prueba auditado en el violín:

db <> fiddle here : construyendo sobre su violín, optimizado y con consultas adicionales.

Relacionado:

Una configuración de prueba con 1000 películas y 200 actores tiene una validez limitada. Las consultas más eficientes toman <0.2 ms. El tiempo de planificación es más que el tiempo de ejecución. Una prueba con 100k o más filas sería más reveladora.

¿Por qué recuperar solo los nombres de los autores? Una vez que recupera varias columnas, ya tiene una situación ligeramente diferente.

ORDER BY titleno tiene sentido mientras se filtra por un solo título con WHERE title = 'ACADEMY DINOSAUR'. Tal vez ORDER BY film_id?

Y para el tiempo de ejecución total, más bien use EXPLAIN (ANALYZE, TIMING OFF)para reducir el ruido (potencialmente engañoso) con sobrecarga de subtiempo.

Responder

Es difícil formar una regla general simple, porque el rendimiento total depende de muchos factores. Pautas muy básicas:

  • Agregar todas las filas en las subtablas conlleva menos gastos generales, pero solo paga cuando realmente necesita todas las filas (o una parte muy grande).

  • Para seleccionar algunas filas (¡su prueba!), Diferentes técnicas de consulta producen mejores resultados. Ahí es donde LATERALentra. Lleva más sobrecarga pero solo lee las filas requeridas de las subtablas. Una gran victoria si solo se necesita una fracción (muy) pequeña.

Para su caso de prueba particular, también probaría un constructor ARRAY en la LATERALsubconsulta :

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 

Si bien solo agrega una matriz en la subconsulta lateral, un simple constructor ARRAY funciona mejor que la función de agregado array_agg(). Ver:

O con una subconsulta poco correlacionada para el caso simple:

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, básicamente, solo 2x LEFT JOINy luego agregar :

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;

Estos tres parecen más rápidos en mi violín actualizado (planificación + tiempo de ejecución).

Su primer intento (solo ligeramente modificado) suele ser el más rápido para recuperar todas o la mayoría de las películas , pero no para una pequeña selección:

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!

Las pruebas con cardinalidades mucho más grandes serán más reveladoras. Y no generalice los resultados a la ligera, hay muchos factores para el rendimiento total.

Erwin Brandstetter
fuente