聊起流批一体,大多数人第一反应是Flink。这没毛病,Flink在计算引擎这一层确实把批和流的SDK、运行时、状态管理揉在了一起,确实能让你用一套代码跑两种模式。但真正在业务上落过地的人都会有一个共同感受:引擎统一了,数据并没有统一。离线链路凌晨跑批,实时链路秒级出数,两边各建各的表,各写各的SQL,字段名看着差不多,口径却对不上。遇到过太多次“实时比离线多0.3%,谁都没错但就是不一样”的鬼故事。把问题从计算层往下挖,最后才会发现,真正卡脖子的地方不在Flink,而在数据模型——你拿什么结构存数据、定义主键、管理时间、保证幂等,这决定了流和批到底能不能共享同一张表、同一套口径、同一个血缘。
这就是今天要聊的“实时数据模型”。它不是某个新中间件,也不是一套新框架,而是一组面向流批双场景设计的数据建模规范,贯穿ODS、DWD、DWS每一层。这篇文章会把我们在实时数仓项目里的建模思路、设计细节、落地步骤和踩过的坑,按实操顺序完整写一遍,适合数仓工程师、实时开发、数据架构师参考。如果你正被批流对账折磨,建议从第二部分的模型设计看起,大概率能帮你少走一个月的弯路。
1. 流批一体为什么卡在模型这一层
1.1 引擎统一只是上半场
很多团队对标“流批一体”时,把宝全押在计算引擎上。引擎确实解决了“同一种逻辑,两种执行模式”的问题:你可以用一套SQL在批模式跑历史全量,在流模式跑增量订阅;状态后端也能同时服务微批和流式。但计算过程统一了,计算结果并没有统一。下游的数据仓库、报表、指标体系,大家依然在用两套物理表:一套T+1更新的离线表,一套秒级刷新的实时表。
我见过一个交易数据场景:离线订单宽表按天分区,每天凌晨全量覆盖;实时订单表是一张Kafka消息流,只保留当天数据且按状态变更重复输出。两张表字段大部分同名,但“订单状态”在离线表是一个字符串枚举,在实时流里是状态变更事件,需要自己聚合才能拿到最新状态。读数的人要么找离线开发,要么找实时开发,两边解释完口径之后,业务还是觉得“你们数据是不是有问题”。
这个例子说明,引擎只是把车造得更快,数据模型才是底盘。底盘不对,马力越大,跑偏越远。
1.2 两套模型带来的三种撕裂
把离线表和实时流分两套模型维护,至少会遇到三种层面的撕裂。
第一是口径撕裂。口径撕裂最常见。离线一小时窗口,通常习惯按事件时间取整,比如date_format(event_time,'yyyyMMddHH');实时窗口却容易按处理时间收口。两边差半小时,GMV可能差0.5%,看着不大,但对账就是过不去。第二是存储撕裂。离线数据沉在Hive或湖存储,实时数据趴在消息队列和状态里,血缘断成两截,溯源时根本不知道这张实时表是从哪个离线模型变出来的。第三是治理撕裂。指标字典两份,权限体系两套,离线开发和实时开发各改各的,改动没有联动,最终口径漂移。
这三种撕裂加在一起,会让团队的日常变成“天天对账、周周解释、月月返工”。计算引擎再强,解决不了这些问题。
1.3 实时数据模型到底指什么
我的定义是:实时数据模型,是同时能被批式任务和流式任务读写、回放、合并的数据结构规范。它不是某一类具体表,而是一整套约束,包括主键策略、时间语义、更新方式、幂等保障和元数据管理。
判断一个模型合不合格,我有一个很硬的标准:把喂给实时任务的那份数据,换成重新读一次历史,能不能重放出一模一样的表。如果做不到,所谓的流批一体就是伪命题。能做到,离线任务和实时任务才能在同一张表上做各自擅长的事情,并且任何时候对账都有据可查。
2. 实时数据模型核心设计要素
2.1 主键是流表真正的身份锚点
离线表的主键更多是用来去重、关联,但流式表的主键直接参与状态计算、时间窗口和upsert,是状态识别和结果更新的核心。我见过很多实时任务出乱子,根源都是主键没设计好。
主键设计有三个原则。第一,优先用业务天然主键,比如订单ID、用户ID加行为时间。第二,状态流必须带版本字段,用order_id + update_time或者order_id + version组合,否则同一个订单多次状态变更,流式更新会直接覆盖成错的数据。第三,主键必须支撑端到端幂等,否则任务从checkpoint恢复后重放,会产生脏数据。
举一个例子,一张订单状态表:
CREATE TABLE dwd_order_status_real_time ( order_id STRING, -- 业务主键 order_version BIGINT, -- 状态变更版本,从1递增 old_status STRING, new_status STRING, event_time STRING, -- 业务发生时间,统一UTC etl_time STRING, -- 数据写入时间 write_seq BIGINT, -- 写入序号,用于幂等去重 PRIMARY KEY (order_id, order_version) NOT ENFORCED )注意消息表和状态表的主键,作用并不一样。消息表只追加,主键用于去重;状态表靠主键做upsert。建模时把这两种语义分开,批处理读状态表的“最新一条”,流处理实时更新同一条,两边读到的就是同一份数据。
2.2 时间语义统一是口径一致的前提
流批对不上账,八成问题出在时间语义。建模阶段必须把时间语义统一收口,否则后面全是扯皮。
我的做法是两条硬性要求。第一,所有业务表强制有event_time字段,表示业务发生时间;所有加工表强制有etl_time字段,表示任务写入时间,两者并存,谁也不许少。第二,计算窗口只认一个“时间桶”表达式,例如DATE_FORMAT(event_time, 'yyyyMMddHH'),离线的分区字段和实时窗口的收口时间都使用这个表达式,不各写各的。
离线表分区字段dt通常写成yyyyMMdd,如果实时聚合也想对齐,建议把离线分区的粒度细化到小时,或者至少保留一个小时级时间桶字段。跨时区问题也要处理,event_time统一存UTC时间戳,展示层再转本地时间。时间桶没有统一而导致的“差一个窗口”“差半小时”问题,是实时数仓里最常见的疑难杂症,核心就是这里没做对齐。
2.3 表形态怎么选:消息、快照、拉链
实时数据模型落地时,第一步就是决定每张表用什么形态。我一般把表分成三种:消息表、快照表、拉链表。
| 表形态 | 写入方式 | 语义 | 流批读取方式 | 适合场景 |
|---|---|---|---|---|
| 消息表 | insert only | 追加流水 | 流读topic,批读分区文件 | 日志、行为流水、业务明细 |
| 快照表 | upsert | 当前最新状态 | 流读变更流,批读最新快照 | 订单状态、用户画像、库存 |
| 拉链表 | upsert加版本 | 历史变化轨迹 | 流读变更,批读任一有效时间 | 维表、状态变化分析 |
在实际项目里,不建议一上来就全用拉链表,拉链表在数据量大的时候,存储和回溯成本都非常高。大多数场景用“消息表+快照表”的组合就够了。只有维表这种需要回看历史版本的场景,才优先考虑拉链表。表形态定了,流批两边读数据的方式就跟着定,这就是模型约束的力量。
2.4 幂等与回放:可重算是模型的及格线
回放测试是检验实时数据模型是否合格的硬指标。把同一段历史输入重新灌进去,输出的表必须和第一次跑出来的一致。如果做不到,任何对账都是无底洞。
要做到可回放,必须保证三个点。第一,写入幂等,用主键加write_seq做去重,重复写入不会产生脏数据。第二,覆盖语义,用最新版本覆盖旧版本,而不是先删后插;基于流式sink,这通常意味着要支持主键upsert。第三,状态可重置,实时任务重启时能回到某个checkpoint,或者能从批式初始化数据重新开始。
在项目启动的时候就把这三个点写进建模规范里,后续所有表评审都用这个标准卡一遍。这里有一个建议,每个模型上线前,强制做一次“回放验证”,把消息队列里的历史数据重新跑一遍,对比结果。没有这步,后面每一个数据问题都会很难查。
3. 分层落地:一套模型跑通实时数仓
3.1 ODS层:MQ和湖存储共享同一套Schema
ODS层是整个数据链路的地基。很多团队在ODS就把实时和离线分开了,Kafka一套topic,Hive一套表,源头就分叉。这是最大的坑。
ODS层我的做法是:消息队列和湖存储共享同一套Schema。所有源系统接入之前,先把Schema注册到统一中心,Kafka的topic和湖存储的物理表使用同一份DDL,字段顺序、类型、注释都完全一致。这样,topic里的最新数据和湖里的历史分区,看起来就是一张表的不同时间切片。
公共字段必须预留在Schema里:event_time、etl_time、src_system、trace_id。无论业务数据长什么样,这四个字段都要填。src_system标记来源系统,后面做血缘和权限都靠它;trace_id用于全链路追踪和问题定位,排查实时问题时没有它会非常痛苦。
ODS层清洗规则能少则少,基本只做格式校验和公共字段填充。你要相信,在源头多保留一分信息,后面模型就能多一分灵活性。
3.2 DWD层:明细模型同时服务流和批
DWD层是实时数据模型价值最集中的一层。这里有两个核心习惯,对后面所有指标查询都有深远影响。
第一,事实流和状态流分开建模。事实流保存每次事件,是append语义,比如订单事件表,每次状态变化都留一条记录;状态流保存主键最新状态,是upsert语义,比如订单状态表,同一订单只有一条最新记录。两边的公共字段完全一致,流任务消费事实流实时更新状态流,批任务读事实流重放历史、读状态流拿当前快照。第二,能退化维度就退化维度。把用户ID、城市、类目等常用维度字段直接冗余进事实表,避免实时join维表。实时任务里每一次join,都意味着状态成本和延迟风险,建模阶段能把维度退化掉,后面能省很多事。
一个实时明细宽表的DDL示例:
CREATE TABLE dwd_order_detail_real_time ( order_id STRING, user_id STRING, item_sku_id STRING, order_amount DECIMAL(12,2), order_status STRING, province_id STRING, category_id STRING, event_time STRING, etl_time STRING, write_seq BIGINT, PRIMARY KEY (order_id) NOT ENFORCED ) PARTITIONED BY (bucket STRING)这个表离线模式直接扫描分区,实时模式消费流做upsert,字段和主键完全一致。指标对不上时,两边可以互查,不用再各看各的中间表。
3.3 DWS层:指标模型要“中台化”
DWS层最常见的错误是做成“一指标一表”。每个业务方提一个指标,就建一张表,最后几十张聚合表,每张表的字段和口径都靠人传人。真正的实时数据模型,在DWS层应该做的是把指标“中台化”。
我们的做法是:DWS只存储“原子粒度数据加派生指标定义”,不在表里堆砌所有指标。比如订单事实表已经有实付金额、订单状态,GMV、客单价、取消率这些派生指标都不单独建表,而是在指标层定义统一表达式。每个指标只有一行定义,包括口径表达式、时间范围、过滤条件。批任务和流任务都引用这一行定义,生成各自的执行计划,但结果保证一致。
指标定义示例:
gmv := SUM(pay_amount) WHERE order_status <> 'CANCELLED'流式任务跑增量,批式任务跑历史,同一个表达式,时间范围一拼接,两边自然能对上。这套方案落地之后,新增一个指标不用再开发新表,在指标中心配置一下,批流两侧同时生效,效率提升非常明显。
3.4 宽表与实时看板落地细节
实时看板是DWS能力对外的主要出口。做看板有个常见误区,业务提一个看板,开发就写一个实时任务,看板一多,任务爆炸,运维压力巨大。
按实时数据模型的思路,看板应该抽象成“指标集加维度模板”,把常用维度组合预先定义好,模型层数据自动服务,而不是为每个看板单独开发链路。比如交易看板,固定维度是省份、品类、小时桶,指标集是GMV、订单量、客单价,那不管业务方开多少个看板页面,底层都复用同一份模型。
宽表字段设计要克制。字段越多,流式upsert和状态压力越大。只保留查询频率高的字段,低频查询放OLAP引擎里现算。写入策略上,根据主键变更频率评估flush间隔,高频更新场景下,频繁flush反而会导致小文件过多和下游查询抖动。
4. 工程实现:实时模型链路的关键处理
4.1 双流JOIN与维表关联的建模约束
实时任务做JOIN,比离线难一个量级,因为双流join涉及状态对齐和乱序。模型层面有两件必须要做的事。
第一,维表必须版本化。实时任务在join维表时,如果只拿最新值去匹配历史事件,那么维表一旦变更,历史口径全会变掉。正确做法是,维表使用拉链表模型,带dim_valid_from和dim_valid_to两个时间字段,join条件写成事件时间在有效期内:
SELECT o.order_id, d.dim_value FROM dwd_order_detail_real_time o JOIN dim_dimension_version d ON o.dim_key = d.dim_key AND o.event_time BETWEEN d.dim_valid_from AND d.dim_valid_to第二,双流JOIN要严格限制。两条流需要join时,关联键必须在建模阶段就统一类型与时间桶,否则任务会因数据分布不均匀而膨胀。能通过退化字段解决的,绝不建双流join;必须建的,尽量一主一从,主表append,从表做维表缓存。
举例,订单流和物流流join,关联键统一成order_id,并且两边都按同一时间桶分区。这样join时的状态只保存一个窗口周期,而不是全量历史,成本可控。
4.2 迟到数据和乱序数据怎么建模
流处理中,乱序和迟到是无法避免的。很多团队在任务里直接把迟到数据丢弃,结果就是实时结果和离线结果永远差一点。实时数据模型要做的,是把迟到当成常态来设计。
我的方案是:设计“修正事件表”。迟到的数据不再去改主表的旧窗口,而是写入一张修正记录表,记录bucket、biz_key、correct_value、correct_time,最终聚合时把修正记录叠加到对应窗口。这样主表始终保持append语义,不受迟到更新影响,而最终指标能通过修正记录收敛到和离线一致。
延迟窗口的容忍时间,建议设为业务容忍度的一半。比如业务能容忍数据30分钟以内可见,allowedLateness设15到20分钟。设置太宽,数据迟迟不出结果;设太窄,迟到数据全进修正表,修正逻辑压力大。离线侧对应的是“晚到的分区”,通过重跑修正。批流两边修正在同一张修正表上汇合,对账时才能对上。
4.3 状态TTL与幂等写入配置
Flink状态大小直接决定成本和恢复时间。状态设计不合理,任务一重启就是灾难。建模侧有三组控制手段,建议写进规范。
第一,状态TTL要按模型语义设置。默认建议72小时,小时级聚合模型可以缩到24小时。TTL太长,状态膨胀;TTL太短,迟到数据会被状态拒绝,结果不准。在代码里通常这样配置:
StateTtlConfig ttlConfig = StateTtlConfig .newBuilder(Time.hours(72)) .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) .build();第二,状态按时间桶分组。模型里设计bucket字段,state按bucket + key存,过期的桶直接清理,恢复时也只恢复需要的桶,避免全量恢复。
第三,sink端幂等写。流式sink必须支持按主键upsert,不能先删后插。加上write_seq字段,遇到乱序重复数据,以最大write_seq为准,其他丢弃。
4.4 元数据统一与SQL模板复用
实时数据模型要真正跑得好,元数据必须只有一套。表结构由DDL中心统一生成,批任务、流任务、血缘共享同一份元数据。只有元数据统一了,离线开发改一张表结构时,实时侧的字段才不会被落下。
我们实践下来,SQL模板复用是降本增效最明显的一步。模型定义好之后,同一段统计逻辑可以通过模板参数化:
-- 同一段统计逻辑,根据exec_mode切换 SELECT ${time_bucket}(event_time) AS bucket, COUNT(*) AS cnt FROM ${model_name} WHERE event_time >= ${start_time} GROUP BY ${time_bucket}(event_time)批模式跑全量历史,流模式跑增量,逻辑只有一份,不分别在两套任务里维护两套SQL。改逻辑时改一次,批流同时生效,从根本上避免口径漂移。
5. 常见问题与排查技巧实录
5.1 对不上账:实时比离线多1%
这是实时数仓里最经典的灵异事件。遇到过一次,实时GMV比离线高1%左右,业务方天天追着问。
排查过程其实很机械。先把指标口径逐行对比,发现实时任务按事件时间聚合,而离线表按日志分区时间统计。跨零点那半小时,事件发生在23:50的订单,实时算在当天,离线算在第二天,两边差了半小时数据,比例正好1%附近。把模型层的时间桶统一成同一个表达式,并强制离线分区使用事件时间,问题才消失。
这里最大的教训是:对不上账,先别急着调Flink参数,先看模型层两边的“时间桶”和“分区字段”是不是同一个表达式。很多时候,问题根本不在计算引擎。
5.2 维表更新后历史数据全乱
有次改了城市的维表归属,结果实时结果和历史离线结果对不上了。原因是维表没有版本化,实时任务join时查到的都是最新值,历史数据也按最新值算,自然乱套。
解决方法是全部切到拉链维表,join条件带上起止有效时间。修复时,从维表变更时间点做一次重放,把受影响窗口重新计算一遍。这里建议把“维表必须版本化”直接写进建模规范,不要等出问题再补。
5.3 窗口结果老是差一个周期
小时级统计,实时结果总是比离线少1小时数据,且每次差的都是最后一个小时。排查后发现,watermark收口时间与模型分区时间不对齐。实时窗口在18:00收口,但模型时间桶在17:00就切了小时,中间1小时的数据被算进了下一个桶。
解决方法是,统一watermark收口时间和时间桶表达式,并且把watermark日志打印出来做周期性核对。这一条排查建议会帮你省很多时间。
5.4 状态恢复慢到任务起不来
有段时间任务从checkpoint恢复一次要20分钟,业务侧完全不能接受。排查发现,状态没有按时间桶分组,也没有设置TTL,key分布非常散,导致状态文件巨大。
后来把状态按时间桶分组,设置72小时TTL,低频key单独分流,恢复时间降到2分钟以内。如果你也遇到恢复慢的困扰,先看状态大小,再看状态结构是不是足够规整。
5.5 常见问题速查
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 实时比离线多/少数据 | 时间语义不一致、时间桶未对齐 | 对比event_time和分区字段表达式 |
| 维表更新后历史数据全变 | 维表未版本化 | 切拉链表,join带有效时间条件 |
| 窗口结果总差一个周期 | watermark收口与时间桶不一致 | 统一watermark收口时间,打日志核对 |
| 状态恢复慢 | 状态未分桶、无TTL、key过散 | 按时间桶分组,设置合理TTL |
| 重复数据导致指标偏高 | 写入非幂等、乱序未去重 | 主键加write_seq做upsert去重 |
6. 什么时候该上实时数据模型
6.1 先判断需求值不值得
实时数据模型不是银弹。我见过一些团队,内部看板T+1完全够用,却为了“技术先进”强行上一套实时链路,最后运维人力翻了一倍,业务幸福感反而下降。判断该不该上,可以拿三个标准卡:业务是否要求分钟级或秒级时效;指标是否经常要做重跑对账;离线加实时两套系统的口径冲突是否已经在影响业务决策。三条里中了两条,再推进;只中一条,可以先做指标体系规范化,不急着全面铺开实时。
6.2 渐进式改造路线
不要试图一次性推翻旧数仓。我们的经验是从一条核心链路试水,比如交易线或订单线,先做ODS和DWD的统一;再做DWS指标中心化,让批流用同一套SQL模板;然后接入实时看板,同时对账自动化;最后才扩展到其他业务线。第一步最难,也最值钱,因为ODS和DWD一旦统一,后面的DWS和看板都是水到渠成的事。
6.3 团队怎么配合
实时数据模型是“跨工种”的协作结果。数据架构师定建模规范,数仓团队维护离线链路和湖表,实时开发维护流任务,指标平台统一口径。建模文档和评审一定要前置,凡是模型层面该定的主键、时间桶、表形态,提前定完,再分头开发,不然又是各建一套只是换了名字的两张皮。
最后说点个人的体会。做了这么多年数据,我有一个很深的感受:流批一体不是计算引擎的独角戏,而是建模思路的转变。引擎可以让任务跑起来,但只有数据模型才能让数据“说得清、对得上、经得起重放”。如果你正在被批流对账折磨,我建议你把两套表的主键、时间桶和幂等设计拉出来对比一遍,大概率会发现,问题不在Flink,而在表结构。从模型出发做一次梳理,值得你花上一周时间。