Интеграция 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.