基于 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 = 覆盖而非追加
→ 重放不翻倍 ✓
两个细节:
- 主键必须包含分区键 :Paimon 主键表若主键不含分区列,同一主键可能出现在多个分区,唯一性失效。
dt纳入主键是语法要求,也顺带让"跨天重传"的报文(如 23:59:59 采集、00:00:01 重传)落在各自分区内互不干扰; - 万一同车同秒有两条报文 (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 Flink 入湖作业
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 维表入湖:Flink CDC yaml pipeline
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__占位符的替换机制,以及离线作业链的依赖编排与失败重跑策略。
官方参考资料
- Apache Paimon 1.4 文档(主键表 / 分区 / 幂等写入):https://paimon.apache.org/docs/1.4.2/
- Apache Flink 1.20 文档(Kafka Connector / TO_TIMESTAMP_LTZ):https://nightlies.apache.org/flink/flink-docs-release-1.20/
- Flink CDC 3.x 文档(yaml pipeline / schema evolution):https://nightlies.apache.org/flink/flink-cdc-docs/release-3.5/
- 本系列往章:《第 1 章 业务背景与传统 Lambda 架构痛点》《第 2 章 流批一体湖仓总体架构与三条分区契约铁律》《第 3 章 Paimon 1.4 湖仓底座与 Catalog 配置》