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.
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.Ý 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ạnh | Batch | Streaming |
|---|---|---|
| Đọc / ghi | spark.read / df.write | spark.readStream / df.writeStream |
| Dữ liệu | Hữu hạn — biết trước số dòng | Vô hạn — dòng mới đến liên tục |
| Cách chạy | Chạy 1 lần rồi kết thúc | Chạy liên tục theo micro-batch |
| Kết quả | Tính một lần trên toàn bộ data | Tính dần (incremental), cập nhật theo thời gian |
Để 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:
server.database.table).earliest (từ message cũ nhất còn lưu) hoặc latest (chỉ message mới từ lúc chạy).Kafka source luôn trả về một schema cố định — bất kể nội dung message là gì:
| Cột | Kiểu | Ý nghĩa |
|---|---|---|
key | binary | Khóa của message (thường là khóa chính bản ghi) |
value | binary | Nội dung message — phần dữ liệu thật, cần parse |
topic | string | Tên topic |
partition | int | Partition chứa message |
offset | long | Vị trí message trong partition |
timestamp | timestamp | Thời điểm message vào Kafka |
Cột value là binary, 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.
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.* và 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
Để 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 mode | Ghi gì | Dùng khi |
|---|---|---|
append | Chỉ các dòng mới xuất hiện | Query không aggregate (đọc, lọc, parse) |
complete | Toàn bộ bảng kết quả mỗi lần | Có aggregate và cần thấy toàn bộ kết quả |
update | Chỉ 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:
| Sink | Mục đích |
|---|---|
console | In ra màn hình — để học / debug |
memory | Lưu vào bảng tạm trong bộ nhớ để query bằng SQL |
kafka | Ghi ngược kết quả ra một topic Kafka khác |
parquet / files | Ghi xuống file (Parquet, CSV...) — lưu trữ lâu dài |
foreachBatch | Tự xử lý mỗi micro-batch như một DataFrame (ghi vào DB, gọi API...) |
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).
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())
checkpointLocation sẽ phá state của nhau — đặt mỗi job một đường dẫn khác nhau (.../chk/job1, .../chk/job2...).docker compose -f docker-compose.yml -f docker-compose.kafka.yml up -dcustomers) để Debezium bắt thay đổi và phát message CDC lên topic Kafka.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ì.op cùng các cột trong after.* bằng sink console.c / u / d) theo cửa sổ 1 phút, dùng F.window + withWatermark.outputMode("append") và một checkpointLocation riêng; kiểm tra file Parquet được tạo ra trong folder data.