В то время, как пользователи видят позитивные стороны технологий, мы, разработчики, обычно сталкиваемся с ограничениями/недоработками/багами и видим наш продукт с совсем другой стороны. Вот и в этот раз: после публикации результатов сравнительного тестирования где прогонялись запросы теста 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, Паттайя, Тайланд.
ссылка на оригинал статьи https://habr.com/ru/articles/864910/
Добавить комментарий