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
Buffers stream updates in memory, deduplicates successive writes by key, and batches emissions to downstream nodes.
Local key-value engine with MemTable, WAL (Write-Ahead Log), Block Cache, and immutable SSTables with Bloom filters.
Compacted internal Kafka topic (`cleanup.policy=compact,delete`). Provides crash-recovery replay source and standby sync.
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.
- `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
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), andsendfile(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 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- 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.
- Kubernetes Memory Planning Hazard:
- In containerized environments (Kubernetes), developers frequently set container limits based only on the JVM
-Xmxsetting (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:
- In containerized environments (Kubernetes), developers frequently set container limits based only on the JVM
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 16MB).
If an application has 8 active tasks and 3 state stores per task, that equals independent RocksDB instances!
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 Type | Built-In Retention Mechanism | How TTL Works |
|---|---|---|
| WindowStore | Native TimeWindows.ofSizeAndGrace(...) | Segments older than windowSize + gracePeriod are physically dropped |
| SessionStore | Native SessionWindows.ofInactivityGapAndGrace(...) | Expired sessions dropped once inactivity gap + grace period passes |
| KeyValueStore | None 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
order-enrichment-agg-store-changelog โข Local SST: 1.2 GBorder-enrichment-agg-store-changelog โข Local SST: 1.1 GBorder-enrichment-agg-store-changelog โข Local SST: 1.4 GBPartition 0 Restore Boundary
Committed changelog offset safely written to local disk. Skips replay of 1.42M records.
.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
- Commit Coordination: Kafka Streams only updates
.checkpointafter RocksDB flushes all active MemTables to disk as immutable SST files. - Crash Before Checkpoint: If a pod crashes mid-batch, uncommitted MemTable writes are lost, but
.checkpointreflects the last committed offset. On restart, Kafka Streams safely plays the changelog forward from148920to the partition head. - The Checkpoint Erasure Disaster: If operations scripts or pod volume remounts delete
.checkpointwhile retaining the RocksDB.sstdata 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
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);
}
});
