KafkaIQ

1.1 Introduction & Consumer Mental Model

Module Overview

Goal: Build a correct mental model of Kafka consumption without coordination, offsets, or tuning.

What You'll Learn:

  • What a Kafka Consumer is and its role in the ecosystem
  • Pull-based consumption model (vs push)
  • Topic → Partition → Record flow
  • ConsumerRecord structure and components
  • The high-level consume loop: poll → process → (commit)
  • Single consumer, single partition intuition

What's NOT Covered Here:

  • Consumer groups and coordination (covered in Module 2.1)
  • Offset commit mechanics (covered in Module 5.2)
  • Rebalancing (covered in Module 4.1)
  • Performance tuning (covered in Module 7.1)

Key Insight: This module focuses on the fundamental mechanics of reading data from Kafka, not how it scales.


Prerequisites & Setup

Before you start, you'll need the Kafka client library in your project.

Maven

<dependency>
  <groupId>org.apache.kafka</groupId>
  <artifactId>kafka-clients</artifactId>
  <version>3.7.0</version>
</dependency>

Gradle

implementation 'org.apache.kafka:kafka-clients:3.7.0'

Note: Use the latest stable version compatible with your Kafka cluster version.


1. What is a Kafka Consumer?

Definition

A Kafka Consumer is a client application that:

  • Reads records (messages) from Kafka topics
  • Pulls data from specific partitions
  • Tracks its position in each partition (via offsets)
  • Processes records according to application logic

Think of a consumer as a reader at a library that:

  • Picks books (partitions) to read
  • Remembers which page (offset) they're on
  • Reads at their own pace (pull-based)
  • Can resume where they left off

Consumer's Core Responsibilities

┌─────────────────────────────────────────────────┐
│           CONSUMER RESPONSIBILITIES             │
├─────────────────────────────────────────────────┤
│ 1. Request data from brokers (pull)             │
│ 2. Deserialize bytes into objects               │
│ 3. Process records                              │
│ 4. Track consumption position (offsets)         │
│ 5. Commit offsets (checkpoint progress)         │
│ 6. Handle errors and failures                   │
└─────────────────────────────────────────────────┘

2. Pull-Based Consumption Model

Pull vs Push

Kafka uses a pull-based model, unlike many messaging systems.

Pull-Based (Kafka)

┌──────────────┐                     ┌──────────────┐
│   CONSUMER   │  ──── poll() ────→  │    BROKER    │
│              │  ←─── records ────  │              │
└──────────────┘                     └──────────────┘

Consumer controls:
- When to fetch
- How much to fetch
- Processing speed

Advantages:

  • ✅ Consumer controls the pace (backpressure)
  • ✅ Batch processing efficient
  • ✅ Consumer can catch up at full speed
  • ✅ No risk of overwhelming consumers

Disadvantages:

  • ❌ Introduces latency (polling interval)
  • ❌ Consumer must actively poll
  • ❌ Empty polls waste resources

Push-Based (Traditional MQ)

┌──────────────┐                     ┌──────────────┐
│   CONSUMER   │  ←─── push() ─────  │    BROKER    │
│              │                     │              │
└──────────────┘                     └──────────────┘

Broker controls:
- When to send
- How much to send

Advantages:

  • ✅ Low latency (immediate delivery)
  • ✅ No polling overhead

Disadvantages:

  • ❌ Can overwhelm consumers
  • ❌ Complex backpressure mechanisms
  • ❌ Broker must track consumer capacity

Why Kafka Chose Pull

Quote from Kafka documentation:

"A push-based system must choose to either send a request immediately or accumulate messages and send them later... If tuned for low latency, this leads to sending a single message at a time only for the transfer to end up being buffered anyway, which is wasteful."

Key Insight: Pull-based allows consumers to:

  • Batch aggressively when catching up
  • Process one-at-a-time when real-time
  • Control their own throughput

3. The Consumer → Broker Flow (Mental Model)

High-Level Architecture

┌──────────────────┐
│   APPLICATION    │
│                  │
│  consumer.poll() │
└────────┬─────────┘
         │
         ▼
┌─────────────────────────────────────────────────┐
│           KAFKA CONSUMER CLIENT                 │
│  ┌────────────┐  ┌────────────┐  ┌───────────┐ │
│  │   Fetch    │→│Deserializer│→│  Process  │ │
│  │  Request   │  │            │  │  Records  │ │
│  └────────────┘  └────────────┘  └───────────┘ │
└────────┬─────────────────────────────────────────┘
         │ Network
         ▼
┌─────────────────────────────────────────────────┐
│              KAFKA CLUSTER                      │
│                                                 │
│  Topic: "orders"                                │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐     │
│  │Partition0│  │Partition1│  │Partition2│     │
│  │ Broker 1 │  │ Broker 2 │  │ Broker 3 │     │
│  └──────────┘  └──────────┘  └──────────┘     │
└─────────────────────────────────────────────────┘

The Journey of Reading a Record

  1. Application calls consumer.poll()
  2. Consumer sends fetch request to broker
  3. Broker reads records from partition log
  4. Broker returns batch of records
  5. Consumer deserializes keys and values
  6. Application processes records
  7. Consumer tracks new position (offset)
  8. Consumer commits offset (eventually)

4. Understanding Topics, Partitions, and Records

Topic Consumption View

Topic: "orders" (3 partitions)

Partition 0:  [R0] [R1] [R2] [R3] [R4] [R5] ...
              ↑
              Consumer position (offset 0)

Partition 1:  [R0] [R1] [R2] [R3] [R4] [R5] ...
              ↑
              Consumer position (offset 0)

Partition 2:  [R0] [R1] [R2] [R3] [R4] [R5] ...
              ↑
              Consumer position (offset 0)

Key Points:

  • Topics are divided into partitions
  • Each partition is an ordered, immutable log
  • Consumers read from specific partitions
  • Each partition has independent offsets

Single Consumer, Single Partition (Simplest Case)

┌──────────────┐                  Partition 0
│   CONSUMER   │                  ┌────────────────────────┐
│              │  ←── reads ────  │ [0][1][2][3][4][5][6] │
└──────────────┘                  └────────────────────────┘
                                      ↑
                                   offset 3 (next to read)

Mental Model:

  • Consumer reads records sequentially
  • Offset tracks position in the log
  • Consumer moves forward (usually never backward)

5. ConsumerRecord Structure

The ConsumerRecord

public class ConsumerRecord<K, V> {
    String topic();
    int partition();
    long offset();
    long timestamp();
    K key();
    V value();
    Headers headers();
}

Components Explained

Topic (String)

  • Which topic this record came from
  • Important when subscribing to multiple topics
String topic = record.topic(); // "orders"

Partition (int)

  • Which partition this record came from
  • Important for understanding ordering
int partition = record.partition(); // 2

Offset (long)

  • Position of this record in its partition
  • Unique within partition (not globally)
  • Monotonically increasing
long offset = record.offset(); // 12345

Key Insight: Offset is the position in the log, not a timestamp or sequence number across the topic.


Timestamp (long)

  • When the record was created
  • Can be producer-set or broker-set
  • Milliseconds since epoch
long timestamp = record.timestamp(); // 1674567890123

Key (K)

  • Optional identifier for the record
  • Used for partitioning (by producer)
  • Used for grouping/aggregation (by consumer)
String key = record.key(); // "user-123"

Can be null — not all records have keys.


Value (V)

  • The actual data payload
  • What your application cares about
String value = record.value(); // "{\"orderId\": 123, ...}"

Headers (Headers)

  • Optional metadata attached to record
  • Key-value pairs (both are byte arrays)
  • Not used for partitioning
Headers headers = record.headers();
Header traceId = headers.lastHeader("trace-id");
if (traceId != null) {
    String trace = new String(traceId.value());
}

6. The Basic Consume Loop

Minimal Consumer Example

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class SimpleConsumer {
    public static void main(String[] args) {
        // 1. Configuration
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "my-consumer-group");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
            StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
            StringDeserializer.class.getName());

        // 2. Create consumer
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

        // 3. Subscribe to topic(s)
        consumer.subscribe(Collections.singletonList("orders"));

        try {
            // 4. Poll loop
            while (true) {
                ConsumerRecords<String, String> records = 
                    consumer.poll(Duration.ofMillis(100));

                for (ConsumerRecord<String, String> record : records) {
                    System.out.printf("key=%s, value=%s, partition=%d, offset=%d%n",
                        record.key(),
                        record.value(),
                        record.partition(),
                        record.offset()
                    );
                    
                    // Process record here
                }
            }
        } finally {
            // 5. Close consumer
            consumer.close();
        }
    }
}

The Poll Loop Explained

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    
    for (ConsumerRecord<String, String> record : records) {
        // Process each record
    }
}

What happens in poll():

  1. Fetch data from brokers
  2. Deserialize keys and values
  3. Return batch of records
  4. Update internal state (position tracking)

Important: poll() is blocking up to the timeout.

consumer.poll(Duration.ofMillis(100));
// Waits up to 100ms for records
// Returns immediately if records available
// Returns empty if no records after 100ms

Poll Timeout Considerations

Short Timeout (e.g., 100ms)

consumer.poll(Duration.ofMillis(100));

Pros:

  • ✅ Fast reaction to shutdown signals
  • ✅ Tight control loop

Cons:

  • ❌ More frequent empty polls
  • ❌ Slightly more overhead

Long Timeout (e.g., 1000ms)

consumer.poll(Duration.ofMillis(1000));

Pros:

  • ✅ Fewer empty polls
  • ✅ More efficient

Cons:

  • ❌ Slower shutdown response
  • ❌ Blocks longer when no data

Recommended: Start with 100-500ms and adjust based on your needs.


7. Subscription vs Assignment

Kafka consumers can consume partitions in two ways:

consumer.subscribe(Collections.singletonList("orders"));

Behavior:

  • Consumer joins a consumer group
  • Partitions assigned automatically
  • Rebalancing handles changes
  • Typical for scalable applications

Use when: You want automatic partition management.


Assign (Manual, Advanced)

TopicPartition partition0 = new TopicPartition("orders", 0);
TopicPartition partition1 = new TopicPartition("orders", 1);

consumer.assign(Arrays.asList(partition0, partition1));

Behavior:

  • Consumer reads specific partitions
  • No consumer group coordination
  • No automatic rebalancing
  • Full control over partitions

Use when:

  • Custom partition assignment logic
  • Single-consumer scenarios
  • Testing or debugging
  • Reading from specific partitions

Key Difference:

subscribe() → "I want to read from this topic, assign me partitions"
assign()    → "I will read from these specific partitions"

8. Consumer Lifecycle

Consumer State Machine

CREATE → CONFIGURE → SUBSCRIBE → POLL (loop) → CLOSE
   │         │           │            │           │
   │         │           │            │           └─ Release resources
   │         │           │            └─ Process records
   │         │           └─ Join group (if subscribing)
   │         └─ Set properties
   └─ new KafkaConsumer<>(props)

Basic Lifecycle Example

// 1. CREATE & CONFIGURE
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "my-group");
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());

// 2. INITIALIZE
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

// 3. SUBSCRIBE
consumer.subscribe(Collections.singletonList("orders"));

// 4. POLL LOOP
while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        // Process
    }
}

// 5. CLOSE
consumer.close();

9. Mental Model: Key Takeaways

The Consumer's Job

REQUEST DATA
     ↓
[Poll from broker]
     ↓
[Deserialize bytes]
     ↓
[Process records]
     ↓
[Track position]
     ↓
[Commit offset (eventually)]

Three Levels of Abstraction

  1. User Level: Call consumer.poll(), process records
  2. Consumer Level: Fetch data, deserialize, track offsets
  3. Broker Level: Serve records from partition logs

You control level 1.
The consumer handles level 2.
The broker handles level 3.

Questions You Should Be Able to Answer

✅ What is a Kafka Consumer?
✅ What is the pull-based model?
✅ What are the components of a ConsumerRecord?
✅ What is the basic poll loop?
✅ What's the difference between subscribe() and assign()?
✅ What is an offset?

Questions We Haven't Answered Yet

❌ How do multiple consumers coordinate? → Module 2.1
❌ How do I commit offsets safely? → Module 5.2
❌ What happens during a rebalance? → Module 4.1
❌ How do I tune consumer performance? → Module 7.1


10. Common Beginner Misconceptions

❌ Misconception 1: "poll() sends records to my consumer"

Reality: Consumer pulls records by calling poll(). Broker never pushes.

❌ Misconception 2: "Offsets are global across the topic"

Reality: Offsets are per-partition. Partition 0 offset 100 ≠ Partition 1 offset 100.

❌ Misconception 3: "Kafka guarantees processing exactly once"

Reality: Kafka guarantees at-least-once by default. Exactly-once requires careful offset management (covered in Module 5.3).

❌ Misconception 4: "Records are processed in the order they were produced"

Reality: Order is guaranteed within a partition, not across partitions.

❌ Misconception 5: "poll() returns one record"

Reality: poll() returns a batch (0 to many records), not a single record.


11. Simple Working Example

Reading Orders from Kafka

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class OrderConsumer {
    
    public static void main(String[] args) {
        // Configuration
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processors");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
            StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
            StringDeserializer.class.getName());
        
        // Create consumer
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        
        try {
            // Subscribe to topic
            consumer.subscribe(Collections.singletonList("orders"));
            
            // Poll loop
            while (true) {
                ConsumerRecords<String, String> records = 
                    consumer.poll(Duration.ofMillis(100));
                
                for (ConsumerRecord<String, String> record : records) {
                    System.out.printf(
                        "Processing order: key=%s, value=%s, partition=%d, offset=%d%n",
                        record.key(),
                        record.value(),
                        record.partition(),
                        record.offset()
                    );
                    
                    // Your business logic here
                    processOrder(record.key(), record.value());
                }
            }
        } finally {
            consumer.close();
        }
    }
    
    private static void processOrder(String orderId, String orderData) {
        // Process the order
        System.out.println("Order " + orderId + " processed successfully");
    }
}

Output:

Processing order: key=order-123, value={"userId":42,...}, partition=2, offset=1543
Order order-123 processed successfully
Processing order: key=order-124, value={"userId":43,...}, partition=0, offset=890
Order order-124 processed successfully

12. What's Next?

Now that you understand the fundamentals of consuming, the next modules will cover:

  • Module 2.1: How multiple consumers coordinate (consumer groups)
  • Module 3.1: How partitions are assigned to consumers
  • Module 4.1: What happens when consumers join/leave (rebalancing)
  • Module 5: How to manage offsets and delivery semantics
  • Module 6.1: The internal mechanics of the poll loop
  • Module 7.1: How to tune consumer performance
  • Module 8.1: How to handle errors and failures
  • Module 9.1: How to monitor consumers in production
  • Module 10.1: Real-world patterns and best practices

Summary

You learned:

  • ✅ Kafka uses a pull-based consumption model
  • ✅ Consumers read from partitions (ordered logs)
  • ✅ The poll loop is the heart of consumption
  • ✅ Offsets track position in each partition
  • ✅ ConsumerRecord contains key, value, and metadata
  • ✅ subscribe() for automatic assignment, assign() for manual control

Next up: Consumer Groups & Coordination (Module 2.1) — Learn how multiple consumers work together to scale consumption.