Apache Spark 第 13 章 附加篇 · Apache Spark Real-Time Mode 实时计算

§ 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 乱序 + 状态清理 全章三大核心认知

  1. 📌Spark 流是微批,不是真流。

    微批让 Spark 在容错、SQL、生态上遥遥领先,但延迟下限是百毫秒级。选型时要清楚业务延迟底线。

  2. 📌Watermark = 引擎的宽容度。

    只有在 Append 模式下,Watermark 才会触发窗口关闭和 State 清理。有状态聚合的 State 无限增长,99% 是因为没有正确配置 Watermark + Append 模式。

  3. 📌Checkpoint 是流作业的生命线。

    没有 Checkpoint 的流作业重启后会全量重放;多个 Query 必须有各自独立的 Checkpoint 路径;修改 Query Schema 后必须清空旧 Checkpoint。


相关推荐
菜鸡00012 小时前
把一个项目传到 GitLab 的某个群组
大数据·elasticsearch·gitlab
北京软秦科技有限公司10 小时前
AI审核如何助力合规取证?IACheck打造环境检测报告电子存证与法律风险防控新路径
大数据·人工智能
Kethy__10 小时前
计算机中级-数据库系统工程师-计算机体系结构与存储系统
大数据·数据库·数据库系统工程师·计算机中级
MX_935911 小时前
SpringMVC请求参数
java·后端·spring·servlet·apache
云原生指北12 小时前
命令行四件套:fd-rg-fzf-bat
java·大数据·elasticsearch
Datacarts13 小时前
AI大模型时代:微店商品数据API如何重构反向海淘决策
大数据·人工智能·重构
ws20190713 小时前
技术交流与商贸融合,2026广州汽车测试测量展释放产业协同新动能
大数据·人工智能·科技·汽车
运维老曾15 小时前
Flink 自定义数据源开发流程
大数据·flink
BioRunYiXue15 小时前
Nature Methods:CellVoyager 自主 AI 智能体开启生物数据分析新时代
大数据·开发语言·前端·javascript·人工智能·数据挖掘·数据分析