Trino — это популярный SQL-движок для выполнения аналитических запросов к озерам данных и виртуализации. Наша команда создает коммерческий форк Trino, который называется CedrusData. В России Trino набирает популярность как compute движок для новых платформ данных на основе архитектуры lakehouse и замещения Greenplum.

В статье я расскажу, как мы ускорили запросы, научив оптимизатор Trino удалять из плана ненужные Join операторы. Подобная оптимизация (также называемая join elimination, join pruning или join culling) реализована в большом количестве аналитических продуктов, но отсутствует в ванильном Trino.

Мы обсудим, почему в аналитических запросах часто возникают избыточные Join, почему это плохо для SQL-движков, какие эквивалентные преобразования позволяют избавиться от ненужных Join, и с какими проблемами мы столкнулись при интеграции данного функционала в оптимизатор Trino.

Мотивация

Аналитические запросы обычно представляют собой расчеты на основе нескольких таблиц. При этом пользователи могут обращаться не только к целевым таблицам, но и виртуальным представлениям (view). View повышают удобство, упрощают управление безопасностью, а в ряде случае и вовсе являются неотъемлемой частью архитектуры (например, data vault). BI-инструменты часто используют семантические модели для генерации SQL-запросов к источнику данных. С некоторой натяжкой мы можем рассматривать семантические модели как view, которые определены на уровне BI инструмента. Таким образом, пользователи аналитических систем часто используют view в явном или неявном виде.

Рассмотрим следующую схему данных, состояющую из таблицы фактов sales и нескольких измерений.

Рис. 1. Схема данных
Рис. 1. Схема данных

Мы можем определить view, который использует все связанные таблицы:

CREATE VIEW sales_view AS
SELECT * 
FROM sales
  INNER JOIN store ON sales.store_id = store.id
  INNER JOIN item ON sales.item_id = item.id
  INNER JOIN employee ON sales.employee_id = employee.id
  INNER JOIN campaign ON sales.campaign_id = campaign.id

Теперь мы можем использовать view для выполнения широкого спектра запросов. Например, мы можем посчитать продажи по магазинам:

SELECT store.name, SUM(sales.amount)
FROM sales_view
GROUP BY store.name

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

Рис. 2. План при обращении ко view
Рис. 2. План при обращении ко view

Таким образом, при использовании view мы меняем удобство пользователя на скорость. Не самый приятный компромисс. Если мы научим движок определять, что в запросе присутствуют операторы Join, которые не влияют на конечный результат, и удалять их из плана, то мы сможем существенно увеличить производительность. Соответствующая оптимизация называется join pruning.

Когда Join pruning возможен?

Немного скучной теории. Для простоты мы будем рассматривать только INNER JOIN. Для OUTER JOIN правила немного меняются, но мы их опустим, чтобы не перегружать статью.

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

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

Рассмотрим следующий запрос, в котором мы используем только колонки таблицы sales:

SELECT SUM(sales.amount)
FROM sales
  INNER JOIN store ON sales.store_id = store.id

Чтобы безопасно удалить Join с таблицей store, мы должны быть уверены, что каждая запись из sales будет возвращена ровно столько же раз, как и в присутствии Join. В общем случае мы не можем это гарантировать:

  1. Некоторые записи таблицы sales могут быть отфильтрованы оператором Join, если для них нет соответствующих записей в таблице store.

  2. Некоторые записи таблицы sales могут быть возвращены несколько раз, если колонка store.id не является уникальной.

Рис. 3. Возможные варианты соотошений записей таблиц
Рис. 3. Возможные варианты соотошений записей таблиц

Чтобы безопасно удалить Join нам нужно доказать, что описанные выше ситуации невозможны.

Во-первых, если существует внешний ключ FOREIGN KEY (sales.store_id) REFERENCES (store.id), то у нас точно не будет записей в таблице sales, которые были бы отфильтрованы из-за отсутствия соответствующей записий в таблице store.

Рис 4. Эффект FOREIGN KEY constraint
Рис 4. Эффект FOREIGN KEY constraint

Во-вторых, если колонка store.id уникальна, ни одна запись из таблицы sales не будет возвращена более, чем один раз.

Рис 5. Эффект PRIMARY KEY / UNIQUE constraint
Рис 5. Эффект PRIMARY KEY / UNIQUE constraint

В-третьих, мы должны гарантировать, что sales.store_id не имеет NULL значений, так как наш Join их также отфильтровывает. Альтернативно, мы можем вручную добавить предикат store_id IS NOT NULL после удаления Join.

Рис 6. Эффект NOT NULL constraint
Рис 6. Эффект NOT NULL constraint

При выполнении всех трех условий, мы можем удалить Join с таблицей store. При этом результат запроса останется неизменным.

Рис. 7. Join pruning
Рис. 7. Join pruning

На основе данных правил join pruning реализован в различных продуктах. Например: Databricks, SAP HANA, Snowflake, Tableau.

Стоит отметить, что аналитические системы и BI инструменты, как правило, не могут гарантировать, что заданные constraint действительно выполняются. Вместо этого вы как пользователь явно уведомляете движок о том, что доверяете тем или иным constraint.

Как мы добавляли Join pruning в Trino

Данный параграф содержит скучные детали того, как мы добавляли join pruning в Trino. Если вы не являетесь фанатом внутренностей СУБД, можете сразу перейти к следующему параграфу.

Теоретические основы join pruning понятны. Но интеграция изменений в конкретный продукт зачастую сопряжена с рядом сложностей, обусловленных архитектурными особенностями системы. Trino не является исключением.

Первым делом нам потребовалось добавить понятие constraint (PRIMARY KEY, UNIQUE, FOREIGN KEY, NOT NULL) в оптимизатор Trino. К несчастью, в Trino в принципе нет понятия constraint.

Мы предложили сообществу Trino добавить constraint в оптимизатор, но получили отказ. Мотивация: Trino не сможет гарантировать, что constraint действительно выполняется. Это действительно так, но в мире аналитических систем отсутствие валидации constraint давно не является проблемой — см. ключевое слово RELY в документации аналитических движков. Тем не менее мы не смогли оперативно переубедить сообщество. Это означало, что нам придется добавить понятие constraint в свой форк, а не в Trino. Сказано — сделано. Мы расширили парсер Trino, и добавили возможность задания нужных нам constraint через параметры конфигурации или свойства сессии (чтобы управлять оптимизацией в рамках отдельного запроса). Например:

SET SESSION cedrusdata_constraints = ARRAY[
  'PRIMARY KEY example.tpch.part(partkey)',
  'PRIMARY KEY example.tpch.supplier(suppkey)', 
  'FOREIGN KEY example.tpch.partsupp(partkey) REFERENCES example.tpch.part(partkey)',
  'FOREIGN KEY example.tpch.partsupp(suppkey) REFERENCES example.tpch.supplier(suppkey)'
];

Далее нам необходимо было понять, на каком этапе оптимизации делать join pruning. Планировщик Trino состоит из нескольких десятков фаз, называемых "optimizers", которые следуют друг за другом в строгом порядке, см. класс PlanOptimizers. Мы подобрали подходящее место для встраивания нашей оптимизации. Например, мы точно знали, что наш optimizer должен работать после PredicatePushDown, но до PushPredicateIntoTableScan. Почему? Потому что оптимизатор PredicatePushDown умеет преобразовывать некоторые CROSS JOIN в INNER JOIN, что крайне важно для join pruning. А PushPredicateIntoTableScan наоборот может существенно затруднить join pruning, так как в некоторых случаях он безвозвратно скрывает некоторую важную для нас информацию о предикатах. Все это тонкие нюансы работы оптимизатора Trino, которые не слишком интересны конечному пользователю, но крайне важны для корректной реализации сложных улучшений.

Оставалось лишь дорисовать сову — реализовать сам join pruning. Сначала мы добавили специальную механику, которая позволяет определить для каждого оператора набор текущих constraint и предикатов. Технически это паттерн visitor, который обходит дерево операторов снизу вверх и рассчитывает текущие ограничения оператора.

Рис. 8. Выведение constraint для операторов
Рис. 8. Выведение constraint для операторов

Далее мы реализовали логику, которая для каждого оператора Join определяет на основе доступных constraint, возможно ли удалить одну из его сторон. Если да, то мы просто заменяем Join на один из его входов, и опционально добавляем сверху NOT NULL фильтр для колонок, участвующих в join condition.

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

  • Добавили учет транзитивности. Например, мы можем переписать GROUP BY store.id на GROUP BY store.store_id, так как условие sales INNER JOIN store ON sales.store_id = store.id гарантирует эквивалентность данных колонок. Данное переписывание позволяет в некоторых случаях удалить Join, даже если в запросе мы ссылаемся на колонки как правого, так и левого входов.

  • Добавили поддержку OUTER JOIN. Данное улучшение требует некоторой модификации правил переписывания дерева операторов, но в целом реализуется даже проще, чем поддержка INNER JOIN. Вместе с тем, OUTER JOIN встречается достаточно часто, поэтому без его поддержки мы не смогли бы в должной степени раскрыть потенциал нашей оптимизации.

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

Рис. 9. Join трех таблиц, топология chain
Рис. 9. Join трех таблиц, топология chain

Создаем view, который объединяет все три таблицы:

CREATE VIEW sales_view AS
SELECT * 
FROM sales
  INNER JOIN store ON sales.store_id = store.id
  INNER JOIN city ON store.city_id = city.id

Обращаемся к колонкам sales, но join pruning не происходит. В чем проблема?

SELECT sales.amount
FROM sales_view

В процессе анализа мы быстро поняли, что виноват порядок Join. Если оптимизатор принимал решение расположить Join в порядке sales JOIN (store JOIN city), то join pruning отрабатывал успешно:

Рис. 10. Порядок Join, при котором возможен pruning таблиц store и city
Рис. 10. Порядок Join, при котором возможен pruning таблиц store и city

Если же порядок Join был (sales JOIN store) JOIN city, то возникала неприятная ситуация: (sales JOIN store) обязан вернуть колонку store.city для выполнения вышестоящего Join. Таким образом, оптимизатор считал, что нам нужны колонки с обоих входов нижнего Join, и не выполнял join pruning.

Рис. 11. Порядок Join, при котором невозможен pruning таблицы store
Рис. 11. Порядок Join, при котором невозможен pruning таблицы store

Иными словами, промежуточный Join может использовать колонки, которые нужны только вышестоящим Join, но не нужны пользователю. Обработать данную ситуацию с помощью бинарных Join затруднительно. Мы поняли, что вместо бинарных Join (2-way) нам нужно использовать N-way Join.

Многие оптимизаторы умеют объединять соседние операторы Join в один большой N-way Join. В Trino есть несколько реализаций подобной логики, одна из которых называется JoinGraph. Мы переписали наш join pruning optimizer таким образом, чтобы он находил соседние Join, объединял их в JoinGraph, и уже после этого определял, какие входы могут быть удалены.

Рис. 12. Пример JoinGraph
Рис. 12. Пример JoinGraph

Теперь наша реализация join pruning корректно определяла все таблицы, которые могут быть безопасно удалены из плана.

Но возникла новая проблема! В Trino JoinGraph является промежуточным представлением плана, которое обязательно должно быть преобразовано обратно в набор бинарных Join по окончании оптимизации. В процессе тестирования мы обнаружили, что порядок Join до преобразования в JoinGraph может отличаться от порядка Join, который получается после "разворачивания" JoinGraph обратно в бинарные Join. Это обусловлено тем, что JoinGraph был добавлен в Trino для реализации одной конкретной оптимизации (см. EliminateCrossJoins), в которой в силу ряд особенностей финальный порядок Join не имеет значения. В нашем же случае мы обязаны были сохранить оригинальный порядок Join, так как этот порядок может быть задан пользователем сознательно. В противном случае пользователь мог бы столкнуться с ситуацией, когда наш новый модный функционал многократно замедляет выполнение запроса из-за нарушения порядка Join. От обилия подобных нюансов у нас начинало немного подгорать.

Рис. 13. Нежелательное изменение порядка Join при использовании JoinGraph
Рис. 13. Нежелательное изменение порядка Join при использовании JoinGraph

Для преодоления данной проблемы нам пришлось отказаться от идеи использования уже существующего JoinGraph, и написать свою реализацию, которая гарантирует сохранение оригинального порядка Join.

Был еще ряд неожиданных мелких проблем, которые я не буду описывать. В конце концов мы убедились, что все целевые сценарии join pruning (топологии chain, start, starflake) работают, и запросы возвращают корректные результаты.

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

Проверим оптимизацию в действии. Мы будем использовать схему данных TPC-DS, scale factor 1000. Данные хранятся в S3, формат Parquet, коннектор Hive. Два узла CedrusData по 32 логических ядра и 128 Gb оперативной памяти.

Создадим view, который объединяет таблицу фактов store_sales (~2.7 млрд строк) с четырьмя измерениями:

CREATE VIEW store_sales_view AS
SELECT * 
FROM store_sales
  INNER JOIN customer_address ON ss_addr_sk = ca_address_sk
  INNER JOIN customer_demographics ON ss_cdemo_sk = cd_demo_sk
  INNER JOIN date_dim on ss_sold_date_sk = d_date_sk
  INNER JOIN item ON ss_item_sk = i_item_sk

Попробуем посчитать сумму продаж по годам без join pruning.

SELECT d_year, SUM(ss_quantity * ss_list_price)  
FROM store_sales_view
GROUP BY d_year
ORDER BY d_year

Время выполнения запроса: 49 секунд. Посмотрим на план:

EXPLAIN (TYPE LOGICAL, FORMAT GRAPHVIZ)
SELECT d_year, SUM(ss_quantity * ss_list_price)  
FROM store_sales_view
GROUP BY d_year
ORDER BY d_year

Видим, что все четыре Join из оригинального view остались на своих местах:

Рис. 14. План запроса до применения join pruning
Рис. 14. План запроса до применения join pruning

Включим join pruning, задав в явном виде constraints (некоторые детали опущены для простоты):

SET SESSION cedrusdata_constraints = ARRAY[
    'PRIMARY KEY customer_address(ca_address_sk)',
    'PRIMARY KEY customer_demographics(cd_demo_sk)',
    'PRIMARY KEY date_dim(d_date_sk)', 
    'PRIMARY KEY item(i_item_sk)',
    'FOREIGN KEY store_sales(ss_addr_sk) REFERENCES customer_address(ca_address_sk)', 
    'FOREIGN KEY store_sales(ss_cdemo_sk) REFERENCES customer_demographics(cd_demo_sk)', 
    'FOREIGN KEY store_sales(ss_sold_date_sk) REFERENCES date_dim(d_date_sk)', 
    'FOREIGN KEY store_sales(ss_item_sk) REFERENCES item(i_item_sk)'
]

Запустим запрос еще раз. Время выполнения с join pruning: 24 секунды. План запроса показывает, что остался только один Join между store_sales и date_dim:

Рис. 15. План запроса после применения join pruning
Рис. 15. План запроса после применения join pruning

Нам удалось ускорить запрос в два раза. Достаточно неплохой результат! Значит, наши мучения с оптимизатором Trino не были напрасными.

На широком наборе запросов данная оптимизация может давать прирост производительности от нескольких десятков до сотен процентов.

Вместо заключения

В CedrusData мы постоянно добавляем важные улучшения производительности, которые позволяют нашим клиентам быстрее решать свои аналитические задачи. Join pruning — лишь одна из многих оптимизаций, которые мы уже реализовали в нашем форке Trino. Получить пробную версию CedrusData можно по ссылке.

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

Ну а если вы хотите вместе с нами работать над современными open-source проектами, такими как Trino, Apache Iceberg или DuckDB, то мы всегда будем рады получить ваше резюме!

В следующей статье мы расскажем, как нам в CedrusData удалось кратно ускорить чтение и запись данных из/в Greenplum через протокол GPFDIST.

Не забывайте подписаться на наш Telegram-канал о Trino и CedrusData (без пяти минут 500 участников!), YouTube-канал c докладами о Trino, и блог на Хабре.

Связь.

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


  1. Tzimie
    17.09.2024 06:01

    Замечательно! Но не могу не заметить, что MSSQL делает такое из коробки. Конечно, сейчас в одной стране после известных событий по понятным причинам все переходят на Постгре, но тем не менее...


    1. redfox0
      17.09.2024 06:01

      Многие СУБД умеют самые различные оптимизации: https://javarush.com/groups/posts/423-kljevihe-optimizacii-sql-ne-zavisjajshie-ot-stoimostnoy-modeli-chastjh-5-