В то время, как пользователи видят позитивные стороны технологий, мы, разработчики, обычно сталкиваемся с ограничениями/недоработками/багами и видим наш продукт с совсем другой стороны. Вот и в этот раз: после публикации результатов сравнительного тестирования где прогонялись запросы теста Join-Order-Benchmark на базе с партициями и без, меня не отпускало ощущение, что всё-таки что-то я не досмотрел и при наличии партиций постгрес должен строить план хуже, чем без них. И это должен быть не просто баг, а технологическое ограничение. И вот, методом разглядывания потолка удалось-таки найти тонкое место - запросы с лимитами.

В отличие от одиночной таблицы, при наличии ограничения на количество выдаваемых строк или fractional paths (к примеру, SQL-директива LIMIT может задавать такое ограничение), оптимизатор сталкивается со множеством вопросов: сколько строк будет извлечено из каждой партиции? А может будет задействована только первая? А какая будет первая? - это неочевидно в условиях потенциально возможного execution-time pruning'a партиций ...

А что, если сканирование партиций у нас будет происходить по индексу, а результат будет получен слиянием - то есть при наличии MergeAppend'a - здесь совсем непонятно как оценивать количество строк, которые должны будут быть извлечены из партиции и, следовательно, какой оператор сканирования применить. А что, если используя partitionwise join у нас под Append попадает целое поддерево - знание о лимитах в таком случае является важным - к примеру, при выборе типа JOIN, разве нет?

Проблема промежуточных планов запросов

Такое множество вопросов к партиционированию привело к компромиссному решению в при выборе плана запроса для поддерева, находящегося под Append'ом: для целей выбора оптимально fractional path рассматривается два варианта плана: с минимальным total cost и с минимальным startup cost. Грубо говоря, план будет оптимален, если у нас в запросе есть LIMIT 1 или какой-то очень большой LIMIT. А как быть с промежуточными вариантами? Давайте посмотрим в конкретные примеры (спасибо @apyhalov).

DROP TABLE IF EXISTS parted,plain CASCADE;
CREATE TEMP TABLE parted (x integer, y integer, payload text)
PARTITION BY HASH (payload);
CREATE TEMP TABLE parted_p1 PARTITION OF parted
  FOR VALUES WITH (MODULUS 2, REMAINDER 0);
CREATE TEMP TABLE parted_p2 PARTITION OF parted
  FOR VALUES WITH (MODULUS 2, REMAINDER 1);
INSERT INTO parted (x,y,payload)
  SELECT (random()*600)::integer, (random()*600)::integer, md5((gs%500)::text)
  FROM generate_series(1,1E5) AS gs;
CREATE TEMP TABLE plain (x numeric, y numeric, payload text);
INSERT INTO plain (x,y,payload) SELECT x,y,payload FROM parted;
CREATE INDEX ON parted(payload);
CREATE INDEX ON plain(payload);
VACUUM ANALYZE;
VACUUM ANALYZE parted;

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

EXPLAIN (COSTS OFF)
SELECT * FROM plain p1 JOIN plain p2 USING (payload) LIMIT 100;
EXPLAIN (COSTS OFF)
SELECT * FROM parted p1 JOIN parted p2 USING (payload) LIMIT 100;

/*
 Limit
   ->  Nested Loop
         ->  Seq Scan on plain p1
         ->  Memoize
               Cache Key: p1.payload
               Cache Mode: logical
               ->  Index Scan using plain_payload_idx on plain p2
                     Index Cond: (payload = p1.payload)

 Limit
   ->  Merge Join
         Merge Cond: (p1.payload = p2.payload)
         ->  Merge Append
               Sort Key: p1.payload
               ->  Index Scan using parted_p1_payload_idx on parted_p1 p1_1
               ->  Index Scan using parted_p2_payload_idx on parted_p2 p1_2
         ->  Materialize
               ->  Merge Append
                     Sort Key: p2.payload
                     ->  Index Scan using parted_p1_payload_idx on parted_p1 p2_1
                     ->  Index Scan using parted_p2_payload_idx on parted_p2 p2_2
*/

Планы запросов выглядят оптимально: в зависимости от лимита будет выбрано только минимально необходимое число строк, поскольку имея индекс по атрибуту соединения мы уже имеем упорядоченный доступ к данным. А теперь спровоцируем сложное поддерево под аппендом, путём включения Partitionwise Join:

SET enable_partitionwise_join = 'true';
EXPLAIN (COSTS OFF)
SELECT * FROM parted p1 JOIN parted p2 USING (payload) LIMIT 100;
/*
 Limit
   ->  Append
         ->  Nested Loop
               Join Filter: (p1_1.payload = p2_1.payload)
               ->  Seq Scan on parted_p1 p1_1
               ->  Materialize
                     ->  Seq Scan on parted_p1 p2_1
         ->  Nested Loop
               Join Filter: (p1_2.payload = p2_2.payload)
               ->  Seq Scan on parted_p2 p1_2
               ->  Materialize
                     ->  Seq Scan on parted_p2 p2_2
*/

При том, что ничего не изменилось в данных, мы видим, что выбран неудачный план. Причина такой деградации в том, что при планировании Append'a, оптимизатор выбирает самый дешёвый по критерию startup_cost план. А им является тот, который содержит NestLoop + SeqScan - с точки зрения скорости запуска такого плана, при отсутствии необходимости сканировать таблицы совсем, такой план немного выигрывает даже у очевидного NestLoop + IndexScan.

Так работает текущий Postgres, включая dev-ветку. Однако эта проблема исправляется достаточно просто, путём добавления соответствующей логики в код оптимизатора. Совместными с @billexpи @apyhalov усилиями мы подготовили патч, который можно найти на текущем коммитфесте , и который фиксит данную проблему. А в треде с его обсуждением можно найти ещё одно интересное наблюдение по поводу коррекции startup_cost оператора последовательного сканирования, имплементация которого также может облегчить ситуацию с выбором неоптимальных fractional paths для случая с LIMIT 1.

Применив данный патч получим уже премлемый план запроса:

 Limit
   ->  Append
         ->  Nested Loop
               ->  Seq Scan on parted_p1 p1_1
               ->  Memoize
                     Cache Key: p1_1.payload
                     Cache Mode: logical
                     ->  Index Scan using parted_p1_payload_idx on parted_p1 p2_1
                           Index Cond: (payload = p1_1.payload)
         ->  Nested Loop
               ->  Seq Scan on parted_p2 p1_2
               ->  Memoize
                     Cache Key: p1_2.payload
                     Cache Mode: logical
                     ->  Index Scan using parted_p2_payload_idx on parted_p2 p2_2
                           Index Cond: (payload = p1_2.payload)

А вот следующая проблема уже не имеет простого решения в настоящий момент.

Проблема вычисляемого лимита

Рассмотрим следующий запрос:

EXPLAIN (COSTS OFF)
SELECT * FROM parted p1 JOIN parted p2 USING (payload,y)
ORDER BY payload,y LIMIT 100;

Запустив его с нашим патчем вы получите хороший план - будет использован NestLoop с параметризованным сканированием по индексу, который вернет только минимально необходимое количество строк. Однако путем простого уменьшения лимита мы получим изначальную унылую картину:

EXPLAIN (COSTS OFF)
SELECT * FROM parted p1 JOIN parted p2 USING (payload,y)
ORDER BY payload,y LIMIT 1;

/*
Limit
   ->  Merge Append
         Sort Key: p1.payload, p1.y
         ->  Merge Join
               Merge Cond: ((p1_1.payload = p2_1.payload) AND (p1_1.y = p2_1.y))
               ->  Sort
                     Sort Key: p1_1.payload, p1_1.y
                     ->  Seq Scan on parted_p1 p1_1
               ->  Sort
                     Sort Key: p2_1.payload, p2_1.y
                     ->  Seq Scan on parted_p1 p2_1
         ->  Merge Join
               Merge Cond: ((p1_2.payload = p2_2.payload) AND (p1_2.y = p2_2.y))
               ->  Sort
                     Sort Key: p1_2.payload, p1_2.y
                     ->  Seq Scan on parted_p2 p1_2
               ->  Sort
                     Sort Key: p2_2.payload, p2_2.y
                     ->  Seq Scan on parted_p2 p2_2
*/

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

Фундаментальная проблема здесь в том, что оптимизатор знает только цифру итогового лимита на количество строк в запросе/подзапросе. В нашем случае, когда IncrementalSort должен будет досортировать поступающие данные, при планировании оператора Append оптимизатор не может предположить, сколько данных потребуется: в результате может потребоваться только одна строка, так и все строки из каждой партиции - в зависимости от распределения данных в столбце у.

Даже представив теоретически, что мы научим IncrementalSort вычислять количество групп по столбцу payload и, на основании этого оценивать максимально потребное количество строк в каждой партиции, мы не можем улучшить план, поскольку планирование оператора Append уже завершено, возможные варианты его исполнения уже зафиксированы - мы ведь планируем запрос снизу-вверх!

Подводя итог. Партиционированные таблицы таки сильно усложняют задачу для текущей версии Postgres, ограничивая пространство поиска оптимальных планов запросов и процесс перехода на них следует тщательно тестировать, делая упор на кейсы, где требуется некоторая лимитированная выборка данных из таблиц и нет очевидного прунинга партиций на этапе планирования. И хотя направление активно развивается, можно ожидать улучшений ситуации в ближайшем будущем (особенно, если пользователи будут активнее репортить возникающие проблемы), но существуют кейсы, где решение в рамках существующей архитектуры не очевидно и требует дополнительного R&D.

Согласны вы с моими выводами, или я только что ерунду написал? Пожалуйста, оставьте ваше мнение в комментариях.

THE END.

9 Декабря 2024, Паттайя, Тайланд.

Комментарии (0)