离线数仓技术栈
小黄已经学习了基础的数仓建模理论知识,现在要开始学习离线数仓技术栈,真正的开始走向实战了,还是照旧,跟着AI学习技术栈。
为什么会有Hadoop
对于Hadoop,之前我一直存在一种误解,以为他是一个类似于MySQL的数据库,其实不然,Hadoop其实是一个平台,接下来我们来研究下为什么Hadoop会诞生。
MySQL管理大数据
假设现在有100TB的数据需要存储,MySQL查询起来可能比较吃力,可能需要一个更大的服务器,或者依靠分库分表,但是业务查询起来会非常头疼,所以当数据量还会增长时,只能加大磁盘空间。
Hadoop管理大数据
Hadoop就完全不一样了,他可以解决两个核心问题
- 数据太大:
HDFS分布式存储 - 计算太慢:
MapReduce或者Spark
原理是什么呢?比如有3台服务器组成Hadoop,现在需要存储3000万条数据,A服务器存储0-1000万条,B存储1000万-2000万条,C存储2000万-3000万条,将数据分散到不同服务器存储,降低了单台服务器的空间,这就是分布式存储。那计算能力的提升也是一样,本来单台服务器查询只使用单台服务器的CPU,而3台服务器可以使用三台服务器的CPU进行查询,效率成倍提升。
总结就是:因为单机已经无法存储和计算海量数据,所以需要把存储和计算能力分散到多台普通服务器上。
HDFS存储原理
假设有 10 台服务器,每台都有 10TB 磁盘。
请思考两个问题:
- 是不是这 10 台服务器加起来就一定能存 100TB 数据?
- 如果其中一台服务器突然坏了,上面的数据怎么办?
这是小黄给出的回答:
我觉得10台服务器不一定能存100tb的数据,要考虑系统本身占用资源,而且我觉得他为了让数据关联起来是不是还会做一些类似于连接的指标,这也会占用一点空间。如果一台服务器坏了是不是会有备份?
HDFS默认会保存多个副本,假如有一个100MB的文件,HDFS不只会存一份,而是
Server1 保存一份
Server2 保存一份
Server3 保存一份
也就是说100MB的文件实际占用了300MB,如果副本数是3,那么100TB的空间大约能存33TB。
其实副本机制回答了上述的两个问题,因为有副本机制的存在,就算一台服务器物理层面的毁坏了,数据也不会丢失,HDFS会用空间换可靠性。
Block
Block是HDFS最小存储单位,假设现在有500MB的文件,他不会直接把500MB存到server1中而是先把文件切成块,再放到不同的服务器
Block1(128MB)
Block2(128MB)
Block3(128MB)
Block4(116MB)
为什么要这样存储?
- 因为一台机器可能放不下,这只是500MB的数据,未来可能是100TB甚至500TB,所以必须要拆成块存储
- 可以并行读取,假设需要查500MB的数据,如果都存在一台服务器上,那么读的时候其他服务器都是空闲的,而现在可以调用4台服务器同时读取,速度快很多。
另外块是128MB也是经典默认值,可以根据自己的配置调整,如果1MB的话,分的块很多,那每个块存放在哪里的数据就要保存很多份,对NameNode节点是个灾难,而如果Block太大的话,可能无法同时充分利用到所有的服务器。
NameNode
NameNode是Hadoop的集群的主节点,不用来存储数据,只用来保存元数据(Matadata)
例如:
sale_order.csv
↓
Block1
↓
Server1
-----------------------------------
sale_order.csv
↓
Block2
↓
Server5
-----------------------------------
sale_order.csv
↓
Block3
↓
Server8
它主要记录的是一个文件拆成了哪些Block,对应的Block存储再哪台服务器上。
DataNode
NameNode的作用是用来管理数据,而DataNode就是真实存储数据的地方,也就是上面对应的Server1这种机器。
总结
总结一下整个存储流程
写入过程
客户端
↓
上传文件
↓
切成多个 Block
↓
分散到多个 DataNode
↓
NameNode
记录:
Block 的位置
读取过程
客户端
↓
问 NameNode
↓
得到 Block 位置
↓
直接找 DataNode
↓
组装成完整文件
HDFS高可用机制
接下来我们需要更加深入研究HDFS的高可用性
心跳机制(Heartbeat)
上面说到NameNode管理DataNode,这里有一个问题,NameNode怎么知道哪些DN(DataNode简称)是在线的哪些是离线的,答案就是心跳机制,这个其实对Java开发来讲应该比较熟悉,Java服务注册到Nacos,也是通过服务向Nacos发送心跳Nacos才知道该服务还在线。
同理,DN定时向NameNode发送心跳告知自己还存在。
副本恢复
假设集群存在 DN1、DN2、DN3、DN4 四台 DataNode,各节点数据块分布:
- DN1:Block1,Block2,Block3
- DN2:Block2
- DN3:Block2,Block4
集群默认副本数为 3,当 DN2 宕机:
- NameNode 接收不到 DN2 心跳,判定 DN2 失效;
- NN 查询元数据,发现 Block2 原本三份副本,DN2 下线后仅剩 DN1、DN3 两份,副本数量不足;
- NN 依据机架副本放置策略,选取健康节点 DN4,下发副本重建任务;
- DN4 主动向存有 Block2 的 DN1 或 DN3 拉取 Block2 数据,本地生成新副本;
- DN4 完成存储后上报 NameNode,NN 更新元数据,此时 Block2 副本恢复为 DN1、DN3、DN4 三份。
NameNode 只调度,数据块的复制传输全部由 DataNode 之间完成。
Block Report(块汇报)
块汇报和心跳都属于 DN 上报 NN 的机制,但作用完全不同:
- 心跳是高频轻量上报,仅告知 NameNode 当前 DN 存活,同时接收 NN 下发的数据操作指令,不携带本机全部数据块信息;
- 块汇报为定期全量上报,DN 会把本机存储的所有 Block 完整清单发送给 NN,NN 依靠这份清单核对全局块副本情况,检测副本丢失、多余副本、损坏数据块。
安全模式
当HDFS开启了安全模式,所有数据都只能读不能写,比如服务刚启动,NameNode还没收到心跳和块汇报,无法确认数据完整性,这时候就会开启安全模式,直至确认数据完整。
NameNode HA
NameNode HA 就是 NameNode 的高可用方案。
正常读写业务都通过 Active(主)NameNode 交互 DataNode。单台 NameNode 存在单点故障:一旦主 NN 宕机,整个 HDFS 无法提供读写服务,业务直接瘫痪。
因此 HA 架构部署一主一备两台 NameNode:
- 正常工作时只有 Active 主节点对外提供读写服务,Standby 备节点不处理客户端请求;
- 主节点故障后,集群自动完成故障转移,Standby 升级为新的 Active,接管全部读写工作,业务无需人工干预即可恢复。
Hive
Hive 是 HDFS 上的 SQL 数据仓库工具,它不存储数据,而是管理元数据,并把 HDFS 上的文件映射成一张张可以使用 SQL 查询的表。
因为数据存在HDFS上,我们写SQL语句读文件,因为HDFS只是文件系统,他不知道什么是SELECT等,所以这时候Hive就出现了,Hive会建立一张表,告诉大家这张表对应的是这个目录
CREATE TABLE ods.sale_order
(
order_id STRING,
customer STRING,
amount DECIMAL(18,2)
)
STORED AS PARQUET
LOCATION '/warehouse/ods/sale_order/'; 实际文件目录
Hive实际上保存的也是元数据
- 表名
- 字段名
- 字段类型
- 分区信息
- 数据格式(Parquet、ORC、Text 等)
- 数据所在位置(HDFS 路径)
例如:
表名:
ods.sale_order
字段:
order_id
customer
amount
格式:
Parquet
位置:
/warehouse/ods/sale_order/
分区
分区的本质是把一张超大的逻辑表,按某个维度拆分成多个物理目录,从而减少扫描的数据范围,提高查询效率
例如建表的时候确定了分区字段
CREATE TABLE dwd_sale_order (
order_id STRING,
customer STRING,
amount DECIMAL(18,2)
)
PARTITIONED BY (dt STRING)
STORED AS ORC;
他仅仅是把这两个HDFS的目录分成了这样
/warehouse/dwd_sale_order/
├── dt=20260701/
│ ├── 0000.orc
│ └── 0001.orc
│
├── dt=20260702/
│ ├── 0000.orc
│
└── dt=20260703/
├── 0000.orc
比如我们执行下面SQL,他会命中分区(20260702),只会查询这个分区下的文件,就不会去全表检索了
select * from dwd_sale_order where dt = 20260702
但是执行下面的sql,他就只能全表检索
select * from dwd_sale_order where customer = 'zhangsan'
静态分区
以下这段SQL,通过PARTITION(dt='2026-07-01')指定了分区,Hive直接把这些数据放到dt=2026-07-01
INSERT OVERWRITE TABLE ods_sale_order
PARTITION(dt='2026-07-01')
SELECT
order_id,
customer,
amount
FROM tmp_sale_order;
动态分区
下面这段SQL,dt是一个参数,他的值取决于tmp_sale_order表中的dt,Hive会根据他实际上属于那一天放到对应的分区,这就是动态分区。
INSERT INTO ods_sale_order
PARTITION(dt)
SELECT
order_id,
amount,
dt
FROM tmp_sale_order;
ORC 和 Parquet
这里要讲一下行存 和列存的概念
行存
MySQL就是典型的行存,磁盘是存的数据如下
1 张三 浙江 手机 3999 1
2 李四 江苏 电脑 5999 1
3 王五 广东 鼠标 199 2
这样存储的好处是什么呢?因为在Web开发中,最常用的是要查询这一行数据信息,比如select * from user where name = '张三',所以这样效率更高
列存
同样存储上面这些信息,列存的话格式如下
order_id
1
2
3
customer
张三
李四
王五
province
浙江
江苏
广东
price
3999
5999
199
这样有什么好处呢?因为数仓基本上分析的都是聚合,比如今天要看销售额select SUM(price) from xxx,他不张三李四,也不关心浙江江苏。
ORC和Parquet都是列式存储
| ORC | Parquet |
|---|---|
| Hive 最喜欢 | Spark 最喜欢(但 Hive 也支持) |
| 压缩率更高 | 兼容性更好 |
| Hive 生态 | 大数据生态通用 |
Spark
Spark 是一款基于内存、高速通用的分布式大数据计算引擎,可一站式完成批处理、流计算、交互式查询与机器学习任务。
分区(Partition)
**Partition 是计算单位。**Spark会产生多个Partition,就跟HDFS把文件分成块一样,但是目的不同,一个是为了存储,一个是为了计算。
| HDFS Block | Spark Partition |
|---|---|
| 存储单位 | 计算单位 |
| HDFS 管 | Spark 管 |
| DataNode 保存 | Executor 处理 |
| 负责放数据 | 负责算数据 |
有些人会问为什么不直接使用Block,而是单独搞出一个分区?
我认为Spark是比HDFS高一层的概念,所以不应该直接用Block,他主要目的是为了自己怎么计算方便,并且Spark可以承接其他数据库,并非单一对接HDFS。
执行流程
先来看一下一条普通SQL再MySQL中查询和SparkSQL中查询的执行顺序。
| 步骤顺序 | MySQL 执行流程 | Spark SQL 执行流程 |
|---|---|---|
| 1 | 客户端提交 SQL | 编写 SQL 提交 |
| 2 | MySQL 接收 SQL | Spark SQL 接收 SQL |
| 3 | 解析 SQL | Parser 解析 |
| 4 | 优化 SQL | 生成逻辑计划 Logical Plan |
| 5 | 单机执行 SQL | Catalyst Optimizer 优化器优化 |
| 6 | 返回结果 | 生成物理计划 Physical Plan |
| 7 | - | 生成 Task |
| 8 | - | Executor 并行计算 |
这样放在一起对比,不难看出SparkSQL也需要解析优化,但是在优化后多了三步:生成物理计划 → 拆分 Task → 多 Executor 分区分布式并行计算,靠多机器分担大数据量聚合计算。
分区裁剪
假设有一个ODS表:他是按照dt分区的
sql
CREATE TABLE ods_sale_order(
order_id STRING,
order_date DATE,
customer STRING
)
PARTITIONED BY (dt STRING);
-
where order_date > '2026-07-01'他不会走分区,还是会全表扫描 -
where dt > '2026-07-01',他会命中分区
也就是说在筛选条件时,永远优先使用分区字段过滤,而不是对业务字段做函数计算。
生效条件
- 表必须是分区表,过滤字段为分区键;
- 过滤条件是常量等值 / 范围(
=、in、<、>、between); - 不对分区字段做函数、类型转换运算;
- 过滤条件能在逻辑计划阶段被 Catalyst 识别。
Shuffle
Shuffle的作用是重新分布数据。
例如有一张DWD表,业务需求要每个客户的销售额
| order_id | customer | amount |
|---|---|---|
| 1 | A | 100 |
| 2 | B | 200 |
| 3 | A | 300 |
| 4 | C | 100 |
| 5 | B | 150 |
Spark 把它分成两个 Partition。
Partition1
----------------
A 100
B 200
A 300
Partition2
----------------
C 100
B 150
那现在Partition1汇总后可以得到:
A 400
B 200
Partition2汇总后可以得到:
C 100
B 150
但是最终结果应该是
| customer | amount |
|---|---|
| A | 400 |
| B | 350 |
| C | 100 |
这时候就轮到Shuffle出场了,Shuffle会把数据重新分布,将key相同的数据放到同一个分区中,当然说的是计算分区,也就是变成:
新的Partition1
A400
C100
新的Partition2
B200
B150
为了计算,把数据从一个 Executor 发送到另一个 Executor。
所以这里就会引出一个性能调优的问题,只要涉及到发送,就会涉及到网络传输,网络传输的耗时就比较长了。
数据倾斜
这个是企业中会面临的非常头疼的问题。
假设有 4 个 Shuffle Partition。
正常情况下,希望是:
Partition1:250万
Partition2:250万
Partition3:250万
Partition4:250万
4 个 Executor 一起算。假设每个需要 1 分钟。整个任务大约 1 分钟结束。
但是因为Shuffle把数据重新分布了,如果这些数据中有一个客户占了大头,而其他999个客户只占一部分,洗牌后就会变成
Partition1(客户A):999万
Partition2:3000
Partition3:4000
Partition4:3000
结果,那Spark就必须要等最长久的任务算完才能算结束,整个任务速度取决于最慢的那个 Partition。
Executor1:算999万(20分钟)
Executor2:2秒结束
Executor3:2秒结束
Executor4:2秒结束
Broadcast Join
Broadcast Join翻译过来是广播 join。在我们日常写sql时,join总是最常见的,join的时候最怕两个数据不在一个Executor上,这样就需要传输数据,影响效率。
而Broadcast Join就是把一些数据量比较小的,直接复制到各个Executor上,从而减少传输,比如客户数据5000个,直接在Executor上存一分,需要的时候直接去查,就不需要网络传输消耗了。
常用的就是事实表join维度表。
Join
│
┌───────────┴───────────┐
│ │
Shuffle Join Broadcast Join
│ │
大表移动 小表广播
│ │
网络开销大 避免大表 Shuffle
│ │
适合大表Join大表 适合大表Join小表
Hint
这是一个强制广播的语法,默认join的表10MB以内他都会自动广播,但有时候可能别人把自动广播禁用了,导致无法使用广播功能,/*+ BROADCAST(c) */的意思是强制广播c表
sql
SELECT /*+ BROADCAST(c) */
o.order_id,
c.customer_name
FROM dwd_order o
LEFT JOIN dim_customer c
ON o.customer_id = c.customer_id;
AQE
AQE简单来讲就是Spark优化器,之前说的shuffle问题、数据倾斜、广播join、小文件都可以自适应优化
YARN
一句话:
YARN 是 Hadoop 集群的资源管理器。
它只做三件事:
-
管 CPU
-
管内存
-
管 Executor
spark-submit │ ▼ Driver(总指挥) │ 向 YARN 申请资源 │ ▼ ┌────────┬────────┬────────┐ ▼ ▼ ▼Executor1 Executor2 Executor3
│ │ │
└────────┴────────┘
并行计算
│
▼
写回 Hive/HDFS
DataX
DataX 是一个离线数据同步框架。
之前一直在讲怎么在数仓里面操作数据、分层等,其实数据分析最重要的一步是从业务数据库把数据拉过来。
像我之前同步ERP都某些表,自己用Java写的时候,需要查询出来数据在插入到另外一个数据库,几乎每个表都需要写代码同步,并且数据太大了还容易OOM,而DataX就完美的解决了这个问题。
DataX Job
DataX 本身几乎不用写 Java 代码,而是写 JSON 配置。
基本上整体结构长这样,reader负责从哪里度数据,writer负责数据写到哪里。
json
{
"job": {
"content": [
{
"reader": {
...
},
"writer": {
...
}
}
]
}
}
例如同步订单
json
"reader":{
"name":"mysqlreader",
"parameter":{
"username":"root",
"password":"***",
"connection":[
{
"table":[
"sale_order"
]
}
]
}
}
"writer":{
"name":"hdfswriter",
"parameter":{
"path":"/warehouse/ods/sale_order"
}
}
一般来说同步ODS层的数据前,都先把Hive表建好,DataX是不会帮你建Hive表的,只负责把数据存到HDFS上
sql
CREATE TABLE ods_sale_order (
order_id BIGINT,
amount DECIMAL(18,2)
)
PARTITIONED BY(dt STRING)
STORED AS ORC
LOCATION '/warehouse/ods/sale_order';
自动生成Job
刚刚看上面,如果我们同步一张表,也是需要写一个Json,那其实跟Java也没啥区别
一般来说会有一张配置表
| 表名 | 是否同步 | 同步方式 |
|---|---|---|
| customer | Y | 全量 |
| supplier | Y | 增量 |
| material | Y | 全量 |
| sale_order | Y | 增量 |
然后程序:
循环:
for(每张表){
生成json
调用DataX
}
所以:
真正写的 DataX JSON 可能只有一个模板。
例如:
{
"reader": {
"table":"${tableName}"
},
"writer": {
"path":"/warehouse/ods/${tableName}"
}
}