小肥柴的Hadoop之旅 快速实验篇(B01)用户行为日志画像与 DWD 标准化
电商用户行为分析实战 · B 组 · 第一阶段
数据源:
user_visit_action.txt(18.6 MB,180570 行)技术栈:Hadoop MR + Hive on MR
目录
- 前言
- [0. 实验全景:从原始日志到 DWD 分层](#0. 实验全景:从原始日志到 DWD 分层)
- [1. 数据源与业务语义](#1. 数据源与业务语义)
- [1.1 数据源结构](#1.1 数据源结构)
- [1.2 三类字段:共性、行为、维度](#1.2 三类字段:共性、行为、维度)
- [1.3 三种"没有值"的语义区分](#1.3 三种"没有值"的语义区分)
- [1.4 行为判定规则与互斥声明](#1.4 行为判定规则与互斥声明)
- [2. 实验架构:本地编译 + 集群运行](#2. 实验架构:本地编译 + 集群运行)
- [2.1 环境分层](#2.1 环境分层)
- [2.2 数据流全景](#2.2 数据流全景)
- [2.3 五张 Hive 表的依赖链](#2.3 五张 Hive 表的依赖链)
- [3. 六条假设:从声明到实测](#3. 六条假设:从声明到实测)
- [3.1 H1 结构完整性](#3.1 H1 结构完整性)
- [3.2 H2 行为互斥](#3.2 H2 行为互斥)
- [3.3 H3 日期分布:推翻"单日"假设](#3.3 H3 日期分布:推翻"单日"假设)
- [3.4 H4 品类-产品配对:推翻"配对"假设](#3.4 H4 品类-产品配对:推翻"配对"假设)
- [3.5 H5 文件顺序 vs 时间顺序](#3.5 H5 文件顺序 vs 时间顺序)
- [3.6 H6 脏数据密度:远比预期干净](#3.6 H6 脏数据密度:远比预期干净)
- [4. MR 清洗程序](#4. MR 清洗程序)
- [4.1 Mapper:字段级语义显式化](#4.1 Mapper:字段级语义显式化)
- [4.2 Driver:Tool + ToolRunner 写法](#4.2 Driver:Tool + ToolRunner 写法)
- [4.3 Counter:MR 里的免费画像工具](#4.3 Counter:MR 里的免费画像工具)
- [4.4 执行命令与
-D参数位置](#4.4 执行命令与 -D 参数位置)
- [5. Hive 建表与依赖链](#5. Hive 建表与依赖链)
- [5.1 五张 HQL 脚本的作用与依赖](#5.1 五张 HQL 脚本的作用与依赖)
- [5.2 外部表 + ORC 两级设计](#5.2 外部表 + ORC 两级设计)
- [5.3 Derby 元数据的目录漂移](#5.3 Derby 元数据的目录漂移)
- [6. 数据画像结果](#6. 数据画像结果)
- [6.1 结构完整性](#6.1 结构完整性)
- [6.2 行为互斥验证](#6.2 行为互斥验证)
- [6.3 日期分布](#6.3 日期分布)
- [6.4 日 × 行为交叉](#6.4 日 × 行为交叉)
- [6.5 维度画像](#6.5 维度画像)
- [7. 数据处理的反复拉扯(专题)](#7. 数据处理的反复拉扯(专题))
- [7.1 编码:GBK 不可映射字符](#7.1 编码:GBK 不可映射字符)
- [7.2 日期:从硬编码到分布画像](#7.2 日期:从硬编码到分布画像)
- [7.3 Hive 保留字:date → dt](#7.3 Hive 保留字:date → dt)
- [7.4 CTAS 限制:array<bigint> → array<string>](#7.4 CTAS 限制:array
→ array ) - [7.5 多值炸裂:从配对到独立](#7.5 多值炸裂:从配对到独立)
- [7.6 压缩:Snappy 与 Hive SerDe 的错位](#7.6 压缩:Snappy 与 Hive SerDe 的错位)
- [8. 验证与守恒](#8. 验证与守恒)
- [8.1 行数守恒](#8.1 行数守恒)
- [8.2 MR vs Hive 交叉验证](#8.2 MR vs Hive 交叉验证)
- [8.3 质量报告](#8.3 质量报告)
- [9. 工程收获](#9. 工程收获)
- [9.1 Counter 的价值](#9.1 Counter 的价值)
- [9.2 Tool + ToolRunner 的意义](#9.2 Tool + ToolRunner 的意义)
- [9.3 Hive 工作目录一致性](#9.3 Hive 工作目录一致性)
- [9.4 幂等性:输出目录先删](#9.4 幂等性:输出目录先删)
- [10. 方法收获](#10. 方法收获)
- [10.1 审计不是"发现问题",而是"检验假设"](#10.1 审计不是"发现问题",而是"检验假设")
- [10.2 多值字段:先画像,再炸裂](#10.2 多值字段:先画像,再炸裂)
- [10.3 文件顺序 ≠ 时间顺序](#10.3 文件顺序 ≠ 时间顺序)
- [10.4 外部表 + ORC 的两级设计](#10.4 外部表 + ORC 的两级设计)
- [11. 遗留问题与已知未验证方向](#11. 遗留问题与已知未验证方向)
- [11.1 07-27 数据疑截断](#11.1 07-27 数据疑截断)
- [11.2 3 组完全重复](#11.2 3 组完全重复)
- [11.3 100 用户样本量](#11.3 100 用户样本量)
- [11.4 无维表](#11.4 无维表)
- [11.5 B02 的改进方向](#11.5 B02 的改进方向)
- [12. 结语](#12. 结语)
- [附录 A:Counter 完整存档](#附录 A:Counter 完整存档)
- [附录 B:执行环境](#附录 B:执行环境)
前言
B01 是 B 组电商用户行为分析的第一阶段,任务是:把一份 18.6 MB、180570 行的原始行为日志,经过画像、清洗、语义显式化,落地成可复用的 DWD 明细层。
这份报告和一般"步骤说明"不同。它不只写"做了什么",还写"原本以为是什么、实测发现是什么、为什么会这样、修正后得到什么"。因为真实的数据工程里,最耗时的不是敲代码,而是发现自己的假设错了。
全文围绕六个假设的检验展开:2 条成立,4 条被推翻。每一条推翻,都对应一次设计调整。这些"推翻"才是本阶段最有价值的部分。
0. 实验全景:从原始日志到 DWD 分层
先给一张全景图。
text
原始日志文件 user_visit_action.txt
│ 18.6 MB,180570 行,下划线分隔,13 字段
│
│ ① hdfs dfs -put
▼
HDFS ODS 层
/b_group/ods/user_action/user_visit_action.txt
│
│ ② Hadoop MR(CleanDriver + CleanMapper)
│ · 解析 13 字段
│ · 行为判定(search/click/order/pay)
│ · 语义显式化(-1/null/空串 → 显式标记)
│ · 多值规范化
│ · 16 个审计维度 → Counter
▼
HDFS DWD TSV 层
/b_group/dwd/user_behavior_tsv/part-m-00000
│ 25 列,tab 分隔
│
│ ③ Hive 建表(01--05)
▼
Hive 表(b_group 库)
├── ods_user_action 外部表 180570 行
├── dwd_user_behavior_tsv 外部表 180570 行
├── dwd_user_behavior_orc ORC 180570 行(主 DWD)
├── dwd_user_behavior_dirty ORC 0 行(真缺失隔离)
├── dwd_order_category ORC 35188 行
├── dwd_order_product ORC 23597 行
├── dwd_pay_category ORC 24147 行
└── dwd_pay_product ORC 16096 行
一句话:一份日志,经过 MR 清洗、Hive 分层,产出 1 张主 DWD + 1 张脏数据隔离 + 4 张多值子表,供 B02--B15 使用。
1. 数据源与业务语义
1.1 数据源结构
| 项 | 值 |
|---|---|
| 文件名 | user_visit_action.txt |
| 大小 | 18.6 MB |
| 行数 | 180570 |
| 格式 | 下划线 _ 分隔,13 字段 |
| 语义 | 一行 = 一次用户行为 |
| HDFS 路径 | /b_group/ods/user_action/user_visit_action.txt |
字段定义:
| # | 字段 | 类型 | 说明 |
|---|---|---|---|
| 1 | date | string | 行为日期 |
| 2 | user_id | long | 用户 ID |
| 3 | session_id | string | 会话 UUID |
| 4 | page_id | long | 页面 ID |
| 5 | action_time | string | 动作时间(含空格) |
| 6 | search_keyword | string | 搜索关键词 |
| 7 | click_category_id | long | 点击品类 |
| 8 | click_product_id | long | 点击产品 |
| 9 | order_category_ids | string | 下单品类,逗号分隔 |
| 10 | order_product_ids | string | 下单产品 |
| 11 | pay_category_ids | string | 支付品类 |
| 12 | pay_product_ids | string | 支付产品 |
| 13 | city_id | long | 城市 ID |
1.2 三类字段:共性、行为、维度
13 个字段按语义可分为三类:
共性字段(5 个):每行都有,与行为类型无关。
text
date, user_id, session_id, page_id, action_time
行为字段(6 个):按行为类型分列,非当前行为的字段填"不适用"值。
| 行为 | 承载字段 | 不适用时填什么 |
|---|---|---|
| 搜索 | search_keyword |
字符串 null |
| 点击 | click_category_id、click_product_id |
数值 -1 |
| 下单 | order_category_ids、order_product_ids |
字符串 null |
| 支付 | pay_category_ids、pay_product_ids |
字符串 null |
维度字段(2 个):可用于分组的维度。
text
city_id, page_id (page_id 已在共性字段中,也承担维度角色)
两个设计要点:
- 点击用
-1表示"不适用",因为它是数值类型字段。 - 搜索/下单/支付用字符串
null表示"不适用",因为原始日志是文本文件,字段必须有字面值。
1.3 三种"没有值"的语义区分
清洗过程中必须区分三种"看起来像没有值"的情况:
| 形式 | 例子 | 语义 | 处理 |
|---|---|---|---|
| SQL NULL | Hive 里的 NULL | 真的没有 | 需判断 |
| 空字符串 | "" |
有字段但无内容 | 需判断 |
| 占位符 | -1、字符串 null |
不适用于本行 | 转 NULL 或保留 |
关键 :非点击行的 click_category_id = -1,语义是"这行不是点击",不是数据缺失。这两种语义在 DWD 里要分开标记。
1.4 行为判定规则与互斥声明
数据集的设计约定是:一行恰好命中 4 类行为中的 1 类,这就是"四类行为互斥"。
但这是设计上的声明,不是逻辑必然。可能存在:
- 一行同时满足搜索和点击(埋点出错);
- 一行 4 类都不命中(纯页面浏览)。
因此互斥必须在实际数据上验证,不能默认成立。
判定规则直接由字段语义推导:
text
is_search = search_keyword != null && search_keyword != 'null' && search_keyword != ''
is_click = click_category_id != -1 && click_product_id != -1
is_order = order_category_ids != null && order_category_ids != 'null' && order_category_ids != ''
is_pay = pay_category_ids != null && pay_category_ids != 'null' && pay_category_ids != ''
行为组合分类:
| 命中数 | behavior_type | 说明 |
|---|---|---|
| 1 | search / click / order / pay | 正常,符合设计 |
| ≥2 | multi | 互斥违规,保留但标记 |
| 0 | unclassified | 未分类,保留但标记 |
四位组合编码 (search, click, order, pay),如 0100 = 仅点击。
2. 实验架构:本地编译 + 集群运行
2.1 环境分层
本实验涉及两个环境:
本地开发环境(Windows)
text
E:\Java_proj\MyHadoopB1\
├── pom.xml
├── src/main/java/com/bgroup/b01/
│ ├── CleanMapper.java
│ └── CleanDriver.java
└── target/
└── b01-1.0.jar ← Maven 构建产物
本地只负责编写并编译两个 Java 文件,生成 b01-1.0.jar。
集群运行环境(hadoop@master)
text
/home/hadoop/
├── user_visit_action.txt ← 原始数据(上传前)
├── b01-1.0.jar ← 从本地上传的编译产物
└── b01/
├── sql/
│ ├── 01_create_ods.hql
│ ├── 02_create_dwd_tsv.hql
│ ├── 03_create_dwd_orc.hql
│ ├── 04_create_dirty.hql
│ └── 05_create_subtables.hql
└── output/
└── b01_quality_report.csv
所有 HQL 脚本都写在集群上,通过 hive -f sql/xxx.hql 执行。
2.2 数据流全景
text
原始日志文件
↓ hdfs dfs -put
HDFS ODS (/b_group/ods/user_action/)
↓ Hadoop MR (CleanDriver + CleanMapper)
HDFS DWD TSV (/b_group/dwd/user_behavior_tsv/)
↓ Hive 建外部表 + ORC 转换
Hive 表(8 张)
2.3 五张 Hive 表的依赖链
五张 HQL 表不是平铺的,而是有明确的依赖链:
text
[b01] ODS 原始日志 (/b_group/ods/user_action/)
│
└── [01] 建 ods_user_action 外部表
作用:直读原始 txt,用于行数守恒和原始查询
[b01] MR 输出 (/b_group/dwd/user_behavior_tsv/)
│
└── [02] 建 dwd_user_behavior_tsv 外部表
作用:把 MR 输出的 TSV 映射成 Hive 表(25 列)
│
├── [03] 建 dwd_user_behavior_orc
│ 作用:从 02 读,转 ORC + 多值字段 split 成数组
│ 产出:主 DWD 表,供 B03--B12 使用
│ │
│ └── [05] 建 4 张多值子表
│ 作用:从 03 读,把数组 explode 成行
│
└── [04] 建 dwd_user_behavior_dirty
作用:从 02 读,过滤 is_true_missing = true 的行
执行顺序约束:
01独立,可先可后02必须在 MR 完成后执行(MR 输出目录必须先存在)03、04依赖0205依赖03
3. 六条假设:从声明到实测
实验开始前,我们对这份数据有六个默认假设。有的来自数据集设计文档,有的来自经验。这一章逐条检验它们的命运。
3.1 H1 结构完整性
假设:13 字段、下划线分隔、结构规整,无空行、无字段数异常、无真缺失。
验证方式 :MR Counter 中 EMPTY_LINE、FIELD_COUNT_ERROR、TRUE_MISSING 三个计数器。
结果 :✅ 成立。
三个 Counter 全部为 0。结构层面零缺陷。
3.2 H2 行为互斥
假设:四类行为互斥,每行恰好命中 1 类。
验证方式 :Mapper 对每行计算 hits 和 combo,通过 COMBO_* 和 BEHAVIOR_MULTI / BEHAVIOR_UNCLASSIFIED Counter 汇总。
结果 :✅ 成立,且零例外。
COMBO_* 只出现 4 种(1000/0100/0010/0001),multi = 0,unclassified = 0。四类合计 180570 = TOTAL。这是用 Counter 严格证明的,不是抽样估计。
3.3 H3 日期分布:推翻"单日"假设
假设:数据只覆盖 2019-07-17 一天。
验证方式:第一次 MR 运行时,Mapper 中硬编码:
java
if (!"2019-07-17".equals(f[0])) {
context.getCounter("B01", "DATE_OUT_OF_RANGE").increment(1);
}
结果 :❌ 假设被推翻。
DATE_OUT_OF_RANGE = 163943,只有 6627 行是 7-17。进一步用 shell 查分布,发现实际覆盖 11 天(2019-07-17 ~ 2019-07-27)。
修正:把硬编码判断改为分布画像:
java
context.getCounter("B01", "DATE_" + f[0]).increment(1);
重跑后,Counter 里直接得到 11 个 DATE_*,无需再单独查 Hive。
3.4 H4 品类-产品配对:推翻"配对"假设
假设 :order_category_ids 和 order_product_ids 一一对应,可以用 zip 配对成 (category_id, product_id)。
验证方式 :在 Hive 里按 size() 对比两个数组的长度。
结果 :❌ 假设被推翻。
11768 条 order 行中:
text
size_equal = 2399 ← 只有 20% 两数组长度相等
size_diff = 9369 ← 80% 长度不等
抽样看原始数据:
text
品类 ["15","13","5","11","8"] 产品 ["99","2"] ← 5 vs 2
品类 ["15","9","3"] 产品 ["30"] ← 3 vs 1
品类 ["6"] 产品 ["100","42","47"] ← 1 vs 3
结论 :这不是脏数据,而是语义理解错误。一次下单涉及"一组品类"和"一组产品",两者独立,不是配对关系。
修正 :DWD 不建 dwd_order_item 配对表,改拆四张独立子表。
3.5 H5 文件顺序 vs 时间顺序
假设:文件顺序 = 时间顺序,可以靠文件行号推断用户动作序列。
验证方式 :在 session 内,按 action_time 排序,看 raw_line_id 是否也单调:
sql
WITH s AS (
SELECT session_id, action_time,
CAST(raw_line_id AS bigint) AS rid,
LAG(CAST(raw_line_id AS bigint)) OVER (
PARTITION BY session_id ORDER BY action_time
) AS prev_rid
FROM dwd_user_behavior_orc
)
SELECT COUNT(*), SUM(CASE WHEN rid < prev_rid THEN 1 ELSE 0 END) FROM s;
结果 :❌ 假设被推翻。
text
total_rows = 180570
rid_disorder_cnt = 3908 (2.16%)
3908 行在 session 内时间倒流。这是分布式日志采集的天然特征。
修正 :B06 页面单跳转化率、B07 Session 序列分析必须先 ORDER BY action_time, raw_line_id,不能靠文件顺序推断用户动作序列。
3.6 H6 脏数据密度:远比预期干净
假设 :原始日志里 null、-1、空串混用,脏数据可能大量存在。
验证方式:MR 的字段级 Counter(13 字段 × 3 类异常值)+ 格式校验 Counter(时间、UUID、多值)。
结果 :❌ 假设被推翻。
所有格式校验 Counter 全部为 0:
text
TIME_PARSE_ERROR = 0
SESSION_ID_BAD_FORMAT = 0
MULTI_BAD_order_cat = 0
MULTI_BAD_order_prod = 0
MULTI_BAD_pay_cat = 0
MULTI_BAD_pay_prod = 0
真缺失 = 0,时间格式全部合法,session_id 全部是标准 UUID,多值格式规整。
这条推翻的意义:原本以为要花大力气清洗脏数据,实测发现数据质量远好于预期,可以把精力放在"语义澄清"和"画像"上。
4. MR 清洗程序
4.1 Mapper:字段级语义显式化
CleanMapper 是清洗的核心。它对每行做 5 件事:
- 结构校验:字段数是否 13,是否空行
- 真缺失检查:5 个关键字段是否有真缺失
- 字段值审计 :13 个字段各自的
null/-1/空串计数 - 行为判定 :根据字段语义判定
behavior_type和behavior_combo - 输出语义显式化 :把
-1、字符串null转成\N(Hive 侧变 SQL NULL),同时保留is_*_field_used区分"字段不适用"和"字段适用但值为空"
完整代码:
java
package com.bgroup.b01;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class CleanMapper extends Mapper<LongWritable, Text, NullWritable, Text> {
private final Text outValue = new Text();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String line = value.toString();
context.getCounter("B01", "TOTAL").increment(1);
if (line == null || line.trim().isEmpty()) {
context.getCounter("B01", "EMPTY_LINE").increment(1);
return;
}
String[] f = line.split("_", -1);
if (f.length != 13) {
context.getCounter("B01", "FIELD_COUNT_ERROR").increment(1);
return;
}
// ===== 1. 关键字段真缺失 =====
boolean trueMissing = isBlank(f[0]) || isBlank(f[1]) || isBlank(f[2])
|| isBlank(f[3]) || isBlank(f[4]);
if (trueMissing) context.getCounter("B01", "TRUE_MISSING").increment(1);
// ===== 2. 字段值分布审计 =====
for (int i = 0; i < 13; i++) {
String v = f[i];
if ("null".equals(v)) {
context.getCounter("B01", "FIELD_" + (i + 1) + "_NULL").increment(1);
} else if ("-1".equals(v)) {
context.getCounter("B01", "FIELD_" + (i + 1) + "_NEG1").increment(1);
} else if (v.isEmpty()) {
context.getCounter("B01", "FIELD_" + (i + 1) + "_EMPTY").increment(1);
}
}
// ===== 3. 行为判定 =====
boolean isSearch = !isBlankOrNull(f[5]);
boolean isClick = !"-1".equals(f[6]) && !"-1".equals(f[7]);
boolean isOrder = !isBlankOrNull(f[8]);
boolean isPay = !isBlankOrNull(f[10]);
int hits = 0;
if (isSearch) hits++;
if (isClick) hits++;
if (isOrder) hits++;
if (isPay) hits++;
String behaviorType;
if (hits == 1) {
behaviorType = isSearch ? "search" : isClick ? "click" : isOrder ? "order" : "pay";
} else if (hits >= 2) {
behaviorType = "multi";
context.getCounter("B01", "BEHAVIOR_MULTI").increment(1);
} else {
behaviorType = "unclassified";
context.getCounter("B01", "BEHAVIOR_UNCLASSIFIED").increment(1);
}
context.getCounter("B01", "BEHAVIOR_" + behaviorType.toUpperCase()).increment(1);
String combo = (isSearch ? "1" : "0") + (isClick ? "1" : "0")
+ (isOrder ? "1" : "0") + (isPay ? "1" : "0");
context.getCounter("B01", "COMBO_" + combo).increment(1);
// ===== 4. 时间审计 =====
if (!isValidDateTime(f[4])) {
context.getCounter("B01", "TIME_PARSE_ERROR").increment(1);
}
context.getCounter("B01", "DATE_" + f[0]).increment(1);
// ===== 5. session_id 格式 =====
if (!isUuid(f[2])) {
context.getCounter("B01", "SESSION_ID_BAD_FORMAT").increment(1);
}
// ===== 6. 多值处理 =====
String orderCat = isOrder ? normalizeMulti(f[8], context, "order_cat") : "\\N";
String orderProd = isOrder ? normalizeMulti(f[9], context, "order_prod") : "\\N";
String payCat = isPay ? normalizeMulti(f[10], context, "pay_cat") : "\\N";
String payProd = isPay ? normalizeMulti(f[11], context, "pay_prod") : "\\N";
// ===== 7. 质量标记 =====
List<String> flags = new ArrayList<>();
if (trueMissing) flags.add("TRUE_MISSING");
if (hits >= 2) flags.add("MULTI_BEHAVIOR");
if (hits == 0) flags.add("UNCLASSIFIED");
// ===== 8. 输出 =====
String out = String.join("\t",
f[0], f[1], f[2], f[3], f[4].trim(), f[12],
behaviorType, combo,
isSearch ? f[5] : "\\N",
isClick ? f[6] : "\\N",
isClick ? f[7] : "\\N",
orderCat, orderProd, payCat, payProd,
isSearch ? "true" : "false",
isClick ? "true" : "false",
isOrder ? "true" : "false",
isPay ? "true" : "false",
hits == 1 ? "true" : "false",
hits >= 2 ? "true" : "false",
hits == 0 ? "true" : "false",
trueMissing ? "true" : "false",
String.join(",", flags),
String.valueOf(key.get())
);
outValue.set(out);
context.write(NullWritable.get(), outValue);
}
// ===== 工具方法 =====
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
private static boolean isNullLiteral(String s) {
return "null".equalsIgnoreCase(s == null ? "" : s.trim());
}
private static boolean isBlankOrNull(String s) {
return isBlank(s) || isNullLiteral(s);
}
private static boolean isValidDateTime(String s) {
if (isBlank(s)) return false;
return s.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
}
private static boolean isUuid(String s) {
if (isBlank(s)) return false;
return s.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
+ "-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
}
private static String normalizeMulti(String s, Context ctx, String tag) {
if (isBlankOrNull(s)) return "\\N";
String[] parts = s.split(",", -1);
StringBuilder sb = new StringBuilder();
boolean hasBad = false;
for (String p : parts) {
String t = p.trim();
if (t.isEmpty()) { hasBad = true; continue; }
if (!t.matches("\\d+")) { hasBad = true; continue; }
if (sb.length() > 0) sb.append(",");
sb.append(t);
}
if (hasBad) ctx.getCounter("B01", "MULTI_BAD_" + tag).increment(1);
return sb.length() == 0 ? "\\N" : sb.toString();
}
}
Mapper 的输出是 25 列 tab 分隔文本,对应 DWD 表结构。
4.2 Driver:Tool + ToolRunner 写法
CleanDriver 负责作业配置。用 Tool + ToolRunner 而不是直接硬编码:
java
package com.bgroup.b01;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.conf.Configured;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.Tool;
import org.apache.hadoop.util.ToolRunner;
public class CleanDriver extends Configured implements Tool {
@Override
public int run(String[] args) throws Exception {
if (args.length != 2) {
System.err.println("Usage: CleanDriver <input> <output>");
return 1;
}
Configuration conf = getConf();
setIfAbsent(conf, "mapreduce.map.memory.mb", "512");
setIfAbsent(conf, "mapreduce.map.java.opts", "-Xmx400m");
setIfAbsent(conf, "mapreduce.task.io.sort.mb", "128");
setIfAbsent(conf, "mapreduce.map.sort.spill.percent", "0.80");
Job job = Job.getInstance(conf, "B01 Clean");
job.setJarByClass(CleanDriver.class);
job.setMapperClass(CleanMapper.class);
job.setNumReduceTasks(0);
job.setOutputKeyClass(NullWritable.class);
job.setOutputValueClass(Text.class);
FileInputFormat.addInputPath(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
return job.waitForCompletion(true) ? 0 : 1;
}
private static void setIfAbsent(Configuration conf, String key, String value) {
if (conf.get(key) == null) conf.set(key, value);
}
public static void main(String[] args) throws Exception {
int exitCode = ToolRunner.run(new Configuration(), new CleanDriver(), args);
System.exit(exitCode);
}
}
两个设计要点:
Tool + ToolRunner:命令行-D key=value会被 ToolRunner 自动解析进 Configuration,改参数不用重编译。setNumReduceTasks(0):纯 map 作业,没有 reduce 阶段。这也是 2GB 小集群上能跑通的关键。
4.3 Counter:MR 里的免费画像工具
Counter 是 Hadoop 提供的分布式计数器:
- 每个 task 里
increment,框架自动汇总 - 作业结束后用
hadoop job -counters <jobId>读取 - 不占输出文件,不参与 shuffle,不影响主逻辑
这次 MR 一共用了 40+ 个 Counter 覆盖 16 个审计维度。这是 B01 数据画像的核心手段。
4.4 执行命令与 -D 参数位置
bash
# 1 上传原始数据到 HDFS
hdfs dfs -mkdir -p /b_group/ods/user_action
hdfs dfs -put -f user_visit_action.txt /b_group/ods/user_action/
# 2 清空旧输出目录(幂等)
hdfs dfs -rm -r -f /b_group/dwd/user_behavior_tsv
# 3 执行 MR 清洗
hadoop jar b01-1.0.jar com.bgroup.b01.CleanDriver \
-D mapreduce.map.memory.mb=512 \
-D mapreduce.map.java.opts=-Xmx400m \
-D mapreduce.task.io.sort.mb=128 \
-D mapreduce.map.sort.spill.percent=0.80 \
/b_group/ods/user_action \
/b_group/dwd/user_behavior_tsv
# 4 验证输出行数
hdfs dfs -text /b_group/dwd/user_behavior_tsv/part-m-* | wc -l
注意:-D 参数必须放在 jar 名 + 主类名之后、两个路径参数之前。这是 ToolRunner / GenericOptionsParser 的硬性要求,放错位置参数会当路径解析。
5. Hive 建表与依赖链
5.1 五张 HQL 脚本的作用与依赖
01_create_ods.hql
sql
CREATE DATABASE IF NOT EXISTS b_group;
USE b_group;
CREATE EXTERNAL TABLE IF NOT EXISTS ods_user_action (
raw_line string
)
STORED AS TEXTFILE
LOCATION '/b_group/ods/user_action/';
作用 :把原始日志目录映射成 Hive 外部表。每行一个 raw_line 字符串,不做解析。用于行数守恒和原始查询。
02_create_dwd_tsv.hql
sql
USE b_group;
CREATE EXTERNAL TABLE IF NOT EXISTS dwd_user_behavior_tsv (
dt string,
user_id bigint,
session_id string,
page_id bigint,
action_time string,
city_id bigint,
behavior_type string,
behavior_combo string,
search_keyword string,
click_category_id bigint,
click_product_id bigint,
order_category_ids string,
order_product_ids string,
pay_category_ids string,
pay_product_ids string,
is_search_field_used boolean,
is_click_field_used boolean,
is_order_field_used boolean,
is_pay_field_used boolean,
is_valid_behavior boolean,
is_multi_behavior boolean,
is_unclassified boolean,
is_true_missing boolean,
quality_flags string,
raw_line_id string
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY '\t'
NULL DEFINED AS '\\N'
STORED AS TEXTFILE
LOCATION '/b_group/dwd/user_behavior_tsv/';
作用 :把 MR 输出的 TSV 目录映射成 Hive 外部表,25 列。LOCATION 指向 MR 输出,NULL DEFINED AS '\\N' 把 MR 输出的 \N 解析成 SQL NULL。第一列 dt 而非 date(date 是 Hive 保留字)。
03_create_dwd_orc.hql
sql
USE b_group;
DROP TABLE IF EXISTS dwd_user_behavior_orc;
CREATE TABLE dwd_user_behavior_orc
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
AS
SELECT
dt, user_id, session_id, page_id, action_time, city_id,
behavior_type, behavior_combo,
search_keyword, click_category_id, click_product_id,
split(order_category_ids, ',') AS order_category_ids,
split(order_product_ids, ',') AS order_product_ids,
split(pay_category_ids, ',') AS pay_category_ids,
split(pay_product_ids, ',') AS pay_product_ids,
is_search_field_used, is_click_field_used,
is_order_field_used, is_pay_field_used,
is_valid_behavior, is_multi_behavior, is_unclassified, is_true_missing,
quality_flags, raw_line_id
FROM dwd_user_behavior_tsv
WHERE is_true_missing = false;
作用 :把 TSV 外部表转成 ORC 列式存储,同时把 4 个多值字符串字段 split 成 array<string>。这是 B03--B12 使用的主 DWD 表。
04_create_dirty.hql
sql
USE b_group;
DROP TABLE IF EXISTS dwd_user_behavior_dirty;
CREATE TABLE dwd_user_behavior_dirty
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
AS
SELECT * FROM dwd_user_behavior_tsv WHERE is_true_missing = true;
作用 :把关键字段真缺失的行单独存到脏数据表,与主 DWD 表隔离。本例 is_true_missing = 0,所以脏表为空。保留作为"脏数据隔离"机制存在。
05_create_subtables.hql
sql
USE b_group;
-- 订单-品类子表
DROP TABLE IF EXISTS dwd_order_category;
CREATE TABLE dwd_order_category
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
AS
SELECT user_id, session_id, action_time, city_id,
CAST(cat AS bigint) AS category_id
FROM dwd_user_behavior_orc
LATERAL VIEW explode(order_category_ids) t AS cat
WHERE behavior_type = 'order' AND order_category_ids IS NOT NULL;
-- 订单-产品子表
DROP TABLE IF EXISTS dwd_order_product;
CREATE TABLE dwd_order_product
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
AS
SELECT user_id, session_id, action_time, city_id,
CAST(prod AS bigint) AS product_id
FROM dwd_user_behavior_orc
LATERAL VIEW explode(order_product_ids) t AS prod
WHERE behavior_type = 'order' AND order_product_ids IS NOT NULL;
-- 支付-品类子表
DROP TABLE IF EXISTS dwd_pay_category;
CREATE TABLE dwd_pay_category
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
AS
SELECT user_id, session_id, action_time, city_id,
CAST(cat AS bigint) AS category_id
FROM dwd_user_behavior_orc
LATERAL VIEW explode(pay_category_ids) t AS cat
WHERE behavior_type = 'pay' AND pay_category_ids IS NOT NULL;
-- 支付-产品子表
DROP TABLE IF EXISTS dwd_pay_product;
CREATE TABLE dwd_pay_product
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
AS
SELECT user_id, session_id, action_time, city_id,
CAST(prod AS bigint) AS product_id
FROM dwd_user_behavior_orc
LATERAL VIEW explode(pay_product_ids) t AS prod
WHERE behavior_type = 'pay' AND pay_product_ids IS NOT NULL;
作用:把主 DWD 表里的 4 个多值数组炸裂成子表,一行 = 一个"订单/支付中一个品类或产品"。供 B04、B10 等按品类聚合的分析使用。
关键点 :不建 dwd_order_item 配对表。原设计假设品类和产品一一配对,实测推翻(见 3.4 节),改为拆四张独立子表。
执行顺序
bash
cd /home/hadoop/b01
hive -f sql/01_create_ods.hql
hive -f sql/02_create_dwd_tsv.hql
hive -f sql/03_create_dwd_orc.hql
hive -f sql/04_create_dirty.hql
hive -f sql/05_create_subtables.hql
5.2 外部表 + ORC 两级设计
为什么用"外部表 + ORC 表"两级:
- 外部表
dwd_user_behavior_tsv指向 MR 输出的原始目录,不拷贝数据,节省空间 - ORC 表
dwd_user_behavior_orc是真正查询用的表,压缩比约 9:1(29.8 MB → 3.3 MB) - 保留两层,是为了让 MR 输出可以被独立审计,不受 Hive 表结构变更影响
5.3 Derby 元数据的目录漂移
Hive 默认用内嵌 Derby 做元数据存储。Derby 的行为是:在当前工作目录下创建 metastore_db/。
也就是说:
- 在
/home/hadoop/b01跑 Hive,元数据在/home/hadoop/b01/metastore_db - 在
/home/hadoop/b01/sql跑 Hive,元数据在/home/hadoop/b01/sql/metastore_db - 在
/home/hadoop跑 Hive,元数据在/home/hadoop/metastore_db
不同目录跑 Hive,等于不同的元数据库,之前建的表会"消失"。
这是初学者最容易踩的坑之一。本实验统一在 /home/hadoop/b01 下执行所有 Hive 命令。
6. 数据画像结果
6.1 结构完整性
MR Counter 第一次运行:
text
TOTAL = 180570
EMPTY_LINE = 0
FIELD_COUNT_ERROR = 0
TRUE_MISSING = 0
TIME_PARSE_ERROR = 0
SESSION_ID_BAD_FORMAT = 0
结论:结构层面零缺陷。无空行,无字段数异常,无真缺失,时间格式全部合法,session_id 全部是标准 UUID。
6.2 行为互斥验证
MR Counter 结果:
text
COMBO_1000 = 40232 ← 仅搜索
COMBO_0100 = 120519 ← 仅点击
COMBO_0010 = 11768 ← 仅下单
COMBO_0001 = 8051 ← 仅支付
-----
180570 = TOTAL
BEHAVIOR_MULTI = 0
BEHAVIOR_UNCLASSIFIED = 0
用"字段 null 计数"反推行为数,与 Counter 完全一致:
| 行为 | 反推公式 | 结果 | Counter |
|---|---|---|---|
| search | 180570 − 140338 | 40232 | 40232 ✅ |
| click | 180570 − 60051 | 120519 | 120519 ✅ |
| order | 180570 − 168802 | 11768 | 11768 ✅ |
| pay | 180570 − 172519 | 8051 | 8051 ✅ |
行为分布的业务解读:
- 点击占比 66.7%,最多
- 搜索占比 22.3%
- 下单占比 6.5%
- 支付占比 4.5%
典型的电商漏斗形态,与后续 B03 用户行为漏斗分析的预期一致。
6.3 日期分布
11 天,180570 行:
text
16820 2019-07-25
16773 2019-07-21
16772 2019-07-24
16705 2019-07-22
16694 2019-07-18
16636 2019-07-19
16627 2019-07-17
16615 2019-07-26
16610 2019-07-20
16518 2019-07-23
13800 2019-07-27 ← 偏低约 15~20%
这条分布有两个意义:
- 推翻"单日"假设(见 3.3 节),后续 B03--B12 可以做跨天趋势。
- 暴露 07-27 异常:比前 10 天低 15~20%,疑为数据截断。
6.4 日 × 行为交叉
text
日期 search click order pay
2019-07-17 3683 11105 1109 730
2019-07-18 3684 11239 1044 727
2019-07-19 3817 10945 1148 726
2019-07-20 3653 11114 1125 718
2019-07-21 3801 11221 978 773
2019-07-22 3743 11117 1101 744
2019-07-23 3641 10984 1122 771
2019-07-24 3701 11215 1072 784
2019-07-25 3786 11164 1110 760
2019-07-26 3606 11206 1078 725
2019-07-27 3117 9209 881 593
观察:
- 前 10 天非常稳定,日间波动 < 5%
- 07-27 所有行为类型同比偏低,疑为数据截断
- 跨天趋势分析时,需标注或排除 07-27
6.5 维度画像
text
distinct_user = 100
distinct_session = 9457
distinct_city = 26
distinct_page = 50
关键洞察:
- 只有 100 个用户。这不是"大数据",是"小样本长周期"。
- 每用户平均 1806 条行为,平均 95 个 session
- 后续 RFM、用户分层分析时,100 个用户的 KMeans 分层意义有限,需要报告这一约束
7. 数据处理的反复拉扯(专题)
这一章把整个实验里所有"试错---修正"的过程集中起来。每一次修正,都对应一个假设被推翻。
7.1 编码:GBK 不可映射字符
现象:Windows 下 Maven 编译 Java 文件时,报"编码 GBK 的不可映射字符"。
根因:源码是 UTF-8,Maven 默认编译编码在中文 Windows 下落到 GBK。
修正 :pom.xml 中加入:
xml
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
并在 maven-compiler-plugin 里显式声明 <encoding>UTF-8</encoding>。
验证:用 Python 按 UTF-8 解码读源码,能正常显示中文注释。
7.2 日期:从硬编码到分布画像
现象 :第一次 MR 运行,DATE_OUT_OF_RANGE = 163943,看起来像大量脏数据。
根因 :Mapper 里硬编码了 if (!"2019-07-17".equals(f[0])),默认数据只有一天。
修正:改成分布画像:
java
context.getCounter("B01", "DATE_" + f[0]).increment(1);
意义:
- "越界"是价值判断,默认了"只有一天"的假设
- "分布画像"是事实陈述,不预设
- 在数据探索阶段,多画事实,少下判断
7.3 Hive 保留字:date → dt
现象:
sql
SELECT split(raw_line,'_')[0] AS date, COUNT(*) FROM ods_user_action GROUP BY ...;
-- 报错:cannot recognize input near 'AS' 'date' ',' in selection target
根因 :date 是 Hive 保留字,不能作为列别名或列名。
修正:
- DWD 表第一列改名为
dt - 所有查询统一用
dt - 用
sed批量替换 HQL 脚本里的date→dt
7.4 CTAS 限制:array<bigint> → array<string>
现象:
sql
CAST(split(order_category_ids, ',') AS array<bigint>) AS order_category_ids
在 Hive 3.1.3 的 CREATE TABLE AS SELECT 中报:
text
FAILED: ParseException line 11:41 cannot recognize input near 'array' '<' 'bigint'
根因 :Hive 3.1.3 CTAS parser 对嵌套 CAST(... AS array<bigint>) 的处理有限制。
修正 :改用 array<string>,在子表里再 cast 为 bigint。多值字段本以字符串存储,array<string> 天然匹配。
7.5 多值炸裂:从配对到独立
现象 :原 05 用 zip + posexplode 配对炸裂,得到 dwd_order_item = 4780,远少于 order 行数 11768。
根因 :zip 会在两个数组长度不一致时截断,隐藏了"品类与产品不配对"的语义错误。
修正:改拆四张独立子表:
text
dwd_order_category = 35188
dwd_order_product = 23597
dwd_pay_category = 24147
dwd_pay_product = 16096
意义:一次对数据语义的澄清,避免了后续所有分析(如 B10 品类共现)建立在错误前提上。
7.6 压缩:Snappy 与 Hive SerDe 的错位
现象 :第一次 MR 输出加了 Snappy 压缩,hdfs dfs -cat part-m-* | wc -l 得到 26875,与 180570 完全不符。
根因 :hdfs dfs -cat 是原样输出字节流,不是解压。wc -l 数的是压缩字节里的换行符。
修正:两个方案------
- 用
hdfs dfs -text自动解压 - 或者干脆不用输出压缩(18MB 数据压缩收益极小),重跑 MR
最终选择:重跑 MR 时去掉压缩参数,简化后续 Hive 建表。
8. 验证与守恒
8.1 行数守恒
text
ODS = 180570
DWD_TSV = 180570
DWD_ORC = 180570
DIRTY = 0
守恒式 :ODS = DWD_ORC + DIRTY + FIELD_COUNT_ERROR + EMPTY_LINE,即 180570 = 180570 + 0 + 0 + 0。
8.2 MR vs Hive 交叉验证
| 行为 | MR Counter | Hive 独立 SQL | 一致 |
|---|---|---|---|
| search | 40232 | 40232 | ✅ |
| click | 120519 | 120519 | ✅ |
| order | 11768 | 11768 | ✅ |
| pay | 8051 | 8051 | ✅ |
| multi | 0 | 0 | ✅ |
| unclassified | 0 | 0 | ✅ |
MR 与 Hive 两条独立路径得到完全一致的结果,清洗逻辑可信度高。
8.3 质量报告
output/b01_quality_report.csv:
text
metric value
total_rows 180570
behavior_search 40232
behavior_click 120519
behavior_order 11768
behavior_pay 8051
multi_behavior 0
unclassified 0
true_missing 0
distinct_user 100
distinct_session 9457
distinct_city 26
distinct_page 50
dt_min 2019-07-17
dt_max 2019-07-27
9. 工程收获
这一章讲"下次做工程能更顺"。
9.1 Counter 的价值
Counter 是 MR 的免费画像工具:不占输出文件、不参与 shuffle、不影响主逻辑。
本实验用 40+ Counter 覆盖了 16 个审计维度,是所有画像结论的原始来源。
最佳实践:写 MR 时同步设计 Counter,把审计作为一等公民。
9.2 Tool + ToolRunner 的意义
用 Tool + ToolRunner 代替硬编码 Configuration:
- 命令行
-D key=value自动解析进 Configuration - 改参数不用重编译
- 符合工程规范,便于运维传参
约束 :-D 必须放在 jar 名 + 主类名之后、应用参数之前。
9.3 Hive 工作目录一致性
内嵌 Derby 会在当前工作目录 创建 metastore_db/。不同目录跑 Hive,等于不同的元数据库。
最佳实践 :固定一个工作目录(如 /home/hadoop/b01)跑所有 Hive 命令。
长期方案:改用 MySQL 存储元数据,集中管理、不随目录漂移。
9.4 幂等性:输出目录先删
MR / Hive 输出目录已存在会直接报错。每次跑作业前必须:
bash
hdfs dfs -rm -r -f <output_dir>
Hive 建表时用 DROP TABLE IF EXISTS 保证幂等。
10. 方法收获
这一章讲"下次想问题能更对"。
10.1 审计不是"发现问题",而是"检验假设"
本实验最核心的一条:审计不是被动地找问题,而是主动地检验假设。
原设计有 6 条假设,被推翻了 4 条:
- 数据不是单日(11 天)
- 品类与产品不配对
- 文件顺序 ≠ 时间顺序
- 脏数据密度远低于预期
每一条推翻,都对应一次设计调整。 这些"推翻"本身是最有价值的产出。
10.2 多值字段:先画像,再炸裂
不要一拿到多值字段就直接 explode。先画像,看形态。
原设计用 zip 配对,得到 4780 行,看起来"跑通了"。但实际是 80% 的数据被 zip 的长度截断悄悄丢掉了。跑通 ≠ 正确。
原则:
- 先跑
size()分布,看两个数组形态 - 如果是配对关系,用
zip - 如果是独立集合,用
explode,拆多张子表
10.3 文件顺序 ≠ 时间顺序
分布式日志采集,文件顺序由采集进程写入时间决定,不是事件发生时间。
推论 :任何依赖事件序列的分析(页面跳转、会话序列),必须显式 ORDER BY action_time,不能靠文件行号。
B01 发现 2.16% 的乱序比例。这个结论必须在 B06、B07 的序列分析中处理。
10.4 外部表 + ORC 的两级设计
MR 输出目录 → Hive 外部表 → ORC 表,是数据治理的常见模式:
- 外部表指向原始目录,不改原始数据
- ORC 表是查询用副本,结构可演进
- 保留两层,MR 输出可独立审计,不受 Hive 表结构变更影响
收益:一次投入,长期可维护。
11. 遗留问题与已知未验证方向
11.1 07-27 数据疑截断
07-27 数据量比前 10 天低 15~20%。最可能是采集未完成。跨天趋势分析时需标注或排除。
未验证:能否通过当日 session 分布异常(如大量 session 只有一个事件)佐证"截断"。
11.2 3 组完全重复
3 组完全重复,都是"同一用户、同一秒、同一页面、同一搜索词"(如"吃鸡"、"内存")。占比 0.0017%。
未验证:是否是埋点重复上报,还是真实的双击行为。暂不处理。
11.3 100 用户样本量
只有 100 个用户。后续 B11 RFM 分层分析时,KMeans 在 100 个样本上分 5~8 群,统计意义有限。
建议:B11 用规则分层(如 R/F/M 打分法),不用 KMeans;或在报告中标注这一约束。
11.4 无维表
城市、品类、产品只有 ID,没有对应的名称/省份/品类树。
影响:
- B09 城市/区域品类偏好分析需要自建小维表(26 个城市手工映射省份)
- 品类共现分析(B10)只能按 ID 呈现,业务解读受限
未验证:能否从公开数据源补充城市映射。
11.5 B02 的改进方向
- 主表按
dt分区:11 天数据,分区存储提升查询效率 - 新增
ts字段 :action_time转timestamp类型 - 新增
seq_in_session:session 内按时间排序的序号,一劳永逸解决乱序问题 - 子表补充
dt列:便于按天聚合
12. 结语
B01 是 B 组实验的第一阶段。它完成了从原始日志到 DWD 明细层的完整落地,但更重要的是:它把"我以为"和"实际是"之间的差距记录了下来。
六条假设,两条成立,四条被推翻。每一条推翻,都对应一次设计调整------日期 Counter 从硬编码改为分布画像、多值子表从 1 张拆为 4 张、序列分析必须显式排序。
这些修正,才是 B01 对后续 B02--B15 的真正贡献:不是 "一份干净的 DWD 表",而是"一套已被验证的数据认知"。
附录 A:Counter 完整存档
text
B01
BEHAVIOR_CLICK=120519
BEHAVIOR_ORDER=11768
BEHAVIOR_PAY=8051
BEHAVIOR_SEARCH=40232
COMBO_0001=8051
COMBO_0010=11768
COMBO_0100=120519
COMBO_1000=40232
DATE_2019-07-17=16627
DATE_2019-07-18=16694
DATE_2019-07-19=16636
DATE_2019-07-20=16610
DATE_2019-07-21=16773
DATE_2019-07-22=16705
DATE_2019-07-23=16518
DATE_2019-07-24=16772
DATE_2019-07-25=16820
DATE_2019-07-26=16615
DATE_2019-07-27=13800
FIELD_10_NULL=168802
FIELD_11_NULL=172519
FIELD_12_NULL=172519
FIELD_6_NULL=140338
FIELD_7_NEG1=60051
FIELD_8_NEG1=60051
FIELD_9_NULL=168802
TOTAL=180570
附录 B:执行环境
| 项 | 版本 |
|---|---|
| Hadoop | 3.3.6 |
| Hive | 3.1.3 |
| 集群 | 1 master + 3 worker,每节点 2GB |
| Java | 8 |
本文是 B01 阶段完整复盘,也是 B02--B15 的数据底座说明。