2. Spark结构化流基础回顾:DataStreamReader、DataStreamWriter、Trigger、Checkpoint、OutputMode

在构建模型漂移监控管道之前,必须扎实掌握Spark结构化流(Structured Streaming)的核心抽象。结构化流将数据流视为一张“无界表”(Unbounded Table),持续追加新数据,并支持基于事件时间的窗口聚合与状态管理。本章聚焦五个基础构件:DataStreamReader(数据接入)、DataStreamWriter(数据输出)、Trigger(触发策略)、Checkpoint(容错机制)和OutputMode(输出语义)。

核心认知:结构化流不是逐条处理,而是以“微批次”或“连续处理”的方式,将无界表上的增量查询结果周期性地写出。理解这五个构件,是后续实现模型特征漂移检测与告警推送的基础。

2.1 DataStreamReader:从源头读取流数据

DataStreamReader 是结构化流的入口,负责从各种数据源(Kafka、文件、Socket、Rate等)创建 DataFrame。它通过链式方法设置格式、选项和模式,最终调用 load() 返回一个流式 DataFrame。

方法 / 属性 说明 常用示例
.format("kafka") 指定数据源格式 kafka, parquet, json, csv, rate, socket
.option("kafka.bootstrap.servers", "...") 设置数据源连接参数 Kafka broker地址、订阅topic、起始偏移量
.option("subscribe", "topic1") 订阅Kafka topic 支持正则表达式订阅多个topic
.option("startingOffsets", "earliest") 指定起始偏移量位置 earliest / latest / {"topic":{"0":23}}
.schema(schema) 为文件源提供显式Schema(推荐) StructType定义字段名与类型
.load() 创建流式DataFrame 返回DataFrame,isStreaming=true
// 示例:从Kafka读取模型推理日志
import org.apache.spark.sql.types._

val schema = StructType(Array(
  StructField("event_time", TimestampType),
  StructField("model_id", StringType),
  StructField("feature_vector", ArrayType(DoubleType)),
  StructField("prediction", DoubleType),
  StructField("ground_truth", DoubleType)
))

val kafkaDF = spark
  .readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "broker1:9092,broker2:9092")
  .option("subscribe", "model-inference-log")
  .option("startingOffsets", "earliest")
  .option("failOnDataLoss", "false")
  .load()
  .selectExpr("CAST(value AS STRING) as json_str")
  .select(from_json(col("json_str"), schema).as("data"))
  .select("data.*")
实践建议:在模型漂移场景中,强烈建议为Kafka消息使用Avro或Protobuf序列化,并配合Schema Registry管理。JSON虽然灵活,但解析开销大且容易因字段类型不匹配导致作业失败。使用 from_json 时务必提供显式Schema。

2.2 DataStreamWriter:将结果写出到外部系统

DataStreamWriter 是结构化流的输出终端,负责将流式查询的结果以指定模式写出到Kafka、文件、控制台或自定义接收器。它通过 writeStream 方法从DataFrame创建,并链式配置输出格式、输出模式、触发器、检查点位置等。

方法 说明 典型值
.format("console") 输出到控制台(调试用) console, kafka, parquet, memory, foreach
.outputMode("append") 指定输出语义 append, complete, update
.trigger(Trigger.ProcessingTime("10 seconds")) 设置触发间隔 ProcessingTime, Once, Continuous
.option("checkpointLocation", "/path/checkpoint") 设置检查点目录(必须) HDFS或S3路径
.start() 启动流式查询 返回 StreamingQuery 对象
// 示例:将漂移检测结果写出到Kafka告警topic
val driftAlertDF = ... // 经过漂移检测逻辑后的DataFrame

val query = driftAlertDF
  .selectExpr("to_json(struct(*)) AS value")
  .writeStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "broker1:9092")
  .option("topic", "model-drift-alert")
  .option("checkpointLocation", "/data/checkpoints/drift-detector")
  .outputMode("append")
  .trigger(Trigger.ProcessingTime("30 seconds"))
  .start()

query.awaitTermination()
关键注意:checkpointLocation 是必选项,不能省略。检查点目录存储了偏移量、状态数据和元数据,是故障恢复的唯一依据。生产环境中必须使用高可用的分布式文件系统(HDFS、S3、GCS),且不要手动删除或修改该目录下的文件。

2.3 Trigger:控制数据处理的节奏

Trigger决定了流式查询何时检查新数据并启动一次微批次处理。结构化流支持三种触发策略,适用于不同的延迟与吞吐需求。

Trigger 触发策略对比 ProcessingTime 固定时间间隔触发 默认:0秒(尽可能快) Once 仅执行一次微批次 适用于一次性回溯处理 Continuous 低延迟(毫秒级) 实验性功能,2.4+ 适用场景建议 • ProcessingTime:模型推理日志的常规漂移检测(10秒~1分钟间隔) • Once:每日凌晨对前一天全量数据进行漂移基线重算 • Continuous:对延迟极度敏感的实时告警(如PSI超过阈值立即触发)
// 三种Trigger的代码示例
import org.apache.spark.sql.streaming.Trigger

// 1. ProcessingTime:每30秒触发一次
df.writeStream.trigger(Trigger.ProcessingTime("30 seconds"))

// 2. Once:只处理一次当前所有可用数据,然后停止
df.writeStream.trigger(Trigger.Once())

// 3. Continuous:实验性,每1毫秒检查一次(需设置checkpoint)
df.writeStream.trigger(Trigger.Continuous("1 second"))
漂移监控选型:对于大多数模型漂移监控场景,ProcessingTime 10~60秒是平衡延迟与计算成本的合理区间。如果使用Once模式做日级基线重算,务必在作业启动前确保上游数据已全部到达。

2.4 Checkpoint:容错与状态恢复的基石

Checkpoint目录是结构化流实现“精确一次”(Exactly-Once)语义的核心。它持久化三部分信息:偏移量(已处理数据的进度)、状态数据(如聚合中间结果)、提交日志(已完成批次)。当作业失败重启时,Spark从Checkpoint恢复偏移量和状态,确保数据不丢不重。

Checkpoint子目录 存储内容 恢复作用
offsets/ 每个批次已读取的源偏移量范围 重启后从上次提交的偏移量继续读取
commits/ 已成功完成的批次ID 避免重复处理已完成批次
state/ 有状态操作(如聚合、mapGroupsWithState)的中间状态 恢复窗口聚合、会话等状态数据
sources/ 每个数据源的元数据(如Kafka分区与偏移量映射) 确保源读取进度一致
// 设置Checkpoint的两种方式
// 方式1:在DataStreamWriter中指定
df.writeStream
  .option("checkpointLocation", "hdfs://namenode:8020/checkpoints/model-drift")
  .start()

// 方式2:在SparkSession级别设置默认(不推荐,因为所有查询共享)
spark.conf.set("spark.sql.streaming.checkpointLocation", "/default/checkpoint")
生产铁律:
  • 每个流式查询必须使用独立的Checkpoint目录,不能复用。
  • Checkpoint目录一旦创建,不要手动修改或删除其中的任何文件。
  • 如果业务逻辑(如SQL、UDF)发生不兼容变更,旧Checkpoint可能无法恢复,此时需要清空Checkpoint并重新消费全部数据。
  • 使用S3作为Checkpoint存储时,务必启用一致性保证(如S3Guard或使用HDFS兼容的存储)。

2.5 OutputMode:定义输出的语义边界

OutputMode决定了每次微批次触发时,哪些行会被写入外部系统。它直接影响结果表的可见性和状态管理方式。

OutputMode 输出语义对比 Append(追加) 仅输出新增行 适用于无状态过滤/投影 最常用,性能最佳 Complete(完整) 每次输出全部结果表 仅适用于聚合查询 数据量大会有性能压力 Update(更新) 仅输出有更新的行 支持聚合与无聚合 Spark 2.1.1+ 引入 漂移监控中的OutputMode选择 • Append:适用于将每条推理记录写入特征存储或日志(无状态) • Complete:适用于输出每个模型的当前漂移指标聚合结果(如PSI均值) • Update:适用于输出状态变化(如模型漂移等级从“正常”变为“严重”)
// OutputMode 代码示例

// Append模式:仅输出新增的推理记录(无状态)
inferenceDF.writeStream
  .outputMode("append")
  .format("parquet")
  .option("path", "/data/inference-logs")
  .start()

// Complete模式:输出每个模型的聚合统计(必须有groupBy)
val aggDF = inferenceDF.groupBy("model_id").agg(avg("prediction").as("avg_pred"))
aggDF.writeStream
  .outputMode("complete")
  .format("console")
  .start()

// Update模式:输出状态发生变化的行
val stateDF = inferenceDF.groupBy("model_id").agg(collect_list("prediction").as("preds"))
stateDF.writeStream
  .outputMode("update")
  .format("console")
  .start()
关键约束:
  • Append 要求查询中不能有聚合操作(除非使用水印+事件时间窗口)。
  • Complete 要求查询必须包含聚合(groupBy/agg),且结果集不能无限增长。
  • Update 是Append的超集,允许聚合输出仅更新的行,但需要状态存储支持。
在模型漂移监控中,Update模式是最灵活的选择,既能输出聚合结果,又不会像Complete模式那样每次输出全量数据。

2.6 五要素协同工作流

将上述五个构件串联起来,形成一个完整的结构化流作业生命周期:

  1. 定义Schema:为输入数据(如Kafka消息中的JSON/Avro)定义明确的StructType。
  2. 创建DataStreamReader:配置数据源格式、连接参数、起始偏移量,调用 load() 得到流式DataFrame。
  3. 应用业务逻辑:对DataFrame进行过滤、转换、聚合、漂移检测等操作。
  4. 配置DataStreamWriter:选择输出格式、OutputMode、Trigger、Checkpoint路径。
  5. 启动查询:调用 start() 返回 StreamingQuery 对象,可通过 awaitTermination() 保持作业运行。
  6. 故障恢复:作业失败后重启,Spark自动从Checkpoint目录恢复偏移量和状态,继续处理。
// 完整示例:漂移监控作业骨架
val schema = StructType(Array(
  StructField("model_id", StringType),
  StructField("timestamp", TimestampType),
  StructField("feature_hash", StringType),
  StructField("score", DoubleType)
))

val sourceDF = spark
  .readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "kafka:9092")
  .option("subscribe", "model-scores")
  .option("startingOffsets", "earliest")
  .load()
  .selectExpr("CAST(value AS STRING) as json")
  .select(from_json(col("json"), schema).as("data"))
  .select("data.*")

val driftDF = sourceDF
  .groupBy("model_id")
  .agg(
    mean("score").as("avg_score"),
    stddev("score").as("std_score")
  )

driftDF.writeStream
  .outputMode("update")
  .format("console")
  .option("checkpointLocation", "/data/checkpoint/drift-demo")
  .trigger(Trigger.ProcessingTime("10 seconds"))
  .start()
  .awaitTermination()
本章小结:DataStreamReader负责接入,DataStreamWriter负责输出,Trigger控制节奏,Checkpoint保障容错,OutputMode定义语义边界。这五个构件构成了结构化流作业的骨架。在后续章节中,我们将在此基础上叠加模型漂移检测算法、特征分布对比、告警策略等业务逻辑,构建完整的监控管道。