Я блокчейн разработчик и в проекте у нас базы с сотнями гигабайт данных с децентрализованных бирж. Чтобы строить аналитические отчеты и делать агрегации, такие как вычисления цен, биржевых свечей, объемов торгов, цен на токены, мы используем БД Clickhouse (далее CH). До этого я работал только с Postgres (и давно с MSSQL), и хочу рассказать, как я вкатывался, что удивило – практический опыт и WTFы. Прочитав эту статью вам, возможно, захочется сделать аналитику по своим данным в Clickhouse – возможно, ищете, что полезного освоить на длинных выходных. Итак, поехали!

Зачем вообще Clickhouse?

Добавил этот раздел после вопросов в комментах. CH – это БД, спроектированная для OLAP, т.е. запросов со сложной обработкой данных, такие как агрегации, различные аналитические разрезы по очень большим объемам. Данные хранятся по колонкам (columnar), и эти данные по умолчанию сжаты (ZSTD в CH Cloud), поскольку данные по колонкам однотипные и хорошо сжимаются. Приведу пример конкретной таблицы из моей БД:

SELECT 
    column,
    formatReadableSize(data_compressed_bytes) AS compressed,
    formatReadableSize(data_uncompressed_bytes) AS uncompressed,
    data_uncompressed_bytes / data_compressed_bytes AS ratio
FROM system.columns
WHERE table = 'liquidity_events_raw' AND database = 'liquidity'
ORDER BY ratio DESC

Compressed – сколько данные занимают на диске, uncompressed – это сколько бы они заняли, если бы лежали по строчкам. В целом, на моей таблице компрессия в 6 раз – таблица на диске занимает 129 Гб, а если бы те же данные вставить в, например, Postgres – заняла бы 786 Гб. Это не только сильно экономит место, но и позволяет быстрее делать агрегаты – ведь читать с диска данных нужно меньше, а I/O – как раз бутылочное горлышко. Более подробно можно почитать тут и тут. Я очень уважаю Постгрес и использую его, но CH и Postgres – БД для разных задач. Так что если нужно быстро делать аналитические запросы, то я выберу CH.

Итак, приступим к практике.

Данные не всегда финальны / "никуда без мержинга"

Типичный use-case кликхауса – мы вставляем данные в таблицу, и потом пишем по ним аналитические запросы. Предположим, нам поступают цены на токены, и мы хотим иметь возможность посмотреть последнюю цену в любой момент времени:

CREATE TABLE latest_price (
    token String,
    price Float64,
    version UInt32
) ENGINE = MergeTree()
ORDER BY token;й

-- в таблицу поступают разные текущие значения 
INSERT INTO latest_price VALUES ('ETH', 3000, 1);
INSERT INTO latest_price VALUES ('ETH', 3100, 2);
INSERT INTO latest_price VALUES ('ETH', 3050, 3);
INSERT INTO latest_price VALUES ('BTC', 100000, 1);

Вот как можно было бы посмотреть последнюю цену. Очень предсказуемо, в Постгресе тоже будет работать:

SELECT token, price, version
FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY token ORDER BY version DESC) AS rn
    FROM latest_price
) t WHERE rn = 1;

/* 
Выводит:
        BTC	100000.0	1
        ETH	3050.0      3
*/

Теперь давайте соптимизируем место на диске; говорят, в CH для этого есть ReplacingMergeTree, которая сохраняет только последнюю версию данных (последнюю цену). Для этого, создадим ту же таблицу заново, но с другим ENGINE и вставим те же данные:

CREATE TABLE latest_price (
    token String,
    price Float64,
    version UInt32
) ENGINE = ReplacingMergeTree(version) -- строчки с бОльшим version "остаются"
ORDER BY token;

-- Те же INSERTs

-- И финально SELECT
SELECT * FROM latest_price;

/*
ETH	3000.0	    1
ETH	3100.0   	2
ETH	3050.0	    3
BTC	100000.0	1
*/

Странно, вроде предполагается, что мы получим только последние версии (две строчки). Вот мы и "открыли" концепцию нефинальных данных (non-final data). Пока таблица состоит из 4х строчек, но когда-нибудь (непонятно когда), она оптимизируется до 2х (на практике, это через полчаса должно произойти). Можно воспользоваться FINAL, чтобы посмотреть, как это должно выглядеть:

SELECT * FROM latest_price FINAL;

/*
ETH	3050.0	    3
BTC	100000.0	1
*/

FINAL не оказывает влияния на данные на диске, а мержит данные (берет последнюю версию цены) в RAM. Чтобы выполнить мержинг на диске раньше времени, можно написать OPTIMIZE TABLE latest_price FINAL;

Что значит мержинг? Можно почитать, но двух словах – для быстроты вставки на каждый insert batch создается свой part, физическая частичка данных на диске, которые потом смерживаются (соединяются) бекграунд процессом, тем самым экономя место на диске. Можно посмотреть parts таблицы запросом:

SELECT name, rows
FROM system.parts
WHERE (`table` = 'latest_price') AND active
ORDER BY name ASC;

-- до OPTIMIZE TABLE
/*
name         rows
  all_1_1_1	 1
  all_2_2_1	 1
  all_3_3_1	 1
  all_4_4_1	 1
*/

-- после OPTIMIZE TABLE
/*
  name         rows
  all_1_4_2	   2
*/

Это был первый WTF – я привык к тому, что в Постгресе (OLTP) мы всегда видим только финальные данные. В случае CH (OLAP), нефинальность данных обеспечивает более высокую скорость вставки, перенося косты мержинга на бекграунд процесс. Поэтому CH не используется для трекинга состояния пользователей, статусы покупок и документов – только для аналитики по большим данным.

картинка отсюда https://habr.com/ru/companies/servermall/articles/948686/

Подробнее про parts, про движок MergeTree, про Partitions (с помощью которых можно включать/выключать части данных).

Никуда без materialized views (MVs)

MV нужны, чтобы предвычислить данные на этапе вставки для более быстрых селектов в дальнейшем. Элементарный пример – MV читает все цены токенов, но сохраняет в target таблицу только записи выше 5000:

-- Target-таблица
CREATE TABLE expensive_tokens (
    token String,
    price Float64,
    version UInt32
) ENGINE = MergeTree() ORDER BY token;

-- MV: только цены > 5000
CREATE MATERIALIZED VIEW expensive_tokens_mv TO expensive_tokens AS
SELECT * FROM latest_price WHERE price > 5000;

-- делаем вставки

SELECT * FROM expensive_tokens;
/*
выводит
BTC	100000.0	1
*/

Более сложный случай – комбинация MV + AggregatingMergeTree. На основе таблицы latest_price выше мы хотим предвычислять и сохранять в другую таблицу последнюю цену токена:

-- создадим таблицу latest_price как раньше

-- destination таблица (куда пишется результат агрегирования)
CREATE TABLE token_stats (
    token String,
    latest_price AggregateFunction(argMax, Float64, UInt32)
) ENGINE = AggregatingMergeTree()
ORDER BY token;

-- MV, который читает из исходной (source) таблицы latest_price
CREATE MATERIALIZED VIEW token_stats_mv TO token_stats AS
SELECT
    token,
    -- след. стейт - цена для максимальной версии (последняя цена)
    argMaxState(price, version) AS latest_price
FROM latest_price
GROUP BY token;

-- теперь можно выполнить наши INSERTы

-- Посмотрим результат
SELECT 
    token,
    argMaxMerge(latest_price) AS price
FROM token_stats
GROUP BY token;

/*
  BTC	100000.0
  ETH	3050.0
*/

Когда я увидел примеры MV, вроде в целом стало понятно. Но сразу стали появляться вопросы:

  1. В чем разница между MV+AggregatingMergeTree vs ReplacingMergeTree (см. предыдущий пункт)?

    Ответ: MV сдвигает "тяжелую" обработку на INSERT, облегчив SELECT. В случае ReplacingMergeTree "тяжелая" обработка приходится на этап мерджа (либо через FINAL в памяти либо OPTIMIZE TABLE на диске). В случае MV – на этапе вставки, что предпочтительнее для больших таблиц. Также, MV+AggregatingMergeTree хранит только конкретные стейты, а ReplacingMergeTree – все строчки, что требует больше места на диске.

    Также, FINAL нельзя использовать в других MV, поэтому тут только MV+AggregatingMergeTree. Лично мне больше нравится MV + AggregatingMergeTree (тут невозможно забыть про финальность), но для простоты и если данные просто селектами дергать, то можно и ReplacingMergeTree (главное не забыть FINAL). ReplacingMergeTree – если вся строчка нужна.

  2. Когда срабатывает MV и что такое "работает на вставке" – это же просто селект. Из чего конкретно делается селект? Можно ли посмотреть всю source таблицу?

    То, что в MV в запросе выше latest_price – это не вся таблица, а только новые строчки, которые вставляются в рамках текущего батча. Всю таблицу напрямую посмотреть не получится, но можно сделать так:

    – Создаем еще одну MV на latest_price (например, latest_price_dup), которая будет просто дублировать latest_price.

    – В token_stats_mv делаем так

    WITH all_table AS (
      SELECT * FROM latest_price     -- новые строчки
      UNION ALL
      SELECT * FROM latest_price_dup -- старые строчки
    ),
    SELECT * FROM all_table  -- вся исходная таблица целиком
  3. Можно ли создать MV, если исходная таблица уже заполнена данными?

    Можно, для этого используется модификатор POPULATE при создании MV. Но тогда не получится указать destination таблицу. А иногда бывает нужно. Тогда можно сделать копию исходной таблицы, создать на нее MV, сделать вставку из исходной таблицы в копию, MV отработает, потом удалить копию и пересоздать MV на оригинальную таблицу – теперь и данные в MV есть, и все готово к работе. В целом, POPULATE менее гибкие, поэтому я в итоге перестал их юзать.

Подробнее про MV почитать тут.

Производительность JOIN

Выглядят они так же как и в Postgres:

CREATE TABLE liquidity_events (
    timestamp DateTime,
    token String,
    amount UInt32
) ENGINE = MergeTree()
ORDER BY token;

CREATE TABLE token_prices (
    token String,
    price Float64
) ENGINE = MergeTree()
ORDER BY token;

-- заполняем таблицы данными

-- извлекаем все события, джойним цены и выводим объем.
SELECT le.timestamp, le.token, tp.price*le.amount AS volume
FROM liquidity_events le
  JOIN token_prices tp ON tp.token = le.token
WHERE le.timestamp >= '2025-04-30 00:00:00'

Кажется, что все просто, но на практике CH загружает в память ВСЮ правую таблицу (то есть token_prices), несмотря на то, что у нее есть индекс по token, т.е. запрос будет очень медленным! Когда я впервые столкнулся, то не мог понять, почему так медленно.

Решение – либо менять местами таблицы, либо по возможности максимально фильтровать правую таблицу ДО джойна (можно вынести в CTE для читаемости), например:

SELECT le.timestamp, le.token, tp.price*le.amount AS volume
FROM liquidity_events le
  JOIN (
        SELECT * FROM token_prices
        -- какое-то условие для уменьшения размера таблицы
        -- например тут, если нужны данные только по трехбуквенным токенам
        -- (искусственный пример для простоты)
        WHERE length(token) = 3
      ) tp ON tp.token = le.token
WHERE le.timestamp >= '2025-04-30 00:00:00'
  AND length(le.token) = 3 -- тут тоже сразу фильтруем левую таблицу

JOIN с неточным совпадением (ASOF JOIN)

Иногда между таблицами нет точного совпадения. Например, нам нужно найти объем в $ операции ликвидности, для этого нужно для каждой liquidity_events найти цену токена в момент события либо последнюю ближайшую (последний обмен). Тогда используется ASOF JOIN:

CREATE TABLE liquidity_events (
    timestamp DateTime,
    token String,
    amount UInt32
) ENGINE = MergeTree()
ORDER BY token;

CREATE TABLE token_prices_ext (
    token String,
    timestamp DateTime,
    price Float64
) ENGINE = MergeTree()
ORDER BY (token, timestamp);

-- заполняем таблицы данными

-- извлекаем все события, джойним цены и выводим объем.
SELECT le.timestamp, le.token, tp.price*le.amount AS volume
FROM liquidity_events le
   ASOF JOIN token_prices_ext tp ON tp.token = le.token 
                        AND tp.timestamp <= le.timestamp
WHERE le.timestamp >= '2025-04-30 00:00:00'

Для него один из ON условий должен обязательно совпадать (в данном случае token), а другой быть больше-равно или меньше-равно.

Как показано в предыдущем примере, CH загрузит token_prices_ext в память целиком (что очень долго), поэтому нужно ограничивать правую часть джойна. На практике это можно сделать эвристикой. Например, token_prices_ext содержит данные до декабря 2025, а в таблице liquidity_events будут события только до 1 июня (то есть мы можем исключить данные в token_prices_ext июнь-декабрь), поэтому можно ограничить даты справа.

Также, если предположить, что для всех событий ликвидности есть цена не далее чем 1 день назад, то можно ограничить даты token_prices_ext еще и слева:

...
ASOF JOIN (
    SELECT * FROM token_prices_ext
    WHERE 
      timestamp <= '2025-06-01 00:00:00' -- ограничение справа
      AND timestamp >= '2025-04-29 00:00:00' -- ограничение по дате слева
) tp ON ...

Пользуясь такими эвристиками, можно здорово ускорить запросы.

Мелочи, про которых захотелось рассказать

Все мы привыкли к CTE. Так вот, в CH они не инлайнятся, то есть будут выполнены каждый раз при использовании, в отличие от Постгреса. На это дело уже два года как висит PR. Так что, будьте внимательны.

Из очень удобного – можно в отличие от Постгреса делать так:

SELECT a*b AS computed
FROM ...
WHERE computed > 0   -- алиас можно сразу использовать в условиях

Инструментарий

Как установить CH. На Mac поставил без проблем через brew (только потом не мог найти папку с данными). Для запросов рекомендую DBeaver, только обновите драйвер до последней версии (сейчас 0.9.5), иначе очень вероятны странные ошибки.

Также классная штука – clickhouse-client. Это консольный клиент, с помощью которого можно работать даже по SSH.

# запрос для MacOS
/opt/homebrew/bin/clickhouse client  --host myhost --port 9000 --user myuser --password 'my_pass'

Самое приятное в clickhouse-client – то, что при выполнении длинного запроса, нажав пробел, можно увидеть детальный прогресс, метрики запроса, используемую память и т.д. Наверняка это можно вытащить через системные таблицы, но иметь это в UI очень удобно.

Для отладки медленных запросов, если добавить перед запросом "EXPLAIN indexes=1", то можно получить план выполнения. Ниже изображен пример плохого запроса – были просканированы все parts и гранулы, что говорит о том, что индексы не задействованы вообще.

Иногда бывает удобно просить LLM объяснить план.

Конечно, правильные индексы – в CH называются ordering/sorting keys – это основное, на что надо смотреть при дизайне таблицы. Есть доп фишка – projections – когда к готовой таблице можно добавить как бы еще один sorting key, при этом таблица на диске задублируется (тк. порядок данных уже будет другой) – про это можно почитать в блоге CH.

Заключение

В рамках статьи невозможно охватить много материала – Clickhouse поистину бесконечен. Я надеюсь, что статья позволит вкатиться в аналитику больших данных быстрее, чем это сделал я. Если будут комментарии, багфиксы или ПРы – буду благодарен. Всех с наступающим!

Минутка PR. Веду тг‑канал Web3 разработчик. Пишу небольшие заметки о задачах по блокчейну/крипте (Ethereum), которые решаю. Буду рад видеть среди подписчиков!

Комментарии (16)


  1. Keeper22
    31.12.2025 11:05

    Зачем вкатываться в Clickhouse при живом Postgres-е?


    1. root85 Автор
      31.12.2025 11:05

      Я извиняюсь , а вы статью читали? )

      Postgres — OLTP база, Clickhouse — OLAP. Это разные БД для разных задач.


      1. StasTukalo
        31.12.2025 11:05

        Ну так все равно- зачем КХ для хранения котировок? Мне тоже не очень понятно.

        Расскажите пожалуйста, как вы вообще пришли к необходимости КХ?

        Я занимаюсь примерно таким же анализом, что описывается у вас- моя бд наверняка не такая большая как у вас- всего 638 ГБ, 2.52 млрд строк. Крутится под MariaDB пятилетней давности на достаточно слабой машине бытового уровня. Вопрос- зачем вам КХ? Я не ёрничаю, я реально хочу понять на каких задачах вам начало не хватать постгреса?


        1. root85 Автор
          31.12.2025 11:05

          Вопрос хороший, спасибо. По поводу, как КХ появился у нас – так исторически сложилось. То есть пример "было так, не устраивало вот это - перешли на КХ" – привести не смогу.

          У нас не только хранение котировок, а всевозможные агрегаты по этим данным (свечи разных интервалов, объемы торгов в разных разрезах, исторические параметры в разных разрезах и всякие такие штуки). Наверное, можно сделать это разными способами на разных БД, но в долгосрок OLAP БД тут выиграет.

          Приведу один пример – объем хранилища (и зависящая от этого скорость выполнения запросов). В КХ данные хранятся по столбцам, в Postgres – по строкам. Если данные хранятся по столбцам, то КХ их эффективно сжимает – разница может в несколько раз быть, тк. LZ4/ZSTD хорошо работает на повторяющихся данных. Вот тут подробнее можно почитать https://clickhouse.com/docs/data-compression/compression-in-clickhouse У вас как данные хранятся, по столбцам или по колонкам?


          1. StasTukalo
            31.12.2025 11:05

            Спасибо за ответ!

            У вас как данные хранятся, по столбцам или по колонкам?

            В mysql обычное строчное хранение.

            А кроме уменьшения объема какие есть еще преимущества столбчатого хранения?

            И если позволите нескромный вопрос- если не секрет- большая у вас команда? И что вообще за команда- вы есть в публичном поле? Хотел бы почитать про вас.

            Успехов в Новом году!


            1. root85 Автор
              31.12.2025 11:05

              А кроме уменьшения объема какие есть еще преимущества столбчатого хранения?

              Сильно быстрее запросы, т.к. читать нужно меньше с диска: (1) данные по колонкам хранятся, то есть чтобы SUM(amount*price) сделать, БД отдельно читает 2 колонки, а не все строчки в случае построчного хранения (2) колонки сжаты по умолчанию LZ4/ZSDT кодеком, что тоже уменьшает объем считываемых данных.

              По скорости тут кто-то бенчмарки проводил https://www.fiveonefour.com/blog/PostgreSQL-vs-ClickHouse

              И что вообще за команда- вы есть в публичном поле? 

              Да, работаю в одной из tier-1 команд в блокчейн данных и индексинге – если перейти в профиле в ТГ/Твиттер, можно инфу найти, тут не буду рекламировать )

              Вам тоже огромных успехов!


        1. root85 Автор
          31.12.2025 11:05

          Дополню, возможно, это не совсем понятно из текста. Мы не используем КХ для всего, только для вышеперечисленных задач аналитики и агрегации. Постгрес у нас тоже есть, но для транзакционных данных – юзеры, тарифные планы, статусы воркеров / задач и т.п.


        1. root85 Автор
          31.12.2025 11:05

          Добавил в начало статьи немного конкретики про сжатие.


  1. stas_dubich
    31.12.2025 11:05

    Clickhouse шикарен сам по себе, но тут важно помнить что он про архивные данные, логи и прочее

    Под операционку все же нужна постгря и условный redis

    Чем то одним покрыть все потребности в принципе невозможно, а вот грамотно скомбинировать все - это ключ к успеху


    1. root85 Автор
      31.12.2025 11:05

      Да, CH — это про аналитику. Ну мы его так используем. Там есть еще Log engine как раз для логов , но я его не использовал.


      1. seriych
        31.12.2025 11:05

        Log, как ни странно, не для логов - это скорее как временные маленькие таблицы. Для логов обычные *MergeTree, плюс можно пробовать всякие новые фичи типа полнотекстовых индексов.


        1. root85 Автор
          31.12.2025 11:05

          Спасибо за уточнение!


  1. SwVZFSf4fLt8NSZNfV87
    31.12.2025 11:05

    Бесплатный мониторинг от nginx amplify отключается 31 января 2026 , поэтому пришлось настраивать другое решение.

    Использую clickhouse для хранения веб логов nginx, отлично справляется. Поставил TTL 2 года для хранения записей.

    В nginx настроил расширенные логи с geoip, статистика по ASN, странам.

    Связка такая:

    nginx -> vector (vector.dev ) -> clickhouse -> grafana

    1. nginx сохраняет логи в файл

    2. vector читает файлы логов и отправляет в clickhouse.

    3. grafana читает из clickhouse и отображает dashboard с необходимыми параметрами.

    Огромный плюс в том, что можно построить любой dashboard в grafana, намного стало больше возможностей, чем просто с nginx amplify.

    Автоматически выявляю ботов и подозрительные ASN.
    Вижу замедления http запросов по каждому uri path и бэкенду.

    По размеру данных:

    100 млн строк расширенных логов nginx ≈ 6.2 ГБ хранилища clickhouse.

    Конкретно под логи и их аналитику очень советую.


    1. root85 Автор
      31.12.2025 11:05

      Пушка, спасибо за идею!


    1. Jedie
      31.12.2025 11:05

      Тоже так делаю, только nginx в vector передает логи через syslog:unix-socket чтобы совсем уж быстро.


    1. RaptorTV
      31.12.2025 11:05

      Ничё не понял, но тоже за компанию поставлю лайк и скажу, что идея бомба!