Education › Data › Stage 3: Scale & streaming

Streaming with Kafka

Topics, partitions, consumer groups, delivery guarantees, and stream processing patterns.

Intermediate ~40 min read Module 11 of 16

Batch answers questions about yesterday. Some questions cannot wait: is this payment fraudulent, is this sensor failing, which product just sold out, what is the user doing right now. Streaming processes events as they happen, and Apache Kafka is the log at the centre of most streaming architectures — a durable, ordered, replayable record of events that producers append to and any number of consumers read at their own pace. This module explains the log model and why it matters, Kafka's topics, partitions and consumer groups, the delivery guarantees you can actually get, schemas, and the stream processing patterns — windows, joins, state — that turn events into answers. Batch will not go away; the goal is knowing when streaming is worth its complexity.

After this module you can
  • Explain the log abstraction: append-only, ordered per partition, retained, replayable
  • Design topics, keys and partitions for ordering and parallelism
  • Choose and implement delivery guarantees: at-most-once, at-least-once, exactly-once semantics
  • Enforce schemas with a registry and evolve them compatibly
  • Build stream processing with windows, event time and watermarks, and know when batch is the better answer

The log

Kafka is not a queue that deletes messages once read. It is a log: an append-only sequence of records, each with an offset, retained for a configured time or size regardless of who has read it. Producers append; consumers read from any offset and track their own position. Several consumers can read the same data independently, a new consumer can start from the beginning and replay history, and a crashed consumer resumes from where it left off. This decoupling — producers do not know or wait for consumers — is why a log works as the backbone between systems that evolve separately.

A topic is a named log, split into partitions for parallelism and scale. Within a partition, records are strictly ordered; across partitions there is no ordering guarantee. Records have an optional key; records with the same key always go to the same partition, so all events for one customer, one order, one device arrive in order to whoever reads that partition. Choosing the key is choosing what must be ordered together, and it decides how evenly load spreads: a key with a few hot values (country) skews partitions; a key with many values (customer id) balances them.

TOPIC: ORDERSappend by key hashsame key, same partitionschema idread, commit offsetindependent groupown offsets, lag 0Producer Aacks=all, idempotentProducer Bkey = customer_idSchema registryAvro, BACKWARDpartition 0offsets 0..8412partition 1offsets 0..8399partition 2offsets 0..8420orders-loader2 consumers, lag 12fraud-detectorown offsets, lag 0
The log model: producers append keyed records to a partitioned topic, each partition is an ordered, retained sequence with offsets, and independent consumer groups read the same partitions at their own pace, each tracking its own committed offset. Lag is the gap between a group's committed offset and the end of the partition.
Creating a topic with enough partitions to grow into, seven days of retention, and a quick produce and consume to see offsets.
bash
kafka-topics.sh --bootstrap-server broker:9092 --create --topic orders \
  --partitions 12 --replication-factor 3 \
  --config retention.ms=604800000 --config min.insync.replicas=2

# produce keyed records (key:value with a separator)
printf 'cust-1042:{"order_id":9001,"amount":42.5}\ncust-77:{"order_id":9002,"amount":9.99}\n' \
  | kafka-console-producer.sh --bootstrap-server broker:9092 --topic orders \
      --property parse.key=true --property key.separator=:

# consume from the beginning, showing partition and offset
kafka-console-consumer.sh --bootstrap-server broker:9092 --topic orders --from-beginning \
  --property print.partition=true --property print.offset=true --property print.key=true --max-messages 2
Note

Partition count is hard to change well later (keys remap), so choose for growth: more partitions than current consumers, bounded by broker limits and the cost of many small partitions. Twelve to fifty is common for a busy topic; thousands is a mistake.

Consumers, groups and offsets

A consumer group is a set of consumers sharing a group id; Kafka assigns each partition of the subscribed topics to exactly one consumer in the group, so the group processes the topic in parallel with each partition read once. Add a consumer and partitions rebalance; a consumer dies and its partitions move to survivors. Each group tracks its committed offset per partition: the position it will resume from after a restart. Independent groups (the fraud service and the analytics loader) each maintain their own offsets over the same topic, reading at their own speed.

A consumer that commits offsets only after processing succeeds, giving at-least-once delivery. The handler must therefore be idempotent.
python
from confluent_kafka import Consumer, KafkaException

consumer = Consumer({
    "bootstrap.servers": "broker:9092",
    "group.id": "orders-loader",
    "enable.auto.commit": False,          # we commit explicitly, after work is done
    "auto.offset.reset": "earliest",      # a brand-new group starts from the beginning
    "max.poll.interval.ms": 300000,
})
consumer.subscribe(["orders"])

try:
    while True:
        msg = consumer.poll(1.0)
        if msg is None:
            continue
        if msg.error():
            raise KafkaException(msg.error())
        process(msg.key(), msg.value())        # idempotent: upsert by order_id
        consumer.commit(message=msg, asynchronous=False)   # commit this offset + 1
finally:
    consumer.close()

Consumer lag — committed offset versus the latest offset per partition — is the health metric of streaming: growing lag means consumers cannot keep up, are stuck, or have died. Monitor it per group and alert on it. Slow processing inside poll loops causes the broker to consider the consumer dead and rebalance, which makes lag worse; keep per-record work fast, batch the slow parts, and raise max.poll.interval.ms only with a reason.

Delivery guarantees, honestly

There are three achievable semantics. At-most-once: commit before processing; a crash loses the in-flight record; acceptable for metrics where a gap is tolerable. At-least-once: commit after processing; a crash reprocesses the record; the default for anything that matters, and it requires idempotent processing (upsert by key, deduplicate by event id) so replays are harmless. Exactly-once semantics (EOS): Kafka's idempotent producer prevents duplicate appends from producer retries, and transactions let a consume-transform-produce loop commit its output records and its input offsets atomically, so a Kafka-to-Kafka pipeline neither loses nor duplicates. EOS stops at Kafka's edge: writing to an external database exactly once still needs idempotent writes or a transactional sink.

SemanticsMechanismCost and limits
At-most-onceCommit offset before processingCheap; loses records on crash
At-least-onceCommit after processing; idempotent handlerDuplicates on crash, absorbed by idempotency
Exactly-once (Kafka to Kafka)Idempotent producer + transactions (isolation.level=read_committed)Higher latency; only within Kafka
Exactly-once to external sinksIdempotent writes keyed on event id, or two-phase commit sinksDesign work in the sink
Producer settings for durability and no duplicate appends: acks from all in-sync replicas, idempotence on, and a delivery callback so failures are not silent.
python
from confluent_kafka import Producer

producer = Producer({
    "bootstrap.servers": "broker:9092",
    "acks": "all",                 # leader + in-sync replicas must persist it
    "enable.idempotence": True,    # retries cannot create duplicates
    "linger.ms": 20,               # small batching for throughput
    "compression.type": "zstd",
})


def on_delivery(err, msg):
    if err is not None:
        log_failed_event(msg.key(), err)   # dead-letter or alert; never ignore


producer.produce("orders", key="cust-1042", value=payload_bytes, on_delivery=on_delivery)
producer.flush(timeout=10)
Watch out

The phrase "exactly-once" on a product page rarely means what people hope. Ask where the guarantee ends. Nearly every real pipeline is at-least-once plus idempotent sinks, and that is fine when designed on purpose.

Schemas and evolution

Bytes on a topic have no schema unless you give them one, and a producer that changes its JSON shape breaks every consumer at once. A schema registry stores versioned schemas (Avro, Protobuf or JSON Schema) per topic; producers serialise with a registered schema id embedded in the record, consumers fetch the schema by id and deserialise correctly even across versions. The registry enforces a compatibility mode: backward compatibility (new schema can read old data: add optional fields, remove fields with defaults) is the usual choice so consumers can upgrade after producers. This is the data contract from the quality module, enforced at the moment of writing.

An Avro schema for order events. Adding channel with a default is a backward-compatible change; renaming amount is not.
json
{
  "type": "record",
  "name": "OrderPlaced",
  "namespace": "acme.shop",
  "fields": [
    {"name": "order_id", "type": "long"},
    {"name": "customer_id", "type": "long"},
    {"name": "ordered_at", "type": {"type": "long", "logicalType": "timestamp-millis"}},
    {"name": "amount", "type": {"type": "bytes", "logicalType": "decimal", "precision": 12, "scale": 2}},
    {"name": "status", "type": {"type": "enum", "name": "Status", "symbols": ["PLACED", "PAID", "SHIPPED", "REFUNDED"]}},
    {"name": "channel", "type": ["null", "string"], "default": null}
  ]
}

Design events as facts about what happened (OrderPlaced, PaymentCaptured), carrying the identifiers and values a consumer needs, with a unique event id and the event time as a field. Include a version or rely on the registry's; never put a secret or a full card number in an event, because a log retains it for the retention period across every replica and every consumer's copy.

Stream processing: windows, time and state

Reading one record at a time is easy; most useful computations involve many: revenue per minute, distinct users per hour, joining a click stream with a purchase stream, detecting three failed logins in five minutes. Stream processors (Kafka Streams, Apache Flink, Spark Structured Streaming) manage the state these need and the hard problem behind them: time. Events carry an event time (when it happened) and arrive at a processing time (when you saw it), and they arrive late and out of order. Windows must be computed on event time, which means deciding how long to wait for stragglers: a watermark declares "I assume no events older than T will still arrive", closes windows before T, and routes anything later to a side output or a correction.

A tumbling window on event time with a watermark, in Spark Structured Streaming: revenue per minute per country, tolerating ten minutes of lateness, written with checkpoints so it restarts exactly where it stopped.
python
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import StructType, StructField, LongType, StringType, DoubleType, TimestampType

spark = SparkSession.builder.appName("revenue_per_minute").getOrCreate()

schema = StructType([
    StructField("order_id", LongType()), StructField("country", StringType()),
    StructField("amount", DoubleType()), StructField("ordered_at", TimestampType()),
])

orders = (
    spark.readStream.format("kafka")
    .option("kafka.bootstrap.servers", "broker:9092")
    .option("subscribe", "orders")
    .option("startingOffsets", "latest")
    .load()
    .select(F.from_json(F.col("value").cast("string"), schema).alias("o")).select("o.*")
)

per_minute = (
    orders
    .withWatermark("ordered_at", "10 minutes")                       # tolerate 10 min of lateness
    .groupBy(F.window("ordered_at", "1 minute"), "country")
    .agg(F.sum("amount").alias("revenue"), F.count("*").alias("orders"))
)

query = (
    per_minute.writeStream
    .outputMode("append")                                           # emit a window once it is final
    .format("parquet")
    .option("path", "s3://acme-lake/gold/revenue_per_minute/")
    .option("checkpointLocation", "s3://acme-lake/checkpoints/revenue_per_minute/")
    .trigger(processingTime="30 seconds")
    .start()
)
query.awaitTermination()

Window types: tumbling (fixed, non-overlapping: per minute), sliding (overlapping: last five minutes, every minute) and session (gap-based: a user's visit). Stream-stream joins need state bounded by time (join clicks to purchases within an hour); stream-table joins enrich events with a slowly changing reference. All of it needs checkpointing so state and offsets survive restarts, and it needs the same idempotent sinks as batch, because a restart replays from the last checkpoint.

Tip

Reach for streaming when the value of an answer decays in minutes and the consumer can act on it. If the dashboard is looked at once a day, a fifteen-minute micro-batch from the same topic is simpler to operate and gives the same business result.

Hands-on practice

A keyed topic, a lagging consumer and a windowed aggregate

  1. Start a single-broker Kafka locally (the official Docker image in KRaft mode is enough). Create the orders topic with six partitions.
  2. Write a producer with acks=all and idempotence that sends 10,000 keyed order events with a timestamp field, with 30% of them arriving up to five minutes late (set ordered_at earlier than send time).
  3. Write the at-least-once consumer from the lesson that upserts into a local SQLite or Postgres table by order_id. Kill it halfway, restart, and confirm the table has exactly 10,000 rows.
  4. Start a second consumer in the same group and watch partitions rebalance in the logs; then start a consumer with a different group id and confirm it reads from the beginning independently.
  5. Slow the consumer with a sleep and watch lag grow with kafka-consumer-groups.sh --describe --group orders-loader. Remove the sleep and watch it recover.
  6. Register the Avro schema in a local schema registry (or validate with a JSON Schema in code) and confirm a producer sending a record with a renamed field is rejected.
  7. Run the Structured Streaming per-minute aggregation against the topic with a 10-minute watermark. Compare the windows it emits with a batch GROUP BY minute over the same data and explain any differences from late events.
Cheat sheet

Streaming with Kafka — at a glance

Main things to focus on

  • Kafka is a retained, replayable log; consumers track their own offsets; ordering only within a partition
  • The key decides ordering and load balance; partitions decide parallelism and are hard to change
  • At-least-once plus idempotent sinks is the honest default; exactly-once holds only inside Kafka with transactions
  • Commit after processing; monitor consumer lag per group; keep poll loops fast
  • Schemas in a registry with backward compatibility are the streaming form of data contracts
  • Windows on event time with watermarks; state and offsets in checkpoints; idempotent outputs

Topics and CLI

kafka-topics.sh --create --partitions N --replication-factor 3Create with room to grow
retention.ms / retention.bytesHow long the log keeps records
min.insync.replicas=2 + acks=allDurable writes
kafka-consumer-groups.sh --describe --group GLag per partition
--from-beginning / auto.offset.reset=earliestReplay from the start
cleanup.policy=compactKeep latest value per key (changelog topics)

Producer and consumer

enable.idempotence=true, acks=allNo duplicate appends from retries
linger.ms, batch.size, compression.type=zstdThroughput levers
on_delivery callbackNever ignore failed sends
enable.auto.commit=false; commit after processAt-least-once
group.idOne partition per consumer within a group
max.poll.interval.msSlow handlers trigger rebalances

Guarantees and schemas

commit before = at-most-once; after = at-least-onceChoose deliberately
transactional.id + isolation.level=read_committedExactly-once within Kafka
idempotent sink keyed on event idExactly-once effect at external systems
schema registry: Avro / Protobuf / JSON SchemaSchema id in each record
BACKWARD compatibility: add optional, remove with defaultConsumers upgrade after producers
event = fact + ids + event_time + event_idDesign of a good event

Stream processing

withWatermark(ts, '10 minutes')How long to wait for late events
window(ts, '1 minute') / ('5 minutes', '1 minute') / session_windowTumbling / sliding / session
outputMode append | update | completeEmit final / changed / all windows
checkpointLocationState + offsets for restart
trigger(processingTime='30 seconds')Micro-batch cadence
stream-stream join with time boundsState must be bounded

Common pitfalls

  • Keying by a low-cardinality field (country) and skewing all traffic onto two partitions.
  • Auto-committing offsets before processing and losing records on every crash.
  • Believing 'exactly-once' extends to the database the consumer writes to.
  • Producing JSON with no schema, then breaking every consumer with a renamed field.
  • Windowing on processing time, so late events land in the wrong window or vanish.
  • Streaming for a report someone reads once a day.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →