2. 算子与数据流:算子的概念、数据流图、有状态与无状态算子
好,咱们进入第二章。这一章我打算聊聊算子和数据流。说实话,这两个概念是分布式计算的基石。你如果搞懂了它们,后面看调度、看容错,都会轻松很多。
我个人习惯把算子想象成「加工车间里的机器」。数据流过来,算子处理一下,再吐出去。就这么简单。但真正落地的时候,坑可不少。
2.1 算子的概念
算子是什么?说白了,就是一个对数据做变换的函数。在分布式系统里,它跑在集群的各个节点上。
举个例子,你有一个整数列表,想过滤出大于10的数:
// 伪代码,但意思到了
val numbers = listOf(5, 12, 3, 20, 8)
val filtered = numbers.filter { it > 10 }
// 结果: [12, 20]
这里的 filter 就是一个算子。它接收输入,产生输出,不依赖外部状态。
但实际项目中,算子远不止这么简单。我在做实时风控系统时,遇到过需要同时关联用户画像、历史行为、当前请求三个数据源的场景。那时候一个算子内部要维护多个状态,复杂度一下子就上来了。
2.2 数据流图
数据流图,就是把这些算子串起来的「蓝图」。你想想看,一个复杂的ETL任务,可能有几十个算子。如果没有图来组织,根本没法管理。
我习惯把数据流图画成DAG(有向无环图)。为什么不能有环?因为一旦有环,数据就可能无限循环,系统就炸了。嗯,这里要注意,有些系统支持迭代计算,但那是在特殊机制下实现的,不是真正的环。
下面这张图展示了一个典型的数据流图结构:
你看,数据从源流入,经过算子A、B、C,最终输出。算子B因为有状态,需要额外访问状态存储。这就是一个典型的数据流图。
2.3 有状态 vs 无状态算子
这是面试高频题,也是实际开发中容易踩坑的地方。
无状态算子
无状态算子,就是每次处理数据时,不依赖任何历史信息。来一条数据,处理一条,处理完就忘。
典型的无状态算子:map、filter、flatMap。
// 无状态:每次只处理当前元素
val result = list.map { it * 2 }
// 输入 [1,2,3] → 输出 [2,4,6]
// 不依赖之前的结果
无状态算子的好处很明显:容易并行,容易容错。节点挂了,重启一个就行,不需要恢复任何状态。
有状态算子
有状态算子就复杂了。它需要记住之前处理过的数据。比如做窗口聚合、去重、排序,都需要状态。
举个例子,你想统计过去5分钟内每个用户的点击次数:
// 伪代码:有状态的窗口聚合
stream
.keyBy(userId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new CountAggregator())
// 这里需要维护每个用户在当前窗口内的计数状态
我曾经在一个实时推荐项目中,因为状态管理没做好,导致内存溢出。当时每个用户维护了一个长达7天的行为序列,用户量一上来,直接OOM。后来改用RocksDB做状态后端,才稳住。
2.4 如何选择
能无状态,就无状态。这是铁律。
但业务需求摆在那里,很多时候不得不用有状态。这时候要注意几点:
- 状态后端选型:内存快但容量小,RocksDB慢但能存大状态
- 状态TTL:给状态设置过期时间,别让数据无限堆积
- Checkpoint策略:有状态算子必须配合可靠的checkpoint机制
| 特性 | 无状态算子 | 有状态算子 |
|---|---|---|
| 容错恢复 | 简单,直接重启 | 复杂,需恢复状态 |
| 并行度 | 可任意扩展 | 受状态分区限制 |
| 内存消耗 | 低 | 高,需管理 |
| 典型场景 | 数据清洗、格式转换 | 窗口聚合、去重、排序 |
好了,这一章就到这里。算子和数据流是分布式计算的骨架,理解透彻了,后面学调度、学容错,都会顺很多。