☰
实时数据模型:流批一体不只是Flink,模型才是关键
2026/10/22 4:08:47 网站建设 项目流程

做了几年实时数仓,一个很深的感触是:“流批一体”这四个字,被Flink的光芒盖得太久了。一提到流批一体,大家本能地想到Flink,想到Flink SQL,想到状态、窗口、检查点。但我踩过的坑告诉我,选型Flink只是解决了“用什么算”的问题,真正决定流批一体能不能跑起来的,是“数据怎么组织”的问题,也就是实时数据模型。

前两年我所在的某开发团队上马一个实时数仓改造项目。当时大家信心很足,认为把离线Hive SQL改写成Flink SQL,跑在Kafka上就算完成流批一体了。结果三个月后,核心看板数据对不上账:凌晨离线批任务算出的日活,和实时看板差了12%。一线业务拿着两张报表来质问,我们排查了整整一周——问题不在Flink,不在Kafka,更不在SQL语法。真正的根子,出在我们的数据模型还是老一套离线建模思路,根本没有为流式语义做设计。

这篇文章我不打算讲具体产品的配置手册,而是想围绕“实时数据模型”把流批一体这件事聊透:为什么光有Flink远远不够,实时数据模型和离线模型在本质上有什么区别,实际建模时要遵循哪些原则,以及我在真实项目里踩过的坑。

1. 当大家都在聊Flink的时候,流批一体还缺什么

1.1 引擎解决“算得快”,模型解决“算得对”

流批一体这个概念被讨论了很多年,市面上大多数文章的重点都在计算引擎上。Flink确实做得好——有状态计算、精确一次语义、窗口机制完善、生态丰富。于是很多团队形成一种路径依赖:想要流批一体,就上Flink。

但这里头有个很容易被忽略的事实:Flink只是一个执行引擎,它负责“算得快”,不负责“算得对”。数据模型才是决定“算得对不对”的关键。同样的Flink SQL,同样的数据源,用不同的方式建模,跑出来的结果语义完全不一样。举个最简单的例子:一条订单事件流,如果你只把它当成append-only的日志流去处理,那么去重、更新、回撤这些操作就需要在引擎里额外实现;但如果你一开始就把它建模成主键表(upsert model),那引擎处理的就是“实体的最新状态”,语义完全不同,后续的指标计算、维度关联和重放逻辑也更清晰。

换句话说,引擎的确定性再强,也架不住模型层面的语义混乱。数据模型定义了数据的来源、形态、时间语义和更新方式,这些才是流批一体能否“变对”的根基。

1.2 流批一体的完整拼图:引擎、存储、模型、元数据

我后来把流批一体的技术栈拆成四层看:

层次解决的问题典型组件
统一计算层流与批任务用同一套SQL/算子逻辑Flink、Spark Structured Streaming
统一存储层一张表同时供流写批读,或批写流读Hudi、Iceberg、Delta、Doris主键模型
统一模型层抽象出事件流、主键表、快照表三类基本形态Kafka Streams的KTable/KSQL、各种湖表格式
统一元数据与管理层Schema、分区、血缘、口径统一管理Hive Metastore、数据目录服务、Flink Catalog

很多文章只讲第一层和第四层,好像“用Flink + 元数据统一”就等于流批一体。但项目做到后期你会发现,存储层和模型层才是决定性能和口径的关键。计算引擎可以随时换,数据模型一旦定错,后面所有下游都要跟着改。前期的需求调研、Schema设计、主键设计、时间语义定义,往往比后期调优更重要——这也是为什么我坚持认为“实时数据模型”才是流批一体的隐藏主角。

2. 实时数据模型并不是把离线表“搬到流上”

2.1 时间语境不同:T+1快照 vs 连续变化

离线数仓的数据模型,天然是围绕“分区”和“快照”设计的。一张订单事实表,按天分区,每天凌晨跑一个批任务把昨天的数据算完,写入新的分区。这是典型的T+1模型——数据是对“一个时间切片”的静态描述。

实时数据模型完全不同。实时链路里没有“今天这个分区”的概念,只有一条不断流动的事件流,每个事件都有自己的事件时间(event time)和处理时间(processing time)。数据不是在某个固定时间点“完成”的,而是处于持续变化中:上一秒可能还没有这笔订单,下一秒订单来了,再过一秒订单又被取消。

这就带来一个关键转变:离线模型的单位是“表分区”,实时模型的单位是“事件与状态”。如果你把离线模型直接搬到流上,还是用“每天一个批次”的思路去思考,那实时数据的价值根本没有被释放——你要的不是T+1的一份快照,而是任意时刻都能看到一个口径一致、延迟秒级的最新状态。

2.2 建模对象不同:事实+维度 → 事件+状态+物化视图

离线数据建模的主流方法论是维度建模:事实表、维度表、星型模型、雪花模型。这张订单事实表和商品维度表在离线环境里怎么关联,大家都已经很熟了——每天调度一次JOIN,生成一张宽表,供报表查询。

但实时环境下,所有表都是流动的。维度表本身也是流:商品改名了、用户升级了,这些都是源源不断的事件。事实流和维度流两条流的JOIN,不再是每天跑一次离线任务,而是一个持续运行的流式计算。这个时候,数据模型的基本对象变成了三个:

  • 事件流(Stream):只追加、不可变,比如订单创建事件、商品变更事件。
  • 状态表(State/Table):按主键记录“最新状态”,比如当前所有订单的实时状态、当前商品维度的最新快照。
  • 物化视图(Materialized View):持续更新、实时计算结果,比如每分钟的GMV、各品类的实时销量。

如果从一开始就按照“事件流+状态表+物化视图”这三个抽象去建模,你会发现流批一体变得清澈了许多。相反,如果心里只有“事实表+维度表”,流批两套链路的口径永远会在某个角落里分叉。

2.3 一个直观例子:从订单事实表到实时订单宽表

拿订单域举例。离线侧,建一张订单事实表,一张商品维度表,每天跑一次任务,用主键JOIN生成一张订单宽表,存进数仓供报表查询。这个流程没什么问题。

实时侧,如果沿用这张离线订单宽表的Schema去“照搬”,你就会很痛苦:订单表是可变的,从下单到支付、到发货、到取消,状态一直在更新;你没法像离线那样每天全量覆盖,因为宽表里的数据每秒钟都在变。这时候,正确的建模方式应该是:

  • 顶层建一条订单事件流(order_events),保留所有原始变更事件。
  • 基于订单主键构建一个订单状态表(order_state),用最后一条事件覆盖前一条,记录订单的实时最新状态——这其实就是Flink里的Keyed State或Doris主键模型能够实现的东西。
  • 在此基础上定义“实时订单宽表”为物化视图,关联商品维表的最新快照,做到秒级刷新。

这样一来,实时模型和离线模型在语义上是同源的——订单宽表既能从离线分区里读出来,也能从实时链路里物化出来。两者在口径上天然对齐,不再需要两套代码反复对数字。

3. 实时数据模型的设计核心:三种基础形态

3.1 事件流模型:只追加、可回放

事件流模型是最接近“事实发生”的模型。它本质上是一条不可变的消息序列,每条消息对应一个真实发生的事件,只能追加,不能修改和删除。日志、行为轨迹、交易事件,都适合建造成这种模型。

设计事件流模型时,有三个点容易忽略。一是分区键的选择:分区键决定了事件的顺序保证以及下游并行处理的粒度。比如订单事件用order_id做分区键,某个订单的所有变更事件就会被路由到同一个分区,下游按序处理;如果你用user_id做分区键,同一订单的事件可能分散在不同分区,处理时会很麻烦。二是时间字段的保留:事件流里至少要保留两个时间,一个是事件发生时间(业务语义),一个是消息入流时间(系统语义),千万不能只留一个。三是Schema的兼容性:事件流一旦上线,新旧版本的字段变更会持续发生,所以序列化格式最好选择带Schema演进能力的方案,如Avro或Protobuf。

事件流模型有一个天然优势——可回放。因为消息不删不改,你可以随时把某段历史事件重新消费一遍,用来重算指标或者修复bug。这是离线模型很难做到的,也是流批一体里“批”部分能复用流逻辑的基础。

3.2 主键表模型:用状态承载“最新事实”

主键表模型,也叫Upsert模型,是实时数据模型里最重要的一个形态。它解决的问题很简单:同一个业务实体(订单、用户、商品)在事件流里会留下多条记录,但我们希望拿到的通常是它“当前的最新状态”。于是基于主键去覆盖旧值,形成一张逻辑上的可变表。

主键表模型的落地方式有好几种。在Flink内部,可以用Keyed State实现;在存储层,可以依赖Hudi、Iceberg、Delta的upsert能力;在OLAP引擎里,Doris的主键模型和ClickHouse的ReplacingMergeTree也能实现类似的覆盖语义。不同实现的差别主要在:覆盖是同步还是异步、是否支持版本号、是否支持删除标记。建模时要结合存储的特性选取合适方案。

主键表模型的设计,关键在主键的定义上。主键粒度决定了状态的大小,也决定了重放的语义。比如你要算“每个用户每天的下单金额”,主键如果定成user_id + order_id,那么同一用户同一天有多笔订单时,每笔订单都保留一条记录,状态膨胀很快;如果主键定成user_id + day,那么同一用户同一天只保留一条聚合记录,状态小得多,但代价是你放弃了明细级的实时更新能力。这个取舍没有标准答案,得看你到底要服务哪些查询。

3.3 快照模型:主键表的“时间切片”

快照模型本质上是主键表在某个时间点上的切片。离线数仓里的分区表,本质上就是一种快照模型——每天一个分区,保存当天的数据形态。实时场景里,快照模型通常由主键表演化而来,借助存储引擎的时间旅行(Time Travel)能力,可以查询任意历史时刻的表状态。

我建议把这三类模型看成可以互相转换的统一体系:

  • 事件流按主键做覆盖,就得到主键表。
  • 主键表在某个时间点取快照,就得到快照表。
  • 快照表与后续的事件流再做合并,又可以重新回到新的主键表。

理解了这个转换关系,你才能真正理解为什么“流批一体”不是把流和批拼在一起,而是同一套数据在不同时间维度上的不同表现形态。批是流的快照,流是批的增量。

4. 实时数仓分层里,数据模型如何落实流批一体

4.1 ODS层:Topic即表,但要管好Schema与生命周期

实时数仓的ODS层,很多人觉得就是一堆Kafka Topic,谈不上建模。但这个认知会在项目中期给你惹来麻烦。Topic不是临时的消息管道,而是和Hive表一样的数据资产,需要明确的Schema、分区策略、生命周期和数据质量规则。

在ODS层建模时,我会坚持三个原则:第一,不做过度的清洗和过滤,保留原始事件的完整字段,宁可让下游各取所需,不要在上游自作主张丢字段;第二,必须保留元数据字段,比如事件产生时间、事件入库时间、事件对应的业务主键、事件类型,这四个字段几乎每个下游任务都会用到;第三,Topic的粒度按业务域而不是按报表划分,比如订单域一个Topic、用户域一个Topic,不要因为某个报表需要一个特定事件,就单独开一个Topic。

ODS层的Schema演进也要重点设计。业务方改接口是常态,字段新增、字段类型调整、枚举值扩展,这些变化如果处理不好,下游所有实时任务都可能挂掉。我们当时的做法是:统一用Avro Schema,在大目录服务里注册版本,所有消费端通过Schema Registry拉取。这样上游改字段,下游能感知到兼容性变化,而不是流里突然冒出奇怪数据。

4.2 DWD层:维度退化的时机,决定实时任务复杂度

DWD层是实时数仓建模最复杂的一层,因为它要做清洗、过滤、维表关联和结构化。在这个层面,最大的建模决策是:什么时候做维度关联。

离线的DWD层,JOIN维表是再普通不过的操作。但在实时链路里,JOIN的成本和复杂度完全不同——维表数据也在流式更新,两个流的JOIN不仅需要状态管理,还要处理迟到数据、维表延迟更新等原因导致的关联不上问题。所以实时DWD层建模,我更倾向于采用****“先落维表快照,再退化维度”****的思路。

具体来说:商品、用户这类维度变化频率不高,通过CDC管道同步到实时存储(如Doris或Hudi),形成一个维表快照。然后DWD层的明细事件流在处理时,用主键去这个维表快照里做普通查询关联,把需要的维度字段退化到明细数据里。这样事件流在后续流转过程中,不再需要频繁与其他流JOIN,计算链路会简单很多,也能减少一类非常难排查的“关联不上导致丢数据”问题。

4.3 DWS与ADS层:让聚合结果本身成为可查询的数据模型

DWS层做轻度聚合,ADS层做应用级数据。这两层的建模,最容易被忽视的一点是:聚合结果不是“一次性计算”,而是另一种数据模型。

实时计算里,窗口聚合的结果天然是一条新的数据流:基于TUMBLE窗口的每分钟GMV,基于HOP窗口的滚动成交额,这些结果流既可以被下游继续计算,也可以物化到存储引擎里供查询。如果在建模时不明确“聚合窗口的类型、聚合粒度、输出主键”,就会出现一个尴尬局面——报表上同样的指标,在Flink里算一遍,在OLAP引擎里又算一遍,两边窗口口径对不上。

我在项目里的建议是:DWS层输出的每一张聚合表,都要明确两件事。一是主键,比如“业务日期 + 统计维度 + 粒度”的组合;二是窗口语义,是滑动窗口、滚动窗口还是会话窗口,窗口的延迟边界是多少。把这些写进数据模型的定义里,下游BI查询才不会被误导。

4.4 一张表两个生命周期:湖仓表格式带来的物理统一

前面讲的更多是逻辑层面的统一。物理层面,Hudi、Iceberg、Delta这类湖仓表格式提供了一个非常关键的底座:支持流式写入的upsert,同时保留快照读和增量读能力。

这就让同一张物理表,既能被实时任务以流式方式写入、更新,又能被离线批任务以快照方式读取、分析。建模上,事件流对应的主键表可以直接落到这种表格式上;批和流不再是两套存储,而是一张表上的两个生命周期:流读增量,批读快照。这也是我认为“流批一体”在存储层最落地的一次实践。当然湖表格式在实时性上离秒级还有一些距离,这个要看具体场景取舍,但不能因此否定它在架构层面的价值。

5. 从真实项目里挖出来的建模坑

5.1 迟到的数据和混乱的事件时间

实时建模里,最经典的问题就是乱序和迟到。我们曾经做一个转化漏斗分析,事件时间是基于客户端埋点时间。结果发现,部分用户设备的时间戳不准确,有的快了几分钟,有的慢了几小时,导致水位线排得非常不稳定,漏斗数据频繁跳动。

排查到最后,我们调整了事件时间的定义:业务时间优先级大于客户端时间——客户端时间只作为兜底,优先采用服务端记录的时间。同时在水位线策略上留了足够的延迟边界,并额外设计了迟到数据补偿任务,每天凌晨用离线数据校验前一天的实时结果。从模型角度看,这些经验和技巧核心是:时间语义必须定义到字段级别,并且在实时模型里给“迟到数据”设计一条补偿路径,否则再强的计算引擎也保证不了数据质量。

5.2 upsert状态膨胀,主键粒度不是越细越好

主键表模型用得很顺手之后,我们踩了另一个坑:主键粒度设计得过细,导致状态无限膨胀。比如某些指标做了“用户+订单+商品+事件类型”这么细的主键,结果Flink的State越来越大,Checkpoint频繁超时,整个作业延迟上升。

后来我总结了主键设计的两个原则:一是主键粒度与查询粒度对齐——你要查的是用户级,主键就别下沉到订单级;二是明确状态TTL——不是所有状态都需要永久保留,超过业务窗口的历史状态,该回收就回收。做实时建模,状态的大小和管控必须从一开始就想好,否则后面再优化,伤筋动骨。

5.3 维度频率变更带来的指标漂移

还有一类坑,只在实时数仓里会遇到——维度表高频变更导致历史指标“被覆盖”。离线模型里,每天生成完快照,历史事实不会因为维度表更新而改变;但实时模型里,输出的是“当前最新状态”,商品改个类目、用户升个等级,历史订单的关联维度就会被覆盖,于是“昨天的GMV”可能因为今天的维度变更而变了。

这个问题没有完美答案,只能在建模层面做一个取舍约定:要么接受“实时口径=当前维度”,要么用“版本的维度快照”去冻结历史。我们的做法,是对核心财务指标使用维度快照关联——Kafka里同时保留商品维度变更事件流,下游计算时按订单事件发生时点去匹配当时的维度快照,不跟随维度变化而漂移。这当然会增加状态成本,但口径稳定性的收益远大于成本。

6. 流批一体最终要走到哪:让模型工程化,而不是让引擎万能化

6.1 从应用场景反推模型,而不是从引擎反推模型

跟很多团队聊实时数据架构,我发现一个通病:大家会先选Flink,然后再想“Flink能做什么”。这个顺序是反的。正确的顺序应该是:先梳理业务场景需要哪些实时指标、哪些明细查询、哪些告警逻辑,然后反推出这些场景背后需要哪些数据模型——是事件流、主键表,还是快照表。

举个例子,做实时大屏和做实时风控,背后需要的数据模型就很不一样。前者需要低延迟的聚合宽表,适合主键表模型直接落到OLAP引擎;后者需要完整的原始事件上下文,更适合保留明细事件流。如果一开始不把这些场景差异理清楚,统一用同一套模型硬套,最终还是得拆了重做。

6.2 模型先行,引擎随从

回到开篇那句话:流批一体不只有Flink,还有实时数据模型。经过前面几个项目的实践,我越来越确信,数据模型才是流批一体真正的骨架,引擎只是在骨架之上扩充血肉的工具。模型定义清楚之后,迁移引擎、替换组件、扩展场景都变成可控的工作量;模型糊里糊涂,换再强的引擎也只是把错误的结果算得更快。

我现在做新的实时项目,第一件事不是画架构图,而是拉上业务和数据团队,先把核心实体的事件流、主键表、快照表、物化视图定义清楚,把时间语义和主键粒度敲定,再谈选型和开发。这个流程多出来的时间,往往后面会在故障排查和数据对账里成倍省回来。

最后分享一点个人体会:如果你所在团队正准备做流批一体,不要被市面上“Flink万能”的声音带偏。花至少三分之一的前期时间在数据建模上,把你的事实、维度、事件、状态、快照梳理清楚,定义好时间语义和主键策略。这套功夫做扎实,流批一体的路会比你想象中平稳得多。

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

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

立即咨询