Spark Streaming with Kafka

Apache Kafka stores streams of events in named topics. Many companies use it as the central pipe for clicks, payments, and sensor readings. Spark Structured Streaming reads from Kafka, processes the events, and writes results to files, tables, or another Kafka topic.

The Layman View

Kafka works like a post office with labelled mailboxes. Producers drop letters into a mailbox called a topic. Spark picks up the letters, reads them, and files the results. The mailbox keeps every letter for a set time, so Spark can restart and collect missed mail.

The Big Picture

Producers                 Kafka                    Spark                   Sinks
---------                 -----                    -----                   -----
Web app   ---\            topic: clicks            Structured Streaming    Delta table
Mobile app ---->  [P0][P1][P2][P3]  ---------->   parse, filter, count ---> Parquet files
Sensors   ---/            (partitions)                                     Kafka topic

Adding the Connector

Spark needs the Kafka connector package. Match the Scala and Spark versions to your installation.

spark-submit --packages org.apache.spark:spark-sql-kafka-0-10_2.13:YOUR_SPARK_VERSION stream_job.py

Reading from Kafka

raw = (spark.readStream.format("kafka")
         .option("kafka.bootstrap.servers", "broker1:9092,broker2:9092")
         .option("subscribe", "clicks")
         .option("startingOffsets", "latest")
         .load())

raw.printSchema()

root
 |-- key: binary
 |-- value: binary
 |-- topic: string
 |-- partition: integer
 |-- offset: long
 |-- timestamp: timestamp

Kafka delivers the key and value as raw bytes. Cast them to strings before use.

Parsing JSON Messages

from pyspark.sql import functions as F
from pyspark.sql.types import StructType, StructField, StringType, IntegerType

schema = StructType([
    StructField("user",   StringType()),
    StructField("page",   StringType()),
    StructField("amount", IntegerType())
])

events = (raw
    .select(F.col("value").cast("string").alias("json"), "timestamp")
    .select(F.from_json("json", schema).alias("e"), "timestamp")
    .select("e.*", "timestamp"))
bytes  --cast-->  JSON text  --from_json-->  columns: user | page | amount | timestamp

Processing and Writing

page_counts = (events
    .withWatermark("timestamp", "5 minutes")
    .groupBy(F.window("timestamp", "1 minute"), "page")
    .count())

query = (page_counts.writeStream
    .format("parquet")
    .option("path", "/data/page_counts")
    .option("checkpointLocation", "/checkpoints/page_counts")
    .outputMode("append")
    .start())

Writing Back to Kafka

A Kafka sink needs a column named value, and optionally a column named key.

out = events.select(F.col("user").alias("key"),
                    F.to_json(F.struct("user", "page")).alias("value"))

(out.writeStream.format("kafka")
    .option("kafka.bootstrap.servers", "broker1:9092")
    .option("topic", "clicks_clean")
    .option("checkpointLocation", "/checkpoints/clicks_clean")
    .start())

Offsets and Checkpoints

Kafka numbers each message in a partition. Spark records the last processed offset of every partition in the checkpoint folder. A restarted job resumes from those offsets and skips nothing.

Partition 0: ...[40][41][42][43][44]
                            ^
                  checkpoint stores offset 43; restart continues at 44
  • startingOffsets applies only the first time a query runs. Later runs follow the checkpoint.
  • maxOffsetsPerTrigger limits the batch size and prevents overload after a long pause.

Good Practices

  • Use a separate checkpoint folder for every query.
  • Define the message schema explicitly.
  • Keep the number of Spark tasks close to the number of Kafka partitions.
  • Monitor the gap between the newest Kafka offset and the processed offset. A growing gap signals that Spark runs behind.

Starting from Earlier Messages

Kafka keeps messages for a retention period, so a new Spark query can read history. Set startingOffsets to earliest for a first run that processes every stored message. Set it to latest to read only new arrivals.

.option("startingOffsets", "earliest")

Kafka log:  [0][1][2][3][4][5][6][7][8]   now
earliest -> starts at 0
latest   -> starts after 8

Subscribing to Several Topics

Use a comma-separated list or a pattern to read many topics in one query. The topic column in the output tells you the origin of each row.

.option("subscribe", "clicks,payments")
.option("subscribePattern", "events_.*")

Testing Without a Cluster

Run a local Kafka broker in a container, produce a few sample messages, and print the stream to the console sink. Check that the parsed columns look right before you write to permanent storage.

query = events.writeStream.format("console").outputMode("append").start()