FinCodeCow · Глава V

Механика потоковой обработки

От бессостоятельных топологий до состояния, окон и Interactive Queries

Если предыдущие главы были посвящены тому, что такое события и как они хранятся в брокере, то эта глава переносит нас внутрь «кухни» микросервиса. Здесь мы разберём, как именно приложение принимает поток сырых фактов, применяет к ним бизнес-логику и выдаёт результат. На первый взгляд процесс кажется тривиальным циклом, но под капотом скрывается элегантная механика маршрутизации, которая делает возможной масштабируемую обработку больших данных.

Это пятая глава цикла о событийно-управляемой архитектуре. Первая отвечала на вопрос «почему», вторая — «как устроено под капотом», третья — «как проектировать контракты», четвёртая — «как подключить legacy». Теперь мы переходим к вычислительному ядру событийной архитектуры.

• • •

IБазовый ритм: потребить → обработать → произвести

Любой событийно-управляемый микросервис, работающий с потоками, живёт по простому, но строгому ритму:

  1. Потребить событие из входного потока.
  2. Обработать его — применить бизнес-логику или трансформацию.
  3. Произвести ноль, одно или несколько выходных событий.

Точкой входа в этот процесс является топология микросервиса — граф операций, управляемых данными. Именно топология диктует, какие фильтры применять, как маршрутизировать данные и когда вызывать агрегации.

В Kafka Streams топология описывается декларативно через DSL:

KStream<String, OrderEvent> orders = builder.stream("orders");

orders
    .filter((key, value) -> value.getAmount() > 100)
    .selectKey((key, value) -> value.getCustomerId())
    .groupByKey()
    .count()
    .toStream()
    .to("customer-order-counts");

Этот код строит граф: чтение → фильтр → смена ключа → перегруппировка → агрегация → запись. Каждая операция — узел в топологии.

• • •

IIБессостоятельные топологии: конвейер трансформаций

Самые простые микросервисы не хранят состояние в памяти. Они обрабатывают каждое событие изолированно, как на конвейере.

Filter

Пропускает событие дальше только если оно соответствует критерию. Создаёт 0 или 1 событие на выходе.

Map

Изменяет ключ и/или значение события, создавая ровно одно новое событие. Может вызвать переподразделение.

mapValues

Изменяет только значение, не трогая ключ. Не вызывает переподразделения — критично для производительности.

Branching

Направляет события в разные выходные потоки на основе их свойств — VIP-клиенты в один поток, остальные в другой.

Merging

Объединяет несколько входных потоков в один логический поток для совместной обработки.

FlatMap

Преобразует одно событие в ноль, одно или несколько. Полезно для разбиения одного события на несколько.

Эти операции легковесны и легко параллелятся, но они не могут ответить на вопросы, требующие знания прошлого: «Сколько раз этот пользователь кликнул за последний час?» Для таких задач нужна локальность данных.

• • •

IIIKStream vs KTable: поток фактов против таблицы состояния

Прежде чем идти дальше, нужно явно разграничить два фундаментальных понятия Kafka Streams, которые часто путают.

KStream — поток фактов

Каждая запись независима. OrderPlaced, затем OrderPaid — это два разных факта, оба важны. Ничего не удаляется. Это история.

KTable — таблица состояния

Каждая запись — обновление по ключу. CustomerUpdated с тем же ID перезаписывает предыдущее состояние. Только последнее значение имеет смысл.

Практическое следствие: KStream-KTable JOIN — это способ обогатить поток фактов текущим состоянием справочника. Например, каждое событие OrderPlaced можно обогатить именем клиента из KTable customers.

Правило преобразования

KStream можно превратить в KTable через toTable(), а KTable — в KStream через toStream(). Это две формы одних и тех же данных — то же самое table-stream duality, которое обсуждалось в главе II.

• • •

IVПереподразделение: искусство локальности данных

В мире распределённых систем данные часто приходят в брокер событий, партиционированные по одному ключу (например, по deviceId). Но если бизнес-логика требует агрегации по другому ключу (например, по userId), данные нужно перегруппировать.

Проблема: разорванные данные. Если события одного пользователя разбросаны по разным партициям брокера, разные экземпляры микросервиса будут обрабатывать их параллельно. Невозможно точно подсчитать сумму покупок или собрать сеанс активности, не собрав все события в одну «кучу».

Решение: переподразделение (Repartitioning). Микросервис создаёт новый внутренний поток событий, применяя новый алгоритм хеширования ключей. Теперь все события, относящиеся к одному пользователю, гарантированно попадают в один и тот же раздел и обрабатываются одним экземпляром потребителя.

В Kafka Streams переподразделение происходит автоматически при операциях selectKey, map (если ключ изменился) и перед groupByKey, aggregate, join.

Что происходит физически: Kafka Streams создаёт внутренний Kafka-топик с именем вида applicationId-<name>-repartition. Данные записываются в этот топик с новым ключом и читаются обратно. Это не in-memory операция — это полноценный round-trip через брокер.

Стоимость переподразделения

Каждый repartition удваивает трафик: запись + чтение. На практике один pipeline с тремя repartition-точками может увеличить общий трафик в 4–5 раз. Реальный пример из production: входной поток 24k msg/s и 16 MB/s превратился в 112k msg/s и 152 MB/s после трёх repartition-топиков.

Оптимизация. Если несколько stateful-операций работают с одним и тем же ключом, достаточно одного repartition. Явный вызов repartition() после selectKey объединяет все последующие stateful-операции на одном переподразделении:

// Один repartition вместо двух
KStream<String, String> repartitioned = inputStream
    .selectKey((k, v) -> v.substring(0, 5))
    .repartition();  // явный repartition

repartitioned.groupByKey().count();
repartitioned.groupByKey().windowedBy(...).count();
• • •

VСоподразделение и GlobalKTable

Представьте, что нужно соединить (сделать JOIN) поток кликов пользователей с потоком их профилей. Чтобы эта операция была эффективной и не требовала сетевых вызовов между разными серверами, оба потока должны быть соподразделены (co-partitioned).

Соподразделение означает, что два или более потоков событий:

  • Имеют одинаковое количество партиций.
  • Используют один и тот же алгоритм распределения ключей по партициям.

Благодаря этому контроллер гарантирует, что данные с одинаковым ключом из разных потоков будут назначены одному и тому же экземпляру микросервиса.

Критическое предупреждение

Kafka Streams не проверяет соподразделение автоматически. Если партиции не совпадают, JOIN будет работать, но выдаст неполные результаты — тихо и без ошибок.

GlobalKTable: JOIN без соподразделения

Не всегда соподразделение — единственный путь. GlobalKTable — это таблица, которая полностью реплицируется на каждый экземпляр приложения.

  • Не требует соподразделения — каждый инстанс имеет полную копию таблицы.
  • Позволяет JOIN по произвольному полю через KeyValueMapper, а не только по ключу потока.
  • Не синхронизирована по времени — нет event-time выравнивания между потоком и таблицей.

Когда использовать: справочники (страны, валюты, категории), конфигурации, справочные данные, которые помещаются в память каждого инстанса.

Когда НЕ использовать: большие таблицы (репликация на каждый инстанс = расход памяти × N), данные с интенсивными обновлениями.

Тип JOIN Соподразделение По ключу По произвольному полю Event-time синхронизация
KStream-KStream Да Да Нет Да (windowed)
KStream-KTable Да Да Нет Да
KTable-KTable Да (или FK join) Да Да (через FK) Да
KStream-GlobalKTable Нет Нет Да Нет
• • •

VIState stores и changelog: локальность становится состоянием

Переподразделение даёт локальность данных. Но чтобы агрегировать, считать окна и делать JOIN, микросервису нужно хранить состояние. И хранить его не в внешней БД (это сеть + задержка), а локально.

State Store — локальное key-value хранилище, которое живёт на диске каждого инстанса. В Kafka Streams по умолчанию используется RocksDB — embedded LSM-tree движок, написанный в Facebook.

Почему RocksDB

Нет сети в hot path. Сотни тысяч puts/sec на инстанс. Размер не ограничен heap — состояние на диске. Долговечность через WAL внутри RocksDB.

Цифра от Bloomberg

В 2017 году Bloomberg сообщил о 30-кратном снижении задержки после перехода на embedded RocksDB вместо внешней БД.

Но локальный диск — это точка отказа. Если под упал, состояние исчезло. Решение — changelog topic: для каждого state store Kafka Streams автоматически создаёт компактный Kafka-топик, в который асинхронно пишется каждое изменение состояния.

Input Topic → Processor → State Store (RocksDB, локальный диск)
                    ↓
              Changelog Topic (Kafka, реплицируемый)

При отказе инстанса новый инстанс читает changelog с начала и восстанавливает RocksDB. Compaction гарантирует, что changelog хранит только последнее значение для каждого ключа, поэтому объём восстановления пропорционален размеру состояния, а не количеству обработанных событий.

Практическое предупреждение

State store ≠ бесконечное хранилище. RocksDB пишет на локальный диск, и если состояние растёт (миллионы ключей), диск может переполниться. Мониторьте размер state store и используйте retention для оконных агрегаций.

• • •

VIIОконные функции: время как измерение

Агрегации без окон — это бегущий счётчик. Но бизнес спрашивает: «Сколько заказов за последний час?» или «Был ли всплеск активности за 5 минут?». Для этого нужны окна.

Тип окна Перекрытие Размер Когда использовать
Tumbling Нет Фиксированный «Заказов за каждый час» — чистые непересекающиеся интервалы
Hopping Да Фиксированный, шаг < размера «15-минутный rolling count, обновление каждую минуту» — сглаженные метрики
Sliding Да Привязан к событиям «События в пределах 5 минут друг от друга» — корреляция
Session Динамическое Зависит от активности «Сессия пользователя до 30 минут бездействия» — разрывная активность

Tumbling — простейший. Каждое событие попадает ровно в одно окно. Окна не пересекаются. Идеально для периодических отчётов: «выручка за каждый час».

Hopping — окно фиксированного размера, но сдвигается с меньшим шагом. Событие может попасть в несколько окон. Используется для rolling-метрик. Цена — больше состояния и больше выходных событий.

Sliding — окно привязано не к фиксированной сетке, а к временным меткам самих событий. Два события агрегируются вместе, если их метки в пределах размера окна. В Kafka Streams используются только для JOIN-операций.

Session — окно растёт, пока события продолжают приходить, и закрывается после заданного периода бездействия. Сессии для одного ключа могут сливаться. Естественный выбор для пользовательских сессий.

Пример оконной агрегации в Kafka Streams DSL:

KTable<Windowed<String>, Long> hourlyCounts = stream
    .groupByKey()
    .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
    .count();
• • •

VIIIEvent time vs processing time

Все оконные функции опираются на время. Но чьё время?

Processing time

Время, когда событие обрабатывается микросервисом. Зависит от загрузки, задержек сети, rebalance. Нестабильно.

Event time

Время, когда событие произошло в реальном мире (timestamp в payload). Не зависит от инфраструктуры. Детерминировано.

В Kafka Streams оконные операции по умолчанию используют event time — временную метку записи в Kafka. Это критично: если событие задержалось в брокере на 10 минут, оно всё равно попадёт в правильное окно, а не в «текущее».

Grace period — окно, которое позволяет принимать «опоздавшие» события после формального закрытия окна. Например, окно 5 минут с grace 1 минуту принимает события с event-time в пределах окна до 1 минуты после его закрытия. Без grace окно закрывается жёстко, и опоздавшие события отбрасываются.

Практическое правило

Для финансовых систем — event time + grace period. Для метрик мониторинга — processing time. Смешение двух подходов в одной топологии — источник тихих ошибок.

• • •

IXExactly-once v1 и v2 в потоковой обработке

Stateless обработка тривиально идемпотентна: повторное событие даёт тот же результат. Но stateful обработка с состоянием — нет. Если событие обработано, состояние обновлено, но offset не закоммичен — при рестарте событие обработается повторно, и агрегат будет неверным.

Kafka Streams решает это через exactly-once. Существуют две версии:

Версия Как работает Особенности
exactly_once (v1) Одна транзакция на каждую запись, через transactional.id Высокий overhead, ограничения на количество producer-ов, «дыры» при рестарте
exactly_once_v2 Транзакция объединяет запись в state store, публикацию и коммит offset Меньше транзакций, лучшая пропускная способность, рекомендуется с Kafka 3.0+

Включается одной настройкой: processing.guarantee=exactly_once_v2.

Цена: дополнительные накладные расходы на транзакции (тяжелее commit, больше задержка). Для большинства business-critical приложений это оправданная плата.

Ограничение: для внешних систем (БД, HTTP-вызовы) EOS Kafka не работает — нужна идемпотентность на стороне потребителя, как обсуждалось в главе II.

Взаимодействие со standby replicas: при использовании EOS standby replicas читают changelog без транзакций — это допускается, так как standby только восстанавливают состояние, а не публикуют выходные события.

• • •

XВосстановление после отказов: stateless и stateful

Бессостоятельный режим. Для микросервисов без поддержки состояния отказ экземпляра — не трагедия. Новый инстанс просто присоединяется к группе потребителей, получает свою долю партиций и возобновляет чтение с последнего закоммиченного offset. Время восстановления — секунды.

Stateful режим. Для stateful микросервисов восстановление сложнее. Новый инстанс должен перестроить локальный state store из changelog topic.

Стратегия Время восстановления Покрывает loss региона Стоимость
Changelog replay Минуты–часы Да Базовая
Standby replicas Секунды (5–30 с) Нет Двойной диск на состояние
Backup + restore Минуты Да Дополнительное хранилище

Standby replicas — горячий резерв. Настройка num.standby.replicas=1 означает, что для каждой активной задачи существует одна резервная, которая непрерывно читает changelog и поддерживает актуальную копию состояния. При отказе активного инстанса standby мгновенно становится активным — без полного replay.

• • •

XIРаспределение партиций и rebalance

Кто решает, какой экземпляр микросервиса будет читать какую партицию? Этим занимается контроллер разделов, встроенный в клиентскую библиотеку.

Стратегии назначения:

  1. Round-robin — партиции раздаются по очереди. Отлично для stateless-обработчиков.
  2. Sticky — партиции закрепляются за инстансом и не перераспределяются без необходимости. Критично для stateful: избегает дорогостоящей пересылки state store.
  3. Cooperative sticky assignor — минимизирует disruption при rebalance: перераспределяются только изменившиеся партиции, остальные продолжают обработку без паузы.

Static group membership (group.instance.id) — потребитель при рестарте получает те же партиции без rebalance вообще. Это важно для stateful приложений: нет rebalance → нет перераспределения state store → нет простоя.

• • •

XIIInteractive Queries: чтение состояния извне

State store — это не только внутренняя механика для агрегаций. Kafka Streams позволяет читать состояние извне — через REST API вашего приложения.

Пример: сервис рекомендаций материализует таблицу «топ-10 категорий по частоте покупок для каждого клиента» в state store. Внешний клиент может запросить эту таблицу через REST:

@GetMapping("/customers/{id}/top-categories")
public List<Category> getTopCategories(@PathVariable String id) {
    ReadOnlyKeyValueStore<String, List<Category>> store =
        streams.store(StoreQueryParameters.fromNameAndType(
            "top-categories-store",
            QueryableStoreTypes.keyValueStore()));
    return store.get(id);
}

Это даёт:

  • CQRS без внешней БД. Read model живёт прямо в state store.
  • Миллисекундный отклик. Нет сети между приложением и хранилищем.
  • Автоматическую актуализацию. Состояние обновляется по мере поступления событий.
Ограничение Interactive Queries

State store локален для каждого инстанса. Если запрос приходит на инстанс, у которого нет нужного ключа, придётся маршрутизировать запрос к правильному инстансу через metadata service или использовать global state store.

• • •

XIIIPunctuator API: периодические действия

Kafka Streams предоставляет Punctuator API — механизм для периодических действий, не привязанных к поступлению событий.

Пример: отправить heartbeat раз в минуту, даже если события не приходят:

builder.schedule(
    Duration.ofMinutes(1),
    PunctuationType.WALL_CLOCK_TIME,
    timestamp -> {
        // отправка heartbeat
    });

Punctuator бывает двух типов:

  • WALL_CLOCK_TIME — реальное время, не зависит от событий.
  • STREAM_TIME — event time, срабатывает только при поступлении новых событий.

Типичные сценарии: таймауты сессий, отправка метрик, flush state store, эмиссия агрегатов по расписанию.

• • •

XIVТестирование топологий: TopologyTestDriver

Kafka Streams предоставляет TopologyTestDriver — механизм для тестирования топологий без реального брокера.

TopologyTestDriver driver = new TopologyTestDriver(topology, config);

TestInputTopic<String, OrderEvent> input = driver.createInputTopic(
    "orders", new StringSerializer(), new OrderEventSerializer());

TestOutputTopic<String, Long> output = driver.createOutputTopic(
    "customer-order-counts", new StringDeserializer(), new LongDeserializer());

input.pipeInput("order-1", new OrderEvent("cust-42", 150.0));

assertThat(output.readValueToMap()).containsEntry("cust-42", 1L);

driver.close();

Это критично для CI/CD:

  • Тесты работают в миллисекундах, без Docker.
  • Можно проверить всю топологию end-to-end: вход → обработка → состояние → выход.
  • Проверка оконных агрегаций с продвижением event time вручную.
• • •

XVИнструменты: Kafka Streams vs Flink vs custom consumer

Критерий Kafka Streams Apache Flink Custom Consumer
State management Embedded RocksDB + changelog Configurable backends (memory, RocksDB, S3) Ручная реализация
Exactly-once exactly_once_v2 Checkpointing + snapshot barrier Ручная идемпотентность
Windowing Tumbling, hopping, sliding, session Те же + advanced (allowed lateness, side outputs) Ручная реализация
Event time / watermarks Ограниченная поддержка Полная: watermarks, late events, side outputs Ручная реализация
Savepoints Нет (есть state backups) Да — обновление job без потери состояния Нет
Сложность Низкая (library, не кластер) Высокая (отдельный кластер, JobManager) Зависит от задачи
Лучше для Kafka-to-Kafka pipelines Сложная аналитика, multi-source, ML Простые конвейеры, специфичные требования

Правило выбора: Kafka Streams — если вход и выход в Kafka и нужна простота деплоя. Flink — если нужна сложная аналитика, multi-source потоки, продвинутые watermarks или ML-пайплайны. Custom consumer — для простых stateless конвейеров, где overhead фреймворка не оправдан.

• • •

XVIЧеклист и связь с циклом

Связь с предыдущими главами

Глава Фокус Связь с текущей главой
I. Среда формирует архитектуру Почему EDA и как это меняет организации Топология микросервиса отражает bounded context команды
II. Анатомия EDA Топологии, брокеры, идемпотентность Table-stream duality, партиционирование, consumer groups — фундамент
III. Анатомия идеального события Контракты и Schema Registry Схемы событий определяют топологию обработки
IV. Data Liberation Legacy-интеграция и событизация Kafka Streams — инструмент событизации raw CDC в canonical events
V. Механика потоковой обработки State, windows, EOS Вычислительное ядро событийной архитектуры

Чеклист перед публикацией топологии в production

  • Партиции входного и выходного топиков согласованы для всех JOIN
  • Co-partitioning проверен вручную (Kafka Streams не проверяет автоматически)
  • Явные repartition() добавлены для объединения stateful-операций
  • State store мониторится по размеру и количеству ключей
  • Standby replicas настроены для stateful-задач
  • Exactly-once выбран осознанно (exactly_once_v2)
  • Rebalance-стратегия — cooperative sticky assignor
  • Static group membership (group.instance.id) для stateful
  • Grace period настроен для оконных операций с event time
  • TopologyTestDriver используется в CI для проверки топологии
  • Interactive Queries настроены, если нужен внешний доступ к состоянию
  • Punctuator добавлен для периодических задач (heartbeat, flush)
Финальный тезис

Переподразделение и соподразделение — это не просто технические приёмы, а способы физического упорядочивания хаотичного потока фактов в структурированные, локализованные наборы данных. Именно эти механизмы позволяют перейти от простых фильтров к сложным агрегациям, оконным функциям и JOIN-ам, которые делают событийную архитектуру не просто «трубой для данных», а полноценной вычислительной платформой.

Глава V цикла «Событийно-управляемая архитектура». Источники: Confluent, Apache Kafka, Baeldung, Conduktor, Habr, Mind Forge, Kafka Backup, RisingWave, практические кейсы production-систем.

FinCodeCow · Chapter V

The Mechanics of Stream Processing

From stateless topologies to state, windows, and Interactive Queries

If previous chapters were dedicated to what events are and how they are stored in a broker, this chapter takes us inside the microservice "kitchen." Here we will explore how exactly an application receives a stream of raw facts, applies business logic, and produces a result. At first glance, the process seems like a trivial cycle, but under the hood there is an elegant routing mechanics that makes scalable processing of big data possible.

This is the fifth chapter of the event-driven architecture series. The first answered "why," the second — "how it works under the hood," the third — "how to design contracts," the fourth — "how to connect legacy." Now we move to the computational core of event-driven architecture.

• • •

IThe Basic Rhythm: Consume → Process → Produce

Any event-driven microservice working with streams lives by a simple but strict rhythm:

  1. Consume an event from the input stream.
  2. Process it — apply business logic or transformation.
  3. Produce zero, one, or more output events.

The entry point to this process is the microservice topology — a graph of data-driven operations. The topology dictates which filters to apply, how to route data, and when to invoke aggregations.

In Kafka Streams, topology is described declaratively through the DSL:

KStream<String, OrderEvent> orders = builder.stream("orders");

orders
    .filter((key, value) -> value.getAmount() > 100)
    .selectKey((key, value) -> value.getCustomerId())
    .groupByKey()
    .count()
    .toStream()
    .to("customer-order-counts");

This code builds a graph: read → filter → change key → regroup → aggregate → write. Each operation is a node in the topology.

• • •

IIStateless Topologies: A Conveyor of Transformations

The simplest microservices don't store state in memory. They process each event in isolation, like on a conveyor.

Filter

Passes an event forward only if it matches a criterion. Creates 0 or 1 event on output.

Map

Changes the key and/or value of an event, creating exactly one new event. May trigger repartitioning.

mapValues

Changes only the value, not the key. Does not trigger repartitioning — critical for performance.

Branching

Routes events to different output streams based on their properties — VIP customers to one stream, others to another.

Merging

Combines multiple input streams into one logical stream for joint processing.

FlatMap

Transforms one event into zero, one, or several. Useful for splitting one event into several.

These operations are lightweight and easily parallelized, but they cannot answer questions requiring knowledge of the past: "How many times did this user click in the last hour?" For such tasks, data locality is needed.

• • •

IIIKStream vs KTable: Stream of Facts vs State Table

Before going further, we need to explicitly distinguish two fundamental Kafka Streams concepts that are often confused.

KStream — Stream of Facts

Each record is independent. OrderPlaced, then OrderPaid — two different facts, both important. Nothing is deleted. It is history.

KTable — State Table

Each record is an update by key. CustomerUpdated with the same ID overwrites the previous state. Only the last value matters.

Practical consequence: KStream-KTable JOIN is a way to enrich a stream of facts with the current state of a reference. For example, each OrderPlaced event can be enriched with the customer name from the KTable customers.

Conversion Rule

KStream can be turned into KTable via toTable(), and KTable into KStream via toStream(). They are two forms of the same data — the same table-stream duality discussed in Chapter II.

• • •

IVRepartitioning: The Art of Data Locality

In distributed systems, data often arrives in the event broker partitioned by one key (e.g., deviceId). But if business logic requires aggregation by another key (e.g., userId), the data must be regrouped.

Problem: fragmented data. If events of one user are scattered across different broker partitions, different microservice instances process them in parallel. It is impossible to accurately count purchases or assemble an activity session without gathering all events into one "heap."

Solution: repartitioning. The microservice creates a new internal event stream, applying a new key hashing algorithm. Now all events related to one user are guaranteed to land in the same partition and be processed by one consumer instance.

In Kafka Streams, repartitioning happens automatically on selectKey, map (if the key changed) and before groupByKey, aggregate, join.

What happens physically: Kafka Streams creates an internal Kafka topic named applicationId-<name>-repartition. Data is written to this topic with the new key and read back. This is not an in-memory operation — it is a full round-trip through the broker.

The Cost of Repartitioning

Each repartition doubles traffic: write + read. In practice, one pipeline with three repartition points can increase total traffic 4–5×. A real production example: input stream 24k msg/s and 16 MB/s became 112k msg/s and 152 MB/s after three repartition topics.

Optimization. If several stateful operations work with the same key, one repartition is enough. An explicit repartition() after selectKey combines all subsequent stateful operations on one repartition:

// One repartition instead of two
KStream<String, String> repartitioned = inputStream
    .selectKey((k, v) -> v.substring(0, 5))
    .repartition();  // explicit repartition

repartitioned.groupByKey().count();
repartitioned.groupByKey().windowedBy(...).count();
• • •

VCo-partitioning and GlobalKTable

Imagine you need to JOIN a stream of user clicks with a stream of their profiles. For this operation to be efficient and require no network calls between servers, both streams must be co-partitioned.

Co-partitioning means two or more event streams:

  • Have the same number of partitions.
  • Use the same algorithm for distributing keys across partitions.

Thanks to this, the controller guarantees that data with the same key from different streams is assigned to the same microservice instance.

Critical Warning

Kafka Streams does not check co-partitioning automatically. If partitions don't match, the JOIN will work but produce incomplete results — silently and without errors.

GlobalKTable: JOIN Without Co-partitioning

Co-partitioning is not always the only path. GlobalKTable is a table fully replicated to every application instance.

  • No co-partitioning required — each instance has a full copy of the table.
  • Allows JOIN by arbitrary field via KeyValueMapper, not just by stream key.
  • No time synchronization — no event-time alignment between stream and table.

When to use: reference data (countries, currencies, categories), configurations, reference data that fits in each instance's memory.

When NOT to use: large tables (replication to every instance = memory × N), data with intensive updates.

JOIN Type Co-partitioning By Key By Arbitrary Field Event-time Sync
KStream-KStream Yes Yes No Yes (windowed)
KStream-KTable Yes Yes No Yes
KTable-KTable Yes (or FK join) Yes Yes (via FK) Yes
KStream-GlobalKTable No No Yes No
• • •

VIState Stores and Changelog: Locality Becomes State

Repartitioning provides data locality. But to aggregate, count windows, and JOIN, a microservice needs to store state. And store it locally, not in an external DB (that would mean network + latency).

State Store — a local key-value store living on each instance's disk. Kafka Streams uses RocksDB by default — an embedded LSM-tree engine written at Facebook.

Why RocksDB

No network in hot path. Hundreds of thousands of puts/sec per instance. Size not limited by heap — state lives on disk. Durability through WAL inside RocksDB.

The Bloomberg Number

In 2017, Bloomberg reported a 30× reduction in latency after switching to embedded RocksDB instead of an external DB.

But local disk is a point of failure. If the pod crashes, state is gone. Solution — changelog topic: for each state store, Kafka Streams automatically creates a compacted Kafka topic where every state change is asynchronously written.

Input Topic → Processor → State Store (RocksDB, local disk)
                    ↓
              Changelog Topic (Kafka, replicated)

When an instance fails, the new instance reads the changelog from the beginning and restores RocksDB. Compaction guarantees the changelog keeps only the latest value per key, so restoration volume is proportional to state size, not to the number of processed events.

Practical Warning

State store ≠ infinite storage. RocksDB writes to local disk, and if state grows (millions of keys), the disk can overflow. Monitor state store size and use retention for windowed aggregations.

• • •

VIIWindowing Functions: Time as a Dimension

Aggregations without windows are just a running counter. But business asks: "How many orders in the last hour?" or "Was there a spike in 5 minutes?" For this, we need windows.

Window Type Overlap Size When to Use
Tumbling No Fixed "Orders per hour" — clean non-overlapping intervals
Hopping Yes Fixed, step < size "15-min rolling count, update every minute" — smoothed metrics
Sliding Yes Event-bound "Events within 5 minutes of each other" — correlation
Session Dynamic Activity-dependent "User session up to 30 min of inactivity" — bursty activity

Tumbling — simplest. Each event lands in exactly one window. Windows don't overlap. Ideal for periodic reports: "revenue per hour."

Hopping — fixed-size window but slides with a smaller step. An event can land in several windows. Used for rolling metrics. Cost — more state and more output events.

Sliding — window bound not to a fixed grid but to event timestamps. Two events aggregate together if their timestamps are within the window size. In Kafka Streams, used only for JOIN operations.

Session — window grows while events keep coming, closes after a specified idle period. Sessions for one key may merge. Natural choice for user sessions.

Example of windowed aggregation in Kafka Streams DSL:

KTable<Windowed<String>, Long> hourlyCounts = stream
    .groupByKey()
    .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
    .count();
• • •

VIIIEvent Time vs Processing Time

All windowing functions rely on time. But whose time?

Processing Time

The time when the event is processed by the microservice. Depends on load, network delays, rebalance. Unstable.

Event Time

The time when the event occurred in the real world (timestamp in payload). Independent of infrastructure. Deterministic.

In Kafka Streams, windowed operations use event time by default — the timestamp of the record in Kafka. This is critical: if an event was delayed in the broker by 10 minutes, it will still fall into the correct window, not the "current" one.

Grace period — a window that allows accepting "late" events after the formal window closure. For example, a 5-minute window with 1-minute grace accepts events with event-time within the window up to 1 minute after its closure. Without grace, the window closes strictly and late events are dropped.

Practical Rule

For financial systems — event time + grace period. For monitoring metrics — processing time. Mixing both approaches in one topology is a source of silent errors.

• • •

IXExactly-once v1 and v2 in Stream Processing

Stateless processing is trivially idempotent: replaying an event yields the same result. But stateful processing is not. If an event is processed, state is updated, but the offset is not committed — on restart the event is processed again, and the aggregate becomes wrong.

Kafka Streams solves this via exactly-once. There are two versions:

Version How It Works Features
exactly_once (v1) One transaction per record, via transactional.id High overhead, limits on producers, "holes" on restart
exactly_once_v2 A transaction combines state store write, publication, and offset commit Fewer transactions, better throughput, recommended with Kafka 3.0+

Enabled with a single setting: processing.guarantee=exactly_once_v2.

Cost: additional transaction overhead (heavier commit, more latency). For most business-critical applications, this is a justified price.

Limitation: for external systems (DB, HTTP calls), Kafka EOS doesn't work — idempotency on the consumer side is required, as discussed in Chapter II.

Interaction with standby replicas: when using EOS, standby replicas read the changelog without transactions — this is allowed, since standbys only restore state and don't publish output events.

• • •

XRecovery from Failures: Stateless and Stateful

Stateless mode. For microservices without state, instance failure is not a tragedy. A new instance just joins the consumer group, gets its share of partitions, and resumes reading from the last committed offset. Recovery time — seconds.

Stateful mode. For stateful microservices, recovery is harder. A new instance must rebuild the local state store from the changelog topic.

Strategy Recovery Time Covers Region Loss Cost
Changelog replay Minutes–hours Yes Base
Standby replicas Seconds (5–30 s) No Double disk for state
Backup + restore Minutes Yes Additional storage

Standby replicas — a hot reserve. Setting num.standby.replicas=1 means for each active task there is one standby that continuously reads the changelog and maintains a current copy of state. When an active instance fails, the standby instantly becomes active — without a full replay.

• • •

XIPartition Assignment and Rebalance

Who decides which microservice instance reads which partition? This is handled by the partition controller built into the client library.

Assignment strategies:

  1. Round-robin — partitions are distributed in turn. Great for stateless processors.
  2. Sticky — partitions stick to the instance and are not redistributed without need. Critical for stateful: avoids costly state store transfer.
  3. Cooperative sticky assignor — minimizes disruption during rebalance: only changed partitions are redistributed, the rest continue processing without a pause.

Static group membership (group.instance.id) — a consumer gets the same partitions back on restart without any rebalance. This matters for stateful applications: no rebalance → no state store redistribution → no downtime.

• • •

XIIInteractive Queries: Reading State from Outside

A state store is not only internal mechanics for aggregations. Kafka Streams allows reading state from outside — via your application's REST API.

Example: a recommendation service materializes a "top-10 categories by purchase frequency for each customer" table in a state store. An external client can query this table via REST:

@GetMapping("/customers/{id}/top-categories")
public List<Category> getTopCategories(@PathVariable String id) {
    ReadOnlyKeyValueStore<String, List<Category>> store =
        streams.store(StoreQueryParameters.fromNameAndType(
            "top-categories-store",
            QueryableStoreTypes.keyValueStore()));
    return store.get(id);
}

This gives:

  • CQRS without an external DB. The read model lives right in the state store.
  • Millisecond response. No network between application and storage.
  • Automatic actualization. State updates as events arrive.
Interactive Queries Limitation

The state store is local to each instance. If a request arrives at an instance that doesn't have the needed key, you have to route the request to the correct instance via a metadata service or use a global state store.

• • •

XIIIPunctuator API: Periodic Actions

Kafka Streams provides the Punctuator API — a mechanism for periodic actions not tied to event arrival.

Example: send a heartbeat every minute even if no events arrive:

builder.schedule(
    Duration.ofMinutes(1),
    PunctuationType.WALL_CLOCK_TIME,
    timestamp -> {
        // send heartbeat
    });

Punctuator comes in two types:

  • WALL_CLOCK_TIME — real time, independent of events.
  • STREAM_TIME — event time, fires only when new events arrive.

Typical scenarios: session timeouts, sending metrics, flushing state store, emitting aggregates on schedule.

• • •

XIVTesting Topologies: TopologyTestDriver

Kafka Streams provides TopologyTestDriver — a mechanism for testing topologies without a real broker.

TopologyTestDriver driver = new TopologyTestDriver(topology, config);

TestInputTopic<String, OrderEvent> input = driver.createInputTopic(
    "orders", new StringSerializer(), new OrderEventSerializer());

TestOutputTopic<String, Long> output = driver.createOutputTopic(
    "customer-order-counts", new StringDeserializer(), new LongDeserializer());

input.pipeInput("order-1", new OrderEvent("cust-42", 150.0));

assertThat(output.readValueToMap()).containsEntry("cust-42", 1L);

driver.close();

This is critical for CI/CD:

  • Tests run in milliseconds, no Docker.
  • You can verify the whole topology end-to-end: input → processing → state → output.
  • Verification of windowed aggregations with manual advancement of event time.
• • •

XVTools: Kafka Streams vs Flink vs Custom Consumer

Criterion Kafka Streams Apache Flink Custom Consumer
State management Embedded RocksDB + changelog Configurable backends (memory, RocksDB, S3) Manual
Exactly-once exactly_once_v2 Checkpointing + snapshot barrier Manual idempotency
Windowing Tumbling, hopping, sliding, session Same + advanced (allowed lateness, side outputs) Manual
Event time / watermarks Limited support Full: watermarks, late events, side outputs Manual
Savepoints No (state backups only) Yes — job update without state loss No
Complexity Low (library, not a cluster) High (separate cluster, JobManager) Depends on the task
Best for Kafka-to-Kafka pipelines Complex analytics, multi-source, ML Simple pipelines, specific requirements

Selection rule: Kafka Streams — if input and output are in Kafka and deployment simplicity matters. Flink — if complex analytics, multi-source streams, advanced watermarks or ML pipelines are needed. Custom consumer — for simple stateless pipelines where framework overhead is unjustified.

• • •

XVIChecklist and Link to the Series

Link to Previous Chapters

Chapter Focus Link to This Chapter
I. The Medium Shapes the Architecture Why EDA and how it changes organizations Microservice topology reflects the team's bounded context
II. The Anatomy of EDA Topologies, brokers, idempotency Table-stream duality, partitioning, consumer groups — foundation
III. The Anatomy of an Ideal Event Contracts and Schema Registry Event schemas define processing topology
IV. Data Liberation Legacy integration and eventification Kafka Streams — the tool for eventifying raw CDC into canonical events
V. The Mechanics of Stream Processing State, windows, EOS The computational core of event-driven architecture

Pre-Production Topology Checklist

  • Input and output topic partitions aligned for all JOINs
  • Co-partitioning verified manually (Kafka Streams does not check automatically)
  • Explicit repartition() added to combine stateful operations
  • State store monitored for size and key count
  • Standby replicas configured for stateful tasks
  • Exactly-once chosen deliberately (exactly_once_v2)
  • Rebalance strategy — cooperative sticky assignor
  • Static group membership (group.instance.id) for stateful
  • Grace period configured for event-time windowed operations
  • TopologyTestDriver used in CI to verify topology
  • Interactive Queries set up if external access to state is needed
  • Punctuator added for periodic tasks (heartbeat, flush)
Final Thesis

Repartitioning and co-partitioning are not just technical tricks but ways of physically ordering a chaotic stream of facts into structured, localized data sets. These mechanisms enable the transition from simple filters to complex aggregations, windowed functions, and JOINs that make event-driven architecture not just a "pipe for data" but a full-fledged computational platform.

Chapter V of the "Event-Driven Architecture" series. Sources: Confluent, Apache Kafka, Baeldung, Conduktor, Habr, Mind Forge, Kafka Backup, RisingWave, production system case studies.