4、Structured Streaming 基础:流式数据源、输出模式与触发器

好,咱们进入实时入湖的第一道门槛——Structured Streaming。说实话,很多同学一开始就被「流式」两个字吓住了,觉得跟批处理是两套完全不同的东西。其实不然。Structured Streaming 的设计哲学就是「把流当成无限的表来操作」。你想想看,批处理是对一张有限表做查询,流处理只不过是把这张表变成了无限追加的数据而已。

我个人习惯把 Structured Streaming 拆成三个核心要素来理解:数据从哪来(Source)、怎么处理(Query)、结果写哪去(Sink)。这一章咱们重点啃前两个——流式数据源和输出模式,触发器是控制节奏的关键,也得一并拿下。

4.1 流式数据源:实时数据的入口

Structured Streaming 内置了几种常用的数据源,我挑最常用的三个讲。

数据源 适用场景 容错机制
File Source 监控目录下的新文件(如日志、CSV) 基于文件路径 + 修改时间
Kafka Source 高吞吐消息队列,生产环境标配 基于 offset + checkpoint
Socket Source 测试/演示用,别上生产 无容错,断连即丢

File Source 是我最早接触的流式源。它本质上就是不断扫描一个目录,把新出现的文件当作新批次读进来。我在项目中遇到过一个问题:如果文件写入不是原子操作(比如正在写就被 Spark 扫描到了),会读到不完整的数据。后来我强制要求上游用 .copy 或 .tmp 后缀写完再重命名,才彻底解决。

// 监控 HDFS 上的日志目录
val df = spark.readStream
  .format("json")
  .schema(logSchema)
  .option("maxFilesPerTrigger", 10)   // 每批次最多处理10个文件
  .load("/data/logs/")

Kafka Source 是生产环境的主力。它的核心是 offset 管理——你消费到哪条消息,Spark 会记录在 checkpoint 里。我曾经帮一个团队排查过数据重复问题,最后发现是他们把 startingOffsets 设成了 "earliest" 但 checkpoint 目录没配对,每次重启都从头消费。嗯,这里要注意:checkpoint 是 Exactly-Once 的命根子。

val kafkaDF = spark.readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:9092,host2:9092")
  .option("subscribe", "topic_orders")
  .option("startingOffsets", "latest")   // 生产环境建议用 latest
  .load()
避坑指南:我曾经见过有人把 Socket Source 直接用在演示环境给客户看,结果网络抖动一下,数据全丢了,场面一度非常尴尬。记住:Socket 只适合你在本地 nc -lk 9999 敲着玩,千万别拿它做任何正经事。

4.2 输出模式:决定你看到什么

输出模式说白了就是——每次触发计算后,怎么把结果写出去。有三种模式,我按使用频率排个序。

模式 行为 典型场景
Append 只输出新增的行 无状态过滤、ETL 入湖
Update 输出更新的行(仅聚合键变化时) 实时聚合、去重计数
Complete 每次输出全部结果 小规模全量聚合(如全局计数)

Append 模式 最直观。你做一个 filter 或 select,来一条数据就输出一条。我做的第一个实时入湖方案用的就是 Append——从 Kafka 读 JSON,简单清洗后直接写 Delta 表。这种模式对状态管理要求最低,性能也最好。

Update 模式 适合做聚合。比如统计每个用户的订单数,新订单来了,只输出该用户更新后的计数。我遇到过一个问题:如果聚合的 key 基数特别大(比如上亿用户),Update 模式的状态存储会膨胀得很厉害。后来我加了 withWatermark 来清理过期状态,才稳住。

val aggDF = kafkaDF
  .groupBy("user_id")
  .count()
  .writeStream
  .outputMode("update")
  .format("console")
  .start()

Complete 模式 每次把整个结果集写一遍。你想想看,如果聚合结果有 100 万行,每次触发都写 100 万行,这谁受得了?所以它只适合结果集很小的场景,比如全局 PV 计数。

我的经验:实时入湖 90% 的场景用 Append 模式就够了。只有当你需要做流式聚合并且要把聚合结果写回 Delta 时,才考虑 Update 或 Complete。别一上来就选 Complete,那是给自己挖坑。

4.3 触发器:控制处理节奏

触发器决定了「多久触发一次计算」。Structured Streaming 提供了三种触发策略。

触发类型 配置方式 行为
默认(微批) 不指定 上一批处理完立即处理下一批
固定间隔 trigger(ProcessingTime("10 seconds")) 每 10 秒启动一批,不管上一批是否完成
一次性 trigger(Once()) 只处理一次,然后停止
连续处理 trigger(Continuous("1 second")) 低延迟(毫秒级),但功能受限

默认微批 是最稳的。它像是一个勤劳的工人,干完活马上问「还有吗?」。延迟通常在秒级,适合大部分实时入湖场景。

固定间隔 我用的不多。因为它有个问题:如果处理时间超过了间隔,下一批会排队,造成背压。我曾经在一个项目中为了控制写入 Delta 的频率,用了 30 秒的固定间隔,结果数据积压越来越严重。后来改成默认微批 + 限流 maxOffsetsPerTrigger,反而更平滑。

df.writeStream
  .trigger(ProcessingTime("10 seconds"))
  .format("delta")
  .start("/path/delta-table")

一次性触发器 是个宝贝。它让流式查询只跑一次就结束,本质上变成了批处理。我经常用它来做「增量 ETL」——每天凌晨用 Once() 把前一天积累的流式数据一次性处理完,既享受流式 API 的便利,又不用长期占用资源。

连续处理 是 Spark 2.3 引入的实验特性,延迟能压到毫秒级。但限制很多:不支持 sort、不支持某些聚合、容错机制也不如微批成熟。我建议你先观望,等它 GA 了再考虑。

小技巧:如果你对延迟要求不高(比如 1-5 分钟),用默认微批 + maxOffsetsPerTrigger 限流,既简单又稳定。别为了追求「实时」而盲目上连续处理,稳定比快更重要。

4.4 知识体系总览

下面这张图把 Structured Streaming 的核心脉络串起来了。从数据源进来,经过输出模式的选择,再由触发器控制节奏,最终写出到 Sink。我建议你把它当作一张地图,遇到问题时先定位是哪个环节出了岔子。

Structured Streaming 核心三要素 流式数据源 File / Kafka / Socket 输出模式 Append / Update / Complete 触发器 微批 / 固定间隔 / Once Spark SQL 引擎 Sink(Delta / Console) 💡 核心要点 流 = 无限追加的表 Checkpoint 保 Exactly-Once ⚠️ 常见误区 Complete 模式滥用 Socket 用于生产

4.5 实战组合:一个完整的流式读取 + 输出示例

把上面三个要素串起来,就是一个完整的流式作业。下面这个例子从 Kafka 读取订单数据,做简单的过滤,然后以 Append 模式写入 Delta 表,每 10 秒触发一次。

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

val ordersDF = spark.readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "localhost:9092")
  .option("subscribe", "orders")
  .option("startingOffsets", "latest")
  .load()
  .selectExpr("CAST(value AS STRING) as json")
  .select(from_json($"json", orderSchema).as("data"))
  .select("data.*")

val validOrders = ordersDF.filter($"amount" > 0)

validOrders.writeStream
  .format("delta")
  .outputMode("append")
  .trigger(Trigger.ProcessingTime("10 seconds"))
  .option("checkpointLocation", "/tmp/checkpoint/orders")
  .start("/data/delta/orders")

个人建议:刚开始做实时入湖时,先用 format("console") 把输出打在控制台上观察几轮,确认数据流和逻辑都没问题,再换成 Delta Sink。这一步能帮你省下大量排查问题的时间。