web-dev-qa-db-fra.com

Pourquoi ce LEFT JOIN est-il si pire que LEFT JOIN LATERAL?

J'ai les tableaux suivants (extraits de la base de données Sakila):

  • film: film_id is pkey
  • acteur: acteur_id est pkey
  • film_actor: film_id et acteur_id sont les clés du film/acteur

Je sélectionne un film particulier. Pour ce film, je veux aussi que tous les acteurs participent à ce film. J'ai deux requêtes pour cela: une avec un LEFT JOIN Et une avec un 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;

Lors de la comparaison du plan de requête, la première requête est bien pire (20x) que la seconde:

 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

Pourquoi est-ce? Je veux apprendre à raisonner à ce sujet, afin que je puisse comprendre ce qui se passe et prédire comment la requête se comportera lorsque la taille des données augmentera et quelles décisions le planificateur prendra dans certaines conditions.

Mes pensées: dans la première requête LEFT JOIN, Il semble que la sous-requête soit exécutée pour tous les films de la base de données, sans tenir compte du filtrage dans la requête externe que nous ne sommes intéressés que par un film particulier. Pourquoi le planificateur n'est-il pas en mesure d'avoir ces connaissances dans la sous-requête?

Dans la requête LEFT JOIN LATERAL, Nous poussons plus ou moins ce filtrage vers le bas. Le problème rencontré lors de la première requête n'est donc pas présent ici, d'où les meilleures performances.

Je suppose que je recherche principalement des règles de base, des idées reçues, ... donc cela la magie du planificateur devient une seconde nature - si cela a du sens.

mise à jour (1)

La réécriture du LEFT JOIN Comme suit donne également de meilleures performances (légèrement meilleures que le 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

Comment pouvons-nous raisonner à ce sujet?

mise à jour (2)

J'ai continué avec quelques expériences et je pense qu'une règle d'or intéressante est: appliquer la fonction d'agrégation aussi haut/tard que possible. La requête dans update (1) fonctionne probablement mieux car nous agrégons dans la requête externe, et non plus dans la requête interne.

La même chose semble s'appliquer si nous réécrivons le LEFT JOIN LATERAL Ci-dessus comme suit:

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

Ici, nous avons déplacé array_agg() vers le haut. Comme vous pouvez le voir, ce plan est également meilleur que l'original LEFT JOIN LATERAL.

Cela dit, je ne sais pas si cette règle empirique inventée par soi-même ( applique la fonction d'agrégation aussi haut/tard que possible) est vraie dans d'autres cas.

informations supplémentaires

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

Version: PostgreSQL 10.4 sur x86_64-pc-linux-musl, compilé par gcc (Alpine 6.4.0) 6.4.0, 64 bits

Environnement: Docker: docker run -e POSTGRES_PASSWORD=sakila -p 5432:5432 -d frantiseks/postgres-sakila. Veuillez noter que l'image sur le hub Docker est obsolète, j'ai donc d'abord fait une construction localement: build -t frantiseks/postgres-sakila Après avoir cloné le référentiel git.

Définitions des tableaux:

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

acteur

 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

film_actor

 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

Données: elles proviennent de la base de données d'exemples Sakila. Cette question n'est pas un cas réel, j'utilise cette base de données principalement comme exemple de base de données d'apprentissage. J'ai été initié à SQL il y a quelques mois et j'essaie d'élargir mes connaissances. Il a les distributions suivantes:

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
13
Jelly Orns

Configuration de test

Votre configuration d'origine dans le violon laisse place à l'amélioration. Je n'arrêtais pas de demander votre configuration pour une raison.

  • Vous avez ces index sur film_actor:

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

    Ce qui est déjà assez utile. Mais pour mieux prendre en charge votre requête particulière, vous auriez un index multicolonne sur (film_id, actor_id), colonnes dans cet ordre. Une solution pratique: remplacez idx_fk_film_id Par un index sur (film_id, actor_id) - ou créez le PK sur (film_id, actor_id) Aux fins de ce test, comme je le fais ci-dessous. Voir:

    Dans une lecture seule (ou la plupart du temps, ou généralement lorsque VACUUM peut suivre l'activité d'écriture), il est également utile d'avoir un index sur (title, film_id) pour autoriser les analyses d'index uniquement. Mon scénario de test est désormais hautement optimisé pour les performances de lecture.

  • Décalage de type entre film.film_id (integer) et film_actor.film_id (smallint) . Bien que cela fonctionne cela ralentit les requêtes et peut entraîner diverses complications. Rend également les contraintes FK plus chères. Ne faites jamais cela si cela peut être évité. Si vous n'êtes pas sûr, choisissez integer sur smallint. Alors que smallint peut économiser 2 octets par champ (souvent consommé par le remplissage d'alignement), il y a plus de complications qu'avec integer.

  • Pour optimiser les performances du test lui-même, créez des index et des contraintes après en insérant beaucoup de lignes. Il est beaucoup plus lent d'ajouter des tuples de manière incrémentielle aux index existants que de les créer à partir de zéro avec toutes les lignes présentes.

Sans rapport avec ce test:

  • Séquences autonomes et valeurs par défaut des colonnes au lieu de colonnes serial (ou IDENTITY) beaucoup plus simples et plus fiables. Non.

  • timestamp without timestamp N'est généralement pas fiable pour une colonne comme last_update. Utilisez timestamptz à la place. Et notez que les valeurs par défaut des colonnes ne couvrent pas la "dernière mise à jour" à proprement parler.

  • Le modificateur de longueur dans character varying(255) indique que le cas de test n'est pas destiné à Postgres pour commencer parce que la longueur impaire est assez inutile ici. (Ou l'auteur n'a aucune idée.)

Considérez le cas de test audité dans le violon:

db <> violon ici - en s'appuyant sur votre violon, optimisé et avec des requêtes ajoutées .

En relation:

Une configuration de test avec 1000 films et 200 acteurs a une validité limitée. Les requêtes les plus efficaces prennent <0,2 ms. Le temps de planification est plus que le temps d'exécution. Un test avec 100 000 lignes ou plus serait plus révélateur.

Pourquoi récupérer seulement prénoms des auteurs? Une fois que vous avez récupéré plusieurs colonnes, vous avez déjà une situation légèrement différente.

ORDER BY title N'a aucun sens lors du filtrage d'un seul titre avec WHERE title = 'ACADEMY DINOSAUR'. Peut-être ORDER BY film_id?

Et pour runtime total utilisez plutôt EXPLAIN (ANALYZE, TIMING OFF) pour réduire le bruit (potentiellement trompeur) avec un surcoût de sous-synchronisation.

Réponse

Il est difficile de former une règle empirique simple, car les performances globales dépendent de nombreux facteurs. Lignes directrices très basiques:

  • L'agrégation des lignes toutes dans les sous-tables entraîne moins de frais généraux mais ne paie que lorsque vous avez réellement besoin de toutes les lignes (ou d'une très grande partie).

  • Pour sélectionner quelques-uns lignes (votre test!), Différentes techniques de requête donnent de meilleurs résultats. C'est là que LATERAL entre en jeu. Il porte plus de surcharge mais ne lit que les lignes requises dans les sous-tables. Une grosse victoire si seulement une (très) petite fraction est nécessaire.

Pour votre cas de test particulier, je testerais également un constructeur ARRAY dans la sous-requête LATERAL:

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 

Tout en agrégeant un seul tableau dans la sous-requête latérale, un constructeur ARRAY simple fonctionne mieux que la fonction d'agrégation array_agg(). Voir:

Ou avec une sous-requête faiblement corrélée pour le cas 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';

Ou, très fondamentalement, juste 2x LEFT JOIN Puis agréger :

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;

Ces trois semblent les plus rapides dans mon violon mis à jour (planification + temps d'exécution).

Votre première tentative (seulement légèrement modifiée) est généralement la plus rapide pour récupérer tous ou la plupart des films , mais pas pour une petite sélection:

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!

Les tests avec des cardinalités beaucoup plus importantes seront plus révélateurs. Et ne généralisez pas les résultats à la légère, il existe de nombreux facteurs pour la performance totale.

7