Допустим, вы уже знаете, зачем нужен ClickHouse: быстрая аналитика, агрегации по миллионам строк, которые PostgreSQL считает десятки секунд. Теперь вопрос практический: как его подключить к Spring Boot-сервису, как туда попадают данные и как потом их читать?
Одиночные вставки дробят таблицу на мелкие куски, и движок не успевает их сливать. Потребитель копит события и отправляет одну пачку — получается один кусок. Если после сбоя та же пачка придёт снова, replicated-таблица узнает её по хешу блока и не вставит второй раз.
Подключение: второй DataSource рядом с PostgreSQL
В большинстве сервисов уже есть PostgreSQL как основная база. ClickHouse — это второе хранилище, которое живёт рядом, а не вместо. Spring Boot позволяет держать несколько DataSource-ов одновременно.
Официальный драйвер — com.clickhouse:clickhouse-jdbc. Он работает поверх HTTP-интерфейса ClickHouse (порт 8123), а не через нативный протокол. Это удобно: firewall-правила проще, никакого специального клиента.
Одна оговорка про версию. Начиная с 0.8 драйвер переписан на новый клиент и объявляет все свои зависимости обычным образом — достаточно одной строки в сборке. А вот в версиях 0.6 и раньше в обычном артефакте зависимостей не было вовсе, и без классификатора :all (или :http) сборка поднималась, а драйвер на первом же соединении падал с NoClassDefFoundError. Если наткнётесь на такой совет в старой статье — он про старые версии.
Конфигурация:
@Configuration
public class ClickHouseConfig {
@Bean(defaultCandidate = false)
@ConfigurationProperties("app.clickhouse")
DataSourceProperties clickHouseDataSourceProperties() {
return new DataSourceProperties();
}
@Bean(defaultCandidate = false)
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);
}
@Bean
JdbcTemplate clickHouseJdbcTemplate(@Qualifier("clickHouseDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
app:
clickhouse:
url: jdbc:clickhouse://clickhouse.internal:8123/analytics
username: app_analytics
password: ${CLICKHOUSE_PASSWORD}
Несколько деталей, которые тут важны:
- Пул маленький — максимум 5 соединений. Аналитические запросы тяжёлые и запускаются редко; 50 параллельных агрегаций могут положить сервер.
@Bean(defaultCandidate = false)(Spring Boot 3.4+) обязателен: без него автоконфигурация не создаст основнойDataSource. С ним PostgreSQL остаётся как есть —@Transactionalи jOOQ работают с ним, не замечая ClickHouse.- У ClickHouse нет транзакций, поэтому никакой транзакционной обвязки для нового клиента не нужно.
Запись: почему не писать напрямую из сервиса
Первая идея обычно такая: после сохранения заказа добавить строчку clickHouse.insert(...). Выглядит просто — но это двойная запись в две разные системы без гарантий. Если ClickHouse недоступен, бизнес-операция сломается. Если приложение упадёт между двумя записями — данные потеряются или рассинхронизируются. Вдобавок одиночные INSERT-ы в ClickHouse быстро приводят к ошибке TOO_MANY_PARTS — движок не успевает сливать мелкие кусочки данных.
Надёжный путь выглядит иначе:
PostgreSQL (outbox в той же транзакции) → Kafka → пакетный потребитель → ClickHouse
Заказ и запись в outbox идут одной транзакцией, дальше событие уходит в Kafka, а потребитель копит строки и вставляет их в ClickHouse одним куском.
Сервис пишет только в PostgreSQL. Событие через outbox уходит в Kafka. Отдельный потребитель накапливает события и вставляет их пачками. Каждый шаг независим: если ClickHouse упал, Kafka просто подождёт. Когда он поднимется, потребитель продолжит с того места, где остановился.
Пакетный потребитель: накопление и вставка
Сначала таблица, в которую всё это поедет:
CREATE TABLE order_events (
event_id UUID,
event_time DateTime,
event_type LowCardinality(String),
region LowCardinality(String),
customer_id UInt64,
order_id UUID,
amount Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (region, event_type, event_time);
От таблицы из предыдущих статей раздела она отличается одной колонкой — event_id. Там события просто копились, и собственный идентификатор у строки был не нужен. Здесь их приносит Kafka, которая доставляет каждое событие минимум один раз, и без такого идентификатора повтор не отличить от новой строки. Как именно он спасает — в разделе про идемпотентность ниже.
@Component
@RequiredArgsConstructor
public class OrderEventsClickHouseSink {
private final JdbcTemplate clickHouseJdbcTemplate;
@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 (?, ?, ?, ?, ?, ?, ?)
""";
clickHouseJdbcTemplate.batchUpdate(sql, events.stream().map(this::toRow).toList());
}
}
Вставку пачкой делает JdbcTemplate, а не JdbcClient: пакетного режима у JdbcClient нет, документация Spring за ним отсылает к JdbcTemplate.
Что настраивают на стороне Kafka: max.poll.records в тысячи (размер пачки), fetch.max.wait.ms — сотни миллисекунд (чтобы не дёргать ClickHouse слишком часто). Подтверждение offset-а — только после успешной вставки. Если что-то пошло не так, потребитель перечитает ту же пачку.
Асинхронная вставка как упрощённый вариант
Если поток данных небольшой, можно включить async insert на стороне ClickHouse: сервер сам копит мелкие вставки в буфер и сливает их пачкой. Включается через async_insert=1 в настройках соединения или в самом запросе — приложение перестаёт заботиться о накоплении.
Обратная сторона зависит от одной настройки. По умолчанию (wait_for_async_insert=1) сервер отвечает «принято» только после того, как буфер реально записан на диск, — тогда подтверждённые вставки не теряются, но каждая занимает чуть больше времени. А вот если выключить ожидание ради скорости, ответ придёт сразу, и всё, что осталось в памяти на момент падения сервера, пропадёт. Для метрик и счётчиков второе приемлемо, для событий с деньгами — нет, там всё равно нужен Kafka-пайплайн.
Kafka-движок внутри ClickHouse
Третий вариант — убрать Java-потребителя вообще: ClickHouse умеет читать из Kafka сам через ENGINE = Kafka плюс materialized view. Данные попадают в таблицу напрямую, без промежуточного кода.
Цена: вся логика (маппинг, повторы, алерты) уходит в DDL ClickHouse. Java-команда её не видит и не контролирует. Такой вариант хорошо работает, когда аналитическим контуром владеет отдельная команда. Если сервис сам отвечает за свои данные, явный потребитель на Java нагляднее.
Размер пачки и что делать с ошибкой
Сколько копить. Ориентир один на всю фазу: одна вставка — это десятки тысяч строк, а не сотни и не миллионы. Меньше — сервер захлебнётся числом кусков (каждая вставка создаёт кусок, и фоновые слияния не успевают, а на пороге сервер отвечает ошибкой о слишком многих частях). Больше — растёт задержка появления данных и потребление памяти у потребителя. Практическая раскладка: копить до 50–100 тысяч строк или до нескольких секунд — что наступит раньше. Число прочитанных за раз записей в потребителе подбирают так, чтобы пачка набиралась за один-два обхода, а не за сотню.
Что делать при ошибке. Вставка пачки — это всё или ничего: сервер либо принял блок целиком, либо нет. Дальше важно различать две ситуации.
Временная ошибка (сеть, перезагрузка узла, превышен лимит памяти) — повторяем ту же пачку с нарастающей паузой. Смещение не подтверждаем, и после перезапуска пачка будет перечитана — это нормально, потому что дедупликация по контрольной сумме блока сделает повтор безвредным.
Постоянная ошибка (одна строка с неверным типом, переполнение Decimal, строка длиннее ожидаемого) повтором не лечится: пачка будет падать вечно, а потребитель встанет и перестанет читать очередь вовсе. Это самый опасный сценарий, потому что выглядит он как «всё работает, но данных нет». Лечение — разбить пачку: при повторной неудаче вставлять по одной строке (или делением пополам), плохие строки отправлять в очередь недоставленных сообщений с исходным сообщением и текстом ошибки, остальное записывать. Потерянные строки потом разбирают руками, а конвейер продолжает работать.
Типы: что делает драйвер, а что придётся самому
Соответствие типов ClickHouse и Java выглядит очевидным ровно до первого несовпадения.
Decimal(18, 2) ↔ BigDecimal — работает напрямую, но масштаб надо соблюдать: значение с большим числом знаков после запятой сервер округлит молча, а с большей целой частью — отвергнет. Приводите в приложении явно (setScale(2, RoundingMode.HALF_UP)), чтобы округление было вашим решением.
DateTime64(3) ↔ Instant или LocalDateTime — тут ловушка в часовом поясе: у колонки может быть свой пояс, у драйвера свой, а у сервера третий. Правило то же, что с PostgreSQL: в приложении работать в Instant, а поясом колонки управлять явно при создании таблицы.
UUID ↔ java.util.UUID — поддерживается напрямую, но при вставке строкой сервер требует канонический вид с дефисами.
LowCardinality(String) ↔ обычная String: для драйвера это просто строка, вся оптимизация происходит на стороне сервера, и в коде ничего менять не надо.
Array(T) ↔ Object[] или список: драйвер умеет, но при параметризованной вставке массивы часто требуют явного указания типа элемента.
Nullable(T) ↔ те же типы плюс null, но помните, что на стороне схемы Nullable стоит денег (отдельный файл с признаком пустоты на колонку), и в аналитике его обычно заменяют значением по умолчанию.
Миграции схемы: Liquibase здесь не помощник
Обычные инструменты миграций умеют ClickHouse плохо или не умеют вовсе (нет транзакционного DDL, свой синтаксис, распределённые таблицы требуют выполнения на всех узлах). Поэтому порядок заводят свой, и он должен быть описан в репозитории.
Рабочая схема такая: каталог с пронумерованными файлами .sql, таблица в самом ClickHouse со списком применённых миграций, небольшой скрипт (или задание в конвейере сборки), который читает список, применяет недостающие и записывает факт применения. В кластере команды выполняют с ON CLUSTER, чтобы изменения доехали до всех узлов.
Что важно учесть заранее. Все изменения должны быть совместимыми вперёд: добавление колонки с значением по умолчанию — безопасно, удаление или переименование — нет, потому что старая версия приложения продолжает писать старую схему. Тяжёлые операции (ALTER TABLE … UPDATE, MATERIALIZE PROJECTION) выполняются асинхронно, и миграция считается завершённой не по возврату команды, а по опустевшей очереди мутаций. И тестовое окружение обязано разворачиваться теми же файлами, иначе расхождение между стендом и продом обнаружится на первом же отчёте.
Идемпотентность: что делать с повторными вставками
Kafka доставляет события минимум один раз. После сбоя потребитель перечитает часть пачки, и те же строки вставятся повторно. Есть два способа защититься.
Дедупликация на уровне блока. Replicated-таблицы запоминают хеши последних вставленных пачек. Если та же пачка придёт снова — ClickHouse просто её проигнорирует. Работает именно в случае «упали между INSERT и подтверждением offset-а, пересобрали ту же пачку из тех же событий».
У этой защиты есть два предела, и оба стоит знать заранее. Первый — память: помнятся не все пачки, а последние replicated_deduplication_window штук (по умолчанию 10 000) и не дольше replicated_deduplication_window_seconds (в свежих версиях это час; раньше стояла неделя). Пачка, повторившаяся позже, приедет как новая.
Второй предел жёстче. Хеш считается по содержимому блока целиком, а не по идентификаторам событий внутри. Поэтому «та же пачка» означает буквально ту же: те же строки, в том же порядке, в том же количестве. Перечитали из Kafka чуть больше событий, или потребитель собрал пачку другого размера, или порядок строк изменился — блок другой, хеш другой, дубли лягут в таблицу. Ровно поэтому одной дедупликации блоков мало, и рядом нужен второй приём.
ReplacingMergeTree по идентификатору события. Движок ReplacingMergeTree ORDER BY event_id при фоновом слиянии схлопывает строки с одинаковым ключом, оставляя последнюю версию. Дубли попадут в таблицу, но уйдут при merge. До слияния uniq(event_id) посчитает правильно, count() — нет.
Обе защиты видно на маленькой программе: таблица — список строк, движок помнит хеши вставленных блоков.
живой пример
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class BatchSinkDemo {
record Event(String id, String status) {
@Override
public String toString() {
return id + ":" + status;
}
}
static final List<Event> table = new ArrayList<>();
static final Set<Integer> insertedBlocks = new HashSet<>();
public static void main(String[] args) {
List<Event> block = List.of(new Event("e1", "NEW"), new Event("e2", "PAID"));
insert(block);
insert(block);
insert(List.of(new Event("e2", "SHIPPED")));
System.out.println("строк в таблице: " + table.size() + " " + table);
System.out.println("uniq(event_id): " + merged().size());
System.out.println("после слияния: " + merged());
}
static void insert(List<Event> block) {
if (!insertedBlocks.add(block.hashCode())) {
System.out.println("пачка " + block + " уже вставлялась — пропуск");
return;
}
table.addAll(block);
System.out.println("вставлен кусок " + block);
}
static Map<String, String> merged() {
Map<String, String> latest = new LinkedHashMap<>();
for (Event event : table) {
latest.put(event.id(), event.status());
}
return latest;
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Повтор той же пачки не прошёл по хешу, а дубль, пришедший другой пачкой, дожил в таблице до слияния.
Для финансовых отчётов обычно нужны оба способа, для продуктовой аналитики чаще хватает 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 снимает изменения с уровня базы данных.
Оба пути ведут из PostgreSQL в ClickHouse через Kafka, но outbox пишет событие кодом сервиса, а CDC снимает изменения с журнала базы.
В ClickHouse такие данные обычно хранят в ReplacingMergeTree по первичному ключу с версией из LSN или поля updated_at. При обновлении строки в PostgreSQL в ClickHouse приходит новая версия, и merge оставит актуальную.
Отдельный вопрос, на котором такой конвейер чаще всего ломается: удаления. Захват изменений присылает их отдельным видом события, и в аналитической таблице их надо как-то выразить — просто «не вставлять» нельзя, потому что старая версия строки уже лежит.
Схема та же, что в статье про моделирование: движок, сворачивающий версии по ключу, плюс колонка-признак удаления. Событие обновления приезжает как новая версия с текущей меткой времени, событие удаления — как версия с той же меткой и единицей в признаке. При чтении с FINAL (или через argMax с фильтром по признаку) удалённые строки не попадают в выдачу, а при слиянии убираются совсем.
Второе, что стоит учесть: порядок. Захват изменений гарантирует порядок в пределах ключа раздела очереди, поэтому ключом раздела делают первичный ключ таблицы — иначе обновление может приехать раньше вставки, и версия окажется не той. И третье: начальная загрузка. Захват изменений присылает только изменения с момента включения; историю переливают отдельной выгрузкой, и стык между ними сверяют по меткам времени.
Тестирование с 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 перед проверкой.
Глубже: асинхронная вставка вместо пакетного потребителярасширенное
В основной части асинхронная вставка названа упрощённым вариантом для небольшого потока. Стоит разобрать её как полноценную альтернативу, потому что правило «не вставляй по одной строке» повторяется трижды, а ответ на вопрос «а если всё-таки нужно по одной» лежит именно здесь.
Механика. При async_insert = 1 сервер не создаёт кусок на каждый INSERT, а складывает строки в буфер в памяти и сбрасывает его в один кусок, когда набирается async_insert_max_data_size (по умолчанию 10 МБ) или проходит async_insert_busy_timeout_ms (по умолчанию 200 мс, в свежих версиях порог подстраивается под поток сам). Тысяча сервисов, каждый со своей строкой в секунду, дают один кусок в 200 мс вместо тысячи, и TOO_MANY_PARTS не наступает. Буфер ведётся на таблицу и на набор настроек запроса, поэтому вставки с разными настройками в буфере не объединяются.
Гарантии зависят от wait_for_async_insert. При единице (умолчание) клиент получает ответ после записи буфера на диск, и подтверждённая вставка не теряется даже при падении сервера; каждая вставка при этом ждёт до 200 мс, что для фоновой отправки событий приемлемо. При нуле ответ приходит сразу, и содержимое буфера при падении пропадает. Дедупликация повторных вставок, которая для обычных пакетов работает по хешу блока, для асинхронных включается отдельно, async_insert_deduplicate = 1, и работает только с wait_for_async_insert = 1 на реплицируемых таблицах.
Настройки задают на пользователе ClickHouse или в строке подключения, чтобы код их не знал:
jdbc:clickhouse://ch:8123/analytics?async_insert=1&wait_for_async_insert=1
Когда этого достаточно: событий до десятков тысяч в секунду на кластер, источников много и собрать их в один потребитель негде, потеря событий за последние миллисекунды при падении не критична или закрыта wait_for_async_insert = 1, и повторная доставка от отправителя не нужна. Когда нужен потребитель на Kafka: события с деньгами, где отправитель обязан получить подтверждение и повторить; преобразование перед вставкой; и поток, при котором ClickHouse не единственный получатель. Наблюдают за асинхронной вставкой через system.asynchronous_inserts (что сейчас в буферах) и system.asynchronous_insert_log.
У асинхронной вставки есть свои настройки, и без них она ведёт себя непредсказуемо. async_insert_max_data_size — сколько байт сервер копит, прежде чем записать (по умолчанию порядка мегабайта); async_insert_busy_timeout_ms — как долго ждёт, если объём не набрался (по умолчанию порядка секунды); async_insert_max_query_number — предел по числу запросов в буфере. Сливается буфер по тому условию, которое наступит первым.
И главный выбор — ждать ли подтверждения. wait_for_async_insert = 1 (по умолчанию) означает, что клиент получает ответ после фактической записи: медленнее, зато «записано» значит записано. Ноль — ответ сразу после попадания в буфер: быстрее, но при падении сервера буфер теряется, и клиент об этом не узнает. Для аналитики второй вариант иногда приемлем, для данных, которые нужно сверять с источником, — нет.
Глубже: когда ClickHouse недоступенрасширенное
ClickHouse в сервисе это второй DataSource, и он падает независимо от первого: слияния съели диск, узел перезагружается, сеть до аналитического кластера ушла. Сервис, который отвечает пользователям, при этом не должен замечать ничего, кроме пустого блока «аналитика».
Чтение: у аналитического репозитория свой короткий таймаут (socket_timeout в строке подключения, секунды, а не минуты по умолчанию) и свой пул с одним-двумя соединениями, чтобы очередь на ClickHouse не выбрала пул потоков сервиса. Ручка отчёта отвечает «данные временно недоступны» или отдаёт последнее закэшированное значение, а не 500. Размыкатель цепи вокруг репозитория перестаёт ходить в лежащий кластер после серии ошибок.
Запись: если события идут через Kafka, потребитель просто останавливается, offset не подтверждается, и после подъёма ClickHouse всё доедет; это главный аргумент за очередь между сервисом и аналитикой. Если вставка идёт из сервиса напрямую, её либо оборачивают в тот же outbox, что и для PostgreSQL, либо принимают потерю событий за время инцидента и пишут об этом в документации к отчётам. Смешанный вариант, «пишем напрямую, а при ошибке в очередь», хуже обоих: порядок событий ломается, а дедупликация усложняется.
Коротко
- Драйвер
com.clickhouse:clickhouse-jdbcходит по HTTP (порт 8123); ClickHouse подключают вторымDataSource, основной PostgreSQL и@Transactionalне затрагиваются. - Напрямую из обработчика не пишут: двойная запись ненадёжна, а одиночные INSERT-ы дробят таблицу на куски. Надёжный путь — PostgreSQL → outbox → Kafka → пакетный потребитель → ClickHouse; для зеркала таблиц вместо outbox берут CDC через Debezium.
- Для небольших потоков — async insert на стороне сервера; для данных с деньгами — только Kafka. Пачка при этом — десятки тысяч строк или несколько секунд: меньше даёт лавину кусков, больше — задержку и память.
- Идемпотентность: дедупликация блоков ловит повтор той же пачки (поэтому временную ошибку вставки просто повторяют),
ReplacingMergeTreeпоevent_id— дубль из другой пачки, а постоянную ошибку лечат делением пачки и очередью недоставленных сообщений. - Чтение — отдельный репозиторий с явными DTO; SQL пишут руками, кодогенерация не нужна.
- Тесты — Testcontainers + реальный DDL; для
ReplacingMergeTreeслияние форсируют перед проверкой. - Асинхронная вставка складывает строки в серверный буфер (10 МБ или 200 мс) и подходит потоку от многих источников без Kafka;
wait_for_async_insert = 1не теряет подтверждённое, дедупликация включается отдельно. - ClickHouse лёг: свой короткий таймаут и маленький пул у аналитического репозитория, отчёт деградирует до «недоступно», запись переживает инцидент через Kafka или outbox.
- Типы требуют внимания к масштабу
Decimalи поясуDateTime64;LowCardinalityдля драйвера просто строка, аNullableв аналитике стоит места. - Миграции схемы ведут своим порядком (нумерованные файлы, таблица применённых,
ON CLUSTER), а удаления из захвата изменений выражают колонкой-признаком в движке со сворачиванием версий.
Что почитать дальше
- Моделирование и запросы — схемы таблиц, в которые этот пайплайн пишет.
- Эксплуатация — что мониторить у пайплайна со стороны ClickHouse.
- Распределённые паттерны — outbox, идемпотентность и итоговая согласованность в общем виде.
- Kafka в production — потребители, накопление событий, очереди недоставленных сообщений.