Почему этот LEFT JOIN работает намного хуже, чем LEFT JOIN LATERAL?

13

У меня есть следующие таблицы (взяты из базы данных Sakila):

  • film: film_id это pkey
  • actor: actor_id - это pkey
  • film_actor: film_id и actor_id - это ключи к фильму / актеру

Я выбираю конкретный фильм. Для этого фильма я также хочу, чтобы все актеры участвовали в этом фильме. У меня есть два запроса для этого: один с а LEFT JOINи один с 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;

При сравнении плана запроса первый запрос работает намного хуже (в 20 раз), чем второй:

 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

Почему это? Я хочу научиться рассуждать об этом, чтобы понять, что происходит, и предсказать, как будет вести себя запрос при увеличении размера данных и какие решения примет планировщик при определенных условиях.

Мои мысли: в первом LEFT JOINзапросе похоже, что подзапрос выполняется для всех фильмов в базе данных, без учета фильтрации внешнего запроса, который нас интересует только для одного конкретного фильма. Почему планировщик не может получить эти знания в подзапросе?

В LEFT JOIN LATERALзапросе мы более или менее «проталкиваем» эту фильтрацию вниз. Таким образом, проблема, с которой мы столкнулись в первом запросе, здесь не присутствует, следовательно, лучшая производительность.

Я думаю, что я в основном ищу правило большого пальца, общие мудрости ... так что эта магия планировщика становится второй натурой - если это имеет смысл.

обновление (1)

Переписав LEFT JOINследующее, вы получите лучшую производительность (чуть лучше, чем 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

Как мы можем рассуждать об этом?

обновление (2)

Я продолжил некоторые эксперименты и думаю, что интересное эмпирическое правило таково: применяйте агрегатную функцию настолько высоко / поздно, насколько это возможно . Запрос в update (1), вероятно, работает лучше, потому что мы агрегируем во внешнем запросе, а не во внутреннем запросе.

То же самое применимо, если мы переписали LEFT JOIN LATERALвышеизложенное следующим образом:

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

Здесь мы двинулись array_agg()вверх. Как видите, этот план тоже лучше оригинала LEFT JOIN LATERAL.

Тем не менее, я не уверен, является ли это эмпирическое правило ( примененное к статистической функции как можно выше / позднее ) верным в других случаях.

Дополнительная информация

Скрипка: https://dbfiddle.uk/?rdbms=postgres_10&fiddle=4ec4f2fffd969d9e4b949bb2ca765ffb

Версия: PostgreSQL 10.4 на x86_64-pc-linux-musl, скомпилированная gcc (Alpine 6.4.0) 6.4.0, 64-битная

Окружающая среда: Docker: docker run -e POSTGRES_PASSWORD=sakila -p 5432:5432 -d frantiseks/postgres-sakila. Обратите внимание, что образ в Docker-хабе устарел, поэтому сначала я сделал сборку локально: build -t frantiseks/postgres-sakilaпосле клонирования репозитория git.

Табличные определения:

фильм

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

Данные: это из базы данных Sakila. Этот вопрос не является реальным случаем, я использую эту базу данных в основном в качестве учебной базы данных. Я познакомился с SQL несколько месяцев назад и пытаюсь расширить свои знания. Имеет следующие дистрибутивы:

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
Желе орнс
источник
1
Еще одна вещь: вся важная информация должна идти в вопросе (в том числе ваша скриптовая ссылка). Никто не захочет читать все комментарии позже (или они все равно будут удалены неким очень способным модератором).
Эрвин Брандштеттер
Скрипка добавлена ​​к вопросу!
Джелли Орнс

Ответы:

7

Испытательная установка

Ваша оригинальная установка в скрипке оставляет место для улучшения. Я продолжал спрашивать о вашей настройке по причине.

  • У вас есть эти индексы на film_actor:

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

    Что уже очень полезно. Но лучшей поддержка вашего конкретного запроса, вы бы индекс многоколоночного на (film_id, actor_id), столбцы в указанном порядке. Практическое решение: замените idx_fk_film_idиндекс на (film_id, actor_id)- или создайте PK (film_id, actor_id)для целей этого теста, как я делаю ниже. Видеть:

    В режиме «только для чтения» (или, в основном, или обычно, когда VACUUM может справиться с операцией записи), также помогает включить индексирование, (title, film_id)чтобы разрешить сканирование только по индексу. Мой тестовый пример теперь сильно оптимизирован для производительности чтения.

  • Несоответствие типов между film.film_id( integer) и film_actor.film_id( smallint). Хотя это работает, это замедляет запросы и может привести к различным осложнениям. Также делает ограничения FK более дорогими. Никогда не делайте этого, если этого можно избежать. Если вы не уверены, выбрать integerболее smallint. Хотя smallint можно сэкономить 2 байта на поле (часто потребляемое выравниванием выравнивания), сложностей больше, чем с integer.

  • Чтобы оптимизировать производительность самого теста, создайте индексы и ограничения после массовой вставки большого количества строк. Существенно медленнее постепенно добавлять кортежи к существующим индексам, чем создавать их с нуля при наличии всех строк.

Не связанные с этим тестом:

  • Отдельно стоящие последовательности плюс значения по умолчанию для столбцов вместо гораздо более простых и надежных serial(или IDENTITY) столбцов. Не.

  • timestamp without timestampобычно ненадежен для столбца, как last_update. Используйте timestamptzвместо этого. И обратите внимание, что по умолчанию столбец не охватывает «последнее обновление», строго говоря.

  • Модификатор длины в character varying(255)указывает, что тестовый пример не предназначен для Postgres, потому что нечетная длина здесь довольно бессмысленна. (Или автор не имеет понятия.)

Рассмотрим проверенный контрольный пример в скрипте:

db <> fiddle here - построение на вашей скрипке, оптимизированное и с добавленными запросами.

Связанный:

Тестовая установка с 1000 фильмами и 200 актерами имеет ограниченный срок действия. Наиболее эффективные запросы занимают <0,2 мс. Время планирования больше времени выполнения. Тест с 100k или более строк будет более показательным.

Зачем искать только имена авторов? Как только вы получите несколько столбцов, у вас уже будет немного другая ситуация.

ORDER BY titleне имеет смысла при фильтрации для одного заголовка с WHERE title = 'ACADEMY DINOSAUR'. Может быть ORDER BY film_id?

А для общего времени выполнения лучше использовать EXPLAIN (ANALYZE, TIMING OFF)для уменьшения (потенциально вводящего в заблуждение) шум с дополнительными затратами времени.

Ответ

Сложно составить простое правило, потому что общая производительность зависит от многих факторов. Очень основные рекомендации:

  • Агрегирование всех строк в подстолбьях требует меньше накладных расходов, но платит только тогда, когда вам действительно нужны все строки (или очень большая часть).

  • Для выбора нескольких строк (ваш тест!) Различные методы запроса дают лучшие результаты. Вот где LATERALприходит. Он несет больше накладных расходов, но читает только необходимые строки из вложенных таблиц. Большой выигрыш, если нужна только (очень) маленькая фракция.

Для вашего конкретного теста я также протестировал бы конструктор ARRAY в 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 

При агрегировании только одного массива в боковом подзапросе простой конструктор ARRAY работает лучше, чем функция агрегирования array_agg(). Видеть:

Или со слабо коррелированным подзапросом для простого случая:

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

Или, в принципе, просто 2х, LEFT JOINа затем агрегировать :

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;

Эти три кажутся самыми быстрыми в моей обновленной скрипке (планирование + время выполнения).

Ваша первая попытка (только слегка измененная), как правило, быстрее всего получить все или большинство фильмов , но не для небольшого выбора:

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!

Тесты с гораздо большим количеством элементов будут более показательными. И не обобщайте результаты слегка, есть много факторов для общей производительности.

Эрвин Брандштеттер
источник