Отчёт по таблице событий открывается за секунды, и кто-то предлагает «переехать на ClickHouse». Прежде чем соглашаться, стоит выяснить, какие запросы медленные и почему. Секции и BRIN отрезают время, предагрегат убирает повторный подсчёт, параллельные воркеры делят скан. Колоночное хранение выигрывает там, где запрос обязан пройти весь объём и нужны два-три столбца из шести. Ниже одна таблица, загруженная в обе системы, и пять запросов с временем, объёмом чтения и размером на диске. Устройство ClickHouse описано в обзоре архитектуры и в статье про разреженный ключ MergeTree; метод измерений — в честном нагрузочном тестировании.
Рамки сравнения
Обе системы запущены на одной машине с одинаковыми лимитами (--memory 2g --cpus 4), на одних и тех же данных, чтения идут из страничного кэша ОС. Сравнение честное только в этих рамках. Объём — 20 млн строк (1777 МиБ в PostgreSQL), потому что диск тестовой машины ограничен; цифры «на сотни миллионов строк» отсюда не выводятся. Отношения времени между системами при другом объёме, другом железе и других версиях будут другими. Часть выводов (какие запросы в какой системе дешевле) зависит от формы запроса, а не от объёма, и воспроизводится у тебя за четверть часа.
docker network create dba-olap-net
docker run -d --name dba-olap-pg --network dba-olap-net --memory 2g --cpus 4 -e POSTGRES_PASSWORD=pg \
postgres:18 -c shared_buffers=512MB -c max_parallel_workers_per_gather=4 -c max_wal_size=2GB -c track_io_timing=on
docker run -d --name dba-olap-ch --network dba-olap-net --memory 2g --cpus 4 -e CLICKHOUSE_PASSWORD=ch \
clickhouse/clickhouse-server:25.3
docker exec dba-olap-pg psql -U postgres -Atc "select version()"
docker exec dba-olap-ch clickhouse-client --password ch -q "select version()"
ch() { docker exec -i dba-olap-ch clickhouse-client --password ch "$@"; }
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
25.3.14.14
Данные и две схемы
Шесть столбцов, время растёт вместе с вставкой (типичный журнал событий), 1 млн пользователей с перекосом, восемь типов событий, десять стран, у 3 % событий есть выручка. В PostgreSQL таблица разбита на 12 месячных секций; поверх — BRIN по времени и B-tree по (user_id, ts) для точечных запросов.
CREATE TABLE events (
ts timestamptz NOT NULL, user_id int NOT NULL, event_type text NOT NULL,
country text NOT NULL, duration_ms int NOT NULL, revenue_cents int NOT NULL
) PARTITION BY RANGE (ts);
-- 12 секций events_2025_11 … events_2026_10 (цикл DO), затем:
INSERT INTO events
SELECT timestamptz '2025-11-01' + (interval '1 second' * (i::float8 * 31536000 / 20000000)),
(power(random(), 2) * 1000000)::int,
(ARRAY['view','click','search','add_to_cart','purchase','login','logout','share'])[1 + (random()*7)::int],
(ARRAY['RU','BY','KZ','US','DE','TR','IN','BR','FR','GB'])[1 + (random()*9)::int],
(random()*5000)::int,
CASE WHEN random() < 0.03 THEN (random()*20000)::int ELSE 0 END
FROM generate_series(0, 20000000 - 1) AS i;
CREATE INDEX events_ts_brin ON events USING brin (ts) WITH (pages_per_range = 32);
CREATE INDEX events_user_ts ON events (user_id, ts);
CREATE MATERIALIZED VIEW events_daily AS
SELECT date_trunc('day', ts) AS day, event_type, country,
count(*) AS events, sum(revenue_cents) AS revenue_cents, sum(duration_ms) AS duration_ms
FROM events GROUP BY 1,2,3;
CREATE INDEX events_daily_day ON events_daily (day);
VACUUM (ANALYZE) events;
Предагрегат — это решение «заранее выбрать измерения»: сутки × тип события × страна, 29 200 строк. Время создания на этой машине: вставка 23,4 с, BRIN 5,4 с, B-tree 8,9 с, matview 6,5 с, VACUUM 15,0 с. Полное обновление REFRESH MATERIALIZED VIEW events_daily перечитывает всю таблицу:
Time: 8425.449 ms (00:08.425)
В ClickHouse тот же набор столбцов, секции по месяцу и ключ сортировки по времени. DateTime хранит секунды, поэтому доли секунды timestamptz отбрасываются; запросы ниже работают с границами суток и на это не влияют.
CREATE TABLE events (
ts DateTime, user_id UInt32, event_type LowCardinality(String),
country LowCardinality(String), duration_ms UInt32, revenue_cents UInt32
) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY ts
Перенос: табличная функция postgresql()
Для разового переноса CDC не нужен: ClickHouse сам читает таблицу PostgreSQL по сети контейнеров (postgresql()). Замер по часам оболочки, потом проверка строк и сумм на обеих сторонах:
s=$(date +%s.%N); ch -q "INSERT INTO events SELECT ts, user_id, event_type, country, duration_ms, revenue_cents \
FROM postgresql('dba-olap-pg:5432','postgres','events','postgres','pg')"; e=$(date +%s.%N)
echo "load sec: $(echo "$e - $s" | bc)"
ch -q "SELECT count() FROM events"
ch -q "SELECT sum(revenue_cents), avg(duration_ms) FROM events"
docker exec -i dba-olap-pg psql -U postgres -Atc "SELECT sum(revenue_cents), avg(duration_ms) FROM events"
load sec: 8.548451171
20000000
5984271044 2499.5935917
5984271044|2499.5935917000000000
20 млн строк за 8,5 с, суммы совпали. Это снимок на момент запроса: изменения после него в ClickHouse не попадут. Для непрерывного переноса в документации описана цепочка Debezium → Kafka → ClickHouse: коннектор PostgreSQL читает логическую репликацию, а ClickHouse принимает топик через интеграцию с Kafka. Цепочка здесь не поднималась, поэтому числа задержки не приводятся; перед внедрением её лаг измеряется на своём потоке.
Пять запросов и метод
Q1 — сутки по времени, Q2 — сумма и среднее по двум столбцам на всём объёме, Q3 — 80 групп на всём объёме, Q4 — 1 млн групп (топ-10 пользователей), Q5 — последние 20 событий одного user_id. Для Q1–Q3 PostgreSQL проверен дважды: по events и по предагрегату events_daily (sum(events) вместо count(*), у Q1 условие WHERE day = '2026-03-10').
-- Q1
SELECT event_type, count(*), sum(revenue_cents) FROM events WHERE ts >= '2026-03-10' AND ts < '2026-03-11' GROUP BY 1 ORDER BY 1;
-- Q2
SELECT sum(revenue_cents), avg(duration_ms) FROM events;
-- Q3
SELECT event_type, country, count(*), sum(revenue_cents) FROM events GROUP BY 1,2 ORDER BY 1,2;
-- Q4
SELECT user_id, count(*) AS c FROM events GROUP BY user_id ORDER BY c DESC, user_id LIMIT 10;
-- Q5
SELECT * FROM events WHERE user_id = 40000 ORDER BY ts DESC LIMIT 20;
Каждый запрос выполнялся четыре раза подряд; первый — прогрев, результат — медиана трёх остальных. Время PostgreSQL — \timing в psql (результат уходит в \o /dev/null), ClickHouse — query_duration_ms из system.query_log с меткой log_comment. Объём чтения PostgreSQL — страницы shared hit + read из EXPLAIN (ANALYZE, BUFFERS) × 8 КиБ; ClickHouse — read_bytes из system.query_log, то есть байты после распаковки. Эти две величины сравнивают порядок, а не байты на диске.
Строки system.query_log для Q2 и Q5 в ClickHouse (метка, миллисекунды, прочитано строк, прочитано байт); первая строка каждой четвёрки — прогрев:
Q2 24 20000000 87191348
Q2 26 20000000 87191348
Q2 20 20000000 87191348
Q2 21 20000000 87191348
Q5 19 18435328 75703504
Q5 20 18181376 74691728
Q5 24 18181376 74680164
Q5 21 19877120 81620320
Медианы по всем запросам. Параллельность PostgreSQL — max_parallel_workers_per_gather = 4, как в команде запуска.
| Запрос | PostgreSQL, время · чтение | PostgreSQL + matview | ClickHouse |
|---|---|---|---|
| Q1 сутки | 8,2 мс · 3,6 МиБ | 0,11 мс · 48 КиБ | 4 мс · 301 КиБ |
| Q2 два столбца, всё | 480 мс · 1174 МиБ | 1,5 мс · 2,1 МиБ | 21 мс · 83 МиБ |
| Q3 80 групп, всё | 1052 мс · 1174 МиБ | 5,5 мс · 2,1 МиБ | 182 мс · 45 МиБ |
| Q4 1 млн групп | 3837 мс · 1174 МиБ и temp | не подходит | 186 мс · 76 МиБ |
| Q5 точка | 0,27 мс · 280 КиБ | не подходит | 21 мс · 71 МиБ |
Тип запроса × система на 20 млн событий
Что читает каждая система
Q1 в PostgreSQL: секция events_2026_03 выбрана по времени, внутри неё BRIN сузил чтение до 448 страниц из 12 760 (фрагмент плана).
Bitmap Heap Scan on events_2026_03 events (actual rows=54794.00 loops=1)
Rows Removed by Index Recheck: 4837
Heap Blocks: lossy=448
Buffers: shared hit=5 read=452
-> Bitmap Index Scan on events_2026_03_ts_idx (actual rows=4480.00 loops=1)
Buffers: shared hit=5 read=4
BRIN хранит минимум и максимум на 32 страницы и целиком добавляет в битовую карту каждый диапазон, который мог бы подойти (bringetbitmap, тег REL_18_6): отсюда lossy и 4837 строк, отброшенных перепроверкой.
/* add the pages in the range to the output bitmap, if needed */
if (addrange)
{
uint64 pageno;
for (pageno = heapBlk;
pageno <= Min(nblocks, heapBlk + opaque->bo_pagesPerRange) - 1;
pageno++)
{
MemoryContextSwitchTo(oldcxt);
tbm_add_page(tbm, pageno);
totalpages++;
MemoryContextSwitchTo(perRangeCxt);
}
}
Тот же запрос с enable_bitmapscan = off и enable_indexscan = off читает всю секцию (Buffers: shared hit=24 read=12760) и занимает 24,8 мс против 8,2 мс с BRIN. Индекс на секции — 32 КиБ, на все двенадцать — 384 КиБ. Это работает, пока время в таблице растёт вместе со вставкой; после массового UPDATE или загрузки старых данных диапазоны перекрываются, и BRIN перестаёт отсекать.
В ClickHouse EXPLAIN indexes=1 показывает те же три шага: одна часть из двенадцати по MinMax, затем по первичному ключу 7 гранул из 208 — это 57 344 строки.
PrimaryKey
Keys:
ts
Condition: and((ts in (-Inf, 1773187199]), (ts in [1773100800, +Inf)))
Parts: 1/1
Granules: 7/208
Q2 в PostgreSQL читает все 150 237 страниц таблицы (1174 МиБ), потому что строка хранится целиком, а нужны два столбца из шести. ClickHouse читает только столбцы revenue_cents и duration_ms: 83 МиБ. Q4 в PostgreSQL ещё и пишет временные файлы (temp written=103205 страниц) при work_mem = 4MB; с work_mem = 256MB запрос шёл 3700 мс против 3837 мс, так что решающий вклад вносит не сброс на диск.
Параллельные воркеры и предагрегат
Параллельный скан — единственное, что помогает PostgreSQL на полном проходе, и он не бесплатный. Те же запросы с max_parallel_workers_per_gather = 0:
| Запрос | 0 воркеров | 4 воркера |
|---|---|---|
| Q2 | 959 мс | 480 мс |
| Q3 | 2913 мс | 1052 мс |
Четыре воркера (и ведущий процесс) дали ускорение в 2,0 раза на Q2 и в 2,8 раза на Q3, не в четыре-пять; потолок задаёт --cpus 4 контейнера. Матвью же отвечает на Q1–Q3 за 0,11–5,5 мс, потому что читает 29 200 строк вместо 20 млн. Цена: он знает только заложенные измерения (сутки, тип, страна), отстаёт от таблицы до следующего REFRESH, который перечитывает все данные (8,4 с). Q4 и Q5 им не покрываются: пользователя в нём нет. B-tree по (user_id, ts) решает Q5 за 0,27 мс (без него — 319 мс) ценой 602 МиБ индекса.
Размер на диске
docker exec dba-olap-pg psql -U postgres -Atc "select pg_size_pretty(sum(pg_relation_size(c.oid))) from pg_class c where relname ~ '^events_2[0-9_]+\$' and relkind='r'"
ch -q "SELECT formatReadableSize(sum(bytes_on_disk)), count() FROM system.parts WHERE table='events' AND active"
1174 MB
246.61 MiB 12
Размер в PostgreSQL: таблица 1174 МиБ, B-tree 602 МиБ, BRIN 0,4 МиБ — вместе 1777 МиБ против 246,6 МиБ в ClickHouse (в 7,2 раза); matview добавляет ещё 2,8 МиБ. Без B-tree таблица всё равно больше в 4,8 раза. Сжатие тут почти ни при чём: данные ClickHouse занимали 270,06 МиБ до сжатия и 246,6 МиБ после; разницу даёт раскладка (UInt32 и LowCardinality вместо int и text в строках с заголовками). Кодеки (Delta, DoubleDelta) не подбирались, столбец ts на умолчаниях почти не сжался: 76,6 МиБ при 76,3 МиБ без сжатия; о выборе кодеков — в обзоре архитектуры. Размер в PostgreSQL для запросов Q2–Q4 важнее, чем кажется: он равен объёму чтения.
Внутри PostgreSQL: колоночные расширения
Расширения pg_duckdb и колоночное хранение Citus добавляют PostgreSQL векторное исполнение. В контейнер postgres:18 они не ставятся: apt-cache search -n "citus|duckdb" по репозиторию PGDG вернул пустой список, поэтому здесь не измерялись.
Что из этого следует
На 20 млн строк ответ зависит от запроса, а не от размера всей таблицы:
- Запрос с временем в условии (Q1) и запросы с известными измерениями (Q1–Q3) решаются средствами PostgreSQL: секции, BRIN и матвью дали 0,11–8,2 мс, быстрее ClickHouse на сырых данных.
- Два запроса, которые обязаны пройти всю таблицу без предагрегата (Q2 и Q4), в ClickHouse быстрее в 23 и 21 раз. Порядок разницы — «на порядок» — подтвердился именно на них; на Q3 с 80 группами разница 5,8 раза, на Q1 — 2 раза.
- Точечное чтение (Q5) в ClickHouse в 78 раз медленнее, чем B-tree в PostgreSQL: ключ сортировки один, и по
user_idон не помогает. Второй путь доступа в ClickHouse — отдельное проектирование (ключ MergeTree), а не индекс «на всякий случай». - Вторая система — это вторая загрузка (8,5 с для разовой, неизвестный лаг для CDC), вторые бэкапы, вторая схема и риск расхождения данных. Если ни один запрос из списка не уходит на полный проход без предагрегата, перенос не окупается. Если один-два таких запроса есть, сначала проверь, не закрывает ли их ещё один матвью.
Чек-лист
- Выпиши запросы отчётов и отнеси каждый к одному из трёх классов: период по времени, полный проход с заранее известными измерениями, полный проход с произвольными измерениями или высокой кардинальностью.
- Измерь каждый запрос по методу выше: четыре запуска, медиана трёх,
EXPLAIN (ANALYZE, BUFFERS)и байты чтения. Время без объёма чтения не объясняет, что происходит. - Для первого класса проверь, что время в таблице растёт вместе со вставкой, и поставь секции и BRIN; для второго — матвью с периодом
REFRESH, допустимым для отчёта; только третий класс — повод для ClickHouse. - Перед переносом оцени второй путь доступа (точечные чтения) и способ держать данные в синхроне; лаг CDC измеряй на своём потоке.
- Убери стенд вместе с томами:
docker rm -f -v dba-olap-pg dba-olap-ch && docker network rm dba-olap-net.