前面三篇分别讲了有界/无界流、有状态计算、应用场景。这一篇把视角从「原理」拉回「写代码」:一个 Flink 作业到底是怎么把数据读进来、处理掉、再写出去的。不管什么业务,落到代码上都是同一套三段式结构------Source 读取 → Transform 转换 → Sink 写出------差异只在于数据源有界还是无界。
下面用两个能直接编译运行的完整案例,把批处理和流处理各走一遍,再讲清两者的区别和统一。
一、数据读取处理的全流程:三段式结构
一个 Flink 作业无论复杂与否,主干永远是下面这条流水线:

- Source(读取) :从有界源(文件、Hive、MySQL 快照)或无界源(Kafka、Pulsar)把数据读进来,转成
DataStream。 - Transform(转换) :
map、filter、keyBy、window、join等对数据做加工,这是业务逻辑的落点。 - Sink(写出):把结果写到文件、数据库、消息队列或数据湖。
关键结论先说:三段式结构对批流完全一致 ,真正决定「批」还是「流」的,是 Source 的有界性,以及 execution.runtime-mode 这个配置。下面的两个案例,代码骨架几乎一样,差异只在 Source、Sink 和聚合方式。
二、批处理案例:读取文件、分组聚合、写出结果
场景:订单日志文件 orders.log,每行一条记录,格式为 order_id,amount。目标是统计每个订单的累计金额,处理完输出最终结果并退出。这是典型的有界批处理。

完整代码(Flink 1.18,可直接编译运行):
java
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
public class BatchReadProcessDemo {
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
// 显式声明批执行模式:有界源走阻塞式算子,读完输出最终结果后退出
env.setRuntimeMode(RuntimeExecutionMode.BATCH);
// ① Source:逐行读取有界文件,每行是一条记录
DataStream<String> lines = env.readTextFile("hdfs:///data/orders.log");
// ② Transform:解析 → 分组 → 聚合
DataStream<Tuple2<String, Double>> result = lines
.map(line -> {
// 每行 "1001,9.9" 切分成 [orderId, amount]
String[] p = line.split(",");
return Tuple2.of(p[0], Double.parseDouble(p[1]));
})
// keyBy:按订单 ID 分组,同 key 进同一子任务,为聚合做准备
.keyBy(t -> t.f0)
// sum(1):对 Tuple 的第 1 列(amount)求和,内部是 ValueState 累积
.sum(1);
// ③ Sink:写出结果。print 打印到控制台,生产可换 writeAsText 写文件
result.print();
env.execute("batch-read-process-demo");
}
}
几个要点:
env.setRuntimeMode(RuntimeExecutionMode.BATCH)显式声明批模式。有界源读完后作业自然退出;即使不声明(默认 STREAMING),结果也一致,但 BATCH 模式会额外启用排序、全局聚合、Sort-based Shuffle 等阻塞式优化,大吞吐场景更快。sum(1)的1是 Tuple 字段下标(从 0 开始),f0是 key、f1是金额,所以对f1求和写sum(1)。这里容易写错------下标错一位,聚合对象就错了。keyBy(t -> t.f0)用了Tuple2的f0字段;若换成自定义 POJO,sum("amount")按字段名反射访问会更可读。
三、流处理案例:消费 Kafka、窗口聚合、写回 Kafka
场景:订单事件实时写入 Kafka topic orders,目标是按分类统计每分钟的订单金额,结果写回 Kafka 供大屏消费。这是典型的无界流处理。

完整代码(Flink 1.18,依赖 flink-connector-kafka):
java
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import java.time.Duration;
public class StreamReadProcessDemo {
// 订单事件 POJO;sum("amount") 要求字段可被反射访问
public static class Order {
public String orderId;
public double amount;
public long eventTime; // 事件发生时间(毫秒时间戳)
public String category;
public Order() {}
public Order(String orderId, double amount, long eventTime, String category) {
this.orderId = orderId;
this.amount = amount;
this.eventTime = eventTime;
this.category = category;
}
// 每行 "orderId,amount,eventTime,category" 解析成 Order
public static Order parse(String line) {
String[] p = line.split(",");
return new Order(p[0], Double.parseDouble(p[1]),
Long.parseLong(p[2]), p[3]);
}
}
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
// ① Source:KafkaSource 消费无界流
KafkaSource<String> source = KafkaSource.<String>builder()
.setBootstrapServers("localhost:9092")
.setTopics("orders")
.setGroupId("flink-order-group")
// 从 group.id 记录的 offset 继续消费(首次则从 earliest)
.setStartingOffsets(OffsetsInitializer.committedOffsets())
.setValueOnlyDeserializer(new SimpleStringSchema())
.build();
DataStream<String> raw = env.fromSource(
source, WatermarkStrategy.noWatermarks(), "Kafka Source");
// ② Transform:解析 → 事件时间 → 分组 → 窗口 → 聚合
DataStream<Order> result = raw
.map(Order::parse)
// 声明事件时间 + 允许 5 秒乱序的 Watermark
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Order>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((order, ts) -> order.eventTime))
// 按分类分组,聚合分布到各并行子任务
.keyBy(order -> order.category)
// 事件时间滚动窗口:每 1 分钟聚合一次
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.sum("amount");
// ③ Sink:写回 Kafka 供下游消费(生产用 KafkaSink),这里先 print 调试
result.print();
// 常驻运行,不主动退出
env.execute("stream-read-process-demo");
}
}
批流案例放在一起看,差异一目了然:
- Source 不同 :批处理用
readTextFile读有界文件;流处理用KafkaSource消费无界队列,并要指定OffsetsInitializer(committedOffsets从上次提交位点续读,earliest从头,latest只读新数据)。 - 聚合方式不同 :批处理用全局
sum,读完给出最终值;流处理必须用window把无界流切成有限块,否则「永远算不完」。 - 时间语义不同:流处理显式声明了事件时间和 Watermark,窗口按事件发生时间触发;批处理读文件通常不涉及乱序问题,用默认时间即可。
四、批流读取处理的统一:Table API / SQL
如果觉得 DataStream 的批流写法差异太大,可以用 Table API / SQL 把两者收敛成同一句。上一篇提过批流一体,这里落到代码:
sql
-- 有界表:连文件,批执行
CREATE TABLE orders_file (
order_id BIGINT, amount DOUBLE, category STRING, ts TIMESTAMP(3)
) WITH ('connector' = 'filesystem', 'path' = 'hdfs:///data/orders', 'format' = 'csv');
-- 无界表:连 Kafka,流执行,声明 Watermark
CREATE TABLE orders_kafka (
order_id BIGINT, amount DOUBLE, category STRING, ts TIMESTAMP(3),
WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH ('connector' = 'kafka', 'topic' = 'orders',
'properties.bootstrap.servers' = 'localhost:9092', 'format' = 'json');
-- 同一句聚合:连有界表跑批,连无界表跑流,语义完全一致
SELECT category,
TUMBLE_START(ts, INTERVAL '1' MINUTE) AS window_start,
SUM(amount) AS total
FROM orders_kafka
GROUP BY TUMBLE(ts, INTERVAL '1' MINUTE), category;
这就是 Flink 批流一体的价值:读文件就是批,读 Kafka 就是流,业务逻辑只写一次。DataStream API 适合需要精细控制的复杂逻辑,Table SQL 适合数据团队、口径对齐的场景,两者可以混用。
五、数据读取处理环节的五个常见坑
OffsetsInitializer.latest()丢了历史数据 。流处理上线前,如果 Source 配了latest,那么启动之前就已经存在的消息不会被消费,历史回填会「漏一批」。首次上线要做全量初始化,应该用earliest或committedOffsets,等追上后再切latest。- 事件时间窗口不配 Watermark,窗口不触发 。
ts字段有了,但没写 Watermark,事件时间窗口等不到「Watermark 越过窗口结束时间」,作业跑起来不出结果。乱序场景下 Watermark 的延迟参数(如 5 秒)要按业务可接受的迟到范围设置。 - 无界源硬设 BATCH 模式,提交直接报错 。
BATCH要求数据源有界(能读完),Kafka 这类无界源会在作业提交阶段就失败。反之,有界源设 STREAMING 不会报错,只是享受不到批执行优化。 sum字段下标写错,聚合到错误的列 。Tuple2的下标从 0 开始,sum(1)聚合的是f1。用自定义 POJO 时改用sum("字段名"),可读性更好,也不容易错位。- Sink 不支持两阶段提交,exactly-once 退化。上一篇讲过,端到端 exactly-once 依赖 Sink 支持两阶段提交(Kafka 事务、Iceberg/Hudi)。接到一个普通 JDBC Sink,Flink 只能退化成 at-least-once,重复数据要靠下游去重兜底。
Flink 的数据读取处理,剥开所有业务细节,本质就一件事:Source 决定批流,Transform 承载逻辑,Sink 决定落点,三段式结构从批到流一以贯之 。把「有界走批、无界走流、SQL 统一」这三句话吃透,再看任何 Flink 作业的代码,都能一眼看清它的数据从哪来、怎么算、到哪去。
Hudi)。接到一个普通 JDBC Sink,Flink 只能退化成 at-least-once,重复数据要靠下游去重兜底。