По мере работы приходилось часто сталкиваться с тем, что не все коллеги были знакомы с комбинаторами агрегатных функций в ClickHouse или же ограничивались использованием комбинатора -If. Это побудило меня написать статью. Надеюсь, она окажется для вас полезной.
В статье будут рассмотрены не все комбинаторы, но если у вас возникнут вопросы с каким-нибудь из них, смело спрашивайте в комментариях. С полным списком можно ознакомиться по ссылке.
Все примеры в статье реализованы с использованием встроенных инструментов ClickHouse или с помощью датасета цен на недвижимость в Великобритании. Ссылка на инструкцию по созданию и загрузке таблицы, а подробное описание полей можно посмотреть в источнике.
Скрипт создания таблицы
CREATE TABLE default.uk_price_paid
(
`price` UInt32,
`date` Date,
`postcode1` LowCardinality(String),
`postcode2` LowCardinality(String),
`type` Enum8('other' = 0, 'terraced' = 1, 'semi-detached' = 2, 'detached' = 3, 'flat' = 4),
`is_new` UInt8,
`duration` Enum8('unknown' = 0, 'freehold' = 1, 'leasehold' = 2),
`addr1` String,
`addr2` String,
`street` LowCardinality(String),
`locality` LowCardinality(String),
`town` LowCardinality(String),
`district` LowCardinality(String),
`county` LowCardinality(String)
)
ENGINE = MergeTree
ORDER BY (postcode1, postcode2, addr1, addr2)
SETTINGS index_granularity = 8192
-If комбинатор
Начнем с самых простых, но от этого не менее полезных комбинаторов в ClickHouse. Таким комбинатором является -If. В моей практике он — лидер по частоте использования. Он позволяет указывать дополнительное условие для агрегатной функции. В добавление к полю, по которому мы агрегируем, добавляем условие или можем использовать поле из таблицы, при условии, что она принимает значение 1/0.
К примеру, мы хотим узнать отношение средней цены продажи в определённом районе Лондона, пусть это будет Сити, к средней цене продаж во всем Лондоне с начала 2024 года.
SELECT avgIf(price, district = 'CITY OF LONDON') / avg(price) AS ratio_
FROM uk_price_paid
WHERE (date >= '2024-01-01') AND (town = 'LONDON')
┌────────────ratio_─┐
1. │ 9.245274507120163 │
└───────────────────┘
Этот комбинатор значительно упрощает написание SQL-запросов и позволяет избежать использования джоинов и подзапросов.
-Distinct
Комбинатор позволяет отбирать для агрегатной функции только уникальные значения.
Скрипт создания и наполнения таблицы
CREATE TABLE random
(
`column1` UInt8
)
ENGINE = Memory
INSERT INTO
random
SELECT
*
FROM
generateRandom(
'column1 UInt8', -- нейминг столбца и требуемый тип данных
42 -- random seed
)
LIMIT 10000
Теперь мы имеем таблицы с 10 000 случайных значений, принадлежащих к диапазону от 0 до 255.
SELECT
sum(column1) AS sum_total,
sumDistinct(column1) AS sum_distinct,
sumDistinctIf(column1, column1 < 10) AS sum_distinct_cond
FROM random
┌─sum_total─┬─sum_distinct─┬─sum_distinct_cond─┐
1. │ 1266960 │ 32640 │ 45 │
└───────────┴──────────────┴───────────────────┘
Получили сумму арифметической прогрессии в случаях sum_distinct и sum_distinct_cond.
State и производные комбинаторы
Следующие комбинаторы позволяют сохранять и работать с промежуточным состоянием агрегатных функций, это такие комбинаторы, как -State, -Merge и -MergeState.
Начнем с -State. Этот комбинатор позволяет сохранить агрегатную функцию в промежуточном состоянии, из которого в дальнейшем можно получить конечный результат. Сейчас, скорее всего, у вас повис вопрос, а зачем это может понадобиться? Есть несколько ситуаций, когда применяют этот комбинатор:
Внутри AggregatingMergeTree — это движок таблиц из семейства MergeTree, подробнее про который можно прочитать по ссылке. Если описать коротко, основой юзер-кейс этого движка — обновление расчёта метрик по мере появления новых данных. Схему можно посмотреть на рисунке ниже.
В данном случае материализованное представление работает как триггер на вставку данных, но следует помнить, что увеличение количества материализованных представлений, подключенных к одной таблице-источнику, ведёт к увеличению времени вставки данных в таблицу-источник.
Другой кейс — это экономия места (предагрегированные функции в большинстве кейсов занимают значительно меньше места в сравнении с исходными данными) и возможность построения гибкой витрины данных. Метрики в предагрированном состоянии можно хранить и в обычной таблице из семейства MergeTree.
Перейдем к логике работы комбинатора State. Для каждой функции наполнение предагрегированного состояния отличается: если для функции avg это будет значение числителя и знаменателя, то для функции uniq это будет хэш-таблица.
Давайте проверим, как работает комбинатор и как он совмещается с другими комбинаторами, на примере датасета, который мы сгенерировали случайным образом.
SELECT
sumState(column1) AS sum_total,
sumDistinctState(column1) AS sum_distinct,
sumDistinctIfState(column1, column1 < 10) AS sum_distinct_cond
FROM random
Если мы выполним это запрос, то не получим конечные цифры, а увидим только бинарные данные. Чтобы завершить агрегацию, нам пригодится комбинатор -Merge или функция finalizeAggregation, и мы получим результат, как считали раньше.
Запрос в двух вариантах
SELECT
sumMerge(sum_total) AS sum_total,
sumDistinctMerge(sum_distinct) AS sum_distinct,
sumDistinctIfMerge(sum_distinct_cond) AS sum_distinct_cond
FROM
(
SELECT
sumState(column1) AS sum_total,
sumDistinctState(column1) AS sum_distinct,
sumDistinctIfState(column1, column1 < 10) AS sum_distinct_cond
FROM random
)
┌─sum_total─┬─sum_distinct─┬─sum_distinct_cond─┐
1. │ 1266960 │ 32640 │ 45 │
└───────────┴──────────────┴───────────────────┘
; -- или
SELECT
finalizeAggregation(sum_total) AS sum_total,
finalizeAggregation(sum_distinct) AS sum_distinct,
finalizeAggregation(sum_distinct_cond) AS sum_distinct_cond
FROM
(
SELECT
sumState(column1) AS sum_total,
sumDistinctState(column1) AS sum_distinct,
sumDistinctIfState(column1, column1 < 10) AS sum_distinct_cond
FROM random
)
┌─sum_total─┬─sum_distinct─┬─sum_distinct_cond─┐
1. │ 1266960 │ 32640 │ 45 │
└───────────┴──────────────┴──────────────────
Также в случае проектирования таблиц нам пригодится такая функция, как toTypeName, которая подскажет, какой тип данных у наших предагрегированных функций (AggregateFunction), что поможет минимизировать вероятность ошибиться с типом данных.
SELECT
toTypeName(sumState(column1)) AS sum_total,
toTypeName(sumDistinctState(column1)) AS sum_distinct,
toTypeName(sumDistinctIfState(column1, column1 < 10)) AS sum_distinct_cond
FROM random
┌─sum_total─────────────────────┬─sum_distinct──────────────────────────┬─sum_distinct_cond──────────────────────────────┐
1. │ AggregateFunction(sum, UInt8) │ AggregateFunction(sumDistinct, UInt8) │ AggregateFunction(sumDistinctIf, UInt8, UInt8) │
└───────────────────────────────┴───────────────────────────────────────┴────────────────────────────────────────────────┘
Также стоит помнить, что для алиасов, таких как median и другие, будет использоваться функция, отвечающая за расчёт. Например, для median это quantile. Мы это можем увидеть, выполнив следующий запрос:
SELECT toTypeName(medianState(column1))
FROM random
┌─toTypeName(medianState(column1))───┐
1. │ AggregateFunction(quantile, UInt8) │
└────────────────────────────────────┘
Если же говорить про комбинатор -MergeState, то он объединяет промежуточные состояния агрегатных функций в одно. К примеру, это может пригодиться при объединении State-функций типа uniq.
Демонстрация сравнения
SELECT uniqMerge(vs)
FROM
(
SELECT uniqMergeState(column1) AS vs
FROM
(
SELECT uniqState(number) AS column1
FROM numbers(1, 5)
UNION ALL
SELECT uniqState(number) AS column1
FROM numbers(4, 10)
)
)
┌─uniqMerge(vs)─┐
1. │ 13 │
└───────────────┘
Получаем такой же результат, что и при простом вызове функции uniq.
SELECT uniq(number) AS vs
FROM
(
SELECT number
FROM numbers(1, 5)
UNION ALL
SELECT number
FROM numbers(4, 10)
)
┌─vs─┐
│ 13 │
└────┘
Рассмотрим ситуацию, когда нам может пригодиться -MergeState. Допустим, у нас есть две таблицы: в одной хранится предагрегированный таймспент пользователей при взаимодействии с видео, а в другой — предагрегированный таймспент пользователей при взаимодействии с аудио. Мы хотим получить общее предагрегированное состояние для пользователей. Можем сделать avgMergeState над предагригированным таймспентом и посчитать, сколько в среднем пользователи проводят времени в нашем продукте, учитывая данные и с аудио, и с видео.
Перейдем к данным, представленным в начале статьи, и посмотрим, как можно сэкономить место и построить гибкую витрину. Для начала разберемся с тем, что мы хотим посчитать. Пусть это будет средняя цена. Также нужно выбрать измерения, по которым мы хотим агрегировать информацию. Это будет город, район и дата, таким образом, получаем следующий запрос:
SELECT
date,
town,
district,
avgState(price)
FROM uk_price_paid
GROUP BY
date,
town,
district
Определим нужный тип данных для столбцов таблицы
В этом нам поможет рассмотренная ранее функция toTypeName.
SELECT
toTypeName(date),
toTypeName(town),
toTypeName(district),
toTypeName(avgState)(price)
FROM uk_price_paid
GROUP BY
date,
town,
district
limit 1
Теперь нужно создать подходящую таблицу для этих данных:
CREATE TABLE default.uk_price_paid_agg
(
`date` Date,
`town` LowCardinality(String),
`district` LowCardinality(String),
`avg_price` AggregateFunction(avg, UInt32)
)
ENGINE = MergeTree
ORDER BY (town, district, date)
SETTINGS index_granularity = 8192
Посмотрим, сколько заняла наша таблица:
SELECT
database,
`table`,
formatReadableSize(sum(data_compressed_bytes + data_uncompressed_bytes)) AS table_size,
sum(rows) AS rows
FROM system.parts
WHERE (active = 1) AND (database LIKE '%') AND (`table` LIKE '%')
GROUP BY
database,
`table`
ORDER BY table_size DESC
┌─database─┬─table─────────────┬─table_size─┬─────rows─┐
1. │ default │ uk_price_paid_agg │ 129.76 MiB │ 6469138 │
2. │ default │ uk_price_paid │ 1.02 GiB │ 29145919 │
└──────────┴───────────────────┴────────────┴──────────┘
Таблица занимает почти в 8 раз меньше места, чем источник. Благодаря этой витрине, мы можем гибко считать значение средней цены без обращения к данным в таблице-исходнике, что ускорит выполнение запроса и позволит построить гибкие дашборды, используя только агрегированную витрину. То есть, если мы будем фильтровать данные по городам или датам с районами, мы всегда будем получать корректное значение среднего. Это позволяет строить дашборды над источниками с большим количеством измерений для более удобной визуализации.