Возможности комбинаторов в ClickHouse

Что делать с запросами к СУБД, выполнение которых затягивается на десятки минут, как можно оптимизировать вложенные операторы, чтобы получить нужные данные за секунды? За счет чего подобные операции выполняются в Visiology автоматически? Ответы на эти вопросы мы попробуем дать сегодня на примере небольшого синтетического теста со сложным SQL-запросом, и разберемся при чем тут комбинаторы в ClickHouse. Эта статья будет полезна тем, кто интересуется SQL-оптимизаторами, а также всем существующим и будущим пользователям Visiology, кто хочет заглянуть под капот системы. Если вы из их числа, добро пожаловать под кат :)

По мере того, как количество данных на BI-платформе растет, становится очевидно, что для комфортной работы важны не только возможности системы, но и ее производительность. В Visiology есть возможность обрабатывать запросы, затрагивающие сотни миллионов строк, благодаря тому, что в Visiology используется собственный движок ДанКо. Тут помогают синтаксис DAX и высокопроизводительная колоночная СУБД ClickHouse. Чтобы платформа работала быстро в состав движка ДанКо входят SQL-оптимизаторы, которые значительно повышают эффективность выполнения запросов. 

Важной частью DAX-движка ДанКо в Visiology являются SQL оптимизаторы, которые помогают производить преобразования SQL с учетом используемой модели данных.

0db4854288eae62a31adc5dd1195878e.png

Комбинаторы ClickHouse дают дополнительные преимущества для оптимизаторов SQL ДанКо, которых нет в других реляционных базах данных.

Рассмотрим пример работы одного из SQL оптимизаторов ДанКо, который использует комбинаторы ClickHouse и информацию о кардинальности (в смысле баз данных, т.е., по сути, доля уникальных значений в столбцах таблиц) на тестовых данных:

  • Таблица sales со 100 миллионами записей с полями уникальный номер заказа order_number, количество amount, дата заказа order_date и тип продажи sales_type (число с тремя значениями: 0, 1 и 2).

  • Таблица календаря dates с 10 тысячами записей с полем дата date, месяц month и год year

Будет использован следующий SQL для генерации таблиц в ClickHouse:

CREATE OR REPLACE TABLE sales
(
   order_number Int64,
   amount       Float64,
   order_date   Date,
   sales_type   Int64
) ENGINE = Log;
INSERT INTO sales
SELECT toString(1000000000 + number) AS order_number,
      number % 100                  AS amount,
      dateAdd(number % 700, toDate('2023-01-01')),
      number % 3                    AS sales_type
FROM system.numbers
LIMIT 100000000;
CREATE OR REPLACE TABLE dates(date Date, month Int64, year Int64) ENGINE = Log;
INSERT INTO DATES
SELECT dateAdd(number, toDate('2023-01-01')) AS date,
      toMonth(dateAdd(number, toDate('2023-01-01'))) AS month,
      toYear(dateAdd(number, toDate('2023-01-01'))) AS year
FROM system.numbers
LIMIT 10000;

Наш пример может показаться простым, но, тем не менее, он интересен даже просто с точки зрения работы реляционных баз данных. Это будет синтетический запрос с проблемами производительности (ведь на запросе без проблем производительности ничего и не видно), но на его примере можно проиллюстрировать возможности SQL оптимизаторов. Видно, что до оптимизации в этом запросе есть не то, что один CROSS JOIN, а даже вложенные друг в друга CROSS JOIN в CROSS JOIN:

-- до оптимизации - выполняется 12 секунд
SELECT expression_result.grouped_sales_type AS grouped_sales_type,
      expression_result.grouped_date       AS grouped_date,
      if(if(expression_result.grouped_date <=
            max(if(toLastDayOfMonth(expression_result.grouped_date) = expression_result.date,
                   toLastDayOfMonth(addYears(expression_result.date, -1)),
                   addYears(expression_result.date, -1))),
            true, false) AND
         if(expression_result.grouped_date >=
            min(if(toLastDayOfMonth(expression_result.date) = expression_result.date,
                   toLastDayOfMonth(addYears(expression_result.date, -1)),
                   addYears(expression_result.date, -1))),
            true, false), true, false)         AS condition
FROM (SELECT grid.grouped_sales_type AS grouped_sales_type,
            grid.date               AS grouped_date,
            dates.date              AS date
     FROM dates AS dates
              CROSS JOIN (SELECT grid.grouped_sales_type AS grouped_sales_type,
                                 dates.date              AS date
                          FROM (SELECT sales.sales_type AS grouped_sales_type
                                FROM sales AS sales
                                GROUP BY sales.sales_type) AS grid
                                   CROSS JOIN dates AS dates) AS grid
        ) AS expression_result
GROUP BY expression_result.grouped_sales_type,
        expression_result.grouped_date;

Для такого вида запроса можно дать следующее словесное описание:

Проверка попадания даты в период и особая логика для последнего дня месяца

Да, глубокого смысла в запросе нет, он полезен с точки зрения иллюстрации возможностей оптимизации. Ведь это, казалось бы, совершенно безнадежный запрос — 2 вложенных друг в друга CROSS JOIN. Такого вида SQL при увеличении количества записей в таблице календаря dates, например, до 30 тысяч, может выполняться минуты. Но ClickHouse дает в чем-то уникальные возможности оптимизации за счет комбинаторов. В итоге можно сравнительно легко оптимизировать запрос с таким уровнем вложенности.

Итак, после оптимизации методами ДанКо, мы получаем:

-- after - выполняется меньше секунды
SELECT expression_result.grouped_sales_type AS grouped_sales_type,
      expression_result.grouped_date       AS grouped_date,
      if(if(expression_result.grouped_date <=
            maxMerge(complex_date_max_state), true, false) AND if(expression_result.grouped_date >=
            minMerge(complex_date_min_state),
            true, false), true, false)         AS condition
FROM (SELECT grid.grouped_sales_type AS grouped_sales_type,
            grid.date               AS grouped_date,
            maxStateMerge(complex_max_state)              AS complex_date_max_state,
            minStateMerge(complex_min_state)              AS complex_date_min_state
     FROM (SELECT
               maxState(if(toLastDayOfMonth(dates.date) = dates.date,
                  toLastDayOfMonth(addYears(dates.date, -1)),
                  addYears(dates.date, -1))) AS complex_max_state,
               minState(if(toLastDayOfMonth(dates.date) = dates.date,
                  toLastDayOfMonth(addYears(dates.date, -1)),
                  addYears(dates.date, -1))) AS complex_min_state
         FROM dates AS dates)
          AS dates
              CROSS JOIN (SELECT grid.grouped_sales_type AS grouped_sales_type,
                                 dates.date              AS date
                          FROM (SELECT sales.sales_type AS grouped_sales_type
                                FROM sales AS sales
                                GROUP BY sales.sales_type) AS grid
                                   CROSS JOIN dates AS dates) AS grid
         GROUP BY grid.grouped_sales_type AS grouped_sales_type,
            grid.date               AS grouped_date
   ) AS expression_result
GROUP BY expression_result.grouped_sales_type,
        expression_result.grouped_date;

Видно, что оптимизация происходит «на 3 уровня вниз» с использованием maxMerge/MaxStateMerge/maxState (и аналогично maxMerge/MaxStateMerge/maxState), позволяет успешно выполнить менее чем за секунду достаточно «безнадежный» с точки зрения производительности синтетический запрос с двумя вложенными CROSS JOIN для таблицы продаж  с 100 млн записей и календарем с 10 тысячами записей, причем, обратите внимание, вложенные CROSS JOIN остаются на месте! Это достигается за счет комбинаторов ClickHouse.

Интересно, что оптимизация производится с учетом кардинальностей, а sales_type у нас — низкокардинальный (т.е. содержит небольшое количество уникальных значений — всего 3 разных значения). Поэтому GROUP BY по sales_type оправдан и приносит улучшение производительности.

Если в запросе вместо низкокардинального sales_type (с тремя разными значениями) будет использоваться уникальный (и высококардинальный) order_number, то GROUP BY order_number не будет иметь смысла с точки зрения производительности, соответственно, такой в таком случае ДанКо и не будет делать такую оптимизацию из соображений кардинальности.

Заключение

Конечно, такого вида пример — лишь показательный образец применения комбинаторов на синтетическом SQL, и я здесь привел его только для демонстрации возможностей оптимизаторов ДанКо, которые используют особенности ClickHouse. Но подобная задача может быть актуальна при автоматическом построении запросов самых разных типов. 

Желаю успехов в BI и построении дашбордов!

© Habrahabr.ru