Spark 2023-08-05 · 20 min 阅读

Spark 流式进化史:Watermark 之前,我以为自己已经懂了流式

Spark 流式处理有两条完全不同的路:DStream 是 RDD 的流式封装,Structured Streaming 是把流当成表。我用了一年 DStream 之后被通知迁移——那次迁移让我意识到 Spark 流式只有最新一代才适合做”事件时间”窗口。这一篇讲清楚 DStream 与 Structured Streaming 的架构差异、事件时间 vs 处理时间、Watermark 的语义、流与批的 JOIN、Kafka 集成——这是一条”从流式入门到理解 Spark 流式灵魂”的路径。


序:用了一年 DStream 之后,我被通知迁移

2019 年我在一家电商公司处理实时订单流。技术栈是:

Kafka → Spark Streaming (DStream) → Cassandra

我自以为了解 Spark 流式——直到产品经理找我:

“我们要把订单事件的处理时间从 ‘处理时刻’ 改成 ‘业务时刻’,不然用户下单 30 秒后才看到订单状态,体验不对。”

那一刻我才意识到:我处理的是”处理时间”,不是”事件时间”。DStream 窗口是基于”消息到达 Spark 的时间”,不是”消息实际产生的时间”。如果用户下单时网络延迟 5 秒,我的窗口聚合就错过了正确的事件。

那次迁移让我从 DStream 转到 Structured Streaming。迁移完我才看明白:DStream 是 RDD 的流式封装,Structured Streaming 是把流当成无界表。前者按”处理时间”切窗,后者可以按”事件时间”切窗 + Watermark 容错。

这一篇把那次踩坑 + 迁移的经验讲给你:从 DStream 到 Structured Streaming 的架构差异、窗口演进、Watermark 灵魂、Kafka 集成。

一、流式的两条路:DStream 与 Structured Streaming

Spark 流式处理有两套完全独立的 API:

维度 DStream(低阶) Structured Streaming(高阶)
抽象 RDD 的流式封装 无界表(unbounded table)
时间模型 处理时间 事件时间 + 处理时间
容错 Checkpoint Checkpoint + WAL
数据源 多种(含 KafkaUtils) 多种(含 kafka format)
输出 Action 风格 Output mode(append/complete/update)
状态管理 updateStateByKey、mapWithState 任意复杂状态(groupBy + agg)
与 DataFrame 集成 ❌ ✅(直接是 DataFrame)

一个值得记住的对比

// DStream:基于 RDD 流
val lines = streamingContext.socketTextStream("localhost", 9999)
val words = lines.flatMap(_.split(" "))
val counts = words.map(x => (x, 1)).reduceByKey(_ + _)
counts.print()

// Structured Streaming:基于表
val df = spark.readStream.format("socket").option("host", "localhost").option("port", 9999).load()
val words = df.select(explode(split($"value", " ")).as("word"))
val counts = words.groupBy("word").count()
val query = counts.writeStream.outputMode("complete").format("console").start()
query.awaitTermination()

同样的功能,Structured Streaming 写起来像 SQL,DStream 像 RDD。

我当年因为 DStream 折腾的事

DStream 的 API 跟 RDD 像,但跟 DataFrame 完全不通。如果你想用 DataFrame 的优化(Catalyst、Tungsten),用 DStream 就要绕一圈:先把流转成 DataFrame、再 join、再 back 到流。

Structured Streaming 直接就是 DataFrame——优化全继承。

二、DStream:低阶 API

DStream(Discretized Stream,离散化流)把流数据按批(batch interval)切成”小 RDD”:

import org.apache.spark.streaming.{Seconds, StreamingContext}

val ssc = new StreamingContext(spark.sparkContext, Seconds(5))
//                                                            <-- 每 5 秒切一批

// socket 数据源
val lines = ssc.socketTextStream("localhost", 9999)
//                <-- 从 socket 读,按行切

// 经典 wordcount
val words = lines.flatMap(_.split(" "))
val pairs = words.map(w => (w, 1))
val counts = pairs.reduceByKey(_ + _)

counts.print()         // <-- 打印到 driver 控制台
ssc.start()            // <-- 启动流
ssc.awaitTermination() // <-- 阻塞直到流结束

理解 DStream 的核心:每个 batch interval 是一次 mini RDD 操作。每 5 秒 Spark 把这 5 秒内的数据当成一个 RDD 算一次。

一个反直觉的细节:DStream 是 lazy 的吗

val lines = ssc.socketTextStream(...)                   // <-- Transformation,lazy
val counts = lines.flatMap(...).reduceByKey(...)        // <-- Transformation,lazy
counts.print() // <-- Action,但 print 是注册一个输出,不是触发计算
ssc.start()                                             // <-- 启动流后才真正开始计算

DStream 的 transformation 是 lazy,但 start() 之后就开始持续计算,每个 batch interval 触发一次。

三、窗口:window / reduceByKeyAndWindow / 滑动窗口

DStream 提供四种窗口操作:

// 1. window:把多个 batch 的数据合并成 RDD
val windowedWords = words.window(Seconds(30))    // <-- 30 秒的窗口

// 2. countByWindow:直接数行数
val count = words.countByWindow(Seconds(30))    // <-- 30 秒内的单词总数

// 3. reduceByWindow:自定义 reduce
val sum = words.countByWindow(Seconds(30))      // <-- 类似

// 4. reduceByKeyAndWindow:按 key 的窗口聚合
val pairCounts = pairs.reduceByKeyAndWindow(
  (a: Int, b: Int) => a + b,                   // <-- reduce 函数
  Seconds(30),                                 // <-- 窗口长度
  Seconds(10)                                  // <-- 滑动步长
)

滑动窗口 vs 滚动窗口

// 滚动窗口(步长 = 窗口长度):每个窗口独立
val t1 = words.window(Seconds(30))             // <-- [0-30s] 一次
val t2 = words.window(Seconds(30))             // <-- [30-60s] 又一次

// 滑动窗口(步长 < 窗口长度):窗口有重叠
val t3 = words.window(Seconds(30), Seconds(10))
// <-- [0-30s], [10-40s], [20-50s]...

实际项目里滑动步长一般比窗口短,比如窗口 1 分钟、步长 10 秒——每 10 秒输出一次过去 1 分钟的数据。

反向函数:优化窗口增量计算

val pairs = ...

// 加一个反向函数,Spark 知道怎么减(避免全量重算)
val pairCounts = pairs.reduceByKeyAndWindow(
  (a: Int, b: Int) => a + b,            // <-- 加:新数据进来
  (a: Int, b: Int) => a - b,            // <-- 减:旧数据移出窗口
  Seconds(30),                            // <-- 窗口
  Seconds(10)                             // <-- 步长
)

反向函数让 Spark 只算增量,不重算整个窗口——性能提升 5-10 倍。我的电商项目用了这个优化,CPU 降了一半。

四、Structured Streaming:高阶 API

Structured Streaming 把流当成无界表——每个新事件是表的新行:

// 读 socket 数据源
val df = spark.readStream
  .format("socket")
  .option("host", "localhost")
  .option("port", 9999)
  .load()                                  // <-- 返回 DataFrame[String],每行是一行 socket 数据

// SQL 风格的 groupBy
val words = df.select(explode(split($"value", " ")).as("word"))
val counts = words.groupBy("word").count()

// 输出
val query = counts.writeStream
  .outputMode("complete")               // <-- 输出完整结果(区别于 append/update)
  .format("console")                    // <-- 输出到 console
  .start()

query.awaitTermination()

三种输出模式

模式 行为 适用
append 只输出新增的行(默认) 大多数场景
complete 输出完整结果(全量重写) 聚合(groupBy + count)
update 输出变化的行 聚合 + 状态变化
// append:聚合不能用(必须是有界聚合)
df.select($"value").writeStream.outputMode("append").start()

// complete:聚合用 complete
df.groupBy("word").count().writeStream.outputMode("complete").start()

五、事件时间 vs 处理时间

这是 Spark 流式最重要的概念区分。

// 处理时间:Spark 收到事件的时间(毫秒时间戳)
df.groupBy(window($"processingTime", "10 minutes")).count()

// 事件时间:事件实际产生的时间(业务时间戳)
df.groupBy(window($"eventTime", "10 minutes")).count()

我当年被这个坑过

2019 年的电商项目,我用 DStream 算”过去 5 分钟订单数”:

// DStream
val orders = KafkaUtils.createStream(...)
orders.countByWindow(Seconds(300))           // <-- 默认按处理时间

问题:用户 10:00:00 下单、Kafka 延迟 10 秒、Spark 10:00:10 才收到。如果窗口是”10:00 - 10:05”,这条订单算到了 10:00 之后那个窗口。

DStream 没有”事件时间”概念——只有按”消息到达 Spark 时间”切窗。

迁移到 Structured Streaming:

val orders = spark.readStream.format("kafka")
  .option("subscribe", "orders")
  .load()
  .select(from_json($"value".cast("string"), schema).as("data"))
  .select($"data.*", $"data.eventTime".cast("timestamp").as("event_time"))

orders.groupBy(window($"event_time", "5 minutes")).count()
  .writeStream.outputMode("complete").format("console").start()

事件时间是业务时间,处理时间是 Spark 时间。分析时必须用事件时间。

六、Watermark:让迟到数据有边界

事件时间解决了”按业务时间切窗”,但还有个问题:迟到数据。

10:00 产生的事件,因为 Kafka 重试,10:30 才到 Spark
这属于"迟到数据"

无 Watermark 时:Spark 会无限等下去,10:30 收到就重新计算 10:00-10:05 的窗口——状态会无限增长。

Watermark 是”我愿意等多长时间”的声明:

import org.apache.spark.sql.streaming.Trigger

val orders = spark.readStream.format("kafka")...

orders
  .withWatermark("event_time", "10 minutes") // <-- 最多等 10 分钟
  .groupBy(window($"event_time", "5 minutes"))
  .count()
  .writeStream
  .outputMode("complete")
  .trigger(Trigger.ProcessingTime("10 seconds"))      // <-- 每 10 秒触发一次微批
  .option("checkpointLocation", "/path/checkpoint")
  .start()

withWatermark("event_time", "10 minutes") 意思是:事件时间 T 的数据,最多等到 T + 10 分钟。超过就丢弃。

一个值得记住的细节:Watermark 的语义

// 事件时间 = 10:00
// Watermark 延迟 = 10 分钟
// Spark 等待到 10:10
// 10:10 之后,10:00 这个事件就当作"过期"处理

// 实际触发条件:
// 当前最大事件时间 = 10:15
// Watermark = 10:15 - 10 = 10:05
// 10:05 之前的窗口被认为"完整"

Watermark 不是”等 N 分钟”,是”事件时间推进 N 分钟后,旧的窗口被认定完整”。我第一次看官方文档理解错了——以为”延迟 10 分钟就是等 10 分钟”,实际上是”事件时间推进后才推进”。

七、流与批、流与流的 JOIN

Structured Streaming 支持两类流式 JOIN:

流-批 JOIN(流 join 静态维度表)

// 这是流(订单)
val orders = spark.readStream.format("kafka")...

// 这是静态维度(用户表)
val users = spark.read.parquet("path/to/users").cache()

// JOIN:给订单补上用户信息
val result = orders.join(users, "city")
result.writeStream.format("console").start()

流-批 JOIN 简单:流每次触发时,去查一次维度表(Spark 会 broadcast)。

流-流 JOIN(流 join 流)

val clicks = spark.readStream.format("kafka").option("topic", "clicks").load()...
val impressions = spark.readStream.format("kafka").option("topic", "impressions").load()...

// 流-流 JOIN 需要 Watermark
clicks
  .withWatermark("clickTime", "5 minutes")
  .join(
    impressions.withWatermark("impressionTime", "5 minutes"),
    expr("clickerId = impressionerId AND clickTime BETWEEN impressionTime AND impressionTime + INTERVAL 1 HOUR"),
    "leftOuter"
  )
  .writeStream.start()

流-流 JOIN 的硬性要求:两个流都必须有 Watermark。否则 Spark 不知道”两个流之间怎么对齐时间”。

八、Kafka 集成

Structured Streaming 集成 Kafka 非常简单:

val df = spark.readStream
  .format("kafka")                                    // <-- 用 kafka 数据源
  .option("kafka.bootstrap.servers", "host1:9092,host2:9092")
  .option("subscribe", "topic1")                      // <-- 订阅 topic
  .option("startingOffsets", "latest")                // <-- 从最新开始(earliest = 从头)
  .load()                                              // <-- DataFrame: key | value | topic | partition | offset | timestamp | timestampType

// value 是二进制,需要解析
val parsed = df.selectExpr("CAST(value AS STRING)").select(from_json($"value", schema).as("data"))

写 Kafka:

val query = parsed
  .selectExpr("to_json(struct(*)) AS value")          // <-- 序列化成 JSON
  .writeStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:9092")
  .option("topic", "topic2")
  .option("checkpointLocation", "/path/checkpoint")
  .start()

query.awaitTermination()

一个反直觉的细节:Checkpoint 必填

Structured Streaming 的 Kafka 集成必须指定 checkpointLocation——否则重启后会丢失状态(watermark、聚合结果)。

.option("checkpointLocation", "/path/checkpoint")   // <-- 必填

我用第一次忘了加,重启后 watermark 从 0 开始算——所有过期数据都重算了一次。

本篇小结

这一篇覆盖了 Spark 流式的 8 个话题——两条路、DStream、窗口、Structured Streaming、事件时间、Watermark、JOIN、Kafka——它们都围绕一个中心思想:

DStream 是 RDD 的流式封装,Structured Streaming 是把流当成表——后者才是 Spark 流式的未来。

🔜 下篇预告

这一篇我们讲了 Spark 流式——把数据流当成表。

但有些问题天然不是表,而是图:社交网络、推荐系统、欺诈检测——这些场景里”实体之间的关系”才是核心。下一篇讲 GraphX:用图算法揪出三人成伙的犯罪团伙。我会从一个真实的面试题开场,把 aggregateMessages 和 Pregel 的差异讲透。

# Spark