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

13

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 JOINet 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 LEFT JOINrequête, 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 LEFT JOIN LATERALrequête, 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 cette magie de planificateur devient une seconde nature - si cela a du sens.

mise à jour (1)

La réécriture de ce LEFT JOINqui suit donne également de meilleures performances (légèrement meilleures que les 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 empirique intéressante est: appliquer la fonction d'agrégation aussi haut / tard que possible . La requête dans la mise à jour (1) fonctionne probablement mieux parce que 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 ce qui LEFT JOIN LATERALprécède 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 array_agg()progressé. 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 ( appliquer la fonction d'agrégation aussi haut / tard que possible ) est vraie dans d'autres cas.

Information additionnelle

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 fait une construction localement d'abord: build -t frantiseks/postgres-sakilaaprès le clonage du 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

acteur de film

 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
Jelly Orns
la source
1
Une dernière chose: toutes les informations importantes doivent entrer dans la question (y compris votre lien de violon). Personne ne voudra lire tous les commentaires plus tard (ou ils seront supprimés par un certain modérateur très compétent de toute façon).
Erwin Brandstetter
Fiddle est ajouté à la question!
Jelly Orns

Réponses:

7

Configuration de test

Votre configuration d'origine dans le violon laisse place à amélioration. J'ai continué à 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 les (film_id, actor_id)colonnes dans cet ordre. Une solution pratique: remplacez idx_fk_film_idpar 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 (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.

  • Tapez incompatibilité 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 integerplus smallint. Bien qu'il smallint puisse é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 avoir inséré en vrac de nombreuses 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 plus les valeurs par défaut des colonnes au lieu de colonnes beaucoup plus simples et plus fiables serial(ou IDENTITY). Non.

  • timestamp without timestampest généralement peu fiable pour une colonne comme last_update. Utilisez timestamptzplutôt. 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 car 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 <> fiddle here - s'appuyant sur votre violon, optimisé et avec des requêtes supplémentaires.

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 ne récupérer que les 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 titlen'a aucun sens lors du filtrage d'un seul titre avec WHERE title = 'ACADEMY DINOSAUR'. Peut ORDER BY film_id- être ?

Et pour une durée d'exécution totale, utilisez plutôt EXPLAIN (ANALYZE, TIMING OFF)pour réduire (potentiellement trompeur) le bruit avec des frais généraux de sous-synchronisation.

Répondre

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 de toutes les lignes 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 lignes (votre test!), Différentes techniques de requête donnent de meilleurs résultats. C'est là LATERALqu'intervient. Il comporte plus de surcharge mais ne lit que les lignes requises des 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 LATERALsous - requête :

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 simple constructeur ARRAY 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, fondamentalement, juste 2x LEFT JOINet ensuite 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!

Des 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.

Erwin Brandstetter
la source