Все мы любим ClickHouse, но прекрасно знаем, что у этой СУБД есть свои особенности и ограничения. В этой статье мы поговорим о том, почему нужно избавляться от лишних операторов JOIN, если вы работаете с большими нагрузками, а также оценим, какой эффект дает исключение JOINов, поднятие их на уровень выше, перестановка таблиц местами и некоторые другие хитрые трюки на уровне кода SQL. Всех, кто работает с ClickHouse, а также тех, кто не хочет работать с ClickHouse, но подумывает получить все готовенькое от Visiology, приглашаю под кат!
Привет, дорогой читатель! Меня зовут Руслан Халиуллин, я работаю в команде разработки аналитического движка Visiology 3 ДанКо. На летней конференции HighLoad++ наш архитектор Никита Ильин уже рассказывал о движке, модели данных и применяемых оптимизациях. И сегодня мы подробнее поговорим о том, как происходит процесс работы с ClickHouse “под капотом”, когда пользователи видят красивый DAX и получают быстрые результаты.
При всей нашей искренней любви к ClickHouse, которая естественна для всех российских вендоров BI, на самом деле весь секрет высокой производительности и удобства Visiology 3 заключается не в самом ClickHouse, а в том, что “над ним”. Весь секрет — в ДанКо. Это наш движок, который основывается на базе ClickHouse 23.3.4.17 для хранения данных и выполнения пользовательских запросов и PostgreSQL 15 для работы с метаданными.
Основные задачи, которые решает наш движок:
- загрузка данных из разных источников
- управление метаданными TOM (Tabular Object Model)
- управление доступом к данным (RLS)
- обеспечение масштабируемости и отказоустойчивости движка
- поддержка выполнения DAX (такого же как в PowerBI).
Последний пункт, хоть и не является единственной “обязанностью” ДанКо, но обычно вызывает наибольший интерес. Если разобрать работу с DAX по этапам, то она будет выглядеть следующим образом:
-
Клиент отправляет DAX-запрос
-
Строится дерево DAX
-
Применяется алгоритм преобразования DAX в SQL
-
Оптимизатор SQL улучшает запрос
-
Запрос выполняется в ClickHouse
-
Результат передается клиенту
ДанКо-магия
И тут я предлагаю обратить пристальное внимание на “пункт №4”. Оптимизация очень важна! И если кто-то думает, что можно легко передать любой запрос в ClickHouse в виде SQL…то он, конечно прав. Но для этого существует миллион способов. Как уже писал мой коллега Антон Кондауров, можно сгенерировать и послать огромные многоуровневые SQL-запросы. Отправить такой запрос в ClickHouse напрямую возможно, но это может привести к нежелательным последствиям:
Чтобы избежать этого на уровне кода каждая DAX-функция имеет свой обработчик, который отвечает за построение соответствующего SQL-подзапроса, используя обратный обход в глубину. В процессе обработки DAX-формулы создается новое дерево, но уже на основе SQL.
Конечно, нам хотелось бы сразу построить оптимальный SQL, чтобы для каждого оператора DAX была своя выверенная рабочая схема. Однако на практике такой подход оказался неэффективным. В первой версии нашего алгоритма, зашитого в движок ДанКо, мы уже пробовали применить такой подход. И изначально разработка шла хорошо, а поддержка функций без многоуровневых вложенностей развивалась достаточно быстро. Например, первая версия движка уже поддерживала подобную формулу:
EVALUATE
SUMMARIZECOLUMNS (
'product'[classname],
'product'[colorname],
"result",
CALCULATE (
SUM ( 'facts'[unitprice] ),
REMOVEFILTERS ( 'product' ),
SUMMARIZE ( 'product', 'product'[classname] ),
FILTER ( 'product', 'product'[classname] IN { "Economy" } )
)
)
Такой запрос возвращает таблицу, где для каждого класса и цвета товара отображается сумма цен по товарам класса 'Economy', при этом игнорируются любые другие фильтры, наложенные на таблицу 'product'.
Но по мере усложнения задач выяснилось, что для реальной работы нужен не выверенный алгоритм, а методы оптимизации, которые помогают адаптироваться к разным ситуациям. Помню, как мы столкнулись с проблемой, когда продуктовая команда поставила задачу реализовать поддержку сценариев накопленного итога (Year-to-Date, YTD).
То есть запрос выглядел примерно так:
EVALUATE
SUMMARIZECOLUMNS (
‘date’[year],
'date'[month],
"Sales",
CALCULATE (
SUM ( 'facts'[unitprice] ),
FILTER (
ALL ( ‘date’ ),
AND(
‘date’[datekey] >= DATE(YEAR(MAXX(‘date’, ‘date’[datekey])), 1, 1),
‘date’[datekey] <= MAXX ( ‘date’, ‘date’[datekey] )
)
)
)
)
С этого момента мы поняли, что пора заняться существенной переработкой функционала. Дело было в том, что код, написанный "на if-чиках" стало очень тяжело нести дальше.. Да, при этом строился сразу максимально простой и оптимальный SQL - сопровождение такого решения была крайне ресурсоемкой. Чтобы добавить новый функционал нужно было перерабатывать очень много кода. Также в случае возникновения багов возникали бы сложности с поддержки.
Все что нужно было делать дальше — грубо говоря, реализовать свой Visitor и продумать правильную обертку функции в SQL. Да, есть и более сложные кейсы, когда этого недостаточно…но их очень мало, и если нужно с ними можно разобраться отдельно. В целом новый подход позволил легко добавлять новые функции и исключить целый спектр проблем на самых разных участках.
Оптимизации движка
Итак, много месяцев назад мы пошли путем оптимизаций движка и достигли хороших результатов. Например, в последнем релизе Visiology 3.10 удалось повысить скорость доступа к данным до 13 раз по сравнению с 3.9! Паттерн Visitor, который мы используем для разработки оптимизаций, позволяет обрабатывать различные элементы дерева SQL-запросов. На момент написания статьи у нас уже реализовано несколько десятков SQL-оптимизаций, среди которых rule-based и эвристические подходы, учитывающие специфику работы ClickHouse.
Удаление избыточных JOIN
Один из самых полезных классов оптимизаций SQL в Visiology 3 — это борьба с операторами JOIN, которые очень не любит СУБД ClickHouse. Избыточные JOIN могут генерироваться в процессе работы алгоритма преобразования DAX в SQL. И если мы избавляемся от них, то делаем наш движок быстрее. В качестве примера для оптимизации рассмотрим простой кейс: сумма продаж товаров в разбиение по брендам.
Примерный DAX будет выглядеть следующим образом:
EVALUATE SUMMARIZECOLUMNS(
‘products’[brandname],
“Result”,
SUM(‘facts’[unitprice])
)
Чтобы задача не выглядела такой уж простой, также применим политику разделения прав доступа RLS с выражением:
'product'[colorname] IN {"Red","Blue"}
На самом деле здесь приведен сильно упрощенный DAX.
Реальный виджет сгенерирует немного более сложный DAX
DEFINE
VAR __DS0Core =
SUMMARIZECOLUMNS (
'product'[brandname],
"unitprice", SUM ( 'facts'[unitprice] )
)
EVALUATE
TOPN ( 3000, __DS0Core, 'product'[brandname], ASC, [unitprice], ASC )
ORDER BY
'product'[brandname] ASC,
[unitprice] ASC
В результате получим следующее:
В процессе преобразования DAX -> SQL сгенерируется следующий фрагмент SQL:
SELECT
`grouping_table_0`.`brandname` AS `brandname`,
SUM(`main_table`.`salesamount`) AS `scalar_result`
FROM `factonlinesales` AS `main_table`
INNER JOIN `dimproduct` AS `join_table_0`
ON `main_table`.`productkey` =
`join_table_0`.`productkey`
INNER JOIN (SELECT brandname, productkey
FROM `dimproduct`
WHERE colorname IN ('Red', 'Blue')) AS `grouping_table_0`
ON `main_table`.`productkey` =
`grouping_table_0`.`productkey`
GROUP BY `grouping_table_0`.`brandname`
-- Elapsed: 1.061 sec. Processed 200.01 million rows, 3.60 GB (188.43 million rows/s., 3.39 GB/s.)
Если присмотреться к коду, становится видно, что у нас имеется два INNER JOIN причем на одну и ту же таблицу, и по тому одному и тому же условию. Но информация о связях хранится в модели данных (признак ключевого поля), а модель данных Visiology аналогична PowerBI (Tabular Object Model)
После создания связей информация будет храниться в модели, которую мы в свою очередь можем использовать в целях повышения эффективности запросов и генерации корректных условий объединения. Мы можем воспользоваться метаданными и понять, что один из INNER JOIN избыточен. Кстати, именно это и делает оптимизатор, после работы которого мы получаем следующий SQL:
SELECT `join_table_0`.`brandname` AS `brandname`,
SUM(`main_table`.`salesamount`) AS `scalar_result`
FROM `factonlinesales` AS `main_table`
INNER JOIN (SELECT brandname, productkey
FROM `dimproduct`
WHERE colorname IN ('Red', 'Blue')) AS `join_table_0`
ON `main_table`.`productkey` =
`join_table_0`.`productkey`
GROUP BY `join_table_0`.`brandname`
-- Elapsed: 0.508 sec. Processed 200.00 million rows, 3.60 GB (393.72 million rows/s., 7.09 GB/s.)
Время выполнения такого SQL уже практически в 2 раза меньше, чем у неоптимизированного!
Перенос CROSS JOIN на уровень выше (CROSS JOIN lift up)
Еще одна оптимизация, направленная на “зачистку” JOIN’ов. Ее суть заключается в том, что агрегирование большего объема данных в неоптимизированном запросе не дает преимуществ, и подзапрос в CROSS JOIN можно поднять на уровень выше, что не влияет на результат агрегации, но значительно уменьшает количество обрабатываемых данных.
Пример запроса до оптимизации:
SELECT
brandname AS brandname,
gender AS gender,
sum(unitprice) AS scalar_result
FROM
(
SELECT
grid_grouping_table_0.brandname_IntKey AS brandname,
grouping_table_0.dimcustomer_gender_IntKey AS gender,
tableRef.unitprice AS unitprice
FROM `factonlinesales` AS tableRef
INNER JOIN `dimcustomer` AS grouping_table_0 ON tableRef.customerkey = grouping_table_0.dimcustomer_customerkey
CROSS JOIN
(
SELECT grouping_table_0.brandname_IntKey AS brandname_IntKey
FROM `dimproduct` AS grouping_table_0
GROUP BY grouping_table_0.brandname_IntKey
) AS grid_grouping_table_0
) AS expression_result
GROUP BY
brandname,
gender
-- Elapsed: 6.209 sec. Processed 205.00 million rows, 4.03 GB (33.02 million rows/s., 648.27 MB/s.)
Пример запроса после оптимизации:
SELECT
grid_grouping_table_0.brandname_IntKey AS brandname,
gender AS gender,
scalar_result AS scalar_result
FROM
(
SELECT
gender AS gender,
sum(unitprice) AS scalar_result
FROM
(
SELECT
grouping_table_0.dimcustomer_gender_IntKey AS gender,
tableRef.unitprice AS unitprice
FROM `factonlinesales` AS tableRef
INNER JOIN `dimcustomer` AS grouping_table_0 ON tableRef.customerkey = grouping_table_0.dimcustomer_customerkey
) AS expression_result
GROUP BY gender
) AS q1
CROSS JOIN
(
SELECT grouping_table_0.brandname_IntKey AS brandname_IntKey
FROM `dimproduct` AS grouping_table_0
GROUP BY grouping_table_0.brandname_IntKey
) AS grid_grouping_table_0
-- Elapsed: 2.528 sec. Processed 205.00 million rows, 4.03 GB (81.10 million rows/s., 1.59 GB/s.)
Как видно, после оптимизации время выполнения запроса сократилось более чем в два раза.
А чтобы убедиться, что после оптимизации агрегируется меньшее количество строк, включим трассировочные логи в ClickHouse и посмотрим вывод на этапе AggregatingTransform.
1b92661c477a :) set send_logs_level='trace'
Полный trace-log нам не нужен, и сегодня в статье далее ограничимся лишь...
важными фрагментами для наглядности.
До оптимизации:
[1b92661c477a] 2024.09.06 12:12:59.305456 [ 338 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 129127955 to 33 rows (from 2.16 GiB) in 6.383092333 sec. (20229686.218 rows/sec., 347.27 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.311600 [ 307 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 130962469 to 33 rows (from 2.20 GiB) in 6.389262603 sec. (20497274.433 rows/sec., 351.86 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.313333 [ 360 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 137136197 to 33 rows (from 2.30 GiB) in 6.390995532 sec. (21457720.681 rows/sec., 368.35 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.326882 [ 356 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 141480361 to 33 rows (from 2.37 GiB) in 6.404524396 sec. (22090689.683 rows/sec., 379.21 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.328622 [ 382 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 137554032 to 33 rows (from 2.31 GiB) in 6.406298282 sec. (21471687.072 rows/sec., 368.59 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.329879 [ 417 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 138669553 to 33 rows (from 2.32 GiB) in 6.407484223 sec. (21641809.511 rows/sec., 371.51 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.356774 [ 353 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 141573190 to 33 rows (from 2.37 GiB) in 6.434395655 sec. (22002562.104 rows/sec., 377.70 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.356935 [ 373 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 137492993 to 33 rows (from 2.30 GiB) in 6.434534453 sec. (21367978.368 rows/sec., 366.81 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.365264 [ 368 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 142723097 to 33 rows (from 2.39 GiB) in 6.44286983 sec. (22152100.037 rows/sec., 380.27 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.373358 [ 429 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 142471054 to 33 rows (from 2.39 GiB) in 6.45097717 sec. (22085189.615 rows/sec., 379.12 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.374560 [ 342 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 139734221 to 33 rows (from 2.34 GiB) in 6.452169086 sec. (21656937.247 rows/sec., 371.77 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.378941 [ 354 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 133620256 to 33 rows (from 2.24 GiB) in 6.456601217 sec. (20695138.434 rows/sec., 355.26 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.379065 [ 386 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 141940227 to 33 rows (from 2.38 GiB) in 6.456731679 sec. (21983293.415 rows/sec., 377.37 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.379649 [ 415 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 133205765 to 33 rows (from 2.23 GiB) in 6.457270776 sec. (20628802.728 rows/sec., 354.12 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.388573 [ 442 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 135935855 to 33 rows (from 2.28 GiB) in 6.46620084 sec. (21022522.864 rows/sec., 360.88 MiB/sec.)
[1b92661c477a] 2024.09.06 12:12:59.401929 [ 349 ] {5544887b-05e9-46d8-8005-e715a6eae953} <Trace> AggregatingTransform: Aggregated. 136372775 to 33 rows (from 2.29 GiB) in 6.479541868 sec. (21046669.314 rows/sec., 361.29 MiB/sec.)
После оптимизации:
[1b92661c477a] 2024.09.06 12:13:25.042414 [ 359 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12808430 to 3 rows (from 207.66 MiB) in 2.617344924 sec. (4893672.929 rows/sec., 79.34 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.043540 [ 317 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12384902 to 3 rows (from 200.79 MiB) in 2.618482485 sec. (4729801.353 rows/sec., 76.68 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.043831 [ 376 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12663365 to 3 rows (from 205.30 MiB) in 2.618796948 sec. (4835565.816 rows/sec., 78.40 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.044516 [ 394 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12364709 to 3 rows (from 200.46 MiB) in 2.619461731 sec. (4720324.353 rows/sec., 76.53 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.056207 [ 419 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12468362 to 3 rows (from 202.14 MiB) in 2.631182757 sec. (4738690.981 rows/sec., 76.83 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.058145 [ 384 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 11805397 to 3 rows (from 191.39 MiB) in 2.633096444 sec. (4483465.475 rows/sec., 72.69 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.058671 [ 414 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12148974 to 3 rows (from 196.96 MiB) in 2.633635352 sec. (4613005.362 rows/sec., 74.79 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.060461 [ 434 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12561836 to 3 rows (from 203.66 MiB) in 2.635431357 sec. (4766519.897 rows/sec., 77.28 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.062830 [ 328 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12141162 to 3 rows (from 196.84 MiB) in 2.637803787 sec. (4602754.026 rows/sec., 74.62 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.064855 [ 372 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 13061317 to 3 rows (from 211.76 MiB) in 2.639808233 sec. (4947827.966 rows/sec., 80.22 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.068199 [ 332 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12695009 to 3 rows (from 205.82 MiB) in 2.643170936 sec. (4802946.653 rows/sec., 77.87 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.068372 [ 350 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 13106208 to 3 rows (from 212.48 MiB) in 2.643356741 sec. (4958168.452 rows/sec., 80.38 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.070724 [ 421 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12122227 to 3 rows (from 196.53 MiB) in 2.645681416 sec. (4581892.183 rows/sec., 74.28 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.072034 [ 370 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 13028005 to 3 rows (from 211.22 MiB) in 2.646973907 sec. (4921848.669 rows/sec., 79.80 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.078213 [ 348 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 12694017 to 3 rows (from 205.80 MiB) in 2.653175635 sec. (4784461.621 rows/sec., 77.57 MiB/sec.)
[1b92661c477a] 2024.09.06 12:13:25.080020 [ 443 ] {0d7901da-2937-41fd-97b3-126fc5898c5f} <Trace> AggregatingTransform: Aggregated. 11946080 to 3 rows (from 193.68 MiB) in 2.655006124 sec. (4499454.782 rows/sec., 72.95 MiB/sec.)
ClickHouse запустил параллельный процесс выполнения агрегации после оптимизации на 16 потоков. При этом если до оптимизации в каждом потоке было по 135 млн строк, то после оптимизации стало по 12 млн. Хорошее сокращение!
Разворачиваем таблицу в подзапрос (для включения ClickHouse оптимизации с PREWHERE)
Оператор PREWHERE в ClickHouse ограничивает количество прочитанных данных до того, как будут прочитаны ненужные для фильтрации столбцы. Иными словами сначала применяется фильтрация на уровне минимального набора столбцов и только после этого считаются остальные столбцы, участвующие в запросе.
Такая трансформация, как разворачивание таблицы в подзапрос, позволяет ClickHouse использовать возможность PREWHERE для предварительной фильтрации данных, что может существенно улучшить производительность запросов.
Рассмотрим пример запроса до применения оптимизации:
SELECT
dimproduct.classname AS classname,
sum(factonlinesales.unitprice) AS scalar_result
FROM `factonlinesales` AS factonlinesales
INNER JOIN `dimproduct` AS dimproduct ON dimproduct.productkey = factonlinesales.productkey
INNER JOIN `dimcustomer` AS dimcustomer ON dimcustomer.customerkey = factonlinesales.customerkey
WHERE (dimcustomer.gender = 'F') AND (dimproduct.brandname = 'Contoso')
GROUP BY dimproduct.classname
-- Elapsed: 4.421 sec. Processed 205.00 million rows, 4.47 GB (46.37 million rows/s., 1.01 GB/s.)
В этом запросе мы видим, что фильтрация применяется после объединения таблиц, что приводит к обработке большого объема данных.
Теперь применим оптимизацию и посмотрим что изменится:
SELECT
dimproduct.classname AS classname,
sum(factonlinesales.unitprice) AS scalar_result
FROM `factonlinesales` AS factonlinesales
INNER JOIN
(
SELECT
transformedTableToSubQuery.brandname AS brandname,
transformedTableToSubQuery.classname AS classname,
transformedTableToSubQuery.productkey AS productkey
FROM `dimproduct` AS transformedTableToSubQuery
) AS dimproduct ON dimproduct.productkey = factonlinesales.productkey
INNER JOIN
(
SELECT
transformedTableToSubQuery.gender AS gender,
transformedTableToSubQuery.customerkey AS customerkey
FROM `dimcustomer` AS transformedTableToSubQuery
) AS dimcustomer ON dimcustomer.customerkey = factonlinesales.customerkey
WHERE (dimproduct.brandname = 'Contoso') AND (dimcustomer.gender = 'F')
GROUP BY dimproduct.classname
-- Elapsed: 1.725 sec. Processed 202.49 million rows, 4.44 GB (117.41 million rows/s., 2.57 GB/s.
В случае первого запроса видим:
[a2a868d5d579] 2024.09.05 12:24:32.198817 [ 48 ] {c455f7a5-39de-4041-877a-cb4c5e25e403} <Debug> QueryPlanOptimizations: Pushed down filter equals(brandname, 'Contoso') to the Left side of join
Но строк все равно было прочитало суммарно: 202.49 million rows
Теперь смотрим трассировку оптимизированного запроса и видим что-то новенькое:
[a2a868d5d579] 2024.09.05 12:25:15.522413 [ 48 ] {9612130d-7464-439d-938e-df7be2451174} <Trace> MergeTreeBaseSelectProcessor: PREWHERE condition was split into 1 steps: "equals(brandname, 'Contoso')"
Благодаря такой трансформации запроса нам удалось включить дополнительную оптимизацию со стороны ClickHouse.
Перенос местами таблиц при JOIN
Если нужно JOINить две таблицы друг с другом, нужно сначала подумать, кого на ком JOINить. :) Такая особенность связана с тем, что ClickHouse поднимает в памяти хэш-таблицу. Т.е. чем больше строк в таблице справа (которая присоединяется), тем больше оперативной памяти будет занято.
Эта проблема уже описана в документации:
Currently, ClickHouse does not reorder joins. Always ensure the smallest table is on the right-hand side of the Join. This will be held in memory for most join algorithms and will ensure the lowest memory overhead for the query.
Итак, что мы можем сделать? Поменяем таблицы местами. И наш пример до оптимизации выглядит так:
SELECT sum(t2.unitprice)
FROM (SELECT brandname, productkey
FROM `dimproduct`
WHERE brandname IN ('Contoso')
) AS t1
LEFT OUTER JOIN `factonlinesales` AS t2 ON t1.productkey = t2.productkey
GROUP BY brandname
-- Elapsed: 12.765 sec. Processed 200.00 million rows, 3.60 GB (15.67 million rows/s., 282.03 MB/s.)
В трассировочных логах видим, что пик использования оперативной памяти составил 10Гб.
[1b92661c477a] 2024.09.06 08:10:26.049867 [ 49 ] {c5431d66-1324-43cc-b35e-24bf75f1d476} <Debug> MemoryTracker: Peak memory usage (for query): 10.01 GiB.
А после оптимизации он выглядит вот так:
SELECT sum(t2.unitprice)
FROM `factonlinesales` AS t2
RIGHT OUTER JOIN (
SELECT brandname, productkey
FROM `dimproduct`
WHERE brandname IN ('Contoso')
) AS t1 ON t1.productkey = t2.productkey
GROUP BY brandname
-- Elapsed: 0.871 sec. Processed 200.00 million rows, 3.60 GB (229.73 million rows/s., 4.14 GB/s.)
Картина изменилась:
[1b92661c477a] 2024.09.06 08:11:08.319432 [ 49 ] {7446d3e1-3beb-445a-9b89-46563327c465} <Debug> MemoryTracker: Peak memory usage (for query): 26.29 MiB.
Оптимизация на уровне DAX-дерева
Как следует из названия, эта оптимизация применяется на этапе построения DAX-дерева, перед генерацией окончательного SQL-запроса. Ранее я упомянул, что алгоритмы могут генерировать многоуровневые SQL-запросы, где каждая DAX-функция превращается в отдельный подзапрос. Чтобы уменьшить сложность и повысить эффективность обработки запросов, мы реализовали оптимизации, которые сокращают SQL до более простого вида уже на этапе генерации, вместо того чтобы полагаться только на последующую SQL-оптимизацию.
Пример типичного DAX-выражения для фильтрации данных по диапазону дат:
FILTER (
‘date’ ,
AND(
‘date’[datekey] >= DATE(2021,1,1)
‘date’[datekey] <= DATE(2022,12,31)
)
)
Без оптимизаций, это выражение может быть преобразовано в несколько вложенных подзапросов, как в примере ниже:
SELECT l.datekey
FROM
(SELECT datekey
FROM `dimdate`
WHERE `datekey` >= toDate(concat(toString(2021), '-', toString(1), '-', toString(1)))
) AS l
JOIN
(SELECT datekey
FROM `dimdate`
WHERE `datekey` <= toDate(concat(toString(2022), '-', toString(12), '-', toString(31)))
) AS r
ON l.datekey = r.datekey
Однако наша оптимизация позволяет избежать ненужных подзапросов и объединений (JOIN), генерируя гораздо более простой SQL-запрос:
SELECT datekey
FROM `dimdate`
WHERE if((`datekey` >= toDate(concat(toString(2021), '-', toString(1), '-', toString(1)))) AND
(`datekey` <= toDate(concat(toString(2022), '-', toString(12), '-', toString(31)))), true,
false)
Заключение
В этой статье мы затронули лишь несколько оптимизаций, используемых в аналитическом движке Visiology 3 ДанКо. Эти улучшения позволяют эффективно преобразовывать DAX-выражения в оптимизированные SQL-запросы, снижая нагрузку на базу данных и повышая общую производительность системы, что мы видим фактически от релиза к релизу Visiology 3.
Если вы работаете с ClickHouse как с аналитической СУБД, вы можете самостоятельно реализовать подобные оптимизации при повышении нагрузки, увеличении объемов данных или количества пользователей. Однако практика нашей разработки показала, что все это — большие и трудоемкие задачи. Но зато пользователи Visiology получают десятки уже готовых оптимизаций, которые позволяют движку ДанКо работать с ClickHouse намного быстрее даже при больших объемах данных и сложных запросах.
Автор: ruslanen