сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · ClickHouse

Агрегации и приближённые вычисления

Зачем это спрашивают

Дашборд считает уникальных пользователей за месяц по таблице на тридцать миллиардов строк. Каждое открытие - полминуты ожидания и десятки прочитанных гигабайт, и так у каждого из сорока менеджеров. Лечится это не железом: цифры считают один раз, на вставке, а потом складывают готовое.

Типовые формулировки: «как ускорить дашборд по миллиардам событий?», «почему 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, остальные разбираются в тренажёре.

  1. #ch_aggregation1 / 5
    Что делает комбинатор -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.

  2. #ch_aggregation2 / 5
    Зачем нужны комбинаторы -State и -Merge (например, uniqState / uniqMerge)?
    A)Чтобы отключить агрегацию и вернуть исходные строки таблицы без изменений
    B)Чтобы принудительно выполнить агрегат строго на одном сервере, запретив распределённое выполнение
    C)Чтобы преобразовать результат агрегата в текстовую строку для удобного последующего экспорта в файл
    D)Сохранить промежуточное состояние агрегата (-State) и позже до-агрегировать его (-Merge) — для предрасчёта
    показать ответ и разбор
    +D)Сохранить промежуточное состояние агрегата (-State) и позже до-агрегировать его (-Merge) — для предрасчёта

    // разбор: -State возвращает не финальное значение, а сериализуемое промежуточное состояние агрегата (для uniq — HLL-структуру), которое можно хранить (AggregatingMergeTree) и объединять. -Merge берёт такие состояния и доводит до итога. Связка — основа многоуровневого предрасчёта: считаем состояния по часам, затем -Merge их в дни/месяцы без обращения к сырью. Проверено: слияние двух uniqState дало 800 уникальных.

  3. #ch_aggregation3 / 5
    Что важно помнить про quantile(0.9)(x) в ClickHouse?
    A)Он приближённый; для устойчивости и точности есть варианты quantileTDigest, quantileExact и др.
    B)Что это точный перцентиль, вычисляемый сортировкой всех значений колонки x
    C)Что 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.

  4. #ch_aggregation4 / 5
    Что делает агрегатная функция 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].

  5. #ch_aggregation5 / 5
    Большой 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.

дальше

Теорию прочитали. Навык ставится повторением

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