До сих пор мы рассматривали микросервисы как «конвейеры без памяти»: событие вошло, трансформировалось, вышло. Но реальный бизнес требует контекста. Чтобы ответить на вопрос «Какова общая сумма покупок этого клиента за последние 30 дней?» или «Сколько единиц товара осталось на складе после этой транзакции?», сервису нужно помнить. Ему нужно состояние.
Эта глава погружается в самую сложную и одновременно самую мощную часть событийно-управляемой архитектуры — потоковую обработку с поддержкой состояния (Stateful Stream Processing). Мы разберём, как состояние рождается из потока событий, как оно защищается от потери при сбоях, какие типы хранилищ существуют и почему дубликаты — это не баг, а неизбежная реальность распределённых систем.
Это седьмая глава цикла. Она опирается на главу II (table-stream duality, CQRS), главу V (state stores, changelog, standby replicas) и главу VI (event time, out-of-order). Здесь мы углубляем каждую из этих тем.
В основе stateful-обработки лежит одна из самых элегантных концепций событийной архитектуры: поток событий и таблица состояний — это два представления одной и той же сущности.
| Поток (Stream) | Таблица (Table) |
|---|---|
| Неизменяемая, упорядоченная по времени последовательность фактов | Снимок текущего состояния на определённый момент |
| «Цена товара X изменилась на $10», «Затем на $15», «Затем на $12» | «Цена товара X = $12» |
| История изменений | Результат применения всех событий |
Эта дуальность работает в обе стороны. Любую таблицу можно превратить в поток, записывая каждое её изменение как событие (паттерн Change Data Capture). И любой поток можно превратить в таблицу, последовательно применяя все его события (процесс материализации).
Материализация потока в таблицу — это фундамент для всех stateful-операций: агрегаций, окон, joins, дедупликации. Именно поэтому table-stream duality — первый раздел этой главы, а не последний.
Когда микросервис материализует состояние в локальное хранилище (RocksDB на диске), он обретает скорость: чтение и запись происходят локально, без сетевых задержек. Но если сервер сгорит, локальное состояние будет утеряно.
Решение — журнал изменений (changelog topic). Это специальный compacted Kafka-топик, куда микросервис записывает каждое изменение своего внутреннего состояния. По сути, это «страховочная копия» таблицы, разложенная на последовательность фактов.
Input Topic → Processor → State Store (RocksDB, local disk)
↓
Changelog Topic (Kafka, compacted)
Механизм восстановления. При потере инстанса новый инстанс подписывается на changelog и «проигрывает» все события с начала, восстанавливая RocksDB. Поскольку changelog — compacted topic, хранится только последнее значение для каждого ключа, поэтому объём восстановления пропорционален размеру состояния, а не количеству обработанных событий.
Kafka Streams дополнительно ведёт локальный checkpoint-файл, отслеживающий последний offset, для которого состояние сохранено. При рестарте приложение сначала проверяет checkpoint: если он есть, восстановление начинается с последней позиции, а не с нуля.
Changelog topic должен иметь то же число партиций, что и state store. Если партиции не совпадают — восстановление не сработает корректно. Kafka Streams создаёт changelog автоматически, но при ручной настройке это нужно проверить.
RocksDB — embedded key-value движок, построенный на LSM-дереве (Log-Structured Merge Tree). Понимание его устройства необходимо для диагностики проблем производительности и роста состояния.
Write → MemTable (in-memory, sorted)
↓ flush
SSTable (immutable file on disk)
↓ compaction
Larger SSTables (merged, deduplicated)
При удалении ключа RocksDB не удаляет запись физически — записывается специальный маркер tombstone. Во время чтения RocksDB видит tombstone и не возвращает значение. Но сама запись и tombstone продолжают занимать место в SSTable-файлах до compaction.
Команда «Магнит» столкнулась с ростом RocksDB при регулярном удалении десятков тысяч записей: логически данные исчезали, но физический размер не менялся. Решение — включить periodic compaction (каждый час вместо дефолтного «выключено») и сократить интервал удаления obsolete files.
// Принудительный compaction каждый час (дефолт = 0, выключен!)
options.setPeriodicCompactionSeconds(3600);
// Удаление obsolete файлов каждый час
options.setDeleteObsoleteFilesPeriodMicros(3600 * 1000000L);
RocksDB по умолчанию использует Bloom filters для ускорения point lookups. Это критически важно для дедупликации: проверка «был ли уже этот event_id» должна быть быстрой. Без Bloom filters каждая проверка сканировала бы несколько SSTable.
Kafka Streams предоставляет шесть типов хранилищ состояний, каждый заточенный под конкретную задачу.
| Тип | Назначение | Persistence | Retention |
|---|---|---|---|
| Persistent KeyValueStore | Текущее значение на ключ, счётчики, дедупликация | RocksDB | Бесконечная (нет TTL) |
| Timestamped KeyValueStore | Значение + timestamp последнего обновления | RocksDB | Бесконечная |
| Versioned KeyValueStore (KIP-889, Kafka 3.5+) | Несколько версий на ключ для temporal joins | RocksDB | History retention |
| Window Store | Агрегаты по временным окнам | RocksDB | По retention (авто-истекание) |
| Session Store | Сессии пользователей (gap-based) | RocksDB | По retention |
| Global Store | Полная репликация на каждый инстанс | RocksDB | Бесконечная |
Дефолт. Хранит последнее значение на ключ. Растёт бесконечно: нет встроенного TTL. Используется для счётчиков, дедупликации, материализации CQRS read models.
Для оконных агрегаций. Сегменты старше retention автоматически удаляются, changelog использует compact,delete политику. Ограничение размера — встроенное.
Динамические окна на основе сессий. Ключевая особенность — merge: если новая активность «дотягивается» до предыдущей (gap < timeout), два окна объединяются.
Слияние сессий требует копирования всего состояния двух окон в новое. Для больших сессий с миллионами событий это дорогая операция. Настройте максимальный размер сессии или используйте timeout агрессивнее.
Полная репликация всех партиций на каждый инстанс. Не требует соподразделения. Подходит для компактных справочников.
Versioned KeyValueStore (KIP-889, Kafka 3.5+) — революция для stream-table joins. Вместо одного значения на ключ хранит несколько версий с timestamp.
Price of product X:
10:00 → $10
10:05 → $15
10:03 → $12 ← out-of-order!
Versioned store:
get("X", asOfTimestamp=10:03) → $12 ← correct!
get("X", asOfTimestamp=10:05) → $15
get("X", asOfTimestamp=10:08) → $15 ← latest
Это решает проблему out-of-order в stream-table joins: если событие 10:03 пришло позже события 10:05, versioned store корректно вернёт состояние на 10:03.
// Создание versioned store (Kafka 3.5+)
StoreBuilder<VersionedKeyValueStore<String, Price>> priceStore =
Stores.versionedKeyValueStoreBuilder(
Stores.persistentVersionedKeyValueStore(
"prices-store",
Duration.ofDays(7) // history retention = 7 days
),
Serdes.String(), Serdes.forPrice()
);
History retention одновременно является grace period: события с timestamp старше текущего stream time минус retention — отбрасываются.
Для temporal joins, где важна корректность при out-of-order. Для обычных справочников — избыточно. Для больших таблиц — история версий занимает много места.
State store — не только внутренний кэш для обработчика. Его можно читать снаружи через REST API — это заменяет отдельную базу данных для query-сценариев.
ReadOnlyKeyValueStore<String, Long> store = streams.store(
StoreQueryParameters.fromNameAndType(
"word-counts",
QueryableStoreTypes.keyValueStore()
)
);
Long count = store.get("kafka"); // point lookup
Состояние распределено по партициям, каждый инстанс хранит только свою долю. Для запроса «по всему состоянию» нужен RPC-слой: инстанс, получивший запрос, определяет, на каком инстансе живёт нужный ключ (через KafkaStreams#metadataForKey), и либо отвечает локально, либо проксирует запрос на удалённый инстанс.
StateQueryRequest<Long> request = StateQueryRequest.inStore("word-counts")
.withQuery(KeyQuery.withKey("kafka"));
StateQueryResult<Long> result = streams.query(request);
IQv2 возвращает StateQueryResult с per-partition результатами и информацией о позиции (Position) — позволяет оценить свежесть данных. Поддерживает KeyQuery (point lookup) и RangeQuery (сканирование).
enableStaleStores() (KIP-535) — чтение из standby-реплик во время восстановления. Жертвует свежестью ради доступности: «лучше ответ, отстающий на секунду, чем исключение при каждом деплое».
KeyValueStore растёт бесконечно. Нет встроенного TTL, нет eviction thread, нет expireAfter. Каждое значение, записанное в plain KV store, остаётся навсегда — через рестарты, через rebalance — пока вы не удалите его вручную.
WindowStore автоматически удаляет сегменты старше retention, а его changelog использует compact,delete политику. KeyValueStore использует compact-only changelog: tombstone никогда не записывается автоматически, поэтому при восстановлении все «удалённые» ключи воскресают из changelog.
store.delete(), а context.forward(key, null)). Без tombstone восстановление из changelog вернёт «удалённые» данные.public void init(ProcessorContext context) {
this.context = context;
this.dedupStore = (KeyValueStore<String, Long>) context.getStateStore("dedup");
context.schedule(
Duration.ofMinutes(30),
PunctuationType.WALL_CLOCK_TIME,
this::cleanupOldEntries
);
}
void cleanupOldEntries(long timestamp) {
long cutoff = timestamp - Duration.ofHours(24).toMillis();
try (KeyValueIterator<String, Long> it = dedupStore.all()) {
while (it.hasNext()) {
var entry = it.next();
if (entry.value < cutoff) {
dedupStore.delete(entry.key);
context.forward(entry.key, null); // tombstone в changelog
}
}
}
}
KIP-1225 (август 2025) — предложение добавить нативный TTL в TimestampedKeyValueStore через StoreBuilder.withTtl(Duration). Автоматическое истекание по embedded timestamp, tombstone-flush в changelog, eviction по wall-clock time. Пока не реализовано, но закрывает самую частую боль.
Восстановление из changelog — надёжное, но не мгновенное. Если состояние занимает гигабайты, replay может занять минуты. Для критичных процессов это неприемлемо.
Горячие реплики (Standby Replicas). Пока инстанс А активно обрабатывает партицию P0, инстанс Б в фоновом режиме тоже читает тот же changelog P0 и строит идентичную копию состояния. При отказе А контроллер мгновенно переключает обработку на Б — состояние уже готово.
Настройка: num.standby.replicas=1 — одна резервная реплика на каждую активную задачу. Время восстановления сокращается с минут до секунд. Цена — двойной расход диска.
| Стратегия | Время восстановления | Стоимость |
|---|---|---|
| Changelog replay | Минуты–часы | Базовая |
| Standby replicas | Секунды (5–30 с) | Двойной диск |
| Backup + restore | Минуты | Доп. хранилище |
Одна из самых мощных операций — обогащение потока событий данными из таблицы состояний в реальном времени.
| Поток (Stream) | Таблица (Table) | Результат |
|---|---|---|
| Клик пользователя | Профиль пользователя | Клик + демография |
| Транзакция | Курс валют | Сумма в USD |
| Заказ | Остаток на складе | Заказ + availability |
Для корректной работы оба источника должны быть соподразделены: одинаковое число партиций и один partitioner. JOIN происходит локально, в памяти — без сетевых вызовов.
Таблица в stream-table join — живая. Она постоянно обновляется из своего потока событий. Если профиль изменился, KTable это отразит, и последующие JOIN-ы будут использовать свежие данные.
Обычный KeyValueStore хранит только последнее значение на ключ. Если событие 10:03 пришло после события 10:05 (out-of-order), JOIN использует уже обновлённое значение, а не то, что было на 10:03 — результат некорректен. Versioned store (KIP-889) решает: get(key, 10:03) возвращает значение, актуальное на 10:03.
Гарантировать, что каждое событие будет обработано строго один раз (exactly-once), математически невозможно в общем случае. Сети рвутся, таймауты срабатывают, и продюсер не знает, было ли событие записано. Поэтому индустрия оперирует термином «практически однократная обработка» (effectively-once).
Kafka Streams с версии 2.6 (rename на exactly_once_v2 в 3.0) поддерживает атомарные транзакции: чтение входного события, обновление state store, запись в output topic и коммит offset — в одной Kafka-транзакции. Либо всё фиксируется, либо всё откатывается.
processing.guarantee=exactly_once_v2
| Метрика | At-least-once | Exactly-once v2 |
|---|---|---|
| Дубликаты в output | Возможны после сбоя | Нет (в пределах Kafka) |
| Consumer isolation | read_uncommitted |
read_committed |
| Commit interval | 30s (дефолт) | 100ms (автосжатие, в 300 раз чаще) |
| Throughput | Базовый | -10–30% от базового |
| Latency | Минимальная | +8–30ms на commit |
v1 (exactly_once) был deprecated в Kafka 3.0 и удалён в 4.0 — требовал одного продюсера на каждую входную партицию, что создавало огромный overhead. v2 — единственный рекомендуемый режим.
Даже с транзакциями дубликаты могут проникнуть (если продюсер повторил отправку до получения ack). Потребитель должен распознавать и игнорировать дубликаты:
event_id (UUID или бизнес-ключ + версия).public void process(Record<String, OrderEvent> record) {
String eventId = record.value().getEventId();
if (dedupStore.get(eventId) != null) {
return; // уже обработано
}
// Бизнес-логика
long newTotal = currentTotal + record.value().getAmount();
balanceStore.put(record.key(), newTotal);
// Запись в дедуп-таблицу — в том же state store, атомарно с бизнес-обновлением
dedupStore.put(eventId, System.currentTimeMillis());
}
Самый коварный сценарий: микросервис обработал событие, обновил state store и таблицу дедупликации, но не успел закоммитить offset. При перезапуске он прочитает то же событие заново.
Если бизнес-операция неидемпотентна (balance += 100), повторная обработка удвоит сумму. Именно поэтому:
Состояние микросервиса, журнал изменений, смещения и таблица дедупликации — это единая система. Они должны восстанавливаться согласованно. Разрыв этой связности (например, хранение дедупликации в отдельной БД без changelog) — прямой путь к потере данных или двойным списаниям.
Имя state store не просто идентификатор. Оно попадает в:
Переименование store = смена changelog topic = полная потеря состояния при следующем рестарте.
// Слабое имя — implementation detail
Materialized.as("store1")
// Сильное имя — domain projection
Materialized.as("account-payment-total")
Если два processor'а используют store с одинаковым именем — Kafka Streams выбросит TopologyException. Это частая ошибка при рефакторинге: переименовали store в одном месте, забыли в другом.
Правило: имена store должны быть уникальны в пределах всей топологии. Для избежания конфликтов используйте префиксы по домену:
Materialized.as("account.balance")
Materialized.as("account.dedup")
Materialized.as("inventory.stock")
Materialized.as("inventory.dedup")
Kafka Streams не поддерживает изменение топологии без сброса store. Если вы изменили логику обработки — старое состояние может стать несовместимым.
--reset-local-state — удаляет локальный RocksDB, но сохраняет changelog. При следующем запуске состояние восстанавливается из changelog. Подходит, если логика совместима.
Создайте новый store с новым именем. Старый changelog останется нетронутым. После проверки нового пайплайна — удалите старый store и его changelog.
При breaking changes в топологии всегда используйте новое имя store. Это даёт возможность откатиться на старую версию без потери состояния.
Stateful приложение — это не «настроил и забыл». Без мониторинга вы узнаете о проблеме только когда диск переполнится или восстановление затянется на часы.
| Метрика | Что показывает | Порог |
|---|---|---|
rocksdb-block-cache-hit-ratio |
Эффективность кэша | < 0.8 — тревога |
rocksdb-estimated-num-keys |
Количество ключей в store | Растёт линейно — проверить TTL |
rocksdb-cur-size-all-mem-tables |
Размер MemTable | Растёт быстрее flush — проверить write rate |
rocksdb-num-files-at-level-N |
Число SSTable на уровне N | Растёт без compaction — включить periodic |
rocksdb-block-cache-miss |
Промахи кэша | Растёт — увеличить block cache |
Ключевая метрика: record-lag-max по changelog topic. Если lag растёт — восстановление займёт больше времени. Алерт при lag > 100k записей или при росте за 15 минут.
Замеряется через JMX или custom metric. Практический способ: имитировать падение инстанса в staging и замерить, сколько времени занимает полное восстановление. Это значение должно укладываться в SLA.
Если compaction отстаёт — SSTable-файлы копятся, диск переполняется. Метрика: rocksdb-compaction-pending-bytes. Алерт при > 10 GB или при росте за час.
| Критерий | Kafka Streams | Flink |
|---|---|---|
| State backend | Local RocksDB | RocksDB или disaggregated (Flink 2.0+) |
| Durability | Changelog topic (compacted) | Checkpoint в S3/HDFS |
| Recovery | Replay changelog (минуты) | Download checkpoint (секунды–минуты) |
| Large state | До 50–100 GB на инстанс | Терабайты через кластер |
| Incremental | Compaction = естественная инкрементальность | Incremental checkpoints (дельта) |
| Savepoints | Нет (только reset offset) | Да (zero-downtime upgrades) |
| Exactly-once | exactly_once_v2 |
Checkpoint + two-phase commit |
| Disaggregated state | Нет | Да (Flink 2.0 ForSt) |
Flink 2.0 (март 2025) представил disaggregated state backend (ForSt): состояние живёт в remote object storage (S3), а не на локальном диске TaskManager. State pages in/out по мере необходимости.
Что это даёт:
Trade-offs:
Когда выбирать:
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| II. Анатомия EDA | Table-stream duality, CQRS | Материализация потока в таблицу — основа stateful-обработки |
| V. Механика потоковой обработки | State stores, changelog, standby replicas | Эта глава углубляет: LSM, шесть типов stores, TTL |
| VI. Время в потоках | Event time, out-of-order | Versioned store (KIP-889) решает out-of-order в joins |
| VII. Память микросервисов | Stateful processing, effectively-once | Единая система: state + changelog + offset + dedup |
account-payment-total, а не store1)periodicCompactionSeconds, deleteObsoleteFilesPeriodMicrosnum.standby.replicas >= 1 для критичных stateful сервисовprocessing.guarantee=exactly_once_v2 для business-critical пайплайновrocksdb-block-cache-hit-ratio > 0.8rocksdb-estimated-num-keys — не растёт линейно без TTLrocksdb-compaction-pending-bytes < 10 GBrecord-lag-max changelog < 100k записейПонимание этих механизмов позволяет строить системы, которые не просто «работают в счастливых сценариях», а выживают в хаосе реальных распределённых инфраструктур — с сетевыми разрывами, перезагрузками серверов и дублирующимися событиями.
Глава VII цикла «Событийно-управляемая архитектура». Источники: Apache Kafka, Confluent, Conduktor, Automq, Lydtech Consulting, Habr (кейс «Магнита»), RisingWave, Software Patterns Lexicon, KIP-889, KIP-796, KIP-535, KIP-1225.
So far we have viewed microservices as "conveyors without memory": an event enters, transforms, exits. But real business requires context. To answer "What is the total amount of purchases by this customer over the last 30 days?" or "How many units of stock remain after this transaction?", a service needs to remember. It needs state.
This chapter dives into the most complex and simultaneously most powerful part of event-driven architecture — Stateful Stream Processing. We will explore how state is born from a stream of events, how it is protected from loss during failures, what types of storage exist, and why duplicates are not a bug but an inevitable reality of distributed systems.
This is the seventh chapter of the series. It builds on Chapter II (table-stream duality, CQRS), Chapter V (state stores, changelog, standby replicas), and Chapter VI (event time, out-of-order). Here we deepen each of these topics.
At the heart of stateful processing lies one of the most elegant concepts of event-driven architecture: an event stream and a state table are two representations of the same entity.
| Stream | Table |
|---|---|
| An immutable, time-ordered sequence of facts | A snapshot of current state at a specific moment |
| "Price of product X changed to $10," "Then to $15," "Then to $12" | "Price of product X = $12" |
| History of changes | Result of applying all events |
This duality works both ways. Any table can be turned into a stream by recording every change as an event (the Change Data Capture pattern). And any stream can be turned into a table by sequentially applying all its events (the process of materialization).
Materializing a stream into a table is the foundation for all stateful operations: aggregations, windows, joins, deduplication. That's why table-stream duality is the first section of this chapter, not the last.
When a microservice materializes state in a local store (RocksDB on disk), it gains speed: reads and writes happen locally, without network latency. But if the server burns, the local state is lost.
The solution is the changelog topic. This is a special compacted Kafka topic where the microservice writes every change of its internal state. Essentially, it is a "backup copy" of the table, decomposed into a sequence of facts.
Input Topic → Processor → State Store (RocksDB, local disk)
↓
Changelog Topic (Kafka, compacted)
Recovery mechanism. When an instance is lost, the new instance subscribes to the changelog and "replays" all events from the beginning, restoring RocksDB. Since the changelog is a compacted topic, only the latest value per key is stored, so recovery volume is proportional to state size, not to the number of processed events.
Kafka Streams additionally maintains a local checkpoint file tracking the last offset for which state was saved. On restart, the application first checks the checkpoint: if it exists, recovery starts from the last position, not from zero.
The changelog topic must have the same number of partitions as the state store. If partitions don't match, recovery won't work correctly. Kafka Streams creates the changelog automatically, but with manual setup this must be verified.
RocksDB is an embedded key-value engine built on an LSM tree (Log-Structured Merge Tree). Understanding its structure is necessary for diagnosing performance and state growth issues.
Write → MemTable (in-memory, sorted)
↓ flush
SSTable (immutable file on disk)
↓ compaction
Larger SSTables (merged, deduplicated)
When a key is deleted, RocksDB does not physically remove the record — a special marker tombstone is written. During reads, RocksDB sees the tombstone and doesn't return the value. But the record and tombstone continue to occupy space in SSTable files until compaction.
The "Magnit" team faced RocksDB growth during regular deletion of tens of thousands of records: logically data disappeared, but physical size didn't change. The solution was to enable periodic compaction (every hour instead of default "off") and shorten the interval for deleting obsolete files.
// Force compaction every hour (default = 0, disabled!)
options.setPeriodicCompactionSeconds(3600);
// Delete obsolete files every hour
options.setDeleteObsoleteFilesPeriodMicros(3600 * 1000000L);
RocksDB uses Bloom filters by default to speed up point lookups. This is critical for deduplication: checking "has this event_id already been processed" must be fast. Without Bloom filters, every check would scan multiple SSTables.
Kafka Streams provides six types of state stores, each tailored to a specific task.
| Type | Purpose | Persistence | Retention |
|---|---|---|---|
| Persistent KeyValueStore | Current value per key, counters, deduplication | RocksDB | Infinite (no TTL) |
| Timestamped KeyValueStore | Value + timestamp of last update | RocksDB | Infinite |
| Versioned KeyValueStore (KIP-889, Kafka 3.5+) | Multiple versions per key for temporal joins | RocksDB | History retention |
| Window Store | Aggregates over time windows | RocksDB | By retention (auto-expire) |
| Session Store | User sessions (gap-based) | RocksDB | By retention |
| Global Store | Full replication to every instance | RocksDB | Infinite |
The default. Stores the latest value per key. Grows infinitely: no built-in TTL. Used for counters, deduplication, materializing CQRS read models.
For windowed aggregations. Segments older than retention are automatically deleted, and the changelog uses a compact,delete policy. Size limit is built-in.
Dynamic windows based on sessions. The key feature is merge: if new activity "reaches" the previous one (gap < timeout), two windows combine.
Session merging requires copying all state of two windows into a new one. For large sessions with millions of events, this is an expensive operation. Set a maximum session size or use a more aggressive timeout.
Full replication of all partitions to every instance. Doesn't require co-partitioning. Suitable for compact reference data.
Versioned KeyValueStore (KIP-889, Kafka 3.5+) is a revolution for stream-table joins. Instead of one value per key, it stores multiple versions with timestamps.
Price of product X:
10:00 → $10
10:05 → $15
10:03 → $12 ← out-of-order!
Versioned store:
get("X", asOfTimestamp=10:03) → $12 ← correct!
get("X", asOfTimestamp=10:05) → $15
get("X", asOfTimestamp=10:08) → $15 ← latest
This solves the out-of-order problem in stream-table joins: if event 10:03 arrives after event 10:05, the versioned store correctly returns the state at 10:03.
// Creating a versioned store (Kafka 3.5+)
StoreBuilder<VersionedKeyValueStore<String, Price>> priceStore =
Stores.versionedKeyValueStoreBuilder(
Stores.persistentVersionedKeyValueStore(
"prices-store",
Duration.ofDays(7) // history retention = 7 days
),
Serdes.String(), Serdes.forPrice()
);
History retention simultaneously serves as the grace period: events with timestamps older than the current stream time minus retention are dropped.
For temporal joins where correctness matters with out-of-order. For ordinary reference data — overkill. For large tables — version history takes a lot of space.
A state store is not only an internal cache for the processor. It can be read from outside via a REST API — replacing a separate database for query scenarios.
ReadOnlyKeyValueStore<String, Long> store = streams.store(
StoreQueryParameters.fromNameAndType(
"word-counts",
QueryableStoreTypes.keyValueStore()
)
);
Long count = store.get("kafka"); // point lookup
State is distributed across partitions, and each instance holds only its own share. For a "whole state" query, an RPC layer is needed: the instance that received the request determines which instance holds the key (via KafkaStreams#metadataForKey) and either responds locally or proxies the request.
StateQueryRequest<Long> request = StateQueryRequest.inStore("word-counts")
.withQuery(KeyQuery.withKey("kafka"));
StateQueryResult<Long> result = streams.query(request);
IQv2 returns a StateQueryResult with per-partition results and Position information — allowing assessment of data freshness. Supports KeyQuery (point lookup) and RangeQuery (scanning).
enableStaleStores() (KIP-535) — reading from standby replicas during recovery. Trades freshness for availability: "better a response lagging by a second than an exception on every deploy."
KeyValueStore grows infinitely. No built-in TTL, no eviction thread, no expireAfter. Every value written to a plain KV store stays forever — through restarts, through rebalance — until you delete it manually.
WindowStore automatically deletes segments older than retention, and its changelog uses a compact,delete policy. KeyValueStore uses compact-only changelog: a tombstone is never written automatically, so on recovery all "deleted" keys resurrect from the changelog.
store.delete(), but context.forward(key, null)). Without the tombstone, recovery from the changelog returns "deleted" data.public void init(ProcessorContext context) {
this.context = context;
this.dedupStore = (KeyValueStore<String, Long>) context.getStateStore("dedup");
context.schedule(
Duration.ofMinutes(30),
PunctuationType.WALL_CLOCK_TIME,
this::cleanupOldEntries
);
}
void cleanupOldEntries(long timestamp) {
long cutoff = timestamp - Duration.ofHours(24).toMillis();
try (KeyValueIterator<String, Long> it = dedupStore.all()) {
while (it.hasNext()) {
var entry = it.next();
if (entry.value < cutoff) {
dedupStore.delete(entry.key);
context.forward(entry.key, null); // tombstone to changelog
}
}
}
}
KIP-1225 (August 2025) — a proposal to add native TTL to TimestampedKeyValueStore via StoreBuilder.withTtl(Duration). Automatic expiration by embedded timestamp, tombstone-flush to the changelog, eviction by wall-clock time. Not yet implemented, but it closes the most common pain point.
Recovery from changelog is reliable but not instant. If state occupies gigabytes, replay can take minutes. For critical processes this is unacceptable.
Standby Replicas. While instance A actively processes partition P0, instance B in the background also reads the same changelog P0 and builds an identical copy of the state. When A fails, the controller instantly switches processing to B — the state is already ready.
Configuration: num.standby.replicas=1 — one standby replica per active task. Recovery time shrinks from minutes to seconds. Cost — double disk usage.
| Strategy | Recovery Time | Cost |
|---|---|---|
| Changelog replay | Minutes–hours | Base |
| Standby replicas | Seconds (5–30 s) | Double disk |
| Backup + restore | Minutes | Extra storage |
One of the most powerful operations is enriching an event stream with data from a state table in real time.
| Stream | Table | Result |
|---|---|---|
| User click | User profile | Click + demographics |
| Transaction | Currency rate | Amount in USD |
| Order | Stock on hand | Order + availability |
For correct operation, both sources must be co-partitioned: same number of partitions and same partitioner. The JOIN happens locally, in memory — without network calls.
The table in a stream-table join is live. It is continuously updated from its own event stream. If the profile changes, the KTable reflects it, and subsequent JOINs use fresh data.
An ordinary KeyValueStore stores only the latest value per key. If event 10:03 arrives after event 10:05 (out-of-order), the JOIN uses the already-updated value, not the one at 10:03 — the result is incorrect. Versioned store (KIP-889) solves this: get(key, 10:03) returns the value actual at 10:03.
Guaranteeing that every event is processed strictly once (exactly-once) is mathematically impossible in the general case. Networks break, timeouts fire, and the producer doesn't know whether the event was written. Therefore the industry operates with the term "effectively-once".
Kafka Streams since version 2.6 (renamed to exactly_once_v2 in 3.0) supports atomic transactions: reading the input event, updating the state store, writing to the output topic, and committing the offset — in one Kafka transaction. Either all is committed, or all is rolled back.
processing.guarantee=exactly_once_v2
| Metric | At-least-once | Exactly-once v2 |
|---|---|---|
| Output duplicates | Possible after failure | None (within Kafka) |
| Consumer isolation | read_uncommitted |
read_committed |
| Commit interval | 30s (default) | 100ms (auto-shrink, 300× more often) |
| Throughput | Base | -10–30% from base |
| Latency | Minimal | +8–30ms per commit |
v1 (exactly_once) was deprecated in Kafka 3.0 and removed in 4.0 — it required one producer per input partition, creating huge overhead. v2 is the only recommended mode.
Even with transactions, duplicates can slip through (if the producer retried sending before receiving an ack). The consumer must recognize and ignore duplicates:
event_id (UUID or business key + version).public void process(Record<String, OrderEvent> record) {
String eventId = record.value().getEventId();
if (dedupStore.get(eventId) != null) {
return; // already processed
}
// Business logic
long newTotal = currentTotal + record.value().getAmount();
balanceStore.put(record.key(), newTotal);
// Dedup write — in the same state store, atomic with business update
dedupStore.put(eventId, System.currentTimeMillis());
}
The most insidious scenario: the microservice processed the event, updated the state store and dedup table, but didn't manage to commit the offset. On restart it will read the same event again.
If the business operation is non-idempotent (balance += 100), reprocessing doubles the amount. That's why:
A microservice's state, changelog, offsets, and dedup table are one system. They must recover consistently. Breaking this connection (e.g., storing dedup in an external DB without a changelog) is a direct path to data loss or double charges.
A state store name is not just an identifier. It ends up in:
Renaming a store = changing the changelog topic = complete state loss on next restart.
// Weak name — implementation detail
Materialized.as("store1")
// Strong name — domain projection
Materialized.as("account-payment-total")
If two processors use stores with the same name, Kafka Streams throws a TopologyException. This is a common error during refactoring: renamed a store in one place, forgot in another.
Rule: store names must be unique across the entire topology. To avoid conflicts, use domain prefixes:
Materialized.as("account.balance")
Materialized.as("account.dedup")
Materialized.as("inventory.stock")
Materialized.as("inventory.dedup")
Kafka Streams doesn't support topology changes without resetting the store. If you changed processing logic, the old state may become incompatible.
--reset-local-state — deletes local RocksDB but preserves the changelog. On next start, state is restored from the changelog. Suitable if the logic is compatible.
Create a new store with a new name. The old changelog remains untouched. After validating the new pipeline — delete the old store and its changelog.
For breaking changes in topology, always use a new store name. This allows rolling back to the previous version without losing state.
A stateful application is not "set and forget." Without monitoring, you'll learn of a problem only when the disk overflows or recovery drags on for hours.
| Metric | What It Shows | Threshold |
|---|---|---|
rocksdb-block-cache-hit-ratio |
Cache efficiency | < 0.8 — alarm |
rocksdb-estimated-num-keys |
Number of keys in store | Grows linearly — check TTL |
rocksdb-cur-size-all-mem-tables |
MemTable size | Grows faster than flush — check write rate |
rocksdb-num-files-at-level-N |
Number of SSTables at level N | Grows without compaction — enable periodic |
rocksdb-block-cache-miss |
Cache misses | Growing — increase block cache |
Key metric: record-lag-max on the changelog topic. If lag grows — recovery will take longer. Alert at lag > 100k records or on growth within 15 minutes.
Measured via JMX or custom metric. Practical approach: simulate an instance crash in staging and measure full recovery time. This value must fit within the SLA.
If compaction lags — SSTable files accumulate, disk overflows. Metric: rocksdb-compaction-pending-bytes. Alert at > 10 GB or on growth within an hour.
| Criterion | Kafka Streams | Flink |
|---|---|---|
| State backend | Local RocksDB | RocksDB or disaggregated (Flink 2.0+) |
| Durability | Changelog topic (compacted) | Checkpoint in S3/HDFS |
| Recovery | Replay changelog (minutes) | Download checkpoint (seconds–minutes) |
| Large state | Up to 50–100 GB per instance | Terabytes via cluster |
| Incremental | Compaction = natural incrementality | Incremental checkpoints (delta) |
| Savepoints | No (only reset offset) | Yes (zero-downtime upgrades) |
| Exactly-once | exactly_once_v2 |
Checkpoint + two-phase commit |
| Disaggregated state | No | Yes (Flink 2.0 ForSt) |
Flink 2.0 (March 2025) introduced the disaggregated state backend (ForSt): state lives in remote object storage (S3) rather than on the local disk of a TaskManager. State pages in/out on demand.
Benefits:
Trade-offs:
When to choose:
| Chapter | Focus | Link to This Chapter |
|---|---|---|
| II. The Anatomy of EDA | Table-stream duality, CQRS | Materializing a stream into a table is the foundation of stateful processing |
| V. The Mechanics of Stream Processing | State stores, changelog, standby replicas | This chapter deepens: LSM, six types of stores, TTL |
| VI. Time in Streams | Event time, out-of-order | Versioned store (KIP-889) solves out-of-order in joins |
| VII. The Memory of Microservices | Stateful processing, effectively-once | One system: state + changelog + offset + dedup |
account-payment-total, not store1)periodicCompactionSeconds, deleteObsoleteFilesPeriodMicrosnum.standby.replicas >= 1 for critical stateful servicesprocessing.guarantee=exactly_once_v2 for business-critical pipelinesrocksdb-block-cache-hit-ratio > 0.8rocksdb-estimated-num-keys — not growing linearly without TTLrocksdb-compaction-pending-bytes < 10 GBrecord-lag-max of changelog < 100k recordsUnderstanding these mechanisms allows you to build systems that don't just "work in happy scenarios" but survive the chaos of real distributed infrastructures — with network partitions, server restarts, and duplicate events.
Chapter VII of the "Event-Driven Architecture" series. Sources: Apache Kafka, Confluent, Conduktor, Automq, Lydtech Consulting, Habr (the "Magnit" case), RisingWave, Software Patterns Lexicon, KIP-889, KIP-796, KIP-535, KIP-1225.