基于 FlinkSQL + Paimon + Doris 的车联网流批一体湖仓实践 —— 第 2 章 流批一体湖仓总体架构与三条分区契约铁律

基于 FlinkSQL + Paimon + Doris 的车联网流批一体湖仓实践 ------ 第 2 章 流批一体湖仓总体架构与三条分区契约铁律

系列定位 :本系列以车联网 TSP 行程主题数仓为真实生产案例 ,完整复盘一套 Flink 1.20 + Paimon 1.4 + Doris 4.1 流批一体湖仓的架构设计、建设过程、上线部署、性能优化与踩坑总结。所有踩坑均为生产环境实测,非理论推演。

章节定位 :本章是全系列总纲。第 1 章给出了目标架构的鸟瞰图,本章把它逐层拆开,并立下贯穿全系列的三条分区契约铁律(R1 / R2 / R3)------它们违反任何一条,实时与离线的数据就会打架。后面第 7、10、12、13、16 章会反复回扣这三条铁律。

上一章回顾:第 1 章明确了业务三大需求(实时看板 / 离线报表 / 统一口径且互不踩踏)与 Lambda 架构的根本矛盾,并给出破局思路:同一 Paimon 存储 + 同一 Catalog,流批各写各表,Doris 统一收口。


章节导读

  • [2.1 总体架构图逐层拆解](#2.1 总体架构图逐层拆解)
  • [2.2 铁律 R1:离线只写 dt <= T-1](#2.2 铁律 R1:离线只写 dt <= T-1)
  • [2.3 铁律 R2:实时只写 dt >= T](#2.3 铁律 R2:实时只写 dt >= T)
  • [2.4 铁律 R3:对外查询历史走离线、当天走实时](#2.4 铁律 R3:对外查询历史走离线、当天走实时)
  • [2.5 唯一交汇点:种子作业](#2.5 唯一交汇点:种子作业)
  • [2.6 组件版本基线(实测环境)](#2.6 组件版本基线(实测环境))
  • [2.7 版本组合是踩坑重灾区](#2.7 版本组合是踩坑重灾区)
  • [2.8 本章小结与下章预告](#2.8 本章小结与下章预告)

2.1 总体架构图逐层拆解

先把完整架构图立在这里,这张图会伴随整个系列,后续每一章都是在给它的某一块填细节。

复制代码
                Kafka(cnx_vehicle_data_qb 企标报文)
                          │  Flink 消费(实时入湖)
                          ▼
   tsp_ods.ods_vehicle_qb_msg_report_original   ←------  两链路唯一共享点
   (Paimon,按天分区,vin 分桶)                        MySQL 维表
            │                                        │ Flink CDC(yaml pipeline)
            ├────────────────────┐                   ▼
   [离线] DS 调度批作业            │            Paimon 维表(ods 层)
   runtime-mode=BATCH             │
   R1:只写 dt <= T-1             │        [实时] 常驻流作业(9 个作业链)
   INSERT OVERWRITE 幂等          │        R2:只写 dt >= T(屏障)
   dwd → dws → ads(原名)         │        dwd_*_rt → dws_*_rt → ads_*_rt
            │                     │        _cum_rt 的 T-1 种子行由离线独家写
            ▼                     ▼
   ┌──────────────────────────────────────────────┐
   │              Doris 服务层                      │
   │  · Paimon Catalog 直查(当天/明细)             │
   │  · 异步物化视图 MV(历史聚合加速,离线表之上)    │
   │  · VIEW v_*(R3:历史走 MV,当天走实时直查)     │
   └──────────────────────────────────────────────┘
                       ▼
                 BI / APP / 对账

这张图可以拆成五层来看,每层的核心职责与"谁在写、谁能读"如下:

层 组件 核心职责 写入者 读取者
① 数据入口层 Kafka + MySQL 企标报文汇聚;业务维表维护 业务系统 / TBox Flink 入湖作业 / Flink CDC
② 湖存储 ODS 层 Paimon(tsp_ods 库) 报文原文落地(唯一真相源)+ 维表入湖 Flink 入湖作业 / Flink CDC 离线与实时两链路
③ 离线数仓层 Paimon(原名表)+ DS T+1 权威口径,dwd→dws→ads,只写 dt <= T-1 DS 调度的批作业(R1) Doris 服务层、种子作业
④ 实时数仓层 Paimon(_rt 表) 当天数据秒级出数,只写 dt >= T 9 个常驻流作业(R2) Doris 服务层、实时递推链路
⑤ 服务层 Doris 4.1 历史加速(MV)+ 当天直查 + 分流收口(R3) MV 刷新作业 BI / App / 对账

三个贯穿全系列的设计要点,先在这里立牌:

  1. ODS 是两链路唯一共享点 :离线与实时都从 ods_vehicle_qb_msg_report_original 读起,各自加工、各写各的表------共享的只有"源头",从 DWD 开始就物理分名。
  2. 实时与离线表物理分名 :离线表用原名(如 dws_vehicle_trip_daily),实时表一律 _rt 后缀(如 dws_vehicle_trip_daily_rt)。物理分名是流批一体的前提(第 12 章展开论证)。
  3. 递推累计表 _cum_rt 是唯一例外:它由实时链路命名、却被离线种子作业"独家写 T-1 分区"------这是全方案唯一的跨界写入,也是唯一的交汇点(2.5 节展开)。

2.2 铁律 R1:离线只写 dt <= T-1

2.2.1 契约内容

R1:离线链路的所有写入,只允许落在 dt <= T-1 的分区上。

"权威数据"的定义决定了这条铁律:离线链路是 T+1 口径,今天(T 日)的数据还没闭合(车辆可能还在路上、报文还在迟到),离线没有资格写今天的分区。

2.2.2 落地方式:让契约"天然满足"

这条铁律最漂亮的地方在于------它不需要任何额外的代码来守护:

sql 复制代码
-- 离线 DML 的统一模板(由提交脚本自动替换 __DT__ 为昨天)
INSERT OVERWRITE tsp_dws.dws_vehicle_trip_daily PARTITION (dt = '__DT__')
SELECT * FROM (
  -- 源查询(注意:这里不能以 WITH 开头,第 7 章详述此坑)
  SELECT vin, COUNT(*) AS trip_cnt, ...
  FROM   paimon.tsp_ods.ods_vehicle_qb_msg_report_original
  WHERE  dt = '__DT__'
  GROUP  BY vin
) t;

关键机制有三个:

机制 说明 效果
INSERT OVERWRITE ... PARTITION (dt='__DT__') 静态分区覆盖写,__DT__ 由提交脚本统一替换为昨天 物理上只可能写昨天的分区
OVERWRITE 幂等 整区覆盖而非追加,重跑任意一天结果确定 回补、回滚、口径修正全部安全(第 8 章)
WHERE dt = '__DT__' 源查询只扫目标分区 每日跑批只处理 T-1 增量,成本恒定

因为静态分区的值是脚本注入的固定常量,离线作业想写今天的分区都写不了------这就是"天然满足"。

2.2.3 违反 R1 的后果

如果有人图省事在离线作业里写了 dt >= CURRENT_DATE 的动态逻辑,会发生什么:

复制代码
离线批作业 02:00 跑,写入了 T 日的"半截数据"
        │
        ▼
实时链路同时也在写 T 日分区
        │
        ▼
同一分区两个写入者:
  · 离线 INSERT OVERWRITE 会把实时已写入的行覆盖掉(凌晨的快照是残缺的)
  · 实时作业的 upsert 又把离线的覆盖回来
        │
        ▼
T 日数据反复横跳,凌晨看是离线的残缺值,白天是实时的值
  ------ 报表"鬼影"数据,且极难排查

一句话记住 R1:离线的权威性来自"只写已经闭合的历史",今天的数据永远归实时链路。


2.3 铁律 R2:实时只写 dt >= T

2.3.1 契约内容

R2:实时链路的所有写入,只允许落在 dt >= T(今天及以后)的分区上。

与 R1 对称:实时链路是"当天数据"的专属写者。历史分区(dt <= T-1)是离线的领地,实时不许碰------实时作业重启时尤其危险,默认配置下流读会从历史快照开始回放,把历史数据重写一遍。

2.3.2 落地方式:源表屏障 + 自动传导

R2 的落地是在 DWS 层两个作业的源表扫描处加一道屏障:

sql 复制代码
-- 实时 DWS 作业的源表读取(屏障)
FROM paimon.tsp_dws.dws_vehicle_trip_segment_rt /*+ OPTIONS('scan.mode' = 'latest') */
WHERE dt >= CAST(CURRENT_DATE AS STRING)   -- 实时只碰今天

两个关键细节:

① 为什么屏障加在 DWS 层,而不是每个作业都加?

DWS 是实时链路里"批读上游 Paimon 表"的枢纽(DWD 层作业消费的是 Kafka/流读,天然不碰历史)。在枢纽处加屏障,下游自动传导------ads 层读 DWS 时拿到的已经是"只有今天"的数据流,无需重复过滤。全链路只有两处源表需要屏障,维护面最小。

② 为什么写 CAST(CURRENT_DATE AS STRING) 而不是 DATE_FORMAT(CURRENT_DATE, 'yyyy-MM-dd')?

这是个实测踩过的编译期报错:Flink 的 CURRENT_DATE 返回 DATE 类型,而 DATE_FORMAT 只接受 TIMESTAMP/CHARACTER,Calcite 直接拒绝:

复制代码
SqlValidatorException: Cannot apply 'DATE_FORMAT' to arguments of type
'DATE_FORMAT(<DATE>, <CHAR(11)>)'

正确写法就是 CAST(CURRENT_DATE AS STRING)------dt 本身是 STRING 类型,'yyyy-MM-dd' 格式的字符串按字典序比较等价于 日期比较,语义不变。另外注意:在 Doris 里 DATE_FORMAT(CURRENT_DATE(), ...) 是合法的,两个引擎语法不通用,别混写(第 17 章坑 11 完整复盘)。

配合 scan.mode = 'latest'(实时只从当前快照开始消费,不做历史回放,第 11 章详述),实时链路从机制上就"看不见"历史分区。

2.3.3 违反 R2 的后果

复制代码
实时作业重启,scan 回放了历史快照
        │
        ▼
没有屏障 → 把 dt <= T-1 的历史行重新 upsert 进实时表
        │
        ▼
历史分区出现"实时写入": 
  · 与离线权威值冲突(谁后写谁是错的)
  · 主键表 upsert 覆盖了本不该动的行
        │
        ▼
历史报表悄悄变化,且每次实时重启都可能再变一次
  ------ 这就是"踩踏"

一句话记住 R2 :实时只拥有"今天",历史是离线的禁区;重启回放是实时链路最常见的历史入侵路径,屏障 + scan.mode=latest 双保险缺一不可。


2.4 铁律 R3:对外查询历史走离线、当天走实时

2.4.1 契约内容

R3:所有对外查询,dt < T 的数据读离线表(权威值),dt >= T 的数据读实时表(新鲜值)。

R1/R2 保证了"写"不打架,R3 解决"读"的一致性:BI 和 App 不应该自己判断"现在几点、该读哪张表",这个分流逻辑必须收口在一个统一入口。

2.4.2 落地方式:Doris 分流 VIEW

在 Doris 服务层建一个 v_* 视图,按 dt 分流(VIEW 定义在查询时求值,"今天"永远是对的):

sql 复制代码
CREATE VIEW tsp_dws.v_dws_vehicle_trip_daily AS
-- 历史分支:走离线 MV(毫秒级聚合加速)
SELECT ... FROM mv_dws_vehicle_trip_daily
WHERE dt < DATE_FORMAT(CURRENT_DATE(), '%Y-%m-%d')
UNION ALL
-- 当天分支:Paimon Catalog 直查实时表最新快照 + 递推累计 LEFT JOIN
SELECT ... FROM paimon_lake.tsp_dws.dws_vehicle_trip_daily_rt
LEFT JOIN paimon_lake.tsp_dws.dws_vehicle_trip_daily_cum_rt ...
WHERE dt >= DATE_FORMAT(CURRENT_DATE(), '%Y-%m-%d');

对 BI 的切换成本是改一个表名 :tsp_dws.dws_vehicle_trip_daily → v_dws_vehicle_trip_daily,SQL 零改动。30 秒切流、BI 无感回滚的细节在第 13 章完整展开。

2.4.3 违反 R3 的后果

没有统一收口时,每个 BI 报表自己拼"离线表 UNION 实时表",会出现:

  • 有人忘了过滤边界,同一天的行读了两份(离线的 T-1 行 + 实时的 T-1 行);
  • 有人把"当天"写死成 '2026-09-30',跨天报表静默出错;
  • 口径修正后,个别报表还在读旧表。

一句话记住 R3:分流逻辑只允许存在一份------在 Doris 的 VIEW 里;业务方永远只面对一张"看起来完整"的表。


2.5 唯一交汇点:种子作业

2.5.1 为什么必须有一个交汇点

三条铁律让两条链路在"表"维度完全隔离,但有一类指标天然跨不动------全历史递推指标,比如"日累计能耗""最近 15 天平均 SOH"。Flink 流式算子无法"从开天辟地累加到今天":状态装不下全历史,作业重启后累计基准还会归零。

解法是引入一张递推累计表 _cum_rt:

  • 实时作业 B 用 LOOKUP JOIN 取 T-1 分区的基准行(含全历史累计值),加上 T 日增量,写出 T 日行;
  • 而 T-1 基准行的权威版本,由离线种子作业每日 02:30 覆盖写入。

2.5.2 种子作业的写入契约

复制代码
┌────────────────────────────────────────────────────────────────┐
│                _cum_rt 表的分区归属(分区级单写者)              │
└────────────────────────────────────────────────────────────────┘

  分区维度:   dt=T-2      dt=T-1        dt=T
              ┌───────┐   ┌─────────┐   ┌──────────┐
              │ 离线写  │   │ 离线种子 │   │ 实时作业B │
              │(权威历史)│   │ 02:30覆盖│   │ (当天递推) │
              └───────┘   └─────────┘   └──────────┘
                            ▲
                            │  LOOKUP JOIN 取基准行
                            └────────── 实时作业B 每次递推前读取

  写入者矩阵:
    dt <= T-1  →  只有离线(含种子作业)      ← 零冲突
    dt >= T    →  只有实时作业 B             ← 零冲突
  • 种子作业用 INSERT OVERWRITE ... PARTITION (dt='__DT__') 覆盖 T-1 分区------这个分区每天只有一个写入者(离线种子),与实时作业 B 写的 T 日分区互不相交;
  • 实时作业 B 对 T-1 分区只读不写(LOOKUP JOIN 是查询)。

这就是"分区级单写者 ":虽然 _cum_rt 这张表同时被离线和实时"碰",但在分区维度上任何时刻都只有一个写入者------零冲突,全程不需要任何数据同步工具。

2.5.3 "实时先行、离线校准"循环

种子作业让整个系统形成一个自愈的循环:

复制代码
  当天 00:00 起            当天全天               次日 02:30
 ┌────────────┐      ┌────────────────┐      ┌──────────────────┐
 │ 实时作业 B    │ ───► │ T 日行持续更新    │ ───► │ 离线种子覆盖写 T 日  │
 │ 读 T-1 种子行 │      │ (秒级新鲜,但基准  │      │ (T 日分区成为新的   │
 │ (昨日权威值)  │      │  是 T-1 权威值)  │      │  "T-1 权威种子")   │
 └────────────┘      └────────────────┘      └──────────────────┘
                              ▲                              │
                              └──────── 循环往复 ──────────────┘
  • 实时先出数:T 日行在当天秒级可见(用 T-1 权威基准 + T 日实时增量);
  • 离线每日校准 :凌晨 02:30 种子作业把权威值写回,累计字段最多存在 1 天未校准窗口------这个口径已与业务方书面确认接受;
  • 附带修复:实时作业重启/断流后递推基准不再归零,次日种子强制拉回权威值(第 10 章详述"断天归零"隐患)。

一句话记住交汇点 :唯一允许的"跨界",是离线种子对 _cum_rt 表 T-1 分区的独家覆盖写------它把"两套链路"重新缝合成一个口径自洽的整体。


2.6 组件版本基线(实测环境)

本系列所有 SQL、参数、报错信息均来自下表这套实测组合。换版本请先看 2.7 节的警告。

组件 版本 说明
Flink 1.20.3.5(JDK 17) YARN session 形态:实时常驻 session + 离线 flink-offline-batch session
Paimon 1.4.x(paimon-flink-1.20-1.4.2) Hive Metastore catalog;connector 版本必须与 Flink 大版本严格对应
Doris 4.1.3-rc02 三节点;FE HTTP 端口 28030 / MySQL 协议端口 29030(非默认 8030/9030,注意)
DolphinScheduler 3.x Shell 节点 + 资源中心提交 SQL
Flink CDC 3.x yaml pipeline 模式,MySQL 维表入湖
Kafka 3.x 企标报文 topic:cnx_vehicle_data_qb
MySQL 8.0 业务维表源库(车型 / 组织 / 用户)

注意 Doris 端口 :本案例集群把 FE HTTP 配成了 28030、MySQL 协议配成了 29030。后文所有 curl、JDBC URL、Catalog 建表语句中的端口均以此为准,读者照抄时请换成自己集群的端口。


2.7 版本组合是踩坑重灾区

⚠️ 这是本系列最重要的警告之一 :上述组件单独看都成熟稳定,但组合起来的类型映射、类加载、时区处理存在大量暗坑。本系列第 17 章收录了 14 类生产实测问题,其中一半与"版本组合"直接相关。选型/升级前,请至少核对以下清单:

风险点 典型症状(详见第 17 章) 预防动作
Paimon connector 与 Flink 大版本错配 建表/读写行为异常,甚至类找不到 paimon-flink-<flink大版本>-<paimon版本> 三段必须严格匹配
连接器 jar 双份加载(-j 参数 + lib 目录) 建 Paimon Catalog 报 SPI ServiceConfigurationError: ... not a subtype jar 只放 ${FLINK_HOME}/lib,提交绝不传 -j(坑 2)
Doris 4.1 Arrow 读取的类型映射 读 Doris 报 FLINK type is DATEV2, but arrow type is TIMESTAMPSECTZ 读 Doris 用 connector='jdbc',写才用 connector='doris'(坑 5)
JDBC 读 Doris 缺时区 TIMESTAMP 整体偏 8 小时,静默写错 URL 带 serverTimezone=Asia/Shanghai + table.local-time-zone,两处缺一不可(坑 6)
Flink 与 Doris 的日期函数差异 DATE_FORMAT(CURRENT_DATE) 在 Flink 报错、在 Doris 合法 两引擎 SQL 别混写,函数迁移时逐个核对(坑 11)
sql-client 1.20 模式限制 1.20 只有 embedded/gateway,不支持 application 模式 部署方案按 session/gateway 设计(第 14 章)

给选型者的建议:

  1. 新组件引入前,先用最小 SQL 集(建 Catalog → 建表 → 写一条 → 读一条 → 改一个 DATE 字段)做冒烟,别等全链路搭完才发现类型对不上;
  2. 升级任一组件前,把第 17 章的 14 坑清单过一遍,逐条确认新版本行为;
  3. 锁死版本号:生产环境不追新,1.20.3.5 + 1.4.2 + 4.1.3-rc02 这套组合已被验证,动版本 = 重新踩一遍坑。

2.8 本章小结与下章预告

本章小结

复制代码
┌────────────────────────────────────────────────────────────────┐
│                     第 2 章 要点回顾                            │
└────────────────────────────────────────────────────────────────┘

  ✓ 总体架构五层:数据入口 → ODS(唯一共享点) → 离线层(原名表)
                  → 实时层(_rt 表) → Doris 服务层
  ✓ 铁律 R1:离线只写 dt <= T-1
      落地 = INSERT OVERWRITE PARTITION (dt='__DT__'),天然满足
  ✓ 铁律 R2:实时只写 dt >= T
      落地 = DWS 源表屏障 WHERE dt >= CAST(CURRENT_DATE AS STRING)
            + scan.mode='latest',下游自动传导
  ✓ 铁律 R3:对外查询历史走离线、当天走实时
      落地 = Doris 分流 VIEW v_*,查询时求值,BI 只换表名
  ✓ 唯一交汇点:_cum_rt 的 T-1 种子行由离线独家写
      分区级单写者 → 零冲突,全程不用同步工具
  ✓ 版本基线与重灾区警告:第 17 章 14 坑一半与版本组合有关

三条铁律速查表(建议截图保存)

铁律 内容 落地方式 违反后果
R1 离线只写 dt <= T-1 离线 DML 统一 INSERT OVERWRITE ... PARTITION (dt='__DT__'),__DT__=昨天,天然满足 与实时同分区双写,覆盖横跳产生"鬼影"数据
R2 实时只写 dt >= T DWS 层两处源表屏障 WHERE dt >= CAST(CURRENT_DATE AS STRING),下游自动传导 重启回放入侵历史分区,覆盖离线权威值
R3 对外查询:历史走离线、当天走实时 Doris VIEW 按 dt 分流(查询时求值) BI 各自拼 SQL,边界重复/写死日期/读旧表

下章预告

第 3 章 Paimon 1.4 湖仓底座与 Catalog 配置:架构定了,开始打地基。讲 Hive Metastore Catalog 的配置细节、表属性逐项拆解(primary-key / bucket / bucket-key / file.format / compression)、changelog-producer 的三种模式选型(实测选 full-compaction 的理由),以及一个前置避坑------连接器 jar 的加载位置直接决定 Catalog 能不能建起来。

官方参考资料

相关推荐
俊哥大数据1 小时前
基于 FlinkSQL + Paimon + Doris 的车联网流批一体湖仓实践 —— 第 4 章 Kafka 企标报文入湖(ODS 表设计)
flink·doris·湖仓一体·paimon·流批一体
俊哥大数据2 小时前
基于 FlinkSQL + Paimon + Doris 的车联网流批一体湖仓实践 —— 第 3 章 Paimon 1.4 湖仓底座与 Catalog 配置
flink·doris·湖仓一体·paimon
starzy19901 天前
Flink ResourceManager启动流程源码深度剖析:从ClusterEntrypoint到Slot分配的完整链路
java·大数据·flink
starzy19902 天前
Flink ApplicationMaster启动流程源码深度剖析:从YARN提交到JobMaster启动的完整链路
大数据·flink
用户3610588626123 天前
Flink Sliding Window 详解及代码实现:从窗口重叠到状态爆炸防控
大数据·flink
starzy19904 天前
Flink 提交任务源码深度剖析:从CliFrontend到JobMaster的完整提交链路
大数据·flink
starzy19905 天前
Flink Rpc通信源码级详解:从RpcService到动态代理的完整调用链路
大数据·rpc·flink
starzy19906 天前
Flink Akka底层原理深度剖析:从ActorSystem到Dispatcher调度器的底层实现
大数据·flink
nvd116 天前
# 从 192 秒到 13 秒:Apache Flink 批处理极速调优实战记录
大数据·flink·apache