Skip to main content

Kafka Streams โ€” State Stores & RocksDB Tuning

State stores are local key-value databases that hold aggregation state, join tables, and custom business context. Understanding their internal architecture, disk flushing behavior, and off-heap memory footprint is essential for production performance and stability.


1. Internal Architecture

Kafka Streams State Stores: Write Cache, RocksDB LSM-Tree & Changelogs
Stream Updatesput(K, V)1. RAM Write CacheDedup & Buffer (10MB)2. Embedded RocksDBLocal SSD / LSM-Tree3. Changelog TopicCompacted Kafka Log
TASK STATE STORE STORAGE PIPELINE
Tier 1: In-Memory Write Cache (RAM)Default 10MB

Buffers stream updates in memory, deduplicates successive writes by key, and batches emissions to downstream nodes.

โ–ผ commit.interval.ms flush()
Tier 2: RocksDB Embedded LSM-Tree (Local SSD)Sub-ms Access

Local key-value engine with MemTable, WAL (Write-Ahead Log), Block Cache, and immutable SSTables with Bloom filters.

โ–ผ Async changelog write & replication
Tier 3: Kafka Changelog Topic (Remote Durability)Log Compaction

Compacted internal Kafka topic (`cleanup.policy=compact,delete`). Provides crash-recovery replay source and standby sync.

WHY 3 TIERS?

Write Amplification Protection + Zero-Loss Durability

If every incoming stream event hit RocksDB and Kafka changelogs directly, high-volume key updates (e.g. 100K clicks/sec on 10 popular keys) would overwhelm local disk I/O and network bandwidth.

Key Tuning Knobs:
  • `statestore.cache.max.bytes`: Global cache pool shared across all tasks (default 10MB).
  • `commit.interval.ms`: 100ms with EOS, 30s otherwise. Controls how often Tier 1 flushes to Tier 2 & 3.

State Store Types

// 1. KeyValueStore โ€” simple key-value (most common)
Materialized.<String, Long, KeyValueStore<Bytes, byte[]>>as("count-store")
.withKeySerde(Serdes.String())
.withValueSerde(Serdes.Long());

// 2. WindowStore โ€” keyed by (key, window-start-time)
Materialized.<String, Long, WindowStore<Bytes, byte[]>>as("windowed-count-store");

// 3. SessionStore โ€” keyed by (key, session-start, session-end)
Materialized.<String, Long, SessionStore<Bytes, byte[]>>as("session-store");

// 4. In-Memory Store โ€” state stored in heap (lost on crash, rebuilt from changelog)
Materialized.<String, Long, KeyValueStore<Bytes, byte[]>>as("ephemeral-store")
.withStoreType(Materialized.StoreType.IN_MEMORY);

2. RocksDB โ€” Why It Is Used

Kafka Streams State Stores: Write Cache, RocksDB LSM-Tree & Changelogs
1. MemTableIn-memory Skiplist2. Append WALSequential Disk Log3. Flush SSTableLevel 0 Immutable4. CompactionLeveled Merge
1. MemTable Insert: Written to in-memory skiplist (sub-microsecond latency).
2. WAL Append: Sequentially appended to Write-Ahead Log on disk for durability.
3. SSTable Flush: Full MemTable becomes immutable and flushes to disk SSTable file.
4. Background Compaction: Leveled compaction merges SSTables and removes old versions.
SENIOR PERFORMANCE NOTE

Off-Heap Memory & RocksDB Block Cache Sizing

RocksDB allocations occur in native off-heap memory (C++), outside JVM `-Xmx` heap limits. In container environments (Kubernetes), forgetting to budget off-heap memory causes the Linux kernel OOM killer to terminate pods silently. Always use `RocksDBConfigSetter` to cap the shared block cache.

RocksDB is a Log-Structured Merge-tree (LSM-tree) embedded key-value database, optimized for write-heavy workloads on fast SSDs.

Why RocksDB over a Java HashMap?

  • Dataset can exceed available RAM โ€” RocksDB spills to local disk transparently.
  • Supports fast range queries โ€” required for windowed aggregations.
  • Crash-safe via Write-Ahead Log (WAL) โ€” data survives JVM crashes without requiring a full changelog replay from Kafka brokers.
  • Highly tunable memory/disk trade-off via block cache size, write buffer sizes, and compression.

3. How RocksDB Interacts with Linux OS Page Cache

A frequent architectural point of confusion: Does Kafka Streams have a Page Cache, or is that only on the Kafka Broker?

Both use the Linux OS Page Cache, but in fundamentally different ways:

  • On the Kafka Broker: The Page Cache directly buffers topic partition log segment files (.log), and sendfile(2) streams them to the network via Zero-Copy DMA without touching user space.
  • In Kafka Streams: The Page Cache runs on your client application host or Kubernetes pod, acting as a secondary disk cache for RocksDB:
Kafka Streams Memory Hierarchy on Client Host / K8s Pod:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Tier 1: JVM Heap Memory (-Xmx) โ”‚
โ”‚ - Topology DAG, SerDes, StreamThread queues, POJO records โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Tier 2: RocksDB Off-Heap Native Memory (C++ via JNI) โ”‚
โ”‚ - MemTable: In-memory write buffer for fast mutations โ”‚
โ”‚ - Block Cache: Decompressed hot key-value data blocks โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Tier 3: Linux OS Page Cache (Host / Container Kernel RAM) โ”‚
โ”‚ - Caches compressed SSTable (.sst) files and WAL logs โ”‚
โ”‚ - Prevents physical disk I/O when Block Cache misses occur โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Tier 4: Physical Storage (Local NVMe / Persistent Volume) โ”‚
โ”‚ - Flushed SSTables, .checkpoint file โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
  1. Read Request Path: When your stream topology queries a state store (store.get(key)):
    • It checks the RocksDB in-memory Block Cache in off-heap C++ RAM.
    • On a block cache miss, RocksDB performs a file read. The Linux kernel intercepts this read and checks the OS Page Cache. If the SST page is present in Page Cache, it is read into native memory at RAM bus speeds without hitting storage media.
    • Only on a cold Page Cache miss does the kernel trigger physical NVMe/SSD read I/O.
  2. Kubernetes Memory Planning Hazard:
    • In containerized environments (Kubernetes), developers frequently set container limits based only on the JVM -Xmx setting (e.g. limit: 4Gi, -Xmx3g).
    • Because RocksDB allocates native C++ memory for its Block Cache and MemTables outside the JVM, and the Linux kernel allocates Page Cache for SST files within the container cgroup, total memory usage will exceed 4 GiB, triggering an OOMKilled (Exit Code 137) termination.
    • Production Sizing Formula: Containerย Memoryย Limitโ‰ฅJVMย Heapย (โˆ’Xmx)+RocksDBย Blockย Cache+MemTables+Pageย Cacheย Bufferย (25โ€“30%)\text{Container Memory Limit} \ge \text{JVM Heap } (-Xmx) + \text{RocksDB Block Cache} + \text{MemTables} + \text{Page Cache Buffer (25--30\%)}

4. Bounding Native Off-Heap Memory with RocksDBConfigSetter

By default, every state store in every task creates its own independent RocksDB instance, each allocating its own Block Cache (default 32MB) and MemTables (3 ร—\times 16MB). If an application has 8 active tasks and 3 state stores per task, that equals 8ร—3=248 \times 3 = 24 independent RocksDB instances! 24ร—(32MBย Blockย Cache+48MBย MemTables)โ‰ˆ1.92ย GBย nativeย C++ย RAM24 \times (32\text{MB Block Cache} + 48\text{MB MemTables}) \approx 1.92\text{ GB native C++ RAM} Without global bounding, native memory balloons uncontrollably under load and triggers a container OOMKilled (Exit Code 137).

The Solution: Shared Cache and Shared WriteBufferManager

Implement org.apache.kafka.streams.state.RocksDBConfigSetter to enforce a single global memory pool shared across all RocksDB instances on the JVM:

import org.apache.kafka.streams.state.RocksDBConfigSetter;
import org.rocksdb.BlockBasedTableConfig;
import org.rocksdb.BloomFilter;
import org.rocksdb.Cache;
import org.rocksdb.CompactionStyle;
import org.rocksdb.CompressionType;
import org.rocksdb.LRUCache;
import org.rocksdb.Options;
import org.rocksdb.WriteBufferManager;
import java.util.Map;

public class CustomRocksDBConfigSetter implements RocksDBConfigSetter {

// Shared native memory budget across ALL RocksDB instances in this JVM process
// Total Native RocksDB Cap = 512 MB (Block Cache + Write Buffers)
private static final Cache SHARED_BLOCK_CACHE = new LRUCache(384 * 1024 * 1024L); // 384 MB
private static final WriteBufferManager SHARED_WRITE_BUFFER_MANAGER =
new WriteBufferManager(128 * 1024 * 1024L, SHARED_BLOCK_CACHE); // 128 MB write buffer charged to cache

@Override
public void setConfig(String storeName, Options options, Map<String, Object> configs) {
// 1. Enforce global shared write buffer manager
options.setWriteBufferManager(SHARED_WRITE_BUFFER_MANAGER);

// 2. Table options: attach shared block cache and configure bloom filter
BlockBasedTableConfig tableConfig = new BlockBasedTableConfig();
tableConfig.setBlockCache(SHARED_BLOCK_CACHE);
tableConfig.setBlockSize(16 * 1024L); // 16 KB block size (improves point lookup performance)
tableConfig.setCacheIndexAndFilterBlocks(true); // Cache index/filter in the shared block cache
tableConfig.setFilterPolicy(new BloomFilter(10, false)); // 10 bits per key (~1% false positive rate)
options.setTableFormatConfig(tableConfig);

// 3. Compaction and I/O tuning
options.setCompactionStyle(CompactionStyle.LEVEL);
options.setCompressionType(CompressionType.LZ4_COMPRESSION); // Fast compression, low CPU overhead
options.setMaxWriteBufferNumber(3);
options.setWriteBufferSize(32 * 1024 * 1024L); // 32 MB per memtable
options.setMaxBackgroundJobs(4); // Parallel flush and compaction threads
}

@Override
public void close(String storeName, Options options) {
// RocksDBConfigSetter close hook (resources cleaned on JVM shutdown)
}
}

Enable it in your streams configuration:

props.put(StreamsConfig.ROCKSDB_CONFIG_SETTER_CLASS_CONFIG, CustomRocksDBConfigSetter.class.getName());

5. State Store TTL & Expiration Patterns

Unbounded state stores are the primary cause of disk space exhaustion and prolonged recovery times in production.

Store TypeBuilt-In Retention MechanismHow TTL Works
WindowStoreNative TimeWindows.ofSizeAndGrace(...)Segments older than windowSize + gracePeriod are physically dropped
SessionStoreNative SessionWindows.ofInactivityGapAndGrace(...)Expired sessions dropped once inactivity gap + grace period passes
KeyValueStoreNone by default (Retained indefinitely!)Requires KIP-653 / KIP-1033 state store TTL or custom Punctuator

Key-Value Store Expiration via Processor API Punctuator

public class TtlCleanupProcessor extends ContextualProcessor<String, ValueWithTimestamp<String>, String, String> {

private KeyValueStore<String, ValueWithTimestamp<String>> stateStore;
private static final long TTL_MS = Duration.ofHours(6).toMillis();

@Override
public void init(ProcessorContext<String, String> context) {
super.init(context);
this.stateStore = context.getStateStore("ttl-store");

// Schedule wall-clock punctuator to purge expired keys every 15 minutes
context.schedule(Duration.ofMinutes(15), PunctuationType.WALL_CLOCK_TIME, timestamp -> {
try (KeyValueIterator<String, ValueWithTimestamp<String>> iterator = stateStore.all()) {
while (iterator.hasNext()) {
KeyValue<String, ValueWithTimestamp<String>> entry = iterator.next();
if (timestamp - entry.value.getTimestamp() > TTL_MS) {
stateStore.delete(entry.key); // Writes tombstone to state store and changelog
}
}
}
});
}

@Override
public void process(Record<String, ValueWithTimestamp<String>> record) {
stateStore.put(record.key(), record.value());
context().forward(record.withValue(record.value().getValue()));
}
}

6. Changelog Topics & Durability

Every persistent state store is backed by a compacted Kafka changelog topic (cleanup.policy=compact). Total changelog size is bounded by the number of unique keys rather than event history.

Map<String, String> changelogConfig = Map.of(
"min.insync.replicas", "2",
"replication.factor", "3",
"retention.ms", "-1", // Never expire
"cleanup.policy", "compact"
);

Materialized.as("order-summary-store")
.withLoggingEnabled(changelogConfig);

7. The .checkpoint File Anatomy & Hazard

Kafka Streams State Stores: Write Cache, RocksDB LSM-Tree & Changelogs
Task State FlushRocksDB.flush() on syncWrite .checkpoint FilePersist Changelog OffsetsFast RestartSkip Replay
PERSISTED .checkpoint PARTITION OFFSETS
Partition 0Offset 1,420,580
Topic: order-enrichment-agg-store-changelog โ€ข Local SST: 1.2 GB
Partition 1Offset 1,398,210
Topic: order-enrichment-agg-store-changelog โ€ข Local SST: 1.1 GB
Partition 2Offset 1,450,012
Topic: order-enrichment-agg-store-changelog โ€ข Local SST: 1.4 GB
CRASH RECOVERY LIFECYCLE

Partition 0 Restore Boundary

Committed changelog offset safely written to local disk. Skips replay of 1.42M records.

โ€ข Clean Shutdown: Writes .checkpoint file with offset 1420580. On restart, RocksDB is 100% warm; 0 changelog replay required.
โ€ข Crash (OOM / Kill -9): .checkpoint file is absent or stale. Task replays changelog starting from last persistent commit boundary.

Located on disk at <state.dir>/<application.id>/<task_id>/.checkpoint, this plain text file represents the watermark up to which RocksDB data has been fsynced to storage:

0 <-- Checkpoint file version (0)
2 <-- Number of state stores tracked
order-count-store-changelog 0 148920
summary-store-changelog 0 93402

Why Checkpoint Corruption Causes Catastrophic Cold Replay

  1. Commit Coordination: Kafka Streams only updates .checkpoint after RocksDB flushes all active MemTables to disk as immutable SST files.
  2. Crash Before Checkpoint: If a pod crashes mid-batch, uncommitted MemTable writes are lost, but .checkpoint reflects the last committed offset. On restart, Kafka Streams safely plays the changelog forward from 148920 to the partition head.
  3. The Checkpoint Erasure Disaster: If operations scripts or pod volume remounts delete .checkpoint while retaining the RocksDB .sst data files, Kafka Streams treats the state as dirty and corrupted, wipes the directory, and replays the entire changelog from offset 0!

8. Failure Recovery & Standby Replicas

Interactive Failover Recovery & Standby Replica Simulator
Instance A (Primary)๐Ÿ’ฅ Crashes at t=0sInstance B (Standby Task)Maintains warm RocksDB copyInstant Promotion (t โ‰ˆ 2s)Replay only last ~10 recordsRUNNINGMTTR: ~2s
RECOVERY TIME OBJECTIVE (RTO) CALCULATOR
Changelog Records to Replay:1.0M Records
โ€ข Replay Throughput: ~500,000 rec/sec
โ€ข Estimated MTTR: ~2s
ARCHITECTURAL TRADE-OFF

Standby Replicas: High Availability

Standby tasks run continuously in the background, consuming changelogs to keep their local RocksDB mirror fresh. On failover, promotion takes only 1-2 seconds with zero cluster disruption.

Standby Replicas (num.standby.replicas)

Standby replicas are shadow tasks that passively consume a state store's changelog without processing input events:

Normal operation (num.standby.replicas = 1):
Instance A: Active Task 0 โ”€โ”€โ–บ Processes orders, writes changelog
Instance B: Standby Task 0 โ”€โ”€โ–บ Passively consumes changelog, keeps local RocksDB warm

Instance A crashes:
Instance B already has state at t โ‰ˆ now
Recovery time drops from minutes to sub-second!

Observing Restoration with StateRestoreListener

kafkaStreams.setGlobalStateRestoreListener(new StateRestoreListener() {

@Override
public void onRestoreStart(TopicPartition topicPartition, String storeName,
long startingOffset, long endingOffset) {
log.info("Starting restore for store [{}] partition [{}] (records: {})",
storeName, topicPartition, endingOffset - startingOffset);
}

@Override
public void onBatchRestored(TopicPartition topicPartition, String storeName,
long batchEndOffset, long numRestored) {
log.debug("Restored batch of {} records for store [{}]", numRestored, storeName);
}

@Override
public void onRestoreEnd(TopicPartition topicPartition, String storeName, long totalRestored) {
log.info("Completed restore for store [{}] partition [{}] (total: {})",
storeName, topicPartition, totalRestored);
}
});
๐Ÿ“–
Track Page Progress0 / 635 Read
Knowledge Base Completion0%