KafkaIQ

7.1 Producer Internals

Module Overview

Goal: Explain why configs behave the way they do by understanding internal mechanics.

What You'll Learn:

  • Producer thread architecture
  • RecordAccumulator and memory buffers
  • Sender thread and network I/O
  • Batching mechanics (how batches are formed)
  • Metadata management and refresh
  • Request lifecycle to brokers
  • Memory management and back pressure
  • Why certain configurations matter

What's NOT Covered Here:

  • How to tune for performance (covered in Module 8)
  • Business-level patterns (covered in Module 9.1)
  • Error handling (covered in Module 6.1)

Key Insight: This module unlocks understanding, not usage.


1. Producer Architecture Overview

High-Level Components

┌─────────────────────────────────────────────────────────────┐
│                   APPLICATION THREAD                        │
│                                                             │
│  producer.send(record) ─────────────────────┐              │
│                                              ↓              │
│                                      ┌──────────────┐      │
│                                      │  Serializer  │      │
│                                      └──────┬───────┘      │
│                                             ↓              │
│                                      ┌──────────────┐      │
│                                      │ Partitioner  │      │
│                                      └──────┬───────┘      │
│                                             ↓              │
│                                   ┌──────────────────┐    │
│                                   │ RecordAccumulator│    │
│                                   │  (Memory Buffer) │    │
│                                   └────────┬─────────┘    │
└────────────────────────────────────────────┼──────────────┘
                                             │
                                             ↓
┌─────────────────────────────────────────────────────────────┐
│                     SENDER THREAD (Background)              │
│                                                             │
│  ┌──────────────┐        ┌──────────────┐                 │
│  │ Fetch Batches│   ──→  │ Send to      │                 │
│  │ from Buffer  │        │ Broker       │                 │
│  └──────────────┘        └──────┬───────┘                 │
│                                  │                          │
│                                  ↓                          │
│                         ┌────────────────┐                 │
│                         │ Handle Response│                 │
│                         │ & Callbacks    │                 │
│                         └────────────────┘                 │
└─────────────────────────────────────────────────────────────┘
                                  │
                                  ↓
┌─────────────────────────────────────────────────────────────┐
│                      KAFKA BROKER                           │
└─────────────────────────────────────────────────────────────┘

2. Thread Model

Two Main Threads

1. Application Thread (User Thread)

Responsibilities:

  • Serialize records
  • Determine partition
  • Add records to accumulator
  • Return immediately (non-blocking)

What it does NOT do:

  • Send data over network
  • Wait for broker response

2. Sender Thread (Background I/O Thread)

Responsibilities:

  • Fetch ready batches from accumulator
  • Send batches to brokers
  • Handle responses
  • Invoke callbacks
  • Manage retries

Lifecycle:

// Start
KafkaProducer producer = new KafkaProducer<>(props);
// → Sender thread starts automatically

// Shutdown
producer.close();
// → Sender thread stops after sending pending records

Thread Safety: KafkaProducer is Thread-Safe

Critical Point: KafkaProducer is fully thread-safe.

// ✅ SAFE: Multiple threads sharing one producer
KafkaProducer<String, String> producer = new KafkaProducer<>(props);

// Thread 1
producer.send(new ProducerRecord<>("topic", "key1", "value1"));

// Thread 2 (simultaneously)
producer.send(new ProducerRecord<>("topic", "key2", "value2"));

// Thread 3 (simultaneously)
producer.send(new ProducerRecord<>("topic", "key3", "value3"));

Why it's safe:

  • The send() method is synchronized internally
  • RecordAccumulator uses concurrent data structures
  • Each thread's records are safely added to the buffer
  • Sender thread coordinates access to batches

Best Practice: Share One Producer Across Threads

// ✅ GOOD: One producer, many threads
public class OrderService {
    private final KafkaProducer<String, String> producer;
    
    public OrderService() {
        this.producer = new KafkaProducer<>(props);
    }
    
    // Called by multiple threads
    public void publishOrder(Order order) {
        producer.send(new ProducerRecord<>("orders", order.getId(), order.toJson()));
    }
}

Why share one producer?

  1. Efficiency: One producer = one buffer, one sender thread, one set of connections
  2. Better batching: Records from all threads batch together
  3. Resource efficiency: Fewer TCP connections to brokers
  4. Simpler lifecycle: One producer to close

Anti-Pattern: One Producer Per Thread

// ❌ BAD: Creating producer per thread
public void publishOrder(Order order) {
    KafkaProducer<String, String> producer = new KafkaProducer<>(props); // DON'T!
    producer.send(new ProducerRecord<>("orders", order.getId(), order.toJson()));
    producer.close(); // Expensive!
}

Problems:

  • Each producer creates its own connections
  • No batching across threads
  • Overhead of creating/destroying producers
  • Wastes memory and network resources

Exception: When Multiple Producers Make Sense

You might want separate producers when:

  1. Different configurations needed (e.g., different acks levels)
  2. Isolation required (e.g., critical vs. non-critical data)
  3. Different clusters (e.g., multi-datacenter)
// ✅ OK: Different configs for different use cases
KafkaProducer<String, String> criticalProducer = new KafkaProducer<>(criticalProps);
KafkaProducer<String, String> analyticsProducer = new KafkaProducer<>(analyticsProps);

3. The RecordAccumulator

What is the RecordAccumulator?

The RecordAccumulator is an in-memory buffer that holds records before sending.

┌──────────────────────────────────────────────────────┐
│           RecordAccumulator (Memory Buffer)          │
├──────────────────────────────────────────────────────┤
│                                                      │
│  Topic: "orders"                                     │
│    Partition 0: [batch1: record1, record2, ...]     │
│    Partition 1: [batch2: record3, record4, ...]     │
│    Partition 2: [batch3: record5, record6, ...]     │
│                                                      │
│  Topic: "events"                                     │
│    Partition 0: [batch4: record7, record8, ...]     │
│    Partition 1: [batch5: record9, record10, ...]    │
│                                                      │
└──────────────────────────────────────────────────────┘

Size limit: buffer.memory (default 32 MB)

Purpose

  1. Batch records for efficiency
  2. Decouple application thread from network I/O
  3. Buffer during temporary broker unavailability
  4. Enable async sends (application doesn't wait for network)

Buffer Structure

RecordAccumulator
  ├─ topic-A, partition-0 → Deque<ProducerBatch>
  │     └─ [Batch 1] → [Batch 2] → [Batch 3] ...
  │
  ├─ topic-A, partition-1 → Deque<ProducerBatch>
  │     └─ [Batch 1] → [Batch 2] ...
  │
  └─ topic-B, partition-0 → Deque<ProducerBatch>
        └─ [Batch 1] ...

Key: Each (topic, partition) has its own queue of batches.


4. Batching Mechanics

How Batches Are Created

Time  Application Thread              RecordAccumulator
─────┼────────────────────────────────┼──────────────────────────
  0  │ send(record1) → partition 0    │
  1  │                                │ Create new batch
  2  │                                │ Add record1 to batch
  3  │                                │ Batch size: 100 bytes
  4  │                                │
  5  │ send(record2) → partition 0    │
  6  │                                │ Add record2 to batch
  7  │                                │ Batch size: 200 bytes
  8  │                                │
  9  │ send(record3) → partition 0    │
 10  │                                │ Add record3 to batch
 11  │                                │ Batch size: 300 bytes
     │                                │
     │                 ← Batch ready when: →
     │                   - Batch size ≥ batch.size (16 KB default)
     │                   - OR linger.ms expires (0ms default)

Batch Ready Conditions

A batch is ready to send when:

  1. Batch is full: currentSize >= batch.size
  2. Linger time expired: Time since first record ≥ linger.ms
  3. Out of memory: buffer.memory exhausted (force send to free space)
  4. Producer closing: close() or flush() called

Example: Batching Timeline

Config:
  batch.size = 16 KB
  linger.ms = 10 ms

Timeline:
0ms    │ record1 added (size: 1 KB) → Start linger timer
1ms    │ record2 added (size: 2 KB) → Total: 3 KB
2ms    │ record3 added (size: 3 KB) → Total: 6 KB
5ms    │ record4 added (size: 4 KB) → Total: 10 KB
8ms    │ record5 added (size: 5 KB) → Total: 15 KB
10ms   │ Linger timer expires! 
       │ → Batch sent (15 KB < 16 KB, but linger.ms reached)

Result: Batch sent after 10ms with 5 records (15 KB).


Memory Management

Configuration

props.put("buffer.memory", 33554432); // 32 MB total buffer
props.put("batch.size", 16384);       // 16 KB per batch

How Memory is Allocated

buffer.memory = 32 MB (total available)

Current usage:
  topic-A, partition-0: 2 batches × 16 KB = 32 KB
  topic-A, partition-1: 1 batch × 16 KB = 16 KB
  topic-B, partition-0: 3 batches × 16 KB = 48 KB
  ...
  
Total used: 10 MB
Available: 22 MB

When Buffer is Full

// Application thread
producer.send(record); // Blocks!

// Blocks up to max.block.ms (default 60 seconds)
// If timeout → TimeoutException

Causes:

  • Sending faster than broker can consume
  • Network slow or broker down
  • Batches not being sent (sender thread blocked)

Symptoms: send() becomes slow or times out.


5. The Sender Thread

Sender Thread Loop

// Pseudocode: Sender thread loop
while (running) {
    // 1. Determine which batches are ready
    List<ProducerBatch> readyBatches = accumulator.getReadyBatches();
    
    // 2. Group batches by broker (node)
    Map<Node, List<ProducerBatch>> batchesByNode = groupByNode(readyBatches);
    
    // 3. Send to each broker
    for (Node node : batchesByNode.keySet()) {
        List<ProducerBatch> batches = batchesByNode.get(node);
        sendBatches(node, batches);
    }
    
    // 4. Handle responses
    handleResponses();
    
    // 5. Sleep briefly (avoid busy loop)
    Thread.sleep(pollTimeoutMs);
}

Sending to Brokers

Sender Thread fetches batches:

Broker 1 (leader for partition 0):
  ├─ topic-A, partition-0, batch [record1, record2, ...]
  └─ topic-B, partition-0, batch [record7, record8, ...]

Broker 2 (leader for partition 1):
  └─ topic-A, partition-1, batch [record3, record4, ...]

Broker 3 (leader for partition 2):
  └─ topic-A, partition-2, batch [record5, record6, ...]

→ Sender sends one request per broker
→ Each request contains multiple batches (if going to same broker)

In-Flight Requests

max.in.flight.requests.per.connection: Maximum requests sent without acknowledgment.

props.put("max.in.flight.requests.per.connection", 5);

Example: 5 in-flight requests

Time  Sender Thread                     Broker
─────┼──────────────────────────────────┼────────────────
  1  │ Send request 1 ───────→          │
  2  │ Send request 2 ───────→          │
  3  │ Send request 3 ───────→          │
  4  │ Send request 4 ───────→          │
  5  │ Send request 5 ───────→          │
  6  │ Wait for at least one response   │ (5 in-flight)
  7  │ ←─────── Response for req 1      │
  8  │ Send request 6 ───────→          │ (back to 5 in-flight)

Impact:

  • Higher value → More throughput (pipelining)
  • Lower value → Less throughput, better ordering guarantee (with retries)

In-Flight Requests and Ordering

Critical Insight: max.in.flight.requests.per.connection affects ordering when retries are enabled.

Scenario: Ordering Violation with Retries

max.in.flight.requests.per.connection = 5
retries = 10

Time  Sender Thread                     Broker
─────┼──────────────────────────────────┼────────────────
  1  │ Send batch 1 (seq=0) ───────→   │
  2  │ Send batch 2 (seq=1) ───────→   │
  3  │ Send batch 3 (seq=2) ───────→   │
  4  │                                  │ Batch 1 fails (transient error)
  5  │ ←─────── Error for batch 1      │
  6  │ ←─────── Success for batch 2    │ ✅ Batch 2 committed (offset 100)
  7  │ ←─────── Success for batch 3    │ ✅ Batch 3 committed (offset 101)
  8  │ Retry batch 1 ───────→          │ ✅ Batch 1 committed (offset 102)

Result: Batch order = 2, 3, 1 (WRONG!)

Problem: Batch 2 and 3 succeeded while batch 1 was retrying, violating ordering.


props.put("enable.idempotence", true);
props.put("max.in.flight.requests.per.connection", 5); // Safe with idempotence

How it works:

  • Producer assigns sequence numbers to batches
  • Broker detects out-of-order batches and rejects them
  • Producer retries until ordering is restored
With idempotence:

Time  Sender Thread                     Broker
─────┼──────────────────────────────────┼────────────────
  1  │ Send batch 1 (seq=0) ───────→   │
  2  │ Send batch 2 (seq=1) ───────→   │
  3  │ Send batch 3 (seq=2) ───────→   │
  4  │                                  │ Batch 1 fails
  5  │ ←─────── Error for batch 1      │
  6  │ ←─────── Batch 2 arrives        │ ❌ Rejected (seq=1, expected seq=0)
  7  │ ←─────── Batch 3 arrives        │ ❌ Rejected (seq=2, expected seq=0)
  8  │ Retry batch 1 ───────→          │ ✅ Batch 1 committed (seq=0)
  9  │ Retry batch 2 ───────→          │ ✅ Batch 2 committed (seq=1)
 10  │ Retry batch 3 ───────→          │ ✅ Batch 3 committed (seq=2)

Result: Batch order = 1, 2, 3 (CORRECT!)

Solution 2: Limit In-Flight Requests (Legacy)

props.put("enable.idempotence", false);
props.put("max.in.flight.requests.per.connection", 1); // Only 1 at a time

How it works:

  • Only one request in-flight at a time
  • No subsequent batches sent until previous succeeds
  • Guarantees ordering but severely limits throughput

Tradeoff:

  • ✅ Ordering preserved
  • ❌ Very low throughput (no pipelining)
  • ❌ Not recommended for modern Kafka

Recommendation

Always use idempotent producer:

props.put("enable.idempotence", true); // Default in Kafka 3.0+
props.put("max.in.flight.requests.per.connection", 5); // Default

Benefits:

  • ✅ Ordering guaranteed
  • ✅ High throughput (pipelining)
  • ✅ No duplicates on retries
  • ✅ Best of both worlds

Only use max.in.flight.requests.per.connection = 1 if:

  • You're on a very old Kafka version (< 0.11) without idempotence
  • You absolutely cannot enable idempotence (rare)

6. Metadata Management

What is Metadata?

Metadata includes:

  • List of brokers
  • Topics and partition count
  • Leader broker for each partition
  • ISR (In-Sync Replicas) for each partition

Why needed: Producer must know which broker is the leader for each partition.


Metadata Refresh

Producer starts
  │
  ├─ Fetch metadata from broker
  │  (connect to any broker from bootstrap.servers)
  │
  └─ Cache metadata in memory

Application sends record to partition 0
  │
  └─ Lookup leader for partition 0 in cache
     └─ Leader: Broker 2
     └─ Send to Broker 2

Metadata becomes stale (leader changed)
  │
  └─ Producer receives NOT_LEADER_FOR_PARTITION
     └─ Refresh metadata
     └─ Retry with new leader

Metadata Refresh Triggers

  1. On producer creation — Initial metadata fetch
  2. Topic doesn't exist in cache — Fetch on first send
  3. Error from broker — NOT_LEADER_FOR_PARTITION, UNKNOWN_TOPIC_OR_PARTITION
  4. Periodic refresh — Every metadata.max.age.ms (default 5 minutes)

Configuration:

props.put("metadata.max.age.ms", 300000); // 5 minutes

Metadata Request Blocking

// Application thread
producer.send(new ProducerRecord<>("new-topic", "key", "value"));

// If topic metadata not in cache:
// 1. Block up to max.block.ms (default 60 seconds)
// 2. Request metadata from broker
// 3. Wait for response
// 4. Continue send

// If timeout → TimeoutException

Why blocking? Producer needs to know which partition and broker to send to.


7. Request Lifecycle

Detailed Request Flow

┌──────────────────────────────────────────────────────────────────┐
│                     REQUEST LIFECYCLE                            │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. Application Thread                                           │
│     ├─ Serialize record                                          │
│     ├─ Determine partition                                       │
│     ├─ Add to RecordAccumulator                                  │
│     └─ Return Future<RecordMetadata>                             │
│                                                                  │
│  2. Sender Thread (when batch ready)                             │
│     ├─ Fetch batch from accumulator                              │
│     ├─ Group by destination broker                               │
│     ├─ Create ProduceRequest                                     │
│     │    - Include PID, Epoch (if idempotent)                    │
│     │    - Include sequence number (if idempotent)               │
│     │    - Include transaction ID (if transactional)             │
│     └─ Send over network                                         │
│                                                                  │
│  3. Broker Processes Request                                     │
│     ├─ Validate request (permissions, version, etc.)             │
│     ├─ Check for duplicates (if idempotent)                      │
│     ├─ Write to partition log                                    │
│     ├─ Replicate to followers (if acks=all)                      │
│     └─ Send ProduceResponse                                      │
│                                                                  │
│  4. Sender Thread Receives Response                              │
│     ├─ Success? Update metadata (offset, timestamp)              │
│     ├─ Error? Determine if retriable                             │
│     │    - Retriable → Retry (back to accumulator)               │
│     │    - Non-retriable → Fail                                  │
│     └─ Invoke callback                                           │
│                                                                  │
│  5. Callback Execution (in Sender Thread)                        │
│     └─ Application-provided callback invoked                     │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

ProduceRequest Structure

ProduceRequest {
    acks: 1 (or 0, -1/all)
    timeout: 30000 ms
    transactionalId: "my-tx-id" (if transactional)
    
    topicData: [
        {
            topic: "orders",
            partitions: [
                {
                    partition: 0,
                    records: [
                        {
                            offset: (assigned by broker)
                            timestamp: 1674567890123,
                            key: "user-123",
                            value: "order-data",
                            headers: [...],
                            
                            // If idempotent:
                            producerId: 12345,
                            producerEpoch: 0,
                            sequence: 42
                        },
                        ...
                    ]
                }
            ]
        }
    ]
}

ProduceResponse Structure

ProduceResponse {
    responses: [
        {
            topic: "orders",
            partitions: [
                {
                    partition: 0,
                    errorCode: 0 (NONE = success),
                    baseOffset: 1234567, // Offset of first record
                    logAppendTime: 1674567890456,
                    logStartOffset: 0
                }
            ]
        }
    ]
}

8. Compression Internals

Where Compression Happens

Application Thread                     Sender Thread
      │                                      │
      ├─ send(record1)                       │
      ├─ send(record2)                       │
      ├─ send(record3)                       │
      │                                      │
      │  Records added to batch (uncompressed)
      │                                      │
      │                                      ├─ Fetch batch
      │                                      ├─ Compress entire batch
      │                                      └─ Send compressed bytes

Key Point: Compression happens per batch, not per record.


Compression Ratio

Batch (uncompressed):
  record1: 100 bytes
  record2: 100 bytes
  record3: 100 bytes
  Total: 300 bytes

Compression type: lz4

Batch (compressed): 150 bytes

Compression ratio: 2:1 (50% reduction)

Benefits:

  • Reduced network bandwidth
  • Reduced broker disk usage
  • Higher effective throughput

Cost:

  • CPU overhead on producer
  • CPU overhead on consumer (decompression)

9. Flow Control and Backpressure

When Producer is Slower Than Broker

Application sends → RecordAccumulator → Sender → Broker
                    (buffer)
                    
Buffer usage: 10% (plenty of space)
Producer: Happy ✅

When Producer is Faster Than Broker

Application sends FAST → RecordAccumulator → Sender (slow) → Broker (slow)
                         (buffer fills up)

Buffer usage: 90%
Buffer usage: 95%
Buffer usage: 99%
Buffer usage: 100% (FULL!)

Application: send() blocks ⏸️

Backpressure: Producer slows down automatically when buffer fills.


Handling Backpressure

Option 1: Block (Default)

props.put("max.block.ms", 60000); // Block up to 60 seconds

producer.send(record); // Blocks if buffer full

Effect: Application slows down naturally.


Option 2: Fail Fast

props.put("max.block.ms", 0); // Don't block

try {
    producer.send(record);
} catch (org.apache.kafka.common.errors.TimeoutException e) {
    // Buffer full — reject send
    logger.warn("Buffer full, dropping record");
}

Effect: Application handles overflow explicitly.

Note on Exception Types:

When the buffer is exhausted, you may see:

  • org.apache.kafka.common.errors.TimeoutException — Failed to allocate memory within max.block.ms
  • org.apache.kafka.common.errors.BufferExhaustedException — Explicit indication buffer is full

Both indicate the same root cause: producer buffer memory is exhausted.

Common Causes:

  1. Sending faster than broker can consume
  2. Network issues or broker slowness
  3. buffer.memory too small for workload
  4. Sender thread blocked or slow

Solutions:

  • Increase buffer.memory
  • Reduce send rate (implement backpressure)
  • Check broker/network health
  • Tune batch.size and linger.ms for faster flushing

Option 3: Increase Buffer

props.put("buffer.memory", 67108864); // 64 MB (was 32 MB)

Effect: More headroom, but doesn't solve root cause.


10. Why Configurations Matter

Understanding Through Internals

Now you understand why these configs exist:

batch.size

Why it matters: Determines when RecordAccumulator considers batch full.

  • Small: More frequent sends, lower latency, less throughput
  • Large: Fewer sends, higher latency, more throughput

linger.ms

Why it matters: How long Sender waits to fill batch before sending.

  • 0ms: Send as soon as any record arrives (low latency)
  • >0ms: Wait to batch more records (high throughput)

buffer.memory

Why it matters: Total memory for RecordAccumulator.

  • Small: Buffer fills quickly → Backpressure
  • Large: More headroom, but uses more memory

max.in.flight.requests.per.connection

Why it matters: How many requests Sender can send without waiting for responses.

  • 1: Strict ordering (but slow)
  • 5: Good throughput with ordering (if idempotent)
  • >5: Max throughput (may reorder on retry)

compression.type

Why it matters: Sender compresses batches before sending.

  • none: No CPU overhead, more network
  • lz4/snappy: Balanced
  • gzip/zstd: Max compression, high CPU

11. Internal Metrics

Key Internal Metrics to Monitor

// Buffer usage
kafka.producer:type=producer-metrics,client-id=<id>,name=buffer-available-bytes
  → How much buffer space is free

kafka.producer:type=producer-metrics,client-id=<id>,name=buffer-exhausted-rate
  → How often buffer fills up (backpressure)

// Batching
kafka.producer:type=producer-metrics,client-id=<id>,name=batch-size-avg
  → Average batch size

kafka.producer:type=producer-metrics,client-id=<id>,name=records-per-request-avg
  → How many records per request

// Sender thread
kafka.producer:type=producer-metrics,client-id=<id>,name=request-rate
  → Requests per second

kafka.producer:type=producer-metrics,client-id=<id>,name=request-latency-avg
  → Average request latency

Summary

Key Takeaways

✅ Two threads: Application thread (adds to buffer), Sender thread (network I/O)
✅ RecordAccumulator: In-memory buffer for batching
✅ Batching: Records grouped by (topic, partition) before sending
✅ Batch ready when: Full (batch.size), linger expires (linger.ms), or buffer full
✅ Sender thread: Fetches batches, sends to brokers, handles responses
✅ Metadata: Cached, refreshed on errors or periodically
✅ In-flight requests: Multiple requests can be outstanding (max.in.flight.requests)
✅ Compression: Happens per batch in Sender thread
✅ Backpressure: send() blocks when buffer full (max.block.ms)
✅ Callbacks: Executed in Sender thread (keep them fast)


Mental Model

Application sends → Serializer → Partitioner → RecordAccumulator (buffer)
                                                       ↓
                                                 [Batch by partition]
                                                       ↓
                                            Sender Thread (background)
                                                       ↓
                                              [Compress batches]
                                                       ↓
                                              [Send to brokers]
                                                       ↓
                                            [Handle responses]
                                                       ↓
                                            [Invoke callbacks]

Why This Matters

Understanding internals helps you:

  • Debug performance issues
  • Tune configurations intelligently
  • Predict behavior under load
  • Troubleshoot mysterious failures
  • Design efficient producer applications

What's Next

  • Module 8: Performance & Tuning
    Now that you understand internals, learn how to optimize

  • Module 9.1: Practical Producer Patterns
    Apply knowledge to real-world scenarios


Previous Module: ← 6.1 - Error Handling & Failure Scenarios
Next Module: 8.1 - Batching & Buffering →