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 (无状态) 状态存储 数据源 无状态算子 有状态算子 输出算子 状态访问

你看,数据从源流入,经过算子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做状态后端,才稳住。

避坑指南: 有状态算子一定要关注状态大小。我曾经见过一个团队,状态膨胀到几百GB, checkpoint 每次都超时。最后不得不重新设计状态结构。

2.4 如何选择

能无状态,就无状态。这是铁律。

但业务需求摆在那里,很多时候不得不用有状态。这时候要注意几点:

  • 状态后端选型:内存快但容量小,RocksDB慢但能存大状态
  • 状态TTL:给状态设置过期时间,别让数据无限堆积
  • Checkpoint策略:有状态算子必须配合可靠的checkpoint机制
特性 无状态算子 有状态算子
容错恢复 简单,直接重启 复杂,需恢复状态
并行度 可任意扩展 受状态分区限制
内存消耗 低 高,需管理
典型场景 数据清洗、格式转换 窗口聚合、去重、排序
我的经验: 设计算子时,先问自己三个问题:这个算子需要记住历史数据吗?如果节点挂了,丢失状态会怎样?状态能分区吗?回答完这三个问题,你基本就知道该用哪种了。

好了,这一章就到这里。算子和数据流是分布式计算的骨架,理解透彻了,后面学调度、学容错,都会顺很多。

可可精品资料库