Scaling Kafka Streams in Production: Partitioning, Tuning, and Monitoring

Scaling Kafka Streams in Production: Partitioning, Tuning, and Monitoring

Moving a Kafka Streams app from local dev to production means dealing with skewed partitions, consumer lag, rebalances, and state store bloat. This guide is a field-tested playbook with runnable snippets: how to choose keys, increase partitions without downtime, tune Streams configs, enable static membership, and set up metrics you can trust.

The Production Playbook

Partitioning that actually scales

Pick a key that spreads load

Bad keys create hot partitions. A good rule is “same entity → same key; cardinality high enough to spread.”

// Producer: derive a balanced key (e.g., sessionId vs userId)
var props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

// Optional: custom partitioner when you need domain-aware distribution
props.put("partitioner.class", "com.tbt.CustomHasherPartitioner");

try (var producer = new KafkaProducer<String, String>(props)) {
    String sessionId = SessionKey.derive(userId, deviceId, timestamp); // high-cardinality
    producer.send(new ProducerRecord<>("clicks", sessionId, payload));
}
// Example custom partitioner (simple & fast)
public class CustomHasherPartitioner implements Partitioner {
    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
                         Object value, byte[] valueBytes, Cluster cluster) {
        var partitions = cluster.partitionsForTopic(topic).size();
        // 64-bit hashing -> positive -> modulo partition count
        long h = java.util.zip.CRC32C.class.desiredAssertionStatus() ? 0 : 0; // placeholder trick to load class
        int hash = java.nio.ByteBuffer.wrap(
            java.security.MessageDigest.getInstance("MD5").digest(keyBytes)
        ).getInt();
        return (hash & 0x7fffffff) % partitions;
    }
    public void close() {}
    public void configure(Map<String, ?> configs) {}
}

Align partitions for joins

If two streams will be joined, both topics must use the same keying and partition count to avoid expensive repartitioning.

# Create aligned topics for a join
kafka-topics.sh --create --topic orders      --partitions 48 --replication-factor 3 --bootstrap-server $BS
kafka-topics.sh --create --topic order-items --partitions 48 --replication-factor 3 --bootstrap-server $BS
// Streams join without shuffle since keys/partitions align
KStream<String, Order> orders = builder.stream("orders");
KStream<String, Item> items = builder.stream("order-items");

orders.join(items,
    (o, i) -> EnrichedOrder.of(o, i),
    JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofMinutes(5))
).to("enriched-orders");

Safely increase partitions

You can increase partitions, but not decrease. Plan ahead.

# Check current partitions
kafka-topics.sh --describe --topic clicks --bootstrap-server $BS

# Increase partitions during off-peak (producers keep working)
kafka-topics.sh --alter --topic clicks --partitions 96 --bootstrap-server $BS

If consumer lag spikes after increasing, roll out more Streams instances or more threads (see below).

Streams instance and thread model

// Streams config: set threads, state dir, EOS, and internal replication
var props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "clicks-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");

// Use multiple threads per instance (match CPU cores sensibly)
props.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, "4");

// Enable EOS v2 in production
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);

// Faster warmup; keep state on SSD-backed path
props.put(StreamsConfig.STATE_DIR_CONFIG, "/var/lib/kstreams/state");

// Internal topics safety
props.put(StreamsConfig.REPLICATION_FACTOR_CONFIG, "3");
props.put(StreamsConfig.NUM_STANDBY_REPLICAS_CONFIG, "1");

// Caching & commits (tune with real traffic)
props.put(StreamsConfig.CACHE_MAX_BYTES_BUFFERING_CONFIG, 10 * 1024 * 1024); // 10MB
props.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 1000);

Reduce rebalance pain with static membership

// Static membership prevents full rebalances on rolling deploys
props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, System.getenv("POD_NAME")); // unique per pod/VM
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "15000");
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "3000");

In Kubernetes, set POD_NAME via the Downward API so every replica gets a stable instance id.

Handle skew and hot keys

// Split heavy keys into subkeys: "userId#bucket"
KStream<String, Event> events = builder.stream("events");

events
    .selectKey((k, v) -> k + "#" + (v.getTimestamp() / 60_000 % 8)) // 8-way bucketing by minute window
    .groupByKey()
    .count()
    .toStream()
    .to("events-counts");

Downstream, merge buckets when serving results.

Monitor what matters

Quick CLI checks

# Consumer group lag overview
kafka-consumer-groups.sh --bootstrap-server $BS --group clicks-app --describe

# Partition distribution sanity
kafka-topics.sh --describe --topic clicks --bootstrap-server $BS | grep Leader

# JVM heap & open files (on each Streams host)
jcmd $(pgrep -f KafkaStreams) VM.native_memory summary
lsof -p $(pgrep -f KafkaStreams) | wc -l

JMX → Prometheus → Grafana

# jmx_exporter config (jmx_exporter.yaml)
startDelaySeconds: 0
lowercaseOutputName: true
rules:
  - pattern: "kafka.streams<type=stream-metrics, thread-id=(.+), task-id=(.+), topic=(.+)><>([^:]+)"
  - pattern: "kafka.consumer<type=consumer-fetch-manager-metrics, client-id=(.+)><>(records-lag-max|records-consumed-total)"
  - pattern: "rocksdb<name=(.+)><>(block-cache-.*|num-immutable-mem-table|cur-size-active-mem-table|estimate-table-readers-mem)"
# Prometheus scrape snippet
scrape_configs:
  - job_name: 'kstreams'
    metrics_path: /metrics
    static_configs:
      - targets: ['kstreams-0:9404','kstreams-1:9404','kstreams-2:9404']

Useful Grafana panels
• Consumer lag (max, p95) per group and topic
• Records in/out rate
• Rebalance count per hour
• RocksDB size, compaction pauses, block cache hit rate

Back-pressure and commit strategy

// Poll cadence and commit interval influence lag/duplication tradeoffs
props.put(StreamsConfig.POLL_MS_CONFIG, "100"); // tighter polling reduces bursty lag
props.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, "1000"); // 1s commit for fast recovery

If you see steady lag growth, scale threads/instances and verify your sinks (DBs/HTTP) aren’t slow. Offload slow I/O to async buffers.

Tuning state stores

// Materialize with explicit store config
Materialized<String, Long, KeyValueStore<Bytes, byte[]>> store =
    Materialized.<String, Long, KeyValueStore<Bytes, byte[]>>as("counts-store")
        .withCachingEnabled()     // batch writes
        .withLoggingEnabled(Map.of("retention.ms", "86400000")); // 1 day changelog

KStream<String, Event> s = builder.stream("events");
s.groupByKey().count(store).toStream().to("events-counts");

Tip: put the state.dir on SSD; alert on disk > 70%.

Rolling upgrades without drama

# 1) Enable static membership (shown above)
# 2) Roll one instance at a time (Kubernetes maxUnavailable=0)
# 3) Verify group stability
kafka-consumer-groups.sh --bootstrap-server $BS --group clicks-app --describe | grep "STABLE"

Sample topology tying it together

var builder = new StreamsBuilder();

KStream<String, Click> clicks = builder.stream("clicks");

// Normalize key, guard against nulls
KStream<String, Click> normalized =
    clicks.selectKey((k, v) -> (v.getSessionId() == null ? "unknown" : v.getSessionId()));

KTable<Windowed<String>, Long> counts =
    normalized
        .groupByKey()
        .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
        .count(Materialized.as("clicks-5m"));

// Export counts
counts.toStream().map((wk, c) -> KeyValue.pair(wk.key(), c)).to("clicks-5m-counts");

// Side-effect sink (e.g., Redis) for serving
counts.toStream().foreach((wk, c) -> RedisClient.set("c5m:" + wk.key(), String.valueOf(c)));

var streams = new KafkaStreams(builder.build(), props);
streams.start();

Capacity heuristics that work

• Max parallelism ≈ total input partitions
• num.stream.threads ≈ CPU cores per pod minus 1
• Start with cache 10–64MB, commit 1s, EOS v2 on
• Partition count = peak QPS ÷ per-thread sustainable QPS (add 20–30% headroom)

Troubleshooting quick wins

• Lag grows only on a few partitions → key skew; change keying or add bucketing
• Frequent rebalances → set static membership; check flappy pods/Brokers
• RocksDB stalls → reduce block cache pressure, move state to SSD, watch compactions
• EOS throughput low → ensure idempotent producers and fast sinks; consider at-least-once for non-critical paths

Frequently Asked Questions

What’s the fastest way to scale a lagging app?

Increase instances first if partitions ≥ instances. If not, increase partitions, then add instances and threads.

Do I always need exactly-once semantics?

Use EOS for money, inventory, and compliance-critical paths. For analytics/log pipelines, at-least-once is simpler and faster.

How many partitions should I start with?

Enough to cover peak QPS with headroom. If unsure, start at 24–48 for medium workloads and scale up using --alter.

How do I prevent slow sinks from causing lag?

Buffer with an internal queue and push to sinks asynchronously or via a separate worker pool; never block the stream thread on external I/O.

How do I join two topics at scale?

Make sure both use the same key and same partition count; otherwise Streams creates repartition topics and you pay in latency and cost.