← назад к разделу

Допустим, вы уже знаете, зачем нужен 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 параллельных агрегаций могут положить сервер.
  • Основной DataSource PostgreSQL остаётся как есть. @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 — потребители, накопление событий, очереди недоставленных сообщений.