快速上手数仓基础知识
-
- 一、 数仓解决了一个什么问题?为什么要设计数仓?
-
- 那数仓时怎么解决这个问题的呢?
- 二、数仓的数据究竟是怎么存储的?
-
- 2.1 Parquet存储格式
- 2.2 HDFS分布式存储
-
- 那我该怎么知道这些文件块存储在哪里了呢?
- 只靠列存来优化查询速度吗?
- 三、交互接口 Hive SQL
-
- 3.1 支持原生复杂数据类型
- 3.2 支持拆分数组
- 3.3 窗口函数
- 3.4 Join策略
- 3.5 SQL执行顺序
- 四、计算引擎Spark
-
- 4.1 MapReduce
- 4.2 为什么要有Spark
- 4.3 DAG 是什么
- 4.4 RDD 解决了什么问题
- 4.5 shuffle 作用是什么
- 五、 数仓分层模型
一、 数仓解决了一个什么问题?为什么要设计数仓?
传统数据库(OLTP)处理海量数据时耗时过长,当数据量到达TB甚至PB级的时候,MySQL的表现就极差,即使加上索引和分库分表也难以解决这个问题,因为MySQL的B+数本来就针对单点查询,不擅长做全表扫描
那数仓时怎么解决这个问题的呢?
基本上有这四个方面:
- 改用分布式存储
- 改变了存储方式,改行存储为列存储,便于后续聚合查询以及应用统计
- 引入分布式计算
- 分层冗余架构设计
那接下来我们就一层层的解析数仓的这四个方面
二、数仓的数据究竟是怎么存储的?
这里面其实分为两个部分
- 文件在服务器上怎么存
- 文件内部什么格式存
这分别跟两个不同的概念有关------HDFS,Parquet
2.1 Parquet存储格式
传统的数据库存储的格式都是行存储,这样存储的优点在于能够快速读取数据,修改数据,但想一下,如果遇到某种情况需要统计某一列的总和,或者均值,这种情况下该怎么办?只能一行行的把数据读取出来再进行聚合,显然这样操作的效率非常低。
而Parquet存储格式改用了列存储,就是为了应对这样的统计场景,当需要单独查询某一列数据的时候,只需要单独将这一列拿出来即可,这样的IO消耗显然不是一个量级的。
在其尾部维护了一些信息,记录了每列的最大值和最小值,方便后续查询
2.2 HDFS分布式存储
即使用上了parquet列存格式,但是tb甚至pb级的文件也不能直接作为单文件存储,这样并不符合容灾要求,一旦出问题很难处理。所以HDFS采取了两个措施:
- 文件分块,把整个数据文件切成一个个的128MB的Block,存放在不同的服务器上
- 每块文件做备份处理,通常情况下,会把一个Block存储三份,其中两份再同一机架上的不同服务器,减少单服务器宕机时跨机架造成的延迟。另一份跨机架存储,用作最后保险,防止整个机柜出问题。
那我该怎么知道这些文件块存储在哪里了呢?
HDFS有一个"大脑",用来记忆那些文件存储在哪里了,这个就是NameNode,但这个存储并不是持久化在本地的,这个是动态维护的,由各个服务器定时心跳上报各自存储了哪些文件的哪几个block,在查询的时候就能精确定位。
在mysql层也维护了一张元数据的表 Hive Metastore 记录了要查询的表所在分区的物理位置与行列信息
只靠列存来优化查询速度吗?
当然不是,parquet设计的理念其实是"不读数据",也就是在查询的时候对数据进行最大程度的优化
- 分区裁剪:在建表的时候就设立分区字段,parquet存储也会按照分区字段来存储在不同的路径下,常见的就是用日期做分区字段
- 列裁剪:尽可能避免使用select * ,只检索需要的列
- 谓词下推: 根据parquet footer的数据进行过滤,比如 parquet footer存储了这块的这列数据的最大值就是 500,那我查询 >700的数据肯定就不在这块中,就把这块过滤掉了,本质上是把where条件从SQL执行器推到了存储层
- Bloom Filter: 一个哈希位数组,用来判断这个数据是否一定不在这个列中。
Bloom Fifter怎么工作的?
内部有一个位数组和K个哈希函数
这列的每个数据在插入时都会遍历这些哈希函数,并将命中的位数组下标对应的值置为1
要查询一个值是否在这列中,只需要遍历哈希函数,看看对应的位数组值是否都为1即可
都为1不一定在,可能会有hash碰撞,但如果有某个值不为1,则一定不在列中
三、交互接口 Hive SQL
虽然Hive SQL 看上去跟我们平常的SQL差不多,但是Hive表实际上跟普通的数据库有很大的区别。
Hive表的元数据存储在MySQL中(Hive Metastore),而真实的数据则是存储在HDFS中的,这就导致了很多问题,hive表没有外键,没有索引,也不支持行级修改,因为底部的parquet文件想要修改只能够重写一遍,所以修改代价极其昂贵,更新 = 全量覆盖。
总而言之,Hive表 = Hive Metastore + HDFS
3.1 支持原生复杂数据类型
- Array:有序列表
SQL
CREATE TABLE user_tags (
user_id BIGINT,
tags ARRAY<STRING> -- 标签数组
)
STORED AS PARQUET;
-- 插入
INSERT INTO user_tags VALUES
(1001, ARRAY('学生', '科技', '游戏')),
(1002, ARRAY('宝妈', '美食'));
-- 查询
SELECT tags[0] FROM user_tags WHERE user_id = 1001; -- 取第一个标签
SELECT SIZE(tags) FROM user_tags; -- 数组长度
SELECT * FROM user_tags WHERE ARRAY_CONTAINS(tags, '科技');
- Map: 键值对
SQL
CREATE TABLE user_properties (
user_id BIGINT,
props MAP<STRING, STRING> -- 动态键值对
);
INSERT INTO user_properties VALUES
(1001, MAP('city', '北京', 'vip_level', 'V3'));
-- 查询
SELECT props['city'] FROM user_properties WHERE user_id = 1001; -- 取 'city'
SELECT MAP_KEYS(props), MAP_VALUES(props) FROM user_properties;
- Struct :嵌套对象
SQL
CREATE TABLE orders (
order_id BIGINT,
address STRUCT<province:STRING, city:STRING, street:STRING>
);
INSERT INTO orders VALUES
(1, NAMED_STRUCT('province', '北京', 'city', '北京', 'street', '中关村'));
-- 查询(用点操作符,像访问对象字段)
SELECT address.city FROM orders;
SELECT address.province, address.street FROM orders WHERE order_id = 1;
3.2 支持拆分数组
因为原生支持数组,所以有时候需要将数组拆开,比如上面数组那个例子,要查询每个标签下有多少用户,那就必须把数组拆开。
SQL
SELECT user_id, tag
FROM user_tags
LATERAL VIEW EXPLODE(tags) t AS tag;
结果类似于这样
user_id | tag
1001 | 学生
1001 | 科技
1001 | 游戏
1002 | 宝妈
1002 | 美食
多个列都需要展开的话,最终结果是所有列的笛卡尔积
3.3 窗口函数
最常用的窗口函数就是排序了,这也是统计场景中经常会出现的
分别是ROW_NUMBER,RANK,DENSE_RANK,举个例子
SQL
SELECT user_id, amount,
ROW_NUMBER() OVER(PARTITION BY city ORDER BY amount DESC) AS rn,
RANK() OVER(PARTITION BY city ORDER BY amount DESC) AS rk,
DENSE_RANK() OVER(PARTITION BY city ORDER BY amount DESC) AS drk
FROM orders;
结果:
| user_id | amount | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| A | 100 | 1 | 1 | 1 |
| B | 100 | 2 | 1 | 1 |
| C | 90 | 3 | 3 | 2 |
| D | 80 | 4 | 4 | 3 |
ROW_NUMBER:无并列,相同时随机排序
RANK:允许并列,不连续
DENSE_RANK:允许并列,且连续
OVER()的完整结构
OVER (
PARTITION BY <列表> -- 分组:把数据切成几组
ORDER BY <列表> -- 排序:组内按什么排
ROWS BETWEEN ... AND ... -- 窗口框架:取哪几行
)
这三个部件都可以省略,简单来说就是
PARTITION BY:表示聚合口径
ORDER BY:表示排序口径,也可表示累计范围
ROWS BETWEEN ... AND ... :一般滑动窗口统计的时候才用
order表示统计范围可能不太好理解,举个例子
sql
SELECT user_id, order_time, amount,
SUM(amount) OVER(PARTITION BY user_id ORDER BY order_time) AS cum_amount
FROM orders;
| user_id | order_time | amount | cum_amount |
|---|---|---|---|
| 1001 | 2026-04-20 10:00 | 100 | 100 |
| 1001 | 2026-04-21 15:30 | 200 | 300 |
| 1001 | 2026-04-22 09:15 | 50 | 350 |
| 1002 | 2026-04-20 11:00 | 80 | 80 |
| 1002 | 2026-04-21 14:00 | 120 | 200 |
| 1003 | 2026-04-22 20:00 | 500 | 500 |
3.4 Join策略
由于hive场景下有的表过于大,且数据都是分布式存储在不同的机器上,而mysql只需要单机内存即可完成
这时候就有三种场景:
- Broadcast Hash Join(BHJ)------ 大表+小表 将小表装进内存广播给大表(一般是小于10MB)
- Shuffle Hash Join(SHJ)------ 中等表 + 中等表 两张表都按照key shuffle到相同分区,分区内用小的表构建hashmap,大的查表
- Sort Merge Join(SMJ)------ 大表 + 大表(默认策略) 两张表都shuffle到相同分区,各自排序后用双指针归并
3.5 SQL执行顺序
FROM → WHERE → GROUP BY → HAVING → 窗口函数 → SELECT → ORDER BY
这个顺序决定了上一步没法使用下一步定义的别名
四、计算引擎Spark
4.1 MapReduce
mapreduce是hive的上一代计算引擎,也是分布式计算的起点
他的流程大致分为三个阶段
-
Map 处理
-
Shuffle 分组
-
Reduce 聚合
举个例子输入文件: "hello world hello spark"
Map 阶段(每行独立处理):
("hello", 1), ("world", 1), ("hello", 1), ("spark", 1)Shuffle 阶段(按 key 分组):
"hello" → [1, 1]
"world" → [1]
"spark" → [1]Reduce 阶段(聚合):
("hello", 2), ("world", 1), ("spark", 1)
这样做可以实现我们的目标,但是也存在很多问题:
- 磁盘IO巨大:其中的每一步都要落库,这三阶段需要至少落库三次
- 强制两阶段:所有计算必须套用上面的流程
- 性能差:每次迭代都需要读写HDFS,慢
4.2 为什么要有Spark
上一代的MapReduce存在太多问题需要优化,所以spark引擎应运而生
- 磁盘IO问题,Spark优先使用内存,不会每步操作都去落盘
- DAG 任意算子链构成有向无环图
- RDD 缓存复用
可以大概看出来,Spark快在哪?快在把MR原先的"磁盘IO"瓶颈 变成了现在的"内存+shuffle优化"瓶颈
spark的执行流程是这样的
SQL / DataFrame API
↓ (Catalyst 解析)
Unresolved Logical Plan
↓ (绑定 Hive Metastore 元数据)
Resolved Logical Plan
↓ (规则优化:谓词下推、列裁剪、常量折叠)
Optimized Logical Plan
↓ (Cost-Based Optimizer 选 Join 策略)
Physical Plan
↓ (切分 Stage)
DAG → Stage → Task
↓
Executor 执行
4.3 DAG 是什么
spark比起 MR 支持了更多的算子,而DAG就是负责把所有算子画成一张有向无环图
Spark 支持丰富的算子(Transformation):
单元素变换 : map 、 filter 、 flatMap
聚合类 : reduceByKey 、 groupByKey 、 aggregateByKey
Join 类 : join 、 leftOuterJoin 、 cogroup
集合类 : union 、 intersect 、 distinct
排序类 : sortBy 、 sortByKey
Spark SQL 拿到整张DAG之后,能在全局角度下做优化,就比如之前提到过的谓词下推,列裁剪...
4.4 RDD 解决了什么问题
DAG解决了shuffle优化问题,那RDD解决的就是内存问题。
那问题就来了,为什么RDD不怕内存数据丢失呢?万一宕机了怎么办?
因为RDD存储的不是数据本身,存的是"怎么算"的这条血缘记录,一旦宕机,可以沿着血缘链重新计算该分区,利用重算代替副本。
4.5 shuffle 作用是什么
shuffle 是造成整个过程的主要延迟来源,他主要的作用是跨节点按照key重新分发数据
上游task将自己的结果按照hash(key)分桶写到磁盘
下游task拉取属于自己的数据进行归并处理
为什么慢?
- 磁盘 IO :Shuffle Write 必须落盘(容错 + 解耦上下游)
- 网络 IO :M × R 的跨节点传输
- 序列化 :对象 ↔ 字节流,CPU 开销大
- 小文件 :分区数过多时产生海量小文件
优化
在spark3.0 引入了AQE概念,可以给程提供优化建议,比如:
- 合并小分区
- 拆分倾斜分区
- 切换join策略
在不使用AQE的情况下,数据倾斜也可以通过 插入随机数进行分桶,单独处理倾斜分区等方法进行处理。
五、 数仓分层模型
当然了,数仓也是需要分层的,为了保证数据的 复用、隔离、可追溯
大概分为四层 ODS,DWD,DWS,ADS
- ODS - 原始数据仓库
从数据源原封不动地把数据读取出来,保证数据原的可追溯性 - DWD层 - 明细数据
对数据进行清洗:NULL、异常值统一处理,枚举值标准化
字段冗余:把一些常用维度表的join数据冗余到事实表中(需要手动冗余)
eg:对typeid字段冗余对应的名称 - DWS - 汇总层
统一汇总口径,避免同一数据由于不同部门产生不同的统计口径
将数据按照常用的维度汇总成半成品
eg:按照日期x城市算出总销售额,订单数等 - ADS - 应用层
面向BI报表,数据看板等直接应用场景
注:这四层每层都需要直接依赖上一层的数据,不允许同层依赖(DWS层可对不同粒度进行同层依赖,如7D数据依赖1D数据)