Bài 5

Structured Streaming + Kafka

Xử lý dữ liệu real-time bằng đúng API DataFrame bạn đã quen — Spark coi luồng dữ liệu như một "unbounded table" (bảng vô hạn) liên tục có dòng mới được thêm vào, và bạn chỉ việc viết query như với batch.

Trước khi học: bài này đọc stream từ Kafka của cdc-lab. Bạn phải bật cdc-lab trước (để có network cdc-net), rồi chạy spark-lab kèm docker-compose.kafka.yml để nối vào Kafka qua kafka:9092. Xem lại trang Lab Setup nếu chưa làm.
Batch vs Streaming
Cùng một API, hai cách chạy

Ý tưởng cốt lõi của Structured Streaming: coi luồng dữ liệu như một bảng vô hạn (unbounded table) mà cứ có dữ liệu mới là một dòng được append vào cuối bảng. Bạn viết query y hệt như khi xử lý batch — select, filter, groupBy — còn Spark sẽ tự chạy lặp lại query đó theo từng micro-batch mỗi khi có dữ liệu mới đến.

Nói cách khác, bạn không phải học một API mới. Sự khác biệt nằm ở chỗ nguồn vào/ra là stream chứ không phải file tĩnh, và job chạy liên tục thay vì kết thúc sau một lần.

Khía cạnhBatchStreaming
Đọc / ghispark.read / df.writespark.readStream / df.writeStream
Dữ liệuHữu hạn — biết trước số dòngVô hạn — dòng mới đến liên tục
Cách chạyChạy 1 lần rồi kết thúcChạy liên tục theo micro-batch
Kết quảTính một lần trên toàn bộ dataTính dần (incremental), cập nhật theo thời gian
Pipeline Streaming — CDC qua Kafka vào Spark Scroll / zoom · Mở draw.io ↗
Đọc từ Kafka
Nguồn stream từ cdc-lab

Để mở một stream từ Kafka, dùng spark.readStream với format kafka và khai báo bootstrap server cùng topic cần đọc:

df = (spark.readStream.format("kafka")
    .option("kafka.bootstrap.servers", "kafka:9092")
    .option("subscribe", "dbserver1.inventory.customers")
    .option("startingOffsets", "earliest")
    .load())

Các thành phần:

Kafka source luôn trả về một schema cố định — bất kể nội dung message là gì:

CộtKiểuÝ nghĩa
keybinaryKhóa của message (thường là khóa chính bản ghi)
valuebinaryNội dung message — phần dữ liệu thật, cần parse
topicstringTên topic
partitionintPartition chứa message
offsetlongVị trí message trong partition
timestamptimestampThời điểm message vào Kafka
Parse JSON của Debezium
Từ binary thành cột rõ ràng

Cột valuebinary, nên trước hết phải CAST sang string rồi mới parse JSON. Khai báo một schema mô tả cấu trúc JSON mong muốn, rồi dùng from_json để bóc tách:

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

schema = StructType([
    StructField("id", LongType()),
    StructField("name", StringType()),
    StructField("email", StringType()),
])

parsed = (df.selectExpr("CAST(value AS STRING) AS json")
    .select(F.from_json("json", schema).alias("data"))
    .select("data.*"))

selectExpr("CAST(value AS STRING) ...") đổi binary thành chuỗi JSON; from_json(cột, schema) chuyển chuỗi đó thành một struct theo schema; cuối cùng select("data.*") bung struct thành các cột phẳng.

Cấu trúc message Debezium: mỗi message CDC không chỉ có dữ liệu thô mà là một bao bọc với payload.before (giá trị cũ), payload.after (giá trị mới) và payload.op — loại thao tác: c = create, u = update, d = delete. Muốn lấy bản ghi mới nhất, hãy khai báo schema lồng tới payload.after rồi lọc theo op.

Ví dụ schema lồng đúng cấu trúc Debezium, rồi chọn payload.after.*payload.op:

after_schema = StructType([
    StructField("id", LongType()),
    StructField("name", StringType()),
    StructField("email", StringType()),
])
payload_schema = StructType([
    StructField("op", StringType()),
    StructField("after", after_schema),
])
envelope = StructType([StructField("payload", payload_schema)])

cdc = (df.selectExpr("CAST(value AS STRING) AS json")
    .select(F.from_json("json", envelope).alias("m"))
    .select("m.payload.op", "m.payload.after.*")
    .filter("op IN ('c','u')"))   # bỏ qua delete
Ghi kết quả (sink) & output mode
Đưa stream ra đâu

Để khởi chạy stream, gọi writeStream rồi .start(). Khi học, ghi ra console là tiện nhất — thấy ngay từng micro-batch in ra màn hình:

query = (parsed.writeStream
    .format("console")
    .outputMode("append")
    .option("truncate", False)
    .start())

query.awaitTermination()

outputMode quyết định Spark ghi cái gì sau mỗi micro-batch:

Output modeGhi gìDùng khi
appendChỉ các dòng mới xuất hiệnQuery không aggregate (đọc, lọc, parse)
completeToàn bộ bảng kết quả mỗi lầnCó aggregate và cần thấy toàn bộ kết quả
updateChỉ các dòng vừa thay đổi giá trịCó aggregate nhưng chỉ cần phần cập nhật

Các sink hay dùng:

SinkMục đích
consoleIn ra màn hình — để học / debug
memoryLưu vào bảng tạm trong bộ nhớ để query bằng SQL
kafkaGhi ngược kết quả ra một topic Kafka khác
parquet / filesGhi xuống file (Parquet, CSV...) — lưu trữ lâu dài
foreachBatchTự xử lý mỗi micro-batch như một DataFrame (ghi vào DB, gọi API...)
Aggregation theo cửa sổ thời gian + Watermark
Đếm event theo khoảng thời gian

Với stream, ta thường muốn gom dữ liệu theo cửa sổ thời gian — ví dụ đếm số event mỗi 5 phút. Dùng F.window trong groupBy, kết hợp withWatermark để xử lý dữ liệu đến trễ:

from pyspark.sql import functions as F

agg = (parsed
    .withWatermark("ts", "10 minutes")
    .groupBy(F.window("ts", "5 minutes"), "type")
    .count())

window("ts", "5 minutes") tạo các cửa sổ tumbling (không chồng nhau) rộng 5 phút dựa trên cột thời gian ts; mỗi event rơi vào đúng một cửa sổ và được đếm theo cửa sổ + cột type.

withWatermark("ts", "10 minutes") báo cho Spark biết: dữ liệu có thể đến trễ tối đa 10 phút. Nhờ đó Spark vừa vẫn cộng đúng những event đến muộn trong giới hạn này, vừa được phép dọn state cũ của các cửa sổ đã quá hạn (nếu không, state sẽ phình mãi).

Hiểu watermark: đây là câu trả lời cho "đợi tối đa bao lâu cho dữ liệu trễ". Quá ngưỡng đó, event đến muộn sẽ bị bỏ qua và state của cửa sổ tương ứng được giải phóng.
Checkpoint — bắt buộc cho production
Khôi phục đúng chỗ sau restart
Đừng bỏ qua: checkpointLocation lưu lại offset (đã đọc tới đâu trong Kafka) và state (kết quả aggregate đang dang dở). Nhờ đó khi job bị tắt rồi chạy lại, nó tiếp tục đúng chỗ vừa dừng thay vì đọc lại từ đầu hay bỏ sót — đây là điều kiện để đạt exactly-once.
query = (parsed.writeStream
    .format("console")
    .outputMode("append")
    .option("checkpointLocation", "/home/jovyan/work/data/chk/job1")
    .start())
Lưu ý: mỗi query phải có một thư mục checkpoint riêng. Hai query dùng chung một checkpointLocation sẽ phá state của nhau — đặt mỗi job một đường dẫn khác nhau (.../chk/job1, .../chk/job2...).
Chạy thử nhanh
Từ khởi động đến thấy message
Mẹo: dùng startingOffsets="earliest" để thấy cả các message cũ đã có sẵn trong topic, không phải chờ tạo dữ liệu mới mới thấy gì.
Bài tập
Tự luyện
← Bài trước
Bài 4: ETL & Parquet