О насБлогКонтакты
Базы данных17 апреля 2026 г. 7 минОбновлено: 1 июля 2026 г.

ClickHouse в production: колоночная база данных для реальной аналитики

AunimedaAunimeda
📋 Содержание

Три года назад мы пытались построить аналитическую систему на PostgreSQL. Запрос воронки конверсии по 80 миллионам событий выполнялся 40 секунд. Пользователи жаловались. Мы добавили индексы - стало 30 секунд. Добавили партиции по дате - 20 секунд. Добавили кеш - первый пользователь ждёт 20 секунд, остальные получают устаревшие данные. В итоге мы переехали на ClickHouse, и тот же запрос стал выполняться за 0.3 секунды. Вот что мы узнали за эти три года.

Почему колоночное хранение выигрывает

Когда PostgreSQL читает строку, он читает всю строку целиком - все столбцы. Для OLTP это правильно: SELECT * FROM users WHERE id = 42 нужны все данные о пользователе. Но для аналитики картина другая:

-- Типичный аналитический запрос
SELECT 
    date_trunc('day', event_time) as day,
    COUNT(*) as events,
    COUNT(DISTINCT user_id) as users,
    SUM(revenue) as total_revenue
FROM events
WHERE event_time >= '2026-01-01'
    AND event_type = 'purchase'
GROUP BY day
ORDER BY day;

Этот запрос читает только 4 столбца из таблицы, у которой может быть 30+ столбцов. PostgreSQL всё равно прочитает все 30 - потому что строки хранятся целиком. ClickHouse прочитает только нужные 4.

Реальные цифры с нашего production-кластера:

Метрика PostgreSQL ClickHouse
Запрос воронки, 100M строк ~25 сек ~280 мс
Дисковое чтение 14 GB 0.8 GB
Сжатие данных 1x (без сжатия) ~8x
RAM под запрос 4 GB 200 MB

Разница в дисковом чтении - вот где вся магия. ClickHouse читает в 17 раз меньше данных. Плюс колонки хорошо сжимаются: числа похожего диапазона, строки с повторяющимися значениями. Алгоритм LZ4 по умолчанию, ZSTD для лучшего сжатия за счёт скорости.

Семейство движков MergeTree

Выбор движка таблицы в ClickHouse - это архитектурное решение. Ошибиться здесь дороже, чем в PostgreSQL.

MergeTree - основа всего

CREATE TABLE events
(
    event_id     UUID,
    user_id      UInt64,
    session_id   String,
    event_type   LowCardinality(String),
    event_time   DateTime,
    properties   String,  -- JSON
    revenue      Decimal(18, 2),
    country      LowCardinality(String),
    platform     LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, user_id, event_time)
SETTINGS index_granularity = 8192;

LowCardinality - это оптимизация для столбцов с небольшим числом уникальных значений (меньше ~10,000). ClickHouse будет хранить словарь и индексы, что ускоряет фильтрацию и GROUP BY примерно в 2-3 раза для таких полей.

ORDER BY vs PRIMARY KEY - критически важное различие

Это место, где мы сделали первую серьёзную ошибку. Многие думают, что ORDER BY в MergeTree - это то же самое, что PRIMARY KEY. Нет.

-- Можно написать так:
ENGINE = MergeTree()
ORDER BY (user_id, event_time)

-- А можно явно задать PRIMARY KEY отличным от ORDER BY:
ENGINE = MergeTree()
PRIMARY KEY (user_id)
ORDER BY (user_id, event_time)

ORDER BY определяет физический порядок данных на диске и полный ключ сортировки. PRIMARY KEY определяет, какая часть ключа попадает в разреженный индекс. По умолчанию PRIMARY KEY = ORDER BY.

Зачем разделять? Если у вас запросы вида WHERE user_id = 123, но вы хотите хранить данные отсортированными по (user_id, event_time) для эффективного сканирования диапазонов - PRIMARY KEY (user_id) позволит ClickHouse быстро найти нужный user_id по индексу, а потом отсканировать его события в порядке времени.

Правило: PRIMARY KEY должен быть префиксом ORDER BY. ClickHouse не выдаст ошибку, если это не так - просто проигнорирует настройку.

ReplicatedMergeTree - production без него не бывает

CREATE TABLE events ON CLUSTER my_cluster
(
    -- те же поля
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, user_id, event_time);

{shard} и {replica} - макросы из конфига. ReplicatedMergeTree использует ZooKeeper (или ClickHouse Keeper) для координации. Репликация асинхронная - данные сначала пишутся на одну реплику, потом копируются на другие. Это важно: если вы сразу читаете с другой реплики, данных там может не быть.

SummingMergeTree - предагрегация на уровне движка

CREATE TABLE daily_stats
(
    date        Date,
    user_id     UInt64,
    page_views  UInt64,
    clicks      UInt64,
    revenue     Decimal(18, 2)
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, user_id);

-- Пишем данные построчно
INSERT INTO daily_stats VALUES ('2026-04-17', 42, 10, 3, 99.50);
INSERT INTO daily_stats VALUES ('2026-04-17', 42, 5, 1, 49.75);

-- Со временем (при мерже) ClickHouse автоматически схлопнет их в:
-- ('2026-04-17', 42, 15, 4, 149.25)

Важный нюанс: мержи происходят в фоне, и нет гарантии когда. Поэтому в запросах всегда используйте SUM():

SELECT date, user_id, SUM(page_views), SUM(revenue)
FROM daily_stats
GROUP BY date, user_id;

AggregatingMergeTree - для сложных агрегатов

CREATE TABLE user_funnel_state
(
    date       Date,
    user_id    UInt64,
    step1_cnt  AggregateFunction(count, UInt8),
    step2_cnt  AggregateFunction(count, UInt8),
    revenue    AggregateFunction(sum, Decimal(18, 2))
)
ENGINE = AggregatingMergeTree()
ORDER BY (date, user_id);

Используется вместе с материализованными представлениями для инкрементальной агрегации.

Материализованные представления - инкрементальная агрегация

Это одна из самых мощных и при этом наименее понятых возможностей ClickHouse.

-- Исходная таблица событий
CREATE TABLE events_raw
(
    event_id   UUID DEFAULT generateUUIDv4(),
    user_id    UInt64,
    event_type LowCardinality(String),
    event_time DateTime,
    revenue    Decimal(18, 2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);

-- Таблица-назначение для агрегатов
CREATE TABLE events_hourly_agg
(
    hour       DateTime,
    event_type LowCardinality(String),
    user_count AggregateFunction(uniq, UInt64),
    event_cnt  AggregateFunction(count, UInt8),
    revenue    AggregateFunction(sum, Decimal(18, 2))
)
ENGINE = AggregatingMergeTree()
ORDER BY (hour, event_type);

-- Материализованное представление - триггер на инсерты
CREATE MATERIALIZED VIEW events_hourly_mv
TO events_hourly_agg
AS SELECT
    toStartOfHour(event_time) as hour,
    event_type,
    uniqState(user_id)  as user_count,
    countState()        as event_cnt,
    sumState(revenue)   as revenue
FROM events_raw
GROUP BY hour, event_type;

-- Запрос к агрегированной таблице
SELECT
    hour,
    event_type,
    uniqMerge(user_count)  as unique_users,
    countMerge(event_cnt)  as total_events,
    sumMerge(revenue)      as total_revenue
FROM events_hourly_agg
GROUP BY hour, event_type
ORDER BY hour DESC;

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

Запрос воронки конверсии

Это то, из-за чего многие и переходят на ClickHouse:

-- Воронка: регистрация → просмотр товара → добавление в корзину → покупка
WITH funnel AS (
    SELECT
        user_id,
        windowFunnel(86400)(  -- окно 24 часа
            event_time,
            event_type = 'registration',
            event_type = 'view_product',
            event_type = 'add_to_cart',
            event_type = 'purchase'
        ) AS step
    FROM events
    WHERE event_time >= '2026-04-01'
        AND event_time < '2026-04-17'
    GROUP BY user_id
)
SELECT
    step,
    COUNT(*) AS users,
    ROUND(100.0 * COUNT(*) / MAX(COUNT(*)) OVER (), 2) AS pct
FROM funnel
WHERE step > 0
GROUP BY step
ORDER BY step;

На 100M строк этот запрос у нас выполняется за 0.8 секунды. В PostgreSQL аналогичный запрос через оконные функции - около 45 секунд.

Типичные ошибки при переносе из PostgreSQL

1. Нет UPSERT - только INSERT

В PostgreSQL вы привыкли к INSERT ... ON CONFLICT DO UPDATE. В ClickHouse нет первичных ключей в привычном смысле. Есть несколько подходов:

-- Вариант 1: ReplacingMergeTree
CREATE TABLE users_state
(
    user_id    UInt64,
    email      String,
    updated_at DateTime,
    is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;

-- Пишем обновление как новую строку
INSERT INTO users_state VALUES (42, 'new@email.com', now(), 0);

-- При запросе всегда используем FINAL или дедупликацию
SELECT * FROM users_state FINAL WHERE user_id = 42;
-- или
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC) rn
    FROM users_state
) WHERE rn = 1;

2. JOIN-ы работают иначе

В ClickHouse правая таблица JOIN полностью загружается в память. Порядок таблиц в JOIN важен - большая таблица должна быть слева:

-- Правильно: большая таблица слева
SELECT e.user_id, u.email, COUNT(*)
FROM events e  -- 100M строк
JOIN users u ON e.user_id = u.user_id  -- 1M строк
GROUP BY e.user_id, u.email;

-- Неправильно: OOM или очень медленно
SELECT e.user_id, u.email, COUNT(*)
FROM users u  -- 1M строк
JOIN events e ON u.user_id = e.user_id  -- 100M строк загрузится в память
GROUP BY e.user_id, u.email;

3. DELETE и UPDATE - дорогая операция

В ClickHouse мутации (ALTER TABLE ... DELETE/UPDATE) - тяжёлые фоновые операции, переписывающие куски данных. Не используйте их в горячем пути:

-- Это НЕ мгновенная операция, а фоновая задача:
ALTER TABLE events DELETE WHERE user_id = 42;

-- Проверить статус мутации:
SELECT * FROM system.mutations WHERE is_done = 0;

4. Не используйте nullable без необходимости

Nullable(String) хранит дополнительный битмап для null-флагов и работает медленнее. Если 99% значений не null - используйте пустую строку или 0 как sentinel-значение.

Вместо заключения

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

Мы в итоге пришли к связке: PostgreSQL для транзакционных данных + ClickHouse для аналитики + Kafka для потоковой загрузки событий. Каждый инструмент делает своё.


Если вам нужна помощь с проектированием аналитической архитектуры или миграцией с PostgreSQL - Aunimeda занимается именно такими задачами. Напишите нам.

Читайте также

PostgreSQL блокировки и дедлоки: диагностика и устранение в productionaunimeda
Базы данных

PostgreSQL блокировки и дедлоки: диагностика и устранение в production

Глубокий разбор системы блокировок PostgreSQL: как читать pg_locks, почему ALTER TABLE страшен в production, как воспроизвести и исправить дедлоки, и паттерн SELECT FOR UPDATE SKIP LOCKED для job queues.

Kafka в production: паттерны и ошибки, о которых не пишут в документацииaunimeda
DevOps и инфраструктура

Kafka в production: паттерны и ошибки, о которых не пишут в документации

Практическое руководство по Kafka для тех, кто уже читал введение. Партиционирование, consumer rebalancing, идемпотентность, мониторинг consumer lag и паттерн dead letter queue на реальных примерах.

Kitty Girlfriend: инди-кейс мобильной игры от студии из Бишкекаaunimeda
Разработка игр

Kitty Girlfriend: инди-кейс мобильной игры от студии из Бишкека

Kitty Girlfriend — опубликованная в Google Play казуальная мобильная игра студии Aunimeda. Разбираем инди-путь: Flutter (Flame), монетизация через рекламу, ASO и что нужно знать перед началом разработки собственной игры.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в России. Бесплатная консультация.

Получить консультацию Все статьи