☰
流批一体不只有Flink,实时数据模型才是真正的分水岭
2026/10/22 4:06:57 网站建设 项目流程

聊流批一体的时候,我发现大多数人第一反应都是Flink,引擎层确实被它统一了不少,但真正让项目从“能跑”到“能持续稳定交付”的,反而是下游那一层经常被忽略的东西——数据模型。我去年接手一个实时数仓项目,Flink SQL写得再顺,一到指标口径对比就翻车,离线算的和实时算的永远差那么几个数。折腾了快两个月才想明白,问题根本不在计算引擎,而在于我一直在用离线建模的思路去设计实时链路。标题里那句“流批一体不只有Flink,还有实时数据模型”,本质上就是在提醒一件事:引擎合并只是第一步,模型层的统一才是流批一体的真正分水岭。

1. 为什么说流批一体不只是一个引擎问题

1.1 引擎统一和模型统一是两回事

先聊一个我自己的真实经历。刚接触流批一体的时候,我一度以为把实时任务和离线任务都跑在Flink上就完事了,毕竟引擎统一了,代码也能复用,怎么看都像是“一体”了。但真跑起来之后发现,离线和实时任务虽然都叫“计算订单金额”,口径却经常对不上。

原因在哪儿?引擎只是负责“怎么算”,模型负责“算什么”。你把计算引擎统一了,但如果两套任务用的是不同的表结构、不同的粒度定义、不同的状态管理方式,算出来的结果天生就不一样。比如离线任务习惯用T+1分区,每天凌晨重跑全量;实时任务用事件流,每条订单自然落到时间窗口里。两边读同一张业务表,最后汇总出来的金额却可能因为时区、去重逻辑、事实表粒度这些细节产生偏差。这种偏差不是引擎能解决的,必须从模型层下手。

我一直觉得,流批一体真正的难点不在中间那层计算引擎,而在最上面那层数据模型。Flink、Spark Streaming这些工具解决的是“同一套代码跑两条链路”的问题,但模型要是设计得不好,要么实时读离线表造成延迟瓶颈,要么离线为了对齐实时把全链路重算一遍,两头不讨好。

1.2 实时数据模型到底指什么

很多人第一次听到“实时数据模型”会觉得有点虚,毕竟传统数仓里建模那套理论已经很成熟了,维度建模、事实表、星型模型,哪一套不能直接用?问题在于,传统模型的前提是数据是“静态”的,至少是按批落地的。你可以等一天的数据完整了再ETL、再建模。但实时链路里数据是持续到达的,天然带乱序、迟到、重复这些问题,你不能再按“先落库再加工”的思路来设计表。

所以我理解中的实时数据模型,至少包含四层:

  • 以事件为核心的事实表设计,记录业务动作本身,而不是业务动作的结果状态;
  • 一套可重放的存储结构,同一份数据既能按流的方式实时消费,又能按批的方式回溯重算;
  • 维度信息的实时保障机制,维表不再每天快照一次,而是跟着业务变更实时更新;
  • 统一的指标定义层,无论实时还是离线,同一指标必须指向同一个计算逻辑。

这四层如果都做到了,那才叫真正意义上的流批一体,引擎在这其中扮演的角色反而弱化了很多。毕竟,理论上你换成Spark、换成Kafka Streams,只要模型层设计没变,结果不会出现实质差异。

2. 流批一体建模的关键思路:事件表加可重放状态

2.1 Lambda架构和Kappa架构的局限性

传统方案绕不开Lambda架构,离线和实时两条链路并行,离线用批处理保证最终准确性,实时用流处理保障低延迟。听起来很完美,但维护过Lambda的人都懂,那套方案至少有三个让人头疼的痛点。

第一是成本。同一套业务逻辑要写两遍,一套离线任务,一套实时任务,每写一个指标都得同步两份代码。第二是口径漂移。离线代码和实时代码是两个人维护的话,改了一个忘了改另一个,时间一长两边算出来的数就对不上了。第三是修复链路复杂。实时任务出了bug,你得先把流停掉,再用离线数据把结果补回来,中间那一大段缺失的数据怎么处理,非常折腾。

Kappa架构就是想解决这些问题,统一用流处理,把历史数据也灌进消息队列里重放一遍。但Kappa的实际落地也很挑场景,长时间窗口的聚合计算,比如“过去30天的累计销售额”,你总不能真的把30天的数据全部扔回流里重放吧。而且数据量大之后,从消息队列里回溯历史数据的效率和成本都不太理想。

所以现在的流批一体建模,基本走的是一条混合路线:事实数据进事件表,用“追加”的方式永远保留原始信息;中间状态和聚合结果单独存储,既可以由流任务增量更新,也可以由批任务全量重算。这就是事件表加可重放状态的设计思路。

2.2 事件表为什么是流批一体的地基

聊到事件表,我先打个比方。你把每一笔业务动作都记录成一行不可变的数据,时间、用户、订单、金额、状态,只追加,不修改,这就是事件表。它特别像流水账,每一笔记录都是一个事实,过去发生了就是发生了,不需要被后续的操作改写。

事件表最大的价值在于“源头统一”。它天然适合流式消费,Kafka里的每条消息落到事件表里就是一行;它也天然适合离线回溯,因为数据没有被覆盖,任何时候你都可以按条件重新计算。传统数仓的事实表其实也是差不多的思路,差异在于,传统事实表往往是按日分区存储的,而实时数仓里的事件表是持续追加、按事件时间而非业务日期来组织的。

我在实际项目里设计事件表时,特别注重三个字段:事件时间、业务主键、幂等ID。事件时间决定了这条数据到底归属到哪个时间窗口,业务主键决定了聚合时的粒度,幂等ID则用来应对重复数据的场景——消息队列做到精确一次交付虽然已经很成熟,但万一传入多个下游系统,重复消费的情况还是时有发生。

事件表一旦建好,流批一体的地基就稳了。无论是实时流任务还是离线批任务,读的都是同一张事件表,天然不会出现源数据不一致的问题。

3. 核心细节落地:一张模型如何同时服务实时与离线

3.1 事实表的分区策略与幂等设计

真正落地的时候,第一步是把事实表分成两层:ODS层的原始事件表,和DWD层的明细事实表。ODS层保留最原始的数据,往往直接对应Kafka topic里的消息结构,字段比较多,不做什么清洗,主要是为了保留一切可能性。DWD层则是做完了清洗、去重、补全维度的明细表,是后续计算的基础。

接下去是一堆让人头疼的细节,我一个个说。

先讲分区。离线任务习惯按日期分区,但实时链路里数据不是按日期整齐到达的,很多凌晨的数据可能下午才补齐。我最后采用的是“事件时间分区加到达时间兜底”的方案。每条消息来了之后,先按事件时间落到对应的分区,但如果事件时间太老或者太未来,就统一丢到一个异常分区里,由后续的校准任务处理。这套方案牺牲了一点查询的便利性,但保证了实时和离线读到的数据是一致的。

再讲幂等。实时链路里重复数据真的是很常见,Kafka重平衡、下游重启、网络超时重传,每一步都可能产生重复消息。我的做法是在DWD层建立唯一键,然后通过去重逻辑保证每条明细只保留一份。而去重的实现,Flink里面可以用状态存储做,离线链路里可以直接基于唯一键做distinct,两边虽然实现方式不同,但逻辑一致,保证了最终数据的一致性。

还有一点,事实表的存储选型也很关键。我通常会用消息队列做事件表的底层存储,再加上一个MPP数据库或者列式存储做查询加速,前者保证流的顺序性和回溯能力,后者保证批的查询性能。两者各有分工,但共用的都是同一套逻辑模型。

3.2 维表实时化:绕不开的硬骨头

模型层里,维表往往是最容易被低估实际做起来又最麻烦的环节。传统离线数仓里,维表就是每天凌晨更新一次的全量快照,查询的时候直接关联就行。但实时链路里数据是持续流入的,比如一个新用户刚注册两分钟就下单了,你指望维表里马上有他的新客标记,离线日切绝对做不到。

我最后的方案是维表双轨制。高频变动的维度信息,比如用户等级、商品状态,走实时维表通道,由业务系统的变更事件驱动更新,存放在支持高并发点查的存储里。低频变动的维度信息,比如商品的基础分类,还是走每日全量快照。查询的时候,先查实时维表,再关联快照维表,保证维度的实时性和完整性。

这里特别容易踩一个坑,就是维度关联的时机问题。订单流入的时候,用户的等级可能已经变了,那这笔订单到底算老客还是新客?这是业务上必须明确的问题。我的经验是,把所有维度值连同关联时的版本时间一并存储在事实表里,这样无论是实时算还是离线算,用的都是同一个版本的维度信息,不会出现因为计算时间不同而导致结果不一致的情况。

3.3 聚合层双链路一致性校验方案

事实表和维表都有了,最后一层就是聚合结果。我的做法是,不直接让实时任务产出最终指标,而是让它产出细粒度的增量结果,存储到一个中间聚合表里。离线任务再来跑一遍同样的逻辑,产出全量的聚合结果。两条链路的数据可以互相校验,实时增量累加的值,和离线全量计算的值,理论上应该完全一致。

校验方案我设计了几个层级,由浅入深:

  • 总量校验:某一天的成交金额,离线结果和实时结果的差值是否在可容忍范围内;
  • 明细抽查:随机抽几个维度组合,对比两条链路的明细计算结果,定位偏差来源;
  • 时间漂移分析:对比事件时间与到达时间之间的差值分布,判断是否存在严重的数据延迟或乱序。

这三层校验跑完之后,我才敢说这套模型是稳的。实际上,我用这套方案上线后,拿实时结果和离线结果对比,偏差率从之前的5%左右直接降到了0.1%以内,剩下的0.1%基本还是数据延迟造成的,并非模型问题。

4. 实操过程记录:从模型设计到双链路跑通的完整流程

4.1 第一步:梳理业务事件,建立统一事件字典

刚开始动手的时候,先别急着建表写代码。我习惯先做一件事:把业务方关心的所有核心动作,全部列出来,每个动作对应一个事件,比如下单、支付、退款、发货。每个事件需要定义清楚几件事:事件ID、业务主键、事件时间、核心业务字段、依赖的维度信息。这个过程整理出来的东西,我叫它“事件字典”。

这个事件字典做的好不好,直接决定后面建模的效率。我踩过最大的坑就是,只列了当前业务要用的十几个事件,结果没过半年,业务方又提了一堆新的分析需求,结果好多事件根本没有沉淀下来,只能重新补。所以我的建议是,宁可前期多花两周时间把所有能想到的业务事件都梳理一遍,也不要图快只覆盖眼前需求。

事件字典梳理完之后,还要做的一步是跟业务确认指标口径。举个例子,“成交用户数”到底怎么定义,是按支付成功去重,还是下单就算,很多人以为这种口径问题很简单,实际对起来才发现各部门口径各不相同。这件事必须前置到建模阶段就达成一致,否则后面每张表都白建了。

4.2 第二步:搭建事件表和存储层

事件字典确认后,就可以开始建事件表了。我通常的做法是,先在消息队列里建对应的topic,topic的key就用业务主键加事件类型,保证同一业务实体的同一类事件可以顺序消费。然后消费端再把数据落到事件表里,事件表存一份原始JSON,同时抽取出核心字段单独建列。

存储层我自己比较偏好列式存储,选型逻辑很简单:实时链路的写入是持续的,列式存储对高并发写入比较友好;离线链路的查询是大范围的,列式存储在扫描性能上也有优势。这里我不详细推荐具体产品,因为每个团队的情况不一样,重点是你在选型前想清楚读写比。

还有个细节我补充一下:事件表里一定要保留一个字段叫“到达时间”,它跟“事件时间”不一样,事件时间是业务实际发生的时间,到达时间是这条消息进入数仓的时间。这两个时间的差值,就是数据延迟。这个字段平时用不上,但排查数据问题时它就是救命稻草。我见过太多团队从一开始就没保留这个字段,后来只看到事件时间有问题,怎么定位都定位不到问题源头。

4.3 第三步:实时与离线两套任务的模型映射

模型建完之后,两边就要开始写任务了。这里我犯过的错误是,同一套业务逻辑,实时写一份,离线写一份,写完发现虽然结果差不多,但逻辑相差很多,维护的时候非常痛苦。

后来我调整了思路:把所有的计算逻辑都抽象成配置,谁都能看得懂的那种。比如订单金额的统计口径,我定义成一份配置,包含过滤条件、聚合维度、时间字段、去重字段。实时任务读配置生成Flink SQL,离线任务读配置生成Spark SQL或者Hive SQL。这样两边逻辑严格同源,再也不用担心口径漂移了。

具体到实现上,实时链路我是从消息队列到Flink做清洗和轻度聚合,结果写入中间层存储;离线链路则是直接从事件表出发,基于同样的配置跑批量计算。两条链路虽然有各自的具体实现,但它们读的是同一份数据,用的是同一份配置,所以结果天然是接近的。

4.4 第四步:一致性校验和问题修正

接下去就是我最看重的校验环节。任务上线前,我强烈建议先做一轮历史数据回放测试,把过去30天的数据灌进实时链路里跑一遍,同时把这30天的数据用离线任务跑一遍,然后对结果做对比。这一步能提前暴露绝大多数一致性问题。

我记得第一次做回放测试时,就发现了一个很有意思的偏差:实时链路里用事件时间做窗口聚合,离线链路里却用了处理时间做分区过滤,导致凌晨几个小时的数据归属到了不同日期。这个靠肉眼根本看不出来,但一对比就原形毕露了。后来我把离线任务的过滤条件也改成事件时间之后,两边结果就对齐了。

所以我把一致性校验视为流批一体项目里最不能省的一步。这不仅仅是上线前的检查,更是日常运维的标配任务。我写的校验逻辑是一天跑一次,一旦发现偏差超出阈值,系统自动告警,然后再根据告警信息定位问题。

5. 实时数据模型落地过程中的疑难杂症

5.1 问题一:实时窗口计算与离线全量计算对不上

这是最常见的坑。实时链路里用滚动窗口算最近1小时的成交金额,离线链路却用T+1全量汇总,两种计算方式天然就存在口径差异。更麻烦的是,窗口边界怎么定义,是整点对齐还是按业务自然日,两边也会有不一致。

解决办法是统一窗口语义。我的做法是,把窗口聚合逻辑收口到一个统一的配置中,里面定义窗口起点、窗口长度、滑动步长等参数,实时和离线都读这个配置。这样窗口定义永远只有一份,杜绝了因为实现不同而导致的偏差。

5.2 问题二:维表更新不及时导致关联失败

实时维表做出来之后,我发现还有另外一个坑:某些高频维表的更新事件和事实数据到达的顺序并不是完全一致的。比如商品先发生了销售事件,但商品改价的事件还没到,导致关联出来的价格是旧价格。

针对这种情况,我最后采用了延迟关联的思路:拿到事实数据之后,不立即关联维表,而是短暂缓存几秒钟,等待维表更新事件到达后再做关联。这个“几秒钟”需要根据实际的数据延迟情况来调整,太长影响实时性,太短又缓存了个寂寞。我调了两轮参数,最终确定在5秒左右比较平衡。

5.3 问题三:流任务重启后的状态恢复与数据补偿

流任务跑着跑着进程崩了,重启之后开始消费新的消息,但重启期间遗漏的数据必须补回来。这里有两种做法,一种是基于消息队列的offset自动恢复,保证消息不丢;另一种是从下游事件表重新读取,做一次重放补偿。

我的建议是两种情况都要考虑。如果任务是无状态的,靠上游消息队列重放就可以;如果任务有状态,比如做了滑动窗口聚合,那么重启后必须先恢复最近一段时间的状态,再把窗口间隙的数据补算一遍。这里的关键是,状态恢复和补算的逻辑也要统一收口,否则一旦不一致,结果又会分叉。

5.4 问题四:数据倾斜引发的实时链路延迟

实时任务的并行度再好,也顶不住某个热点key的数据量激增。我们真实遇到过,某商品做促销的时候,单个热点商品的订单量占了全站的大头,聚合算子直接被钉死,整个链路延迟飙升。

解决思路是把热点key打散。把一个大key拆成多个子key,分散到不同并发实例去算,再做一层最终合并。但这个逻辑实现起来并不简单,而且会对结果准确性造成影响,需要额外处理。我最后的做法是只对可容忍的维度打散,核心指标尽量不做这种优化,宁可缓存做热点保护,也不引入复杂度。

6. 实时数据模型适合什么场景,什么时候别硬上

说了这么多,我最后想泼点冷水:实时数据模型不是万能的,也不是所有场景都值得上。

适合上实时数据模型的场景有几个特征。第一,业务对数据新鲜度有明确需求,不是那种“报表晚半天无所谓”的场景;第二,业务的核心指标本身就依赖于跨多条链路的数据整合,需要实时和离线交叉验证;第三,团队有足够的技术储备,没有理解流批一体本质的人来硬推,大概率会做成一个“能跑但是谁也说不清楚结果正确性”的系统。

不太适合的场景也说说。如果你的核心诉求就是解决计算性能问题,那建模帮不了你,换存储或者加资源可能更直接。如果你只有一条实时链路,没有离线需求,那也不一定要做流批一体,单链路做深度优化更划算。如果业务口径本身三天两头变,你花大力气做的统一模型可能随时作废,这时候反而不如先做个能快速迭代的临时方案。

我在实际项目里最深的体会是,实时数据模型这个事,说到底是逼着你想清楚业务指标到底是什么。看起来很技术,其实最花时间的地方是跟业务对口径。建模的每一步都在逼你明确定义:这个指标用哪个字段、哪个时间、哪个去重规则。这些定义清楚了,模型自然就好了。

这么多年折腾下来,我的建议很简单:别迷信引擎,也别轻视模型。Flink当然很重要,但它只是工具,就算你换成其他引擎,一套设计良好的实时数据模型照样能保证结果的稳定和一致。反过来,模型设计得糊里糊涂,换什么引擎都是白搭。流批一体的核心从来不是某个具体组件的选型,而是你能不能把数据模型这件事想明白、做扎实。

最后再分享一个经验:做这套东西的时候,时刻记得看数据链路里的事件时间和到达时间的差值。这个差值才是你系统健康的真实体温计,一旦这个值开始异常增长,别管引擎和模型理论多么完美,一定是有哪里出了问题。先把这条时间线的偏差找出来,再谈其余。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询