Справочник DBA: базы данных изнутри читается по порядку

Статья 14.5 · раздел 14 «Консалтинг: аудит, оптимизация, миграции» · 106 из 109

Аналитика на PostgreSQL или перенос в ClickHouse: 20 млн событий, пять запросов, секции, BRIN и matview

опубликовано
читать
≈ 11 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18 и ClickHouse 25.3.14.14 в контейнере clickhouse/clickhouse-server:25.3, у каждого --memory 2g --cpus 4, сеть dba-olap-net; PostgreSQL: shared_buffers=512MB, work_mem=4MB, max_parallel_workers_per_gather=4; таблица событий 20 000 000 строк: 1777 МиБ в PostgreSQL вместе с индексами, 246,6 МиБ в ClickHouse; общая машина (20 ядер), параллельно работали другие опыты; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Отчёт по таблице событий открывается за секунды, и кто-то предлагает «переехать на 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 + matviewClickHouse
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 МиБ
DBA / 01

Тип запроса × система на 20 млн событий

Матрица «тип запроса × система»: время и объём чтения пяти запросов Q1 сутки: PostgreSQL 8,2 мс и 3,6 МиБ, matview 0,11 мс и 48 КиБ, ClickHouse 4 мс и 301 КиБ. Q2 два столбца на всём объёме: 480 мс и 1174 МиБ, 1,5 мс и 2,1 МиБ, 21 мс и 83 МиБ. Q3 80 групп: 1052 мс и 1174 МиБ, 5,5 мс и 2,1 МиБ, 182 мс и 45 МиБ. Q4 миллион групп: PostgreSQL 3837 мс и 1174 МиБ, предагрегата нет, ClickHouse 186 мс и 76 МиБ. Q5 точечный запрос: PostgreSQL 0,27 мс и 280 КиБ, предагрегата нет, ClickHouse 21 мс и 71 МиБ. PostgreSQL PG + matview ClickHouse Q1 сутки ts за 10.03.2026 8,2 мс 3,6 МиБ 0,11 мс 48 КиБ 4 мс 301 КиБ Q2 всё, 2 столбца sum + avg по таблице 480 мс 1174 МиБ 1,5 мс 2,1 МиБ 21 мс 83 МиБ Q3 всё, 80 групп event_type × country 1052 мс 1174 МиБ 5,5 мс 2,1 МиБ 182 мс 45 МиБ Q4 всё, 1 млн групп топ-10 пользователей 3837 мс 1174 МиБ + temp — нет предагрегата 186 мс 76 МиБ Q5 точка по user_id последние 20 событий 0,27 мс 280 КиБ — нет предагрегата 21 мс 71 МиБ Зелёная клетка: быстрее всего в строке. Пунктир: предагрегат не отвечает на запрос. Чтение: PostgreSQL — страницы × 8 КиБ, ClickHouse — read_bytes без сжатия.
Три класса запросов дали три разных ответа: предагрегат выигрывает там, где измерения заданы заранее; ClickHouse — на полном проходе по узким столбцам без предагрегата; PostgreSQL с B-tree — на точке.

Что читает каждая система

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 воркера
Q2959 мс480 мс
Q32913 мс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.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru