О насБлогКонтакты
Databases17 апреля 2026 г. 8 мин 3

ClickHouse vs PostgreSQL для аналитики: реальные бенчмарки и когда что использовать

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

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посмотрите наши услуги или напишите нам.

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

Event-Driven Architecture в Node.js: Outbox Pattern, Kafka и гарантии доставкиaunimeda
Backend Development

Event-Driven Architecture в Node.js: Outbox Pattern, Kafka и гарантии доставки

Практическое руководство по надёжной event-driven архитектуре в Node.js: Outbox Pattern с PostgreSQL, Kafka с идемпотентностью, Saga для распределённых транзакций — с полным рабочим кодом.

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчикаaunimeda
Разработка

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчика

OWASP Top 10 — это стандарт критических рисков безопасности. SQL-инъекции, сломанный контроль доступа, SSRF — каждый пункт с реальной атакой на ваш Node.js/Next.js код и конкретным исправлением. Актуально для проектов на казахстанском рынке.

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшнaunimeda
Разработка

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшн

Bun 1.x стабилен в production. Deno 2.0 поддерживает npm-пакеты. Node.js 22 запускает TypeScript нативно. Реальные бенчмарки производительности, сравнение инструментов и конкретные рекомендации для казахстанских разработчиков.

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

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

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