巷口新开了一家叫“一茶二室”的奶茶店。楼下是中央备料间,每天打烊后,店员把全天几百张小票汇总,一次性煮好珍珠、泡好茶底——这是“批”;楼上现制台则完全不同,顾客每下一单,店员立刻拿杯、加料、封口——这是“流”。过去店里两本菜谱、两套流程:备料间按“批量清单”走,现制台按“实时接单”走,同一杯奶茶的口味标准竟常常对不上。店长小林做了个决定:把备料和现制合并成同一本菜谱、同一个厨房,既支持一次备一大缸,也支持一杯一杯现做。这本“统一菜谱”,就是大数据世界里传说中的“流批一体”——用一套 API 和一套引擎,同时驾驭离线批处理和实时流处理,彻底告别两套代码、两份数据的时代。
流批一体是实时数仓工程师的“降本增效必修课”:从两套代码的对账地狱,走向一套 API 的优雅统一,是架构能力的分水岭。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
统一引擎 (Flink) Watermark 同一套API处理流+批 批处理链路 (Spark/Hive) 流批一体可视化:对比统一引擎与Lambda架构,展示水位线机制和动态表概念
流批一体概述
一茶二室的楼下备料间里,店员等着打烊后把所有小票一次性算清:今天珍珠用了多少、茶底煮了几锅,数据是“收齐了才算”;楼上现制台则是来一单做一杯,永远不知道下一个顾客点什么,数据是“源源不断”的。店长小林发现,两种活法本质都是“把订单变成奶茶”,只是数据的“边界”不同。
大数据里的批处理,就是楼下备料间的逻辑:把一段时间内收齐的数据一次性算完,有头有尾;流处理则是楼上现制台的逻辑:数据像河水一样不断涌来,来一条处理一条,没有尽头。过去很多公司为了兼顾两者,架了两套系统——离线用 Spark,实时用 Flink,同一指标要写两遍代码,像两本菜谱分别管两个厨房,口径还经常对不上。
流批一体的核心思想,正是小林的那本“统一菜谱”:一套 API、一套引擎,通过切换执行模式同时支持批和流。在它看来,批不过是“有界的流”——水流到了终点,把终点前的所有事件一起算完而已。
小林把那两个厨房合并后,店员们终于不用再记两套流程;同样,企业把两套引擎收编成一套后,维护成本直线下降,指标口径也第一次真正统一了。不过,光有理念还不够,要让一个厨房兼容两种火候,靠的是那本写满基础操作的“统一菜谱”——DataStream API。
- 流式数据 D_stream用集合形式表示源源不断的订单序列,e₁ 是第一杯、e₂ 是第二杯……顾客随时会下新的单,所以这个集合永远等不到最后一个元素,叫无界。
- 批式数据 D_batch表示已收齐的一整批小票,从 d₁ 到 d_n,数量 n 是确定的,像打烊后整理好的所有单据,叫有界。
- 统一泛化流批一体把“流”看成“还没结束的批”,把“批”看成“已经结束的流”,于是两种形态都可以交给同一套处理逻辑,只是执行时机不同。
DataStream统一API
林记的菜谱上写满了基础操作:“加珍珠”“去冰”“换大杯”“按口味分组”。不管是楼下大批量备料,还是楼上现做单杯,店员都是照着同一套操作来组合,只是执行节奏不同。DataStream 之于 Flink,就是这本菜谱。
DataStream 统一 API 的意思是:无论是实时流还是离线批,你都用同一组算子来编写代码——map 做转换、filter 做过滤、keyBy 做分组、window 开窗口、aggregate 做聚合、sink 写出结果。批处理不再需要另一套“Spark 方言”,因为批数据在 API 眼里只是“有界的流”:给同一个任务切到 BATCH 执行模式,引擎就按有界输入做整体调度;切回 STREAMING 模式,就恢复逐条消费、增量计算。
这套设计让“算子一致性”成为天然属性:同一个窗口聚合函数,在流模式下按水位线增量触发,在批模式下按输入边界整体切分,代码一字不改。
有了这本统一菜谱,店长终于不用再教两批店员。但小林很快发现一个新问题:统计“3 点钟卖了多少杯”时,楼下按小票时间算,楼上按接单时间算,两边数字又打架了——问题的根源,藏在“时间”这个看似简单的词里。
- Source / Sink链的两端分别负责读入数据和写出结果,流的 Source 是 Kafka 这类消息队列,批的 Source 是文件系统里的历史数据。
- 中间算子链Map、Filter、KeyBy、Window、Aggregate 依次对数据进行加工,每个算子只做一件事,像菜谱上“先加料、再封口”的步骤。
- 同一链两种模式这条链在 STREAMING 模式下逐条响应,在 BATCH 模式下整体调度,代码本体不因模式而重写。
时间语义
顾客小票上印着“14:32“,这是她实际点单的时刻;厨房忙碌时,店员可能 14:38 才看到这张小票开始制作。小林把这两个时间分别记了下来:前者叫”事件时间“,后者叫”处理时间”。统计 14:30 那一档的销量,到底该信哪个?
在流处理中,“事件时间”是业务实际发生那一刻的时间,它永远不会变,就像小票上的印字;“处理时间”是数据进入引擎、被算子处理的时刻,像店员拿到小票开始做奶茶的时刻,受机器负载、网络延迟影响,充满不确定性;还有第三种“注入时间”,是数据进入系统时被统一打上的时间戳,介于两者之间。
如果按处理时间统计,一台机器卡顿 5 分钟,“3 点档”的数据就可能被记到“3 点 05 分档”;只有事件时间才能还原业务真相。所以流批一体里,事件时间才是默认的“裁判”,而它必须给出一个数学承诺:
选定事件时间后,小林面临下一个难题:网络会让订单乱序到达——3 号桌的小票可能 4 号桌之后才送到柜台。他需要一把“等多久才算齐”的尺子,这就是水位线。
- 事件时间 t_event(e)事件 e 在业务世界发生的时间戳,比如小票上的 14:32,它不随系统状态改变。
- 处理时间 t_proc(e)引擎实际处理这条数据的时钟时刻,比如店员 14:38 才开始做,它受负载影响。
- 非负延迟因为事件必须先发生才可能被处理,所以处理时间不会早于事件时间,差值就是系统带来的延迟,永远大于等于零。
水位线机制
店长小林定了一条规矩:统计 3:00 的订单时,只等到 3:05,误差 5 分钟内的迟到小票都算数,超过 5 分钟就不再等了。这 5 分钟就是“耐心阈值”δ;"3:05"这个时刻,就是店长心中的水位线——它宣告:所有事件时间不晚于 3:00 的订单,此刻应该基本到齐了。
水位线(Watermark)正是这样一把“数据到齐”的尺子:它由已到达事件的最大事件时间减去容忍阈值 δ 得到。水位线越往前推,引擎就越有底气触发窗口:当水位线推进到某个窗口的结束时刻时,这个窗口就“封盘开奖”——像店长宣布“3:00 的账可以算了”。
如果数据提前到达或乱序到达,水位线负责兜底:窗口触发前到达的旧数据正常计入;触发后、水位线允许范围内仍会再等一等(allowedLateness);迟得太离谱的数据则走旁路输出(side output)进“待复核筐”,绝不让它污染已发出的结果。
水位线让“何时算齐”有了数学依据,但窗口结果要如何高效地更新?小林发现,光有静态账本不够——他需要一块会自己跳数字的“看板”。
- S(P)到处理时刻 P 为止,引擎已经收到的全部事件集合,相当于柜台前堆着的所有小票。
- max t_event(e)从已收到的事件里找出事件时间最大的那一个,代表“最新鲜的业务时刻”。
- 减去 δ后退 δ 作为安全余量,意思是“最新消息说到了 3:10,但我担心还有迟到货,所以只敢确认 3:05 之前的账”。
- 窗口触发条件当水位线 W(P) 大于等于窗口 [a, b) 的结束时刻 b 时,窗口立即触发计算并输出结果。
动态表机制
林记前台挂着一块“实时爆款排行看板”,每卖出一杯,看板上对应口味的计数就 +1。小林从不让人每分钟重算整块板,而是“来一条改一次”;打烊后,他又用全天小票重算一遍整块板做对账。同样一块看板,白天增量更新,晚上全量重算——这就是动态表的流与批两面。
动态表(Dynamic Table)是把无限数据流映射成一张“一直在变”的表:每条流入数据都触发一次行级更新,就像每杯奶茶让看板 +1。在这张表上执行的 SQL 叫“连续查询”:它不是跑一次就结束,而是随着新数据的到来持续输出新结果——白天那套增量更新逻辑。
引擎能做到“来一条改一次”,靠的是查询重写:把用户写的 SQL 自动改写成增量算子,只计算“新来的数据影响了哪些旧结果”,再维护一份状态把它们累积起来。最后,查询结果表每变一次就吐出一条变更流,重新变成下游可消费的数据流。
看板之所以能自己跳数字,是因为它背后有人把每一次“新增一杯”“改大杯”“退单”都记成了变更日志——这,就是 Changelog 机制。
- 流到表把无限流逐条“落”成一张不断追加和更新的动态表,对应看板上的每一次 +1。
- 连续查询SQL 被改写成增量算子,数据每到达一条,只重算受影响的那一小块结果,而不是全板重扫。
- 表回流结果表的每次变化都转化成一条变更记录流,供下游实时消费,相当于看板每跳一次就广播一次。
Changelog机制
一位顾客点了中杯奶茶,两分钟后改口要大杯。小林不会让店员“再做一杯新的”然后不管旧杯,而是记一条变更:撤掉中杯、加上大杯;若顾客退单,就记一条删除。厨房里永远只有一杯正在制作的奶茶,但账本上清清楚楚躺着变更记录。
Changelog(变更日志)机制正是这套账本:它把动态表的每一次变化记录成三类操作——插入(+I)、更新(-U 删旧行、+U 加新行)、删除(-D)。下游不需要重新读整张表,只要顺着变更日志一条条“重放”,就能重建出任何时刻的结果表。
这带来一个强大的能力:增量物化。最终结果不再是“每来一条数据就全量重算”,而是“初始快照 + 不断追加的增量变更”。配合 Flink 的检查点机制,变更日志还能保证一致性——系统崩溃后从头重放日志,账本分毫不差,就像录像带可以倒带重播。
账本清楚了,小林终于可以回答最本质的问题:既然批和流都能用同一套工具搞定,那过去为什么要养两个厨房?答案写在一段狼狈的演进史里。
- R(0) 初始快照结果表的起点状态,比如打烊前最后一次全量对账的账本。
- ΣΔᵢ 增量累积把 k 条变更逐条累加到快照上,每一条都精确描述“哪一行发生了什么变化”。
- 四类变更+I 表示新增一行,-U/+U 表示先删旧行再插新行(即更新),-D 表示删除一行。
实时数仓演进
最早,林记是标准的“双厨房”:楼下备料间按天出“全天报表”,楼上现制台按秒出“实时数字”,两本账经常对不上,店长每天要花大量时间“对账”;后来改成“全流水线现制”,所有数据都走流式管道,但重算历史时效率太低;直到统一菜谱出现,才真正做到一个厨房两种火候。
这套演进对应着实时数仓的三代架构。第一代叫 Lambda 架构:离线批链路算全量数据,实时流链路算增量数据,最后合并出结果——但两条链路两套代码,结果冲突时要靠“对账”解决,代价高昂。第二代叫 Kappa 架构:干脆让所有数据都以流的方式处理,需要历史就从头回放消息队列——代码只有一套了,但回放重算既慢又费资源。
流批一体是第三代:在一套引擎里,流模式负责实时增量,批模式负责历史上卷,两种执行模式共享同一套 API 与算子。批不再是“另一个系统”,而是“流引擎的一种特殊执行模式”。这正是它相对 Lambda 和 Kappa 的最优解:一套代码、两种模式、一个口径。
小林终于关掉了那间备料间。如今,一茶二室用一本菜谱管住了两种节奏:批量备料是“把流暂停后整体算”,现制单杯是“边来边算”。而大数据世界里的流批一体,也在实时数仓的舞台上扮演着同样的角色。
- Lambda 的合并全量批结果与增量流结果通过 ⊕ 运算合并,为此必须维护两套代码并对账。
- Kappa 的全流一切数据都走流式管道,历史结果通过从源头回放数据重建。
- 流批统一的换代新一代架构让批任务在流引擎内以 BATCH 模式执行,API 统一、口径统一。
AI 应用场景
实时数仓中的流批统一 ETL
阶段:数据加工与指标计算
解决的问题:传统 Lambda 架构要用 Spark 维护离线 ETL、用 Flink 维护实时 ETL,同一份订单数据写两套 SQL,大促时对账天天加班。流批一体让同一份 Flink SQL 既跑实时链路又切批模式算全量历史,一套代码保住了口径统一。
具体案例:某电商平台把订单明细接入 Kafka,用 Flink SQL 定义“订单宽表”动态表,实时模式产出秒级 GMV 大屏;T+1 凌晨切到 BATCH 模式跑同一 SQL 做全量对账,两边的 GMV 数字终于分毫不差。
金融交易反欺诈实时指标
阶段:实时特征计算
解决的问题:支付交易天然乱序,按处理时间统计会造成误判。用事件时间定义“30 秒内同卡交易次数”,配合水位线容忍网络抖动,迟到交易进旁路输出做人工复核,既保住实时性又不冤枉好用户。
具体案例:某支付平台的风控引擎每秒处理数万笔交易,Flink 按事件时间开滑动窗口统计频次,水位线偏移设为 2 秒;超过窗口才到的交易自动进入 side output,由离线复核模型二次判断。
物联网设备状态实时看板
阶段:设备状态聚合与推送
解决的问题:海量设备上报乱序、时断时续,用普通表更新会丢状态。用动态表 + Changelog 维护“设备最新状态表”,每台设备的每一次状态变更都生成 +U 变更日志,下游大屏按日志增量刷新。
具体案例:某工业互联网平台接入十万台设备,Flink 将上报流映射为动态表,连续查询产出实时健康度指标,Changelog 流推送到 WebSocket 大屏,同时数据湖按批模式每晚重算全量报表。
Python 演示
# -*- coding: utf-8 -*-"""流批一体最小演示:一套窗口求和逻辑,流式与批式两种执行模式复用""" def window_sum(events, start, end): """同一套窗口计算逻辑:流模式增量跑、批模式整体跑都调用它""" return sum(v for t, v in events if start <= t < end) class UnifiedPipeline: """同一份代码,两种执行模式""" def __init__(self, events, mode="streaming", max_delay=1.0): self.events = events # 按到达顺序排列的事件 self.mode = mode # streaming / batch self.max_delay = max_delay # 水位线容忍的最大迟到量 self._count = 0 # 触发窗口次数 def run(self, win_size=5): if self.mode == "batch": ordered = sorted(self.events, key=lambda e: e[0]) max_t = max(t for t, _ in ordered) boundary = win_size while boundary <= max_t + win_size: self._emit(ordered, boundary - win_size, boundary, "batch") boundary += win_size else: self._stream_run(win_size) return self._count def _emit(self, seq, start, end, label): total = window_sum(seq, start, end) print("%s: 窗口[%d, %d) total=%d" % (label, start, end, total)) self._count += 1 def _stream_run(self, win_size): watermark = 0.0 boundary = win_size arrived = [] for t, v in self.events: arrived.append((t, v)) watermark = max(watermark, t - self.max_delay) if watermark >= boundary: self._emit(arrived, boundary - win_size, boundary, "stream") boundary += win_size if __name__ == "__main__": # 事件(3,7)最后才到,属于迟到的乱序数据 orders = [(1, 8), (2, 5), (5, 3), (4, 9), (6, 6), (8, 2), (7, 4), (3, 7)] print("== 流模式:水位线推进、窗口增量触发 ==") UnifiedPipeline(orders, "streaming").run(5) print("== 批模式:同样一套窗口求和逻辑整体切窗 ==") UnifiedPipeline(orders, "batch").run(5)
def window_sum(events, start, end):这就是“统一菜谱”里的那道基础菜:窗口求和,流批两种模式都调用它。return sum(v for t, v in events if start <= t < end)只统计落在 [start, end) 窗口内的事件,边界规则清晰。ordered = sorted(self.events, key=lambda e: e[0])批模式的“打烊整理”:先按事件时间排好序,再整体切窗。watermark = max(watermark, t - self.max_delay)流模式每来一条数据就前推一次水位线,容忍 max_delay 的迟到。if watermark >= boundary:水位线越过窗口边界才触发计算,像店长确认“账可以算了”才动手。self._emit(arrived, boundary - win_size, boundary, "stream")增量触发时复用的是同一份 window_sum,体现“API 统一”。UnifiedPipeline(orders, "streaming").run(5)同一个类只改一个参数就能切换执行模式,正是流批一体的精髓。
常见误区
误区流和批本质一样,区别只是处理速度快慢,把批任务的并行度调低就变成流了。
正解两者是计算模型的不同:批处理面向有界数据,先收齐再整体调度;流处理面向无界数据,来一条处理一条,靠水位线决定何时“算齐”。就像备料是“收工后统一做一大缸”,现制是“来一单做一杯”,不是“做得慢的备料”。
为何常见:因为很多批任务确实可以靠降低延迟跑得“像流”,让人误以为只是性能差异,忽略了有界与无界在触发机制上的本质区别。
误区为了不丢数据,把水位线偏移量 δ 设得很大,这样迟到数据都能等得到。
正解δ 是“等待迟到数据的耐心”,耐心越大,结果产出越迟。δ 过大时,窗口要等很久才触发,实时性被拖垮;δ 过小又丢数据。正确做法是按业务可接受的延迟和迟到比例折中,像店长只等 5 分钟,而不是等一小时。
为何常见:丢数据看起来很可怕,初学者容易忽略“实时性”同样是硬指标,误把水位线当成越大越好的保险丝。
误区处理时间是系统真实时钟,肯定比事件时间可信,统计时直接用处理时间就行。
正解处理时间反映的是“引擎什么时候处理”,不是“业务什么时候发生”。机器一卡,3 点的订单可能被记到 3 点 05 分;只有事件时间(小票时间)才忠实记录业务真相。处理时间唯一准确的用途是监控系统自身健康度。
为何常见:因为直觉上“我看到的时刻”就是真相,忽略了数据在传输和排队中产生的延迟,这正是引入事件时间和水位线的初衷。
误区动态表和数据库物化视图一样,靠定时任务扫全表刷新。
正解动态表是“来一条数据就增量更新一行”,由 Changelog 流驱动,绝不定期全表扫描。连续查询会把 SQL 改写成增量算子,只重算受影响的部分。好比前台看板每卖一杯就 +1,而不是每分钟派人去数一遍全场。
为何常见:大家熟悉传统数据库的物化视图,潜意识里用“定期重算”理解一切,忽略了流式引擎“事件驱动”的本质。
误区Lambda 既能跑批又能跑流,虽然两套代码但都上线了,何必折腾着去统一。
正解Lambda 的两套代码意味着双倍开发、双倍维护,更致命的是两条链路算同一指标可能结果不一致,每晚会话全在对账。流批一体用一套 API 两种执行模式,从根上消灭了对账,Kappa 又弥补了 Lambda 的短板,是架构演进的方向。
为何常见:Lambda 提出早、资料多,很多团队“能用就好”;但只有踩过双链路对账的坑,才会理解统一 API 的价值。
面试题
Q1. 流批一体最核心的思想是什么?
- A.用两套 API 分别开发流和批任务
- B.一套 API 加一套引擎双执行模式← 正确答案
- C.把所有离线任务都改造成流任务
- D.把所有实时任务都改造成批任务
答案:B。流批一体 = 一套 API + 一套引擎,通过切换 STREAMING / BATCH 执行模式同时支持流与批,像一本菜谱管两种火候。A 是两套代码的老路,C、D 是极端化改造,都违背“统一”的初衷。
Q2. 时间语义中「事件时间」指的是什么?
- A.数据进入处理引擎时的时刻
- B.业务实际发生那一刻的时间← 正确答案
- C.数据写入备份存储的时刻
- D.窗口触发进行计算的时间
答案:B。事件时间 = 小票时间,是业务发生时刻;处理时间是引擎处理时刻;注入时间是进入系统的时刻。B 与“小票印的时间”一一对应,其他选项分属处理时间、存储时间和触发时间。
Q3. 关于处理时间与事件时间的关系,正确的是?
- A.处理时间肯定等于事件时间
- B.事件时间一定晚于处理时间
- C.处理时间通常晚于事件时间← 正确答案
- D.两者之间没有任何关联
答案:C。事件先发生、后处理,所以处理时间通常晚于(至少不早于)事件时间,差值即延迟。A 只在理想零延迟下成立,B 把因果倒置,D 忽略了“先发生才能被处理”的约束。
Q4. 水位线(Watermark)的核心作用是什么?
- A.标记最早到达的数据所在窗口
- B.表示数据足够齐并触发窗口计算← 正确答案
- C.替代事件时间参与聚合运算
- D.压缩中间数据以节省存储开销
答案:B。水位线 = 最大事件时间 − 容忍迟到量,它宣告“到这一刻为止的数据视为到齐”,越过窗口边界就触发该窗口计算。它不标记最早数据、不替代事件时间、不压缩存储,B 是其唯一职责。
Q5. 动态表与普通静态表最大的不同是什么?
- A.动态表只能被写入不能查询
- B.动态表数据量固定不随流变化
- C.动态表随流入数据持续更新结果← 正确答案
- D.动态表只用于离线报表的查询
答案:C。动态表把无限流映射成不断变化的表,连续查询随每条新数据增量更新结果,像看板每卖一杯就 +1。A、D 与查询能力矛盾,B 恰好说反了动态的本质。
Q6. Changelog 机制包含哪三类变更操作?
- A.增加、复制、移动三大类操作
- B.插入、更新、删除三大类操作← 正确答案
- C.启动、暂停、停止三大类操作
- D.读取、写入、清空三大类操作
答案:B。Changelog 记录行级变更,用 +I(插入)、-U/+U(更新)、-D(删除)描述结果表的每次变化。B 对应账本上“记一笔、改一笔、划一笔”,其他选项是文件系统或进程概念的混淆。
Q7. 把水位线偏移量 δ 调得过大,会带来什么后果?
- A.结果更及时但数据丢失变多
- B.窗口结果的产出延迟变大← 正确答案
- C.事件时间会被系统自动修改
- D.批处理执行模式无法运行
答案:B。δ 是“等迟到数据的耐心”,等得越久结果出得越晚。δ 过大时,窗口要等水位线慢慢爬到边界,实时性被拖垮。它不会修改事件时间、不丢数据、与批模式无关。
Q8. DataStream 实现流批一体的关键点是什么?
- A.为流批各维护一套不同的算子
- B.切换执行模式复用同一套代码← 正确答案
- C.把流式数据全部落盘再计算
- D.把实时任务全部改成批任务
答案:B。DataStream 统一 API 让 map、filter、window 等算子与执行模式解耦,切到 STREAMING 就逐条增量、切到 BATCH 就整体调度,代码不重写。A 回到两套代码,C、D 都否定了实时能力。
Q9. Lambda 架构存在的主要痛点有哪些?
- A.实时与离线两套代码维护成本高← 正确答案
- B.两条链路的计算结果容易不一致← 正确答案
- C.业务指标的计算口径难以统一← 正确答案
- D.完全无法处理任何实时数据流
答案:ABC。Lambda 用批链算全量、流链算增量,两套代码导致维护贵、对账难、口径分裂,对应 A、B、C。D 说反了,Lambda 恰恰包含实时链路,只是代价高昂。
Q10. 数据源长时间空闲导致水位线不推进时,Flink 可以采用哪些手段恢复窗口触发?
- A.配置空闲源超时自动推进水位线← 正确答案
- B.把事件时间改为处理时间语义
- C.直接删除所有等待触发的窗口
- D.给数据源打上空闲标记忽略← 正确答案
答案:AD。空闲源没有新事件推水位线,会导致窗口永不触发。Flink 提供空闲源超时机制(withIdleness)自动按其他分区推进水位线,A、D 是官方做法。B 是偷换语义,C 直接丢数据,都不可取。