一、流批一体概述:概念、为什么需要、核心挑战
大家好,我是这次课程的主讲人。做了十几年数据架构,从传统数仓到实时数仓,再到现在的流批一体,我踩过的坑确实不少。今天咱们先聊聊最基础的问题——流批一体到底是什么?为什么大家都在提?它又难在哪里?
一句话总结:流批一体不是技术噱头,而是数据架构演进到一定阶段的必然产物。它试图用一套引擎、一套代码、一套元数据,同时搞定实时和离线两种场景。
1.1 流批一体的概念
先看一个最直观的场景。你想想看,一个电商平台每天要处理两类数据:
- 离线批处理:昨天全站的成交额、用户画像、推荐模型训练。数据量大,延迟容忍度高,通常是 T+1。
- 实时流处理:双十一大屏的秒级成交额、风控的毫秒级拦截、实时个性化推荐。数据量可能没那么大,但延迟要求极高。
传统做法是两套系统:批处理用 Hive/Spark SQL,流处理用 Flink/Storm。两套代码、两套运维、两套元数据。数据口径经常对不上——批处理算出来的 GMV 和实时大屏的 GMV 差几个点,业务方来质问,你只能苦笑。
流批一体,说白了就是让同一套代码,既能跑在批模式(处理历史全量数据),也能跑在流模式(处理实时增量数据)。输出结果完全一致,只是时效性不同。
我个人习惯把流批一体分为三个层次:
- 引擎层统一:Flink、Spark Streaming 等引擎同时支持流和批语义。
- API/SQL 层统一:同一段 SQL 在流和批模式下语义一致。
- 存储与元数据层统一:实时表和离线表共用同一份元数据,数据可回溯、可对账。
举个例子,Flink 的 Table API 和 SQL,在流模式下是动态表,在批模式下就是静态表。你写一条 SELECT SUM(amount) FROM orders GROUP BY region,批模式算历史总和,流模式算实时累计。代码一模一样。
-- 同一段 SQL,流批两种语义
INSERT INTO regional_sales
SELECT region, SUM(amount) AS total_sales
FROM orders
WHERE order_time >= '2024-01-01'
GROUP BY region;
嗯,这里要注意:流模式下 GROUP BY 会产生不断更新的结果,而批模式是一次性输出。Flink 内部用 retract 或 upsert 机制来保证两种模式语义等价。我在项目中遇到过团队没搞清楚这个区别,直接把批 SQL 搬到流上,结果状态爆炸——这是后话了。
1.2 为什么需要流批一体
你可能会问:两套系统跑得好好的,为什么要折腾?
我直接说几个真实痛点:
| 痛点 | 传统两套架构 | 流批一体解决 |
|---|---|---|
| 口径不一致 | 批处理用 Hive UDF,流处理用 Flink UDF,逻辑可能不同 | 同一段代码,天然一致 |
| 运维成本高 | 两套集群、两套调度、两套监控 | 一套引擎,统一运维 |
| 数据回溯困难 | 实时数据出问题,要重跑批任务修复,流程割裂 | 流批同源,回溯只需切换模式 |
| 资源利用率低 | 批集群白天闲置,流集群晚上闲置 | 统一资源池,弹性调度 |
| 开发效率低 | 同一逻辑写两遍,还容易写错 | 写一遍,跑两种模式 |
我曾经在一个金融客户那里,看到他们实时风控和离线报表用的两套 SQL 脚本。离线脚本里有个 CASE WHEN 逻辑写错了,实时脚本却是对的。结果风控拦截率正常,但第二天的报表数据异常,业务方吵了一上午。最后发现是两套代码没同步。如果当时用了流批一体,这种问题根本不会出现。
避坑指南:流批一体不是银弹。如果你的场景里实时和离线逻辑天然不同(比如实时需要降采样,离线需要全量聚合),强行统一反而增加复杂度。我曾经见过一个团队为了"流批一体"而流批一体,把两个差异很大的逻辑硬塞进同一段代码,结果维护成本比两套还高。
1.3 流批一体的核心挑战
理想很丰满,现实很骨感。流批一体看起来很美,但落地时有一堆坑。我把它归纳为四个核心挑战:
挑战一:语义一致性
这是最根本的挑战。流处理和批处理对"时间"的理解不同:
- 批处理:数据是有限的,时间就是数据里的一个字段。
- 流处理:数据是无限的,时间有
事件时间、处理时间、摄入时间之分,还有水位线、迟到数据等概念。
同一段 SQL,在批模式下 WHERE order_time > '2024-01-01' 就是简单过滤。在流模式下,你得考虑:水位线怎么设?迟到数据怎么处理?GROUP BY 的结果是不断更新的,下游怎么消费?
Flink 用 动态表 和 连续查询 来解决这个问题,但并不是所有 SQL 都能完美转换。比如 RANK()、LAG() 等窗口函数在流模式下需要维护大量状态,一不小心就 OOM。
我的经验:流批一体的 SQL 要遵循"可撤回"原则。也就是说,流模式下产生的每一条结果,都要能通过 INSERT 或 RETRACT 来修正。批模式是一次性写入,不需要撤回。如果你的业务逻辑不支持撤回(比如最终结果依赖全局排序),那流批一体就很难做。
挑战二:存储与计算分离
传统批处理,数据存在 HDFS 上,计算任务读取后处理。传统流处理,数据存在 Kafka 里,计算任务持续消费。流批一体要求:同一份数据,既能被批任务全量扫描,也能被流任务增量消费。
这就催生了 Lakehouse 架构(如 Iceberg、Hudi、Delta Lake)。这些存储格式支持 ACID 事务、时间旅行、增量查询。批任务读全量快照,流任务读增量变更日志。
-- Iceberg 示例:批模式读全量
SELECT * FROM orders.snapshots WHERE snapshot_id = 12345;
-- 流模式读增量
SELECT * FROM orders.changes WHERE committed_at > '2024-01-01 00:00:00';
但这里有个坑:小文件问题。流任务持续写入,会产生大量小文件。批任务读这些小文件时,性能会急剧下降。我见过一个生产环境,Iceberg 表的小文件数量超过 10 万,一个简单的 COUNT(*) 跑了 20 分钟。解决方案是定期做 COMPACTION,但 compaction 本身又是批任务,怎么和流任务协调?嗯,这又是一个话题。
挑战三:状态管理与容错
流处理是有状态的。比如 SUM、COUNT、JOIN 都需要维护中间状态。批处理是无状态的——每次从头算。流批一体要求:流模式下的状态,在批模式下也能复用。
Flink 的 Savepoint 机制可以保存状态快照,但状态大小可能达到 TB 级别。批模式下如果也要加载这些状态,启动时间会很长。我建议的做法是:
- 流模式:使用
RocksDB状态后端,增量 checkpoint。 - 批模式:不使用状态后端,直接从数据源重新计算。
但这样又带来一个新问题:流模式和批模式的计算结果可能因为状态不一致而产生差异。比如流模式用了 TTL 过期了部分状态,批模式没有过期,结果对不上。
一个小技巧:在流批一体的任务中,我习惯把状态 TTL 设得足够大(比如 7 天),然后定期用批任务做全量对账。如果发现差异,就调整 TTL 或检查逻辑。这叫"流批互验",是保证数据一致性的最后一道防线。
挑战四:血缘追踪的复杂性
这个挑战和我们这门课的主题直接相关。流批一体下,血缘追踪比传统数仓复杂得多:
| 维度 | 传统批血缘 | 流批一体血缘 |
|---|---|---|
| 时间维度 | 静态,一次执行一个结果 | 动态,持续产生结果,需要追踪版本 |
| 数据粒度 | 表级或分区级 | 需要到消息级或事件级 |
| 转换逻辑 | SQL 解析即可 | 需要处理 UDF、状态操作、窗口函数 |
| 存储介质 | HDFS/数仓 | Kafka + 对象存储 + 湖存储,多源异构 |
| 回溯能力 | 容易,重跑即可 | 需要支持时间旅行和增量回溯 |
举个例子,一个实时数据管道:Kafka → Flink 清洗 → Kafka → Flink 聚合 → 写入 Iceberg。中间任何一个环节出问题,你要找到"这条数据是从哪个 Kafka 分区来的?经过了哪些算子?被哪个窗口聚合了?"——在流批一体架构里,这些信息如果不提前埋点,事后根本查不到。
这也是为什么我要做这门课。流批一体的血缘追踪,不是简单地在 SQL 解析树上加几个标签,而是要构建一套端到端的、可回溯的、支持流批两种语义的元数据体系。
1.4 本章小结:一张图看懂流批一体
说了这么多,我用一张图把核心逻辑串起来。这张图也是后续课程的基础框架。
这张图展示了流批一体的四层架构:数据源层 → 计算引擎层 → 存储层 → 血缘与元数据层。每一层都有对应的核心挑战。后面的课程,我们会一层一层拆解,重点放在第四层——血缘追踪上。
好了,第一章就到这里。流批一体不是什么玄学,它就是一套更高效、更一致的数据处理范式。但落地时,语义一致性、存储计算分离、状态管理、血缘追踪这四个挑战,每一个都够喝一壶的。接下来的 29 章,咱们一个一个啃。