Допустим, вы уже знаете, зачем нужен ClickHouse: быстрая аналитика, агрегации по миллионам строк, которые PostgreSQL считает десятки секунд. Теперь вопрос практический: как его подключить к Spring Boot-сервису, как туда попадают данные и как потом их читать?
Разберём по шагам: подключение, запись, чтение и тестирование.
Подключение: второй DataSource рядом с PostgreSQL
В большинстве сервисов уже есть PostgreSQL как основная база. ClickHouse — это второе хранилище, которое живёт рядом, а не вместо. Spring Boot позволяет держать несколько DataSource-ов одновременно.
Официальный драйвер — com.clickhouse:clickhouse-jdbc. Он работает поверх HTTP-интерфейса ClickHouse (порт 8123), а не через нативный протокол. Это удобно: firewall-правила проще, никакого специального клиента.
Конфигурация:
@Configuration
public class ClickHouseConfig {
@Bean
@ConfigurationProperties("app.clickhouse")
DataSourceProperties clickHouseDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
DataSource clickHouseDataSource() {
var hikari = clickHouseDataSourceProperties()
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
hikari.setMaximumPoolSize(5);
return hikari;
}
@Bean
JdbcClient clickHouseJdbcClient(@Qualifier("clickHouseDataSource") DataSource dataSource) {
return JdbcClient.create(dataSource);
}
}
app:
clickhouse:
url: jdbc:clickhouse://clickhouse.internal:8123/analytics
username: app_analytics
password: ${CLICKHOUSE_PASSWORD}
Несколько деталей, которые тут важны:
- Пул маленький — максимум 5 соединений. Аналитические запросы тяжёлые и запускаются редко; 50 параллельных агрегаций могут положить сервер.
- Основной
DataSourcePostgreSQL остаётся как есть.@Transactionalи jOOQ работают с ним, не замечая ClickHouse. - У ClickHouse нет транзакций, поэтому никакой транзакционной обвязки для нового клиента не нужно.
Запись: почему не писать напрямую из сервиса
Первая идея обычно такая: после сохранения заказа добавить строчку clickHouse.insert(...). Выглядит просто — но это двойная запись в две разные системы без гарантий. Если ClickHouse недоступен, бизнес-операция сломается. Если приложение упадёт между двумя записями — данные потеряются или рассинхронизируются. Вдобавок одиночные INSERT-ы в ClickHouse быстро приводят к ошибке TOO_MANY_PARTS — движок не успевает сливать мелкие кусочки данных.
Надёжный путь выглядит иначе:
PostgreSQL (outbox в той же транзакции) → Kafka → пакетный потребитель → ClickHouse
Сервис пишет только в PostgreSQL. Событие через outbox уходит в Kafka. Отдельный потребитель накапливает события и вставляет их пачками. Каждый шаг независим: если ClickHouse упал, Kafka просто подождёт. Когда он поднимется, потребитель продолжит с того места, где остановился.
Пакетный потребитель: накопление и вставка
@Component
@RequiredArgsConstructor
public class OrderEventsClickHouseSink {
private final JdbcClient clickHouseJdbcClient;
@KafkaListener(topics = "order-events", batch = "true",
containerFactory = "batchContainerFactory")
public void consume(List<ConsumerRecord<String, OrderEventPayload>> records,
Acknowledgment ack) {
insertBatch(records.stream().map(r -> r.value()).toList());
ack.acknowledge();
}
private void insertBatch(List<OrderEventPayload> events) {
var sql = """
INSERT INTO order_events
(event_id, event_time, event_type, region, customer_id, order_id, amount)
VALUES (?, ?, ?, ?, ?, ?, ?)
""";
clickHouseJdbcClient.sql(sql)
.params(events.stream().map(this::toRow).toList())
.batchUpdate();
}
}
Что настраивают на стороне Kafka: max.poll.records в тысячи (размер пачки), fetch.max.wait.ms — сотни миллисекунд (чтобы не дёргать ClickHouse слишком часто). Подтверждение offset-а — только после успешной вставки. Если что-то пошло не так, потребитель перечитает ту же пачку.
Асинхронная вставка как упрощённый вариант
Если поток данных небольшой, можно включить async insert на стороне ClickHouse: сервер сам копит мелкие вставки в буфер и сливает их пачкой. Включается через async_insert=1 в настройках соединения или в самом запросе — приложение перестаёт заботиться о накоплении.
Обратная сторона: буфер живёт в памяти ClickHouse-сервера. Если он упадёт до слияния — данные потеряются. Для метрик и счётчиков это приемлемо. Для событий с деньгами — нет, тут нужен Kafka-пайплайн.
Kafka-движок внутри ClickHouse
Третий вариант — убрать Java-потребителя вообще: ClickHouse умеет читать из Kafka сам через ENGINE = Kafka плюс materialized view. Данные попадают в таблицу напрямую, без промежуточного кода.
Цена: вся логика (маппинг, повторы, алерты) уходит в DDL ClickHouse. Java-команда её не видит и не контролирует. Такой вариант хорошо работает, когда аналитическим контуром владеет отдельная команда. Если сервис сам отвечает за свои данные, явный потребитель на Java нагляднее.
Идемпотентность: что делать с повторными вставками
Kafka доставляет события минимум один раз. После сбоя потребитель перечитает часть пачки, и те же строки вставятся повторно. Есть два способа защититься.
Дедупликация на уровне блока. Replicated-таблицы запоминают хеши последних вставленных пачек. Если та же пачка придёт снова — ClickHouse просто её проигнорирует. Работает именно в случае «упали между INSERT и подтверждением offset-а, пересобрали ту же пачку из тех же событий».
ReplacingMergeTree по идентификатору события. Движок ReplacingMergeTree ORDER BY event_id при фоновом слиянии схлопывает строки с одинаковым ключом, оставляя последнюю версию. Дубли попадут в таблицу, но уйдут при merge. До слияния uniq(event_id) посчитает правильно, count() — нет.
Что выбрать — зависит от задачи. Для финансовых отчётов обычно нужны оба способа. Для продуктовой аналитики чаще хватает ReplacingMergeTree.
Чтение: отдельный репозиторий для аналитики
Аналитические запросы отделяют от остального кода в отдельный репозиторий с явными DTO. Это привычный read-side паттерн: один слой пишет, другой читает.
@Repository
@RequiredArgsConstructor
public class RevenueViewRepository {
private final JdbcClient clickHouseJdbcClient;
public List<RevenueByRegionRow> revenueByRegion(LocalDate from, LocalDate to) {
return clickHouseJdbcClient.sql("""
SELECT region, sumMerge(revenue) AS revenue, uniqMerge(orders) AS orders
FROM revenue_by_region_daily
WHERE day BETWEEN ? AND ?
GROUP BY region
ORDER BY revenue DESC
""")
.params(from, to)
.query((rs, i) -> new RevenueByRegionRow(
rs.getString("region"),
rs.getBigDecimal("revenue"),
rs.getLong("orders")))
.list();
}
}
public record RevenueByRegionRow(String region, BigDecimal revenue, long orders) {}
Несколько правил гигиены:
- Пользователь
app_analyticsдля читающего сервиса — только чтение. Пишет в ClickHouse другой пользователь пайплайна. - На профиле пользователя выставляют
max_execution_timeиmax_memory_usage: случайный тяжёлый запрос не должен положить кластер. - ClickHouse отстаёт от PostgreSQL на секунды-минуты. Это нормально, но контракт API должен быть честным: «отчёт актуален на момент последней синхронизации», а не «данные в реальном времени».
Для аналитических запросов с функциями sumIf, argMax, FINAL кодогенерация не помогает — SQL пишут руками через JdbcClient, результат маппят в явные DTO.
CDC как альтернатива outbox
Outbox-пайплайн хорошо работает для доменных событий («заказ оплачен», «товар отгружен»). Но иногда нужно не событие, а зеркало таблицы — например, копия справочника товаров или снимок состояния заказов.
Для этого подходит Change Data Capture (CDC) через Debezium: инструмент читает журнал изменений PostgreSQL (WAL) и публикует изменения в Kafka. Дальше — тот же пайплайн: Kafka → потребитель → ClickHouse. Сервис при этом не меняется вообще — CDC снимает изменения с уровня базы данных.
В ClickHouse такие данные обычно хранят в ReplacingMergeTree по первичному ключу с версией из LSN или поля updated_at. При обновлении строки в PostgreSQL в ClickHouse приходит новая версия, и merge оставит актуальную.
Тестирование с Testcontainers
Testcontainers поддерживает ClickHouse и запускает настоящий сервер в Docker-контейнере прямо в тесте:
@Testcontainers
class RevenueViewRepositoryTest {
@Container
static ClickHouseContainer clickHouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.8");
@Test
void aggregatesRevenueByRegion() {
insertTestEvents();
var rows = repository.revenueByRegion(
LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 31));
assertThat(rows).extracting(RevenueByRegionRow::region)
.containsExactly("msk", "spb");
}
}
Схему создают теми же DDL-скриптами, что используются в продакшене.
Важный нюанс для ReplacingMergeTree-таблиц: фоновое слияние в тесте не детерминировано. Если тест проверяет «последнюю версию» после обновления — нужно либо читать через FINAL (как делает продакшен-код), либо явно запустить OPTIMIZE TABLE ... FINAL перед проверкой. Полагаться на то, что merge произойдёт сам — не стоит.
Коротко
- Драйвер —
com.clickhouse:clickhouse-jdbc, работает через HTTP (порт 8123). - ClickHouse подключают как второй
DataSource; основной PostgreSQL и@Transactionalне затрагиваются. - Напрямую из обработчика в ClickHouse не пишут: двойная запись ненадёжна и одиночные INSERT-ы вредят движку.
- Надёжный путь: PostgreSQL → outbox → Kafka → пакетный потребитель → ClickHouse.
- Для небольших потоков — async insert на стороне сервера; для финансовых данных — только Kafka.
- Идемпотентность: дедупликация блоков +
ReplacingMergeTreeпо идентификатору. - Чтение — отдельный репозиторий с явными DTO; SQL пишут руками, кодогенерация не нужна.
- Для зеркала таблиц (не событий) используют CDC через Debezium вместо outbox.
- Тесты — Testcontainers + реальный DDL; для
ReplacingMergeTreeфорсировать слияние перед проверкой.
Что почитать дальше
- Моделирование и запросы — схемы таблиц, в которые этот пайплайн пишет.
- Эксплуатация — что мониторить у пайплайна со стороны ClickHouse.
- Распределённые паттерны — outbox, идемпотентность и итоговая согласованность в общем виде.
- Kafka в production — потребители, накопление событий, очереди недоставленных сообщений.