§ 01 · 设计思想
为什么要实时计算?批处理不够用在哪里
在电商秒杀、风控检测、物联网告警这些场景里,数据的价值随时间急速衰减------一笔欺诈交易如果 10 分钟后才被发现,损失已经无法挽回。批处理(Batch Processing)每隔几小时跑一次 MapReduce 或 Spark Job,就像每晚才看一次当天的监控录像,对"当下正在发生的事"完全无能为力。
实时计算要解决的核心矛盾是:数据是连续到来的无限流,而计算资源是有限的。Kafka 可以缓冲流数据,但它不会帮你聚合、关联、窗口化。你需要一个流处理引擎。
Spark 选择了一条与 Flink 不同的路:微批(Micro-Batch)哲学。与其把每条记录当作真正的流,不如把流切成极小的批,用成熟的批引擎高速处理每一个小批次。这个决定换来了工程上的巨大红利:容错、SQL 支持、与批处理代码的统一------代价是延迟比真正的流引擎高出一个数量级(百毫秒级 vs 毫秒级)。
time → 批处理 (Batch) Job 1(处理 T0~T6h 的所有数据) Job 2(处理 T6h~T12h 的所有数据) ⚠ 延迟:小时级 微批 (Micro-Batch · Spark) B0 B1 B2 B3 · · · ✓ 延迟:百毫秒~秒级(可配置) ⬅ 高吞吐 ⬅ 低延迟 核心结论
Spark 的微批本质是"用可接受的延迟换取工程简洁性"。 它让你用几乎相同的 DataFrame API 同时写批处理和流处理代码,这对团队协作是巨大优势。
如果你的业务对延迟要求是 秒级以内 ,Spark Structured Streaming 完全够用;如果需要 毫秒级(如高频交易),请考虑 Apache Flink。
实战口诀:"Spark 流是高速批,批的快就是流的慢;延迟要求秒以内,结构化流够用了。"
§ 02 · 整体架构
Structured Streaming 顶层全景
Spark 的实时计算体系经历了两代:第一代 DStream API (Spark 1.x)是 RDD 的流式包装;第二代 Structured Streaming(Spark 2.0+)基于 DataFrame/Dataset,被官方定为长期维护的主流 API。本章以 Structured Streaming 为核心,DStream 作为对比了解。
下图展示了 Structured Streaming 的完整数据流:数据从外部 Source 进入,经过 Query Plan 处理,写出到 Sink,同时 Checkpoint 机制保证容错。
SOURCES Kafka topic / partition File (S3/HDFS) 新文件触发 Socket (测试) Rate (压测) offsets STRUCTURED STREAMING ENGINE StreamExecution(调度中枢) Trigger 触发 → 构造 MicroBatch → 提交 SparkJob Logical Plan 用户定义的转换 Physical Plan Catalyst 优化后 State Store 有状态聚合持久化 Watermark 迟到数据边界 Checkpoint(HDFS / S3) offsets + state 持久化,保证 exactly-once rows SINKS Kafka File (Parquet) Console (调试) ForeachBatch 自定义写出 调度/控制 存储/配置 数据流/正常路径
核心角色一览
StreamExecution 是整个引擎的大脑,负责维护 Trigger 调度循环。每次 Trigger 触发时,它从 Source 读取新 offsets,构建本次微批的 Logical Plan,交给 Catalyst 优化成 Physical Plan,最终作为普通 Spark Job 提交执行。
State Store 是有状态算子(如 groupBy + count)的内存+磁盘存储,基于 RocksDB(Spark 3.2+)或内置的 HDFS-backed 实现,保证 Executor 重启后状态不丢失。
Watermark 是处理迟到数据的时间边界。流数据中事件的"产生时间"和"到达时间"往往不一致(网络延迟、设备离线),Watermark 告诉引擎:"比当前水位线早 X 分钟以前的数据,直接丢弃,不再等待"。
核心结论
Structured Streaming 本质上是一个"增量式批处理器" :每次 Trigger 产生一个微小的 Spark Job,处理从上次偏移量到当前偏移量之间的新数据,然后把结果更新到 Sink,把状态更新到 State Store,把进度更新到 Checkpoint。理解这一点,所有的配置问题(为什么延迟高?为什么 State 越来越大?为什么重启后重复消费?)都有了答案。
实战口诀:"Streaming = 高速循环的 Batch;Source 提供偏移量,Checkpoint 记住偏移量,State Store 记住聚合值。"
§ 03 · 关键机制一:微批执行循环
Trigger 与微批调度:引擎的心跳
StreamExecution 的工作方式就像一个永不停歇的心跳循环。每一次心跳(Trigger)完成以下四个步骤:
① 获取新 Offsets(from Source) Kafka: latestOffset - committedOffset ② 构造 Batch Plan Catalyst 优化 ③ 执行 Spark Job → 写 Sink 普通 Spark Action 触发 ④ 提交 Offsets Checkpoint 持久化 Trigger 心跳调度
四种 Trigger 类型
Spark 提供四种 Trigger 策略,决定了"多久产生一次微批":
| 类型 | 说明 |
|---|---|
| ProcessingTime("2 seconds") | 每 2 秒触发一次微批,如果上次批次未完成则跳过等待。适合大多数生产场景,延迟 = trigger间隔 + 处理时间。 |
| Continuous("100 milliseconds") | 真流模式,延迟低至 ~1ms,仅支持简单 map/filter 变换,不支持聚合、窗口。实验性功能,生产慎用。 |
| Once() | 执行一次微批后退出,用于增量式定时任务。Spark 3.3+ 推荐用 AvailableNow。 |
| AvailableNow() | 处理当前所有可用数据后退出,可分多批次处理,比 Once 更可靠,适合 Lambda 架构的增量批。 |
核心结论
Trigger 决定了吞吐和延迟的权衡。 间隔越短延迟越低,但每次批次的数据量越少、调度开销占比越高;间隔越长吞吐越好,但延迟越高。生产中ProcessingTime("10 seconds")到ProcessingTime("1 minute")最为常见。一个关键的认知:如果当前批次处理时间超过了 Trigger 间隔,Spark 不会同时跑两个批次------下一次 Trigger 会等待当前批次结束再开始,保证批次间有序性。
实战口诀:"Trigger 是节拍器,ProcessingTime 最常用;批次超时不并发,串行保证有序性。"
§ 04 · 关键机制二:Watermark 与迟到数据
水位线:处理"迟到的世界"
流数据最令人头疼的问题之一是乱序(Out-of-order)。想象手机 App 在地铁断网时积攒了 5 分钟的行为日志,一旦重新联网就批量上传------这些数据的"事件时间"(Event Time)在 5 分钟前,但"到达时间"(Processing Time)是现在。
如果你在做每分钟的点击量统计,这批数据应该归到 5 分钟前的窗口,但那个窗口可能已经关闭并输出了。Watermark 就是解决这个问题的机制:它是引擎对"我愿意等多久"的承诺。
Event Time → 10:00 10:01 10:02 10:03 10:04 10:05 10:06 W1 W2 W3 迟到事件(属于 10:00~10:01) Watermark 事件时间 < 水位线 → 丢弃(不加入 W1) 水位线计算公式 watermark = max(event_time) - threshold threshold = withWatermark 设置的延迟
Watermark 与窗口聚合的联动
Watermark 的触发逻辑是:当水位线推进到某个窗口的结束时间时,该窗口被"关闭"并输出结果。具体来说:
- 📌
withWatermark("eventTime", "2 minutes"):声明最多容忍 2 分钟的迟到 - 📌 水位线 = 已观测到的最大 event_time - 2 minutes
- 📌 当水位线 > 窗口结束时间时,该窗口在 Append 模式下才会输出
- 📌 event_time < 水位线的记录:直接丢弃,不参与任何窗口计算
常见陷阱: 在 Update 或 Complete 输出模式下,Watermark 不会触发状态清理,所有历史窗口都保留在 State Store 中,会导致状态无限增长!只有在 Append 模式 下,Watermark 才会触发窗口关闭并清理状态。
实战口诀:"水位线是引擎的宽容度;Append 模式才触发状态清理,Update/Complete 模式慎用有状态聚合。"
§ 05 · 关键机制三:三种 Output Mode
输出模式:决定"把什么写出去"
Structured Streaming 有三种输出模式,它们的区别在于"每次 Trigger 时,把结果表的哪些行写到 Sink"。选错输出模式是初学者最常遇到的报错之一。
Batch # Batch 1 Batch 2 Batch 3 Append 只写新增行 rowA rowB rowA rowB rowC(新) A/B/C rowD(新) Append 模式 每次只输出本批次新增的行 适用:无聚合、或配合 Watermark 的窗口聚合 ✓ 最节省存储,Sink 累积追加 Complete 写整张结果表 A:3 A:5 B:2 A:8 B:4 C:1 Complete 模式 每次输出整张聚合结果表(全量覆盖) 适用:全局聚合(如实时排行榜) ⚠ State Store 永不清理
Update 模式
Update 模式 :每次只输出本批次中发生变化的行(既不是全量,也不是只新增行)。适合"键值更新"场景,如每隔一段时间更新某个 key 的计数。但注意 Update 模式下 Watermark 不会清理历史 key 的状态,需要配合 mapGroupsWithState 手动管理超时清理。
三种模式的适用场景总结:
- Append 无聚合的转换、配合 Watermark 的窗口聚合 → 写 Kafka / 文件
- Complete 全局聚合(实时排行榜、全局统计)→ 写数据库(覆盖)
- Update 有状态的 key 级别更新 → 写支持 upsert 的数据库(如 HBase、Cassandra)
实战口诀:"Append 追加只写新,Complete 全量覆盖写,Update 只写改变行;有聚合不加 Watermark,不能用 Append。"
§ 06 · 关键机制四:State Store 状态管理
状态存储:流处理的"工作内存"
流处理与批处理最大的区别在于:批处理每次都从头计算,流处理需要在微批之间记住中间结果。这个"记住"的能力,就是 State Store。
类比:把 State Store 想象成一个跨批次共享的 HashMap------每个 key 对应一个聚合值,每次微批更新这个 HashMap,下一批读取上一批留下的值继续累加。
Executor 1 (Partition 0) In-Memory State (RocksDB / 内置 HashMap) "user_A" → count:42 "user_B" → count:17 State Checkpoint HDFS / S3 delta 增量写入 Executor 重启 → 从 HDFS 恢复 State 版本管理 version=1 (Batch 1 结束) version=2 (Batch 2 结束) version=3 (Batch 3 结束) 状态过期清理 flatMapGroupsWithState → GroupStateTimeout State Store 实现对比 内置 HDFS-based • 数据全在内存 • 状态大时 GC 压力大 • 默认实现,无需配置 RocksDB (Spark 3.2+) • 数据存本地磁盘 • 支持超大状态 (TB级) ✓ 生产推荐 spark.sql.streaming.stateStore.providerClass
启用 RocksDB State Store
生产环境中,当有状态聚合的 key 数量超过百万时,内置的基于堆内存的 State Store 会导致严重的 GC 停顿。RocksDB State Store 将状态持久化到 Executor 的本地磁盘,只有热数据缓存在内存中,是处理大状态的首选。
RocksDB 启用条件: Spark 3.2+,在 SparkConf 中设置
spark.sql.streaming.stateStore.providerClass=org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider。每个 Executor 需要有足够的本地磁盘空间存储状态快照。
实战口诀:"状态是流的记忆;key 少用内存存,key 多用 RocksDB;Complete 模式 + 无 Watermark = 状态无限增长。"
§ 07 · 新老对比
DStream vs Structured Streaming:两代 API 的本质区别
Spark 历史上有两套流处理 API,了解它们的差异有助于维护老代码,也能理解 Structured Streaming 为什么是"正确"的设计。
| 特性 | DStream(第一代) | Structured Streaming(第二代) |
|---|---|---|
| 基础 | 基于 RDD,操作是函数式的 | 基于 DataFrame/Dataset,统一 API |
| 时间语义 | 只有 Processing Time | 支持 Event Time、Processing Time |
| 乱序处理 | 无内置 Watermark 支持 | 内置 Watermark 处理乱序数据 |
| 窗口操作 | API 独立于批处理 | 与 groupBy 统一语法 |
| Checkpoint | 需手动管理 checkpoint 位置 | 自动管理 offsets checkpoint |
| SQL 支持 | 无 | 完整 SQL 支持 |
| 生态 | 与 Spark SQL 生态割裂 | 与 Delta Lake、MLlib 深度集成 |
| 代码复用 | Streaming 和 Batch 代码不能复用 | Batch/Stream 代码高度复用 |
新项目请不要使用 DStream! DStream API 在 Spark 3.x 已进入维护模式,官方不会再为其添加新功能。Structured Streaming 是官方长期支持的流处理 API,生态也更完整(Delta Lake 的流读写仅支持 Structured Streaming)。
§ 08 · 生产实战
完整生产级代码示例
下面是一个真实的生产场景:从 Kafka 读取用户行为事件(JSON 格式),做基于事件时间的每分钟窗口聚合,统计每个页面的 PV,然后写出到 Kafka 和文件。包含完整的 Watermark、Checkpoint、RocksDB 配置。
python
# =============================================================
# 生产级 Structured Streaming 示例
# 场景:Kafka → 每分钟窗口 PV 统计 → Kafka + Parquet
# =============================================================
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
col, from_json, window, count, current_timestamp
)
from pyspark.sql.types import (
StructType, StructField, StringType, LongType
)
# ── 1. SparkSession 配置 ──────────────────────────────────────
spark = (
SparkSession.builder
.appName("realtime_pv_job")
# 【关键配置】启用 RocksDB State Store,支持超大状态
.config(
"spark.sql.streaming.stateStore.providerClass",
"org.apache.spark.sql.execution.streaming.state"
".RocksDBStateStoreProvider"
)
# 【关键配置】shuffle partition 数量:流处理建议设为 Executor 核数的倍数
.config("spark.sql.shuffle.partitions", "20")
# 【常见错误】不要用默认值 200!流处理中 200 个 shuffle partition
# 会产生大量小任务,严重影响性能
.getOrCreate()
)
spark.sparkContext.setLogLevel("WARN")
# ── 2. 定义 Kafka Source ──────────────────────────────────────
# 事件 Schema: {"user_id":"u1","page":"/home","ts":1700000000000}
event_schema = StructType([
StructField("user_id", StringType(), True),
StructField("page", StringType(), True),
StructField("ts", LongType(), True), # 毫秒时间戳
])
raw_stream = (
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "kafka1:9092,kafka2:9092")
.option("subscribe", "user_events")
# 【关键配置】每批次最大拉取量,防止单批次过大导致 OOM
.option("maxOffsetsPerTrigger", 100000)
# 【关键配置】earliest = 从最早的消息开始;latest = 只处理新消息
# 生产中第一次运行用 earliest,之后靠 checkpoint 恢复
.option("startingOffsets", "earliest")
.load()
)
# ── 3. 解析 JSON ──────────────────────────────────────────────
# Kafka 的 value 字段是 binary,需要先转 string 再解析 JSON
parsed = (
raw_stream
.select(from_json(col("value").cast("string"), event_schema).alias("e"))
.select("e.*")
# 把毫秒时间戳转成 Timestamp 类型(Event Time 的基础)
.withColumn("event_time", (col("ts") / 1000).cast("timestamp"))
# 【错误写法】.withColumn("event_time", current_timestamp())
# 这会用 Processing Time,失去事件时间语义!
)
# ── 4. Watermark + 窗口聚合 ───────────────────────────────────
pv_agg = (
parsed
# 【关键】声明 Watermark:最多容忍 2 分钟的迟到
# 含义:水位线 = max(event_time) - 2min
# 当水位线 > 窗口结束时间,该窗口才在 Append 模式下输出
.withWatermark("event_time", "2 minutes")
# 每 1 分钟的翻滚窗口(tumbling window)
# 如需滑动窗口:window("event_time", "1 minute", "30 seconds")
.groupBy(
window(col("event_time"), "1 minute"),
col("page")
)
.agg(count("*").alias("pv"))
# 把 window struct 展开为 start / end 两列,方便下游消费
.select(
col("window.start").alias("window_start"),
col("window.end").alias("window_end"),
col("page"),
col("pv")
)
)
# ── 5. 写出到 Kafka ───────────────────────────────────────────
# 【关键】有 Watermark 的窗口聚合必须用 Append 模式
# 【错误写法】outputMode("update") → Watermark 不会清理 State
kafka_query = (
pv_agg
.selectExpr(
"CAST(page AS STRING) AS key",
"to_json(struct(*)) AS value"
)
.writeStream
.format("kafka")
.outputMode("append") # ← 必须是 append
.option("kafka.bootstrap.servers", "kafka1:9092,kafka2:9092")
.option("topic", "pv_result")
# 【关键配置】checkpoint 路径,保证重启后不重复消费
# 每个 writeStream 必须有独立的 checkpoint 路径!
.option("checkpointLocation", "s3://my-bucket/checkpoints/pv_kafka")
.trigger(processingTime="10 seconds") # 每 10 秒一个微批
.start()
)
# ── 6. 同时写出到 Parquet(ForeachBatch 自定义写出)────────────
def write_to_parquet(batch_df, batch_id):
"""
ForeachBatch:把每个微批的 DataFrame 用普通批处理 API 写出
优点:可以用所有批处理 Sink(JDBC、自定义等)
注意:batch_df 是普通 DataFrame,不是 Streaming DataFrame
"""
if batch_df.count() > 0: # 空批次直接跳过
(
batch_df.write
.mode("append")
.partitionBy("window_start")
.parquet("s3://my-bucket/pv_data/")
)
parquet_query = (
pv_agg
.writeStream
.outputMode("append")
.option("checkpointLocation", "s3://my-bucket/checkpoints/pv_parquet")
.trigger(processingTime="10 seconds")
.foreachBatch(write_to_parquet)
.start()
)
# ── 7. 等待所有查询完成 ───────────────────────────────────────
# awaitAnyTermination:任何一个 query 失败就退出整个程序
spark.streams.awaitAnyTermination()
# 【错误写法】kafka_query.awaitTermination() 只等待一个 query
# 多个 query 时,应该用 awaitAnyTermination
# ════════════════════════════════════════════════
# ❌ 错误写法 1:聚合后不用 Watermark,却用 Append 模式
# ════════════════════════════════════════════════
df.groupBy("page").count() # 无 Watermark
.writeStream
.outputMode("append") # ← 报错:AnalysisException
.start()
# 错误原因:无 Watermark 的聚合,引擎不知道何时能"确定"某个 key 不会再更新
# 因此 Append 模式无法工作
# ✅ 正确写法 1:加 Watermark 或改用 Update/Complete 模式
df.withWatermark("event_time", "5 minutes")
.groupBy(window("event_time", "1 minute"), "page").count()
.writeStream
.outputMode("append") # ← 正确
.start()
# ════════════════════════════════════════════════
# ❌ 错误写法 2:两个 Query 共用同一 Checkpoint 路径
# ════════════════════════════════════════════════
query1 = df.writeStream.option("checkpointLocation", "/ckpt/job1").start()
query2 = df.writeStream.option("checkpointLocation", "/ckpt/job1").start()
# 错误原因:两个 Query 会争抢同一 Checkpoint 目录,导致数据错乱
# ✅ 正确写法 2:每个 Query 独立的 Checkpoint 路径
query1 = df.writeStream.option("checkpointLocation", "/ckpt/job1_kafka").start()
query2 = df.writeStream.option("checkpointLocation", "/ckpt/job1_parquet").start()
# ════════════════════════════════════════════════
# ❌ 错误写法 3:修改 Query 后复用旧 Checkpoint
# ════════════════════════════════════════════════
# 修改了 groupBy 的列,但 Checkpoint 目录没变
# 运行时会报 StreamingQueryException:Schema 不兼容
# 或者悄悄产生错误结果!
# ✅ 正确写法 3:修改 Query 逻辑后,删除旧 Checkpoint 重新运行
# (或使用新的 Checkpoint 路径)
# ════════════════════════════════════════════════
# ❌ 错误写法 4:shuffle partition 忘记调小
# ════════════════════════════════════════════════
spark = SparkSession.builder.getOrCreate()
# 默认 spark.sql.shuffle.partitions = 200
# 流处理中每 10 秒一个批次,200 个小任务开销巨大
# ✅ 正确写法 4:根据 Executor 核数设置
spark = (
SparkSession.builder
.config("spark.sql.shuffle.partitions", "20") # 约等于总核数
.getOrCreate()
)
§ 09 · 本章总结
知识地图回顾
Structured Streaming 微批引擎 Trigger 四种类型 State Store RocksDB 大状态 Checkpoint Exactly-once 保障 Output Mode Append/Update/Complete Watermark 乱序 + 状态清理 全章三大核心认知
-
📌Spark 流是微批,不是真流。
微批让 Spark 在容错、SQL、生态上遥遥领先,但延迟下限是百毫秒级。选型时要清楚业务延迟底线。
-
📌Watermark = 引擎的宽容度。
只有在 Append 模式下,Watermark 才会触发窗口关闭和 State 清理。有状态聚合的 State 无限增长,99% 是因为没有正确配置 Watermark + Append 模式。
-
📌Checkpoint 是流作业的生命线。
没有 Checkpoint 的流作业重启后会全量重放;多个 Query 必须有各自独立的 Checkpoint 路径;修改 Query Schema 后必须清空旧 Checkpoint。