ClickHouse, TimescaleDB, DuckDB: что выбрать под аналитику

Карта выбора аналитической БД по нишам: масштабная аналитика на ClickHouse, аналитика и time-series внутри экосистемы PostgreSQL на TimescaleDB, встроенная локальная аналитика на DuckDB — с учётом стоимости и эксплуатации

«Взять ClickHouse» — не всегда правильный ответ, даже если задача аналитическая. Иногда достаточно расширения к PostgreSQL, который у вас уже есть. Иногда вообще не нужен сервер — данные лежат в Parquet, и их прекрасно считает библиотека внутри вашего же процесса. Три инструмента — ClickHouse, TimescaleDB, DuckDB — закрывают три разные ниши аналитики, и выбор между ними определяется не «кто быстрее в бенчмарке», а тем, где и как это будет жить и сколько стоить в эксплуатации.

Это восьмая, заключительная статья серии «ClickHouse и аналитические БД» (первая — «Когда нужен OLAP»). Семь предыдущих статей разбирали ClickHouse изнутри: колоночное хранение и векторное исполнение против PostgreSQL (#1), MergeTree и вставки из Go и Java (#2), бенчмарк драйверов ch-go/clickhouse-go/JDBC/HTTP (#3), материализованные представления и пайплайн Kafka→CH (#4), распределённый кластер с шардированием, репликацией и Keeper (#5), эксплуатацию и тюнинг (#6), S3-тиринг и работу с Parquet напрямую из объектного хранилища (#7). Здесь ClickHouse впервые за всю серию ставится не в пару к голому PostgreSQL, а рядом сразу с двумя другими системами — TimescaleDB и DuckDB — на одном и том же сценарии.

Сценарий — тот же аналитический агрегат, что и в первой статье серии: GROUP BY country, toDate(event_time) с count() и sum(revenue), — прогнан одинаково на четырёх системах: ClickHouse (MergeTree), TimescaleDB (гипертаблица + continuous aggregate + native compression), DuckDB (embedded, без контейнера, через go-duckdb/v2) и PostgreSQL (обычная таблица без индексов, тот же baseline, что и в статье #1). Объём здесь — 10 000 000 строк, не 20 миллионов, как в статье #1: усечённое подмножество общего датасета серии, взятое ради того, чтобы прогнать все четыре системы синхронно, в одну сессию, на одном и том же CSV. Направление результатов на этом профиле данных, скорее всего, сохранится и на полном объёме — но отдельного прогона всех четырёх систем на 20 миллионах мы не делали, так что это обоснованное ожидание, а не доказанный факт; абсолютные цифры этой статьи не равны цифрам статьи #1 буквально, сравнивать их напрямую не стоит. Все числа и код — из живого стенда clickhouse/decision в публичном репозитории digital-cookbook: ClickHouse 26.6.1.1193, PostgreSQL 16.14, TimescaleDB 2.28.2-pg16, DuckDB v1.4.1.

Карта выбора: ClickHouse, PostgreSQL, TimescaleDB, DuckDB — один запрос, один ответ, разный профиль

В статье

Три ниши и точка отсчёта — PostgreSQL

Вопрос «какая аналитическая БД лучше» задан неправильно с самого начала — правильный вопрос «в какой нише живёт задача». Три ниши, которые закрывают ClickHouse, TimescaleDB и DuckDB, пересекаются слабо:

  • Масштабная аналитика на выделенном движке. Десятки миллионов строк и больше, высокий throughput запросов, дашборды и бизнес-отчётность, где агрегат должен отвечать за миллисекунды-сотни миллисекунд, а не секунды. Профиль ClickHouse.
  • Аналитика/time-series рядом с транзакционными данными. Умеренный объём, PostgreSQL уже в проде, заводить второе хранилище и синхронизацию между ними — накладнее, чем расширить то, что уже есть. Профиль TimescaleDB.
  • Локальная, встраиваемая аналитика. Разовый расчёт, ETL-шаг, тест, ноутбук, аналитика внутри приложения — там, где поднимать сервер вообще избыточно. Профиль DuckDB.

У всех трёх есть общая точка отсчёта — обычный PostgreSQL без какой-либо аналитической специализации. Статья #1 серии уже показала, где именно он перестаёт тянуть: GROUP BY по всей таблице требует полного скана, индексы этому не помогают (медиана с индексами на event_time/country и без них отличались в пределах шума), и разница с ClickHouse на 20 000 000 строк доходила до 42,7× по времени агрегата и 5,6× по месту на диске. Это тот порог, ниже которого «просто PostgreSQL» — вполне рабочий выбор, а разговор о специализированной аналитической БД не имеет смысла вообще: сложность любой из трёх систем ниже нужно оправдать реальной болью, а не гипотетическим ростом.

ClickHouse: масштабная аналитика

Семь статей серии подробно разобрали, из чего складывается скорость ClickHouse на агрегатах: колоночное хранение читает только нужные колонки, а не строку целиком (#1); векторное исполнение обрабатывает данные блоками, а не строка за строкой (#1); разреженный первичный индекс MergeTree пропускает целые гранулы данных, не читая их вовсе (#2); горизонтальное масштабирование через шардирование и репликацию с координацией через ClickHouse Keeper снимает потолок одной ноды (#5). Схема таблицы в этом стенде — тот же шаблон MergeTree, что и в статье #1, только без суррогатного id — здесь речь исключительно об аналитическом агрегате, не о точечных операциях:

CREATE TABLE demo.decision_events
(
    event_time  DateTime,
    user_id     UInt64,
    event_type  LowCardinality(String),
    url         String,
    duration_ms UInt32,
    country     LowCardinality(String),
    revenue     Decimal(10,2)
)
ENGINE = MergeTree
ORDER BY (event_time, user_id)
PARTITION BY toYYYYMM(event_time)
SETTINGS index_granularity = 8192

Цена этой скорости — тоже разобрана честно, а не спрятана: выделенный кластер требует своей инфраструктуры и мониторинга (#5, #6), вставлять нужно батчами — построчная вставка была медленнее батчевой в 9710× на одинаковом объёме (#2), а точечные UPDATE/DELETE — главный анти-паттерн: мутация переписывает part целиком, и это дороже эквивалентной операции PostgreSQL в 350–1870× (#1). ClickHouse не претендует на роль универсальной БД — он выигрывает там, где вопрос стоит именно так: много данных, много агрегатных запросов, редкие батчевые вставки.

TimescaleDB: аналитика внутри PostgreSQL

TimescaleDB — не отдельная СУБД, а расширение PostgreSQL: CREATE EXTENSION timescaledb, и обычная таблица становится гипертаблицей через create_hypertable() — TimescaleDB сам разбивает её на чанки по времени (в этом стенде — дефолтный chunk_time_interval в 7 дней) и маршрутизирует запросы к нужным чанкам прозрачно для клиента, оставаясь при этом полноценным PostgreSQL со всем, что это означает: SQL целиком, ACID-транзакции, тот же драйвер, та же экосистема backup/HA-инструментов.

-- одинаковая схема для PostgreSQL-baseline и TimescaleDB ДО хайпертаблицы
CREATE TABLE decision_events (
    event_time  TIMESTAMP NOT NULL,
    user_id     BIGINT NOT NULL,
    event_type  TEXT NOT NULL,
    url         TEXT NOT NULL,
    duration_ms INT NOT NULL,
    country     TEXT NOT NULL,
    revenue     NUMERIC(10,2) NOT NULL
);

-- только для TimescaleDB: превращает таблицу в гипертаблицу,
-- авто-партиционирование по event_time
SELECT create_hypertable('decision_events', 'event_time');

Поверх гипертаблицы стенд проверяет обзорно два механизма, ради которых TimescaleDB и выбирают под аналитику — не углубляясь в специализированный time-series (метрики, ретеншн-политики, downsampling во времени: это тема отдельной будущей статьи «Time-series БД»Скоро, где TimescaleDB встанет рядом с VictoriaMetrics).

Continuous aggregate — материализованное представление, инкрементально досчитывающее агрегат по мере поступления новых данных, а не пересчитывающее всё с нуля при каждом запросе:

CREATE MATERIALIZED VIEW decision_events_daily
WITH (timescaledb.continuous) AS
SELECT
    country,
    time_bucket('1 day', event_time) AS day,
    count(*) AS cnt,
    sum(revenue) AS rev
FROM decision_events
GROUP BY country, day
WITH NO DATA;

-- в проде материализацию делает фоновая политика
-- add_continuous_aggregate_policy(...) по расписанию; здесь — один
-- ручной вызов, чтобы не зависеть от таймера scheduler'а в рамках стенда
CALL refresh_continuous_aggregate('decision_events_daily', NULL, NULL);

На стенде refresh_continuous_aggregate материализовал весь диапазон за 4,52 с, получив 1800 строк (country × сутки). Запрос из уже готового предагрегата занял медианных 3,544 мс против 2,715 с у того же агрегата, пересчитанного по сырой гипертаблице, — предагрегат быстрее сырого запроса в 765,98×, а результат обоих запросов совпал побайтово. Важная оговорка про честность: ускорение здесь — тот же принцип, что -State/-Merge в AggregatingMergeTree из статьи #4: предагрегат не «умнее» считает, а просто уже посчитан заранее, и вызов в стенде — однократный ручной CALL, а не работающая фоновая политика обновления, которую в проде включают через add_continuous_aggregate_policy().

Native compression сжимает уже накопленные чанки в колоночный формат внутри самого PostgreSQL:

ALTER TABLE decision_events SET (
    timescaledb.compress,
    timescaledb.compress_orderby = 'event_time',
    timescaledb.compress_segmentby = 'country'
);

-- compress_segmentby по country — тот же принцип, что LowCardinality
-- в ClickHouse: строки одного значения группируются вместе внутри чанка
SELECT compress_chunk(c, if_not_compressed => true) FROM show_chunks('decision_events') c;

До сжатия гипертаблица занимала 998,84 МиБ на 13 чанках; compress_chunk обработал все 13 за 14,83 с, и после сжатия размер упал до 156,61 МиБ — сжатие в 6,38×. Результат агрегата после компрессии не изменился побайтово, а вот скорость сырого запроса — практически нет: 2,575 с после против 2,715 с до, разница в пределах шума. Это честная деталь про природу компрессии: она уменьшает объём хранения и I/O, а не переупаковывает данные так, чтобы ускорить сам скан на порядки, — это не замена предагрегату, а отдельный, ортогональный эффект. Любопытная деталь на закуску: после сжатия TimescaleDB (156,61 МиБ) оказывается даже компактнее сырого несжатого ClickHouse на этом же объёме (185,73 МиБ, раздел ниже) — притом что раскладка «сырого» ClickHouse и так компактнее «сырого» PostgreSQL в 4,10×. Это не отменяет разрыва в скорости запроса: сжатая TimescaleDB всё ещё пересчитывает агрегат по сырым чанкам за секунды, а не за десятки миллисекунд, — колоночное сжатие на диске не заменяет колоночное исполнение запроса.

DuckDB: встроенная аналитика

DuckDB часто описывают как «SQLite для OLAP» — и это по существу верное сравнение: тот же принцип embedded-библиотеки без отдельного серверного процесса, но внутри — колоночный векторный движок, тот же класс исполнения, что и у ClickHouse, только упакованный в одну библиотеку, подключаемую прямо к процессу приложения. В стенде это буквально так: DuckDB работает внутри самого Go-бинарика через github.com/marcboeker/go-duckdb/v2 (CGO) — никакого контейнера, никакого сетевого протокола, ни единого порта, который нужно поднимать или мониторить.

Ключевая сила DuckDB — нативное чтение колоночных форматов прямо с диска, без предварительной загрузки в резидентное хранилище. Стенд конвертирует общий CSV-датасет в Parquet одним SQL-выражением и дальше работает исключительно с этим файлом:

COPY (
    SELECT event_time, user_id, event_type, url, duration_ms, country, revenue
    FROM read_csv('events-decision.csv', header = true, columns = {
        'event_time': 'TIMESTAMP', 'user_id': 'UBIGINT', 'event_type': 'VARCHAR',
        'url': 'VARCHAR', 'duration_ms': 'UINTEGER', 'country': 'VARCHAR',
        'revenue': 'DECIMAL(10,2)'
    })
) TO 'events-decision.parquet' (FORMAT PARQUET);

-- тот же агрегат, что на остальных трёх системах, — но напрямую
-- по файлу, без загрузки в какую-либо резидентную таблицу
SELECT country, CAST(event_time AS DATE) AS d, count(*) AS cnt,
       CAST(ROUND(SUM(revenue) * 100) AS BIGINT) AS cents
FROM read_parquet('events-decision.parquet')
GROUP BY country, d
ORDER BY country, d;

Честная деталь про измерения: конвертация CSV→Parquet заняла 4,34 с на 10 000 000 строк (2 305 491 строк/с) — но это не «загрузка» в смысле остальных трёх систем, а перекодирование файла в колоночный формат, напрямую с загрузкой в резидентное хранилище это число не сравнивается. Получившийся Parquet-файл занял 189,91 МиБ — почти вровень с ClickHouse (185,73 МиБ, 1,02×) и заметно компактнее и PostgreSQL, и несжатой TimescaleDB. Агрегат прямо по этому файлу через read_parquet занял медианных 279,90 мс — медленнее ClickHouse в 2,92×, но на порядок быстрее и PostgreSQL, и сырой TimescaleDB, при нулевой инфраструктуре вокруг.

Обратная сторона той же монеты — DuckDB не служба: один процесс, одна нода, без конкурентного доступа многих клиентов к общему инстансу, без репликации, без сетевого протокола для внешних потребителей. Ниша — ETL-шаг, ad-hoc-анализ, тесты, аналитика внутри самого приложения или на edge, где поднимать что-либо серверное избыточно по определению.

Один агрегат на четырёх системах

Загрузка одного и того же CSV в четыре системы (bulk, «характерный прогон», host-зависимо): ClickHouse — 20,01 с (499 637 строк/с), PostgreSQL — 32,93 с (303 673 строк/с), TimescaleDB — 41,17 с (242 894 строк/с), DuckDB — конвертация CSV→Parquet за 4,34 с (2 305 491 строк/с, отдельная по природе операция). ClickHouse загрузил тот же объём в 1,6× быстрее PostgreSQL тем же батчевым способом.

Размер на диске (детерминировано, до компрессии TimescaleDB) и медианное время того самого аналитического агрегата по пяти прогонам — с побайтовой сверкой результата через контрольную сумму (toInt64(round(sum(revenue) * 100)) по всем группам, CRC32 канонической строки):

10 млн строк, один и тот же агрегат на 4 системах: размер на диске и время запросаРазмер на диске (МиБ)185,73ClickHouse761,36PostgreSQL998,84TimescaleDB189,91DuckDBCH компактнее PG в 4,10×, Timescale — в 5,38×Аналитический агрегат (лог. шкала, мс)10 мс100 мс1000 мс10000 мс95,85 мсClickHouse959,6 мсPostgreSQL2687 мсTimescaleDB279,9 мсDuckDBCH быстрее PG в 10,01×, Timescale — в 28,03×, DuckDB — в 2,92×Все 4 системы: одинаковая контрольная сумма 0a158211 (1800 групп, totalCents=13 553 742 698)

Главный вывод бенчмарка — не «ClickHouse быстрее», это ожидаемо для специализированного OLAP-движка, а то, что все четыре системы посчитали один и тот же агрегат с одинаковым результатом: 1800 групп (country × сутки), сумма выручки в центах — 13 553 742 698 на всех четырёх, контрольная сумма CRC32 канонической строки — 0a158211 — совпала побайтово между ClickHouse, PostgreSQL, TimescaleDB и DuckDB. Разница между ними — не в корректности, а исключительно в цене: во времени исполнения запроса, в объёме на диске и в том, что нужно эксплуатировать, чтобы этот запрос вообще стало можно выполнить.

Стоимость и эксплуатация

Число миллисекунд на графике выше — не единственная, а зачастую не главная переменная в решении. Решающая — что именно придётся содержать вокруг системы:

Система Модель развёртывания Что эксплуатируешь Порог входа
ClickHouse Выделенный кластер: шарды, реплики, координатор Keeper Мониторинг parts/merges, апгрейды, батчевые вставки, отдельная инфраструктура (#5, #6) Высокий, окупается на масштабе
TimescaleDB Расширение к уже работающему PostgreSQL Та же эксплуатация PostgreSQL — HA и репликацияготовится, с 29 сентября, пулинг соединенийготовится, с 30 сентября, вакуум — плюс периодика continuous aggregate/compression policies Почти нулевой инкремент, если PostgreSQL уже в проде
DuckDB Библиотека внутри процесса Ничего — нет сервера, нет отдельного процесса для мониторинга или бэкапа Нулевой, но и не для конкурентной серверной нагрузки
PostgreSQL (baseline) То, что уже есть Обычная эксплуатация PostgreSQL Нулевой инкремент, но упирается в полный скан на объёме

ClickHouse окупает свою сложность там, где объём и throughput запросов делают альтернативу физически нежизнеспособной — не «медленной», а буквально невозможной в приемлемое время. TimescaleDB — почти бесплатное расширение возможностей команды, которая и так администрирует PostgreSQL: тот же движок бэкапов, тот же мониторинг, тот же драйвер в приложении, просто гипертаблица под капотом партиционирует данные по времени и умеет предагрегаты. DuckDB переворачивает вопрос стоимости эксплуатации целиком: спрашивать «сколько стоит содержать DuckDB в проде» бессмысленно — это библиотека, а не сервис, и она либо есть в вашем процессе, либо её там нет.

Граница: Trino и lakehouse

Ни одна из четырёх систем этой статьи не решает ещё один класс задач — федеративный запрос по многим разнородным источникам одним SQL: часть данных в S3 как Parquet, часть в PostgreSQL, часть в Kafka, и всё это нужно объединить в одном отчёте, не копируя данные в единое хранилище заранее. Статья #7 про S3 уже проводила эту границу для табличной функции s3(): чтение чужого Parquet напрямую из объектного хранилища ещё не делает систему федеративным движком — s3() и ENGINE = S3 работают с одним источником, заданным одним URL, а не объединяют разнородные хранилища как единую базу.

Для этого класса задач существует отдельная категория инструментов — федеративные движки и lakehouse-платформы (Trino, Presto и близкие), специализирующиеся именно на запросе по многим разнородным источникам сразу, обычно поверх метаданных open table format — Iceberg, Delta Lake, Hudi, — которые добавляют транзакционность, эволюцию схемы и time-travel поверх обычных файлов в объектном хранилище. Это разобрано отдельно, вне серии про ClickHouse, в статье «Open table formats: Iceberg, Delta и Hudi»Скоро. DuckDB здесь стоит ближе всех к этой границе — он тоже умеет читать Parquet напрямую, — но остаётся однопроцессным embedded-движком, а не распределённым query-слоем поверх кластера разнородных источников: спутать эти два уровня — источник завышенных ожиданий и от DuckDB, и от s3() в ClickHouse.

Карта выбора

flowchart TD Q1{"Аналитика нужна как отдельный контур,
вне обычных OLTP-запросов?"} Q1 -->|"Нет — эпизодически, немного данных"| PG["PostgreSQL
обычные запросы, без специализации"] Q1 -->|"Да"| Q2{"Где должно жить решение?"} Q2 -->|"Внутри уже работающего PostgreSQL,
умеренный объём, аналитика/time-series рядом с OLTP"| TS["TimescaleDB
гипертаблицы + continuous aggregates"] Q2 -->|"Без сервера: локально, embedded,
файлы/Parquet, ETL, ad-hoc"| DUCK["DuckDB
embedded, read_parquet"] Q2 -->|"Большой объём, высокий throughput запросов,
выделенный аналитический контур"| CH["ClickHouse
MergeTree, кластер"] Q2 -->|"Много разнородных источников,
один SQL поверх них"| TRINO["Trino / lakehouse
другая категория — вне серии"] style CH fill:#f4d9c6,stroke:#c67a4a style TS fill:#e8e2d5,stroke:#6d8a99 style DUCK fill:#c9e4c5,stroke:#5b8a5e style PG fill:#f9f3e3,stroke:#8b7355 style TRINO fill:#ded9cb,stroke:#3a3631,stroke-dasharray:4 3

flowchart TD
  Q1{"Аналитика нужна как отдельный контур,
вне обычных OLTP-запросов?"} Q1 -->|"Нет — эпизодически, немного данных"| PG["PostgreSQL
обычные запросы, без специализации"] Q1 -->|"Да"| Q2{"Где должно жить решение?"} Q2 -->|"Внутри уже работающего PostgreSQL,
умеренный объём, аналитика/time-series рядом с OLTP"| TS["TimescaleDB
гипертаблицы + continuous aggregates"] Q2 -->|"Без сервера: локально, embedded,
файлы/Parquet, ETL, ad-hoc"| DUCK["DuckDB
embedded, read_parquet"] Q2 -->|"Большой объём, высокий throughput запросов,
выделенный аналитический контур"| CH["ClickHouse
MergeTree, кластер"] Q2 -->|"Много разнородных источников,
один SQL поверх них"| TRINO["Trino / lakehouse
другая категория — вне серии"] style CH fill:#f4d9c6,stroke:#c67a4a style TS fill:#e8e2d5,stroke:#6d8a99 style DUCK fill:#c9e4c5,stroke:#5b8a5e style PG fill:#f9f3e3,stroke:#8b7355 style TRINO fill:#ded9cb,stroke:#3a3631,stroke-dasharray:4 3
Карта выбора: от класса задачи к конкретной системе
Класс задачи Профиль Система
Масштабная бизнес-аналитика, логи и события в больших объёмах, дашборды Десятки миллионов+ строк, высокий throughput запросов, отдельный контур ClickHouse
Аналитика/time-series рядом с OLTP, не хочется второго хранилища Умеренный объём, PostgreSQL уже в проде, нужны SQL и транзакции рядом TimescaleDB
ETL, ad-hoc расчёты, тесты, аналитика в приложении или на edge Файлы (Parquet/CSV), разовые или batch-запросы, без конкурентной серверной нагрузки DuckDB
Немного данных, эпизодические агрегаты То, что уже умеет обычный PostgreSQL с индексами PostgreSQL, без специализации
Запрос по многим разнородным источникам одним SQL Данные размазаны по S3/озеру/разным СУБД, метаданные Iceberg/Delta/Hudi Trino/lakehouse — другая категория, вне серии

Пограничные случаи различаются по трём вопросам, а не по бренду: сколько данных и с каким throughput запросов (десятки миллионов и много запросов — к ClickHouse), где физически должны жить данные (внутри уже работающего PostgreSQL — к TimescaleDB, в файле на диске без сервера — к DuckDB), и кто это будет эксплуатировать (отдельная команда под выделенный кластер — ClickHouse оправдан, та же команда, что уже держит PostgreSQL — TimescaleDB почти бесплатна, никто, потому что сервиса вообще нет — DuckDB).

Итоги серии

Путь серии: колоночное хранение и векторное исполнение как ответ на то, где PostgreSQL перестаёт тянуть агрегаты (#1) → устройство MergeTree, гранулы и батчевые вставки из Go и Java (#2) → какой драйвер выбрать под конкретную задачу (#3) → предагрегация в реальном времени и поток из Kafka (#4) → горизонтальное масштабирование через шардирование, репликацию и Keeper (#5) → parts, mutations, мониторинг и backup в проде (#6) → S3-тиринг и данные напрямую из объектного хранилища (#7) → карта выбора между ClickHouse, TimescaleDB, DuckDB и PostgreSQL (эта статья).

Финальная мысль, ради которой писалась вся серия: ClickHouse — не «быстрая база данных вообще», а инструмент под конкретный профиль нагрузки — большой объём, высокий throughput аналитических запросов, батчевые вставки, редкие мутации. Там, где этот профиль не выполняется, более простой инструмент почти всегда лучше: TimescaleDB, если аналитика умеренного масштаба может остаться внутри уже работающего PostgreSQL, DuckDB, если сервер вообще не нужен, а данные и так лежат в файлах, и сам PostgreSQL без единой специализации — до тех пор, пока EXPLAIN честно не покажет, что полный скан стал проблемой. Выбор аналитической БД — это не турнирная таблица бенчмарков, а сопоставление профиля нагрузки с тем, где и как система будет жить в проде. Общая карта альтернатив хранения и обработки данных, если нужен более широкий взгляд, чем одна серия про ClickHouse, — в хабе «Данные: карта хранилищ и подходов»готовится, с 22 сентября.

Обсуждение в Telegram

Присоединиться →

Комментарии