Почему планы разные, если запросы логически похожи?

19

Я написал две функции, чтобы ответить на первый домашний вопрос третьего дня из « Семь баз данных за семь недель» .

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

Моя первая попытка правильная, но медленная. Для возврата результата может потребоваться до 2000 мс.

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;

Моя вторая попытка правильная и быстрая. Я оптимизировал его, протолкнув фильтр из CTE в каждую часть объединения.

Я удалил эту строку из внешнего запроса:

WHERE entity_term = query

Я добавил эту строку в первый внутренний запрос:

WHERE actors.name = query

Я добавил эту строку во второй внутренний запрос:

WHERE movies.title = query

Вторая функция занимает около 10 мс, чтобы вернуть тот же результат.

Ничто не отличается в базе данных, кроме определения функций.

Почему PostgreSQL создает такие разные планы для этих двух логически эквивалентных запросов?

EXPLAIN ANALYZEПлан первой функции выглядит следующим образом :

                                                                                       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

EXPLAIN ANALYZEПлан второго запроса выглядит следующим образом :

 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
Иэн Сэмюэл Маклин Старейшина
источник

Ответы:

21

Нет автоматического предиката для CTE

PostgreSQL 9.3 не делает предикатное нажатие для CTE.

Оптимизатор, который делает предикат pushdown, может перемещать туда, где предложения, во внутренние запросы. Цель состоит в том, чтобы отфильтровать нерелевантные данные как можно раньше. Пока новый запрос логически эквивалентен, механизм все еще выбирает все соответствующие данные, поэтому выдает правильный результат, только быстрее.

Разработчик ядра Том Лейн указывает на сложность определения логической эквивалентности в списке рассылки pgsql-performance .

CTE также рассматриваются как заборы для оптимизации; это не столько ограничение оптимизатора, сколько сохранение семантики, когда CTE содержит доступный для записи запрос.

Оптимизатор не отличает CTE только для чтения от доступных для записи, поэтому он слишком консервативен при рассмотрении планов. Обработка «забором» не дает оптимизатору переместить предложение where внутри CTE, хотя мы можем видеть, что это безопасно.

Мы можем подождать, пока команда PostgreSQL улучшит оптимизацию CTE, но сейчас, чтобы получить хорошую производительность, вы должны изменить свой стиль написания.

Переписать для производительности

Вопрос уже показывает один из способов получить лучший план. Дублирование условия фильтра по существу жестко кодирует эффект нажатия предиката.

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

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

Это нелепое количество ненужной работы. Он считывает все данные в базовых таблицах дважды, чтобы найти ответ, когда в базовых таблицах есть только приблизительно 5 совпадающих строк из приблизительно 19350 строк.

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

На странице 85 «Искусства SQL» Стефан Фарульт напоминает нам об ожиданиях пользователей.

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

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

Переписать для удобства обслуживания

Новый запрос сложнее поддерживать, потому что вы можете ввести дефект, изменив одно выражение фильтра, но не другое.

Разве не было бы замечательно, если бы мы могли написать все всего один раз и при этом добиться хорошей производительности?

Мы можем. Оптимизатор делает предикат pushdown для подзапросов.

Более простой пример легче объяснить.

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;

Это создает две таблицы с индексированным столбцом. Вместе они содержат миллион 1с, миллион 2с и один 3.

Вы можете найти иглу 3с помощью любого из этих запросов.

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

План для CTE медленный. Движок сканирует три таблицы и читает около четырех миллионов строк. Это займет около 1000 миллисекунд.

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

План для подзапроса быстрый. Двигатель просто ищет каждый индекс. Это займет меньше миллисекунды.

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

Смотрите SQLFiddle для интерактивной версии.

Иэн Сэмюэл Маклин Старейшина
источник
0

Планы такие же в Postgres 12

Заданный вопрос о Postgres 9.3. Пять лет спустя эта версия устарела, но что изменилось?

PostgreSQL 12 теперь содержит такие CTE.

Встроенные запросы WITH (общие табличные выражения)

Общие табличные выражения (или WITHзапросы) теперь могут автоматически вставляться в запрос, если они а) не являются рекурсивными, б) не имеют побочных эффектов и в) на них ссылаются только один раз в более поздней части запроса. Это устраняет «барьер оптимизации», существовавший со времени введения WITHпредложения в PostgreSQL 8.4.

При необходимости вы можете принудительно выполнить запрос WITH, используя предложение MATERIALIZED, например:

WITH c AS MATERIALIZED ( SELECT * FROM a WHERE a.x % 4 = 0 ) SELECT * FROM c JOIN d ON d.y = a.x;
Иэн Сэмюэл Маклин Старейшина
источник