4. 批式数据资产建模:Hive/Spark 表元数据模型、分区与存储格式管理、批处理任务血缘
批式数据资产,说白了就是那些跑在 Hive 和 Spark 上的离线表。你想想看,一个中型公司的数仓,动辄几千张表,每天几百个任务在跑。如果没有一套清晰的元数据模型,那简直就是灾难。
我个人习惯把批式资产建模分成三个层次来看:表结构本身、分区与存储、任务血缘。这三层缺一不可。今天我们就一个一个拆开讲。
4.1 Hive/Spark 表元数据模型
先说说最基础的表元数据。Hive 的表信息存在 Metastore 里,Spark 表其实也依赖 Hive Metastore(除非你用 Delta Lake 那种独立 Catalog)。
一个完整的表元数据模型,我建议至少包含以下几类信息:
- 基础信息:库名、表名、表类型(内部表/外部表)、创建时间、最后 DDL 时间
- Schema 信息:字段名、字段类型、字段注释、是否可为空、默认值
- 存储信息:存储格式(ORC/Parquet/Text)、压缩方式、文件路径
- 分区信息:分区字段、分区类型、分区数量
- 属性信息:表的自定义属性、事务属性、是否启用 ACID
我在项目中遇到过一种情况:业务方说「这张表有 200 个字段」,结果我们一查 Metastore,实际只有 150 个,剩下 50 个是历史遗留的废弃字段。嗯,这就是元数据管理没做好的典型后果。
核心原则:表元数据必须与物理存储一一对应。任何不一致,都会导致数据目录的「信用破产」。
下面是一个简化的 Hive 表元数据模型示例:
-- Hive 建表语句(附带元数据)
CREATE EXTERNAL TABLE dwd.order_detail (
order_id STRING COMMENT '订单ID',
user_id STRING COMMENT '用户ID',
product_id STRING COMMENT '商品ID',
amount DECIMAL(18,2) COMMENT '订单金额',
status INT COMMENT '订单状态: 1-已支付 2-已发货 3-已完成',
create_time TIMESTAMP COMMENT '创建时间'
)
COMMENT '订单明细表 - 每日全量'
PARTITIONED BY (dt STRING COMMENT '分区日期 yyyy-MM-dd')
STORED AS ORC
TBLPROPERTIES (
'transactional'='true',
'last_refresh_time'='2024-01-15 08:00:00'
);
你可能会问:为什么要把 last_refresh_time 放在 TBLPROPERTIES 里?因为这样可以直接通过 Metastore API 查到,不用去扫描数据文件。我习惯把调度相关的元数据也塞进去,比如数据源标识、负责人、数据等级。
4.2 分区与存储格式管理
分区是批式数据资产的核心管理手段。没有分区的表,在数仓里基本没法用——你想想,一张 10TB 的表,每次查询都全表扫描,谁受得了?
我建议的分区策略是这样的:
- 时间分区:按天分区是最常见的。如果数据量特别大(每天几百 GB),可以考虑按小时分区。
- 业务分区:比如按业务线、按地域。但要注意,分区字段不要太多,否则会产生大量小文件。
- 组合分区:比如
dt/business_line。但层级不要超过 3 层,否则查询性能反而下降。
注意:分区数量不是越多越好。我曾经见过一张表有 5000 多个分区,每个分区只有几十 MB 数据。这种「分区爆炸」会导致 NameNode 压力巨大,查询时元数据加载也慢。
存储格式这块,我直接给结论:
| 存储格式 | 压缩比 | 查询性能 | 适用场景 |
|---|---|---|---|
| ORC | 高(3-5x) | 优秀(支持谓词下推) | Hive 数仓首选 |
| Parquet | 中(2-4x) | 优秀(支持嵌套结构) | Spark 任务、跨引擎场景 |
| Avro | 低(1-2x) | 一般 | 数据交换、Kafka Sink |
| Text/CSV | 无 | 差 | 临时数据、导入导出 |
我个人习惯:数仓内部统一用 ORC,压缩选 ZSTD。ORC 的索引机制对批处理非常友好,而且 Hive 对 ORC 的支持最成熟。如果团队主要用 Spark,那 Parquet 也是好选择——Spark 对 Parquet 的优化做得比 ORC 好一些。
避坑指南:我曾经踩过一个坑——把一张大表从 Text 格式改成 ORC 后,发现某个下游 Spark 任务报错。查了半天,原来是那个任务用了 spark.sql.hive.convertMetastoreParquet=false,导致 Spark 用 Hive SerDe 去读 ORC 文件,性能反而更差。所以改存储格式时,一定要检查所有下游任务的配置。
4.3 批处理任务血缘
血缘,是数据目录的灵魂。没有血缘,你根本不知道一张表的数据从哪来、到哪去。
批处理任务的血缘,我把它分成三个粒度:
- 表级血缘:输入表 → 任务 → 输出表。这是最基础的,能回答「这张表依赖哪些上游表」。
- 字段级血缘:输入字段 → 转换逻辑 → 输出字段。这个更难,但价值更大——能精确定位某个字段的来源。
- 任务级血缘:任务之间的依赖关系。比如 A 任务跑完才能跑 B 任务。
在实际项目中,表级血缘最容易获取。你只要解析 Hive/Spark SQL 的 AST(抽象语法树),就能提取出 INSERT ... SELECT 语句中的输入输出表。字段级血缘就麻烦多了——遇到 CASE WHEN、UDF、子查询,解析逻辑会变得非常复杂。
下面是一个简化的血缘解析流程:
-- 原始 SQL
INSERT OVERWRITE TABLE dws.daily_sales PARTITION(dt='2024-01-15')
SELECT
o.user_id,
o.product_id,
SUM(o.amount) as total_amount,
COUNT(DISTINCT o.order_id) as order_cnt
FROM dwd.order_detail o
WHERE o.dt = '2024-01-15'
AND o.status = 1
GROUP BY o.user_id, o.product_id;
-- 解析后的血缘关系
-- 表级:dwd.order_detail → dws.daily_sales
-- 字段级:
-- dws.daily_sales.user_id ← dwd.order_detail.user_id
-- dws.daily_sales.product_id ← dwd.order_detail.product_id
-- dws.daily_sales.total_amount ← SUM(dwd.order_detail.amount)
-- dws.daily_sales.order_cnt ← COUNT(DISTINCT dwd.order_detail.order_id)
你想想看,如果没有字段级血缘,当业务方问「daily_sales 表的 total_amount 是怎么算的」,你只能回答「来自 order_detail」。但有了字段级血缘,你可以精确地说「来自 order_detail.amount 字段的 SUM 聚合」。这差距有多大,不言而喻。
我的建议:如果团队资源有限,先做表级血缘。等表级血缘稳定运行后,再逐步上字段级血缘。不要一上来就想搞大而全,容易翻车。
最后,我用一张图来总结批式数据资产建模的核心逻辑:
说白了,批式数据资产建模就是这三件事:把表结构管好、把分区存储优化好、把血缘关系理清楚。这三层做好了,你的数据目录就有了坚实的底座。