基于 FlinkSQL + Paimon + Doris 的车联网流批一体湖仓实践 —— 第 4 章 Kafka 企标报文入湖(ODS 表设计)

基于 FlinkSQL + Paimon + Doris 的车联网流批一体湖仓实践 ------ 第 4 章 Kafka 企标报文入湖(ODS 表设计)

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

章节定位 :地基打好(第 3 章),开始灌第一份数据。本章落地"ODS 是全仓唯一真相源"的设计哲学------企标报文怎么进 ODS、为什么 json_data 必须存原文、主键如何实现报文级幂等、维表如何 CDC 入湖并让两条链路共享。这五条设计决策,决定整个数仓的可重算性。

上一章回顾:Paimon Catalog 配置(metastore=hive、多 uri 高可用、与 Hive 共用 warehouse)+ 表属性体系(bucket 推导、changelog-producer=full-compaction、jar 加载铁律)。


章节导读

  • [4.1 ODS 层的职责与"唯一真相源"哲学](#4.1 ODS 层的职责与"唯一真相源"哲学)
  • [4.2 企标报文长什么样](#4.2 企标报文长什么样)
  • [4.3 ODS 表设计五大决策](#4.3 ODS 表设计五大决策)
  • [4.4 完整建表 SQL](#4.4 完整建表 SQL)
  • [4.5 Flink 入湖作业](#4.5 Flink 入湖作业)
  • [4.6 维表入湖:Flink CDC yaml pipeline](#4.6 维表入湖:Flink CDC yaml pipeline)
  • [4.7 验证与监控](#4.7 验证与监控)
  • [4.8 本章小结与下章预告](#4.8 本章小结与下章预告)

4.1 ODS 层的职责与"唯一真相源"哲学

4.1.1 ODS 层的三个职责

在第 2 章的五层架构里,ODS 层(tsp_ods 库)承担三个职责:

职责 内容 谁来用
① 原样落地 Kafka 企标报文原文(JSON)一字不改进湖 离线与实时两条链路共同起点
② 维表承载 MySQL 车型/组织/用户维表 CDC 同步入湖 实时 LOOKUP JOIN + 离线批读
③ 可重算根基 任何口径修正、逻辑变更,都能从 ODS 重放重算 全仓

4.1.2 为什么"存原文"是第一设计原则

第 1 章讲过,企标报文有一个"脾气":字段随车型迭代、业务需求频繁增补。如果 ODS 层把 JSON 展开成几十个强类型列,会发生什么:

复制代码
企标新增一个字段 "battery_pack_voltage"
        │
        ▼
ODS 表加列 → 入湖作业改 SQL → 下游所有作业重编译/重部署
        │
        ▼
历史数据没有该列 → 要么 NULL 要么重导
   ------ 每加一个字段就要动全链路,改的人苦不堪言

所以本案例 ODS 的第一设计原则是:ODS 层不做 schema 展开,json_data 存报文原文,字段解析留给 DWD 。原文在手,随时可以重放/重算------哪怕某天发现 DWD 解析逻辑有 bug,把历史分区的报文重新解析一遍就行,不需要、也不可能去问源系统要历史数据(TBox 报文是过路数据,Kafka 保留期有限,湖里的原文就是唯一副本)。

一句话 :ODS 是全仓唯一真相源。判据很朴素------把 DWD 及以上全部删掉,只靠 ODS 能不能把整个数仓原样重建出来? 能,ODS 才算合格。


4.2 企标报文长什么样

设计表之前先看清数据。一条典型的企标报文(topic:cnx_vehicle_data_qb):

json 复制代码
{
  "vin": "LSVAU2180N2xxxxxx",
  "collect_time": 1727654400,
  "data": {
    "vehicle_status": 1,
    "speed": 63,
    "mileage": 152340.5,
    "soc": 78.5,
    "longitude": 116397123,
    "latitude": 39902345,
    "position_status": 1,
    "charge_status": 0,
    "gear": "D",
    "total_voltage": 355.2,
    "total_current": -45.6
  }
}

三个与表设计直接相关的特征:

特征 说明 设计影响
vin 车架号,车辆唯一标识 桶键与所有下游聚合的第一分组键
collect_time 秒级 Unix 时间戳(10 位),采集时刻 主键之一;dt 分区由它推导
data 业务字段嵌套在 data 对象里,字段集合随车型变化 整体作为 json_data 存原文,不展开

注意秒级时间戳 :collect_time 是 10 位(秒),不是 13 位(毫秒)。Paimon 建表不涉及类型转换问题,但后续 DWD 层用 TO_TIMESTAMP_LTZ(collect_time, 0) 解析时,第二参数必须是 0(秒);如果哪天接入 13 位毫秒报文,要除以 1000------这是项目里反复强调的坑(时间戳位数错了,时间会整体偏移 1000 倍)。


4.3 ODS 表设计五大决策

4.3.1 决策总览

# 决策 取值 解决什么问题
① 存原文 json_data STRING 字段频繁增补,链路免改动;可重放重算
② 报文级幂等主键 primary-key = vin,collect_time,dt Kafka 重放/作业重启不产生重复数据
③ 桶键 = vin bucket-key = vin,bucket = 6 同车同桶,下游聚合与点查桶内完成
④ 按天分区 dt = FROM_UNIXTIME(collect_time) 推导 离线回补整区重算,天然幂等(R1 基础)
⑤ parquet + zstd-3 列存 + 高压缩比 存储成本显著低于 snappy,查询扇出小

下面逐个展开设计思考。

4.3.2 决策②:主键 (vin, collect_time, dt) 为什么能幂等

幂等的含义:同一条报文被消费两次(Kafka 重平衡、作业重启回放、上游重推),表里只有一行。

推导过程:

复制代码
业务语义: 一辆车同一采集秒只上报一条报文
    → (vin, collect_time) 唯一确定一条报文
    → dt 是分区键,主键必须包含分区键(Paimon 硬约束,否则同主键散落多分区)
    → primary-key = (vin, collect_time, dt)
    → 重复消费 = 相同主键的 upsert = 覆盖而非追加
    → 重放不翻倍 ✓

两个细节:

  1. 主键必须包含分区键 :Paimon 主键表若主键不含分区列,同一主键可能出现在多个分区,唯一性失效。dt 纳入主键是语法要求,也顺带让"跨天重传"的报文(如 23:59:59 采集、00:00:01 重传)落在各自分区内互不干扰;
  2. 万一同车同秒有两条报文 (TBox 异常双发):后写覆盖先写,表里保留一条------对分析场景可接受,换取的是幂等这个大得多的收益。如果业务要求双发都保留 ,主键要加一个去重序号字段(如 seq),由入湖作业解析报文生成------本案例实测确认双发率可忽略,未加。

4.3.3 决策③:为什么桶键是 vin 而不是 collect_time

桶键的选择标准是"下游怎么用,键就怎么选":

候选桶键 数据分布 下游行为 结论
vin 百万级基数,哈希均匀 全下游聚合第一分组键都是 vin;同车轨迹点查、同车 JOIN 全部桶内完成 ✅ 选它
collect_time 均匀 聚合按 vin 分组 → 跨桶 shuffle;点查要全桶扫 ❌
随机/无桶键 --- 失去桶内 JOIN 能力(Bucket Join) ❌

bucket=6 的推导在第 3 章(单日 60~80 GB ÷ 10~15 GB/桶),此处不重复。强调一句:桶数主键表建后不可改,初期宁可按"日增长量 × 半年"预估,也不要按"当前量"拍。

4.3.4 决策④:dt 由 collect_time 推导而非 Kafka 时间

dt 不是从 Kafka 消息时间戳取,而是在入湖作业里由 FROM_UNIXTIME(collect_time) 推导:

  • 用采集时间 (业务真实时间)而非消费时间(链路时间):车辆离线后补传的报文,采集时间属于历史日期,落在历史分区------离线回补时能按正确日期重算;
  • 若用消费时间,补传报文会被错归到"今天",历史分区的口径就歪了;
  • 这也是 R1/R2 铁律能成立的前提:分区代表业务日期,不是消费日期。

4.4 完整建表 SQL

sql 复制代码
-- ① 建 Paimon Catalog(第 3 章已详述,jar 铁律见 3.6)
CREATE CATALOG paimon WITH (
  'type'       = 'paimon',
  'metastore'  = 'hive',
  'uri'        = 'thrift://xxx:9083,thrift://xxx:9083',
  'warehouse'  = 'hdfs:///warehouse/tablespace/managed/hive'
);
USE CATALOG paimon;
CREATE DATABASE IF NOT EXISTS tsp_ods;

-- ② ODS 事实表:企标报文原文
CREATE TABLE IF NOT EXISTS tsp_ods.ods_vehicle_qb_msg_report_original (
  vin          STRING,      -- 车架号(桶键)
  collect_time BIGINT,      -- 秒级 Unix 时间戳(10 位)
  json_data    STRING,      -- 企标报文原文(唯一真相源)
  dt           STRING       -- FROM_UNIXTIME(collect_time) 推导的业务日期
) PARTITIONED BY (dt)
WITH (
  'primary-key' = 'vin,collect_time,dt',     -- 报文级幂等
  'bucket' = '6',
  'bucket-key' = 'vin',
  'file.format' = 'parquet',
  'file.compression' = 'zstd',
  'file.compression.zstd-level' = '3',
  'write-buffer-size' = '256 mb',
  'target-file-size' = '128 mb',
  'changelog-producer' = 'full-compaction',  -- 配合 checkpoint 30s,下游可见性 ≤30s
  'dynamic-partition.create-history-partition' = 'true',  -- 历史回补必需
  'history_partition_num' = '365'
);

重复一遍第 3 章的警告 :这张表的属性建后大部分不可改(尤其桶数与主键),上线前请拿真实流量回放验证一遍文件大小分布与写入吞吐,确认 bucket=6 撑得住半年增长再提交。


4.5.1 Kafka Source 表定义

sql 复制代码
CREATE TABLE kafka_qb_source (
  vin          STRING,
  collect_time BIGINT,
  data         STRING        -- data 对象整体作为字符串接收
) WITH (
  'connector' = 'kafka',
  'topic' = 'cnx_vehicle_data_qb',
  'properties.bootstrap.servers' = 'xxx:9092,xxx:9092,xxx:9092',
  'properties.group.id' = 'tsp_ods_qb_inlake',          -- 从配置文件读取注入,禁止硬编码
  'scan.startup.mode' = 'group-offsets',                -- 生产必须 group-offsets,防重启重消费
  'properties.auto.offset.reset' = 'none',              -- 无位点直接报错,不静默从头消费
  'value.format' = 'json',
  'json.ignore-parse-errors' = 'true',                  -- 脏报文丢弃并计数,不让作业崩
  'json.timestamp-format.standard' = 'SQL'
);

三个要点:

配置 理由
group.id 从配置文件读取 消费组 ID 硬编码会在多环境(测试/预发/生产)冲突,提交脚本从环境配置注入(第 15 章)
scan.startup.mode = group-offsets 从消费组已提交位点续传。earliest-offset 会在无位点时重放全量------生产事故级行为
json.ignore-parse-errors = true 偶发残缺报文(网络截断等)只丢弃不让作业崩;脏数据量通过 Flink UI 的 numRecordsInBad 指标观察

4.5.2 入湖 INSERT 作业

sql 复制代码
INSERT INTO paimon.tsp_ods.ods_vehicle_qb_msg_report_original
SELECT
  vin,
  collect_time,
  data AS json_data,
  DATE_FORMAT(
    TO_TIMESTAMP_LTZ(collect_time, 0),   -- 第二参数 0 = 秒级时间戳
    'yyyy-MM-dd'
  ) AS dt
FROM kafka_qb_source;
细节 说明
TO_TIMESTAMP_LTZ(collect_time, 0) 10 位秒级时间戳转 TIMESTAMP_LTZ,精度参数 0 是秒 ;若接入 13 位毫秒报文需先 / 1000
DATE_FORMAT(..., 'yyyy-MM-dd') 由采集时间推导分区值,补传报文落正确的历史分区
data AS json_data data 对象整体序列化回 JSON 字符串,不展开任何字段(决策①)

4.5.3 作业运行参数(流式模板)

sql 复制代码
SET 'execution.runtime-mode' = 'streaming';
SET 'execution.checkpointing.interval' = '30 s';   -- Paimon sink 靠 checkpoint 提交
SET 'table.exec.state.ttl' = '48 h';
SET 'restart-strategy.type' = 'fixed-delay';
SET 'restart-strategy.fixed-delay.attempts' = '2147483647';
SET 'pipeline.name' = 'tsp_ods_qb_msg_inlake';     -- WebUI 可读命名
SET 'parallelism.default' = '6';                    -- 与 bucket=6 对齐

并行度与桶数对齐 :写入并行度 > 桶数时多余 subtask 空转;< 桶数时单 subtask 要写多个桶,吞吐打折。parallelism.default = 6 与 bucket = 6 一一对应。

提交方式 :本作业挂常驻 YARN session(第 14 章部署形态),-Dyarn.application.id=<appId> 显式指定,-Dtable.dml-sync=true 让提交端等到作业真正 RUNNING 才返回。


4.6.1 完整 pipeline 配置(实测)

MySQL 业务库的三张维表(车型信息 / 车系 / 车型字典)用 Flink CDC 3.x 的 yaml pipeline 同步入湖,整库映射、自动建表、DDL 自动演进:

yaml 复制代码
source:
  type: mysql
  hostname: xxx.xxx.xxx.xxx
  port: 3306
  username: xxx
  password: xxx
  tables: iov_veh.t_cn_car_info,iov_veh.t_cn_car_series, iov_veh.t_cn_car_type
  server-id: 5410-5418          # server-id 区间,多并行度防冲突

sink:
  type: paimon
  catalog.properties.metastore: hive
  catalog.properties.uri: thrift://xxx:9083,thrift://xxx:9083
  catalog.properties.warehouse: hdfs:///warehouse/tablespace/managed/hive

pipeline:
  name: vehicle_dim_to_tsp_ods_paimon
  parallelism: 2
  schema.change.behavior: evolve    # MySQL DDL 变更自动演进到 Paimon

route:
  - source-table: iov_veh.t_cn_car_info
    sink-table: tsp_ods.ods_iov_veh_t_cn_car_info
  - source-table: iov_veh.t_cn_car_series
    sink-table: tsp_ods.ods_iov_veh_t_cn_car_series
  - source-table: iov_veh.t_cn_car_type
    sink-table: tsp_ods.ods_iov_veh_t_cn_car_type

4.6.2 三个关键要点

① schema.change.behavior: evolve------维表结构变更零人工

业务侧给维表加列是常态。配了 evolve 之后,MySQL 的 ALTER TABLE ADD COLUMN 会被 CDC 捕获并自动同步到 Paimon 表------源端加列,湖表自动跟 。备选值 exception(默认,报错暂停)在维表场景等于"每次变更都要人工介入",不推荐。

② server-id 必须配区间,不能配单个值

复制代码
增量读取按并行度拆分 binlog 位点:
  parallelism=2 + server-id=5410-5418
    → 子任务1 用 5410,子任务2 用 5411,各占一个 binlog dump 连接

若配单个 server-id=5410:
    → 两个子任务抢同一个 id,MysqlReplicationException
      或周期性断连重连,作业反复重启

区间宽度 ≥ 最大并行度即可(5410-5418 留足扩展余量)。同时注意区间不要与集群内其他 CDC 作业的 server-id 重叠------MySQL 对同一 server-id 的重复 dump 连接会互相踢。

③ 一份维表,两链路共享

维表入湖后的使用方式(后续章节展开,此处立牌):

链路 使用方式 章节
实时 FOR SYSTEM_TIME AS OF 做 LOOKUP JOIN,流式关联维表补维度 第 10 章
离线 批作业直接批读 Paimon 维表(快照读) 第 6 章

维表不再需要"MySQL → 离线库"每日全量导数------CDC 一条链路,实时离线都用,这是"同一 Catalog"红利最直观的体现。


4.7 验证与监控

4.7.1 入湖验证三步

sql 复制代码
-- ① 数据在涨吗?(最新分区行数,间隔 1 分钟跑两次对比)
SELECT dt, COUNT(*) FROM paimon.tsp_ods.ods_vehicle_qb_msg_report_original
WHERE dt = DATE_FORMAT(CURRENT_DATE, 'yyyy-MM-dd') GROUP BY dt;

-- ② 内容对吗?(抽样看原文 JSON 结构是否完整)
SELECT vin, collect_time, json_data
FROM paimon.tsp_ods.ods_vehicle_qb_msg_report_original
WHERE dt = DATE_FORMAT(CURRENT_DATE, 'yyyy-MM-dd') LIMIT 5;

-- ③ 幂等对吗?(同一 vin 同一秒应只有一行)
SELECT vin, collect_time, COUNT(*) c
FROM paimon.tsp_ods.ods_vehicle_qb_msg_report_original
WHERE dt = DATE_FORMAT(CURRENT_DATE, 'yyyy-MM-dd')
GROUP BY vin, collect_time HAVING c > 1;
-- 期望: 0 行

4.7.2 两个必配监控

监控项 手段 告警阈值
当天分区最后提交时间 定时查 Paimon 快照提交时间(snapshot 目录 LATEST 时间戳),或直接观察行数是否停滞 超 30 分钟无新快照即告警------比 Flink 作业状态更早暴露链路已停(第 16 章监控三项之首)
脏报文计数 Flink UI numRecordsInBad(json.ignore-parse-errors 丢弃量) 比率 > 0.1% 排查上游

小文件观察:定期 SHOW CREATE TABLE 后看分区目录文件数(或用 HDFS count),若单日分区文件数持续高于 1500,回到第 3 章检查 write-buffer 与 Compaction 情况。


4.8 本章小结与下章预告

本章小结

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

  ✓ ODS 唯一真相源判据:删掉 DWD 以上,只靠 ODS 能重建全仓
  ✓ 决策① 存原文 json_data:字段频繁增补,链路零改动,可重放
  ✓ 决策② 主键 (vin,collect_time,dt):报文级幂等,重放不翻倍
      ------主键必须包含分区键;同车同秒双发由 upsert 覆盖
  ✓ 决策③ bucket-key=vin:下游聚合第一键,桶内 JOIN/点查
  ✓ 决策④ dt 由 collect_time 推导:分区=业务日期,补传落正确分区
      ------这是 R1/R2 铁律成立的前提
  ✓ 决策⑤ parquet + zstd-3:存储较 snappy 降约 25%
  ✓ 入湖作业:group-offsets + group.id 配置化 + parallelism=bucket
  ✓ 维表 CDC:evolve 自动演进 / server-id 区间 / 一份维表两链路共享

下章预告

第 5 章 离线数仓总览与 DolphinScheduler 调度体系 :数据进湖了,开始建第一条加工链路。讲离线链路的分层作业图(dwd → dws → ads)、DS 如何用 Shell 节点 + 资源中心提交 FlinkSQL、__DT__ / __MONTH__ 占位符的替换机制,以及离线作业链的依赖编排与失败重跑策略。

官方参考资料

相关推荐
俊哥大数据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
starzy19907 天前
Flink 优化之网络缓存优化及参数详解:从数据传输机制到反压原理的生产调优指南
网络·缓存·flink