Все уроки
ДанныеПродвинутый12 мин чтенияПолезно знать

ClickHouse для аналитики

Колоночная база данных, которая агрегирует миллиарды событий за секунды.

  • Columnar
  • OLAP
  • Materialized views
  • Event data
Шаг 1/5
Вопрос: общая сумма продаж. В таблице миллиард строк, в каждой есть date, country, url и amount.

За 30 секунд

ClickHouse — open-source колоночная база данных, созданная для аналитики (OLAP). Она хранит каждую колонку отдельно и в сжатом виде, поэтому запрос, суммирующий одну колонку по миллиардам строк, читает только эту колонку. Она идеальна для событий, логов, метрик и дашбордов продуктовой аналитики. Релизы выходят ежемесячно, плюс два LTS-релиза в год (например, 26.3 LTS в марте 2026), в своей инфраструктуре или как ClickHouse Cloud.

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

Почему это важно

  • Аналитические запросы сканируют огромные диапазоны и агрегируют несколько колонок. Строковая база вроде MySQL или PostgreSQL для этого читает каждую строку целиком, и дашборды встают, как только событий становится сотни миллионов.
  • ClickHouse часто сжимает данные событий в 5–20 раз и обрабатывает миллиарды строк в секунду на одном сервере, что делает дашборды в реальном времени доступными по цене.
  • Команды выбирают его, чтобы увести отчёты с транзакционной базы, — тогда тяжёлые отчёты перестают тормозить checkout или API.
  • Это сильная self-hosted или managed альтернатива оплате за каждое событие во внешних системах аналитики — с полноценным SQL и полным владением данными.

Как это работает

01Идея: колонки, а не строки

Представьте таблицу с миллиардом просмотров страниц. Чтобы ответить на вопрос «просмотры по странам за прошлую неделю», строковое хранилище должно прочитать каждую строку со всеми её двадцатью колонками. Колоночное хранилище держит каждую колонку в отдельном файле: оно читает только дату и страну, а это крошечная доля данных. Значения в одной колонке похожи (много повторяющихся стран), поэтому они очень хорошо сжимаются, а CPU может обрабатывать их пачками с помощью векторных инструкций. Компромисс: получить или обновить одну полную строку медленнее, и ClickHouse не рассчитан на множество мелких транзакционных обновлений. OLTP (заказы, пользователи) остаётся в реляционной базе; OLAP (вопросы по истории) — сюда.

  • OLTP: много мелких чтений и записей отдельных записей, с транзакциями.
  • OLAP: немного больших запросов, которые сканируют и агрегируют множество записей.
  • Векторное выполнение: обработка блока значений за одну операцию CPU вместо одного значения за раз.

02Таблицы MergeTree и ключ сортировки

Почти каждая таблица использует движок из семейства MergeTree. Каждая вставка записывает неизменяемый part, отсортированный по ключу ORDER BY, а фоновые слияния объединяют мелкие parts в крупные. Первичный индекс разреженный: он хранит одну запись на гранулу (по умолчанию 8,192 строки), поэтому достаточно мал, чтобы оставаться в памяти. Когда запрос фильтрует по ведущим колонкам ORDER BY, ClickHouse пропускает гранулы, которые не могут подойти. Поэтому выбор ORDER BY — самое важное проектное решение: сначала колонки с низкой кардинальностью, по которым вы фильтруете всегда (тенант, тип события), затем время. PARTITION BY нужен для управления данными (удаления старых месяцев), а не для скорости; держите партиции крупными.

sql
CREATE TABLE events
(
    tenant_id   UInt32,
    event_type  LowCardinality(String),
    event_time  DateTime64(3, 'UTC'),
    user_id     UInt64,
    url         String,
    country     LowCardinality(String),
    properties  JSON
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_type, event_time)
TTL toDateTime(event_time) + INTERVAL 13 MONTH DELETE;

03Как правильно вставлять данные

ClickHouse любит большие пачки и ненавидит мелкие вставки. Каждый INSERT создаёт как минимум один part, а тысячи однострочных вставок в секунду создают тысячи parts, что вызывает знаменитую ошибку «too many parts». Группируйте на клиенте (от тысяч до сотен тысяч строк на вставку, примерно раз в секунду) или включите асинхронные вставки, чтобы сервер буферизовал мелкие записи за вас. Данные обычно приходят через очередь (Kafka, воркер на Redis, HTTP-коллекторы) и сбрасываются пачками. Форматы вроде JSONEachRow, CSV, Parquet и нативный формат поддерживаются по HTTP (порт 8123) или по нативному протоколу (9000).

bash
# Batch insert over HTTP from a file of JSON lines
curl -u your_user:your_password \
  'http://localhost:8123/?query=INSERT%20INTO%20events%20FORMAT%20JSONEachRow' \
  --data-binary @events.ndjson
 
-- Or let the server batch small inserts
INSERT INTO events SETTINGS async_insert = 1, wait_for_async_insert = 1
FORMAT JSONEachRow {"tenant_id":1,"event_type":"page_view","event_time":"2026-11-05 10:00:00","user_id":7,"url":"/","country":"AM","properties":{}}

04Аналитический SQL

ClickHouse говорит на SQL с множеством аналитических дополнений. uniq() и uniqExact() считают уникальных пользователей (uniq приближённый и намного быстрее), quantile() вычисляет перцентили, countIf() и sumIf() фильтруют внутри агрегатов, а windowFunnel() и retention() напрямую отвечают на продуктовые вопросы. Массивы и map — полноценные типы, а тип JSON хранит полуструктурированные свойства как типизированные подколонки. Всегда фильтруйте по ведущим колонкам ORDER BY и диапазону времени, выбирайте только нужные колонки (никогда не делайте SELECT * по широкой таблице) и используйте LIMIT при исследовании данных.

sql
SELECT
    toStartOfDay(event_time)                      AS day,
    uniq(user_id)                                 AS visitors,
    countIf(event_type = 'purchase')              AS purchases,
    round(purchases / visitors * 100, 2)          AS conversion_pct
FROM events
WHERE tenant_id = 1
  AND event_time >= now() - INTERVAL 30 DAY
GROUP BY day
ORDER BY day;

05Materialized views для быстрых дашбордов

Materialized view в ClickHouse — это триггер на вставку: когда строки приходят в исходную таблицу, view выполняет свой SELECT только над этой новой пачкой и пишет результат в целевую таблицу. Вместе с SummingMergeTree или AggregatingMergeTree это поддерживает предагрегированные rollup-таблицы (по часам, по тенантам) всегда актуальными, и дашборды читают тысячи строк вместо миллиардов. При записи используйте комбинаторы -State, а при чтении -Merge, потому что частичные агрегаты из разных parts нужно сливать. Помните, что view видит только новые вставки; исторические данные заполняйте отдельно через INSERT INTO ... SELECT.

sql
CREATE TABLE events_hourly
(
    tenant_id UInt32, hour DateTime, event_type LowCardinality(String),
    events UInt64, users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree ORDER BY (tenant_id, event_type, hour);
 
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT tenant_id, toStartOfHour(event_time) AS hour, event_type,
       count() AS events, uniqState(user_id) AS users
FROM events GROUP BY tenant_id, hour, event_type;
 
-- Read: SELECT hour, sum(events), uniqMerge(users) FROM events_hourly WHERE tenant_id = 1 GROUP BY hour;

06Как запускать: один узел, кластер или облако

Один сервер ClickHouse выдерживает гораздо больше, чем ожидает большинство команд, — часто миллиарды строк на одной машине с быстрыми NVMe-дисками. Для высокой доступности используйте таблицы ReplicatedMergeTree, которые координирует ClickHouse Keeper (встроенная замена ZooKeeper); для большей ёмкости шардируйте данные по узлам через таблицу Distributed. ClickHouse Cloud отделяет хранение (объектное хранилище) от вычислений и снимает большую часть этой работы — за цену по потреблению. Что бы вы ни выбрали: создавайте пользователей с минимальными правами, не открывайте порты 8123 и 9000 в публичный интернет, задавайте лимиты на запросы (max_memory_usage, max_execution_time) для каждого пользователя и делайте бэкапы через BACKUP TO S3 или snapshot вашего провайдера.

Когда применять

  • Продуктовая аналитика, clickstream и трекинг событий — от сотен миллионов до триллионов строк.
  • Логи, метрики и трейсы, где фильтрация идёт по времени, а агрегация должна быть быстрой.
  • Дашборды для клиентов, которые должны отвечать на агрегатные запросы быстрее секунды.
  • Разгрузка транзакционной базы от тяжёлых отчётов.

Когда не стоит

  • Как основная база приложения с частыми обновлениями отдельных строк, удалениями и транзакциями.
  • Небольшие наборы данных (несколько миллионов строк), где PostgreSQL с хорошими индексами и так отвечает за миллисекунды.
  • Нагрузки, которым нужны строгие внешние ключи и ограничения между сущностями.

Альтернативы

  • PostgreSQL (плюс TimescaleDB или колоночные расширения)

    Остаётся одна база; нормально до умеренных объёмов, медленнее на больших сканированиях.

  • BigQuery / Snowflake

    Полностью управляемые хранилища данных, отлично подходят для пакетной аналитики; оплата за запрос или вычисления, выше задержка для живых дашбордов.

  • Apache Druid / Apache Pinot

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

  • DuckDB

    Встраиваемый колоночный движок для локальной или однопроцессной аналитики по файлам вроде Parquet.

Лучшие практики

  1. Проектируйте ORDER BY от самых частых фильтров: сначала колонки с низкой кардинальностью, затем время.
  2. Вставляйте большими пачками или включите async inserts; никогда не стримьте однострочные вставки.
  3. Используйте LowCardinality(String) для повторяющихся значений и самые маленькие подходящие числовые типы.
  4. Держите партиции крупными (обычно по месяцам) и используйте TTL для автоматического удаления или переноса старых данных.
  5. Предагрегируйте запросы дашбордов через materialized views в таблицы AggregatingMergeTree.
  6. Задавайте для каждого пользователя лимиты на память, время выполнения и размер результата, чтобы защитить кластер.
  7. Храните источник истины для бизнес-сущностей в OLTP-базе и синхронизируйте в ClickHouse.

Частые ошибки

  • Партиционирование по дням или по пользователям, которое создаёт тысячи мелких партиций и слишком много parts.
  • Частые мутации ALTER TABLE ... UPDATE или DELETE, как будто это MySQL; они переписывают parts целиком.
  • Ожидание, что materialized view включит исторические данные, существовавшие до его создания.
  • ORDER BY с колонкой высокой кардинальности на первом месте (например, user_id или UUID), что сводит на нет пропуск гранул по индексу.
  • Публично открытый HTTP-порт с пользователем по умолчанию и без пароля.

Глубже

07Parts, гранулы и как запрос пропускает данные

Каждый part на диске — это директория с отдельным файлом на каждую колонку (wide-формат) плюс primary.idx и файлы засечек (marks). Засечки указывают на начало каждой гранулы внутри сжатых файлов колонок. При выполнении запроса ClickHouse бинарным поиском по primary.idx находит диапазон гранул, чьи значения ORDER BY могут подойти, читает только засечки для этих гранул и распаковывает только эти блоки запрошенных колонок. Индексы пропуска данных (minmax, set, bloom_filter) добавляют сводки по гранулам для колонок вне ключа сортировки. Используйте EXPLAIN indexes = 1, чтобы увидеть, сколько parts и гранул выбрано из общего числа; фильтр, выбирающий 95% гранул, — это замаскированное полное сканирование.

sql
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE tenant_id = 1 AND event_time >= now() - INTERVAL 1 DAY;
 
-- A skipping index for a column outside ORDER BY
ALTER TABLE events ADD INDEX idx_url url TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_url;

08Дедупликация и меняющиеся данные: ReplacingMergeTree и родственники

ClickHouse не обеспечивает уникальность ключей. ReplacingMergeTree оставляет последнюю строку для каждого ключа ORDER BY, выбирая её по колонке версии, но только после слияний, которые происходят в непредсказуемый момент. Запросы, которым нужны точные результаты, используют FINAL (слияние во время чтения; в последних версиях оно намного быстрее, но всё равно стоит ресурсов) или агрегируют через argMax(value, version). CollapsingMergeTree и VersionedCollapsingMergeTree обрабатывают обновления вставкой отменяющей строки с sign -1. Вставки в реплицируемые таблицы дедуплицируются по хэшу блока в течение окна, поэтому повтор той же пачки после таймаута безопасен, если пачка идентична.

sql
CREATE TABLE users_dim
( user_id UInt64, plan LowCardinality(String), updated_at DateTime64(3) )
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;
 
SELECT plan, count() FROM users_dim FINAL GROUP BY plan;

09JOIN-ы, словари и projections

ClickHouse выполняет JOIN-ы, и его планировщик JOIN-ов сильно улучшился в последних релизах, но hash join по умолчанию всё ещё строит правую таблицу в памяти, поэтому держите меньшую таблицу справа и фильтруйте обе стороны до соединения. Для небольших медленно меняющихся таблиц-измерений (страны, тарифы, названия товаров) лучше словари: они загружаются в память и запрашиваются через dictGet(), часто намного быстрее JOIN-а. Projections хранят дополнительную, иначе отсортированную или предагрегированную копию таблицы внутри тех же parts, и оптимизатор использует их автоматически, когда запрос подходит. Они стоят места на диске и времени вставки, поэтому добавляйте их только для доказанно горячих запросов.

10Горизонтальное масштабирование и защита кластера

Сначала масштабируйтесь вертикально: больше ядер и быстрые NVMe дают почти линейный прирост. Когда нужно больше, шардируйте по ключу, соответствующему тенантам (например, cityHash64(tenant_id)), чтобы большинство запросов попадали на один шард, и реплицируйте каждый шард через ReplicatedMergeTree и ClickHouse Keeper (три узла Keeper для кворума). Таблица Distributed рассылает запросы и объединяет результаты. Для multi-tenant аналитики защищайте кластер профилями настроек и квотами: max_memory_usage, max_execution_time, max_rows_to_read и max_concurrent_queries для каждого пользователя, плюс row policies, чтобы пользователь каждого тенанта видел только свои строки. Мониторьте system.query_log, system.parts (parts на партицию) и system.merges и ставьте алерты на количество parts и отставание репликации.

Реальные ситуации

01

Ошибки «Too many parts» во время всплеска трафика

Проблема

Сервис трекинга вставляет каждое событие в ClickHouse сразу по приходу. Во время маркетинговой кампании вставки начинают падать с «Too many parts», а дашборды отстают.

Как решить

Каждая однострочная вставка создаёт part, и слияния не успевают. В краткосрочной перспективе включите async_insert = 1 с wait_for_async_insert = 1 для пользователя трекинга, чтобы сервер группировал записи. Правильно — поставить буфер перед ClickHouse: endpoint трекинга пишет в очередь, а воркер вставляет пачки по 10,000–100,000 строк каждые секунду-две. Проверьте PARTITION BY таблицы: ежедневные партиции или партиции по тенантам умножают число parts, поэтому перейдите на помесячные. Следите за system.parts — активными parts на партицию — и ставьте алерт до порогов. После всплеска OPTIMIZE не нужен: слияния догонят сами.

02

Запросы дашборда замедляются с ростом данных

Проблема

Клиентский дашборд с ежедневными активными пользователями по тенантам на запуске отвечал за 200 ms, а теперь, при трёх миллиардах событий, — за 6 секунд.

Как решить

Проверьте EXPLAIN indexes = 1: если ORDER BY начинается с event_time, запрос каждого тенанта сканирует всех тенантов. Пересоберите таблицу с ORDER BY (tenant_id, event_type, event_time) через INSERT INTO new SELECT FROM old и атомарный EXCHANGE TABLES. Затем добавьте materialized view в таблицу AggregatingMergeTree с часовой гранулярностью и uniqState(user_id), заполните историю и переключите дашборд на rollup с uniqMerge. Теперь дашборд читает тысячи предагрегированных строк вместо миллиардов сырых событий, а сырые данные остаются доступны для детализации.

03

Запрос на удаление по GDPR в хранилище только для дописывания

Проблема

Пользователь просит удалить его данные. Его события разбросаны по 13 месяцам партиций в таблице из нескольких миллиардов строк.

Как решить

Используйте лёгкий DELETE FROM events WHERE user_id = 12345, который сразу скрывает строки маской и физически удаляет их при последующих слияниях; запускайте его фоновой задачей и логируйте запрос. Если запросов на удаление много, раз в день объединяйте их в одну команду с WHERE user_id IN (...). Убедитесь, что rollup-таблицы изначально не содержат персональных идентификаторов: агрегируйте счётчики и приближённые уникальные значения без сырых ID пользователей. Помните о бэкапах: задокументируйте срок хранения, чтобы удалённые данные со временем исчезали и из бэкапов. В долгосрочной перспективе рассмотрите псевдонимные ID в событиях с таблицей соответствия в OLTP-базе — тогда удаление соответствия анонимизирует историю.

Шпаргалка

Выполнить запрос из shell
clickhouse-client --query "SELECT version()"
Размер таблиц и сжатие
SELECT table, formatReadableSize(sum(data_compressed_bytes)) AS c, formatReadableSize(sum(data_uncompressed_bytes)) AS u FROM system.parts WHERE active GROUP BY table
Активные parts по партициям
SELECT partition, count() FROM system.parts WHERE table = 'events' AND active GROUP BY partition
Проверить использование индекса
EXPLAIN indexes = 1 SELECT ...
Самые медленные недавние запросы
SELECT query_duration_ms, query FROM system.query_log WHERE type = 'QueryFinish' ORDER BY query_duration_ms DESC LIMIT 10
Мгновенно удалить старый месяц
ALTER TABLE events DROP PARTITION 202410
Лёгкое удаление
DELETE FROM events WHERE user_id = 12345
Атомарно поменять таблицы местами
EXCHANGE TABLES events AND events_new
Бэкап в S3
BACKUP TABLE events TO S3('https://bucket.s3.amazonaws.com/backups/events', 'KEY_ID', 'SECRET')

Вопросы и ответы

Q1Почему ClickHouse для аналитики настолько быстрее MySQL?

Работают три причины вместе. Он хранит данные по колонкам, поэтому запрос читает только нужные колонки, а не строки целиком. Эти колонки сильно сжаты, часто в десять раз и больше, так что с диска приходит меньше данных. И он выполняет запросы в векторной форме, обрабатывая блоки из тысяч значений за инструкцию CPU на всех ядрах. Разреженный первичный индекс при этом позволяет пропускать целые гранулы, которые не могут подойти. MySQL оптимизирован под противоположную нагрузку: найти и обновить отдельные строки по индексу с транзакциями, и это ClickHouse делает плохо. Каждый быстр в том, для чего создан.

Q2Как выбрать ключ ORDER BY?

Начните с запросов. Перечислите фильтры, которые используют большинство дашбордов и API-вызовов. Сначала поставьте колонки, по которым почти каждый запрос фильтрует на равенство, в порядке от низкой кардинальности к высокой, например tenant_id и event_type, затем колонку времени для диапазонов. Это позволяет разреженному индексу пропускать большинство гранул. Не ставьте UUID или user_id первым, если только большинство запросов не фильтруют по одному пользователю. ORDER BY влияет и на сжатие, потому что похожие значения оказываются рядом. Если одна таблица должна обслуживать очень разные паттерны запросов, добавьте projections или вторую материализованную таблицу с другим порядком.

Q3Можно ли обновлять или удалять данные в ClickHouse?

Да, но проектировать стоит так, чтобы это требовалось редко. Классические ALTER TABLE UPDATE и DELETE — это мутации: они переписывают затронутые parts в фоне, что тяжело. Лёгкий DELETE FROM помечает строки как удалённые скрытой маской и намного дешевле, а последние версии добавляют и лёгкие обновления. Для меняющихся данных обычный паттерн — ReplacingMergeTree: вставьте новую версию строки и позвольте слияниям оставить последнюю, а для точных результатов читайте с FINAL или через argMax. Для хранения по сроку используйте TTL и DROP PARTITION — они мгновенные.

Q4Как доставить данные из приложения в ClickHouse?

Не пишите в ClickHouse синхронно в пути запроса на каждое событие. Приложение или endpoint трекинга отправляет события в буфер — Kafka, очередь на Redis или небольшой сервис-коллектор, — а воркер вставляет пачки раз в секунду или каждые несколько тысяч строк. ClickHouse также может потреблять Kafka напрямую через табличный движок Kafka или ClickPipes в облаке. Для синхронизации реляционных таблиц используйте change data capture или периодические инкрементальные выгрузки по updated_at. Если без мелких вставок никак, включите async_insert, чтобы сервер группировал их. Цель — немного больших вставок.

Q5Что такое materialized view в ClickHouse и чем он отличается от PostgreSQL?

В PostgreSQL materialized view хранит результат запроса, и вы обновляете его по требованию, пересчитывая всё. В ClickHouse классический materialized view — это триггер на вставку: каждый новый блок, вставленный в исходную таблицу, преобразуется запросом view и дописывается в целевую таблицу. Это делает его инкрементальным и всегда актуальным — идеально для rollup. Подвох в том, что он видит только новые данные, поэтому исторические строки нужно заполнять вручную, а агрегации должны использовать функции state и merge, потому что частичные результаты приходят на каждую вставку. В ClickHouse также есть refreshable materialized views для периодического полного пересчёта.

Q6Может ли ClickHouse заменить хранилище данных (data warehouse)?

Для многих команд — да. Он справляется со схемами «звезда», JOIN-ами, оконными функциями и огромными объёмами, а его скорость делает его хорошим движком и для внутренней BI, и для аналитики для клиентов. По сравнению с BigQuery или Snowflake он обычно дешевле при стабильной нагрузке и быстрее отвечает на интерактивные запросы, но больше приходится администрировать самому, если не использовать ClickHouse Cloud, а очень большие JOIN-ы «многие ко многим» требуют более аккуратного моделирования. Хранилища данных сильны в ad-hoc анализе многими аналитиками по множеству источников. Типичная схема — ClickHouse для аналитики событий в реальном времени рядом с хранилищем для финансов и пакетных отчётов.

Вопросы уровня senior

S1Как бы вы спроектировали multi-tenant аналитику для клиентов на ClickHouse?

Я бы использовал общие таблицы с tenant_id первой колонкой в ORDER BY, чтобы запросы каждого тенанта читали узкий диапазон индекса, и шардировал бы по хэшу tenant_id, если одного узла мало. Запросы каждого тенанта идут через отдельного пользователя или профиль настроек с row policies (tenant_id = X), квотами и лимитами на память, время выполнения и прочитанные строки, чтобы один тяжёлый клиент не душил остальных. Дашборды читают из материализованных rollup, а не из сырых событий, а слой API разрешает только заранее заданные формы запросов вместо передачи сырого SQL. Очень крупных тенантов можно вынести на выделенные шарды. Компромисс операционный: общие таблицы эффективны, но требуют строгих ограничений; таблица на тенанта изолирует лучше, но взрывает количество parts и таблиц.

S2ClickHouse или облачное хранилище данных вроде BigQuery для новой аналитической платформы?

Зависит от формы нагрузки. Для пользовательских дашбордов с задержкой меньше секунды, высокой конкурентностью запросов и стабильным потоком событий ClickHouse обычно быстрее и дешевле, потому что вы платите за выделенные вычисления, а не за каждый просканированный байт. Для ad-hoc анализа многими аналитиками по десяткам источников, с пиковым использованием и тяжёлыми JOIN-ами проще BigQuery или Snowflake: не нужно планировать ёмкость, зрелое управление данными и экосистема. ClickHouse требует большей дисциплины моделирования (ключи сортировки, rollup, никаких частых обновлений). Многие компании используют оба: хранилище для финансов и пакетной BI, ClickHouse для продуктовой аналитики и observability, оба питаются от одного потока событий.

S3Как гарантировать exactly-once загрузку из Kafka в ClickHouse?

Настоящий сквозной exactly-once сложен; цельтесь в идемпотентный at-least-once. Вариант первый: табличный движок Kafka с materialized view, который коммитит offset после вставок, но при сбоях может дублировать. Вариант второй: свой consumer, который при повторе вставляет идентичные пачки, чтобы дедупликация блоков в ReplicatedMergeTree отбрасывала повторы (или задавайте insert_deduplication_token на каждую пачку). Вариант третий: sink ClickHouse Kafka Connect или ClickPipes, которые дают семантику exactly-once в рамках своей архитектуры. Во всех случаях добавляйте event_id и используйте ReplacingMergeTree или запросы с argMax для таблиц, где дубли изменили бы цифры, и периодически сверяйте счётчики с источником. Компромисс — сложность против точности; для приблизительных продуктовых метрик at-least-once часто приемлем.

Глоссарий

OLAP
Online analytical processing: большие запросы, которые сканируют и агрегируют множество строк.
Колоночное хранение
Хранение каждой колонки отдельно, чтобы запросы читали только нужные колонки.
MergeTree
Основное семейство табличных движков ClickHouse: отсортированные неизменяемые parts, сливаемые в фоне.
Part
Неизменяемый кусок таблицы на диске, создаваемый каждой вставкой и со временем сливаемый.
Гранула
Наименьший блок, на который указывает разреженный индекс, по умолчанию 8,192 строки.
Разреженный первичный индекс
Одна запись индекса на гранулу; используется для пропуска блоков, которые не могут подойти под фильтр.
Materialized view
В ClickHouse — триггер на вставку, который преобразует новые строки в другую таблицу.
LowCardinality
Обёртка колонки, кодирующая повторяющиеся значения словарём ради меньшего объёма и более быстрых запросов.
ClickHouse Keeper
Встроенный сервис координации для репликации, совместимый с ZooKeeper.

Есть идея продукта?Давайте создадим его.

Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.