Если предыдущие главы были посвящены тому, что такое события и как они хранятся в брокере, то эта глава переносит нас внутрь «кухни» микросервиса. Здесь мы разберём, как именно приложение принимает поток сырых фактов, применяет к ним бизнес-логику и выдаёт результат. На первый взгляд процесс кажется тривиальным циклом, но под капотом скрывается элегантная механика маршрутизации, которая делает возможной масштабируемую обработку больших данных.
Это пятая глава цикла о событийно-управляемой архитектуре. Первая отвечала на вопрос «почему», вторая — «как устроено под капотом», третья — «как проектировать контракты», четвёртая — «как подключить legacy». Теперь мы переходим к вычислительному ядру событийной архитектуры.
Любой событийно-управляемый микросервис, работающий с потоками, живёт по простому, но строгому ритму:
Точкой входа в этот процесс является топология микросервиса — граф операций, управляемых данными. Именно топология диктует, какие фильтры применять, как маршрутизировать данные и когда вызывать агрегации.
В 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");
Этот код строит граф: чтение → фильтр → смена ключа → перегруппировка → агрегация → запись. Каждая операция — узел в топологии.
Самые простые микросервисы не хранят состояние в памяти. Они обрабатывают каждое событие изолированно, как на конвейере.
Пропускает событие дальше только если оно соответствует критерию. Создаёт 0 или 1 событие на выходе.
Изменяет ключ и/или значение события, создавая ровно одно новое событие. Может вызвать переподразделение.
Изменяет только значение, не трогая ключ. Не вызывает переподразделения — критично для производительности.
Направляет события в разные выходные потоки на основе их свойств — VIP-клиенты в один поток, остальные в другой.
Объединяет несколько входных потоков в один логический поток для совместной обработки.
Преобразует одно событие в ноль, одно или несколько. Полезно для разбиения одного события на несколько.
Эти операции легковесны и легко параллелятся, но они не могут ответить на вопросы, требующие знания прошлого: «Сколько раз этот пользователь кликнул за последний час?» Для таких задач нужна локальность данных.
Прежде чем идти дальше, нужно явно разграничить два фундаментальных понятия Kafka Streams, которые часто путают.
Каждая запись независима. OrderPlaced, затем OrderPaid — это два разных факта, оба важны. Ничего не удаляется. Это история.
Каждая запись — обновление по ключу. CustomerUpdated с тем же ID перезаписывает предыдущее состояние. Только последнее значение имеет смысл.
Практическое следствие: KStream-KTable JOIN — это способ обогатить поток фактов текущим состоянием справочника. Например, каждое событие OrderPlaced можно обогатить именем клиента из KTable customers.
KStream можно превратить в KTable через toTable(), а KTable — в KStream через toStream(). Это две формы одних и тех же данных — то же самое table-stream duality, которое обсуждалось в главе II.
В мире распределённых систем данные часто приходят в брокер событий, партиционированные по одному ключу (например, по 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();
Представьте, что нужно соединить (сделать JOIN) поток кликов пользователей с потоком их профилей. Чтобы эта операция была эффективной и не требовала сетевых вызовов между разными серверами, оба потока должны быть соподразделены (co-partitioned).
Соподразделение означает, что два или более потоков событий:
Благодаря этому контроллер гарантирует, что данные с одинаковым ключом из разных потоков будут назначены одному и тому же экземпляру микросервиса.
Kafka Streams не проверяет соподразделение автоматически. Если партиции не совпадают, JOIN будет работать, но выдаст неполные результаты — тихо и без ошибок.
Не всегда соподразделение — единственный путь. GlobalKTable — это таблица, которая полностью реплицируется на каждый экземпляр приложения.
KeyValueMapper, а не только по ключу потока.Когда использовать: справочники (страны, валюты, категории), конфигурации, справочные данные, которые помещаются в память каждого инстанса.
Когда НЕ использовать: большие таблицы (репликация на каждый инстанс = расход памяти × N), данные с интенсивными обновлениями.
| Тип JOIN | Соподразделение | По ключу | По произвольному полю | Event-time синхронизация |
|---|---|---|---|---|
| KStream-KStream | Да | Да | Нет | Да (windowed) |
| KStream-KTable | Да | Да | Нет | Да |
| KTable-KTable | Да (или FK join) | Да | Да (через FK) | Да |
| KStream-GlobalKTable | Нет | Нет | Да | Нет |
Переподразделение даёт локальность данных. Но чтобы агрегировать, считать окна и делать JOIN, микросервису нужно хранить состояние. И хранить его не в внешней БД (это сеть + задержка), а локально.
State Store — локальное key-value хранилище, которое живёт на диске каждого инстанса. В Kafka Streams по умолчанию используется RocksDB — embedded LSM-tree движок, написанный в Facebook.
Нет сети в hot path. Сотни тысяч puts/sec на инстанс. Размер не ограничен heap — состояние на диске. Долговечность через WAL внутри RocksDB.
В 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 для оконных агрегаций.
Агрегации без окон — это бегущий счётчик. Но бизнес спрашивает: «Сколько заказов за последний час?» или «Был ли всплеск активности за 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();
Все оконные функции опираются на время. Но чьё время?
Время, когда событие обрабатывается микросервисом. Зависит от загрузки, задержек сети, rebalance. Нестабильно.
Время, когда событие произошло в реальном мире (timestamp в payload). Не зависит от инфраструктуры. Детерминировано.
В Kafka Streams оконные операции по умолчанию используют event time — временную метку записи в Kafka. Это критично: если событие задержалось в брокере на 10 минут, оно всё равно попадёт в правильное окно, а не в «текущее».
Grace period — окно, которое позволяет принимать «опоздавшие» события после формального закрытия окна. Например, окно 5 минут с grace 1 минуту принимает события с event-time в пределах окна до 1 минуты после его закрытия. Без grace окно закрывается жёстко, и опоздавшие события отбрасываются.
Для финансовых систем — event time + grace period. Для метрик мониторинга — processing time. Смешение двух подходов в одной топологии — источник тихих ошибок.
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 только восстанавливают состояние, а не публикуют выходные события.
Бессостоятельный режим. Для микросервисов без поддержки состояния отказ экземпляра — не трагедия. Новый инстанс просто присоединяется к группе потребителей, получает свою долю партиций и возобновляет чтение с последнего закоммиченного 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.
Кто решает, какой экземпляр микросервиса будет читать какую партицию? Этим занимается контроллер разделов, встроенный в клиентскую библиотеку.
Стратегии назначения:
Static group membership (group.instance.id) — потребитель при рестарте получает те же партиции без rebalance вообще. Это важно для stateful приложений: нет rebalance → нет перераспределения state store → нет простоя.
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);
}
Это даёт:
State store локален для каждого инстанса. Если запрос приходит на инстанс, у которого нет нужного ключа, придётся маршрутизировать запрос к правильному инстансу через metadata service или использовать global state store.
Kafka Streams предоставляет Punctuator API — механизм для периодических действий, не привязанных к поступлению событий.
Пример: отправить heartbeat раз в минуту, даже если события не приходят:
builder.schedule(
Duration.ofMinutes(1),
PunctuationType.WALL_CLOCK_TIME,
timestamp -> {
// отправка heartbeat
});
Punctuator бывает двух типов:
Типичные сценарии: таймауты сессий, отправка метрик, flush state store, эмиссия агрегатов по расписанию.
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:
| Критерий | 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 фреймворка не оправдан.
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| 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 | Вычислительное ядро событийной архитектуры |
repartition() добавлены для объединения stateful-операцийexactly_once_v2)group.instance.id) для statefulПереподразделение и соподразделение — это не просто технические приёмы, а способы физического упорядочивания хаотичного потока фактов в структурированные, локализованные наборы данных. Именно эти механизмы позволяют перейти от простых фильтров к сложным агрегациям, оконным функциям и JOIN-ам, которые делают событийную архитектуру не просто «трубой для данных», а полноценной вычислительной платформой.
Глава V цикла «Событийно-управляемая архитектура». Источники: Confluent, Apache Kafka, Baeldung, Conduktor, Habr, Mind Forge, Kafka Backup, RisingWave, практические кейсы production-систем.
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.
Any event-driven microservice working with streams lives by a simple but strict rhythm:
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.
The simplest microservices don't store state in memory. They process each event in isolation, like on a conveyor.
Passes an event forward only if it matches a criterion. Creates 0 or 1 event on output.
Changes the key and/or value of an event, creating exactly one new event. May trigger repartitioning.
Changes only the value, not the key. Does not trigger repartitioning — critical for performance.
Routes events to different output streams based on their properties — VIP customers to one stream, others to another.
Combines multiple input streams into one logical stream for joint processing.
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.
Before going further, we need to explicitly distinguish two fundamental Kafka Streams concepts that are often confused.
Each record is independent. OrderPlaced, then OrderPaid — two different facts, both important. Nothing is deleted. It is history.
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.
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.
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.
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();
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:
Thanks to this, the controller guarantees that data with the same key from different streams is assigned to the same microservice instance.
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.
Co-partitioning is not always the only path. GlobalKTable is a table fully replicated to every application instance.
KeyValueMapper, not just by stream key.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 |
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.
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.
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.
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.
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();
All windowing functions rely on time. But whose time?
The time when the event is processed by the microservice. Depends on load, network delays, rebalance. Unstable.
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.
For financial systems — event time + grace period. For monitoring metrics — processing time. Mixing both approaches in one topology is a source of silent errors.
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.
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.
Who decides which microservice instance reads which partition? This is handled by the partition controller built into the client library.
Assignment strategies:
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.
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:
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.
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:
Typical scenarios: session timeouts, sending metrics, flushing state store, emitting aggregates on schedule.
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:
| 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.
| 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 |
repartition() added to combine stateful operationsexactly_once_v2)group.instance.id) for statefulRepartitioning 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.