The Anatomy of a Kafka Rebalance Storm
In Apache Kafka consumer groups, stability relies on a delicate contract between the broker's Group Coordinator and the consumer client. If a consumer fails to send heartbeats within session.timeout.ms, or fails to invoke Poll() within max.poll.interval.ms, the coordinator assumes the node is dead. The broker immediately revokes its assigned partitions and triggers a group rebalance.
In high-throughput microservices performing heavy computational workloads or downstream network I/O (such as writing to PostgreSQL or calling third-party APIs), a sudden spike in processing latency can easily push execution time past max.poll.interval.ms. When this happens, a destructive chain reaction known as a Rebalance Storm unfolds:
- Consumer A fetches a batch of messages. Execution stalls on a slow database query or external rate limit.
- The deadline
max.poll.interval.msexpires. Consumer A is evicted from the group, triggering a rebalance. - Kafka revokes Consumer A's assigned partitions and assigns them to Consumer B.
- Consumer B attempts to process the exact same uncommitted batch, stalls for the same reason, and times out as well.
- The entire cluster enters a continuous loop of stop-the-world partition reassignments, bringing consumer throughput to zero.
Architecture: Decoupling Polling from Execution
The fundamental flaw in default Kafka consumer loops is the tight coupling of message fetching (polling) and business logic execution. To guarantee zero rebalance storms, we must completely decouple the polling thread from payload execution.
However, simple asynchronous execution introduces two major risks:
- Partition Ordering Violation: Concurrently processing records from the same partition without ordering constraints can cause out-of-order state updates.
- Unbounded Memory Growth: Spawning goroutines per message without backpressure will exhaust process memory during downstream failure spikes.
The optimal solution is a Partition-Aware Bounded Worker Pool combined with an dynamic backpressure mechanism using Kafka partition pausing (Pause() / Resume()).
Implementation: Partition-Key Hashed Worker Pool in Go
Below is a production-grade implementation pattern in Go. We create a fixed set of worker channels. Messages belonging to the same partition (or partition key) are deterministically routed to the exact same worker goroutine, guaranteeing strictly ordered execution per key while achieving high concurrency across partitions.
package main
import (
"context"
"fmt"
"hash/fnv"
"sync"
"time"
)
type KafkaMessage struct {
Topic string
Partition int32
Offset int64
Key []byte
Value []byte
}
type WorkerPool struct {
workers int
jobChans []chan KafkaMessage
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
tracker *OffsetTracker
}
func NewWorkerPool(workers int, bufferSize int, tracker *OffsetTracker) *WorkerPool {
ctx, cancel := context.WithCancel(context.Background())
pool := &WorkerPool{
workers: workers,
jobChans: make([]chan KafkaMessage, workers),
ctx: ctx,
cancel: cancel,
tracker: tracker,
}
for i := 0; i < workers; i++ {
pool.jobChans[i] = make(chan KafkaMessage, bufferSize)
pool.wg.Add(1)
go pool.workerLoop(i)
}
return pool
}
func (p *WorkerPool) workerLoop(workerID int) {
defer p.wg.Done()
for {
select {
case <-p.ctx.Done():
return
case msg, ok := <-p.jobChans[workerID]:
if !ok {
return
}
p.processMessage(msg)
}
}
}
func (p *WorkerPool) processMessage(msg KafkaMessage) {
// Simulate downstream I/O or heavy computation
time.Sleep(50 * time.Millisecond)
// Mark message execution as complete in offset tracker
p.tracker.MarkCompleted(msg.Topic, msg.Partition, msg.Offset)
}
func (p *WorkerPool) Submit(msg KafkaMessage) bool {
// Hash partition key to ensure deterministic worker assignment
h := fnv.New32a()
h.Write([]byte(fmt.Sprintf("%s-%d-%s", msg.Topic, msg.Partition, string(msg.Key))))
workerIdx := int(h.Sum32()) % p.workers
select {
case p.jobChans[workerIdx] <- msg:
return true
default:
// Queue is full; backpressure triggered
return false
}
}
func (p *WorkerPool) Shutdown() {
p.cancel()
for _, ch := range p.jobChans {
close(ch)
}
p.wg.Wait()
}Managing Safe Offset Commits Out-of-Order
When messages are processed asynchronously across multiple worker routines, completing messages out of order creates a serious commit ambiguity. If message at offset 105 completes before message at offset 102, committing offset 105 to Kafka prematurely will result in data loss if the consumer crashes before offset 102 finishes processing.
To solve this, we implement a contiguous sliding offset tracker. A partition offset can only be committed to Kafka if all preceding offsets in that partition have completed execution.
type PartitionState struct {
sync.Mutex
completedOffsets map[int64]bool
lastCommitted int64
}
type OffsetTracker struct {
sync.RWMutex
partitions map[string]*PartitionState
}
func NewOffsetTracker() *OffsetTracker {
return &OffsetTracker{
partitions: make(map[string]*PartitionState),
}
}
func (t *OffsetTracker) MarkCompleted(topic string, partition int32, offset int64) {
key := fmt.Sprintf("%s-%d", topic, partition)
t.Lock()
state, exists := t.partitions[key]
if !exists {
state = &PartitionState{
completedOffsets: make(map[int64]bool),
lastCommitted: -1,
}
t.partitions[key] = state
}
t.Unlock()
state.Lock()
defer state.Unlock()
state.completedOffsets[offset] = true
}
func (t *OffsetTracker) GetSafeCommitOffset(topic string, partition int32, currentFetchHead int64) int64 {
key := fmt.Sprintf("%s-%d", topic, partition)
t.RLock()
state, exists := t.partitions[key]
t.RUnlock()
if !exists {
return -1
}
state.Lock()
defer state.Unlock()
safeOffset := state.lastCommitted
for check := state.lastCommitted + 1; check <= currentFetchHead; check++ {
if state.completedOffsets[check] {
safeOffset = check
delete(state.completedOffsets, check)
} else {
// Gap detected! We cannot commit past this uncompleted offset.
break
}
}
state.lastCommitted = safeOffset
return safeOffset
}Production Considerations and Backpressure Tuning
Decoupling polling from execution solves partition rebalancing timeouts, but introduces new failure modes if backpressure is neglected. Keep these principles in mind for production deployments:
- Pause Consumer Fetching on Queue Saturation: When worker channels reach 80% capacity, invoke the consumer driver's
Pause()API on those partitions. Continue callingPoll()in a loop to keep sending heartbeats to the broker without fetching new messages. CallResume()once queues drain below 20%. - Tune max.poll.records: Never fetch thousands of records per poll if your downstream workers cannot process them within a reasonable window. Set
max.poll.recordsto a multiple of your total worker pool capacity. - Graceful Drain on SIGTERM: Upon receiving a termination signal, stop fetching new messages, trigger worker pool draining, wait for pending offsets to resolve in the
OffsetTracker, and perform a synchronous final offset commit before shutting down.
Key Takeaways
- Rebalance storms occur when synchronous message processing latency exceeds
max.poll.interval.ms, causing the broker coordinator to evict active consumers. - Decoupling the Kafka polling loop from worker execution guarantees heartbeats are always sent on time regardless of downstream execution delays.
- Partition key hashing within the worker pool ensures strict ordering guarantees per partition key while enabling horizontal concurrency.
- Never commit raw offsets out of order; use a contiguous sliding window tracker to ensure offsets are only committed when all preceding messages in the partition have succeeded.

Be the first to leave a comment.
Leave a comment