基于 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 / 对账 |
三个贯穿全系列的设计要点,先在这里立牌:
- ODS 是两链路唯一共享点 :离线与实时都从
ods_vehicle_qb_msg_report_original读起,各自加工、各写各的表------共享的只有"源头",从 DWD 开始就物理分名。 - 实时与离线表物理分名 :离线表用原名(如
dws_vehicle_trip_daily),实时表一律_rt后缀(如dws_vehicle_trip_daily_rt)。物理分名是流批一体的前提(第 12 章展开论证)。 - 递推累计表
_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 章) |
给选型者的建议:
- 新组件引入前,先用最小 SQL 集(建 Catalog → 建表 → 写一条 → 读一条 → 改一个 DATE 字段)做冒烟,别等全链路搭完才发现类型对不上;
- 升级任一组件前,把第 17 章的 14 坑清单过一遍,逐条确认新版本行为;
- 锁死版本号:生产环境不追新,
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 能不能建起来。
官方参考资料
- Apache Paimon 官网(Table / Catalog / Changelog):https://paimon.apache.org/docs/1.4.2/
- Apache Doris 官网(Multi-Catalog / 物化视图):https://doris.apache.org/zh-CN/docs/4.x/
- Apache Flink 1.20 文档(SQL 语法 / runtime-mode):https://nightlies.apache.org/flink/flink-docs-release-1.20/
- Flink CDC 文档(yaml pipeline):https://nightlies.apache.org/flink/flink-cdc-docs/release-3.5/
- Apache DolphinScheduler 官网:https://dolphinscheduler.apache.org/zh-cn
- 本系列目录:《第 1 章 业务背景与传统 Lambda 架构痛点》