Содержание
- Что такое pg_clickhouse
- Архитектура и ключевые особенности
- Два драйвера подключения и кэш соединений
- Принцип работы
- Что проталкивается и как это проверить через EXPLAIN VERBOSE
- Ограничения и подводные камни
- pg_clickhouse и CDC-репликация
- Сценарии использования
- Практика с pg_clickhouse в Docker
- Заключение
- Референсные ссылки
pg_clickhouse это открытое расширение PostgreSQL, которое отправляет часть SQL-запроса прямо на выполнение в ClickHouse через Foreign Data Wrapper (FDW), вместо того чтобы копировать данные между базами. PostgreSQL строит план запроса как обычно, а для операций над foreign-таблицами ClickHouse включается механизм проталкивания (pushdown), и часть работы, а именно фильтры, агрегаты и оконные функции, выполняется уже на стороне ClickHouse. Такой подход годится там, где горячие транзакционные данные живут в PostgreSQL, а холодная аналитика лежит в ClickHouse, и нужен единый SQL-интерфейс поверх обеих баз без ETL посередине.
Что такое pg_clickhouse
Типичный способ подружить PostgreSQL и ClickHouse это репликация. Инструмент вроде PeerDB читает журнал транзакций PostgreSQL и копирует изменения в ClickHouse отдельным потоком (подробнее в разделе сравнения ниже). pg_clickhouse решает другую задачу. Он не копирует строки, каждая база хранит свои данные на своём месте, а вместо этого учит планировщик PostgreSQL сформулировать чужой запрос на языке ClickHouse и получить готовый результат по сети. С точки зрения приложения ClickHouse выглядит как ещё одна таблица PostgreSQL, к которой можно обратиться обычным SQL, включая JOIN с локальными таблицами.
Расширение развивает ClickHouse Inc., автор одноимённой СУБД, лицензия Apache 2.0, актуальная линия версий по GitHub Releases проекта v0.3.x, оно описано в официальной документации интеграции pg_clickhouse и распространяется готовым Docker-образом ghcr.io/clickhouse/pg_clickhouse, в который уже встроен нужный PostgreSQL вместе с расширением, поэтому собирать его из исходников для знакомства не требуется.
Построение DWH на ClickHouse
Код курса
CLICH
Ближайшая дата курса
30 ноября, 2026
Продолжительность
24 ак.часов
Стоимость обучения
76 800
Архитектура и ключевые особенности
Мостом между базами служит Foreign Data Wrapper с именем clickhouse_fdw. Он подключается к PostgreSQL через три стандартные для любого FDW сущности, а именно foreign server с адресом ClickHouse, user mapping с учётными данными и foreign table, которая описывает структуру удалённой таблицы на языке типов PostgreSQL.
Два драйвера подключения и кэш соединений
Забрать данные из ClickHouse можно двумя способами, через нативный бинарный протокол ClickHouse или через HTTP-драйвер, который годится, когда сеть между базами устроена так, что бинарный порт недоступен, зато открыт HTTP. Оба варианта работают через общий кэш соединений, который переиспользует уже открытые сокеты вместо того, чтобы поднимать новое соединение на каждый запрос. Ключевую работу выполняет депарсер запросов, компонент, который переводит выражения планировщика PostgreSQL, а именно условия, агрегаты и оконные функции, обратно в SQL-диалект ClickHouse перед отправкой по сети. Версию расширения показывает функция pgch_version(), а каталог PostgreSQL хранит лишь major.minor, поэтому точный патч-релиз линейки 0.3.x снаружи не виден.
Принцип работы
PostgreSQL строит план запроса обычным способом. Как только в плане участвует foreign-таблица ClickHouse, механизм pushdown перехватывает соответствующий узел плана и пытается свернуть его в единый запрос на языке ClickHouse, вместо того чтобы построчно вытягивать данные и обрабатывать их локально. Результат этой попытки виден напрямую через команду EXPLAIN (VERBOSE), а именно строку «Remote SQL» с готовым текстом запроса, который PostgreSQL отправил в ClickHouse.
Что проталкивается и как это проверить через EXPLAIN VERBOSE
В ClickHouse уходит заметная часть операций планировщика, поэтому проще перечислить, что расширение умеет свернуть в один удалённый запрос.
- Условия WHERE. Фильтры по колонкам передаются в ClickHouse целиком, а не проверяются построчно после выгрузки.
- Агрегатные функции. COUNT, SUM, AVG и им подобные считаются на стороне ClickHouse, PostgreSQL получает уже готовое число.
- JOIN между таблицами ClickHouse. Если обе стороны JOIN это foreign-таблицы одного и того же сервера, соединение проталкивается целиком.
- Оконные функции. Конструкции с PARTITION BY и ORDER BY выполняются в ClickHouse.
- Строковые, датовые и математические функции, а также регулярные выражения. Регулярки при этом должны быть написаны в диалекте RE2, на котором работает сам ClickHouse, а не в POSIX-диалекте PostgreSQL.
Признак pushdown во всех случаях один, строка Remote SQL в выводе EXPLAIN (VERBOSE) с уже готовым запросом на языке ClickHouse, а не построчная выгрузка данных в PostgreSQL.
Ограничения и подводные камни
Часть ограничений pg_clickhouse обнаруживается только на практике, а документация проекта не всегда успевает за тем, что реализация умеет уже сегодня.
- JOIN между локальной таблицей PostgreSQL и удалённой таблицей ClickHouse работает неэффективно. В такой связке проталкивается не сам JOIN, а голый SELECT одной колонки без фильтра, вся нужная колонка целиком выгружается в PostgreSQL, и уже там строится Hash Join. Раздел «Практика» ниже показывает это прямо на плане запроса.
- Документация и практика на стенде разошлись в агрегатах над numeric и DISTINCT. Справочник проекта называет avg(bigint), avg(interval), агрегаты над numeric и агрегаты с DISTINCT операциями, которые выполняются локально, а не в ClickHouse. На стенде от 2026-09-03 повторить это ограничение не удалось, все проверенные варианты (avg(numeric), avg(bigint), count(DISTINCT …), variance, percentile_cont, string_agg и array_agg) ушли в ClickHouse через Remote SQL до единого. Пункт приводится с этой оговоркой, а не как факт, который можно закладывать в план миграции без собственной проверки на своей версии.
- Значения UInt64 крупнее bigint валят запрос ошибкой. PostgreSQL не умеет хранить весь диапазон беззнакового 64-битного числа ClickHouse, поэтому чтение такого значения через foreign table завершается ошибкой диапазона. Обходной путь из документации оборачивает колонку в toString() на стороне ClickHouse и читает результат как текст.
- Двузначная логика ClickHouse против трёхзначной логики PostgreSQL. Оператор IN в ClickHouse не учитывает NULL так, как это делает стандартный SQL PostgreSQL. Расширение компенсирует разницу, оборачивая проталкиваемые запросы в дополнительный CASE, но эта защита отключается сама, если колонка не объявлена NOT NULL.
Из четырёх пунктов на собственном прогоне подтвердились только JOIN local-remote и переполнение UInt64, остальные два взяты из документации проекта и требуют проверки на конкретной версии перед тем, как на них полагаться в проде.
pg_clickhouse и CDC-репликация
PeerDB, инструмент CDC-репликации (Change Data Capture), решает похожую на первый взгляд задачу другим способом. Он читает журнал транзакций PostgreSQL и копирует изменения строк в ClickHouse отдельным потоком, так что аналитические запросы дальше выполняются полностью внутри ClickHouse над собственной копией данных. Подход разобран в статье блога «Совместное использование ClickHouse и PostgreSQL: CDC с PeerDB», а pg_clickhouse решает тот же вопрос без второй копии данных вообще.
| Критерий | pg_clickhouse (query pushdown) | PeerDB (CDC-репликация) |
|---|---|---|
| Что физически хранится в ClickHouse | Ничего своего, доступ идёт к оригинальным данным по сети на каждый запрос | Полная реплицированная копия таблиц PostgreSQL |
| Свежесть данных | Всегда актуальны, читается сам источник | Отстаёт от источника на задержку доставки CDC-потока |
| Нагрузка на сеть | На каждый запрос, пропорционально его сложности | Непрерывный поток изменений, объём зависит от частоты записи в источник |
| Типичный сценарий | Разовая или ad-hoc OLAP-аналитика поверх свежих данных | Постоянный аналитический слой поверх исторических данных |
| Что ломается при недоступности ClickHouse | Падают только запросы к foreign-таблицам | Останавливается поток репликации, локальная копия остаётся доступна для чтения |
Выбор между подходами определяется тем, что важнее, а именно нулевая задержка чтения свежих данных или изоляция аналитической нагрузки от источника. Ничто не мешает использовать оба сразу, например держать исторический архив в ClickHouse через CDC, а последние часы поднимать через pg_clickhouse.
Практическая архитектура данных
Код курса
PRAR
Ближайшая дата курса
30 ноября, 2026
Продолжительность
24 ак.часов
Стоимость обучения
76 800
Сценарии использования
pg_clickhouse хорошо ложится на сценарий hot/cold-хранения, когда свежие транзакционные данные несколько дней или недель живут в PostgreSQL, а исторический архив хранится в ClickHouse и туда же уходит основная OLAP-нагрузка. Расширение снимает необходимость переписывать привычный SQL под два диалекта и держать в приложении два клиента, партиционирование по времени и запрос через foreign table закрывают эту задачу одним планировщиком. Курс «Построение DWH на ClickHouse» разбирает построение такой аналитической платформы подробнее.
Отказаться от pg_clickhouse стоит там, где задержка чтения должна укладываться в доли миллисекунды, потому что каждый проталкиваемый запрос всё равно идёт по сети до ClickHouse и обратно. Та же логика касается тяжёлых JOIN между локальными и удалёнными таблицами, разобранных в разделе ограничений, и сценариев, где нужна настоящая двусторонняя синхронизация в реальном времени, а не чтение по запросу.
Практика с pg_clickhouse в Docker
По традиции весь код используемый в статье выкладываем на наш GitHub репозиторий
Стенд поднимает два контейнера, официальный образ pg_clickhouse (PostgreSQL 18.4 со встроенным расширением) и отдельный ClickHouse 25.8. Контейнер pg_clickhouse стартует только после того, как ClickHouse пройдёт healthcheck, это задано зависимостью в docker-compose.
# pg_clickhouse v0.3.x (образ ghcr.io/clickhouse/pg_clickhouse:18, реально PostgreSQL 18.4),
# ClickHouse 25.8 (образ clickhouse/clickhouse-server:25.8, реально 25.8.33.6),
# прогнано на стенде 2026-09-03
services:
clickhouse:
image: clickhouse/clickhouse-server:25.8
container_name: pg_clickhouse_ch
environment:
# без явного пароля образ запрещает сетевой доступ пользователю default
# (пускает только localhost) — foreign server из другого контейнера
# получает "Authentication failed" вместо внятной причины
CLICKHOUSE_PASSWORD: demo_pass
ports:
- "8123:8123"
- "9000:9000"
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 3s
timeout: 3s
retries: 20
postgres:
image: ghcr.io/clickhouse/pg_clickhouse:18
container_name: pg_clickhouse_pg
environment:
POSTGRES_PASSWORD: demo_pass
ports:
- "5432:5432"
depends_on:
clickhouse:
condition: service_healthy
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 3s
timeout: 3s
retries: 20
Это не теоретическая предосторожность. Без переменной CLICKHOUSE_PASSWORD образ ClickHouse по умолчанию запрещает сетевой доступ пользователю default и пускает только с localhost, а foreign server пытается достучаться до ClickHouse из соседнего контейнера. Результат на этом стенде выглядел как обманчивый Authentication failed, а не как понятная сетевая ошибка, поэтому первым подозреваемым стоит держать именно пароль, а не сеть Docker.
# Docker Compose v5.3.1, прогнано на стенде 2026-09-03 docker compose up -d docker compose ps
Оба контейнера должны перейти в статус healthy. Дальше ClickHouse наполняется миллионом синтетических строк и представлением, которое пригодится в демонстрации переполнения UInt64.
-- ClickHouse 25.8.33.6, синтаксис по официальной документации ClickHouse, прогнано на стенде 2026-09-03
-- Создаёт демо-таблицу событий и наполняет её синтетическими данными,
-- через которую дальше проверяется pushdown из PostgreSQL.
CREATE DATABASE IF NOT EXISTS analytics;
CREATE TABLE analytics.events
(
event_id UInt64,
user_id UInt32,
event_type String,
amount Decimal(10, 2),
created_at DateTime,
raw_uint64 UInt64
)
ENGINE = MergeTree
ORDER BY (created_at, event_id);
INSERT INTO analytics.events
SELECT
number AS event_id,
toUInt32(1 + rand() % 5000) AS user_id,
['view', 'click', 'purchase'][1 + rand() % 3] AS event_type,
round((rand() % 10000) / 100.0, 2) AS amount,
toDateTime('2026-08-01 00:00:00') + toIntervalSecond(rand() % (30 * 24 * 3600)) AS created_at,
-- у первой строки намеренно максимальное значение UInt64, чтобы показать
-- переполнение bigint в PostgreSQL при чтении через foreign table
if(number = 0, 18446744073709551615, number) AS raw_uint64
FROM numbers(1000000);
-- Представление с безопасным для PostgreSQL текстовым представлением raw_uint64.
-- Обходной путь из документации pg_clickhouse: toString() на стороне ClickHouse.
CREATE VIEW analytics.events_uint64_safe AS
SELECT
event_id,
toString(raw_uint64) AS raw_uint64_str
FROM analytics.events;
SELECT count() AS rows_loaded FROM analytics.events;
# запуск clickhouse_setup.sql внутри контейнера, пин версий — в блоке выше docker exec -i pg_clickhouse_ch clickhouse-client \ --user default --password demo_pass --multiquery < clickhouse_setup.sql
Последняя строка вывода, 1000000, подтверждает, что таблица analytics.events наполнилась целиком до перехода к настройке foreign server.
-- pg_clickhouse v0.3.x (ghcr.io/clickhouse/pg_clickhouse:18, PostgreSQL 18.4),
-- синтаксис по официальной документации ClickHouse/pg_clickhouse, прогнано на стенде 2026-09-03
-- Подключает ClickHouse как foreign server и импортирует схему analytics
-- целиком через IMPORT FOREIGN SCHEMA.
CREATE EXTENSION pg_clickhouse;
-- driver 'binary' — нативный протокол ClickHouse; порт указан явно, потому
-- что pg_clickhouse его не подставляет по умолчанию, а сервер в
-- docker-compose слушает native-протокол на стандартном порту 9000.
CREATE SERVER analytics_srv FOREIGN DATA WRAPPER clickhouse_fdw
OPTIONS (driver 'binary', host 'clickhouse', port '9000', dbname 'analytics');
CREATE USER MAPPING FOR CURRENT_USER SERVER analytics_srv
OPTIONS (user 'default', password 'demo_pass');
CREATE SCHEMA analytics;
IMPORT FOREIGN SCHEMA analytics FROM SERVER analytics_srv INTO analytics;
-- Проверка: список импортированных foreign-таблиц и типы колонок events.
\det+ analytics.*
\d analytics.events
# запуск foreign_setup.sql внутри контейнера, пин версий — в блоке выше docker exec -i pg_clickhouse_pg psql -U postgres < foreign_setup.sql
IMPORT FOREIGN SCHEMA создаёт сразу две foreign-таблицы, events и events_uint64_safe, и маппит типы ClickHouse на PostgreSQL так, как показано ниже.
Foreign table "analytics.events" Column | Type | Collation | Nullable | Default | FDW options ------------+--------------------------+-----------+----------+---------+------------- event_id | bigint | | not null | | user_id | bigint | | not null | | event_type | text | | not null | | amount | numeric(10,2) | | not null | | created_at | timestamp with time zone | | not null | | raw_uint64 | bigint | | not null | |
Обе беззнаковые целочисленные колонки ClickHouse, UInt64 и UInt32, легли в один и тот же тип bigint PostgreSQL. Для user_id это безопасно, диапазон значений маленький, а вот для raw_uint64 такое совпадение типов и станет причиной ошибки чуть ниже.
-- pg_clickhouse v0.3.x (ghcr.io/clickhouse/pg_clickhouse:18, PostgreSQL 18.4),
-- ClickHouse 25.8.33.6, прогнано на стенде 2026-09-03
-- Три проверенных на реальном стенде случая: успешный pushdown, локальный
-- JOIN между local- и remote-таблицами (не проталкивается), переполнение
-- UInt64 при чтении через foreign table.
\timing on
-- ── 1. Успешный pushdown: WHERE + GROUP BY + count() ──────────────────────
-- В "Remote SQL" виден запрос, целиком отправленный в ClickHouse: PostgreSQL
-- получает уже посчитанный результат, а не сырые строки.
EXPLAIN (VERBOSE)
SELECT event_type, count(*) AS cnt
FROM analytics.events
WHERE created_at >= '2026-08-15'
GROUP BY event_type;
SELECT event_type, count(*) AS cnt
FROM analytics.events
WHERE created_at >= '2026-08-15'
GROUP BY event_type
ORDER BY event_type;
-- ── 2. JOIN между локальной таблицей PostgreSQL и foreign table ClickHouse ─
-- Документация предупреждает, что такой JOIN неэффективен. На практике это
-- значит: pg_clickhouse проталкивает не JOIN, а голый SELECT одной колонки
-- без фильтра ("Remote SQL: SELECT user_id FROM analytics.events" — читает
-- всю таблицу), а сам JOIN и агрегацию после него PostgreSQL делает уже
-- локально (Hash Join, HashAggregate в плане).
CREATE TABLE local_users (user_id bigint PRIMARY KEY, segment text);
INSERT INTO local_users
SELECT g, CASE WHEN g % 2 = 0 THEN 'even' ELSE 'odd' END
FROM generate_series(1, 5000) g;
EXPLAIN (VERBOSE)
SELECT lu.segment, count(*) AS cnt
FROM analytics.events e
JOIN local_users lu ON lu.user_id = e.user_id
GROUP BY lu.segment;
-- ── 3. Переполнение UInt64 и обходной путь toString() ──────────────────────
-- Значение 18446744073709551615 (2^64-1) больше максимума bigint —
-- foreign table читает колонку raw_uint64 как bigint и падает с ошибкой.
SELECT raw_uint64
FROM analytics.events
WHERE event_id = 0;
-- Обходной путь из документации: на стороне ClickHouse обернуть колонку в
-- toString() (см. VIEW analytics.events_uint64_safe в clickhouse_setup.sql)
-- и читать её как text через отдельную foreign table.
SELECT raw_uint64_str
FROM analytics.events_uint64_safe
WHERE event_id = 0;
# запуск pushdown_demo.sql внутри контейнера, пин версий — в блоке выше docker exec -i pg_clickhouse_pg psql -U postgres < pushdown_demo.sql
Первый блок подтверждает pushdown буквально. EXPLAIN (VERBOSE) занял 3.2 мс и показал готовый Remote SQL, а сам запрос выполнился за 8.6 мс и вернул три строки с количеством событий по каждому типу, посчитанным на стороне ClickHouse.
Foreign Scan (cost=1.00..5.10 rows=1000 width=40) Output: event_type, (count(*)) Relations: Aggregate on (events) Remote SQL: SELECT event_type, count(*) FROM analytics.events WHERE ((created_at >= '2026-08-15 00:00:00')) GROUP BY event_type Time: 3.232 ms event_type | cnt ------------+-------- click | 177023 purchase | 177740 view | 178132 Time: 8.637 ms
Второй блок показывает обратную ситуацию. Вместо JOIN в Remote SQL попал голый SELECT одной колонки без фильтра, вся колонка user_id вытянута в PostgreSQL целиком, а Hash Join и HashAggregate из плана ниже выполняются уже локально.
HashAggregate (cost=131.22..133.22 rows=200 width=40)
Output: lu.segment, count(*)
Group Key: lu.segment
-> Hash Join (cost=113.60..126.22 rows=1000 width=32)
Output: lu.segment
Inner Unique: true
Hash Cond: (e.user_id = lu.user_id)
-> Foreign Scan on analytics.events e (cost=10.00..20.00 rows=1000 width=8)
Output: e.event_id, e.user_id, e.event_type, e.amount, e.created_at, e.raw_uint64
Remote SQL: SELECT user_id FROM analytics.events
-> Hash (cost=61.60..61.60 rows=3360 width=40)
Output: lu.segment, lu.user_id
-> Seq Scan on public.local_users lu (cost=0.00..61.60 rows=3360 width=40)
Output: lu.segment, lu.user_id
Третий блок демонстрирует переполнение UInt64 и обход через toString(). Первый запрос падает ровно с той ошибкой, которую предсказывает документация, а второй, к представлению events_uint64_safe, возвращает то же число без потерь, потому что на стороне ClickHouse оно уже превращено в текст.
ERROR: pg_clickhouse: value 18446744073709551615 is out of range of bigint
DETAIL: Remote Query: SELECT raw_uint64 FROM analytics.events WHERE ((event_id = 0))
raw_uint64_str
----------------------
18446744073709551615
Три демонстрации закрывают ровно те вопросы, которые в теории остаются на уровне «должно проталкиваться». EXPLAIN (VERBOSE) — единственный надёжный способ проверить, ушёл ли конкретный запрос в ClickHouse целиком, вместо того чтобы верить документации на слово.
Заключение
pg_clickhouse даёт PostgreSQL прямой SQL-доступ к ClickHouse без промежуточной копии данных, проталкивая фильтры, агрегаты и часть JOIN на выполнение туда, где хранятся сами строки. Механизм честно показывает себя через EXPLAIN (VERBOSE), и та же прозрачность обнажает границы. JOIN между локальными и удалёнными таблицами эффективным не станет, переполнение UInt64 нужно обходить toString(), а часть документированных ограничений на стенде не подтвердилась и требует собственной проверки на конкретной версии. Для hot/cold-архитектуры, где свежие данные живут в PostgreSQL, а аналитика уезжает в ClickHouse, это меньше движущихся частей, чем отдельный CDC-конвейер, а для задач с жёсткой задержкой или тяжёлыми межбазовыми JOIN стоит присмотреться к другим решениям.
Референсные ссылки
- pg_clickhouse на ClickHouse Docs, точка входа в документацию по интеграции
- pg_clickhouse reference, архитектура FDW, механизм pushdown, типы данных и ограничения
- Introducing pg_clickhouse, анонс проекта в блоге ClickHouse
- ClickHouse/pg_clickhouse на GitHub, README с требованиями к установке и роадмапом
- Совместное использование ClickHouse и PostgreSQL: CDC с PeerDB, статья блога для сравнения подходов

