Оптимизация 100 миллиардов событий ClickHouse

Реакция: Всеволод Миронович, Директор по данным @ Далее
|
Оптимизация 100 миллиардов событий ClickHouse

Это пост Райана Вориса, инженера из компании Replo. Он описывает интересный рабочий кейс по оптимизации ClickHouse, осваивая систему в высоконагруженном боевом окружении, поэтому мы решили перевести его. Я добавил свои комментарии по принимаемым решениям — местами с критикой, местами с рекомендациями. Надеюсь, будет полезно.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Один из наших новых продуктов в Replo — Replo Analytics. Мы хотим дать возможность клиентам Replo продавать что угодно онлайн, а часть онлайн-продаж — это анализ эффективности кампаний, расчёт конверсии на основе данных о покупках в рамках сессий и так далее. Поскольку Replo автоматически разворачивает посадочные страницы кампаний, мы можем легко отслеживать события и предоставлять данные клиентам с гораздо меньшими усилиями по настройке, чем, скажем, Google Analytics.

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

Хранение аналитических данных

На момент первого запуска Replo Analytics мы потратили 3–4 месяца на прототипирование и собрали то, что казалось надёжным аналитическим пайплайном на ClickHouse. В центре системы была таблица events, которая собирала фронтенд-события из магазинов на Replo. Качество данных было высоким, запросы возвращали то, что мы ожидали. Всё работало, и жизнь была хороша.

Вот как выглядела наша таблица событий:

CREATE TABLE IF NOT EXISTS default.events (    id UUID,    name String,    createdAt DateTime,    receivedAt DateTime,    sessionId UUID,    -- ...    payload Nested(        key String,        value String    ),) ENGINE = MergeTree()PARTITION BY toStartOfInterval(createdAt, INTERVAL 6 hours)ORDER BY (createdAt, sessionId, urlHost, urlPath);

Партиционирование по 6 часам для высокочастотных событий — это классическая грабля, на которую наступают почти все новички. ClickHouse создаёт отдельную директорию на каждую партицию, и когда их становятся тысячи, файловая система начинает задыхаться. Я бы рекомендовал партиционировать хотя бы по дням, а то и по месяцам, если события сыпятся непрерывно. Nested-колонка payload — ещё один спорный момент: внутри неё фактически живёт мини-таблица, и ClickHouse хранит её как отдельный набор столбцов. Для аналитики по ключам это удобно, но для производительности лучше вынести часто используемые поля в плоскую структуру. И ORDER BY с createdAt в начале — да, это логично для временных рядов, но если вы чаще фильтруете по sessionId, поставьте его первым. Иначе ClickHouse будет сканировать все данные сессии, размазанные по всему диапазону дат.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

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

Но по мере роста нагрузки росло и количество событий, сессий за ними, и сложность метрик, которые мы хотели вычислять. Время запросов замедлилось — сначала до неприемлемого, а затем и до нестабильного. Запросы для клиентов, отслеживающих более 10 миллионов просмотров страниц в месяц, стали уходить в тайм-аут.

Мы пробовали тюнинг, оптимизацию запросов, добавляли всё больше и больше таблиц, чтобы разделить данные и ускорить работу, но в итоге поняли: это не то, что можно исправить заплатками. Нам нужен был свежий старт, новая модель — не просто корректная, но и быстрая. Так начался наш путь к тому, что мы внутри команды называем «events data model V2».

Если вы работаете с ClickHouse в сколь-нибудь значимом масштабе или пытаетесь сделать аналитику мгновенной для реальных пользователей, эта история может показаться вам знакомой (или, по крайней мере, занимательной). Это история о том, как мы построили новую модель данных, затем аккуратно мигрировали на неё, а затем мигрировали снова.

events_v2 и events_computed

По мере того как росло количество сессий и клиентская база, запросы, которые раньше выполнялись за сотни миллисекунд, начали уходить в секунды, а затем в десятки секунд. В конце концов мы дошли до того, что выполнение простого COUNT() по недавним сессиям требовало медитативного перерыва.

Мы начали замечать и другие закономерности:

  • Одни и те же данные о покупках в рамках сессий пересчитывались снова и снова. У нас не было слоя кэширования, поэтому каждый запрос был «холодным».
  • Вычисляемые метрики рассчитывались на лету. Это добавляло огромную задержку и делало наши аналитические дашборды медлительными.

Так мы начали исследовать то, что стало нашей V2. Цель была не просто в ускорении запросов — мы хотели переосмыслить сам подход к вычислению метрик из сырых событий.

В основе этого лежала предварительная обработка. Не в общем смысле «да, мы что-то пакетно посчитаем», а полноценная логика атрибуции на уровне сессий, встроенная в пайплайн, вычисляемая один раз и никогда не пересчитываемая без необходимости.

CREATE TABLE events_v2 (  namespace   String,  session     UUID,  date        DateTime64,  id          UUID,  name        String,  data        String)ENGINE = ReplacingMergeTree()PARTITION BY (  toStartOfInterval(date, INTERVAL 2 MONTH),  namespace)ORDER BY (namespace, date, session, id);

Переход на ReplacingMergeTree — шаг в правильном направлении, но важно понимать: дедупликация происходит только в момент слияния кусков (merge), а не при вставке. Это значит, что между вставкой дубликата и его удалением может пройти от нескольких минут до часов. Если вам нужна консистентность «прямо сейчас», FINAL неизбежен. Партиционирование по двум месяцам и namespace — разумный компромисс, но если namespace станет высококардинальным (например, UUID вместо project_id), вы получите веер из тысяч мелких партиций. И ещё: JSON в колонке data — это удобно ровно до тех пор, пока вам не понадобится отфильтровать по полю внутри этого JSON. ClickHouse умеет работать с JSON функциями, но это на порядок медленнее, чем выделенная колонка. Мой совет: вынесите наиболее частые поля в отдельные колонки, а остальное — в JSON.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Это была первая версия таблицы events_v2. Она отражала уроки, которые мы начали усваивать о производительности ClickHouse. namespace стал центральной колонкой для разграничения данных между арендаторами — мы использовали для этого идентификаторы проектов Replo. Кардинальность была относительно низкой, что позволяло ClickHouse пропускать большие объёмы данных, когда запросы были ограничены клиентом.

Мы оптимизировали хранение и производительность запросов с помощью продуманного партиционирования, сортировки и стратегий дедупликации.

Мы партиционировали таблицу и по времени, и по namespace, чтобы соответствовать типичным паттернам запросов, фильтрующим по этим полям. При вставке строк ClickHouse создаёт новую часть данных для каждого уникального значения ключа партиции, а не объединяет все вставленные строки в одну часть. Такой подход уменьшает количество ненужных сканирований и делает запросы эффективнее, поскольку ClickHouse сканирует, загружая партиции в память.

Внутри каждой партиции события сортировались по namespace, затем по session, затем по date и id. Такой порядок позволяет читать данные в последовательности, ориентированной на сессии, и одновременно гарантирует уникальность записей через поле id.

Для обработки дубликатов событий мы использовали ReplacingMergeTree. Этот движок таблиц ClickHouse автоматически удаляет строки-дубликаты с одинаковым ключом сортировки, оставляя только самую последнюю версию.

Наконец, сами данные события хранятся в одной колонке data в формате JSON. Такая архитектура даёт нам гибкость для разных типов событий без необходимости изменять схему таблицы по мере их эволюции.

Мы также приняли важное решение предварительно вычислять некоторые метрики с помощью events_computed:

CREATE TABLE events_computed (  namespace       String,  session         UUID,  date            DateTime64,  id              UUID,  name            String,  data            String,  computed        Nullable(String),  computedVersion UInt8,  computedDate    Nullable(DateTime64))ENGINE = ReplacingMergeTree()PARTITION BY (  toStartOfInterval(date, INTERVAL 2 MONTH),  namespace)ORDER BY (namespace, date, session, id);

Дублирование схемы для предвычисленных метрик — это распространённый, но не самый элегантный паттерн. По сути, вы храните две копии почти одинаковых данных. Альтернатива — хранить агрегированные метрики в отдельной таблице с ключом по сессии (session_metrics), а не прикреплять их к каждой строке события. Это сократит объём хранилища и ускорит запросы, которым не нужны сырые события. Nullable(String) для computed — ещё один момент: ClickHouse не очень дружит с Nullable-колонками, они создают дополнительный файл с маской null-значений. Если computed может быть null, лучше использовать пустую строку или специальное значение-флаг. А computedVersion — молодцы, версионирование вычислений часто недооценивают, а зря.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Внимательные читатели могли заметить, что это определение почти идентично events_v2 — так и есть, но с одним ключевым отличием: оно включает вычисляемые значения. Идея заключалась в том, чтобы взять распространённые метрики, такие как суммы покупок на сессию, и предварительно вычислить их один раз, чтобы сэкономить время выполнения запросов.

Итак, как эти значения заполняются? Это и есть вопрос на 100 миллиардов событий. Именно здесь наша команда начала уходить в кроличью нору: мы пытались понять, когда и что нужно пересчитывать, перепробовали все виды материализованных представлений, которые поддерживает ClickHouse, строили кэш-таблицы, настраивали индексы и в итоге переосмыслили всю стратегию с нуля.

Предварительное вычисление колонок событий каждую минуту

Давайте рассмотрим типичный случай с данными о покупках в рамках сессии. В Replo Analytics мы хотим вычислять количество покупок на сессию и общую сумму этих покупок.

Поскольку большинство событий не являются покупками, мы начали с копирования всех данных из events_v2 в events_computed с пустым значением в колонке computed. Это делалось через простое материализованное представление:

CREATE MATERIALIZED VIEW events_computed_default_mvTO events_computedAS	SELECT	  namespace,	  name,	  session,	  date,	  id,	  data,	  CAST(NULL AS Nullable(String)) AS computed,	  CAST(NULL AS Nullable(DateTime64)) AS computedDate	FROM events_v2	WHERE name != 'purchase';

Далее нам нужен был способ определить, какие сессии требуют вычислений. Для этого мы создали лёгкую суммирующую таблицу sessions_requiring_computation. Идея была проста: если приходит событие покупки, мы помечаем сессию как +1. Когда сессия обработана, мы вычитаем единицу, чтобы «снять пометку».

CREATE TABLE sessions_requiring_computation (  namespace           String,  session             UUID,  insertDate          DateTime64,  requiresComputation Int32)ENGINE = SummingMergeTree()PARTITION BY (toStartOfInterval(insertDate, INTERVAL 6 HOUR), namespace)ORDER BY (namespace, session);

SummingMergeTree для счётчиков — идея рабочая, но с подвохом. Агрегация в SummingMergeTree происходит фоново, и если вы сделаете SELECT sum(requiresComputation) сразу после вставки, результат может быть неконсистентным — ClickHouse вернёт сырые строки, а не сумму. Для точного учёта лучше использовать AggregatingMergeTree с SimpleAggregateFunction(sum, Int32) — тогда агрегация будет гарантирована на уровне движка. И ещё: партиционирование по 6 часам для таблицы, в которую пишут каждую минуту — это снова грабли с мелкими партициями. Здесь это менее критично, потому что данных мало, но привычка вредная.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Два материализованных представления занимались учётом: одно увеличивало счётчик при обнаружении покупки, другое уменьшало его после обработки.

-- ОтметитьCREATE MATERIALIZED VIEW sessions_requiring_computation_mark_mvTO sessions_requiring_computation AS	SELECT namespace, session, now() AS insertDate, 1 AS requiresComputation	FROM events_v2	WHERE name = 'purchase'; -- Снять отметкуCREATE MATERIALIZED VIEW sessions_requiring_computation_unmark_mvTO sessions_requiring_computation AS	SELECT namespace, session, now() AS insertDate, -1 AS requiresComputation	FROM events_computed	WHERE name = 'purchase';

Пока всё хорошо, но нам всё ещё нужно было фактически вычислить метрики и записать их в events_computed. Для этого появилось представление events_computed_fn, которое сканировало sessions_requiring_computation, находило активные строки, объединяло их с сырыми событиями, выполняло агрегацию через OVER (PARTITION BY session) и возвращало JSON. Обратите внимание: нам пришлось ограничить количество возможных сессий для вычисления, потому что мы начали сталкиваться с проблемами памяти при слишком больших объёмах.

CREATE VIEW events_computed_fn ASWITH  200000 AS sessionLimit,  (now() - INTERVAL 4 YEAR) AS startDate,  (now() + INTERVAL 1 DAY) AS endDate,  1 AS currentComputedVersion,   computable_sessions AS (    SELECT namespace, session, SUM(requiresComputation) AS requiresComputation    FROM sessions_requiring_computation    WHERE insertDate >= startDate AND insertDate < endDate    GROUP BY namespace, session    HAVING requiresComputation > 0    LIMIT sessionLimit  ),   events_to_compute AS (    SELECT e.namespace, e.session, e.date, e.id, e.name, e.data    FROM events_v2 e    INNER JOIN computable_sessions s USING (namespace, session)  ),   computed_events AS (    SELECT      namespace, session, date, id, name, data,      COUNT(IF(name = 'purchase', 1, NULL)) OVER (PARTITION BY session)        AS sessionPurchaseCount,      SUM(IF(name = 'purchase', JSONExtract(data, 'payload', 'amount', 'Float64'), 0)) OVER (PARTITION BY session)        AS sessionPurchaseSum    FROM events_to_compute    GROUP BY namespace, session, date, id, name, data  ) SELECT  namespace, session, date, id, name, data,  toJSONString(map(    'session', map(      'purchase', map(        'count', toFloat64(sessionPurchaseCount),        'sum', toFloat64(sessionPurchaseSum)      )    )  )) AS computed,  now() AS computedDateFROM computed_events;

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

CREATE MATERIALIZED VIEW events_computed_mvREFRESH EVERY 1 MINUTEAPPEND TO events_computed AS	SELECT * FROM events_computed_fn;

А чтобы отслеживать, как идут дела, мы добавили аудит-таблицу, которая каждую минуту записывала, сколько сессий всё ещё ожидают вычисления:

CREATE TABLE sessions_requiring_computation_audit (  count UInt64,  date DateTime64) ENGINE = MergeTree()ORDER BY date; CREATE MATERIALIZED VIEW sessions_requiring_computation_audit_mvREFRESH EVERY 1 MINUTEAPPEND TO sessions_requiring_computation_audit AS	WITH	  computable_sessions AS (	    SELECT namespace, session	    FROM sessions_requiring_computation	    WHERE insertDate >= now() - INTERVAL 4 YEAR		    AND insertDate < now() + INTERVAL 1 DAY	    GROUP BY namespace, session	    HAVING SUM(requiresComputation) > 0	  )	SELECT count() AS count, now64() AS date	FROM computable_sessions;

Это было много шагов, но оно работало — наши тесты производительности показали ускорение запросов в 4–10 раз, поэтому мы мигрировали наши ~60 миллиардов событий.

В первый раз.

Расширение, коллапс и кэширование

Replo Analytics изначально поддерживал атрибуцию по первому и последнему касанию, но клиенты попросили добавить дробную атрибуцию. Вместо того чтобы относить 100% выручки на одну страницу за то, что она была первой или последней, нужно было распределять долю между всеми посещёнными страницами, чтобы точнее отражать реальность: визиты клиентов включают несколько страниц, и все они могут в совокупности способствовать покупке.

Логика была прямолинейной: чтобы определить, какой вклад просмотр страницы внёс в выручку сессии, нужно разделить общую сумму покупки на количество значимых просмотров страниц. Если было пять просмотров и одна покупка, каждый просмотр стоит одну пятую суммы покупки. Перемножаем — получаем дробную выручку.

Когда мы начали тестировать это, то заметили, что расчёты кажутся неверными. В итоге мы выяснили, что проблема в дубликатах событий, которые искажали вычисления. Хотя и events_v2, и events_computed использовали ReplacingMergeTree, дедупликация в них происходит лишь со временем. Нельзя полагаться на то, что данные будут идеально чистыми в реальном времени, особенно для свежих событий (а мы предварительно вычисляли поля только для свежих событий).

К счастью, у ClickHouse есть инструменты для этого: FINAL и DISTINCT.

CREATE VIEW events_computed_fn-- ...events_to_compute AS (  SELECT DISTINCT ON (e.namespace, e.subject, e.date, e.id) -- Новый DISTINCT ON  -- ...  FROM events_v2 e FINAL -- Новый FINAL  INNER JOIN computable_subjects s ON s.namespace = e.namespace AND s.session = e.session),session_metrics AS (  SELECT    namespace,    session,    COUNT(IF(      name == 'page_view',      NULL    )) AS attributablePageViewCount  FROM events_to_compute  GROUP BY    namespace,    session),computed_events AS (  SELECT    ... AS sessionPurchaseCount,    ... AS sessionPurchaseSum,    ... AS sessionPurchaseFraction,    ... AS sessionPurchaseFractionalSum,    -- ...  FROM events_to_compute e  JOIN session_metrics s ON (    e.namespace = s.namespace AND e.session = s.session  )  GROUP BY    e.namespace,    -- ...    e.data,    s.attributablePageViewCount)SELECT  -- ...  toJSONString(map(    'session', map(      'purchase', map(        'count', toFloat64(sessionPurchaseCount),        'sum', toFloat64(sessionPurchaseSum),        'fraction', toFloat64(sessionPurchaseFraction),        'fractionalSum', toFloat64(sessionPurchaseFractionalSum)      )    )  )) AS computed,FROM computed_events;

Вот здесь уже пахнет жареным. FINAL + DISTINCT ON в одном запросе — это как одновременно нажать газ и тормоз. FINAL заставляет ClickHouse смержить все куски партиции на лету, а DISTINCT ON добавляет сверху ещё один проход с сортировкой. Для таблицы с миллиардами строк это гарантированный удар по памяти и CPU. ClickHouse не предназначен для таких операций в реальном времени — это OLAP-база, а не OLTP. Если вам нужно гарантированно уникальные данные для вычислений, лучше сделать отдельный пайплайн дедупликации при вставке (например, через ReplacingMergeTree с правильным ключом и версионированием), а FINAL использовать только для точечных запросов к конкретным сессиям. Ну и JSON-маппинг на лету в toJSONString(map(...)) — это ещё один слой жирка, который можно срезать, храня computed в заранее согласованном формате.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Это была та версия events_computed_fn, которая перевернула всю систему. Сначала она работала, но из-за ошибок в исходных расчётах без дедупликации нам пришлось повторно мигрировать все данные, чтобы запустить пересчёт. Вот тогда и начались симптомы.

Потребление памяти росло, и в конце концов обновления начали пропускать своё расписание. В то же время мы заметили кое-что ещё: очередь sessions_requiring_computation не уменьшалась. Она была задумана для отслеживания работы, которую ещё нужно выполнить: увеличивать счётчик при покупке и уменьшать после выполнения вычислений. Если значение сессии больше нуля — значит, она ещё требует обработки. Но снятие отметок не происходило.

Большие сканирования приводят к тайм-аутам

Представление использовало FINAL и DISTINCT ON для схлопывания дубликатов, что требовало сканирования больших партиций таблицы events_v2. Это делало обновления дорогими. Мы поняли, что когда потребление памяти резко возрастало во время одного из таких сканирований, обновление начинало молча падать.

Неудачное обновление означало, что -1 не записывался в таблицу sessions_requiring_computation. Поэтому даже после обработки сессия всё ещё отображалась как требующая работы. Очередь росла и росла, заполняясь сессиями, которые уже были обработаны, но никогда не были помечены как таковые. Загрузка CPU росла, поскольку каждое обновление требовало всё больше вычислений. Обновления представлений стали нестабильными, и начало казаться, что система отстаёт, хотя на самом деле она делала одну и ту же работу снова и снова.

К этому моменту всё запуталось. Функция полагалась на объединение events_v2 с очередью вычислений, чтобы сузить рабочее множество, но теперь очередь была раздута фантомными записями. Каждую минуту мы сканировали огромные партиции, объединяли миллионы строк, выполняли вложенные CTE и ждали, пока ClickHouse сбросит данные на диск или упадёт. Иногда это завершалось успешно, иногда нет.

А что насчёт кэша?

Мы попробовали обычные исправления — ограничить количество сессий за запуск, сократить временное окно, отфильтровать колонки и так далее. Это помогло немного, но основная проблема не была в настройках. Мы делали слишком много работы, слишком часто, со слишком большим количеством движущихся частей на пути чтения.

Наша основная проблема заключалась в том, что нам нужно было дедуплицировать свежие события для точности расчётов, но без использования FINAL или DISTINCT ON против всей таблицы events_v2 или events_computed. Что, если мы создадим небольшие «кэш»-таблицы, которые будут содержать только самые свежие события, например events_v2_recent_events и events_computed_recently?

Мы сделали прототип, и технически он заработал. Производительность была отличной, цифры сходились, представления снова обновлялись. Но к тому моменту вся система превратилась в лабиринт из CTE, материализованных представлений, аудит-таблиц и запасной логики — каждый элемент решал проблему, созданную предыдущим.

Да здравствует простота

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

Вместо того чтобы отслеживать, что нужно пересчитать и когда, мы перевернули проблему. Мы задали более простой вопрос: что, если мы просто будем пересчитывать всё для сессий, в которых недавно была активность покупок? Не всё время, не все события, а только последние 30–40 минут «живых» сессий. Если в сессии недавно было событие покупки — она подходит для пересчёта. Если нет — игнорируем.

Вот таблица, которая обеспечила этот сдвиг:

CREATE TABLE live_session_events_trigger (    namespace String,    session UUID,    date DateTime64(3),    id UUID,    name String,    data String)ENGINE = MergeTreeORDER BY (namespace, session, date, id)TTL toDateTime(date) + INTERVAL 40 MINUTE;

Каждую минуту материализованное представление заполняло эту таблицу новыми событиями покупок из events_v2, создавая скользящее окно активных сессий для пересчёта.

CREATE MATERIALIZED VIEW live_session_events_trigger_mvTO live_session_events_trigger ASSELECT    namespace,    session,    date,    id,    name,    data    FROM events_v2    WHERE date >= (now() - INTERVAL 40 MINUTES)        AND name = 'purchase';

Затем появился flusher. Каждую минуту он брал текущее окно активных сессий, объединял их с недавними событиями, выполнял математику атрибуции и сбрасывал вычисленные результаты напрямую в events_computed.

CREATE MATERIALIZED VIEW session_flusher_mvREFRESH EVERY 1 MINUTEAPPEND TO events_computed ASSELECT    e.namespace as namespace,    -- ...    e.data as data,    CONCAT(        '{',            '"session":{',            '"purchase":{',                '"count":', toString(IFNULL(m.sessionPurchaseCount, 0)), ',',                '"sum":', toString(IFNULL(m.sessionPurchaseSum, 0)), ',',                '"fractionalSum":', toString(m.attributablePageViewCount > 0 ? m.sessionPurchaseSum * (1.0 / m.attributablePageViewCount) : 0),                    -- ...            '}',            '}',        '}'    ) AS computed,    --- ...    now64(3) AS computedDate,    now64(3) AS insertDate    FROM live_session_events_trigger AS t    LEFT JOIN live_session_events AS e USING (namespace, session)    LEFT JOIN (    SELECT        namespace,        session,        COUNTIf(name = 'page_view' AND ...) AS attributablePageViewCount,        COUNTIf(name = 'purchase') AS sessionPurchaseCount,        SUMIf(amount, name = 'purchase') AS sessionPurchaseSum,        anyIf(currencyCode, name = 'purchase') AS currencyCode    FROM live_session_events    GROUP BY namespace, session    ) AS session_metrics USING (namespace, session);

Flusher model — это, по сути, лямбда-архитектура в миниатюре, и это правильный подход для ClickHouse. TTL на 40 минут — хорошее значение, но я бы посоветовал следить за фрагментацией: если данные в live_session_events_trigger обновляются каждую минуту, MergeTree может создавать много мелких кусков, которые не успевают смержиться до истечения TTL. Решение — использовать Buffer-таблицу перед live_session_events_trigger, которая будет накапливать данные и сбрасывать их пачками. И обязательно мониторить отставание материализованных представлений: при пиковых нагрузках они могут не успевать, и вы начнёте терять события. ClickHouse не прощает молчаливых ошибок — если MV упало, вы узнаете об этом только по косвенным признакам.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Flusher оказался проще, дешевле и, что самое важное, предсказуемым. Он убрал все догадки. Больше никакого отслеживания очередей вычислений, никакой борьбы с пограничными случаями дедупликации, никакой молчаливо отказывающей логики снятия отметок. Просто вычисляй свежее — и двигайся дальше.

Оглядываясь назад, это была та версия, к которой мы всегда стремились. Нам просто нужно было сначала построить сложную версию и сломать её. Итак, мы мигрировали и пересчитали всё в последний раз — теперь уже с ~80 миллиардами событий.

А как же остальное?

Не всё укладывается в скользящее 40-минутное окно. Но вместо того чтобы строить универсальный пайплайн, решающий все пограничные случаи в реальном времени, мы пошли более прагматичным путём. Для любых событий, выходящих за пределы живого окна, мы создали отдельные batch-задачи. Они могут запускаться по требованию, когда нам нужно перезалить или переобработать старые данные, не усложняя основную систему.

Ключевые выводы

Выводы команды Replo — это выжимка из граблей, по которым они прошли. И они абсолютно правы: ClickHouse — это не OLTP-база, и попытки сделать из него систему точного учёта состояний с очередями и флагами приводят к тому, что вы тонете в собственной архитектуре. Мой совет вдогонку: если вам нужны инкрементальные пересчёты — используйте AggregateFunction с комбинирующими состояниями. ClickHouse умеет мержить агрегатные состояния без полного пересчёта, и это работает значительно быстрее любых самописных очередей. И да, ReplacingMergeTree + минимум MV — это действительно золотой стандарт для 90% случаев. Остальные 10% — это когда вы уже понимаете, зачем вам нужно что-то сложнее.
Всеволод Миронович
Всеволод Миронович Директор по данным @ Далее

Мы не ставили целью стать экспертами в ClickHouse, материализованных представлениях или пайплайнах атрибуции на основе сессий — и до сих пор ими не являемся. Мы просто хотели быстрых и надёжных метрик. Но где-то на этом пути — после рекурсии CTE, раздувания очередей, взрывов памяти и хаоса с обновлением представлений — мы усвоили несколько вещей, которые, как нам кажется, стоит запомнить.

Когда использовать CTE, а когда отказываться от них

Хотя CTE могут улучшить организацию запросов, их влияние на производительность по сравнению с подзапросами или временными таблицами может различаться. В некоторых случаях, особенно со сложными CTE или когда оптимизатор ClickHouse с трудом находит оптимальный план, временные таблицы могут обеспечить лучшую производительность благодаря возможности применения индексов и статистики.

Оптимизатор запросов ClickHouse не всегда полностью оптимизирует CTE, что может привести к повторным вычислениям, если CTE используется несколько раз. Понимание плана запроса с помощью EXPLAIN PIPELINE может помочь выявить и устранить такие неэффективности.

Избегайте очередей пересчёта с обновлением, если они вам действительно не нужны

Отслеживание сессий, которые «нуждаются» в вычислении, с помощью счётчиков и флагов звучит чисто. На самом деле это ещё одна система, которую нужно поддерживать в рабочем состоянии и синхронизировать. Наша молчаливо раздувалась неделями из-за сломанного пути снятия отметок. Это создавало нагрузку на путь записи и усложняло наблюдаемость. Гораздо лучше вычислять на месте, по фильтрованным окнам, и позволить TTL очищать остальное.

Используйте TTL, кэши и фильтрованные обновления

Лучших результатов мы добились, когда перестали пытаться вычислять всё и всегда. Вместо этого мы сосредоточились на свежих данных. Живые сессии попадают во временные таблицы с TTL. Материализованные представления сканируют только несколько минут данных. А когда нам нужно сделать обратную загрузку, мы запускаем контролируемые batch-задачи по архивированным партициям. ClickHouse отлично подходит и для потоковой, и для пакетной обработки — но их смешивание требует осторожности.

ReplacingMergeTree + минимум материализованных представлений = победа

Здесь нет магии. ReplacingMergeTree обеспечивает чистую дедупликацию со временем без необходимости микроменеджмента. Несколько хорошо спроектированных материализованных представлений дают нам нужные проекции. Никаких FINAL, никаких DISTINCT, никаких раздутых очередей. Просто горсть таблиц, которые легко понять и дёшево поддерживать.

Мы используем FINAL и DISTINCT, но только там, где они предназначены — на этапе чтения, и только при запросе самых свежих данных.

Дальнейшие шаги

Мы считаем, что ещё есть возможности для повышения производительности. Мы рассказали не обо всём, что сделали, чтобы прийти к этому результату — удаление nullable-полей, использование материализованных колонок, явное указание низкокардинальных колонок и так далее. Потребовалось несколько ложных стартов, но сейчас мы довольны тем, как всё сложилось.

Если вы строите что-то похожее, надеюсь, эта история сэкономит вам несколько шагов, даст несколько паттернов для проб или, по крайней мере, покажет наглядные примеры того, как не надо делать.

Есть что оптимизировать или заметили то, что упустили мы? Пишите на x.com/replohq, а если хотите работать над подобными системами или считаете, что сможете построить их лучше, присоединяйтесь к нашей команде на replo.app/careers.