摘要:DStream 是 Spark Streaming 的核心抽象,但很多人用了很久也没搞清楚它到底是什么。这篇把 DStream 拆开------它本质是"RDD 的时间序列",本身不存数据;讲清它内部的 DStreamGraph、Lineage 血缘,以及 getOrCompute 怎么按时间点把 DStream 变成 RDD,顺带说清有状态操作为什么要强制开 checkpoint。
关键词:Spark Streaming, DStream, RDD 序列, DStreamGraph, getOrCompute, checkpoint
一、DStream 到底是个什么东西
先给一个判断,省得绕:DStream 是"RDD 的时间序列",它自己一个字节的数据都不存。
回想上一篇文章里 batchInterval 的概念------Spark Streaming 每 2 秒(举例)切一个 batch,每个 batch 就是一个 RDD。DStream 就是把这些按时间排好的 RDD 串起来的抽象:
RDD @ t0 RDD @ t1 RDD @ t2 ... RDD @ tn
(batch 0) (batch 1) (batch 2) (batch n)
所以你写的 ds.map(...)、ds.reduceByKey(...) 这些操作,本质上是在描述"每个时间片的 RDD 该怎么变换",而不是立刻去算。这一点和 RDD 的惰性求值是一脉相承的------DStream 的转换操作构建的是依赖链,只有遇到 output 操作(print、saveAsTextFiles、foreachRDD)才真正触发计算。
二、DStream 的内部结构

一个 DStream 内部有三块关键的东西。
2.1 DStreamGraph:DStream 的 DAG
和 RDD 有 DAG 一样,DStream 也有自己的图结构 DStreamGraph,里面按角色分三类节点:
- InputDStream :数据入口,比如
SocketInputDStream、KafkaInputDStream,对应上一篇讲的 Receiver。 - TransformedDStream :中间转换,
map/flatMap/filter/join/window都会产生这种节点。 - ForEachDStream :输出节点,
foreachRDD、print属于这一类。
DStreamGraph 维护这张依赖图,每个 batch 时间点到了,就顺着图把每个 DStream 对应的 RDD 算出来。
2.2 Lineage:血缘
DStream 同样记录完整的血缘------"我是从哪个父 DStream、经过什么转换来的"。这条链子的用途和 RDD 的 lineage 一样:容错。
不过 DStream 的血缘有个 RDD 没有的要求:涉及有状态操作或需要故障恢复时,必须开 checkpoint。因为 DStream 的血缘是逻辑上的转换链,跨很多 batch 之后这条链会非常长,光靠血缘重算既不现实,也没法恢复"累计到现在的状态"。checkpoint 会把依赖链和状态持久化到可靠存储(HDFS),Driver 挂了能从 checkpoint 拉起来接着算。
scala
ssc.checkpoint("hdfs://namenode:8020/checkpoint/streaming-app")
// 有状态操作,不开 checkpoint 会直接抛异常
val state = lines.updateStateByKey(updateFunc)
三、转换操作的两个分类维度

理解 DStream 的操作,可以按两个维度切,这直接影响性能和要不要 checkpoint。
维度一:无状态 vs 有状态
| 无状态 | 有状态 | |
|---|---|---|
| 代表算子 | map / filter / union / join | updateStateByKey / window |
| 是否跨 batch | 只看当前 batch | 跨 batch 累计状态 |
| checkpoint | 不需要 | 必须开 |
无状态转换每个 RDD 独立处理,干净利落。有状态转换要在 batch 之间记住状态,这个状态就是靠 checkpoint 落盘保住的。
维度二:无 shuffle vs 有 shuffle
map/flatMap/filter 是窄依赖,一个分区内独立算,不碰网络;join/groupByKey/reduceByKey 是宽依赖,要跨分区 shuffle,代价高一个量级。这和 RDD 的判断完全一致------DStream 的每个操作,最终都会落到对应 RDD 的某个操作上。
四、getOrCompute:DStream 怎么变成 RDD
这是 DStream 最核心的一个方法,把"按时间生成 RDD"这件事讲清楚。
JobGenerator 每个 batch 触发一次,传入时间点 t,然后对每个 DStream 调用 getOrCompute(t):
- 查缓存 :先看
generatedRDDs里有没有t对应的 RDD,有就直接返回。 - 没命中就 compute(t) :调用当前 DStream 的
compute,它会先递归地让父 DStream 生成t时刻的 RDD,再应用当前这一步的转换。 - 缓存返回 :算出来的 RDD 存进
generatedRDDs,避免重复计算。
为什么要缓存?因为同一个 DStream 在同一个时间点可能被多个下游引用------比如一个流既要做实时统计,又要写一份原始数据。不缓存的话,同一个 RDD 会被算两遍,浪费整条链路的计算。
五、transform 和 foreachRDD:打通 RDD API
DStream 的算子就那么几十个,遇到复杂逻辑或者要访问外部资源(连接池、状态存储)时就不够用了。transform 和 foreachRDD 就是为这个留的口子:
scala
// transform:拿到 RDD,返回新的 RDD(继续流式计算)
val cleaned = ds.transform { rdd =>
rdd.mapPartitions { iter => /* 每分区初始化一次连接 */ }
}
// foreachRDD:拿到 RDD,做输出(到此为止,不再返回)
ds.foreachRDD { rdd =>
rdd.foreachPartition { partitionOfRecords =>
/* 写外部存储 */
}
}
两者都在 Driver 端直接拿到 RDD,可以随意用 RDD API。一个常见的最佳实践是:把连接这类重量级对象的创建放进 mapPartitions/foreachPartition 里,而不是在每条记录上 new 一个连接,否则吞吐会崩。
六、总结
- DStream 是 RDD 的时间序列,不存数据,转换操作是惰性的。
- DStreamGraph 分 Input/Transformed/ForEach 三类节点,Lineage 负责容错,有状态操作必须 checkpoint。
- 转换按"无状态/有状态"和"无 shuffle/有 shuffle"两个维度判断,前者决定要不要 checkpoint,后者决定代价高低。
- getOrCompute 是 DStream 到 RDD 的关键:查缓存 → 递归 compute → 缓存返回,缓存是为了不让同一时间点的 RDD 被重复计算。
- 复杂逻辑用 transform/foreachRDD 打通 RDD API,连接等重对象放分区级初始化。
作者 :大数据技术实践者
博客 :blog.starzy.cn
GitHub :starzy1990.github.io
专注 AI Agent · LangGraph · RAG · 大数据架构 · 数据工程实践