摘要
讲透 Flink Transformation API 的完整体系:基础转换(map/flatMap/filter)、重分区(keyBy/rebalance/broadcast 等六种策略)、多流操作(union/connect/join 的选型差异)、分组聚合,附 7 个可直接运行的实战代码片段、重分区对性能的影响与 6 个真实踩坑点。
关键词
Flink、Transformation、map、flatMap、keyBy、rebalance、broadcast、union、connect、join、重分区、算子链
上一篇讲完了 Source------数据怎么进来。这一篇讲数据进来之后怎么处理 ,也就是 Transformation,它是 Flink 作业的「主战场」:你写的业务逻辑 90% 都落在 map、keyBy、window 这些转换算子组成的链条上。
但多数人对 Transformation 的理解停留在「会用几个算子」。这篇把它们分成四类讲透,重点放在三类容易被误解的地方:重分区 (数据怎么流)、多流操作 (几股流怎么合并)、算子链(性能为什么差这么多),每个知识点配可运行代码。
一、Transformation 分类全景
Flink 的转换算子按职责分四类:

- 基础转换 :
map、flatMap、filter------单流内逐条处理,无状态,最常见。 - 重分区 :
keyBy、rebalance、broadcast、global、shuffle、rescale------改变数据流向。 - 多流操作 :
union、connect、join、coGroup------多股流合并或关联。 - 分组聚合 :
keyBy + sum/reduce、window + aggregate------跨记录累积(有状态)。
选型顺序一句话:单条处理用基础转换,跨记录累积先 keyBy,多流合并先想 union 够不够,要关联才 join。
二、实战一:基础转换------map / flatMap / filter
三个最常用的算子,覆盖 80% 的数据清洗逻辑:
java
import org.apache.flink.api.common.functions.FlatMapFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.Collector;
public class BasicTransformDemo {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 输入:每行 "orderId,amount,category"
DataStream<String> lines = env.socketTextStream("localhost", 9999);
// map:1 进 1 出 ------ 解析 + 转换字段
DataStream<String> mapped = lines
.map(line -> {
String[] p = line.split(",");
return "order=" + p[0] + " cat=" + p[2];
});
// flatMap:1 进 0..N 出 ------ 把一行拆成多个词
DataStream<String> words = lines
.flatMap((String line, Collector<String> out) -> {
for (String w : line.split(",")) {
out.collect(w.trim());
}
})
.returns(String.class); // flatMap 的 lambda 需要显式指定返回类型
// filter:按条件保留
DataStream<String> expensive = lines
.filter(line -> Double.parseDouble(line.split(",")[1]) > 100);
mapped.print("mapped");
words.print("words");
expensive.print("expensive");
env.execute("basic-transform-demo");
}
}
三个要点:
flatMap的 lambda 要配.returns(TypeInformation):因为 lambda 无法推断泛型类型,编译期不报错、运行期才炸------这是 flatMap 最常见的坑。map可以返回不同类型 :输入String输出Tuple2都行,map会做类型推断。- 这三个算子都是无状态的:输出只依赖当前输入,可以随意重启重放,性能开销最小。
三、实战二:keyBy 分组聚合
跨记录累积的第一步是 keyBy------它把相同 key 的数据路由到同一个子任务,之后的 sum/reduce 才能正确累积:
java
import org.apache.flink.api.java.tuple.Tuple3;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
public class KeyByAggDemo {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 输入:每行 "category,amount"
DataStream<String> lines = env.socketTextStream("localhost", 9999);
DataStream<Tuple3<String, Double, Integer>> parsed = lines
.map(line -> {
String[] p = line.split(",");
return Tuple3.of(p[0], Double.parseDouble(p[1]), 1);
});
// keyBy 按分类分组 → sum 对第 1 列(amount)增量求和
parsed.keyBy(t -> t.f0)
.sum(1)
.print("category-sum");
// reduce:自定义归约 ------ 求每个分类的累计金额和笔数
parsed.keyBy(t -> t.f0)
.reduce((a, b) -> {
a.f1 = a.f1 + b.f1; // 累计金额
a.f2 = a.f2 + b.f2; // 累计笔数
return a; // 返回合并结果,继续参与下次 reduce
})
.print("category-agg");
env.execute("keyby-agg-demo");
}
}
关键认知:reduce 的输入输出必须是同一类型 (这里都是 Tuple3),因为它拿上一条的聚合结果继续和当前记录合并。要「输入类型 → 不同输出类型」的聚合(比如累加后算平均值),用 AggregatingState 或窗口 aggregate。
四、重分区机制:六种分区策略
数据在算子之间怎么流动,由重分区算子决定。这是性能调优最容易忽略的一环:

| 算子 | 规则 | 典型场景 |
|---|---|---|
keyBy |
hash(key) % 并行度 | 聚合/窗口的前提(语义必需) |
rebalance |
轮询均分到所有下游 | 上游数据倾斜时负载均衡 |
broadcast |
每条复制到所有下游 | 维表/规则广播(数据量必须小) |
global |
全部压到第 1 个实例 | 全局聚合(单点,慎用) |
shuffle |
随机分配 | 测试 |
rescale |
本地就近均衡,不跨节点 | 同 TM 内均衡,开销最小 |
最容易被忽略的性能原则 :keyBy、rebalance、broadcast 都会打断算子链、触发网络 shuffle。能链在一起就别重分区------这是 Flink 性能的第一原则。
五、实战三:rebalance 与 broadcast
java
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
public class PartitionDemo {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<String> lines = env.socketTextStream("localhost", 9999);
// rebalance:轮询发到下游所有实例,缓解上游倾斜
DataStream<String> balanced = lines.rebalance();
// broadcast:每条复制到所有下游实例(配合广播状态做规则下发)
DataStream<String> broadcasted = lines.broadcast();
// rescale:只在本地(同 TM)实例间均衡,不触发跨节点网络传输
DataStream<String> rescaled = lines.rescale();
balanced.print("balanced");
broadcasted.print("broadcasted");
rescaled.print("rescaled");
env.execute("partition-demo");
}
}
选型判断 :数据倾斜导致某个子任务 CPU 打满、其他空闲 → 用 rebalance(代价是全网络 shuffle);如果倾斜发生在单机内部(比如并行度小、单 TM 多 slot),rescale 更划算------它只在本地均衡,不产生跨节点网络开销。
六、多流操作:union / connect / join
三股流的处理方式差异极大,选错代价很高:

6.1 union:纯合并(最轻量)
java
// 要求:两条流类型必须完全一致
DataStream<String> streamA = env.fromElements("a1", "a2");
DataStream<String> streamB = env.fromElements("b1", "b2");
DataStream<String> merged = streamA.union(streamB); // 无状态纯拼接
6.2 connect:双流连接(类型可不同,可共享状态)
java
import org.apache.flink.streaming.api.functions.co.CoFlatMapFunction;
import org.apache.flink.util.Collector;
// 规则流(String)+ 业务流(String),类型可以不同
// coFlatMap 里可以分别处理两条流的数据
DataStream<String> rules = env.fromElements("rule:amount>100", "rule:cat=book");
DataStream<String> events = env.socketTextStream("localhost", 9999);
DataStream<String> result = events.connect(rules)
.flatMap(new CoFlatMapFunction<String, String, String>() {
private String currentRule = "";
@Override
public void flatMap1(String event, Collector<String> out) {
// 处理业务流:用当前规则判断
if (currentRule.isEmpty() || event.contains(currentRule.split(":")[1])) {
out.collect("PASS: " + event);
} else {
out.collect("REJECT: " + event);
}
}
@Override
public void flatMap2(String rule, Collector<String> out) {
// 处理规则流:更新当前规则
currentRule = rule;
}
});
result.print();
connect 是规则引擎的经典写法 :flatMap2 更新规则(可用 RichCoFlatMap 存到广播状态),flatMap1 用规则处理业务数据,规则变更实时生效、无需重启作业。
6.3 join:窗口关联(代价最高)
java
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
// 订单流 + 支付流水流,按订单 ID 关联
// join 必须先 keyBy + window:窗口内缓存双方数据,按 key 配对输出
// orderStream.join(payStream)
// .where(order -> order.orderId)
// .equalTo(pay -> pay.orderId)
// .window(TumblingEventTimeWindows.of(Time.minutes(5)))
// .apply((order, pay) -> order + "|" + pay);
join 的代价 :窗口内要为两条流分别缓存状态,key 多、窗口长时内存压力大;且 keyBy 必然打断算子链。能不用 join 就别用------很多场景用 connect + 定时器 或维表关联可以替代。
七、算子链与性能
为什么前面反复提「打断算子链」?因为算子链是 Flink 性能的核心机制 :满足条件的相邻算子(相同并行度 + 数据 one-to-one 传播)会合并成一个 Task,在同一线程内串行执行,链内数据传递零序列化、零网络开销。
map → filter → map(并行度相同)→ 合成一个 Task,一条链跑完;- 中间插一个
keyBy→ 链被打断,前后各成一条链,中间网络 shuffle。
实战原则:
- 重分区算子能少用就少用,它直接打断链、引入网络开销;
- 想验证链效果:Web UI 看作业图,链内算子在同一个方块里;
- 排查性能问题时,先看数据是不是在算子之间「过网」了------如果一条链被莫名打断,多半是中间有
keyBy/rebalance或者并行度不一致。
八、六个真实踩坑
flatMap的 lambda 不写.returns()。编译期正常、运行期报TypeExtractionException------泛型被 lambda 抹掉了。凡是 lambda 实现flatMap/process这类泛型函数,都要显式.returns(...)。reduce的输入输出类型不一致 。reduce要求输入输出同类型;需要变换类型的聚合用窗口aggregate或AggregatingState。union两边类型不一致 。union 要求类型完全一致,否则编译报错;类型不同但想合并,先map成同一类型或用connect。join忘了keyBy/窗口 。窗口 join 必须先keyBy(where/equalTo)再window,缺一步直接编译失败。另外窗口 join 是内连接 ,配不上的数据不会输出------需要全量输出用coGroup。broadcast放大数据量 N 倍。广播到并行度 N 的下游 = 每条数据复制 N 份。用广播传大表会让网络直接打爆;广播只适合小维表/规则。global造成单点瓶颈 。global()把所有数据压到第一个实例,并行度形同虚设。全局聚合的正确姿势是keyBy("常量key")或明确知道数据量小。
Transformation 是 Flink 作业的中枢,它决定了数据怎么被加工、怎么流动、怎么合并。把「基础转换无状态最轻」「重分区打断链要慎用」「多流操作 union 最轻、join 最重」「有状态聚合必须先 keyBy」这四句话记住,再配合 Web UI 观察实际的数据流图,Transformation 的调优就不再是玄学。
直接打爆;广播只适合小维表/规则。
global造成单点瓶颈 。global()把所有数据压到第一个实例,并行度形同虚设。全局聚合的正确姿势是keyBy("常量key")或明确知道数据量小。