搭建一个离线数仓——从原始日志到 BI 报表

Hadoop 入门系列 · 第 9 篇

Posted by Corey on June 14, 2026

Hadoop 入门系列 · 第 9/10 篇
上一篇:《HBase 随机读写》
下一篇预告:《Hadoop 过时了吗?》


开头:老板要「昨天网站多少 PV、多少 UV」

产品经理每天上午 9 点要一份报表:

  • 昨日 PV(页面浏览量)
  • 昨日 UV(独立访客数)
  • Top 10 热门页面

原始数据是 Nginx 日志,格式乱七八糟,IP 有爬虫,URL 带参数。

你需要一套 离线数仓,把脏 raw log 一层层洗干净,最后变成 BI 仪表盘上的一行数字。


一、数据流向全景

flowchart LR
    A[Nginx 日志文件] --> B[HDFS 原始区]
    B --> C[ODS 层<br/>贴源层]
    C --> D[DWD 层<br/>明细层]
    D --> E[DWS 层<br/>汇总层]
    E --> F[ADS 层<br/>应用层]
    F --> G[(MySQL)]
    G --> H[BI 可视化<br/>Grafana / 帆软]
1
日志文件 → HDFS → Hive(ODS) → Hive(DWD) → Spark SQL(DWS) → MySQL(ADS) → 可视化

二、分层解释:ODS → DWD → DWS → ADS

层级 全称 做什么 表命名示例
ODS Operational Data Store 贴源,原样或轻度清洗 ods.nginx_log_raw
DWD Data Warehouse Detail 明细,去脏、统一字段、维度关联 dwd.page_view_detail
DWS Data Warehouse Summary 汇总,按主题预聚合 dws.page_pv_uv_daily
ADS Application Data Store 应用,面向报表的最终表 ads.daily_report

为什么要分层?

  • ODS:保留原始数据,出问题可回溯
  • DWD:一次清洗,多处复用
  • DWS:避免 BI 直接扫亿级明细,预聚合加速
  • ADS:对接 MySQL/BI,字段语义业务化

三、场景实战:网站 PV / UV 统计

3.1 原始日志样例

1
2
3
192.168.1.100 - u001 [14/Jun/2026:10:01:23 +0800] "GET /product?id=123 HTTP/1.1" 200 2048 "https://example.com" "Mozilla/5.0"
192.168.1.101 - u002 [14/Jun/2026:10:01:24 +0800] "GET /home HTTP/1.1" 200 512 "-" "Mozilla/5.0"
192.168.1.100 - u001 [14/Jun/2026:10:02:01 +0800] "GET /cart HTTP/1.1" 200 768 "-" "Bot/1.0"

3.2 ODS 层:贴源入库

1
2
3
4
5
6
7
8
9
CREATE EXTERNAL TABLE ods.nginx_log_raw (
  raw_line STRING
)
PARTITIONED BY (dt STRING)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\n'
LOCATION '/warehouse/ods/nginx_log_raw/';

-- 加载当天分区
ALTER TABLE ods.nginx_log_raw ADD PARTITION (dt='2026-06-14');

3.3 DWD 层:解析 + 清洗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
CREATE TABLE dwd.page_view_detail (
  ip          STRING,
  user_id     STRING,
  event_time  TIMESTAMP,
  url         STRING,
  status      INT,
  is_bot      BOOLEAN
)
PARTITIONED BY (dt STRING)
STORED AS ORC;

INSERT OVERWRITE TABLE dwd.page_view_detail PARTITION (dt='2026-06-14')
SELECT
  regexp_extract(raw_line, '^([\\d.]+)', 1)                          AS ip,
  regexp_extract(raw_line, '- ([^ ]+) \\[', 1)                       AS user_id,
  from_unixtime(unix_timestamp(
    regexp_extract(raw_line, '\\[([^\\]]+)\\]', 1),
    'dd/MMM/yyyy:HH:mm:ss Z'))                                        AS event_time,
  regexp_extract(raw_line, '"GET ([^ ]+)', 1)                         AS url,
  CAST(regexp_extract(raw_line, '" \\d+ (\\d+)', 1) AS INT)          AS status,
  raw_line LIKE '%Bot%'                                              AS is_bot
FROM ods.nginx_log_raw
WHERE dt = '2026-06-14'
  AND raw_line LIKE '%GET%';

清洗规则:

  • 解析 IP、用户、时间、URL
  • 标记 Bot 流量(is_bot = true
  • 过滤非 GET 请求(可选)

3.4 DWS 层:按日汇总 PV/UV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE TABLE dws.page_pv_uv_daily (
  url         STRING,
  pv          BIGINT,
  uv          BIGINT
)
PARTITIONED BY (dt STRING)
STORED AS ORC;

INSERT OVERWRITE TABLE dws.page_pv_uv_daily PARTITION (dt='2026-06-14')
SELECT
  url,
  COUNT(*)                    AS pv,
  COUNT(DISTINCT user_id)     AS uv
FROM dwd.page_view_detail
WHERE dt = '2026-06-14'
  AND is_bot = false
  AND status = 200
GROUP BY url;

全站 PV/UV:

1
2
3
4
5
6
7
SELECT
  dt,
  SUM(pv) AS total_pv,
  COUNT(DISTINCT user_id) AS total_uv  -- 需从 DWD 层算全站 UV
FROM dws.page_pv_uv_daily
WHERE dt = '2026-06-14'
GROUP BY dt;

更精确的全站 UV 应在 DWD 层:

1
2
3
SELECT COUNT(DISTINCT user_id) AS total_uv
FROM dwd.page_view_detail
WHERE dt = '2026-06-14' AND is_bot = false AND status = 200;

3.5 ADS 层:导出到 MySQL 供 BI 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE ads.daily_site_report (
  dt          STRING,
  total_pv    BIGINT,
  total_uv    BIGINT,
  top_url     STRING,
  top_url_pv  BIGINT
);

INSERT OVERWRITE TABLE ads.daily_site_report
SELECT
  '2026-06-14' AS dt,
  COUNT(*) AS total_pv,
  COUNT(DISTINCT user_id) AS total_uv,
  MAX(url) AS top_url,  -- 简化示例,实际用窗口函数
  MAX(pv) AS top_url_pv
FROM (
  SELECT url, user_id, COUNT(*) OVER() AS dummy
  FROM dwd.page_view_detail
  WHERE dt = '2026-06-14' AND is_bot = false
) t;
1
2
3
4
5
6
7
8
# Sqoop 导出到 MySQL
sqoop export \
  --connect jdbc:mysql://bi-db:3306/report \
  --username bi \
  --password xxx \
  --table daily_site_report \
  --export-dir /warehouse/ads/daily_site_report/dt=2026-06-14 \
  --input-fields-terminated-by '\001'

四、调度:Cron vs Airflow

4.1 Cron(简单场景)

1
2
# 每天凌晨 2 点跑 T+1 报表
0 2 * * * /opt/scripts/run_daily_etl.sh >> /var/log/etl.log 2>&1

run_daily_etl.sh

1
2
3
4
5
6
7
#!/bin/bash
DT=$(date -d 'yesterday' +%Y-%m-%d)

hive -e "ALTER TABLE ods.nginx_log_raw ADD IF NOT EXISTS PARTITION (dt='${DT}')"
hive -f /opt/sql/dwd_page_view.sql --hivevar dt=${DT}
hive -f /opt/sql/dws_pv_uv.sql --hivevar dt=${DT}
sqoop export ... --export-dir /warehouse/ads/.../dt=${DT}

4.2 Airflow(生产推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from airflow import DAG
from airflow.operators.bash import BashOperator
from datetime import datetime, timedelta

default_args = {
    'owner': 'data-team',
    'retries': 2,
    'retry_delay': timedelta(minutes=5),
}

with DAG(
    'daily_pv_uv_report',
    default_args=default_args,
    schedule_interval='0 2 * * *',
    start_date=datetime(2026, 6, 1),
    catchup=False,
) as dag:

    ods = BashOperator(
        task_id='load_ods',
        bash_command='hive -f /opt/sql/ods_load.sql --hivevar dt=',
    )

    dwd = BashOperator(
        task_id='build_dwd',
        bash_command='hive -f /opt/sql/dwd_page_view.sql --hivevar dt=',
    )

    dws = BashOperator(
        task_id='build_dws',
        bash_command='hive -f /opt/sql/dws_pv_uv.sql --hivevar dt=',
    )

    export_mysql = BashOperator(
        task_id='export_to_mysql',
        bash_command='/opt/scripts/sqoop_export.sh ',
    )

    ods >> dwd >> dws >> export_mysql

Airflow 优势:

  • DAG 可视化依赖
  • 失败重试 + 告警
  • 补跑历史分区(backfill)

五、完整架构图

flowchart TB
    subgraph ingest [采集]
        NG[Nginx]
        FL[Flume/Kafka]
    end

    subgraph hdfs [HDFS]
        RAW[/logs/raw/]
        WH[/warehouse/]
    end

    subgraph hive [Hive 数仓]
        ODS[ODS]
        DWD[DWD]
        DWS[DWS]
        ADS[ADS]
    end

    subgraph serve [服务]
        MY[(MySQL)]
        BI[BI Dashboard]
    end

    subgraph sched [调度]
        AF[Airflow DAG]
    end

    NG --> FL --> RAW --> ODS --> DWD --> DWS --> ADS
    ADS --> MY --> BI
    AF -.->|触发| ODS & DWD & DWS & ADS

本节小结

层级 职责
ODS 贴源,保留原始
DWD 清洗明细
DWS 主题汇总
ADS 报表应用
调度 Cron 简单 / Airflow 生产
出口 Sqoop → MySQL → BI

下篇预告

第 10 篇(系列收官):《Hadoop 过时了吗?——从大数据编年史看下一代架构》

  • Hadoop 还用在哪儿
  • Spark / Flink / 云原生
  • Lambda → Kappa → 湖仓一体
  • 学习路线回顾

思考题

DWS 层已经按 URL 汇总了 PV,BI 还要查「各城市 PV」—— 应该回 DWD 层重算,还是在 DWS 加一张新表?为什么?

提示:DWD 有 city 维度(需关联 IP 地域库),DWS 按 URL 汇总丢失了 city —— 应新建 dws.page_pv_by_city_daily,不要硬从 URL 汇总表反推。

下一篇见 🐘