ClickHouse vs PostgreSQL для аналитики: реальные бенчмарки и когда что использовать
В прошлом году мы мигрировали аналитику одного e-commerce проекта с PostgreSQL на ClickHouse. До миграции самый тяжёлый отчёт — конверсионная воронка за 30 дней по 100M+ событий — выполнялся 47 секунд. После — 0.8 секунды. Это не опечатка: в 58 раз быстрее.
Но это не значит что нужно мигрировать всё на ClickHouse. Расскажу когда это реально нужно, как работает физически, и покажу реальные цифры.
Почему колоночное хранение выигрывает для аналитики
Представьте таблицу событий:
| user_id | event_type | amount | created_at | session_id | page | ... |
В строчной БД (PostgreSQL, MySQL) данные хранятся построчно на диске:
[user_1, 'purchase', 1500, '2026-01-01', 'abc123', '/checkout', ...]
[user_2, 'view', 0, '2026-01-01', 'def456', '/product', ...]
[user_1, 'add_cart', 0, '2026-01-01', 'abc123', '/product', ...]
Аналитический запрос: SELECT SUM(amount), COUNT(*) FROM events WHERE event_type = 'purchase'
PostgreSQL должен прочитать каждую строку целиком с диска, чтобы добраться до event_type и amount. Читает 50 байт на строку, использует 3 байта — остальное выбрасывает. KPI: I/O эффективность ~6%.
В колоночной БД (ClickHouse) данные хранятся по столбцам:
user_id колонка: [user_1, user_2, user_1, user_3, ...]
event_type колонка: ['purchase', 'view', 'add_cart', 'purchase', ...]
amount колонка: [1500, 0, 0, 2300, ...]
Тот же запрос читает только колонки event_type и amount — пропускает остальные 15 колонок физически. Плюс колоночные данные однотипны → лучше сжимаются (в 5-10x). I/O эффективность: ~80%+.
Это физическая причина. Не "магия", просто меньше данных читается с диска.
Стенд для бенчмарков
Мы тестировали на реальных данных, аналогичных тому что бывает у казахстанских fintech и e-commerce проектов:
- Таблица: события пользователей (просмотры, добавления в корзину, покупки, оплаты)
- Объём: 100 миллионов строк, ~25GB несжатых
- Железо: VPS 8 CPU / 32GB RAM / NVMe SSD (такие берут у отечественных провайдеров)
- PostgreSQL: 15.4, настроен
shared_buffers=8GB, effective_cache_size=24GB, work_mem=256MB - ClickHouse: 24.3, дефолтные настройки +
max_threads=8
Схема таблицы
-- PostgreSQL
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
session_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL, -- 'view', 'add_cart', 'purchase', 'payment'
amount DECIMAL(12, 2) DEFAULT 0,
currency CHAR(3) DEFAULT 'KZT',
page VARCHAR(500),
product_id UUID,
category VARCHAR(100),
region VARCHAR(100), -- 'Almaty', 'Astana', 'Shymkent', ...
device_type VARCHAR(20), -- 'mobile', 'desktop', 'tablet'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX events_user_id_idx ON events(user_id);
CREATE INDEX events_event_type_idx ON events(event_type);
CREATE INDEX events_created_at_idx ON events(created_at);
CREATE INDEX events_created_at_type_idx ON events(created_at, event_type);
-- ClickHouse
CREATE TABLE events (
id UInt64,
user_id UUID,
session_id UUID,
event_type LowCardinality(String), -- LowCardinality важен для enum-like полей
amount Decimal(12, 2),
currency LowCardinality(FixedString(3)),
page String,
product_id Nullable(UUID),
category LowCardinality(String),
region LowCardinality(String),
device_type LowCardinality(String),
created_at DateTime
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (event_type, user_id, created_at);
-- ORDER BY определяет физический порядок хранения — подбираем под запросы
LowCardinality(String) в ClickHouse — критично для полей с малым количеством уникальных значений. Хранит как числа с dictionary encoding, запросы в 2-5x быстрее, сжатие лучше.
Бенчмарки: реальные запросы
Запрос 1: Простой COUNT с GROUP BY
-- Количество событий по типам за последние 30 дней
SELECT event_type, COUNT(*) as cnt
FROM events
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY event_type
ORDER BY cnt DESC;
| PostgreSQL | ClickHouse | Ускорение | |
|---|---|---|---|
| Холодный кэш | 38.4 сек | 0.31 сек | 124x |
| Горячий кэш | 4.2 сек | 0.28 сек | 15x |
Даже с прогретым кэшем в Postgres — кратно медленнее. ClickHouse сжал все 100M строк в эту колонку до ~200MB и читает их с диска быстрее чем Postgres читает индекс.
Запрос 2: COUNT DISTINCT (тяжёлый для любой БД)
-- Уникальные пользователи по регионам за месяц
SELECT region, COUNT(DISTINCT user_id) as unique_users
FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'
GROUP BY region
ORDER BY unique_users DESC;
| PostgreSQL | ClickHouse | Ускорение | |
|---|---|---|---|
| Время выполнения | 71.2 сек | 1.8 сек | 40x |
ClickHouse использует HyperLogLog для COUNT DISTINCT по умолчанию в некоторых движках, но даже точный uniqExact() даёт 1.8 сек. Postgres строит hash table в памяти на 50M+ UUID — при work_mem=256MB уходит на диск.
Запрос 3: Воронка конверсии (window functions)
-- Воронка: view → add_cart → purchase → payment (за 7 дней)
WITH funnel_data AS (
SELECT
user_id,
MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) as has_view,
MAX(CASE WHEN event_type = 'add_cart' THEN 1 ELSE 0 END) as has_add_cart,
MAX(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) as has_purchase,
MAX(CASE WHEN event_type = 'payment' THEN 1 ELSE 0 END) as has_payment
FROM events
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY user_id
)
SELECT
SUM(has_view) as step1_view,
SUM(has_add_cart) as step2_add_cart,
SUM(has_purchase) as step3_purchase,
SUM(has_payment) as step4_payment,
ROUND(100.0 * SUM(has_add_cart) / NULLIF(SUM(has_view), 0), 2) as view_to_cart_pct,
ROUND(100.0 * SUM(has_purchase) / NULLIF(SUM(has_add_cart), 0), 2) as cart_to_purchase_pct
FROM funnel_data;
| PostgreSQL | ClickHouse | Ускорение | |
|---|---|---|---|
| Время выполнения | 47.1 сек | 0.82 сек | 57x |
Это и был тот запрос который мы показывали клиенту. 47 секунд → 0.8 секунды.
В ClickHouse есть нативная функция windowFunnel() которая делает это ещё быстрее:
-- ClickHouse нативная воронка
SELECT
level,
count() as users
FROM (
SELECT
user_id,
windowFunnel(86400)( -- окно 24 часа
created_at,
event_type = 'view',
event_type = 'add_cart',
event_type = 'purchase',
event_type = 'payment'
) as level
FROM events
WHERE created_at >= now() - toIntervalDay(7)
GROUP BY user_id
)
GROUP BY level
ORDER BY level;
0.4 секунды. Нативные функции ClickHouse для аналитики — отдельная тема.
Движки ClickHouse: не только MergeTree
SummingMergeTree — когда нужны агрегаты в реальном времени
-- Предагрегированные суммы продаж по дням/регионам
CREATE TABLE sales_daily (
date Date,
region LowCardinality(String),
category LowCardinality(String),
revenue Decimal(15, 2),
orders UInt32
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, region, category);
-- При вставке дубликатов по ORDER BY ключу — суммирует автоматически
INSERT INTO sales_daily VALUES ('2026-04-17', 'Almaty', 'Electronics', 150000, 5);
INSERT INTO sales_daily VALUES ('2026-04-17', 'Almaty', 'Electronics', 85000, 3);
-- После слияния получим: 235000, 8
-- Запрос к SummingMergeTree — нужно явно агрегировать (слияние асинхронное)
SELECT date, region, SUM(revenue), SUM(orders)
FROM sales_daily
GROUP BY date, region;
Используем для дашбордов где данные вставляются постоянно — дашборд читает предагрегированные суммы мгновенно.
AggregatingMergeTree + Materialized Views — incremental aggregation
-- Материализованное представление для агрегации событий
CREATE MATERIALIZED VIEW events_hourly_mv
TO events_hourly
AS SELECT
toStartOfHour(created_at) as hour,
region,
event_type,
count() as events_count,
countDistinctState(user_id) as unique_users_state,
sum(amount) as revenue
FROM events
GROUP BY hour, region, event_type;
CREATE TABLE events_hourly (
hour DateTime,
region LowCardinality(String),
event_type LowCardinality(String),
events_count UInt64,
unique_users_state AggregateFunction(uniq, UUID),
revenue Decimal(15, 2)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (hour, region, event_type);
-- Чтение с финализацией агрегата
SELECT
hour,
region,
SUM(events_count) as events,
uniqMerge(unique_users_state) as unique_users,
SUM(revenue) as revenue
FROM events_hourly
GROUP BY hour, region
ORDER BY hour DESC
LIMIT 24;
Каждая новая строка в events автоматически агрегируется в events_hourly_mv. Запрос к часовому агрегату вместо 100M строк работает на ~8760 строках (год * 24 часа).
Когда PostgreSQL + TimescaleDB достаточно
Не всегда нужен ClickHouse. PostgreSQL + TimescaleDB — разумный выбор если:
- Данных до 50-100M строк, и они растут медленно
- Нужны UPDATE/DELETE — ClickHouse для этого не предназначен
- Транзакции — нужна атомарность аналитических и operational записей
- JOIN с нормализованными данными — в ClickHouse JOIN'ы работают, но не так удобно
- Команда знает Postgres, нет ресурсов на новый стек
-- TimescaleDB: превращаем обычную таблицу в hypertable
SELECT create_hypertable('events', 'created_at', chunk_time_interval => INTERVAL '1 week');
-- Автоматическое сжатие старых чанков
SELECT add_compression_policy('events', INTERVAL '7 days');
-- Continuous aggregate — аналог материализованных представлений ClickHouse
CREATE MATERIALIZED VIEW events_daily
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 day', created_at) AS day,
region,
event_type,
COUNT(*) as events,
SUM(amount) as revenue
FROM events
GROUP BY day, region, event_type;
-- Политика обновления агрегата
SELECT add_continuous_aggregate_policy('events_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour'
);
С TimescaleDB наш запрос воронки занял 3.2 секунды вместо 47 — за счёт партиционирования и сжатия. Не 0.8 сек как в ClickHouse, но 3.2 сек вполне приемлемо для многих дашбордов.
Миграция данных из PostgreSQL в ClickHouse
Простая однократная миграция через COPY:
# Экспорт из PostgreSQL
psql -U postgres -d mydb -c "\COPY events TO '/tmp/events.csv' CSV HEADER"
# Импорт в ClickHouse
clickhouse-client --query "INSERT INTO events FORMAT CSVWithNames" < /tmp/events.csv
Для больших объёмов — параллельная миграция с разбивкой по времени:
#!/bin/bash
# Параллельная миграция по месяцам
for MONTH in 2025-01 2025-02 2025-03 2025-04 2025-05 2025-06 2025-07 2025-08 2025-09 2025-10 2025-11 2025-12; do
psql -U postgres -d mydb -c "\COPY (
SELECT * FROM events
WHERE created_at >= '${MONTH}-01'
AND created_at < '${MONTH}-01'::date + INTERVAL '1 month'
) TO '/tmp/events_${MONTH}.csv' CSV" &
done
wait
# Импорт параллельно
for FILE in /tmp/events_*.csv; do
clickhouse-client --query "INSERT INTO events FORMAT CSV" < "$FILE" &
done
wait
Для инкрементальной синхронизации в realtime — используем Debezium (CDC из PostgreSQL WAL) → Kafka → ClickHouse Kafka engine. Но это отдельная тема.
Практическое решение: что выбрать
| Критерий | PostgreSQL | PG + TimescaleDB | ClickHouse |
|---|---|---|---|
| Строк данных | до 10M | до 100M | 100M+ |
| Скорость аналитики | медленно | нормально | быстро |
| UPDATE/DELETE | да | да | нет* |
| Транзакции | да | да | нет |
| Сложность эксплуатации | низкая | средняя | высокая |
| JOIN сложность | простой | простой | сложнее |
*ClickHouse поддерживает ALTER TABLE UPDATE/DELETE но это дорогая операция, не для транзакционного использования.
Для большинства казахстанских стартапов — начинайте с PostgreSQL, добавьте TimescaleDB когда запросы начнут тормозить. Мигрируйте на ClickHouse когда данные перевалят за 50-100M и дашборды нужно строить быстрее 3-5 секунд.
Нужна помощь в выборе стека для аналитики вашего проекта или миграция с PostgreSQL на ClickHouse? Aunimeda — посмотрите наши услуги или напишите нам.