做实时计算的开发者,基本都会在 Storm、Spark Streaming、Flink 这几个词之间来回徘徊。我最早落地的流计算项目是老牌组合:Kafka 进数据,Storm 做处理,当时打动我的就是它逐条转发的低延迟,毫秒级确实香。但跑了大半年,吞吐和运维上的问题就全暴露了,后面才开始认真研究 Spark Streaming,再后来新任务几乎全用 Flink 来写。这篇就当一份过来人的选型笔记,把三个引擎的底层思路、关键差异和我在生产环境里的实操细节都说透,给正在做实时选型或者刚开始搭流计算平台的同学作参考。
文章不会去复述官网文档,而是把“它们到底怎么处理数据、什么时候选谁、上线后又该怎么调”这三件事讲清楚。你会看到我对同一套场景分别用 Spark Structured Streaming 和 Flink 的完整写法,也会看到反压、状态恢复、事件时间这些真正把人卡住的东西。
1. 先想清楚:你需要的“实时”到底有多实时
1.1 离线、准实时、实时的边界
很多人一上来就喊“要实时”,但实际问下来,大部分场景要的只是“比离线快点”。离线任务通常是 T+1,数据隔天才能用;准实时一般是分钟级调度,比如每 5 分钟跑一次 Spark 批任务;真正的实时,指的是数据产生后几百毫秒到几秒内就能看到结果,而且计算是连续发生的,不是靠定时触发。
用生活里的例子理解:离线任务像月底做财务报表,账都结完了才统一算;准实时像每天下班后对一次账;流计算则是监视器,电流一断,报警灯马上亮。大多数业务想要的是第三种,但第三种的技术成本和运维复杂度最高。
流计算处理的本质是无界数据流。数据源持续产生事件,你不知道它什么时候结束、下一秒会来多少,只能“来一条算一条、来一批算一批”。这就带来三个离线处理不太敏感的难点:事件的先后顺序可能乱、数据总量不可控、计算过程中不能像批任务那样随意回溯重算。理解不了这三点,后面调起参来基本靠猜。
1.2 延迟目标决定引擎选型
把延迟目标量化之后,选型其实会清晰很多,因为你只需要回答一个问题:业务愿意等多久出结果?
- 如果是风控拦截、秒杀防刷、实时价格调整,延迟目标在毫秒级,那直接考虑 Flink 或 Storm 这类能逐条处理的引擎。
- 如果是大屏监控、实时报表、运营看板,秒级延迟完全够用,Spark Streaming 和 Flink 都能胜任。
- 如果只是小时级或分钟级汇总,其实用 Spark 批处理加调度器就能搞定,没必要上流计算框架,硬上只会白消耗资源。
我之前待过的一个团队就犯过这个错。为了做一个“实时大盘”,引入了 Flink 集群,结果业务方真正要的数据每天凌晨跑一次批处理就够了。实时流跑了一个月,除了增加成本和故障面,没有带来任何业务收益。选型之前,先和业务方把延迟需求聊透,这是最省钱的一步。
2. 三条技术路线各自是怎么处理流数据的
2.1 Storm:一条条地算,胜在简单直接
Storm 是我最早接触的流计算引擎,它的设计理念非常直白:把计算流程画成一个拓扑(Topology),数据从 Spout(数据源)流入,经过一个个 Bolt(处理节点)流出。每条消息在拓扑里被当成一个 tuple,像接力棒一样在节点之间传递。这种逐条转发的模型让它的理论延迟非常低,因为消息不用等一个批次攒齐,到了就能处理。
Storm 的容错靠的是 ack 机制:每条 tuple 从 Spout 发出后,会构建一棵 tuple 树,每个 Bolt 处理完都要向上游返回 ack,全部节点确认后整条消息才算处理成功。如果超时没确认,Spout 会重发。这个机制保证了消息不丢,但也带来了巨大的性能开销,高吞吐场景下,大量 ack 消息会占满网络和线程资源。
我印象很深的一次事故:当时接了一个每秒 5 万条日志的流量,拓扑里逻辑不复杂,但 ack 线程先成了瓶颈,整个任务延迟从 30 毫秒一路飙到几秒。后来只能砍掉一部分追踪、放宽超时,才勉强稳住。Storm 的组件和架构也有些年头了,Nimbus、Supervisor、Zookeeper 这些角色维护起来要比后来的框架繁琐,现在新项目很少会选它,但不少老系统里还能见到它的身影。
2.2 Spark Streaming:把流切成小批,站在批处理巨人肩上
Spark Streaming 的思路和 Storm 完全不同。它不逐条处理,而是把持续到达的数据按固定间隔切成一堆小批量,每批生成一个 RDD,再交给 Spark 引擎去算。这个模型叫微批(micro-batch),一个 batch interval 就是一次 Spark 批处理,所以它的延迟天然是秒级,因为至少得等一个批次的时间窗口。
这样做的好处很明显:底层的调度、容错、SQL 能力全部复用 Spark 那套成熟机制,RDD 血缘可以自动恢复数据,吞吐量远高于逐条处理的 Storm。坏处也很明显:它不是真正的流处理。比如一个事件在第 3 秒到达,但当前批在第 5 秒才触发,那这个事件就得等到第 5 秒才开始被处理。
另外要注意,Spark Streaming(基于 DStream 的那套 API)和 Structured Streaming 其实是两个东西。前者是对 RDD 流做处理,写起来别扭、性能也不理想;后者把流看成一张不断追加的“无界表”,用 DataFrame 和 SQL 操作,处理乱序数据、事件时间窗口的能力强很多。如果你现在还在照着 DStream 的老教程写代码,我建议尽快迁到 Structured Streaming。用 Spark 做流计算,本质上是用批处理引擎模拟流的效果,理解这一点,你就知道哪些场景适合它:高吞吐、秒级延迟、团队已经是 Spark 技术栈、不想为流计算单独维护一套引擎。
2.3 Flink:事件不落地,天然就是流
Flink 和 Spark Streaming 最大的区别,在于它本身就是为无界数据流设计的。数据到了之后可以直接被处理,不需要攒批,所以延迟能到毫秒级,但它的价值远不止低延迟。Flink 在处理事件时间乱序、窗口、状态管理、精确一次这些问题上,把所有机制都做进了引擎里。
举个最容易感知差异的点:事件时间窗口。业务数据自带时间戳,比如用户下单时间,但在网络传输和业务链路里,事件可能延迟到达。如果按到达时间(处理时间)开窗口,早到和晚到的消息会被分到错误的窗口,统计结果就歪了。Flink 用 Watermark 机制来解决问题:它允许你设定允许乱序的等待时间,到了某个时间点,引擎才知道某个窗口的数据齐了,然后触发计算。这套机制是 Flink 的核心竞争力。
同时,Flink 有完备的状态管理。流计算任务几乎都有状态需求,比如累计计数、最近一段时间的均值、用户会话等,Flink 把这些状态也纳入 checkpoint 进行持久化和恢复。这个能力在 Spark Streaming 里也有,但用起来限制多得多。
用类比来总结三条路线:Storm 像是每个零件来了立刻加工的手工作坊,灵活但产能有限;Spark Streaming 像集装箱港口,船靠岸、卸货、整批吊走,吞吐高但必须等装船;Flink 更像现代化的流水线,传送带不停,零件来了就走,既快又能保持产能。没有绝对好坏,但“假流”和“真流”在复杂场景下的差距是肉眼可见的。
3. 从应用视角看选型:哪些维度最值得权衡
3.1 延迟、吞吐、一致性,先排优先级
做技术选型时,我最常做的一件事就是先列一张对比表,让团队把“绝对不能忍”的点标出来。下面是三个引擎在生产中比较关键的能力对比,你可以直接拿来当判断依据:
| 维度 | Storm | Spark Streaming | Flink |
|---|---|---|---|
| 处理模型 | 逐条处理 | 微批处理 | 事件驱动逐条 |
| 典型延迟 | 毫秒级 | 秒级 | 毫秒级 |
| 稳态吞吐 | 中低 | 高 | 高 |
| 一致性语义 | 至少一次(Trident 可做到恰好一次,但吞吐差) | 至少一次为主,配合幂等 sink 可接近恰好一次 | 原生支持端到端恰好一次 |
| 乱序与事件时间 | 基本不支持 | Structured Streaming 支持 | 原生支持 |
| 状态管理 | 弱,依赖外部存储 | 有限 | 强,状态后端可选 |
| 社区与生态 | 社区沉寂 | Spark 生态完善 | Flink SQL 快速成长 |
表格里最容易被忽略的一行是“一致性语义”。这三个引擎都敢说自己不丢数据,但不丢不等于不重。Storm 的 ack 超时重发机制会带来重复消息;Spark Streaming 微批失败后重跑同一批也会重复输出;Flink 虽然用 checkpoint 保证了引擎内部算一次,但如果下游写入不配合,照样重复。所以,如果业务场景对金额、库存这类数据要求非常高,不能多也不能少,那 Flink 是正确的起点,但你还得设计和它配合的幂等写入方案。
3.2 状态和精确一次处理,是分水岭
状态这个东西,外行看热闹,内行看门道。一个流计算任务做简单 ETL 和过滤,有没有状态问题不大;但一旦做滚动汇总、去重、会话切分、窗口聚合,就离不开状态。Storm 在这块几乎等于没有状态,你需要把中间结果扔到 Redis、HBase 里自己管理,逻辑复杂且恢复困难。Spark Streaming 的状态操作(如 mapWithState)也能用,但局限性很明显,状态规模一大、更新频繁,性能和稳定性都容易出问题。
Flink 把状态做成了一等公民。我们可以给每个 key 维护状态,也可以把状态存到内存或 RocksDB,并随 checkpoint 定期持久化。更有意思的是,Flink 借助 checkpoint 加两阶段提交,实现了对 Kafka 这类支持事务的 sink 的端到端恰好一次。简单说,源端读的位置、计算中间的状态、结果写出的位置,三者被对齐到同一个快照,故障恢复之后既不会漏也不会重。
不过我必须泼点冷水:就算 Flink 保证了引擎层面的恰好一次,如果你的结果是要写进 MySQL,Sink 只是普通 INSERT,系统重启后仍然可能因为未知原因重复执行一次写库。你需要让下游写入变成幂等操作,比如使用唯一键做覆盖更新,或者依赖数据库事务与两阶段提交配合。端到端的一致性从来不是某一个引擎能单独承诺的。
3.3 团队熟悉度和运维成本,往往被低估
很多团队选型只看技术指标,忽略了自己能维护什么。我之前见过一个团队,因为 Flink 在某些大会上的分享很惊艳,就上了一套 Flink 集群,结果整个团队没人熟悉这个框架。项目上线后,状态后端配置错了没人会调,checkpoint 频繁失败找不到原因,最后只能求助外部支持。而他们本来有很熟的 Spark 团队,如果当时用 Structured Streaming 接这类秒级需求,可能早早就交付了。
这不是说 Flink 不好,而是说选型必须考虑存量技术栈和人才池。如果团队离线数仓全是 Spark,那么加一套流计算用 Spark Structured Streaming 是最平滑的路径;如果团队准备从零做一个大规模的实时数仓,并且业务对延迟和一致性要求很高,那投入人力去啃 Flink 是值得的。Storm 则不太建议再作为新项目的选择,除非你的老系统已经稳定运行多年,没有重构动力。
至于框架本身的部署运维,Flink 的 checkpoint 配置、状态后端选型、slot 与并行度规划都比较讲究,Spark Streaming 则跟着 Spark 集群走,不用单独维护一套调度体系。成本这东西在选型阶段看不出差距,等你连续值班三周处理任务恢复问题时就会懂了。
4. 实操记录:同一个场景在两个引擎上的完整跑法
4.1 场景设定:订单金额实时汇总
为了不空谈原理,我拿一个复盘过多遍的真实场景来做示范。某电商团队需要实时统计每个商品类目下的订单总金额,数据从 Kafka 的order_events主题来,每条消息是一段 JSON,包含order_id、cate(类目)、amount(金额)、ts(下单时间)。要求是每分钟输出一次所有类目的累计金额,允许数据乱序到达。这个场景经典到几乎每个实时数仓项目都会遇到,你把下面的方案替换成自己的表单和指标就行。
需要说明的是,消息里的ts是事件时间,代表真实的下单时刻。流计算最忌讳的就是把到达时间当成业务时间,因为网络延迟、客户端离线、队列积压都会导致到达顺序乱掉,时间不准,所有窗口统计全是错的。两个引擎里都必须显式告诉系统“用ts作为事件时间,并允许一定时间的乱序等待”。
4.2 用 Spark Structured Streaming 实现
用 PySpark 写这套逻辑非常短,核心代码大概长这样:
from pyspark.sql import SparkSession from pyspark.sql.functions import window, sum spark = SparkSession.builder \ .appName("order_stream") \ .config("spark.sql.shuffle.partitions", "8") \ .getOrCreate() df = spark.readStream \ .format("kafka") \ .option("kafka.bootstrap.servers", "kafka-1:9092") \ .option("subscribe", "order_events") \ .option("startingOffsets", "latest") \ .load() orders = df.selectExpr("CAST(value AS STRING) as json") \ .selectExpr("from_json(json, 'order_id STRING, cate STRING, amount DOUBLE, ts TIMESTAMP') as data") \ .select("data.*") result = orders \ .withWatermark("ts", "10 minutes") \ .groupBy(window("ts", "1 minute"), "cate") \ .agg(sum("amount").alias("total_amount")) query = result.writeStream \ .outputMode("update") \ .format("console") \ .option("truncate", "false") \ .trigger(processingTime="5 seconds") \ .start() query.awaitTermination()这段代码的关键点有三个。第一,withWatermark("ts", "10 minutes")表示允许事件最多迟到 10 分钟,超过这个时间窗口就被丢弃不再更新;第二,window("ts", "1 minute")定义了 1 分钟的滚动窗口;第三,outputMode("update")表示只把更新的结果输出,而不是把全量结果重发一遍。实际生产里,我一般会把 sink 换成foreachBatch,在微批内把增量结果写入 MySQL 或 HBase,并且用唯一键做幂等覆盖,避免任务重启造成重复数据。
这个方案的延迟基本取决于trigger里设置的 5 秒和窗口边界,所以不是真正意义上的毫秒级实时。但它的优势很明显:写起来就像普通的 Spark SQL,团队不用学一套新 API。
4.3 用 Flink DataStream 实现
同一个场景,用 Flink 的 DataStream API 写起来会多几步,但不难,核心逻辑如下(Java 示意):
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(60_000); Properties props = new Properties(); props.setProperty("bootstrap.servers", "kafka-1:9092"); props.setProperty("group.id", "order-aggregator"); DataStream<String> source = env.addSource( new FlinkKafkaConsumer<>("order_events", new SimpleStringSchema(), props)); DataStream<Order> orders = source.map(json -> parseOrder(json)); orders.assignTimestampsAndWatermarks( WatermarkStrategy.<Order>forBoundedOutOfOrderness(Duration.ofSeconds(10)) .withTimestampAssigner((order, ts) -> order.getTs())) .keyBy(Order::getCate) .window(TumblingEventTimeWindows.of(Time.minutes(1))) .aggregate(new OrderAmountAggregate()) .addSink(new FlinkKafkaProducer<>("agg_result", new JsonSerializationSchema(), props)); env.execute("order-amount-stream");这里需要解释几个 Flink 特有的概念。enableCheckpointing(60_000)表示每 60 秒做一次分布式快照,这是故障恢复和恰好一次的基础。forBoundedOutOfOrderness(Duration.ofSeconds(10))指的是允许事件最多乱序 10 秒,比 Spark 例子里写的小,因为真实场景中两个框架的等待阈值是独立调优的。窗口计算用TumblingEventTimeWindows,表示 1 分钟滚动窗口。
如果你是 SQL 爱好者,这个任务用 Flink SQL 会更简洁,三张 CREATE TABLE 加一条 INSERT 就能搞定。但 DataStream API 在对复杂状态、自定义算子、多流 connect 的场景下更灵活,团队至少要有一个人能看懂它。否则遇到窗口不触发或状态恢复异常时,你连日志都看不懂。
4.4 部署参数和监控上踩过的坑
跑通 demo 只是第一步,生产环境里的坑全在细节里。先说我踩得最狠的几个:并行度设置、checkpoint 频率、反压处理。
并行度不是越大越好。从 Kafka 消费时,一个分区最好对应一个并行实例,并行度超过分区数不会提升吞吐,反而增加 shuffle 成本和 checkpoint 的压力。我曾经把一个 20 分区的 topic 配了 60 并行度,结果大部分资源浪费在空转和网络传输上,Kafka Lag 反而没降下去。合理的做法是先按分区数设置并行度,再对下游聚合算子单独调整并行度。
checkpoint 这个参数也不是越快越好。间隔太短,每次快照都在拷贝状态,严重影响吞吐;间隔太长,故障恢复时丢失的数据量变大、回放时间变长。经验上,一分钟左右的 checkpoint 间隔是大多数订单类任务的稳妥起点,如果状态很大,还要考虑用 RocksDB 状态后端,并给 TaskManager 留足内存,否则会频繁 GC。
反压是流计算另一个老大难。Flink Web UI 上有“Backpressure”标签页,看到红色就要排查算子瓶颈:是某个 key 的数据量太大、还是下游写库太慢、又或者是函数里做了耗时的外部调用。Spark Structured Streaming 没有这么直观的反压界面,但你可以从 Kafka Lag 的趋势看出端倪,Lag 持续上涨就是处理速度跟不上生产速度。这时候优先看瓶颈算子,而不是盲目加资源。
5. 流计算任务常见问题与排查实录
5.1 问题速查表
这里整理的是我在多个流计算项目里反复遇到的典型问题,每个问题都对应到一个具体的解决手段。
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| Flink 事件时间窗口一直不触发 | 没有正确设置 Watermark 或时间戳分配错误 | 检查withTimestampAssigner,用测试数据模拟乱序,在日志里打印当前 Watermark |
| Spark Streaming 任务越跑越慢 | shuffle 并行度过高或 Sink 写入太慢 | 调整spark.sql.shuffle.partitions,Sink 改成批量写入或异步写入 |
| 任务重启后结果重复 | 引擎恢复导致部分数据重放,外部写入不幂等 | 外部存储使用唯一键覆盖更新,或采用 Upsert 语义 |
| Kafka Lag 持续上涨 | 处理能力不足、数据倾斜或反压 | 定位瓶颈算子,优化 key 分布,必要时加盐做两级聚合 |
| 状态无限膨胀,TaskManager 内存告警 | 没有设置状态的过期时间 | 给 keyed state 配置 TTL,清理长期不更新的 key |
| 乱序数据导致统计结果波动大 | Watermark 等待时间太短 | 结合业务容忍度适当调大forBoundedOutOfOrderness或withWatermark的等待时长 |
5.2 排查思路:从 Web UI 和日志入手
排查流计算问题,最忌讳拍脑袋。我的固定套路是先看三样东西:Kafka Lag、引擎 Web UI、日志里的里程碑信息。
Kafka Lag 是所有问题的风向标,消费速度一旦跟不上生产速度,先把目标锁定在数据入口之后的第一个重算子。Flink Web UI 可以比较直观地看到每个算子的Records Sent、Records Received和线程忙等待时间,如果某个算子Busy time长期维持在 90% 以上,它大概率就是瓶颈。Spark Structured Streaming 则在 Streaming UI 里展示每个批次的处理时间,处理时间接近批间隔时就要开始警惕了。
第二件事是确认时间语义配置没有搞错。很多“为什么结果不对”的问题,最后定位到都是 Source 解析时把字段格式弄错了,或者ts当成 String 用了而没有转成 Timestamp。我会在正式跑数之前,用一个小 topic 灌几条带特定时间戳的测试数据,观察窗口是否按预期触发,输出是否符合预期,再放量。
5.3 几个保命的小技巧
最后分享几条从实战里长出来的经验,不一定写在任何官方文档里,但能帮你少熬几个夜。
第一条,生产环境一定要开 checkpoint,并且修改代码逻辑后不要随便删状态。很多团队觉得本地跑没问题,就不开 checkpoint,结果上线后网络抖动引发任务失败,半天数据算不出来,只能从头消费。Flink 恢复时指定 checkpoint 目录即可,Spark Structured Streaming 则要固定checkpointLocation,重启会自动接着跑,前提是你别乱动这个目录。
第二条,消息里必须带业务时间戳,并且统一用事件时间做窗口。处理时间只适合做监控指标,比如“消息从进入到处理完耗时多少”,而不能用来做统计口径。事件时间会用乱序和等待延迟换取准确性,这个代价值得付。如果你发现一个 key 对应的数据量特别大,别急着加机器,先在 key 前面加随机后缀拆分成多个临时 key,做完第一层聚合后再合并,这种“加盐”方案在高峰期非常有效。
第三条,上线前先小流量试跑。不要一上来就全量流量打过去,可以先切 5% 的 topic 分区或者只订阅某几个业务类型,观察 Watermark 推进是否稳定、反压是否出现、结果和离线数据对账差异有多大。对账这一步特别重要,拿离线任务的结果和实时结果比一比,能发现很多引擎层面发现不了的逻辑错误。
第四条,每加一个外部依赖,先想清楚它挂了会怎么样。写库失败要不要重试?Redis 超时会不会阻塞整个流?HBase 抖动会不会让 Flink 算子反压拖垮消费?我在生产里见过最惨的事故,就是因为下游一个监控系统的数据库锁表,导致整个实时链路被拖到 Kafka Lag 涨到几百万。流计算是一个链条,最弱的环节决定整条链的稳定性。