FinCodeCow · Глава VII

Память микросервисов

Потоковая обработка с поддержкой состояния: от LSM-дерева RocksDB до effectively-once

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

Эта глава погружается в самую сложную и одновременно самую мощную часть событийно-управляемой архитектуры — потоковую обработку с поддержкой состояния (Stateful Stream Processing). Мы разберём, как состояние рождается из потока событий, как оно защищается от потери при сбоях, какие типы хранилищ существуют и почему дубликаты — это не баг, а неизбежная реальность распределённых систем.

Это седьмая глава цикла. Она опирается на главу II (table-stream duality, CQRS), главу V (state stores, changelog, standby replicas) и главу VI (event time, out-of-order). Здесь мы углубляем каждую из этих тем.

• • •

IДуальность «Поток — Таблица»: философский камень EDM

В основе stateful-обработки лежит одна из самых элегантных концепций событийной архитектуры: поток событий и таблица состояний — это два представления одной и той же сущности.

Поток (Stream) Таблица (Table)
Неизменяемая, упорядоченная по времени последовательность фактов Снимок текущего состояния на определённый момент
«Цена товара X изменилась на $10», «Затем на $15», «Затем на $12» «Цена товара X = $12»
История изменений Результат применения всех событий

Эта дуальность работает в обе стороны. Любую таблицу можно превратить в поток, записывая каждое её изменение как событие (паттерн Change Data Capture). И любой поток можно превратить в таблицу, последовательно применяя все его события (процесс материализации).

Основа всего

Материализация потока в таблицу — это фундамент для всех stateful-операций: агрегаций, окон, joins, дедупликации. Именно поэтому table-stream duality — первый раздел этой главы, а не последний.

• • •

IIChangelog и checkpoint: страховка от амнезии

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

Решение — журнал изменений (changelog topic). Это специальный compacted Kafka-топик, куда микросервис записывает каждое изменение своего внутреннего состояния. По сути, это «страховочная копия» таблицы, разложенная на последовательность фактов.

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

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

Checkpoint file

Kafka Streams дополнительно ведёт локальный checkpoint-файл, отслеживающий последний offset, для которого состояние сохранено. При рестарте приложение сначала проверяет checkpoint: если он есть, восстановление начинается с последней позиции, а не с нуля.

Критическая деталь

Changelog topic должен иметь то же число партиций, что и state store. Если партиции не совпадают — восстановление не сработает корректно. Kafka Streams создаёт changelog автоматически, но при ручной настройке это нужно проверить.

• • •

IIIRocksDB под капотом: LSM-дерево и практические последствия

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)
  1. Запись попадает в in-memory MemTable — отсортированный буфер.
  2. При заполнении MemTable flush'ится на диск как SSTable — неизменяемый отсортированный файл.
  3. Фоновая compaction сливает несколько SSTable в более крупные, удаляя дубликаты и освобождая место.

Tombstone: удаление, которого нет

При удалении ключа RocksDB не удаляет запись физически — записывается специальный маркер tombstone. Во время чтения RocksDB видит tombstone и не возвращает значение. Но сама запись и tombstone продолжают занимать место в SSTable-файлах до compaction.

Реальный кейс «Магнита»

Команда «Магнит» столкнулась с ростом RocksDB при регулярном удалении десятков тысяч записей: логически данные исчезали, но физический размер не менялся. Решение — включить periodic compaction (каждый час вместо дефолтного «выключено») и сократить интервал удаления obsolete files.

// Принудительный compaction каждый час (дефолт = 0, выключен!)
options.setPeriodicCompactionSeconds(3600);
// Удаление obsolete файлов каждый час
options.setDeleteObsoleteFilesPeriodMicros(3600 * 1000000L);

Bloom filters

RocksDB по умолчанию использует Bloom filters для ускорения point lookups. Это критически важно для дедупликации: проверка «был ли уже этот event_id» должна быть быстрой. Без Bloom filters каждая проверка сканировала бы несколько SSTable.

• • •

IVШесть типов state stores: не один размер для всех

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 Бесконечная

Persistent KeyValueStore

Дефолт. Хранит последнее значение на ключ. Растёт бесконечно: нет встроенного TTL. Используется для счётчиков, дедупликации, материализации CQRS read models.

Window Store

Для оконных агрегаций. Сегменты старше retention автоматически удаляются, changelog использует compact,delete политику. Ограничение размера — встроенное.

Session Store

Динамические окна на основе сессий. Ключевая особенность — merge: если новая активность «дотягивается» до предыдущей (gap < timeout), два окна объединяются.

Стоимость merge сессий

Слияние сессий требует копирования всего состояния двух окон в новое. Для больших сессий с миллионами событий это дорогая операция. Настройте максимальный размер сессии или используйте timeout агрессивнее.

Global Store

Полная репликация всех партиций на каждый инстанс. Не требует соподразделения. Подходит для компактных справочников.

• • •

VVersioned store и KIP-889: время в state store

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 — отбрасываются.

Когда использовать versioned store

Для temporal joins, где важна корректность при out-of-order. Для обычных справочников — избыточно. Для больших таблиц — история версий занимает много места.

• • •

VIInteractive Queries: IQv1 и IQv2

State store — не только внутренний кэш для обработчика. Его можно читать снаружи через REST API — это заменяет отдельную базу данных для query-сценариев.

IQv1 — запрос локального state store

ReadOnlyKeyValueStore<String, Long> store = streams.store(
    StoreQueryParameters.fromNameAndType(
        "word-counts",
        QueryableStoreTypes.keyValueStore()
    )
);
Long count = store.get("kafka");  // point lookup

Состояние распределено по партициям, каждый инстанс хранит только свою долю. Для запроса «по всему состоянию» нужен RPC-слой: инстанс, получивший запрос, определяет, на каком инстансе живёт нужный ключ (через KafkaStreams#metadataForKey), и либо отвечает локально, либо проксирует запрос на удалённый инстанс.

IQv2 — composable Query-объекты (KIP-796)

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 (сканирование).

Stale stores

enableStaleStores() (KIP-535) — чтение из standby-реплик во время восстановления. Жертвует свежестью ради доступности: «лучше ответ, отстающий на секунду, чем исключение при каждом деплое».

• • •

VIITTL-проблема и KIP-1225

KeyValueStore растёт бесконечно. Нет встроенного TTL, нет eviction thread, нет expireAfter. Каждое значение, записанное в plain KV store, остаётся навсегда — через рестарты, через rebalance — пока вы не удалите его вручную.

Почему window stores истекают, а KV — нет

WindowStore автоматически удаляет сегменты старше retention, а его changelog использует compact,delete политику. KeyValueStore использует compact-only changelog: tombstone никогда не записывается автоматически, поэтому при восстановлении все «удалённые» ключи воскресают из changelog.

Три рабочих паттерна очистки

  1. Re-key в WindowStore. Переподразделить данные по entity-ключу в WindowStore с retention = желаемый TTL. Сегменты истекают автоматически, changelog — тоже. Нет punctuator, нет ручных tombstones, нет риска воскрешения.
  2. Punctuator + ручной tombstone. Processor API: периодически сканировать store, удалять устаревшие ключи и записывать tombstone в changelog (не просто store.delete(), а context.forward(key, null)). Без tombstone восстановление из changelog вернёт «удалённые» данные.
  3. TimestampStore + пакетное удаление. Отдельный store, хранящий timestamp последнего изменения каждого ключа. Периодически запускать очистку: найти ключи старше TTL, пакетно удалить из всех связанных stores, записать tombstones.

Пример punctuator для очистки

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: будущее TTL

KIP-1225 (август 2025) — предложение добавить нативный TTL в TimestampedKeyValueStore через StoreBuilder.withTtl(Duration). Автоматическое истекание по embedded timestamp, tombstone-flush в changelog, eviction по wall-clock time. Пока не реализовано, но закрывает самую частую боль.

• • •

VIIIГорячие реплики: мгновенное восстановление

Восстановление из changelog — надёжное, но не мгновенное. Если состояние занимает гигабайты, replay может занять минуты. Для критичных процессов это неприемлемо.

Горячие реплики (Standby Replicas). Пока инстанс А активно обрабатывает партицию P0, инстанс Б в фоновом режиме тоже читает тот же changelog P0 и строит идентичную копию состояния. При отказе А контроллер мгновенно переключает обработку на Б — состояние уже готово.

Настройка: num.standby.replicas=1 — одна резервная реплика на каждую активную задачу. Время восстановления сокращается с минут до секунд. Цена — двойной расход диска.

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

IXStream-Table Joins: обогащение в реальном времени

Одна из самых мощных операций — обогащение потока событий данными из таблицы состояний в реальном времени.

Поток (Stream) Таблица (Table) Результат
Клик пользователя Профиль пользователя Клик + демография
Транзакция Курс валют Сумма в USD
Заказ Остаток на складе Заказ + availability

Для корректной работы оба источника должны быть соподразделены: одинаковое число партиций и один partitioner. JOIN происходит локально, в памяти — без сетевых вызовов.

Таблица в stream-table join — живая. Она постоянно обновляется из своего потока событий. Если профиль изменился, KTable это отразит, и последующие JOIN-ы будут использовать свежие данные.

Проблема классического KV store

Обычный KeyValueStore хранит только последнее значение на ключ. Если событие 10:03 пришло после события 10:05 (out-of-order), JOIN использует уже обновлённое значение, а не то, что было на 10:03 — результат некорректен. Versioned store (KIP-889) решает: get(key, 10:03) возвращает значение, актуальное на 10:03.

• • •

XEffectively-once и дедупликация

Гарантировать, что каждое событие будет обработано строго один раз (exactly-once), математически невозможно в общем случае. Сети рвутся, таймауты срабатывают, и продюсер не знает, было ли событие записано. Поэтому индустрия оперирует термином «практически однократная обработка» (effectively-once).

Транзакции «Клиент — Брокер» (exactly_once_v2)

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). Потребитель должен распознавать и игнорировать дубликаты:

  1. Уникальный ID — каждое событие получает event_id (UUID или бизнес-ключ + версия).
  2. Таблица дедупликации — materialized store обработанных ID. Перед обработкой — проверка.
  3. Атомарная проверка-и-запись — проверка дубликата и обновление бизнес-состояния в одной операции.
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), повторная обработка удвоит сумму. Именно поэтому:

  • Обновление состояния и фиксация offset должны быть атомарны (exactly_once_v2).
  • Таблица дедупликации хранится в том же state store, что и бизнес-данные, и восстанавливается из того же changelog.
Ключевой инсайт

Состояние микросервиса, журнал изменений, смещения и таблица дедупликации — это единая система. Они должны восстанавливаться согласованно. Разрыв этой связности (например, хранение дедупликации в отдельной БД без changelog) — прямой путь к потере данных или двойным списаниям.

• • •

XIИмя state store — это API decision

Имя state store не просто идентификатор. Оно попадает в:

  • Имена internal topics (changelog, repartition)
  • Метрики и дашборды
  • Interactive query код
  • ACLs и governance tooling
  • Runbooks и alerting

Переименование store = смена changelog topic = полная потеря состояния при следующем рестарте.

// Слабое имя — implementation detail
Materialized.as("store1")

// Сильное имя — domain projection
Materialized.as("account-payment-total")
• • •

XIIМиграция и name collision

State store name collision

Если два processor'а используют store с одинаковым именем — Kafka Streams выбросит TopologyException. Это частая ошибка при рефакторинге: переименовали store в одном месте, забыли в другом.

Правило: имена store должны быть уникальны в пределах всей топологии. Для избежания конфликтов используйте префиксы по домену:

Materialized.as("account.balance")
Materialized.as("account.dedup")
Materialized.as("inventory.stock")
Materialized.as("inventory.dedup")

State store migration: как обновить топологию

Kafka Streams не поддерживает изменение топологии без сброса store. Если вы изменили логику обработки — старое состояние может стать несовместимым.

Вариант 1: Reset local state

--reset-local-state — удаляет локальный RocksDB, но сохраняет changelog. При следующем запуске состояние восстанавливается из changelog. Подходит, если логика совместима.

Вариант 2: New store name

Создайте новый store с новым именем. Старый changelog останется нетронутым. После проверки нового пайплайна — удалите старый store и его changelog.

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

При breaking changes в топологии всегда используйте новое имя store. Это даёт возможность откатиться на старую версию без потери состояния.

• • •

XIIIМониторинг stateful приложения

Stateful приложение — это не «настроил и забыл». Без мониторинга вы узнаете о проблеме только когда диск переполнится или восстановление затянется на часы.

Метрики RocksDB

Метрика Что показывает Порог
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

Changelog lag

Ключевая метрика: record-lag-max по changelog topic. Если lag растёт — восстановление займёт больше времени. Алерт при lag > 100k записей или при росте за 15 минут.

Время восстановления

Замеряется через JMX или custom metric. Практический способ: имитировать падение инстанса в staging и замерить, сколько времени занимает полное восстановление. Это значение должно укладываться в SLA.

Compaction lag

Если compaction отстаёт — SSTable-файлы копятся, диск переполняется. Метрика: rocksdb-compaction-pending-bytes. Алерт при > 10 GB или при росте за час.

• • •

XIVFlink vs Kafka Streams: две модели состояния

Критерий 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: disaggregated state backend (ForSt)

Flink 2.0 (март 2025) представил disaggregated state backend (ForSt): состояние живёт в remote object storage (S3), а не на локальном диске TaskManager. State pages in/out по мере необходимости.

Что это даёт:

  • Decouples state size от диска. Не нужно выделять SSD под каждый TaskManager.
  • Ускоряет rescaling. TaskManagers просто начинают читать другие key partitions из S3.
  • Упрощает эксплуатацию. State — это просто объекты в S3, а не тома на локальных дисках.

Trade-offs:

  • Latency. Page in/out из S3 занимает десятки миллисекунд, а не микросекунды, как локальный RocksDB.
  • Стоимость. Оплата за S3 API calls + storage.
  • Не для всех workloads. Для latency-sensitive задач (real-time fraud detection) лучше embedded RocksDB.

Когда выбирать:

  • Kafka Streams — pipeline Kafka-to-Kafka, moderate state (до 50–100 GB), нужна простота деплоя.
  • Flink — большой state (терабайты), сложная аналитика, multi-source, savepoints для zero-downtime upgrades, ML-пайплайны.
  • Flink + disaggregated state — state > 1 TB, elastic rescaling, batch + streaming unified.
• • •

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

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

Глава Фокус Связь с текущей главой
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

Практический чеклист для stateful приложения

  • State store назван осмысленно (account-payment-total, а не store1)
  • Выбран правильный тип store (KV для latest, window для агрегаций, versioned для temporal joins)
  • Для бесконечно растущего KV store реализована очистка (punctuator + tombstone, или re-key в window store)
  • RocksDB настроен: periodicCompactionSeconds, deleteObsoleteFilesPeriodMicros
  • Changelog topic имеет то же число партиций, что и state store
  • num.standby.replicas >= 1 для критичных stateful сервисов
  • processing.guarantee=exactly_once_v2 для business-critical пайплайнов
  • Таблица дедупликации живёт в state store, а не во внешней БД
  • Interactive Queries настроены с RPC-слоем для distributed state
  • Имена store уникальны в пределах топологии (префиксы по домену)
  • При breaking changes — новый store name вместо переименования
  • Мониторинг: state store size, RocksDB compaction lag, changelog lag
  • Проверено: время восстановления после потери local state приемлемо

Чеклист мониторинга

  • rocksdb-block-cache-hit-ratio > 0.8
  • rocksdb-estimated-num-keys — не растёт линейно без TTL
  • rocksdb-compaction-pending-bytes < 10 GB
  • record-lag-max changelog < 100k записей
  • Время восстановления измерено и укладывается в SLA
  • Алерты на рост state store size настроены
Финальный тезис

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

Глава VII цикла «Событийно-управляемая архитектура». Источники: Apache Kafka, Confluent, Conduktor, Automq, Lydtech Consulting, Habr (кейс «Магнита»), RisingWave, Software Patterns Lexicon, KIP-889, KIP-796, KIP-535, KIP-1225.

FinCodeCow · Chapter VII

The Memory of Microservices

Stateful stream processing: from RocksDB's LSM tree to effectively-once

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.

• • •

IStream-Table Duality: The Philosopher's Stone of EDM

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).

The Foundation of Everything

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.

• • •

IIChangelog and Checkpoint: Insurance Against Amnesia

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.

Checkpoint File

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.

Critical Detail

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.

• • •

IIIRocksDB Under the Hood: LSM Tree and Practical Consequences

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)
  1. A write goes to the in-memory MemTable — a sorted buffer.
  2. When the MemTable fills up, it is flushed to disk as an SSTable — an immutable sorted file.
  3. Background compaction merges several SSTables into larger ones, removing duplicates and freeing space.

Tombstone: The Deletion That Isn't

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 Real "Magnit" Case

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);

Bloom Filters

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.

• • •

IVSix Types of State Stores: Not One Size for All

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

Persistent KeyValueStore

The default. Stores the latest value per key. Grows infinitely: no built-in TTL. Used for counters, deduplication, materializing CQRS read models.

Window Store

For windowed aggregations. Segments older than retention are automatically deleted, and the changelog uses a compact,delete policy. Size limit is built-in.

Session Store

Dynamic windows based on sessions. The key feature is merge: if new activity "reaches" the previous one (gap < timeout), two windows combine.

Cost of Session Merge

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.

Global Store

Full replication of all partitions to every instance. Doesn't require co-partitioning. Suitable for compact reference data.

• • •

VVersioned Store and KIP-889: Time in the State Store

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.

When to Use Versioned Store

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.

• • •

VIInteractive Queries: IQv1 and IQv2

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.

IQv1 — Querying the Local State Store

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.

IQv2 — Composable Query Objects (KIP-796)

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).

Stale Stores

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."

• • •

VIITTL Problem and KIP-1225

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.

Why Window Stores Expire but KV Doesn't

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.

Three Working Cleanup Patterns

  1. Re-key into WindowStore. Repartition data by entity key into a WindowStore with retention = desired TTL. Segments expire automatically, and the changelog does too. No punctuator, no manual tombstones, no resurrection risk.
  2. Punctuator + manual tombstone. Processor API: periodically scan the store, delete stale keys, and write a tombstone to the changelog (not just store.delete(), but context.forward(key, null)). Without the tombstone, recovery from the changelog returns "deleted" data.
  3. TimestampStore + batch deletion. A separate store holding the timestamp of the last change per key. Periodically run cleanup: find keys older than TTL, batch-delete from all related stores, write tombstones.

Punctuator Cleanup Example

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: The Future of TTL

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.

• • •

VIIIHot Replicas: Instant Recovery

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
• • •

IXStream-Table Joins: Real-Time Enrichment

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.

The Problem of Classic KV Store

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.

• • •

XEffectively-once and Deduplication

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".

Client–Broker Transactions (exactly_once_v2)

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.

Consumer-Side Idempotency

Even with transactions, duplicates can slip through (if the producer retried sending before receiving an ack). The consumer must recognize and ignore duplicates:

  1. Unique ID — each event gets an event_id (UUID or business key + version).
  2. Deduplication table — a materialized store of processed IDs. Before processing — check.
  3. Atomic check-and-write — dedup check and business state update in one operation.
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());
}

Maintaining Consistent State on Failures

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:

  • State update and offset commit must be atomic (exactly_once_v2).
  • The dedup table is stored in the same state store as business data and restored from the same changelog.
Key Insight

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.

• • •

XIStore Name Is an API Decision

A state store name is not just an identifier. It ends up in:

  • Internal topic names (changelog, repartition)
  • Metrics and dashboards
  • Interactive query code
  • ACLs and governance tooling
  • Runbooks and alerting

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")
• • •

XIIMigration and Name Collision

State Store Name Collision

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")

State Store Migration: How to Update the Topology

Kafka Streams doesn't support topology changes without resetting the store. If you changed processing logic, the old state may become incompatible.

Option 1: Reset Local State

--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.

Option 2: New Store Name

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.

Practical Rule

For breaking changes in topology, always use a new store name. This allows rolling back to the previous version without losing state.

• • •

XIIIMonitoring a Stateful Application

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.

RocksDB Metrics

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

Changelog Lag

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.

Recovery Time

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.

Compaction Lag

If compaction lags — SSTable files accumulate, disk overflows. Metric: rocksdb-compaction-pending-bytes. Alert at > 10 GB or on growth within an hour.

• • •

XIVFlink vs Kafka Streams: Two State Models

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: Disaggregated State Backend (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:

  • Decouples state size from disk. No need to allocate SSD for every TaskManager.
  • Speeds up rescaling. TaskManagers simply start reading other key partitions from S3.
  • Simplifies operation. State is just objects in S3, not volumes on local disks.

Trade-offs:

  • Latency. Page in/out from S3 takes tens of milliseconds, not microseconds like local RocksDB.
  • Cost. Payment for S3 API calls + storage.
  • Not for all workloads. For latency-sensitive tasks (real-time fraud detection), embedded RocksDB is better.

When to choose:

  • Kafka Streams — Kafka-to-Kafka pipeline, moderate state (up to 50–100 GB), deployment simplicity needed.
  • Flink — large state (terabytes), complex analytics, multi-source, savepoints for zero-downtime upgrades, ML pipelines.
  • Flink + disaggregated state — state > 1 TB, elastic rescaling, batch + streaming unified.
• • •

XVChecklists and Link to the Series

Link to Previous Chapters

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

Practical Checklist for a Stateful Application

  • State store named meaningfully (account-payment-total, not store1)
  • Correct store type chosen (KV for latest, window for aggregations, versioned for temporal joins)
  • Cleanup implemented for infinitely growing KV stores (punctuator + tombstone, or re-key into window store)
  • RocksDB configured: periodicCompactionSeconds, deleteObsoleteFilesPeriodMicros
  • Changelog topic has the same number of partitions as the state store
  • num.standby.replicas >= 1 for critical stateful services
  • processing.guarantee=exactly_once_v2 for business-critical pipelines
  • Deduplication table lives in the state store, not in an external DB
  • Interactive Queries configured with an RPC layer for distributed state
  • Store names unique within topology (domain prefixes)
  • On breaking changes — new store name instead of renaming
  • Monitoring: state store size, RocksDB compaction lag, changelog lag
  • Verified: recovery time after loss of local state is acceptable

Monitoring Checklist

  • rocksdb-block-cache-hit-ratio > 0.8
  • rocksdb-estimated-num-keys — not growing linearly without TTL
  • rocksdb-compaction-pending-bytes < 10 GB
  • record-lag-max of changelog < 100k records
  • Recovery time measured and fits within SLA
  • Alerts on state store size growth configured
Final Thesis

Understanding 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.