Spark 与 Flink 入门:批与流的两种血统,视频公司里各干什么活
- 约 31 分钟读完 - 全文 12190 字Spark 与 Flink 入门:批与流的两种血统,视频公司里各干什么活
目标读者:四类人——
- 后端/数据工程师:要上手 Spark 或 Flink,需要心智模型加能跑的代码(看"基础"两节,附 Java API 示例);
- 架构师/技术负责人:在给数据平台选型,想知道两者的边界和硬件投入(看"血统对比"“算力规格"和"选型决策”);
- 运维/SRE:要看监控什么、出事怎么处理(看"监控"和"故障与恢复");
- 正在踩坑的人:直接翻到文末两家的避坑清单。
阅读前提:了解 Kafka 是什么即可,不需要大数据背景。
前两篇讲推荐 Feed 架构时,Spark 和 Flink 反复出现:Spark 做离线训练,Flink 做近线特征。有读者问:Spark 不就是内存分布式计算吗,和 Flink 功能差不多?
看着像,血统完全不同。这篇把两者各自的基础概念讲清,然后放进一家类 Netflix 的视频公司里看它们分别干什么活,最后附上两家的避坑指南。
Spark 基础:批处理血统的内存计算引擎
Spark 在 2010 年前后接棒 Hadoop MapReduce,核心卖点一句话:把中间结果放内存,迭代计算不用反复落盘。MapReduce 每轮迭代都要读写 HDFS,Spark 把数据缓存进内存,迭代型任务(机器学习训练要扫数据几十遍)快了十到一百倍。
三个核心概念:
- RDD / DataFrame:数据被抽象成分布式集合,切成分区(partition)摊在集群各节点上。RDD 是原始 API,现在日常用的是 DataFrame——带结构(schema)的表,配合 Spark SQL 直接写 SQL。
- DAG 执行:你对数据做的一串操作(filter、join、groupBy)不会立即执行,而是攒成一张有向无环图;遇到"要结果"的动作(count、写盘)才触发,调度器把图切成 stage、分发执行。惰性求值让引擎有机会做全局优化。
- Shuffle:数据按 key 重新分发的操作(groupBy、join 都会触发),是 Spark 作业里最贵的一步——数据要跨节点搬运、序列化、落盘。后面避坑指南里一半的坑都和它有关。
血统决定视角:在 Spark 眼里,数据是一批一批的——有界的、静态的数据集。它的流处理能力(Structured Streaming)是后来在批的骨架上加的:把数据流切成一小批一小批,攒一批算一次(micro-batch)。延迟天然是秒级到分钟级。
上手:一个 ETL 作业的 Java 写法。场景:把昨天的播放原始日志清洗成"每视频日播放量"聚合表:
SparkSession spark = SparkSession.builder()
.appName("play-events-etl")
.getOrCreate();
// 读:数据湖里昨天的原始播放事件(分区表)
Dataset<Row> events = spark.read()
.parquet("s3://warehouse/raw/play_events/dt=2026-10-01/");
// 处理:过滤脏数据、按 event_id 去重、换算播放分钟数
Dataset<Row> cleaned = events
.filter("duration > 0 AND user_id IS NOT NULL")
.dropDuplicates("event_id")
.withColumn("play_minutes", col("duration").divide(60));
// join 内容维度表补标题和类目(小表广播,避免 shuffle)
Dataset<Row> enriched = cleaned
.join(broadcast(spark.read().parquet("s3://warehouse/dim/videos/")), "video_id");
// 聚合出结果并写回数仓(coalesce 控制输出文件数,防止小文件)
enriched.groupBy("video_id", "category")
.agg(count("*").alias("plays"), sum("play_minutes").alias("total_minutes"))
.coalesce(4)
.write()
.mode("overwrite")
.parquet("s3://warehouse/agg/daily_video_plays/dt=2026-10-01/");注意这段代码提交后什么都不会立刻发生——filter、join、groupBy 只是在攒 DAG,最后的 write() 这个动作才触发真正的执行。这就是前面说的惰性求值。
这段代码里还有一件"看不见"的事值得说破:分区是自动切的,你全程没有指定过它。分区的三个来源:
- 读入分区由文件决定:读 parquet 时 Spark 按文件切块,
spark.sql.files.maxPartitionBytes(默认 128MB)决定每块多大,每块一个分区。路径里的dt=2026-10-01/本身就是物理分区,Spark 只扫这一天的目录,其余按分区裁剪跳过; - shuffle 后分区由配置决定:
groupBy触发数据重分发,之后的分区数由spark.sql.shuffle.partitions控制,默认 200——小作业跑完写出 200 个小文件,罪魁祸首就是它; - 手动干预只有两个 API:
repartition(n)重新洗牌(会产生一次 shuffle,贵),coalesce(n)只合并不洗牌(便宜)。上面代码写出前的coalesce(4)就是故意把默认的 200 个分区合并成 4 个输出文件。
那"拉起几十台机器并行计算"体现在哪?答案是:不体现在代码里,体现在物理执行里。 同一份代码在一台笔记本上(local 模式)和一百台机器上跑,一行都不用改——分布式是部署特征,不是代码特征。看这段 ETL 提交到集群时实际发生什么:
spark-submit \
--class com.example.PlayEventsETL \
--master yarn \
--num-executors 50 \ # 拉起 50 个 executor 进程,分布在集群各节点
--executor-cores 5 \ # 每个 executor 5 核
--executor-memory 20g \
--driver-memory 8g \
app.jar这里的 app.jar 是你自己的应用代码打包产物:前面那段 ETL 的 Java 代码用 Maven/Gradle 编译,打成包含全部依赖的 fat jar(Maven 用 shade 插件、Gradle 用 shadow 插件),--class 指定里面的入口类。提交时这个 jar 被上传到集群,driver 拿到后把代码分发到各 executor——executor 上跑的 task,执行的就是这个 jar 里的字节码。所以 Spark 作业的交付物就是一个 jar:改代码 = 重新打包重新提交。
生产环境里谁来"敲"这个 spark-submit? 凌晨两点跑批不是值班同学手动敲命令。业界的提交方式按层次分:
- SparkLauncher:Spark 官方自带的 Java API,在 Java 进程里编程式提交作业,还返回句柄可轮询状态——自研平台的标准做法:
SparkAppHandle handle = new SparkLauncher()
.setAppResource("app.jar")
.setMainClass("com.example.PlayEventsETL")
.setMaster("yarn")
.setConf(SparkLauncher.EXECUTOR_MEMORY, "20g")
.startApplication(); // 异步返回,拿 handle 跟踪作业状态- Apache Livy:把提交做成 REST 服务——HTTP POST 一个 jar、轮询状态、拉日志。Web 系统里嵌入 Spark 提交能力的标准件;
- 调度系统:Apache DolphinScheduler、Airflow 这类工作流平台,管的是"凌晨 2 点跑 ETL、成功了再跑模型训练"这种依赖编排,底层调的仍然是 spark-submit 或 Livy;
- 云厂商 API:EMR Steps、Databricks Jobs 这类托管服务,提交动作变成一个云 API 调用。
顺带纠个常见的偏:Apache Commons 系列里没有 Spark 专用提交工具——Commons Exec 只是通用的外部进程包装器,有些自研平台拿它包 spark-submit 命令行(拿退出码、控超时),能用但不是主流。想在 Java 里提交作业,正路是 SparkLauncher 或 Livy。
50 个 executor × 5 核 = 250 个任务槽位同时干活,1TB 堆内存的总池子。作业跑起来后,逻辑计划被拆成物理 stage,每个分区对应一个 task,task 被调度到空闲槽位上:
flowchart TD
A["Stage 1 读取+过滤<br/>2TB parquet 按 128MB 切块<br/>约 16000 个分区 = 16000 个 task"] --> B["Stage 2 join<br/>维度表广播到每个 executor<br/>无 shuffle"]
B --> C["shuffle 重分发<br/>按 video_id 搬运数据"]
C --> D["Stage 3 聚合+写盘<br/>200 个 task 各自聚合自己那份"]
A -.->|"250 个槽位并发<br/>16000 个 task 分批消化"| A
几个数字感:
- 一天 100 亿条播放事件约 2TB parquet,切成约 16000 个分区,每个分区一个 task——250 个槽位并发,分批消化完这 16000 个 task,这就是"拉起多个节点"的物理形态;
- 谁挂了谁重试:某个 executor 宕机,它跑过的 task 在别的节点上按血统重算,作业整体不停;
- 想更快就加机器:
--num-executors 100,槽位翻倍,16000 个 task 消化速度就近似翻倍——这就是水平扩展:代码不变,吞吐随机器数线性涨(直到被 shuffle 或存储 IO 卡住)。
ALS 训练是同一个道理,只是把"一批 task"换成"几十轮、每轮一批 task":每一轮迭代都是一次全集群的分布式计算,中间结果留在内存里不落盘——这正是 Spark 相比 MapReduce 快的根源。
所以读 Spark 代码的正确姿势是:代码是蓝图,集群按分区把蓝图实体化成成千上万的 task。你在代码里看不到"多节点",正如你在建筑图纸上看不到施工队——但图纸上的每一堵墙,都会变成某个工人在某个脚手架上的具体动作。
机器学习也一样是 DataFrame 风格的 API。Spark MLlib 里自带 ALS(交替最小二乘,就是矩阵分解),拿交互数据训练推荐模型只要几行:
ALS als = new ALS()
.setMaxIter(10) // 迭代轮数:交替优化用户矩阵和物品矩阵
.setRegParam(0.1) // 正则化,防过拟合
.setUserCol("user_id")
.setItemCol("video_id")
.setRatingCol("score") // 行为强度:完播=3 点赞=2 点击=1
.setColdStartStrategy("drop");
ALSModel model = als.fit(playEvents);
// 输出用户向量和物品向量,供线上召回使用
model.userFactors().write().parquet("s3://warehouse/rec/user_factors/");
model.itemFactors().write().parquet("s3://warehouse/rec/item_factors/");fit() 跑完,userFactors 就是前面推荐架构文章里说的"每个用户一个几百维的向量"——概念和代码在这里接上了。
Flink 基础:流处理血统的计算引擎
Flink 的出发点反过来:世界本质是流,批只是流的特例(有界流)。数据逐条进来逐条处理,毫秒到秒级延迟。
四个核心概念:
- 事件时间与水位线(event time & watermark):事件"发生的时间"和"到达系统的时间"经常不同(用户手机离线两小时,数据才补传上来)。Flink 允许按发生时间计算,水位线是"我认为早于 t 的事件基本都到齐了"的声明,晚到超过容忍度的数据进侧输出流。这是流处理区别于批处理最本质的概念——批处理里根本没有"数据晚到"这个问题。
- 状态(state):算子可以持有状态,比如"最近一小时的点击数"。状态按 key 分布在各并行实例上,大作业可以到 TB 级。
- checkpoint / exactly-once:定期给所有算子的状态做全局一致性快照,故障后从快照恢复,保证每条数据恰好被计算一次。
- savepoint:手动触发的快照,用于升级和扩缩容——代价是"停作业、改并行度、从快照重启",这也是之前那篇 Netflix 给 Flink 做自动扩缩容那么难的原因。
上手:实时热榜的 Java 写法。场景:统计每个视频最近 10 分钟的播放量,每分钟刷新一次(滑动窗口):
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 每 60 秒做一次 checkpoint,故障恢复就回到最近一次快照
env.enableCheckpointing(60_000);
// 数据源:Kafka
KafkaSource<PlayEvent> source = KafkaSource.<PlayEvent>builder()
.setBootstrapServers("kafka:9092")
.setTopics("play-events")
.setGroupId("hot-ranking")
.setValueOnlyDeserializer(new PlayEventSchema())
.build();
// 关键一步:声明用事件时间,容忍 5 分钟乱序
// 手机离线两小时补传的数据,仍然算回"发生那一刻"的窗口
WatermarkStrategy<PlayEvent> wm = WatermarkStrategy
.<PlayEvent>forBoundedOutOfOrderness(Duration.ofMinutes(5))
.withTimestampAssigner((event, ts) -> event.getEventTime());
DataStream<PlayEvent> events = env.fromSource(source, wm, "kafka-play-events");
events
.keyBy(PlayEvent::getVideoId) // 按视频分流
.window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(1)))
.aggregate(new PlayCounter()) // 窗口内计数
.addSink(new RedisSink<>()); // 结果写 Redis,榜单服务读这段代码里藏着 Flink 思维方式的全部要点:keyBy 决定状态按什么分发(每个 video_id 一份计数状态),window 按事件时间划分窗口而不是按到达时间,水位线声明"晚到 5 分钟以内的数据我等你"。
状态的 TTL 管理(避坑指南第一条)在代码里长这样:
StateTtlConfig ttl = StateTtlConfig.newBuilder(Time.days(7))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // 写入时刷新过期时间
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
ValueStateDescriptor<Long> desc = new ValueStateDescriptor<>("play-count", Long.class);
desc.enableTimeToLive(ttl); // 状态 7 天不活跃自动清理,防止无限膨胀部署:对应的启动命令。Flink 的交付物同样是一个 fat jar,但启动命令和 Spark 有一个本质区别——spark-submit 起的作业算完就退出,flink run 起的作业是常驻的(流处理理论上永不结束):
# 提交常驻流作业到 YARN(跑在 K8s 上则换成 -t kubernetes-application)
flink run-application \
-t yarn-application \
-c com.example.HotRankingJob \ # main 入口类
-p 64 \ # 全局并行度:占用 64 个 slot
hot-ranking.jar
# 升级代码或改并行度的标准动作:先打 savepoint,再从快照恢复
flink stop <jobId> --savepointPath s3://checkpoints/hot-ranking/
flink run-application \
-t yarn-application \
-c com.example.HotRankingJob \
-s s3://checkpoints/hot-ranking/savepoint-abc123 \ # 从这份状态恢复
-p 128 \ # 并行度可以顺便改
hot-ranking.jar对照着看两套命令的分工:Spark 用 --num-executors / --executor-cores 申请资源(因为要装下一批又一批的 task);Flink 用 -p 声明并行度(因为作业结构固定,并行度就是常驻 worker 数)。而 -s 恢复 savepoint 这个参数是 Flink 独有的——Spark 批作业没有"带着状态继续跑"的概念,它每次都是从头算一批新的。
生产环境谁来提交 Flink 作业? 和 Spark 不完全对称——Flink 没有一个像 SparkLauncher 那样常用的官方 Java 提交 API,业界的答案按部署形态分:
- Flink Kubernetes Operator(当前主流):作业定义成一份
FlinkDeploymentYAML(K8s 自定义资源),kubectl apply即提交。Operator 托管整个生命周期:拉起、监控、升级时自动做 savepoint 并从快照恢复——上面手动敲的flink stop --savepointPath那套动作,它全自动; - Apache StreamPark(原 StreamX):作业管理平台,Web 界面管理 jar 上传、配置、启停和 savepoint,国内团队用得多;
- Ververica Platform:Flink 创始团队公司的平台产品(有社区版);
- JobManager REST API:直接 HTTP 上传 jar 再触发 run——Flink Web UI 的提交走的就是它,自研平台想自己包一层时用;
- YARN 模式:就是上面那条
flink run-application命令,CLI 直连,老集群常见。
趋势很明确:K8s Operator 是官方主推方向,新平台直接从这里起步,别再自建提交流程。
血统对比:一张表
| 维度 | Spark | Flink |
|---|---|---|
| 数据观 | 有界批处理是本质,流是微批模拟 | 无界流是本质,批是有界流特例 |
| 典型延迟 | 秒级 - 分钟级 | 毫秒 - 秒级 |
| 状态管理 | 弱(流场景下才需要,能力有限) | 一等公民(keyed state、TB 级) |
| 事件时间 | 支持,但受微批粒度约束 | 原生设计,水位线机制成熟 |
| 生态主场 | 数仓 SQL、MLlib、Python 数据科学 | 实时管道、实时数仓、CEP |
| 思维负担 | 低:写 SQL / DataFrame 像写单机代码 | 高:要时刻想着时间、状态、背压 |
一句话记忆:Spark 把流当批做,Flink 把批当流做;前者简单但延迟高,后者精确但心智负担重。
视频公司里各干什么活
场景设定:一家类 Netflix 的视频公司,几亿用户,每天产生百亿级行为事件。同一条数据流,两种引擎各管一段:
flowchart LR
A["用户行为事件<br/>播放 暂停 点赞 卡顿"] --> B["Kafka 事件总线"]
B --> C["Flink 实时链路<br/>秒级生效"]
B --> D["落地数仓<br/>Iceberg 数据湖"]
D --> E["Spark 离线链路<br/>小时级 天级"]
C --> F["在线服务<br/>画像 热榜 监控告警"]
E --> G["模型 报表 实验分析"]
Flink 的活——“这件事现在就要知道”:
- 实时画像更新:用户每看一条视频,行为事件进 Kafka,Flink 作业滚动更新"最近 N 次行为的加权兴趣向量",秒级生效。下一刷推荐就变。
- 实时热榜:按事件时间滑动窗口统计各视频的播放增速,每分钟刷新 Trending 榜单。注意必须用事件时间——用户手机离线补传的数据不能算到"现在"。
- 播放质量监控(QoS):卡顿率、首帧时间、播放失败率按地区/运营商/客户端版本实时聚合,超过阈值就告警。某运营商骨干网抽风,值班同学三分钟内就该收到电话。
- 实时风控:同一设备十分钟内播放 200 个视频?异常刷量实时拦截,不等第二天对账。
Spark 的活——“这件事今天算完就行”:
- 数仓 ETL:Kafka 落地到 Iceberg 的原始日志,Spark 按天清洗、去重、补维度(join 用户表、内容表),产出标准明细表。全公司数据分析的地基。
- 推荐模型训练:拿过去 30 天的用户-内容交互矩阵,训练排序模型(交互矩阵怎么变成用户偏好,详见推荐 Feed 架构里"用户偏好怎么算出来"一节)。训练要扫数据几十遍,正是 Spark 内存迭代的甜点场景。
- 经营报表:每日播放时长、新增会员、留存率——管理层早上九点要看的看板,Spark 凌晨跑批产出。
- A/B 实验分析:实验组对照组的指标对比、显著性检验,数据科学家写 Spark SQL + Python 完成。
同一条数据的两次旅程:用户晚上 8 点点开一条视频——这个事件两秒内被 Flink 消费,画像更新、热榜计数、质量监控全部生效;凌晨 2 点,同一个事件(已从 Kafka 落盘)被 Spark 的 ETL 作业清洗进数仓,早上 6 点进入模型训练数据。实时链路管"现在",离线链路管"沉淀",谁也别抢谁的活。
算力规格:硬件怎么配
先给结论再讲推理:大数据集群的钱主要花在内存和 SSD 上,CPU 反而不是瓶颈的主角。
Spark 节点的典型规格(按 2026 年的行情):
| 角色 | 规格 | 要点 |
|---|---|---|
| Worker 节点 | 32-64 核 CPU、128-256GB 内存、4-8 块 NVMe SSD 做本地 shuffle 盘、10-25Gbps 网卡 | shuffle 数据写本地盘,SSD 比内存更影响大 join 的稳定性 |
| Executor 切分 | 每个 executor 4-5 核、16-24GB 堆内存 | 一台 32 核 128GB 的机器切 5-6 个 executor;executor 太大 GC 停顿长,太小则 broadcast 等开销被重复多份 |
| Driver | 8-16GB 内存起步 | 调度 + 收集元数据;大 job 的执行计划、broadcast 变量都压在这里 |
Flink 节点的典型规格:
| 角色 | 规格 | 要点 |
|---|---|---|
| TaskManager | 8-16 核、32-64GB 内存、本地 NVMe SSD 必须有 | 大状态用 RocksDB state backend,状态落在本地盘,SSD 的随机读写直接决定状态访问性能 |
| slot 划分 | slot 数 = CPU 核数(1:1 起步) | 并行度不能超过总 slot 数;一个 slot 一个线程,避免 CPU 争抢 |
| JobManager | 4 核 8GB 起步,HA 模式至少两个 | 协调 checkpoint,不搬数据,轻 |
| checkpoint 存储 | S3 / HDFS / 对象存储 | 快照不占本地盘,占的是远程存储和网络带宽 |
容量估算的例子,把数字走一遍:假设行为事件峰值 10 万条/秒、单条 1KB,实时链路(画像 + 热榜 + 监控)合计维护 500GB 状态。
- 网络:10 万 × 1KB = 100MB/s,25Gbps 网卡毫无压力——数据管道的瓶颈很少是网络;
- 状态:按单个 TaskManager 管理 64-128GB RocksDB 状态比较稳妥的经验值,500GB 状态需要 4-8 台 TaskManager;
- CPU:窗口聚合类作业每条事件的计算很轻,10 万条/秒摊到 64 个核上每核每秒 1500 条,富余;
- 所以这套链路 8 台"16 核 64GB + NVMe"的机器跑得很从容,再留 30% 峰值冗余即可。
反过来,凌晨的 Spark 训练作业要扫 30 天 × 百 TB 级日志,它的瓶颈是 shuffle 吞吐和内存,机器数量按"凌晨 6 点前必须跑完"倒推——离线集群的规格是被截止时间定义的。
监控:两套引擎各看什么
Spark 监控的主入口是 Spark UI(每个作业一个 Web 界面),配合 Prometheus 抓指标。值班时按这个顺序看:
- Stages 页:哪个 stage 慢、shuffle read/write 多大——shuffle spill(内存放不下溢写磁盘)是性能第一警报;
- Tasks 页的任务时长分布:p50 是 2 分钟、max 是 40 分钟 → 数据倾斜,不用怀疑;
- Executors 页:GC 时间占比超过 10% 说明内存配置或数据结构有问题;executor lost 频繁出现说明节点或内存超卖;
- Event Log 持久化到 HDFS/S3,作业挂了也能回放分析——务必开启,默认不一定开。
Flink 监控的主入口是 Web UI + 指标上报(Prometheus reporter),核心看四组:
| 指标 | 看什么 | 告警阈值参考 |
|---|---|---|
背压 backPressuredTimeMsPerSecond |
哪个算子被顶住 | 持续 >50% 就要查 |
checkpoint lastCheckpointDuration / lastCheckpointSize |
快照耗时和体积增长趋势 | 耗时接近间隔、体积周环比暴涨都是事故前兆 |
Kafka 消费 lag(records-lag) |
消费追不上生产 = 延迟在堆积 | 持续增长 10 分钟告警 |
| 水位线延迟 | 事件时间推进是否停滞 | 水位线不动 = 窗口永远不触发 |
告警的验收标准只有一条:每个告警都必须对应一个动作。“CPU 高"不是告警,“热榜作业 Kafka lag 连续增长 10 分钟"才是——前者看了也不知道干嘛,后者直接指向扩容或查背压。
故障与恢复:出事时的标准动作
Spark 的故障模型是"重算”,血统里带着 MapReduce 的基因:
- task 失败 → 自动重试(默认 4 次,
spark.task.maxFailures可调),重试时只重算这个分区; - executor 挂了 → 它算过的分区按 lineage(血统)重新算——RDD 天生记得自己从哪算来的,这就是当年 RDD 论文的核心思想;
- 个别 task 特别慢(straggler)→ 开推测执行(
spark.speculation=true),同样的任务在别的节点再跑一份,谁先完用谁; - driver 挂了 → 最麻烦,批作业整体重跑;所以重要的 ETL 要幂等(写分区用 overwrite 而不是 append),重跑不出脏数据。
Flink 的故障模型是"回滚”,回到最近一次 checkpoint:
- TaskManager 挂了 → 作业自动从最近的成功 checkpoint 恢复,Kafka source 回到快照时记录的 offset 重新消费,配合两阶段提交的 sink 保证恰好一次;
- 重启策略别用默认:生产上推荐
failure-rate(10 分钟内允许失败 N 次,超过才放弃)配合重启间隔,防止依赖的 Kafka/Redis 抖动时作业无脑高频重启把对方打垮; - JobManager 挂 → 必须配 HA(ZooKeeper 或 K8s 选主),否则协调者单点;
- checkpoint 是自动的、savepoint 是手动的:代码升级、改并行度必须先做 savepoint 再停作业,从 savepoint 启动新版本。
一个标准故障处置剧本(实时画像更新作业 lag 暴涨):
- 看 Flink UI 背压页——找到链条上第一个变红的算子;
- 是某个下游慢(比如 Redis 抖动)→ 修下游,作业自己追上来;
- 是数据洪峰 → 临时调大并行度(savepoint → 重启 → 恢复),或者干脆等洪峰过去,Kafka 里的数据丢不了;
- 恢复后盯 Kafka lag 回落到正常水位,确认 checkpoint 恢复绿色;
- 事后复盘:这次故障该变成哪条告警规则?
注意底气来自哪里:Kafka 保留了原始事件,Flink 保留了状态快照——前者保证"数据丢不了",后者保证"进度丢不了"。两个都在,故障就只是插曲不是事故。
Kafka + Flink 组合:失败后到底会不会重复、会不会丢
这是面试和值班都绕不开的问题,拆开两个角色看就清楚了。
Kafka 侧:消息几乎不可能"被消费掉"。 Kafka 是持久化日志,消费不删除数据,保留期(比如 7 天)内随便重读。Flink 的 Kafka source 消费时不逐条 ack,只在 checkpoint 里记录各分区的 offset。所以消息丢失只有两种可能:保留期过了,或者 Kafka 集群自己故障。
Flink 侧:重启 = 状态回滚 + offset 回拨,两件事同步发生。 故障后从最近一次成功的 checkpoint 恢复,同一时刻两个动作:
flowchart LR
A["故障发生<br/>部分事件已消费已计算"] --> B["从最近 checkpoint 恢复"]
B --> C["Kafka offset 回拨<br/>到快照时刻"]
B --> D["算子状态回滚<br/>到快照时刻"]
C --> E["快照之后的事件重新消费"]
D --> E
E --> F["在回滚后的状态上重算<br/>内部不多算也不少算"]
假设 checkpoint 间隔 60 秒:故障意味着最多重放最近 60 秒的事件——重放不是丢失,是进度倒退后追赶。由于状态和 offset 同步回滚,窗口计数这类状态计算在内部是精确的(exactly-once)。
但"对外不重复"取决于 sink,这是最容易踩的边界:
| sink 类型 | 端到端语义 | 例子 |
|---|---|---|
| 两阶段提交 sink | exactly-once | Kafka sink(事务)、Iceberg |
| 幂等写入 | 等效 exactly-once | Redis 按 key 覆盖写、MySQL upsert |
| 普通追加 | at-least-once,会重复 | 直接 insert 进 MySQL、对外发 HTTP 回调 |
重放的那 60 秒里,如果 sink 是普通 insert,下游表里就会出现重复行——作业内部算得再精确,写出去的那一刻语义就降级了。这就是"exactly-once 只在链路两端都配合时才成立"的含义。
真正会丢、会重的边界情况清单:
- 没开 checkpoint:offset 只能周期性提交到 Kafka,重启从提交点读——退化成 at-least-once,且恢复语义模糊;
- Kafka 保留期 < 故障停机时间:作业停 8 天、Kafka 只留 7 天,恢复时最早的数据真没了;
- 异步 IO 和侧输出不参与 checkpoint:算子里偷偷调外部接口、写侧输出流,这些动作不在快照里,重放时会重复执行;
- savepoint 与升级不兼容:改了状态结构(比如给状态类加了字段)还想从旧 savepoint 恢复,直接恢复失败——升级前想清楚状态兼容性。
多节点会不会重复消费同一个分区:消费组的互斥语义
顺带的另一个常见疑问:Flink 作业有几十个 source subtask 在多个节点上跑,会不会两个节点同时读同一个 Kafka 分区、把数据算两遍?
不会,这是 Kafka 消费组协议内建的互斥保证,不需要你做任何事:
- 同一作业的所有 source subtask 用同一个
group.id注册,Kafka 的组协调器把分区互斥分配——每个分区同一时刻只属于组内一个消费者。200 个分区、64 个 subtask,每个 subtask 分 3-4 个,互不重叠; - 节点挂了触发 rebalance,分区重新分配,但交接是排他的:旧持有者被踢出组之后,新持有者才接手,不存在两个节点同时持有同一分区的窗口;
- 所以同一作业内"重复消费"只有一种情况:故障恢复时的时间维度重放(上一节讲的回滚),不存在"空间维度"的并行重复。
两个相关的配置纪律:
- source 并行度不要超过分区数。并行度 64、分区只有 32 个时,32 个 subtask 永远分不到分区、空转白占资源。反过来分区数多于并行度没问题,一个 subtask 可以吃多个分区。
- 不同作业必须用不同的
group.id。消费组之间是广播语义——画像作业和热榜作业各自完整消费同一个 topic,互不干扰,这是特性。但两个本该独立的作业如果误配成同一个 group.id,就会瓜分分区、各自只读到一半数据,这种 bug 症状隐蔽(数据看着都在流,就是量不对),值得配进上线检查清单。
数据源不止 Kafka:还能从哪读,API 数据怎么办
Flink 的 source 生态按类别列全:
| 类别 | 连接器 | 场景 |
|---|---|---|
| 消息队列 | Kafka、Pulsar、Kinesis、RocketMQ(社区) | 实时事件流的主力入口 |
| 数据库 CDC | MySQL CDC、PostgreSQL CDC、MongoDB CDC(Flink CDC 项目) | 直接订阅 binlog,数据库变更实时变成流——替代"定时全量导库",是这几年增量最大的方向 |
| 文件/对象存储 | FileSource(S3 / HDFS / 本地),支持批读和持续监听目录 | 批处理、日志文件实时采集 |
| JDBC | Table API 的 JDBC connector | 轮询式读关系库,伪实时 |
| 测试 | DataGen 数据生成器、socket 流 | 本地开发与压测 |
HTTP API 提供的数据怎么办? 核心发行版里没有内置的 HTTP source,分两种情况:
情况一:API 是数据源(想持续消费一个 REST 接口)。正确姿势不是让 Flink 直接轮询 API,而是中间加一层 Kafka:一个轻量采集服务定时调 API、把结果写进 Kafka,Flink 消费 Kafka。原因正是前两节的内容——Kafka 提供可回放、削峰、多消费者,API 直连则什么都没有:Flink 作业一挂,这段时间的 API 数据就永远丢了。非要直连也行:实现自定义 Source 接口自己轮询,但 offset 管理、故障恢复语义全得自己做,一般不值。
情况二:API 是维表(事件流经过时补充字段)。这是更常见的需求:每条事件只有 video_id,需要调内容服务拿标题、类目、时长。Flink 有标准件——异步 IO(Async I/O):在算子里并发调用 HTTP 接口、回调组装结果,不阻塞主流;Table API 里对应的写法是 lookup join。配一层本地缓存(Guava/Caffeine)挡掉热点 key 的重复调用,避免把对方服务打挂。注意它的语义是"查询"不是"消费"——只在事件流经过时被动调用,永远不会主动拉数据。
Spark 避坑指南
按踩坑频率排序:
- 数据倾斜是第一杀手。某个 key 的数据量特别大(比如头部视频占了 10% 播放量),groupBy/join 时对应分区要处理别人几十倍的数据,99% 的任务十分钟跑完,最后 1% 卡两小时。解法:给热点 key 加盐(key 后面拼随机后缀打散)、先聚合再关联、小表广播(broadcast join)避免 shuffle。
- 小文件问题。ETL 作业输出几万个几 KB 的小文件,下游读取慢、元数据服务被打爆。解法:写出前
coalesce/repartition控制分区数,或开自适应合并。 - shuffle 爆炸。join 两张大表,中间数据量是笛卡尔级增长。先看能不能把一张表过滤小再 join,能 broadcast 就 broadcast。
- 滥用 cache。
.cache()不是免费的,内存不够会挤占执行内存反而更慢。只缓存"会被复用多次"的中间结果。 - 分区数拍脑袋。分区太少 → 单任务处理量太大 OOM;太多 → 任务调度开销压垮 driver。经验值:单个分区处理 128MB 上下。
- driver OOM。
collect()把全量数据拉回 driver——生产事故的经典配方。要看几行数据用show()/take()。
Flink 避坑指南
- 状态不设 TTL 无限膨胀。keyed state 默认永不过期,跑一个月状态涨到 TB 级,checkpoint 从秒级拖到分钟级最后超时失败。用户维度状态几乎总该设 TTL(比如 7 天)。
- checkpoint 间隔拍太短。间隔小于一次 checkpoint 本身的耗时,系统永远在做快照没有空算数据。先看一次 checkpoint 要多久,间隔至少留它几倍。
- 背压不会看。作业 lag 持续上涨,先去 UI 看背压面板:红色背压的算子就是瓶颈,瓶颈的上游全会被"传染"成红色——找到链条上第一个变红的,那才是真正的病灶。
- 事件时间和处理时间混用。用处理时间算"每分钟播放量",用户手机补传的数据会把昨天的量记到今天。涉及"按发生时间统计"的需求,必须事件时间 + 水位线,并接受乱序容忍度的权衡。
- 扩缩容当成无状态服务做。改并行度 = savepoint + 停作业 + 重启,大状态作业分钟级中断。扩缩容策略要保守,这个坑的完整故事见 Netflix 两代 autoscaler。
- exactly-once 当默认值。端到端 exactly-once 要求 sink 支持两阶段提交,代价是吞吐下降。很多监控类场景 at-least-once + 下游幂等就够了,别为不需要的语义付费。
通用坑:用 Spark 微批硬扛秒级需求。需求方说"准实时",你拿 Structured Streaming 把触发间隔压到秒级——能跑,但状态管理和乱序处理的坑会一个个冒出来。先问清楚:延迟到底是秒级还是分钟级需求?分钟级 Spark 很舒服,秒级请直接上 Flink,别硬扛。
选型决策
数据是有界的、延迟容忍分钟级以上 → Spark
数据是持续流、延迟要求秒级、或需要复杂状态/事件时间 → Flink
但必须诚实:边界在模糊化。Spark 能做流、Flink 能做批,两个社区都在往对方的领地走;Flink 的 Table API 写 SQL 也越来越像 Spark SQL。真实的选型经常是这样决定的:团队现有栈是什么、招聘市场上谁会、云厂商托管服务谁更便宜。技术血统决定的是"哪个更顺",不是"哪个能做"。
国内的实际格局(2020 年代中期的现状,供校准):
- 新上实时项目基本是 Flink。阿里 2019 年收购 Flink 母公司后全力主推(内部版 Blink 撑双 11 实时大屏),字节、美团、腾讯、快手的实时平台全部 Flink 化,新立项用 Spark Streaming 的已很难见到;
- 存量离线仍是 Spark 的天下:数仓 ETL、T+1 报表、Hive 承接,生态太厚,替换动力不足;
- 两边都在被挤压:Spark 的机器学习场景在流失到 Python 原生生态(PyTorch/Ray);Flink 在进攻离线——“流批一体”(一套 SQL 跑流也跑批)配合 Paimon 这类流式湖格式在落地,但离线存量太重,替换以年计;
- 一句话:新实时 = Flink,存量离线 = Spark,交界处(实时数仓)在打架。求职市场同构:实时岗位 Flink 是硬通货,离线岗位 Spark/Hive 是基础盘。
一句话收拢:Spark 回答"这批数据算出来是什么",Flink 回答"这件事现在发生了什么";先想清楚你的问题是哪一种,再选工具——反过来拿着工具定义问题,是大数据平台翻车的最常见起点。
参考:Apache Spark 与 Apache Flink 官方文档;Structured Streaming 与 Flink DataStream 的设计文档。Netflix 场景参考其技术博客公开的数据平台架构。