流批一体不只有Flink,实时数据模型才是真正的分水岭
聊流批一体的时候我发现大多数人第一反应都是Flink引擎层确实被它统一了不少但真正让项目从“能跑”到“能持续稳定交付”的反而是下游那一层经常被忽略的东西——数据模型。我去年接手一个实时数仓项目Flink SQL写得再顺一到指标口径对比就翻车离线算的和实时算的永远差那么几个数。折腾了快两个月才想明白问题根本不在计算引擎而在于我一直在用离线建模的思路去设计实时链路。标题里那句“流批一体不只有Flink还有实时数据模型”本质上就是在提醒一件事引擎合并只是第一步模型层的统一才是流批一体的真正分水岭。1. 为什么说流批一体不只是一个引擎问题1.1 引擎统一和模型统一是两回事先聊一个我自己的真实经历。刚接触流批一体的时候我一度以为把实时任务和离线任务都跑在Flink上就完事了毕竟引擎统一了代码也能复用怎么看都像是“一体”了。但真跑起来之后发现离线和实时任务虽然都叫“计算订单金额”口径却经常对不上。原因在哪儿引擎只是负责“怎么算”模型负责“算什么”。你把计算引擎统一了但如果两套任务用的是不同的表结构、不同的粒度定义、不同的状态管理方式算出来的结果天生就不一样。比如离线任务习惯用T1分区每天凌晨重跑全量实时任务用事件流每条订单自然落到时间窗口里。两边读同一张业务表最后汇总出来的金额却可能因为时区、去重逻辑、事实表粒度这些细节产生偏差。这种偏差不是引擎能解决的必须从模型层下手。我一直觉得流批一体真正的难点不在中间那层计算引擎而在最上面那层数据模型。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 第二步搭建事件表和存储层事件字典确认后就可以开始建事件表了。我通常的做法是先在消息队列里建对应的topictopic的key就用业务主键加事件类型保证同一业务实体的同一类事件可以顺序消费。然后消费端再把数据落到事件表里事件表存一份原始JSON同时抽取出核心字段单独建列。存储层我自己比较偏好列式存储选型逻辑很简单实时链路的写入是持续的列式存储对高并发写入比较友好离线链路的查询是大范围的列式存储在扫描性能上也有优势。这里我不详细推荐具体产品因为每个团队的情况不一样重点是你在选型前想清楚读写比。还有个细节我补充一下事件表里一定要保留一个字段叫“到达时间”它跟“事件时间”不一样事件时间是业务实际发生的时间到达时间是这条消息进入数仓的时间。这两个时间的差值就是数据延迟。这个字段平时用不上但排查数据问题时它就是救命稻草。我见过太多团队从一开始就没保留这个字段后来只看到事件时间有问题怎么定位都定位不到问题源头。4.3 第三步实时与离线两套任务的模型映射模型建完之后两边就要开始写任务了。这里我犯过的错误是同一套业务逻辑实时写一份离线写一份写完发现虽然结果差不多但逻辑相差很多维护的时候非常痛苦。后来我调整了思路把所有的计算逻辑都抽象成配置谁都能看得懂的那种。比如订单金额的统计口径我定义成一份配置包含过滤条件、聚合维度、时间字段、去重字段。实时任务读配置生成Flink SQL离线任务读配置生成Spark SQL或者Hive SQL。这样两边逻辑严格同源再也不用担心口径漂移了。具体到实现上实时链路我是从消息队列到Flink做清洗和轻度聚合结果写入中间层存储离线链路则是直接从事件表出发基于同样的配置跑批量计算。两条链路虽然有各自的具体实现但它们读的是同一份数据用的是同一份配置所以结果天然是接近的。4.4 第四步一致性校验和问题修正接下去就是我最看重的校验环节。任务上线前我强烈建议先做一轮历史数据回放测试把过去30天的数据灌进实时链路里跑一遍同时把这30天的数据用离线任务跑一遍然后对结果做对比。这一步能提前暴露绝大多数一致性问题。我记得第一次做回放测试时就发现了一个很有意思的偏差实时链路里用事件时间做窗口聚合离线链路里却用了处理时间做分区过滤导致凌晨几个小时的数据归属到了不同日期。这个靠肉眼根本看不出来但一对比就原形毕露了。后来我把离线任务的过滤条件也改成事件时间之后两边结果就对齐了。所以我把一致性校验视为流批一体项目里最不能省的一步。这不仅仅是上线前的检查更是日常运维的标配任务。我写的校验逻辑是一天跑一次一旦发现偏差超出阈值系统自动告警然后再根据告警信息定位问题。5. 实时数据模型落地过程中的疑难杂症5.1 问题一实时窗口计算与离线全量计算对不上这是最常见的坑。实时链路里用滚动窗口算最近1小时的成交金额离线链路却用T1全量汇总两种计算方式天然就存在口径差异。更麻烦的是窗口边界怎么定义是整点对齐还是按业务自然日两边也会有不一致。解决办法是统一窗口语义。我的做法是把窗口聚合逻辑收口到一个统一的配置中里面定义窗口起点、窗口长度、滑动步长等参数实时和离线都读这个配置。这样窗口定义永远只有一份杜绝了因为实现不同而导致的偏差。5.2 问题二维表更新不及时导致关联失败实时维表做出来之后我发现还有另外一个坑某些高频维表的更新事件和事实数据到达的顺序并不是完全一致的。比如商品先发生了销售事件但商品改价的事件还没到导致关联出来的价格是旧价格。针对这种情况我最后采用了延迟关联的思路拿到事实数据之后不立即关联维表而是短暂缓存几秒钟等待维表更新事件到达后再做关联。这个“几秒钟”需要根据实际的数据延迟情况来调整太长影响实时性太短又缓存了个寂寞。我调了两轮参数最终确定在5秒左右比较平衡。5.3 问题三流任务重启后的状态恢复与数据补偿流任务跑着跑着进程崩了重启之后开始消费新的消息但重启期间遗漏的数据必须补回来。这里有两种做法一种是基于消息队列的offset自动恢复保证消息不丢另一种是从下游事件表重新读取做一次重放补偿。我的建议是两种情况都要考虑。如果任务是无状态的靠上游消息队列重放就可以如果任务有状态比如做了滑动窗口聚合那么重启后必须先恢复最近一段时间的状态再把窗口间隙的数据补算一遍。这里的关键是状态恢复和补算的逻辑也要统一收口否则一旦不一致结果又会分叉。5.4 问题四数据倾斜引发的实时链路延迟实时任务的并行度再好也顶不住某个热点key的数据量激增。我们真实遇到过某商品做促销的时候单个热点商品的订单量占了全站的大头聚合算子直接被钉死整个链路延迟飙升。解决思路是把热点key打散。把一个大key拆成多个子key分散到不同并发实例去算再做一层最终合并。但这个逻辑实现起来并不简单而且会对结果准确性造成影响需要额外处理。我最后的做法是只对可容忍的维度打散核心指标尽量不做这种优化宁可缓存做热点保护也不引入复杂度。6. 实时数据模型适合什么场景什么时候别硬上说了这么多我最后想泼点冷水实时数据模型不是万能的也不是所有场景都值得上。适合上实时数据模型的场景有几个特征。第一业务对数据新鲜度有明确需求不是那种“报表晚半天无所谓”的场景第二业务的核心指标本身就依赖于跨多条链路的数据整合需要实时和离线交叉验证第三团队有足够的技术储备没有理解流批一体本质的人来硬推大概率会做成一个“能跑但是谁也说不清楚结果正确性”的系统。不太适合的场景也说说。如果你的核心诉求就是解决计算性能问题那建模帮不了你换存储或者加资源可能更直接。如果你只有一条实时链路没有离线需求那也不一定要做流批一体单链路做深度优化更划算。如果业务口径本身三天两头变你花大力气做的统一模型可能随时作废这时候反而不如先做个能快速迭代的临时方案。我在实际项目里最深的体会是实时数据模型这个事说到底是逼着你想清楚业务指标到底是什么。看起来很技术其实最花时间的地方是跟业务对口径。建模的每一步都在逼你明确定义这个指标用哪个字段、哪个时间、哪个去重规则。这些定义清楚了模型自然就好了。这么多年折腾下来我的建议很简单别迷信引擎也别轻视模型。Flink当然很重要但它只是工具就算你换成其他引擎一套设计良好的实时数据模型照样能保证结果的稳定和一致。反过来模型设计得糊里糊涂换什么引擎都是白搭。流批一体的核心从来不是某个具体组件的选型而是你能不能把数据模型这件事想明白、做扎实。最后再分享一个经验做这套东西的时候时刻记得看数据链路里的事件时间和到达时间的差值。这个差值才是你系统健康的真实体温计一旦这个值开始异常增长别管引擎和模型理论多么完美一定是有哪里出了问题。先把这条时间线的偏差找出来再谈其余。