方糖猫Blog
  • 首页
  • 文章
  • 书架
  • 标签
  • 归档
  • 关于
Posts

Spark 与 Flink 入门:批与流的两种血统,视频公司里各干什么活

方糖猫 方糖猫
2026年10月2日 - 约 31 分钟读完 - 全文 12190 字

目录

  • Spark 基础:批处理血统的内存计算引擎
  • Flink 基础:流处理血统的计算引擎
  • 血统对比:一张表
  • 视频公司里各干什么活
  • 算力规格:硬件怎么配
  • 监控:两套引擎各看什么
  • 故障与恢复:出事时的标准动作
    • Kafka + Flink 组合:失败后到底会不会重复、会不会丢
    • 多节点会不会重复消费同一个分区:消费组的互斥语义
    • 数据源不止 Kafka:还能从哪读,API 数据怎么办
  • Spark 避坑指南
  • Flink 避坑指南
  • 选型决策

相关文章

  • 三万个 Flink 作业怎么自动扩缩容:Netflix 两代 Autoscaler 的教训
  • Spark 从入门到精通:架构原理、RDD/DataFrame、调优踩坑与电商实战全指南
  • 千人千面 + 无限瀑布流:推荐 Feed 的完整实现架构
  • Ruby on Rails 高级篇:接入 Redis、Elasticsearch、消息队列与大数据组件的工程实践
  • Netflix 如何用 Kueue 简化批计算:一次百万级任务的换心手术

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() 这个动作才触发真正的执行。这就是前面说的惰性求值。

这段代码里还有一件"看不见"的事值得说破:分区是自动切的,你全程没有指定过它。分区的三个来源:

  1. 读入分区由文件决定:读 parquet 时 Spark 按文件切块,spark.sql.files.maxPartitionBytes(默认 128MB)决定每块多大,每块一个分区。路径里的 dt=2026-10-01/ 本身就是物理分区,Spark 只扫这一天的目录,其余按分区裁剪跳过;
  2. shuffle 后分区由配置决定:groupBy 触发数据重分发,之后的分区数由 spark.sql.shuffle.partitions 控制,默认 200——小作业跑完写出 200 个小文件,罪魁祸首就是它;
  3. 手动干预只有两个 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(当前主流):作业定义成一份 FlinkDeployment YAML(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 暴涨):

  1. 看 Flink UI 背压页——找到链条上第一个变红的算子;
  2. 是某个下游慢(比如 Redis 抖动)→ 修下游,作业自己追上来;
  3. 是数据洪峰 → 临时调大并行度(savepoint → 重启 → 恢复),或者干脆等洪峰过去,Kafka 里的数据丢不了;
  4. 恢复后盯 Kafka lag 回落到正常水位,确认 checkpoint 恢复绿色;
  5. 事后复盘:这次故障该变成哪条告警规则?

注意底气来自哪里: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,分区重新分配,但交接是排他的:旧持有者被踢出组之后,新持有者才接手,不存在两个节点同时持有同一分区的窗口;
  • 所以同一作业内"重复消费"只有一种情况:故障恢复时的时间维度重放(上一节讲的回滚),不存在"空间维度"的并行重复。

两个相关的配置纪律:

  1. source 并行度不要超过分区数。并行度 64、分区只有 32 个时,32 个 subtask 永远分不到分区、空转白占资源。反过来分区数多于并行度没问题,一个 subtask 可以吃多个分区。
  2. 不同作业必须用不同的 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 避坑指南

按踩坑频率排序:

  1. 数据倾斜是第一杀手。某个 key 的数据量特别大(比如头部视频占了 10% 播放量),groupBy/join 时对应分区要处理别人几十倍的数据,99% 的任务十分钟跑完,最后 1% 卡两小时。解法:给热点 key 加盐(key 后面拼随机后缀打散)、先聚合再关联、小表广播(broadcast join)避免 shuffle。
  2. 小文件问题。ETL 作业输出几万个几 KB 的小文件,下游读取慢、元数据服务被打爆。解法:写出前 coalesce/repartition 控制分区数,或开自适应合并。
  3. shuffle 爆炸。join 两张大表,中间数据量是笛卡尔级增长。先看能不能把一张表过滤小再 join,能 broadcast 就 broadcast。
  4. 滥用 cache。.cache() 不是免费的,内存不够会挤占执行内存反而更慢。只缓存"会被复用多次"的中间结果。
  5. 分区数拍脑袋。分区太少 → 单任务处理量太大 OOM;太多 → 任务调度开销压垮 driver。经验值:单个分区处理 128MB 上下。
  6. driver OOM。collect() 把全量数据拉回 driver——生产事故的经典配方。要看几行数据用 show()/take()。

Flink 避坑指南

  1. 状态不设 TTL 无限膨胀。keyed state 默认永不过期,跑一个月状态涨到 TB 级,checkpoint 从秒级拖到分钟级最后超时失败。用户维度状态几乎总该设 TTL(比如 7 天)。
  2. checkpoint 间隔拍太短。间隔小于一次 checkpoint 本身的耗时,系统永远在做快照没有空算数据。先看一次 checkpoint 要多久,间隔至少留它几倍。
  3. 背压不会看。作业 lag 持续上涨,先去 UI 看背压面板:红色背压的算子就是瓶颈,瓶颈的上游全会被"传染"成红色——找到链条上第一个变红的,那才是真正的病灶。
  4. 事件时间和处理时间混用。用处理时间算"每分钟播放量",用户手机补传的数据会把昨天的量记到今天。涉及"按发生时间统计"的需求,必须事件时间 + 水位线,并接受乱序容忍度的权衡。
  5. 扩缩容当成无状态服务做。改并行度 = savepoint + 停作业 + 重启,大状态作业分钟级中断。扩缩容策略要保守,这个坑的完整故事见 Netflix 两代 autoscaler。
  6. 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 场景参考其技术博客公开的数据平台架构。

  • 系统架构
  • Spark
  • Flink
  • 大数据
  • 流处理
  • 批处理
  • 系统设计
方糖猫
方糖猫(大可)

18 年后端架构,现在独立做 AI 产品。这里记录 AI 视频、AI Agent 与工程实践。

本文阅读量 次
© 方糖猫Blog 2026
本站总访问量 次
蜀ICP备2026001758号