目录
- [01 · 执行引擎内核](#01 · 执行引擎内核)
- [02 · Checkpoint 容错](#02 · Checkpoint 容错)
- [03 · Watermark 深层语义](#03 · Watermark 深层语义)
- [04 · State Store 原理](#04 · State Store 原理)
- [05 · Exactly-Once 机制](#05 · Exactly-Once 机制)
- [06 · 性能调优](#06 · 性能调优)
- [07 · 生产踩坑集](#07 · 生产踩坑集)
- [08 · 完整参考代码](#08 · 完整参考代码)
01 · 执行引擎内核
Structured Streaming 的默认执行模式是微批次(Micro-batch)。每个 Trigger 周期内,驱动器(Driver)完整地走一遍以下流程:
Source 拉取
逻辑计划优化
Task 执行
State 更新
Sink 写出
Commit
关键细节:为什么 Driver 是瓶颈?
每个微批次的调度、偏移量管理、Checkpoint 写入都在 Driver 完成。如果 Kafka Topic 分区数很大(比如 1000+),Driver 的元数据操作本身会带来可观的延迟,这是 latency 无法低于 200ms 的根本原因。Continuous Processing 模式通过将 offset commit 下放到 Executor 解决了这个问题,但牺牲了 Exactly-Once 保证(退化为 At-Least-Once)。
IncrementalExecution:流查询的专属执行计划器
Spark 为流查询定制了 IncrementalExecution,它在标准 Catalyst 优化器之上额外注入了几个规则:
-
StateStoreRestore / StatefulOperator 注入
遇到
groupBy().agg()这类有状态算子时,自动插入StateStoreRestoreExec(读状态)→ 计算 →StateStoreSaveExec(写状态)三层物理节点。用户完全感知不到这个过程。 -
WatermarkPropagation 规则
Watermark 不只是一个时间戳------它会沿着逻辑计划向下传播,告诉每个有状态算子"哪些 window 可以被清理"。多个有状态算子串联时,每个算子的 Watermark 都可能不同。
-
currentBatchId 注入
每个 batch 有全局唯一的
batchId(单调递增整数),它被注入到物理计划中,用于幂等写入(Sink 根据 batchId 去重)。这是 Exactly-Once 的核心基础。
💡 提示
可以用
query.explain(extended=True)打印流查询的完整物理计划,看到所有被注入的 StateStore 节点和 Watermark 节点。这是线上排查性能问题的第一步。
02 · Checkpoint 容错
很多人以为 Checkpoint 就是"保存进度",但实际上它是由三个相互配合的层次组成的:
| 层一:偏移量日志 | 层二:Commit 日志 | 层三:State 快照 |
|---|---|---|
路径 :checkpoints/offsets/ |
路径 :checkpoints/commits/ |
路径 :checkpoints/state/ |
| 内容:每批次消费的 Source offset 范围(JSON) | 内容:已成功完成的 batchId | 内容:RocksDB / HDFS 格式的状态数据 |
| 作用:重启时知道从哪里重新读取数据 | 作用:区分"已开始"与"已完成"的批次 | 作用:恢复聚合、Join 等有状态算子的中间结果 |
| 格式:顺序写,每 batch 一个文件 | 重启时:若 offset 有记录但 commit 无对应 → 该批次重新执行 | 频率:每 N 批次做一次全量快照(默认 10 批) |
重启恢复的精确语义
-
无 Commit 记录的 batch → 幂等重跑
若 batch 3 的 offset 已写入但 commit 不存在,重启后 Spark 会用相同的 offset 范围重跑 batch 3,并写入相同的 batchId=3,Sink 根据 batchId 去重,保证 Exactly-Once。
-
State 恢复的代价
State 快照是开销最大的部分。默认 HDFS State Store 每个快照是一次完整的序列化 + 文件写入。状态越大(比如大窗口聚合),快照越慢,这会直接拉长单批次延迟。换用 RocksDB 后变为增量写(类似 LSM 树的 SST 文件),快照时间可降低 50%~80%。
⚠️ 高频踩坑
修改流查询的 Schema 或有状态算子逻辑后,旧的 Checkpoint 通常不兼容。必须删除 Checkpoint 目录重新启动,否则会在恢复时抛出反序列化异常。这意味着有状态的流作业做任何版本升级都需要提前规划迁移方案。
Checkpoint 存储路径的重要性
python
# ✅ 推荐:S3 / HDFS,保证 Driver 故障后状态不丢失
writeStream.option("checkpointLocation", "s3://prod-bucket/checkpoints/stream-v2/")
# ❌ 危险:本地路径,Driver 重新调度到其他节点后状态全丢
writeStream.option("checkpointLocation", "/tmp/checkpoints/") # 绝对不能用于生产
checkpoints/
├── offsets/ # 0, 1, 2, 3 ... (每批一个文件)
├── commits/ # 0, 1, 2, 3 ... (已完成的批次)
├── state/ # 0/ 按算子 operatorId 分目录
│ └── 0/ # 按分区分子目录
└── metadata # 查询 ID、Spark 版本
03 · Watermark 深层语义
Watermark 的数学定义
watermark(t) = max(event_time_seen_so_far) - delay_threshold
这里有两个关键:
- max 取的是所有分区里见过的最大事件时间(跨分区聚合)
- Watermark 单调递增------一旦推进就不会回退,即使后续批次没有新数据进来。
Watermark 与 Output Mode 的约束矩阵
| Append Mode | Complete Mode | Update Mode |
|---|---|---|
| 必须有 Watermark 才能做窗口聚合 | 每次输出全量聚合结果 | 只输出本批次有变化的行 |
| 窗口结束 + 超过 Watermark 后才输出 | 不能和 Watermark 组合使用 | 可以和 Watermark 组合 |
| 一旦输出,该行不会再被更新 | 状态无限增长,内存压力大 | 配合 Cassandra / HBase 等更新型存储 |
| 适合写 Delta / Parquet 等不可变存储 | 适合全局计数、Top-N 等场景 | 网络传输量最小 |
| 延迟 = 窗口大小 + Watermark 延迟 | 只能写支持覆盖的 Sink(如内存表) | 不能写 Parquet(不支持行级更新) |
迟到数据(Late Data)的处理细节
-
迟到但在 Watermark 之内 → 正常处理
事件时间 < 当前 Watermark 但窗口还未关闭:数据仍被计入聚合结果。窗口关闭的条件是
window_end ≤ watermark,而不是事件到达时间。 -
迟到超过 Watermark → 静默丢弃
Spark 不抛出异常,不记录日志 ,直接丢弃。在
lastProgress.eventTime中可以看到droppedRowsCount字段(部分 Spark 版本),但并非所有版本都暴露这个指标,生产中需要在 Source 端埋点统计迟到率。 -
Watermark 推进的时机
Watermark 在每个微批次结束时更新,而不是实时更新。如果某个批次 30 分钟没有新数据,Watermark 不会推进,所有窗口都不会关闭------这是导致状态无限增长的另一个常见原因。
🔶 Watermark 延迟设多少?
没有公式,需要分析业务数据的迟到分布(P99 迟到时间)。设太小 → 大量数据被丢弃;设太大 → 窗口长时间不关闭,状态膨胀,输出延迟增大。典型做法:先用
Update Mode+ 监控droppedRows确定合理延迟,再切换到Append Mode上生产。
多级 Watermark:流与流 Join 的额外复杂度
python
# Stream-Stream Join 时,两侧都需要设 Watermark
left = spark.readStream....withWatermark("left_time", "1 hour")
right = spark.readStream....withWatermark("right_time", "2 hours")
# Join 条件必须包含时间范围约束,否则状态无限增长
joined = left.join(
right,
(left.user_id == right.user_id) &
(left.left_time.between(
right.right_time - expr("INTERVAL 1 HOUR"),
right.right_time + expr("INTERVAL 1 HOUR")
))
)
# Spark 会取两者 Watermark 的最小值来清理 Join 缓冲区
# min(left_watermark, right_watermark) 决定何时清理状态
04 · State Store 原理
| 默认:HDFSBackedStateStore | 推荐:RocksDBStateStore |
|---|---|
| 状态全量存储在 Executor 内存 | 状态存储在 Executor 本地磁盘(RocksDB LSM 结构) |
| 每次快照:把内存中所有状态序列化写到 HDFS | 增量快照:只把 delta 上传到 HDFS/S3 |
| 状态大时(GBs),快照是主要延迟来源 | 内存仅需缓存热数据,状态可远超内存 |
| Executor 内存必须能装下全部状态 | 快照时间与状态大小解耦 |
| 优点:无额外依赖,配置简单 | 读写延迟略高于纯内存(~微秒级) |
| 适用:状态小(< 100MB/executor) | 适用:大状态(GBs 甚至 TBs)、高并发 |
RocksDB State Store 配置
python
spark = SparkSession.builder() \
.config("spark.sql.streaming.stateStore.providerClass",
"org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider") \
# 每个 operator + partition 独立一个 RocksDB 实例
.config("spark.sql.streaming.stateStore.rocksdb.changelogCheckpointing.enabled", "true") \
# 增量 changelog 模式(Spark 3.4+,进一步减少快照开销)
.config("spark.sql.streaming.stateStore.rocksdb.compactOnCommit", "false") \
# 不在 commit 时做 compaction,减少写放大
.getOrCreate()
State 分区与 Executor 的绑定
关键约束:State 分区数不能在运行时更改
State Store 的分区数由第一次启动时的
spark.sql.shuffle.partitions决定,此后固定不变。如果要扩容(比如从 200 分区改到 400),必须清除 Checkpoint 重启,因为 State 文件的目录结构与分区 ID 绑定。这是流作业扩容最大的约束之一。
🔍 State Store 的读写流程(展开查看内部细节)
-
读:StateStoreRestoreExec
对每条输入记录,以 group key 为键查询 State Store,取出上一批次的聚合中间值(如 sum、count)。默认 HDFS 实现每次都查内存 HashMap;RocksDB 实现先查 block cache,miss 后读磁盘。
-
写:StateStoreSaveExec
计算完成后,将新的聚合值写回 State Store。写操作批量缓存在内存,批次结束时统一 flush。同时根据 Watermark 清理过期的 state key(调用
store.remove())。 -
快照:Checkpoint 时持久化
HDFS 模式:序列化所有 key-value 对,写到
state/operatorId/partitionId/batchId.delta。RocksDB 模式:上传增量 SST 文件到远程存储,本地保留完整 RocksDB 实例供下次快速恢复。
05 · Exactly-Once 机制
Exactly-Once 不是 Spark 单方面的保证,它需要 Source + Engine + Sink 三方协同:
- Source 层:支持重放(Replayable)。Kafka 通过 offset 回溯,文件系统通过路径列表,都能在重试时重新读取相同数据。
- Engine 层:Offset + Commit 两阶段协议确保每个 batch 要么完整执行要么完整回滚,配合 batchId 单调递增实现幂等性。
- Sink 层:必须支持幂等写入或事务写入。Delta Lake、Iceberg 通过乐观锁事务;Kafka Sink 通过 transactional producer;传统 JDBC 需要 upsert + batchId 去重列。
不同 Sink 的语义对比
| ✅ 原生 Exactly-Once | ⚠️ 只能 At-Least-Once |
|---|---|
| Delta Lake(乐观并发控制) | Elasticsearch(无原生事务) |
| Apache Iceberg(ACID 事务) | HBase(需业务层去重) |
| Kafka(事务性 Producer) | Redis(INCR 等操作非幂等) |
| 文件系统(原子 rename 操作) | HTTP 外部接口(通常无法重试) |
| JDBC(upsert + batchId 列) | 自定义 foreachBatch(取决于实现) |
用 foreachBatch 实现自定义幂等 Sink
python
def write_to_postgres_idempotent(batch_df, batch_id):
# batch_id 是单调递增的,利用它做幂等 UPSERT
if batch_df.count() == 0:
return
# 先写到临时表(带 batch_id 标记)
batch_df.withColumn("batch_id", lit(batch_id)) \
.write.jdbc(url, "tmp_batch", mode="overwrite")
# 事务内执行幂等 MERGE(PostgreSQL ON CONFLICT)
with get_pg_conn() as conn:
conn.execute("""
INSERT INTO orders_stats
SELECT * FROM tmp_batch
ON CONFLICT (city, window_start)
DO UPDATE SET
order_count = EXCLUDED.order_count,
batch_id = EXCLUDED.batch_id
WHERE orders_stats.batch_id < EXCLUDED.batch_id
""") # WHERE batch_id < 保证幂等:相同 batch 重复写入无副作用
query = result.writeStream \
.foreachBatch(write_to_postgres_idempotent) \
.option("checkpointLocation", "s3://bucket/checkpoints/pg/") \
.start()
06 · 性能调优
| 3× | 50% | 200ms | 10ms |
|---|---|---|---|
| RocksDB 在大状态场景快照速度提升 | 合理 maxOffsetsPerTrigger 可降低的 GC 压力 | Micro-batch 模式延迟下限(约) | Continuous 模式延迟下限(约) |
关键配置参数速查
python
# ── Kafka Source 限速(防止单批次数据量爆炸)──
.option("maxOffsetsPerTrigger", "50000") # 每批最多处理 5万条
.option("minPartitions", "100") # 最少并行分区数(影响 parallelism)
# ── 状态相关 ──
spark.conf.set("spark.sql.shuffle.partitions", "200")
# 核心:决定 State Store 分区数,启动后不能改!
# 经验值:= 2~4x Executor 核数,避免过多小任务调度开销
spark.conf.set("spark.sql.streaming.stateStore.minDeltasForSnapshot", "10")
# 每积累 10 个 delta 做一次全量快照(RocksDB 模式)
# ── Trigger 间隔选择 ──
.trigger(processingTime="30 seconds") # 低延迟场景
.trigger(processingTime="5 minutes") # 吞吐优先场景(减少调度开销)
.trigger(availableNow=True) # 一次性处理积压,完成后自动停止
# ── 内存优化 ──
spark.conf.set("spark.executor.memory", "8g")
spark.conf.set("spark.memory.fraction", "0.7") # 执行+存储内存占比
spark.conf.set("spark.memory.storageFraction", "0.3") # 存储内存(State 缓存)
背压(Backpressure)与流量控制
Structured Streaming 没有自动背压!
Spark Streaming(旧版 DStream API)有基于速率估算的背压机制,但 Structured Streaming 没有。必须手动通过
maxOffsetsPerTrigger(Kafka)或maxFilesPerTrigger(文件 Source)来限速。
如果 Kafka 积压突然暴增,且没有设置限速,第一个批次可能读入数亿条数据,直接打爆 Executor 内存,导致 OOM 或 GC 停顿加剧,反而让整个流处理陷入更深的积压死循环。
Shuffle 优化:避免流处理的 Small File 问题
python
# 自适应查询执行(AQE)对流处理的支持(Spark 3.3+)
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
# AQE 可以在运行时根据实际数据量合并小分区
# 注意:AQE 对 stateful 算子的支持有限,需测试验证
# 写出时合并小文件
result.writeStream \
.option("maxRecordsPerFile", "1000000") \ # 控制每个文件大小
.format("delta") \
.start("s3://...")
# Delta Lake OPTIMIZE + ZORDER 定期整理小文件(批处理任务,非流处理)
# spark.sql("OPTIMIZE delta.`s3://bucket/delta/orders/` ZORDER BY (city)")
07 · 生产踩坑集
⚠️ 陷阱 1:Watermark 不推进导致状态无限增长
现象 :流作业运行几天后 Executor 内存持续上涨,最终 OOM。
原因 :某个 Kafka 分区长时间没有新数据(冷分区),导致该分区的 max event_time 停滞,拖住整体 Watermark 不前进,窗口无法关闭,State 持续积累。
解决:
- 用
withWatermark时确保所有分区都有心跳数据; - 升级 Spark 3.3+ 并开启
spark.sql.streaming.multipleWatermarkPolicy = max(取所有分区 Watermark 最大值而非最小值,激进但有风险); - 业务层定期向冷分区推入 sentinel 事件。
⚠️ 陷阱 2:foreachBatch 中的副作用导致重复计算
现象 :重启后发现下游数据库中某些记录被插入了两次。
原因 :foreachBatch 中的写操作不是幂等的,重启时 Spark 重跑最后一个未 commit 的 batch,数据被写入两次。
解决 :写入时必须携带 batchId 并在目标端做 WHERE batch_id < incoming_batch_id 的条件过滤。
⚠️ 陷阱 3:修改流查询逻辑后 Checkpoint 不兼容
现象 :上线新版本后启动时报 IllegalStateException: Incompatible schema 或 State schema does not match。
原因 :修改了有状态算子的 key/value Schema(如添加字段、修改聚合逻辑),导致旧 State 快照无法反序列化。
解决:
- 不修改 Schema,只添加新字段并给默认值(有限支持);
- 用
dropDuplicates替代部分状态操作降低 Schema 敏感性; - 最根本:清 Checkpoint 重启,同时做好积压回追策略(用
startingOffsets=earliest从头消费)。
⚠️ 陷阱 4:Driver 单点成为性能瓶颈
现象 :Executor 端 CPU 使用率很低,但批次延迟很高,监控显示 "scheduling delay" 很大。
原因 :Driver 在每个批次串行完成:拉取 offset → 构建计划 → 提交 Task → 收集结果 → 写 Checkpoint。Kafka 分区数多(1000+)时,光是元数据拉取就要几十秒。
解决:
- 减少 Kafka 分区数(合并 Topic);
- 增大
processingTime,用批次间隔换 Driver 处理时间; - 为 Driver 分配更多 CPU 核(
spark.driver.cores); - 拆分为多个独立的流查询(每个独立 SparkSession 有独立 Driver 线程)。
⚠️ 陷阱 5:Complete Mode + 无界聚合 = 内存炸弹
现象 :流作业初期运行正常,几天后 OOM 崩溃。
原因 :Complete Mode 不清理状态,groupBy(user_id).count() 会把所有历史 user_id 都保留在 State Store 中,用户越来越多,状态永久增长。
解决:
- 不要用 Complete Mode 做无界 key 的聚合;
- 改用 Update Mode + 带 Watermark 的窗口聚合,让状态自动清理;
- 如果必须全局统计,考虑用 HyperLogLog 等近似算法(
approx_count_distinct)代替精确计数。
⚠️ 陷阱 6:event_time 字段时区问题
现象 :窗口聚合结果时间偏移了 8 小时(或其他时区差)。
原因 :Kafka 消息中的时间戳是 Unix 毫秒(UTC),但在 Spark 中被当作本地时区解析,或者在不同环境(开发机 vs 生产集群)时区配置不一致。
解决 :显式设置 spark.conf.set("spark.sql.session.timeZone", "UTC"),并在 Schema 定义时使用 TimestampType(自动按 UTC 解析)。在 from_json 解析时尤其注意格式字符串包含时区信息。
✅ 建议的生产监控指标
inputRowsPerSecond(输入速率)、processedRowsPerSecond(处理速率)、batchDuration(批次耗时)、numStateRows(状态行数)、stateMemory(状态内存)。用 Prometheus + Grafana 接入StreamingQueryListener实时采集。
08 · 完整参考代码
python
from pyspark.sql import SparkSession
from pyspark.sql.functions import window, count, col, from_json, lit
from pyspark.sql.types import StructType, StringType, TimestampType
from pyspark.sql.streaming import StreamingQueryListener
import logging, json
# ── 1. SparkSession:开启 RocksDB + AQE ──
spark = SparkSession.builder() \
.appName("OrderStatsStream_v3") \
.config("spark.sql.streaming.stateStore.providerClass",
"org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.session.timeZone", "UTC") \
.config("spark.sql.shuffle.partitions", "200") \
.getOrCreate()
# ── 2. 自定义监控 Listener ──
class MetricsListener(StreamingQueryListener):
def onQueryStarted(self, event):
logging.info(f"Query started: {event.id}")
def onQueryProgress(self, event):
p = event.progress
logging.info({
"batch_id": p.batchId,
"input_rows_per_sec": p.inputRowsPerSecond,
"processed_rows_per_sec": p.processedRowsPerSecond,
"batch_duration_ms": p.durationMs.get("triggerExecution"),
"state_rows": [s.numRowsTotal for s in p.stateOperators],
"watermark": p.eventTime.get("watermark")
})
# 发送到 Prometheus / Datadog / CloudWatch
def onQueryTerminated(self, event):
if event.exception:
logging.error(f"Query failed: {event.exception}")
# 触发告警(PagerDuty / Slack)
spark.streams.addListener(MetricsListener())
# ── 3. Schema ──
schema = StructType() \
.add("order_id", StringType()) \
.add("user_id", StringType()) \
.add("city", StringType()) \
.add("amount", StringType()) \
.add("event_time", TimestampType())
# ── 4. 读取 Kafka(带限速)──
raw = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "broker1:9092,broker2:9092") \
.option("subscribe", "orders") \
.option("startingOffsets", "latest") \
.option("maxOffsetsPerTrigger", "100000") \ # 限速,防止积压时单批爆炸
.option("failOnDataLoss", "false") \ # Kafka 日志过期时不中断
.load() \
.select(from_json(col("value").cast("string"), schema).alias("d")) \
.select("d.*") \
.filter(col("event_time").isNotNull()) # 过滤 parse 失败的脏数据
# ── 5. 带 Watermark 的滑动窗口聚合 ──
result = raw \
.withWatermark("event_time", "10 minutes") \
.groupBy(
window("event_time", "10 minutes", "5 minutes"), # 滑动窗口
"city"
) \
.agg(count("*").alias("order_count"))
# ── 6. 写入 Delta Lake(Exactly-Once,Append)──
query = result.writeStream \
.format("delta") \
.outputMode("append") \
.option("checkpointLocation", "s3://prod/checkpoints/order_stats_v3/") \
.trigger(processingTime="1 minute") \
.start("s3://prod/delta/order_stats/")
# ── 7. 优雅退出处理 ──
try:
query.awaitTermination()
except KeyboardInterrupt:
query.stop()
spark.stop()
except Exception as e:
logging.error(f"Fatal error: {e}")
# 触发 PagerDuty 告警
raise
✅ 架构建议
在 Kubernetes 上运行流作业时,推荐使用 Spark on K8s 的 Driver 重启策略(
restartPolicy: OnFailure)+ S3 Checkpoint,实现 Driver 崩溃后自动恢复,结合 RocksDB State Store 实现亚分钟级恢复时间。

