Агрегации и приближённые вычисления
Дашборд считает уникальных пользователей за месяц по таблице на тридцать миллиардов строк. Каждое открытие - полминуты ожидания и десятки прочитанных гигабайт, и так у каждого из сорока менеджеров. Лечится это не железом: цифры считают один раз, на вставке, а потом складывают готовое.
Типовые формулировки: «как ускорить дашборд по миллиардам событий?», «почему materialized view не посчитал историю?», «что вернёт SELECT из AggregatingMergeTree?».
// Формула паттерна: сырьё → представление на вставку → таблица агрегатов → отчёт читает компактное.
Представление здесь - триггер на вставку
Materialized view в ClickHouse работает не так, как звучит. Это не сохранённый результат запроса, который база поддерживает в актуальном состоянии. Это триггер: при каждой вставке в исходную таблицу приходящий блок прогоняется через запрос представления, и результат дописывается в целевую таблицу.
Отсюда первый сюрприз, который проверяется за минуту. Создаём таблицу, вставляем две строки, потом создаём представление - в нём ноль строк. Историю оно не видит вовсе, потому что вставки уже прошли. Вставляем третью строку - в представлении появляется одна. Старые данные заливаются руками, отдельным INSERT ... SELECT по сырью.
Второй сюрприз - правки. Если строку в сырье изменить или удалить мутацией, представление об этом не узнает: триггер срабатывает только на вставку. Агрегат разъезжается с сырьём молча и навсегда. Значит либо сырьё не мутируем вообще, либо пересобираем затронутые партиции агрегата целиком.
// Из этих двух свойств следует операционная гигиена: агрегат регулярно сверяют с сырьём. Ошибка в GROUP BY внутри представления копит расхождение с первого дня и обнаруживается обычно на встрече с бизнесом.
CREATE TABLE src (id UInt32) ENGINE = MergeTree ORDER BY id;
INSERT INTO src VALUES (1), (2);
CREATE MATERIALIZED VIEW mv ENGINE = MergeTree ORDER BY id
AS SELECT id FROM src;
SELECT count() FROM mv; -- 0 историю не увидел
INSERT INTO src VALUES (3);
SELECT count() FROM mv; -- 1 только новая вставкаСостояния агрегатов: -State и -Merge
Хранить в агрегате готовые суммы кажется естественным, пока речь про суммы. Сложить дневные суммы в месячную легко. А вот сложить дневные значения «уникальных пользователей» нельзя: один и тот же человек заходил и в понедельник, и во вторник, и сумма его посчитает дважды.
Решение - хранить не число, а промежуточное состояние агрегата. Функция с суффиксом -State пишет его в таблицу, функция с суффиксом -Merge доагрегирует при чтении. Состояния корректно складываются между кусками, днями и партициями: два дневных uniqState объединяются в месячный без потери точности, потому что внутри лежит структура, знающая про пересечения.
Колонка при этом имеет тип AggregateFunction(sum, UInt64) или подобный. Если прочитать её обычным SELECT без -Merge, вернётся не число, а бинарное состояние - это и есть классический сюрприз первого дашборда. Проверено: sumState по числам от 0 до 9 читается через sumMerge и даёт 45.
// Для простых суммируемых величин есть SimpleAggregateFunction - легче полноценного состояния и читается как обычная колонка. Для uniq и quantile так не выйдет: им нужна структура, а не число.
CREATE TABLE agg (d Date, s AggregateFunction(sum, UInt64))
ENGINE = AggregatingMergeTree ORDER BY d;
INSERT INTO agg SELECT toDate('2024-01-01'), sumState(toUInt64(number))
FROM numbers(10);
SELECT s FROM agg; -- бинарное состояние, а не число
SELECT sumMerge(s) FROM agg; -- 45- -State / -Merge
- запись промежуточного состояния агрегата / его доагрегация при чтении
Гранулярность агрегата: считаем размер
Агрегат имеет смысл, пока он заметно меньше сырья. Размер считается умножением: число строк в сутки равно произведению кардинальностей всех измерений на число временных корзин.
Пример. Часовая корзина, плюс измерения: страна (10 значений), тип события (50), платформа (3), тариф (4). Считаем: 24 умножить на 10, на 50, на 3, на 4 - это 144 тысячи строк в сутки, около 4,3 миллиона в месяц. Дашборд читает миллионы вместо миллиардов, паттерн работает.
Теперь добавим «на всякий случай» ещё три измерения по десять значений каждое. Умножаем на тысячу - 144 миллиона строк в сутки. Агрегат стал сопоставим с сырьём, и весь смысл потерян. Поэтому измерения выбирают под конкретные вопросы дашборда, а не про запас, и делают несколько узких агрегатов вместо одного универсального.
// Отдельно про память. GROUP BY выполняется хеш-таблицей в оперативке, и группировка по user_id на миллиардах строк её съест. Настройка max_bytes_before_external_group_by, разрешающая сброс на диск, по умолчанию равна нулю - то есть спилла нет, запрос просто упрётся в лимит памяти и упадёт. Правильный ход - не поднимать лимит, а группировать по меньшей кардинальности.
Как отвечать: «Дашборд по миллиардам событий тормозит. Как ускоришь ClickHouse'ом?»
Каскадом агрегатов. Сырьё остаётся в MergeTree, на вставку вешаю materialized view, которое пишет состояния агрегатов в AggregatingMergeTree с гранулярностью под вопросы дашборда - час или день на несколько ключевых измерений. Прикидываю размер заранее умножением кардинальностей: час на четыре измерения даёт порядка сотни тысяч строк в сутки, и дашборд читает миллионы вместо миллиардов. Читает он через функции с суффиксом -Merge, иначе вернутся бинарные состояния вместо чисел. Два подводных камня проговариваю сразу: представление видит только новые вставки, поэтому историю заливаю руками отдельным запросом, и правок сырья оно не видит вообще - значит сырьё не мутируем, либо пересобираем партиции агрегата. И закладываю регулярную сверку агрегата с сырьём: ошибка в GROUP BY копит расхождение молча.
Паттерн целиком, с прикидкой размера числом, обоими сюрпризами представления и операционной гигиеной - так отвечает тот, кто такие каскады строил.
На чём валят
- −Создать представление и ждать, что оно посчитает историю: оно видит только новые вставки.
- −Мутировать сырьё при живом представлении - агрегат разъедется с ним навсегда.
- −Читать AggregatingMergeTree без функций -Merge и получить бинарные состояния вместо чисел.
- −Складывать дневные значения уникальных пользователей как числа: для этого и нужны состояния.
- −Набрать в агрегат десять измерений «на всякий случай» и получить таблицу размером с сырьё.
- −Рассчитывать на сброс GROUP BY на диск: по умолчанию он выключен, запрос упадёт по памяти.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.
- Что делает комбинатор -If в агрегатной функции, например sumIf(x, cond)?A)Прибавляет к результату агрегата единицу за каждую строку, где условие cond оказалось ложнымB)Проверяет условие cond ровно один раз для всей группы сразу, а не отдельно для каждой строкиC)Агрегирует только строки, где cond истинно — сумма x при выполнении условияD)Полностью игнорирует условие cond: sumIf возвращает ровно то же, что и обычная sum(x)
показать ответ и разбор
+C)Агрегирует только строки, где cond истинно — сумма x при выполнении условия// разбор: Комбинатор -If добавляет к агрегату условие: sumIf(x, cond) = сумма x по строкам, где cond, countIf(cond) = число подходящих, avgIf, uniqIf и т.д. Заменяет громоздкое sum(if(cond, x, 0)) и позволяет считать несколько срезов в одном проходе (выручка по каждому статусу разными sumIf). Комбинаторы (-If, -Array, -Merge, -State, -OrNull) — мощная особенность CH. Проверено: sumIf(number, number%2=0) над 0..9 = 20.
- Зачем нужны комбинаторы -State и -Merge (например, uniqState / uniqMerge)?A)Чтобы отключить агрегацию и вернуть исходные строки таблицы без измененийB)Чтобы принудительно выполнить агрегат строго на одном сервере, запретив распределённое выполнениеC)Чтобы преобразовать результат агрегата в текстовую строку для удобного последующего экспорта в файлD)Сохранить промежуточное состояние агрегата (-State) и позже до-агрегировать его (-Merge) — для предрасчёта
показать ответ и разбор
+D)Сохранить промежуточное состояние агрегата (-State) и позже до-агрегировать его (-Merge) — для предрасчёта// разбор: -State возвращает не финальное значение, а сериализуемое промежуточное состояние агрегата (для uniq — HLL-структуру), которое можно хранить (AggregatingMergeTree) и объединять. -Merge берёт такие состояния и доводит до итога. Связка — основа многоуровневого предрасчёта: считаем состояния по часам, затем -Merge их в дни/месяцы без обращения к сырью. Проверено: слияние двух uniqState дало 800 уникальных.
- Что важно помнить про quantile(0.9)(x) в ClickHouse?A)Он приближённый; для устойчивости и точности есть варианты quantileTDigest, quantileExact и др.B)Что это точный перцентиль, вычисляемый сортировкой всех значений колонки xC)Что quantile работает лишь для медианы (0.5) и не принимает других уровнейD)Что результат quantile равен среднему арифметическому значений колонки x
показать ответ и разбор
+A)Он приближённый; для устойчивости и точности есть варианты quantileTDigest, quantileExact и др.// разбор: quantile(level)(x) по умолчанию приближённый (на выборке/reservoir) — быстрый и экономный, но результат может слегка плавать между запусками. Для воспроизводимости и точности есть семейство: quantileExact (точно, дороже), quantileTDigest / quantileTiming под свои профили данных. На больших данных обычно берут приближённые ради ресурсов. Проверено: quantile(0.9)=899.1, quantileTDigest(0.9)=899.5 на 0..999.
- Что делает агрегатная функция groupArray(x)?A)Разворачивает уже существующий массив x обратно в отдельные строки, по одному элементу на строкуB)Собирает значения x всех строк группы в один массивC)Возвращает одно, самое первое встреченное значение x в группе агрегацииD)Сортирует строки группы по значению x, при этом вообще не меняя их итоговое количество
показать ответ и разбор
+B)Собирает значения x всех строк группы в один массив// разбор: groupArray(x) — агрегат, собирающий значения столбца по группе в массив (обратное к ARRAY JOIN). Полезно, чтобы «схлопнуть» историю в одну строку: groupArray(event) по user_id даёт последовательность событий пользователя, которую дальше режут функциями массивов. Есть groupUniqArray (уникальные), groupArray(N) (первые N). Проверено: groupArray(number) над 0..2 = [0,1,2].
- Большой GROUP BY по колонке высокой кардинальности падает с нехваткой памяти. Почему и что делать?A)ClickHouse принципиально не умеет группировать по колонкам высокой кардинальности, так делать не получитсяB)Память тут ни при чём: GROUP BY в ClickHouse по умолчанию выполняется на дискеC)Хеш-таблица групп не влезла в RAM; помогает max_bytes_before_external_group_by (сброс на диск) или сужение ключаD)Нужно просто увеличить число колонок в списке SELECT, и потребление памяти автоматически снизится
показать ответ и разбор
+C)Хеш-таблица групп не влезла в RAM; помогает max_bytes_before_external_group_by (сброс на диск) или сужение ключа// разбор: GROUP BY строит в памяти хеш-таблицу, по строке на уникальный ключ; при высокой кардинальности она распухает и упирается в лимит. Средства: max_bytes_before_external_group_by (разрешить сброс на диск — медленнее, но не падает), огрубление ключа, приближённые агрегаты (uniq вместо точных структур), предагрегация. Классический вопрос про ресурсы CH на собесе аналитика/DE.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.