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

Допустим, вы уже знаете, зачем нужен ClickHouse: быстрая аналитика, агрегации по миллионам строк, которые PostgreSQL считает десятки секунд. Теперь вопрос практический: как его подключить к Spring Boot-сервису, как туда попадают данные и как потом их читать?

потребитель копит события и вставляет их в ClickHouse одной пачкой order-events e1 e2 e3 e4 e5 e6 читает буфер потребителя e1 e2 e3 e4 e5 e6 вставка вставка дубль таблица order_events кусок 1e1, e2, e3 кусок 2e4, e5, e6 e4e5e6сбой до подтверждения offset:потребитель перечитал ту же пачку хеш блока уже был — третьего куска нет

Одиночные вставки дробят таблицу на мелкие куски, и движок не успевает их сливать. Потребитель копит события и отправляет одну пачку — получается один кусок. Если после сбоя та же пачка придёт снова, 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

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 снимает изменения с уровня базы данных.

outbox PostgreSQL таблица outbox Kafka ClickHouse CDC PostgreSQL журнал WAL Kafka ClickHouse

Оба пути ведут из 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), а удаления из захвата изменений выражают колонкой-признаком в движке со сворачиванием версий.

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