Если «тяжеловесные» фреймворки (такие как Apache Spark или Flink) можно сравнить с мощной, но требовательной к ресурсам отдельной электростанцией, то «легковесные» фреймворки — это высокоэффективные генераторы, встроенные прямо в ваши микросервисы. Они не требуют выделения независимого кластера для обработки. Вместо этого они образуют неразрывный симбиоз с брокером событий (Apache Kafka) и системой управления контейнерами (CMS, например, Kubernetes).
В этой главе мы разберём, как работает эта парадигма, почему она стала «золотым стандартом» для stateful-микросервисов, и как с её помощью решать сложнейшие задачи обогащения данных и агрегации в реальном времени.
Это двенадцатая глава цикла. Она опирается на главу V (state stores, rebalance, cooperative sticky), главу VII (RocksDB, LSM, changelog, TTL), главу X (BPC как минимализм) и главу XI (тяжёлые фреймворки как противоположный полюс). Здесь мы замыкаем спектр: от простейшего poll-process-produce через embedded library до распределённых титанов Big Data.
Главное отличие «легковесной» модели заключается в перераспределении ответственности. Фреймворк отказывается от управления кластером, полностью делегируя эти задачи инфраструктуре:
Берёт на себя роль шины обмена данными (shuffle) и надёжного хранилища состояния (changelog). Внутренние топики используются для перераспределения данных между инстансами, а логи изменений гарантируют сохранность состояния при сбоях.
Отвечает за оркестрацию, масштабирование и отказоустойчивость. Добавление или удаление подов автоматически триггерит перераспределение партиций и восстановление состояния из логов.
Благодаря этому приложение развёртывается как обычный микросервис. Параллелизм контролируется стандартным механизмом групп потребителей (Consumer Groups), а данные одного ключа гарантированно обрабатываются на одном физическом узле, обеспечивая локальность данных.
В «тяжёлом» фреймворке Master Node управляет задачами, а Worker'ы — вычислениями. В «лёгком» эту роль берут на себя Kafka (shuffle, changelog) и Kubernetes (оркестрация). Фреймворк остаётся только библиотекой — без кластера, без Master, без Zookeeper.
По умолчанию «легковесные» фреймворки используют внутреннее состояние (например, локальные key-value хранилища вроде RocksDB на SSD-дисках). Это обеспечивает микросекундные задержки при чтении и записи, недостижимые для внешних сетевых БД.
Но что происходит, когда инстанс падает или мы добавляем новый узел для масштабирования? Здесь на сцену выходит журнал изменений (changelog).
Вам не нужно настраивать сложные механизмы контрольных точек (checkpoints) или внешние хранилища — брокер событий уже сделал это за вас.
В тяжёлых фреймворках checkpoint сохраняется в S3/HDFS. В Kafka Streams аналогом является changelog topic. Это два разных подхода к одной задаче: сохранение state для восстановления после сбоя. Разница — в уровне абстракции и стоимости.
Поскольку каждый микросервис сам управляет своим локальным диском и состоянием, вы можете гибко подбирать железо. Один сервис, требующий огромной пропускной способности IOPS, можно запустить на инстансах с дорогими NVMe SSD, а другой, с большим, но редко читаемым состоянием — на дешёвых HDD.
| Сервис | Требования к state | Железо |
|---|---|---|
| Fraud detection | Высокий IOPS, низкая latency | NVMe SSD |
| Рекомендации | Большое состояние, редкие записи | SATA SSD |
| Историческое окно | Огромное состояние, редкое чтение | HDD |
| Дедупликация | Небольшое состояние, частые проверки | NVMe SSD |
В «тяжеловесных» кластерах такое разделение ресурсов крайне затруднено: TaskManager'ы гомогенны, и вы не можете дать одному job'у NVMe, а другому — HDD в одном кластере.
Масштабирование «легковесного» микросервиса — это по сути тот же процесс, что и восстановление после сбоя. Когда вы добавляете новый инстанс, CMS перераспределяет партиции. Новый узел должен загрузить состояние из changelog, прежде чем начать обрабатывать новые события. Это может занять время и вызвать временную задержку (lag).
Чтобы минимизировать простой при масштабировании или сбоях, Kafka Streams поддерживает концепцию горячих реплик.
Фреймворк запускает фоновые задачи, которые просто читают changelog-топики и держат локальное состояние в «тёплом» виде на резервных узлах. Когда основной узел падает или требуется добавить мощности, контроллер групп потребителей мгновенно переключает трафик на узел, у которого состояние уже полностью готово. Это обеспечивает почти нулевое время простоя (zero-downtime) при перераспределении нагрузки.
| Стратегия | Время восстановления | Стоимость |
|---|---|---|
| Changelog replay | Минуты–часы | Базовая |
| Standby replicas | Секунды (5–30 с) | Двойной диск |
| Backup + restore | Минуты | Доп. хранилище |
Для критичных stateful-сервисов всегда включайте num.standby.replicas=1. Это снижает recovery time с минут до секунд за счёт двойного расхода диска. Для некритичных сервисов (аналитика, агрегация) можно обойтись changelog replay.
Многие ставят Kafka Streams и Samza в один ряд как «легковесные библиотеки». Но у них принципиально разные модели, и это влияет на выбор.
| Критерий | Kafka Streams | Apache Samza (Embedded) |
|---|---|---|
| Backend | Только Kafka | Pluggable (Kafka, Kinesis, другие) |
| State backend | RocksDB (Kafka-native) | Pluggable (RocksDB, кастомные) |
| API | High-level DSL + Processor API | Low-level API, ближе к BPC |
| Exactly-once | exactly_once_v2 (Kafka transactions) | Через Kafka transactions |
| K8s native | Да | Да (embedded mode) |
| Зрелость | Промышленный стандарт | Меньше сообщество |
| Best for | Kafka-to-Kafka pipelines | Multi-broker среды |
Если вы живёте в мире Kafka — Kafka Streams. Если у вас несколько брокеров и нужна гибкость — Samza. Для 95% команд Kafka Streams — правильный выбор.
Одна из «киллер-фич» легковесных фреймворков — возможность выполнять реляционные соединения (JOIN) между бесконечными потоками событий и материализованными таблицами прямо в памяти, без обращения к внешним БД.
Представьте, что у вас есть поток кликов и просмотров рекламы (события). Вам нужно соединить их со справочником рекламных объявлений (сущности), чтобы понять, какой клиент владеет рекламой, и посчитать итоговую конверсию.
// Материализуем справочник рекламы в локальную таблицу (KTable)
KTable<AdId, Advertisement> adCatalog = builder.table("ads-topic");
// Берём поток пользовательских сессий (KStream)
KStream<UserId, UserAction> userSessions = builder.stream("sessions-topic");
// Группируем, агрегируем и соединяем со справочником
userSessions
.groupByKey()
.aggregate(...) // Считаем конверсии
.join(adCatalog, (conversion, ad) -> {
// Обогащаем результат метаданными рекламы
if (ad == null) return null; // Обработка tombstone
return new EnrichedReport(conversion, ad.getClientName());
})
.to("enriched-reports-topic");
Как это работает под капотом?
AdId) и имеют одинаковое количество разделов.null. Фреймворк корректно обрабатывает это, позволяя разработчику решить, что делать с «осиротевшими» агрегатами.По своей сути, эта топология эквивалентна сложному SQL-запросу с FULL OUTER JOIN и GROUP BY, но выполняется она непрерывно, в реальном времени и с высокой производительностью.
Для stream-table join оба источника должны быть соподразделены: одинаковое число партиций и один и тот же partitioner. Kafka Streams не проверяет это автоматически — если партиции не совпадают, JOIN выдаст неполные результаты тихо и без ошибок.
Альтернатива — GlobalKTable: таблица, полностью реплицируемая на каждый инстанс. Она не требует co-partitioning и позволяет JOIN по произвольному полю через KeyValueMapper.
| Тип JOIN | Co-partitioning | По ключу | По произвольному полю |
|---|---|---|---|
| KStream-KStream | Да | Да | Нет |
| KStream-KTable | Да | Да | Нет |
| KStream-GlobalKTable | Нет | Нет | Да |
Для компактных справочников (страны, валюты, категории, тарифы), которые помещаются в память каждого инстанса. Для больших таблиц — избыточно, потому что репликация на каждый инстанс расходует память × N.
Stream-table join — только один паттерн. Оконные операции — то, что делает Kafka Streams полноценным фреймворком:
// Tumbling window: заказы за каждый час
stream.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
.count();
// Hopping window: rolling count за 15 минут, обновление каждую минуту
stream.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(15), Duration.ofMinutes(1))
.advanceBy(Duration.ofMinutes(1)))
.count();
// Session window: сессия пользователя с gap 30 минут
stream.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapWithNoGrace(
Duration.ofMinutes(30)))
.count();
Все четыре типа окон из главы V доступны из коробки: tumbling, hopping, sliding, session. Плюс — event time и grace period из главы VI.
Kafka Streams предоставляет exactly_once_v2: атомарная транзакция, которая объединяет запись в state store, публикацию в output topic и коммит offset. Либо всё фиксируется, либо всё откатывается.
processing.guarantee=exactly_once_v2
| Метрика | At-least-once | Exactly-once v2 |
|---|---|---|
| Дубликаты в output | Возможны | Нет (в пределах Kafka) |
| Commit interval | 30s | 100ms (в 300 раз чаще) |
| Throughput | Базовый | -10–30% |
Это ключевое отличие от BPC (глава X), где нужна ручная идемпотентность. В Kafka Streams достаточно включить одну настройку.
State store — не только внутренний кэш для обработчика. Его можно читать снаружи через REST API — это заменяет отдельную БД для query-сценариев.
@GetMapping("/customers/{id}/total")
public Long getTotal(@PathVariable String id) {
ReadOnlyKeyValueStore<String, Long> store = streams.store(
StoreQueryParameters.fromNameAndType(
"customer-totals",
QueryableStoreTypes.keyValueStore()));
return store.get(id);
}
Это даёт:
State store локален для каждого инстанса. Если запрос приходит на инстанс, у которого нет нужного ключа, придётся маршрутизировать запрос к правильному инстансу через metadata service или использовать global state store.
Kafka Streams — не серебряная пуля. Честный разговор об ограничениях:
Мультиязычные команды (Go, Python) — BPC или Faust. State > 100 GB на инстанс — Flink. Сложные joins десятков потоков — Flink. Строгие SLA на latency — Flink с event-time-first. Kafka Streams — оптимум для Kafka-to-Kafka pipelines средней сложности.
Unit-тесты без брокера. Запускает топологию в том же JVM, подаёт события, проверяет выход. Работает за миллисекунды.
Integration-тесты с реальным Kafka в JVM. Проверка end-to-end: input → processing → state → output.
Full-stack тесты с Kafka, Schema Registry и Connect в Docker. Ближе всего к production.
Тестирование отдельных processor'ов в изоляции. Для низкоуровневого Processor API.
TopologyTestDriver driver = new TopologyTestDriver(topology, config);
TestInputTopic<String, OrderEvent> input = driver.createInputTopic(
"orders", new StringSerializer(), new OrderEventSerializer());
TestOutputTopic<String, Long> output = driver.createOutputTopic(
"customer-totals", new StringDeserializer(), new LongDeserializer());
input.pipeInput("order-1", new OrderEvent("cust-42", 150.0));
assertThat(output.readValueToMap()).containsEntry("cust-42", 1L);
driver.close();
| Метрика | Что показывает | Порог алерта |
|---|---|---|
rocksdb-block-cache-hit-ratio |
Эффективность кэша | < 0.8 |
rocksdb-estimated-num-keys |
Количество ключей в store | Растёт линейно без TTL |
record-lag-max |
Changelog lag | > 100k записей |
commit-latency-avg |
Среднее время commit | > 1 сек |
rebalance-rate-per-hour |
Число rebalance за час | > 2 в час |
process-rate |
Скорость обработки | Падение > 50% |
| Сценарий | BPC | Kafka Streams | Flink |
|---|---|---|---|
| Старт | Часы | Дни | Недели |
| Операционные расходы | Низкие | Средние | Высокие |
| Стоимость state | Внешняя БД | Локальный диск | Локальный диск + checkpoint |
| Complexity | Низкая | Средняя | Высокая |
| Best for | Простые потоки | Kafka-to-Kafka | Analytics, ML |
Если ваша команда не хочет писать топологии на Java, индустрия предлагает SQL поверх лёгких библиотек.
Отдельный сервер, который принимает SQL-запросы и генерирует топологии Kafka Streams. Поддерживает streaming SQL, materialized views, pull и push queries. Идеально для аналитиков.
Встроенный SQL в Samza. Более ограничен, чем ksqlDB, но работает embedded. Подходит для простых сценариев.
-- ksqlDB: session windows для рекламы
CREATE STREAM ad_views (
userId VARCHAR KEY,
adId VARCHAR,
eventTime TIMESTAMP
) WITH (
KAFKA_TOPIC = 'ad_views',
VALUE_FORMAT = 'JSON',
TIMESTAMP = 'eventTime'
);
CREATE TABLE sessions AS
SELECT userId,
COUNT(*) AS view_count,
WINDOWSTART AS session_start,
WINDOWEND AS session_end
FROM ad_views
WINDOW SESSION (30 MINUTES)
GROUP BY userId
EMIT CHANGES;
Под капотом ksqlDB генерирует стандартные топологии Kafka Streams. Все преимущества и ограничения (JVM-lock, state store size, no savepoints) наследуются.
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| V. Механика потоковой обработки | State stores, rebalance, cooperative sticky | Kafka Streams использует те же механизмы на уровне библиотеки |
| VI. Время в потоках | Event time, watermarks, windowing | Windowing в Kafka Streams использует event time через retention |
| VII. Память микросервисов | RocksDB, changelog, TTL, IQ | Kafka Streams — практическая реализация всех этих механизмов |
| X. Базовый шаблон Производитель-Потребитель | BPC как минимализм | Kafka Streams — эволюция BPC с внутренним state |
| XI. Тяжеловесные фреймворки | Flink, Spark, Beam, Storm | Kafka Streams — противоположный полюс: embedded вместо кластера |
| XII. Легковесные фреймворки | Kafka Streams, Samza | Золотая середина между BPC и тяжёлыми фреймворками |
«Легковесные» фреймворки — это «золотое сечение» событийно-управляемой архитектуры. Они дают мощь stateful processing без кластерной инфраструктуры, гибкость микросервисов без внешних state stores, и скорость локальных SSD без сетевых задержек. Для 80% production-сценариев Kafka Streams достаточно. Для остальных 20% есть BPC, FaaS, Flink и Spark — каждый инструмент занимает свою нишу.
Глава XII цикла «Событийно-управляемая архитектура». Источники: Apache Kafka, Confluent, Conduktor, практические кейсы production-систем, FinCodeCow experience.
If heavyweight frameworks (like Apache Spark or Flink) can be compared to a powerful but resource-hungry dedicated power plant, then lightweight frameworks are highly efficient generators embedded directly in your microservices. They don't require a dedicated cluster for processing. Instead, they form an inseparable symbiosis with the event broker (Apache Kafka) and the container management system (CMS, e.g., Kubernetes).
In this chapter, we will explore how this paradigm works, why it has become the "gold standard" for stateful microservices, and how it solves the most complex data enrichment and real-time aggregation tasks.
This is the twelfth chapter of the series. It builds on Chapter V (state stores, rebalance, cooperative sticky), Chapter VII (RocksDB, LSM, changelog, TTL), Chapter X (BPC as minimalism), and Chapter XI (heavyweight frameworks as the opposite pole). Here we close the spectrum: from the simplest poll-process-produce through an embedded library to the distributed titans of Big Data.
Takes on the role of the data exchange bus (shuffle) and the reliable state store (changelog). Internal topics redistribute data between instances, and change logs guarantee state preservation during failures.
Handles orchestration, scaling, and fault tolerance. Adding or removing pods automatically triggers partition redistribution and state restoration from logs.
Thanks to this, the application is deployed as a regular microservice. Parallelism is controlled by standard consumer groups, and data of one key is guaranteed to be processed on one physical node, ensuring data locality.
In a heavyweight framework, the Master Node manages tasks, and Workers do computations. In a lightweight one, these roles are taken by Kafka (shuffle, changelog) and Kubernetes (orchestration). The framework remains just a library — no cluster, no Master, no Zookeeper.
In heavyweight frameworks, the checkpoint is saved to S3/HDFS. In Kafka Streams, the analog is the changelog topic. These are two different approaches to one task: state preservation for recovery after a failure.
Since each microservice manages its own local disk and state, you can flexibly choose hardware. One service requiring huge IOPS can run on instances with expensive NVMe SSD, another with large but rarely read state — on cheap HDD.
In heavyweight clusters, such resource separation is extremely difficult: TaskManagers are homogeneous.
Scaling a lightweight microservice is essentially the same process as recovery after a failure. When you add a new instance, CMS redistributes partitions. The new node must load state from changelog before processing new events.
Kafka Streams supports the concept of hot standby replicas. Background tasks read changelog topics and keep local state "warm" on standby nodes. When the primary node fails, the consumer group controller instantly switches traffic to the node whose state is already fully ready.
| Strategy | Recovery Time | Cost |
|---|---|---|
| Changelog replay | Minutes–hours | Base |
| Standby replicas | Seconds (5–30 s) | Double disk |
| Backup + restore | Minutes | Extra storage |
| Criterion | Kafka Streams | Apache Samza (Embedded) |
|---|---|---|
| Backend | Kafka only | Pluggable (Kafka, Kinesis, others) |
| State backend | RocksDB (Kafka-native) | Pluggable (RocksDB, custom) |
| API | High-level DSL + Processor API | Low-level API, closer to BPC |
| Exactly-once | exactly_once_v2 (Kafka transactions) | Via Kafka transactions |
| Best for | Kafka-to-Kafka pipelines | Multi-broker environments |
// Materialize ad catalog into a local table (KTable)
KTable<AdId, Advertisement> adCatalog = builder.table("ads-topic");
// Take user sessions stream (KStream)
KStream<UserId, UserAction> userSessions = builder.stream("sessions-topic");
// Group, aggregate, and join with the catalog
userSessions
.groupByKey()
.aggregate(...) // Count conversions
.join(adCatalog, (conversion, ad) -> {
if (ad == null) return null; // Tombstone handling
return new EnrichedReport(conversion, ad.getClientName());
})
.to("enriched-reports-topic");
AdId) with the same number of partitions.null — the developer decides what to do with orphaned aggregates.Kafka Streams does not check co-partitioning automatically — if partitions don't match, the JOIN silently produces incomplete results. Alternative — GlobalKTable, fully replicated to every instance.
// Tumbling window: orders per hour
stream.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
.count();
// Session window: user session with 30-min gap
stream.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapWithNoGrace(
Duration.ofMinutes(30)))
.count();
processing.guarantee=exactly_once_v2
Kafka Streams provides exactly_once_v2: an atomic transaction combining state store writes, output topic publications, and offset commits. Either all is committed, or all is rolled back.
@GetMapping("/customers/{id}/total")
public Long getTotal(@PathVariable String id) {
ReadOnlyKeyValueStore<String, Long> store = streams.store(
StoreQueryParameters.fromNameAndType(
"customer-totals",
QueryableStoreTypes.keyValueStore()));
return store.get(id);
}
This gives CQRS without an external DB: the read model lives in the state store, millisecond response, automatic actualization.
TopologyTestDriver driver = new TopologyTestDriver(topology, config);
TestInputTopic<String, OrderEvent> input = driver.createInputTopic(
"orders", new StringSerializer(), new OrderEventSerializer());
TestOutputTopic<String, Long> output = driver.createOutputTopic(
"customer-totals", new StringDeserializer(), new LongDeserializer());
input.pipeInput("order-1", new OrderEvent("cust-42", 150.0));
assertThat(output.readValueToMap()).containsEntry("cust-42", 1L);
driver.close();
| Metric | What It Shows | Alert Threshold |
|---|---|---|
rocksdb-block-cache-hit-ratio |
Cache efficiency | < 0.8 |
record-lag-max |
Changelog lag | > 100k records |
commit-latency-avg |
Average commit time | > 1 sec |
rebalance-rate-per-hour |
Rebalances per hour | > 2 per hour |
| Scenario | BPC | Kafka Streams | Flink |
|---|---|---|---|
| Start | Hours | Days | Weeks |
| Operational cost | Low | Medium | High |
| State cost | External DB | Local disk | Local disk + checkpoint |
| Complexity | Low | Medium | High |
-- ksqlDB: session windows for ads
CREATE TABLE sessions AS
SELECT userId,
COUNT(*) AS view_count,
WINDOWSTART AS session_start,
WINDOWEND AS session_end
FROM ad_views
WINDOW SESSION (30 MINUTES)
GROUP BY userId
EMIT CHANGES;
Under the hood, ksqlDB generates standard Kafka Streams topologies. All advantages and limitations (JVM lock, state store size, no savepoints) are inherited.
| Chapter | Focus | Link to This Chapter |
|---|---|---|
| V. The Mechanics of Stream Processing | State stores, rebalance, cooperative sticky | Kafka Streams uses the same mechanisms at library level |
| VI. Time in Streams | Event time, watermarks, windowing | Windowing in Kafka Streams uses event time via retention |
| VII. The Memory of Microservices | RocksDB, changelog, TTL, IQ | Kafka Streams — practical implementation of all these mechanisms |
| X. Basic Producer/Consumer Pattern | BPC as minimalism | Kafka Streams — evolution of BPC with internal state |
| XI. Heavyweight Frameworks | Flink, Spark, Beam, Storm | Kafka Streams — opposite pole: embedded instead of cluster |
| XII. Lightweight Frameworks | Kafka Streams, Samza | The golden middle between BPC and heavyweight frameworks |
Lightweight frameworks are the "golden middle" of event-driven architecture. They provide the power of stateful processing without cluster infrastructure, the flexibility of microservices without external state stores, and the speed of local SSD without network delays. For 80% of production scenarios, Kafka Streams is enough. For the remaining 20%, there is BPC, FaaS, Flink, and Spark — each tool occupies its niche.
Chapter XII of the "Event-Driven Architecture" series. Sources: Apache Kafka, Confluent, Conduktor, production system case studies, FinCodeCow experience.