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

Интеграция ClickHouse в Spring-сервис — это два независимых потока: запись (как события попадают в ClickHouse — и почему почти никогда напрямую из Handler-а) и чтение (как сервис отдаёт аналитику — и почему это отдельный read-слой). Плюс сквозная тема: ClickHouse — второе хранилище, и всё из распределённых паттернов про согласованность двух систем применимо здесь полностью.

Подключение: драйвер и отдельный DataSource

Официальный драйвер — com.clickhouse:clickhouse-jdbc (поверх HTTP-интерфейса). В Spring Boot он живёт вторым DataSource-ом рядом с PostgreSQL:

@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}

Решения, которые тут зашиты: пул маленький (аналитические запросы тяжёлые и редкие — пять соединений достаточно, а пятьдесят параллельных агрегаций положат сервер); основной DataSource PostgreSQL остаётся primary-бином, и @Transactional с jOOQ продолжают работать с ним; ClickHouse-клиент — отдельный JdbcClient без транзакционной обвязки, потому что транзакций там нет.

Для аналитического SQL с sumIf, argMax, FINAL кодогенерация мало что даёт — запросы пишутся руками через JdbcClient, маппинг — в явные read-DTO.

Запись: почему не из Handler-а

Соблазн — дописать в Handler после сохранения заказа строчку clickHouse.insert(...). Это dual write со всеми его болезнями: вторая запись не в транзакции с первой, при сбое ClickHouse ломается бизнес-операция (или молча теряется событие), а построчные INSERT-ы — прямой путь к TOO_MANY_PARTS (см. fundamentals).

Правильный поток тот же, что для любого второго хранилища: PostgreSQL (outbox в той же транзакции) → Kafka → батчевый консьюмер → ClickHouse. Handler пишет только в PG; доменное событие через outbox-релей уходит в 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 чаще необходимого), ack — только после успешной вставки. Получается естественный накопитель: Kafka выдаёт пачку, сервис вставляет её одним INSERT-ом, при падении — перечитает с последнего offset-а.

Альтернатива для скромных потоков — async insert на стороне ClickHouse: сервер сам копит мелкие вставки в буфер (async_insert=1 в настройках соединения или запроса). Это снимает требование батчить в приложении, но буфер живёт в памяти сервера — окно потери при его падении шире, чем у Kafka-пайплайна. Для метрик годится, для событий с деньгами — Kafka.

Третий вариант — Kafka-движок в самом ClickHouse (таблица ENGINE = Kafka + materialized view): вообще без Java-кода. Цена — пайплайн уезжает из сервиса в DDL ClickHouse: ретраи, маппинг и алерты живут там, где Java-команда их не видит. Рабочий вариант, когда аналитический контур владеет ClickHouse-ом сам; для сервиса, который владеет своими данными, явный консьюмер прозрачнее.

Идемпотентность вставки

Kafka даёт at-least-once: после сбоя консьюмер перечитает часть событий, и они вставятся повторно. Два рубежа защиты:

  • Block-level дедупликация. Replicated-таблицы (и обычные при non_replicated_deduplication_window > 0) запоминают хеши последних вставленных блоков: повторная вставка того же батча молча пропускается. Покрывает ровно сценарий «упали между INSERT и commit offset-а» — если батч пересобрался из тех же событий в том же порядке.
  • ReplacingMergeTree по event_id. Страховка от дублей в любом раскладе: ENGINE = ReplacingMergeTree ORDER BY event_id схлопнет повторы при merge. Для событийных таблиц с uniq-агрегацией поверх часто достаточно одного этого: дубль до merge исказит count() на доли процента, а uniq(event_id) не исказит вовсе.

Какой рубеж обязателен — решает семантика данных: для финансовых отчётов — оба, для продуктовых метрик хватает второго.

Чтение: отдельный read-слой

Аналитические запросы из сервиса — это view-репозиторий с явными DTO, по тем же правилам, что read-side в CQRS:

@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 — read-only для читающего сервиса (запись идёт от другого пользователя пайплайна), на профиле — max_execution_time и max_memory_usage, чтобы кривой ad-hoc запрос не положил кластер. И главное организационное правило: ClickHouse — eventually consistent относительно PG; эндпоинты поверх него должны быть честны в контракте (отчёт «на сейчас» — это отчёт на «минус секунды-минуты», см. декларацию staleness в распределённых паттернах).

CDC как альтернатива outbox

Когда нужен не поток доменных событий, а зеркало таблиц PG (снимки заказов, справочники), пайплайн строят через Debezium: PG WAL → Debezium → Kafka → ClickHouse (consumer или Kafka-движок) в ReplacingMergeTree по первичному ключу с версией из LSN/updated_at. Сервис при этом не меняется вовсе — CDC снимает изменения с журнала БД. Выбор между outbox-событиями и CDC — семантический: события несут бизнес-смысл («заказ оплачен»), CDC — состояние строк; для аналитики поведения нужны первые, для зеркала справочников — второй.

Тестирование

Testcontainers поддерживает ClickHouse из коробки:

@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/argMax (как прод-код), либо форсировать слияние OPTIMIZE TABLE ... FINAL — полагаться на фоновый merge в тесте нельзя, он недетерминирован.

Что почитать дальше

  • Моделирование и запросы — схемы таблиц, в которые этот пайплайн пишет.
  • Эксплуатация — что мониторить у пайплайна со стороны ClickHouse.
  • Распределённые паттерны — outbox, идемпотентность и eventual consistency в общем виде.
  • Kafka в production — консьюмеры, батчи, DLQ.