ETHZ-工程师大数据笔记-全-

ETHZ 工程师大数据笔记(全)

001:引言 (1_1)

在本节课中,我们将要学习大数据领域的核心概念、历史背景以及本课程的整体安排。我们将从宏观视角理解数据规模的演变,并探讨处理海量数据所面临的挑战。

课程概述与历史背景

大家好。我是Gi Fni。本学期我们将共同探索过去25年左右数据库的演变,也就是我们通常所说的大数据。

今天的内容有些特殊,更像是一个开胃菜。在第一小时,我将为大家介绍历史背景。在第二小时,我们将复习SQL。

在本课程中,你将学习如何查询可能有些“混乱”的数据。这些数据可能不像Excel表格那样规整,可能存在缺失值,或者规模极其庞大。我们将学习使用相关技术来查询和清理这些数据,例如为机器学习流程准备数据。

我们将研究诸如Hadoop、Spark、Jsoniq以及Rumble DB等技术。其中,Rumble DB是由20多名ETH学生在这里开发的。

归根结底,这门课程是关于规模的,关于大数据的。

为了理解这种规模,我们可以做一个类比。人类的典型尺寸是米的数量级。地球的周长大约是4万公里,也就是40兆米。地球到太阳的距离大约是1.5亿公里,即150吉米。木星的距离大约是1太米。整个太阳系的尺度大约是1拍米。再往外,1光年大约是10拍米。

我们所在的银河系,其尺度大约是1000艾米。而可见宇宙的尺度大约是138尧米。为了探索如此巨大的尺度,我们必须研究构成物质的基本粒子,如夸克和电子。

这个道理同样适用于数据科学。为了理解大数据在巨大规模下的运作方式,我们必须研究数据的“原子”和“分子”,即数据的基本构成单元。

数据科学的定位

我们可以通过一个2x2矩阵来定位数据科学。

  • 数学:依赖于逻辑推导,具有必然性。
  • 物理学:依赖于实验来发现世界的规律。
  • 计算机科学:利用机器自动化处理,其理论基础(如算法)也具有数学般的必然性。
  • 数据科学:它像计算机科学一样使用机器,但像物理学一样,通过收集和分析数据来了解世界。数据科学处于计算机科学和物理学的交汇点。

数据处理的历史演变

现在,让我们回顾数据处理的历史。

  • 史前时期:知识通过口口相传或歌唱来保存和传递,但容易失真或丢失。
  • 文字的发明:书写使得信息能够被准确记录并保存数千年。
  • 表格的出现:考古发现了一块约3800年前的泥板,上面有行和列,这可能是已知最古老的关系表。这表明表格对人类而言是非常直观的数据组织形式。
  • 印刷术:印刷术使得知识能够被大规模复制和快速传播。
  • 计算机的诞生:计算机实现了信息处理的自动化,其能力呈指数级增长。
  • 文件系统(1960年代):早期计算机使用目录和文件来管理数据,但这种方式不够便捷。
  • 关系模型的提出(Edgar Codd):Edgar Codd提出了数据独立性的概念,即通过简单的表格模型(关系模型)将用户与底层存储的复杂性隔离开来。这是数据库领域的一个关键转折点。
  • 新型数据库(2000年代):出现了键值存储、列存储、文档存储等新型数据库技术,以应对多样化的数据需求。

理解大数据:三个V

“大数据”是一个流行词,我们需要明确其定义。大数据通常与三个“V”相关联。

1. 数据量 (Volume)

我们拥有如此多数据的原因如下:

  • 存储能力:硬件技术的发展使得海量数据存储成为可能。
  • 数据价值:数据蕴含着巨大价值,可用于优化决策和科学发现。数据的价值随着数据量的增加呈指数增长,因为不同数据可以关联(Join)产生新洞察。
  • 产品需求:许多产品(如比价服务)需要汇集所有相关数据才能运行。

为了描述巨大的数据量,我们使用一系列前缀。以下是需要掌握的前缀:

  • Kilo (K): 10³
  • Mega (M): 10⁶
  • Giga (G): 10⁹
  • Tera (T): 10¹²
  • Peta (P): 10¹⁵
  • Exa (E): 10¹⁸
  • Zetta (Z): 10²¹
  • Yotta (Y): 10²⁴

其中,Ronna (R, 10²⁷) 和 Quetta (Q, 10³⁰) 是新增的前缀。通常,当数据量达到拍字节(PB)级别,即无法单机存储时,就被认为是“大数据”的起点。

2. 数据多样性 (Variety)

数据可以有多种形态:

  • 表格 (Tables):传统的关系型数据。
  • 树形 (Trees):如JSON、XML文档。
  • 图形 (Graphs):用于表示实体间复杂的关系网络。
  • 立方体 (Cubes):用于多维业务数据分析。
  • 文本 (Text):非结构化数据,随着大语言模型(LLM)的发展愈发重要。

3. 数据速度 (Velocity)

数据产生的速度极快,但处理能力的发展并不均衡,这带来了核心挑战。我们可以从三个技术指标来看:

  • 容量 (Capacity):存储设备能容纳的数据总量。
  • 吞吐量 (Throughput):每秒能读取的数据量。
  • 延迟 (Latency):从发出读取请求到开始接收数据所需的时间。

以硬盘为例:

  • 1956年,IBM 350硬盘容量5MB,吞吐量12.5KB/s,延迟600ms。
  • 今天,大容量HDD容量26TB,吞吐量261MB/s,延迟约2.3ms。

比较其单位体积的演进倍数:

  • 容量增长了约 200亿 倍。
  • 吞吐量仅增长了约 2万 倍。
  • 延迟仅改善了约 250 倍。

容量增长远超吞吐量增长,这意味着即使我们能存下海量数据,也可能没有足够的时间去读完它。这就是大数据处理的核心挑战。

解决方案是并行化 (Parallelization)批处理 (Batch Processing)。通过将任务分发给成千上万的机器同时处理,我们才能应对这种数据产生与处理能力之间的失衡。

课程范围与团队

本课程聚焦于数据库领域,即数据的查询与管理层,而非上层的机器学习或人工智能,尽管它们之间存在联系。

课程团队包括讲师、多位助教(TA)以及一个名为Ethel的AI学习助手。Ethel是基于本课程教材在ETH服务器上训练的模型,你可以像向同学提问一样与她交流,但需注意她可能犯错或产生“幻觉”。

课程安排与期望

以下是本课程的主要组成部分:

  • 每周讲座:现场或在线参加,建议每周跟进学习而非期末突击。
  • 练习课:从下周开始,由助教带领。
  • 阅读材料:主要是免费的在线教材。
  • 编程作业:使用Docker环境,请在本周完成环境配置。
  • 测验:每周有小测验,正确完成可获得加分(总计最多25分),计入期末考试成绩。
  • 期末考试:夏季学期举行,时长为180分钟的笔试。

课程沟通将通过Element聊天室和Moodle论坛进行。所有课程资料(幻灯片、录像、链接)都会放在Moodle上。

总结

本节课中,我们一起学习了大数据课程的引言部分。我们回顾了数据处理从古至今的历史,理解了大数据定义的三个核心维度:数据量(Volume)数据多样性(Variety)数据速度(Velocity)。我们特别探讨了由于存储容量、吞吐量和延迟发展不均衡所带来的根本性挑战,并指出了并行化是解决这一挑战的关键。最后,我们介绍了本课程的教学团队、学习资源以及整体安排。在接下来的课程中,我们将深入这些技术细节,学习如何实际处理和分析大规模、多样化的数据集。

002:SQL回顾与核心概念 🗂️

在本节课中,我们将回顾关系型数据库和SQL的核心概念。这是理解后续大数据技术的基础。我们将从数据模型的基本构成开始,逐步深入到SQL查询语言,并解释为什么在大数据环境中,某些传统规则可以被放宽。

数据独立性与模型

上一节我们介绍了大数据处理的整体框架。本节中,我们来看看关系型数据库的基石——数据独立性。

数据独立性是现代数据库之父埃德加·科德提出的核心思想。它意味着用户只需与一个极其简单的界面交互:表格。表格是人类最容易理解的数据呈现方式。而数据如何物理存储(无论是存储在硬盘、集群,甚至是古老的泥板上),则是一个被隐藏的细节。这种分离的美妙之处在于,你可以更换底层的存储技术,而用户看到的界面和操作方式始终保持不变。

数据形状与关系模型

在五种基本数据形状(表格、树、立方体、图、非结构化数据)中,本节课我们专注于表格。无论数据形状如何,最终都需要以0和1的形式存储在物理介质上,并通过处理器进行计算。对于表格数据,我们需要一个模型来描述它的样子(行和列),以及一种语言(SQL)来处理它。

这有点像烹饪:模型定义了“食材”(数据看起来像什么),而语言则定义了“烹饪方法”(你能对数据做什么)。

关系表的核心构成

现在,让我们深入看看关系表的具体构成。一个关系表可以看作是由行和列组成的简单结构。

以下是描述关系表的核心术语:

  • 表/关系/关系表:数据的整体容器,由行和列组成。
  • 行/记录/元组/实体:表中的一条数据,代表一个独立的业务对象(例如,一名学生)。
  • 列/属性/字段:数据的类别或特征(例如,学生的姓氏、学号)。
  • 值/标量/单元格:行与列交叉处的单个数据项(例如,“张三”)。
  • 主键:能够唯一标识表中每一行的特殊列(例如,学号、社会保险号)。

关系表的数学视角与完整性规则

从更形式化的角度看,一个关系表可以定义为一个模式(即所有列的集合)和一系列元组(即所有行的集合)的组合。每个元组可以看作是一个从属性名到属性值的部分函数

并非所有看似表格的数据都是有效的关系表。一个有效的关系表必须遵守三条完整性规则:

  1. 关系完整性:所有行必须具有完全相同的列(属性集)。你不能将不同“形状”的数据强行拼成一个表。
  2. 原子完整性:每个单元格中的值必须是“原子”的,即不可再分的单一值(如字符串、数字)。不允许表中嵌套表
  3. 域完整性:同一列中的所有值必须属于相同的数据类型(如都是字符串或都是整数)。

遵守这些规则的数据,我们称之为处于第一范式。SQL正是为操作这类规范化的表格数据而设计的语言。

关系代数:操作表格的“动词”

知道了数据是什么(名词),接下来我们需要知道能对数据做什么(动词)。这由关系代数定义。

关系代数提供了一系列操作符来转换和查询表格数据,它们非常直观,很多在电子表格软件中也能找到对应功能:

  • 选择:根据条件筛选出特定的行。例如,筛选出所有来自英国的人。
  • 投影:选择特定的列,隐藏其他列。
  • 分组与聚合:将具有相同值的行归为一组,并对组内的数据进行计算(如求和、计数)。例如,按州分组并计算每个州的总人口。
  • 排序:根据某一列或多列的值对行进行升序或降序排列。
  • 笛卡尔积:将两个表的每一行与另一个表的每一行进行组合,生成所有可能的配对。
  • 连接:笛卡尔积的一种特殊形式,只组合那些在指定列上具有相等值的行。这是合并多个相关数据集最强大、最常用的操作。

范式理论与大数据中的取舍

在传统的数据库设计(如在线交易系统)中,为了避免数据冗余和更新异常,我们通过范式理论来严格设计表结构。更高的范式(如第二范式、第三范式、BCNF)要求更严格,以消除各种数据依赖导致的问题。

然而,在大数据分析的场景下,情况有所不同。我们的数据往往是静态的(一次性加载,多次查询),更注重读取和分析速度,而非频繁的更新操作。因此,在本课程中,我们将放宽甚至抛弃严格的范式要求。我们甚至可以接受违反第一范式,即允许表中嵌套表(这在后续课程中会见到),这在大数据某些处理范式中是常见且有用的。

SQL:声明式查询语言

SQL是操作关系型数据库的标准语言。它有两个关键特性:

  1. 声明式:你只需告诉数据库“你想要什么”(例如,“给我素食菜单”),而不需要指定“如何得到它”(例如,不需要写代码去厨房一步步烹饪)。这使得查询更简洁,并且数据库系统可以自主优化执行过程。
  2. 函数式:SQL查询可以嵌套,一个查询的结果可以作为另一个查询的输入,形成复杂的表达式。

以下是SQL基本查询结构的核心骨架,掌握它就掌握了80%的SQL:

SELECT ...
FROM ...
WHERE ...
GROUP BY ...
HAVING ...
ORDER BY ...
LIMIT ... OFFSET ...

这个固定顺序对应了数据处理的逻辑流程:从哪个表取数据(FROM),过滤行(WHERE),分组(GROUP BY),过滤组(HAVING),选择输出列(SELECT),排序(ORDER BY),最后进行分页(LIMIT/OFFSET)。

总结

本节课中我们一起学习了关系型数据库和SQL的核心基础。我们回顾了数据独立性的重要性,理解了关系表的结构、完整性规则以及操作它们的关系代数。我们特别指出了在大数据背景下,可以灵活处理范式约束。最后,我们介绍了SQL作为声明式查询语言的基本结构和逻辑。

这些概念是后续学习如何在单机乃至大规模数据中心上处理表格数据的基石。下周我们将通过练习来巩固SQL技能,并开始探索如何在大数据环境中存储海量数据。

003:SQL回顾与核心概念 🗃️

在本节课中,我们将回顾上周开始的SQL知识,并继续探讨云存储,学习如何在云端存储海量数据。

在开始之前,我们先通过一个关于数据形态的问题来回顾上周内容,并帮助大家进入状态。上周我们提到了几种基本的数据形态,包括立方体、表格、树状结构等。接下来,我们将聚焦于表格,因为这是自1970年代以来一切的开端。

SQL语言回顾 📝

上一节我们提到了表格是理解数据的基础。本节中,我们来看看用于操作表格的核心语言——SQL。这是一种非常流行且易于学习的语言,即使是非技术人员也能使用。

以下是SQL查询中子句的标准顺序:

  • SELECT: 指定输出结果中包含哪些列。
  • FROM: 指定从哪个表中读取数据。
  • WHERE: 根据条件过滤行,只保留满足条件的记录。
  • GROUP BY: 用于数据聚合,将记录按指定列分组。
  • HAVING: 在分组后,对组进行过滤。
  • ORDER BY: 对结果进行排序。
  • LIMITOFFSET: 用于结果分页,例如 LIMIT 10 OFFSET 0 获取前10条结果。

集合操作与连接 🔗

除了基本的查询,SQL还支持对数据集进行数学集合操作。

以下是主要的集合操作:

  • UNION: 将两个SELECT语句的结果集合并成一个。
  • INTERSECTION: 取两个结果集的交集。
  • MINUS/EXCEPT: 从一个结果集中减去另一个结果集。

然而,更常见和强大的是JOIN(连接)操作。当你有两个包含不同数据但相互关联的表时,就需要使用连接。

以下是连接操作的一个例子:

  • 假设有一个学生表和一个课程表。
  • 你想找出每个学生所选的课程。
  • 这时就需要根据学生ID(或类似字段)将学生表和课程表连接起来。

连接操作有多种类型,最常用的是内连接(INNER JOIN),它只返回两个表中匹配的记录。如果使用FULL OUTER JOIN,则会返回所有记录,对于不匹配的部分用NULL值填充。

规范化形式与反规范化 📊

上一节我们介绍了连接操作,本节中我们来看看一个与之密切相关的概念——规范化形式。在1970年代,数据库设计强调规范化,即避免使用包含过多信息的大表。

以下是规范化形式的核心思想:

  • 高规范化形式: 将数据拆分到多个小表中,每个表专注于一件事(例如,一个人员表,一个课程表)。这有利于数据更新和维护,避免重复和不一致。
  • 低规范化形式/反规范化: 将多个表的数据合并成一个大表。

在大数据分析中,我们通常偏爱反规范化。因为我们主要是读取数据,而不是频繁地修改数据。一个包含所有信息的大表更适合进行快速的分析查询,无需在查询时进行复杂的连接操作。因此,可以说,规范化是拆分的艺术,而连接则是合并的艺术

SQL进阶概念与特性 ⚙️

了解了连接和规范化后,我们再来看看SQL的一些其他重要特性和概念。

以下是几个关键的SQL特性:

  • 嵌套查询: 可以在一个SELECT语句中嵌套另一个SELECT语句,实现复杂的查询逻辑。
  • 三值逻辑: SQL的逻辑运算基于TRUEFALSENULL(未知)三种值,这与NULL值的处理有关。
  • DDL与DML: SQL语言分为两部分:
    • 数据定义语言 (DDL): 用于创建、修改、删除表结构(如CREATE TABLE, ALTER TABLE)。
    • 数据操作语言 (DML): 用于查询和操作表中的数据(如SELECT, INSERT, UPDATE, DELETE)。本课程重点在DML的查询部分。
  • 索引: 类似于书籍末尾的索引,数据库索引是一种数据结构,用于快速定位表中的特定记录,显著提高查询速度。

事务与ACID属性 🛡️

在处理需要修改数据的场景(如在线交易)时,数据库事务的ACID属性至关重要。

以下是ACID属性的含义:

  • 原子性 (Atomicity): 事务中的所有操作要么全部成功,要么全部失败回滚。例如,银行转账必须同时完成扣款和收款。
  • 一致性 (Consistency): 事务必须使数据库从一个一致状态转换到另一个一致状态。例如,确保账户余额不会为负。
  • 隔离性 (Isolation): 并发执行的事务彼此隔离,互不干扰。每个用户感觉自己是唯一在使用系统的人。
  • 持久性 (Durability): 一旦事务提交,其对数据的修改就是永久性的,即使系统故障也不会丢失。

这些属性保证了在OLTP(在线事务处理)场景下数据的可靠性和准确性。而在我们主要关注的OLAP(在线分析处理)场景中,由于侧重只读查询,对ACID的要求可能有所不同。

超越传统关系型数据库 🚀

到目前为止,我们讨论的都是1970年代以来的传统关系型数据库和SQL。但当数据规模急剧增长时,这些系统可能面临挑战。

以下是传统关系型数据库可能遇到的扩展性瓶颈:

  • 海量行: 当表有数十亿甚至数万亿行时。
  • 海量列: 传统数据库通常支持最多约256列。如果需要数百万个稀疏列(每行只使用少数列),则需要新的系统(如Bigtable)。
  • 深度嵌套: 当数据打破第一范式,出现“表中表”的复杂嵌套结构时,数据形态更接近于树(Tree)而非平面表。本课程后续将大量涉及树形结构。

实际上,本学期我们将要学习的大多数技术,都是为了解决海量行、海量列或深度嵌套这三个核心扩展性问题中的一个或多个。

总结 📚

本节课中我们一起学习了SQL语言的核心语法,包括查询子句顺序、集合操作和连接。我们探讨了规范化与反规范化的概念,并理解了大数据分析中偏爱反范式大表的原因。我们还介绍了SQL的三值逻辑、DDL/DML区别、索引以及保障数据完整性的ACID事务属性。最后,我们展望了传统关系型数据库在处理超大规模数据时面临的挑战,引出了本课程后续将要探讨的、能够处理海量行、海量列和嵌套数据的新技术方向。

掌握SQL是基础,请务必通过大量练习来巩固。接下来,我们将进入云存储的世界。

004:云存储 (1/3) 📚

在本节课中,我们将要学习大数据技术栈的基础——云存储。我们将探讨数据中心的真实构成,理解传统关系型数据库在处理海量数据时的局限性,并介绍现代大数据解决方案的核心思想。

欢迎回来。我将以一个问题开始这部分课程。

我想先回顾一些已经讨论过的内容。

你认为数据中心里有什么?你可能在电视上听说过数据中心。如今,到处都是拥有巨型数据中心的大公司。数据中心里有什么?你认为是什么?

是像这样进行秘密计算、让大语言模型(LLM)运行起来的地方吗?还是一台拥有100个CPU、100PB硬盘和1PB内存的巨型机器?抑或是大量廉价的小型机器?或者是一台拥有54个量子比特阵列的巨型量子计算机?

我想到的是Sycamore(谷歌的量子处理器),但我觉得在一次奇怪的实验中,其中一个量子比特失效了,所以降到了53个,但54个是设计目标。对吧?看看大家怎么说。

大多数人选择了同一个选项,并且大多数人是正确的。

如果你曾想象或理想化数据中心的样子,现实可能有些令人失望。但事实上,是的,它只是装满廉价机器的房间,这就是它的本质。

这些是廉价机器,其中一些基本上和你的笔记本电脑差不多。事实上,这有点夸张,因为数据中心的机器可能比你的笔记本电脑更强大一些,但它远没有PB级的内存或100PB的硬盘。实际上,这些东西今天甚至不存在。PB级的内存今天不存在。100PB的硬盘今天也不存在。我们唯一知道的做法就是使用大量内存和硬盘容量都较小的机器。好的,这就是数据中心的构成。稍后我会告诉你更多。

至少对你来说,数据中心大概就是这样。


云存储 ☁️

那么,本次讲座的议程是什么?是大数据。我们需要查询大量数据,什么样的数据?一个例子是我们通过实验收集的数据,比如斯隆数字巡天(SDSS)。我查看了它直到2020年的第四阶段数据,并试图了解其中有多少数据。

我发现其中有273 TB的数据。它被组织在68万个目录中,包含1.76亿个文件。这是大量的数据,我们可以用SQL或其他语言查询这些数据,以发现黑洞、中子星、类星体等等。

出现的第一个问题是:我们把这些数据存储在哪里? 因为数据量太大了。当然,这无法装进你的笔记本电脑。所以我们需要另一种方式。我们有什么?我们有关系型数据库。但关系型数据库的问题(至少在21世纪初)是,一个关系型数据库系统运行在一台机器上。

所以,如果你认为可以把这200 TB数据直接放进一个关系型数据库,请再想想,这行不通,因为那只是一台单独的机器。所以这不是一个选项。事实上,这正是21世纪初公司们遇到的问题:他们用尽了选择,因为他们需要的数据已经无法再放入我们当时拥有的系统中。

所以我们需要另一种解决方案。


但我有个好消息要告诉你。好消息是,自20世纪70年代(甚至更早)以来过去50年发生的一切,即使有了新技术,仍然可以重用。上周和本周我提到的选择、投影、分组、排序、连接等概念仍然适用。声明式函数式语言、优化、查询计划、希腊字母表示的索引等,仍然适用。我们仍然可以拥有这些。表由行、列、主键构成,这些我们也会沿用。但我们可能不止于此,我们会扩展到其他概念,这意味着我们学到的很多东西都可以重用。这是个非常好的消息。


但有些东西会被抛弃,这不应该让你感到惊讶,因为我告诉过你范式(Normal Forms)将会消失。第二范式、第三范式、BC范式,所有这些都将消失。对于本课程的目的,你只需要知道范式是连接(Join)的反面——范式意味着拆分数据。抛弃范式意味着你将数据放回一个巨大的表中。

表完整性、域完整性、原子性完整性,这些基本上是使表成为表的东西。每一行中相同列的值一致,同一列中的所有数据具有相同类型,没有表中有表的情况。所有使表成为表的东西,我们都要抛弃。

我们将看到异构的数据,因为同一列中可能有不同类型的数据。我们将看到嵌套的数据,表中有表。我们将看到非规范化的数据,意味着它不是高范式的,只是一个我们将要查询的巨型表。

这个美丽的世界曾被称为“NoSQL”的流行词世界。现在这个词不那么流行了。当时每个人都在谈论NoSQL。这可能是15年前的ChatGPT。

所以,这实际上是即将出现的新内容,我们将花几周时间学习它。


那么,在接下来的几周里,我们将要重建整个技术栈

这是什么意思?如果你看一个关系型数据库系统,比如70年代那些老旧的系统,它们运行在一台机器上。它只是一个需要安装的软件。事实上,PostgreSQL正是如此。它就像一个黑盒,你只需下载、安装,然后就可以与之交互。

我们需要重建所有组件,将其拆解,然后一层一层地重建。就像盖房子,你有地基、墙壁、电线、管道等等。我们将做同样的事情,构建大数据技术栈。这些是技术栈的所有层级,我们将用整个学期来完成。

第一层是存储。这就是我今天要讲的云存储,有很多花哨的技术名称。这是你存放数据的地方。

然后你有一个编码层。这是如何将数据转换为0和1,与本讲座关系不大。我只是提一下,但我们不会花太多时间。你可能听说过UTF-8。如果你创建文本文件,应该使用UTF-8。这基本上是一种让所有国际语言和特殊字符都能正常工作的方法,与只支持欧洲字符的ASCII或ISO-8859-1不同。UTF-8是当今的标准。所以这是编码方式,但我们同样不会花太多时间。

接下来是索引。如何表示数据?作为可以存储在磁盘上的文本。例子有XML、JSON、CSV。谁听说过CSV?好的,整个网络都是。当你下载数据时,通常会有CSV选项,这只是一种表示表格的方式。它是用文本表示的表格。XML、JSON是用文本表示的树结构。RDF/XML三元组是用文本表示的图。XBR是用文本表示的立方体。所以这些都是将数据存储为文本的语法。为什么?因为你想通过网络传输它,你想下载数据等等。所以我们需要语法。

然后,我们有数据模型,即如何对数据进行建模。对于表格,就是我上周讲的行、列等;对于树、图、立方体(虽然不在本课程中),它基本上是数据背后的模型,是你将要操作的数据的表示。

接着是验证。验证是对数据结构施加约束的过程,例如,确保一列中的所有值都是字符串或都是日期等。在像PostgreSQL这样的关系型数据库系统中,我们不太听说这个,因为它是强制的。如果没有确保数据结构的模式,你甚至无法开始处理数据。但在大数据中,我们实际上使其成为可选的而非强制的,因此它出现得更明确一些,所以我们会花一整周时间学习它。

数据处理是当数据在数据中心成千上万的机器间流动,并以某种方式最终产生结果的时候。这是我最喜欢的部分。所以我们会学习MapReduce和Apache Spark。

然后是索引,这对于快速查找数据非常方便。我也会在专门的一周里详细讲解。

接着是数据存储。这里开始看起来像是你卖给人们的产品。数据存储基本上是一种技术,你可能听说过一些名字:MongoDB(谁听说过MongoDB?),这是一个基于树结构的NoSQL产品的数据库公司。Couchbase也基于树结构。Hive基于立方体。HBase是Google Bigtable的开源版本。MongoDB也是一个NoSQL产品。Cassandra是Meta(Facebook)的HBase替代品等等。这被称为数据存储。这基本上是你销售或供人们下载和使用的东西,是其他一切功能的包装器。

然后,在数据存储之上可以实现的是,如果你有一个像SQL这样的真正的查询语言(还有其他针对不同数据形状的语言),你基本上让用户的生活变得更容易,因为他们可以用那种语言查询数据。

最后,最高层级是用户界面。事实上,我忘了加上它。但这是你放置LLM和GPT等自然语言界面的地方,它也将成为生态系统的一部分。但这不会成为本课程的一部分。只是让你知道它在那里。事实上,既然GPT如此擅长编写SQL,那么你可以想象它实际上可以帮助那些不懂SQL的人。

这就是技术栈,这是一个学期的工作量。当然,我们需要把数据存放在某个地方,这就是为什么今天我们只从技术栈的底层——存储开始。正如我所说,我们需要把数据存放在某个地方,特别是像天体物理学数据这样的数据。事实上,我总是听物理学家说信息是物理的,所以你拥有的任何信息最终都必须以原子和分子的形式存在于某个地方。

但我们需要弄清楚在哪里。


现在,这里有一个非常重要且绝对基本的观点需要理解。

有两种类型的系统。

一种系统像PostgreSQL。它基本上是单体式的,为你处理一切。这意味着你需要将数据导入其中,然后它会自己想办法存储和查询数据等等。这被称为ETL(提取、转换、加载)系统。这些是传统的数据库。它真的是一个封闭的系统,你只能从外部与之交互。这意味着它向你隐藏了复杂性,但也意味着你看不到内部。

但在21世纪初,出现了一种东西,或者更准确地说,是重新出现,因为这实际上在60年代就差不多是这样了。但在某些情况下,这正是你需要的。这被称为数据湖

数据湖是一种带有目录和文件的文件系统。你只是把数据作为文件丢进去。但与左边系统的区别在于,你能看到文件,能看到目录,能看到文件,可以复制它们、下载它们、放到互联网上等等。所以你实际上是从文件系统中读取文件。这也被称为就地查询,因为你基本上是从数据所在的位置查询数据。

你在里面会有CSV文件、JSON文件、XML文件等等,各种文件。你可能也听说过这些,比如当你坐在Python笔记本前使用NumPy、Pandas等时,通常你就是在处理数据湖。事实上,对于某些用例来说,这很棒。但你需要知道,这不是唯一的东西。有些系统向你隐藏了一切,你甚至看不到任何文件,因为那是高度优化的隐藏状态。然后还有被称为数据湖的系统。区别清楚了吗?

好的,现在我们实际上关注的是数据湖,或者更具体地说,是云中的数据湖,而不仅仅是你笔记本电脑上的。


现在,如果我们从70年代开始,想把东西存储在单个磁盘上,这已经是一个硬盘了,我想你们可能已经见过了,对吧?这就是硬盘的样子。谁没见过?好的,大多数人都见过。这是一个硬盘。它有一个旋转的部分。这是机械硬盘,是我们今天仍然喜欢使用的更便宜的那种。

那里的文件以层次结构组织,你可以在里面有目录和文件。

此外,文件实际上被分割成。所以,如果你的笔记本电脑上有一个文件,比如几兆字节,它并不是作为一个整体存储在磁盘上的。事实上,笔记本电脑会将其分割成4KB的块,很多这样的块,并且在你读取或写入时,是按块进行的。这被称为块存储。在你的笔记本电脑上,数据块大约是4KB。


现在,如果你使用这种本地存储,并认为你可以通过用力敲打来塞进天体物理学数据集,那你就错了。因为这种带有硬盘的存储方式在本地机器上可以工作,在你的本地网络上也许可以工作(也许你们有些人在家有一个可以远程访问的硬盘),但如果有一百万人要远程连接到它,它就无法工作了。它之所以能工作,只是因为只有你一个人。广域网(WAN)是广域网络,事情在这里开始出现问题。在文件数量方面,我不知道你们中是否有人做过备份。如果你做备份,你会看到文件数量,可能有成千上万甚至数百万个文件。你笔记本电脑上肯定没有数十亿个文件,因为这也会突破我们现有系统的边界。

但问题是,对于大数据,我们确实需要那种规模的数量,对吧?那么,我们如何用云存储来解决这个问题呢?这就是我们将在9点15分休息后马上要看到的内容。


本节课中,我们一起学习了大数据技术栈的起点——云存储的基本概念。我们了解到数据中心由大量廉价机器构成,传统关系型数据库因单机限制无法处理海量数据。我们引入了数据湖的概念,它允许我们以文件形式直接存储和访问数据,为后续的大数据处理奠定了基础。下一节,我们将深入探讨云存储如何解决可扩展性和海量文件访问的问题。

005:云存储 (2/3) 🗂️

在本节课中,我们将要学习云存储的核心概念,特别是如何通过扩展(Scale Out)而非升级(Scale Up)来处理海量数据。我们将深入探讨亚马逊S3等云存储服务的数据模型、架构特点以及其背后的理论限制。

从扩展说起

上一节我们介绍了处理大数据时面临的存储挑战。本节中我们来看看解决这些挑战的核心策略:扩展。

为了应对海量数据,我们摒弃了传统的目录和文件层级结构。新的数据模型变得极为简单:我们称之为键值模型。这意味着所有文件(现在称为对象)都只拥有一个唯一的标识符(ID)。如果你知道这个ID,你就可以获取对应的对象。其核心概念可以表示为:

数据模型:键值对

Key -> Value
(对象ID) -> (对象数据)

扩展的策略:向上 vs. 向外

当一台机器性能不足时,通常有三种应对策略。

以下是三种主要的扩展思路:

  1. 向上扩展:购买更强大的单台机器(更多CPU、内存、存储)。这种方法成本高昂,且每增加一个单位的性能,其边际成本会急剧上升,存在物理和经济的上限。
  2. 向外扩展:购买更多普通的小型机器。这是数据中心采用的方式,成本增长相对线性,是处理海量数据的可行方案。
  3. 优化代码:在考虑扩展之前,首先尝试在单台机器(如你的笔记本电脑)上通过优化代码、压缩数据等方式解决问题。这通常是最经济高效的第一步。

数据中心的架构

当我们决定向外扩展时,就需要用到数据中心。一个数据中心容纳着成千上万台机器。

以下是数据中心内部的关键组件和规模:

  • 机器数量:一个数据中心通常包含数万台机器,物理上限大约在10万台左右,受限于供电和冷却能力。
  • 单机规格:每台服务器(远强于普通笔记本)可能拥有数十到数百个CPU核心、最高数TB的内存以及数十TB的存储空间。
  • 网络带宽:服务器间的网络连接速度可达100 Gb/s甚至更高,但这仍然是构建大数据系统时的一个重要限制因素。
  • 物理布局:机器被堆叠在机架中。每个机架像乐高积木一样模块化,可以容纳计算服务器、存储硬盘或网络交换机等不同组件。

云存储服务:以亚马逊S3为例

现在让我们将目光转向具体的云存储服务。市场主要由亚马逊AWS、谷歌云和微软Azure主导。亚马逊S3是此类服务的先驱。

S3的数据模型非常简单,包含两个核心概念:

  • 存储桶:用于组织对象的容器。
  • 对象:存储的基本单元,可以是任何文件(图片、视频、数据文件等)。每个对象通过其键(Key,即ID)来唯一标识。

其访问模式可以概括为:

GET /{bucket-name}/{object-key}
PUT /{bucket-name}/{object-key}
DELETE /{bucket-name}/{object-key}

关于S3,有几个重要的技术细节:

  • 对象大小:单个对象最大为5 TB。
  • 无真实层级:虽然对象键中可以使用“/”来模拟目录结构(如 photos/2024/vacation.jpg),但对S3而言,“/”只是键名中的一个普通字符,并无真正的文件夹概念。
  • 服务协议:云服务提供商会通过服务等级协议来承诺服务质量,通常用多个“9”来表示,例如:
    • 持久性:99.999999999%(11个9)意味着每年每1000亿个对象中仅可能丢失一个。
    • 可用性:99.99%意味着每年服务中断时间不超过约1小时。

分布式系统的理论限制:CAP定理

对于S3或任何分布式数据服务,都存在一个根本性的理论约束,即CAP定理

CAP定理指出,在分布式系统中,一致性、可用性和分区容错性三者不可兼得,最多只能同时满足其中两项。

以下是这三个术语的定义:

  • 一致性:无论连接到哪个服务器节点,所有客户端都能读取到相同的最新数据。
  • 可用性:每个向系统的请求都能在一定时间内收到响应(成功或失败)。
  • 分区容错性:当网络发生分区(部分节点之间无法通信)时,系统仍能继续运行。

因此,系统设计必须在网络分区发生时做出权衡,通常表现为以下三种类型:

  • CP系统:保证一致性和分区容错性,但在分区期间可能变得不可用。
  • AP系统:保证可用性和分区容错性,但在分区期间可能返回不一致的数据。
  • CA系统:保证一致性和可用性,但无法容忍网络分区(在实际分布式环境中很难实现)。

如何与云存储交互:REST API

应用程序通过API与S3这样的服务进行交互,最常用的方式之一是REST API,它基于Web的HTTP协议。

一个REST API的核心要素是资源标识符操作方法

资源通过统一资源标识符来定位,其通用格式如下:

<scheme>://<authority>/<path>?<query>#<fragment>

例如,一个S3对象的URI可能看起来像:https://my-bucket.s3.eu-central-1.amazonaws.com/dataset/file.csv

针对一个资源,主要可以执行以下HTTP方法:

  • GET:获取资源(如下载对象)。
  • PUT:上传或替换资源(需指定完整URI)。此操作是幂等的(重复执行相同效果)。
  • DELETE:删除资源。
  • POST:向资源提交数据,通常用于创建新资源(其URI可能由服务器生成)。

总结

本节课中我们一起学习了云存储的基础。我们了解到,为了存储海量数据,云存储采用了简单的键值模型和向外扩展的策略,依托于由数万台机器组成的数据中心。我们以亚马逊S3为例,剖析了其无层级的数据结构、服务等级协议以及通过REST API进行交互的方式。最后,我们探讨了支配所有分布式数据系统的CAP定理,它揭示了在一致性、可用性和分区容错性之间必须做出的根本性权衡。理解这些概念是设计和运用大数据系统的基石。

006:云存储 (3_3) 📚

在本节课中,我们将完成对云存储的讨论,重点回顾亚马逊S3的核心概念,并介绍Azure Blob存储的实现原理。我们还将探讨处理大型数据集上传的实用技巧,并简要了解键值存储与云存储的区别。


概述

上一节我们介绍了云存储的基本模型和亚马逊S3服务。本节中,我们将深入探讨使用S3存储大型数据集时的实际挑战与解决方案,并了解微软Azure Blob存储的架构设计。最后,我们会简要区分云存储与键值存储的不同应用场景。


S3存储大型对象的实践技巧

亚马逊S3允许存储的最大单个对象为5 TB。然而,在实际操作中,上传如此巨大的文件可能会遇到网络不稳定等问题,导致上传失败。

以下是解决此问题的两种方法。

方法一:分割为多个小对象

更可靠的做法是将大型数据集分割成许多较小的文件进行上传。例如,可以将一个5 TB的文件分割成5000个各为1 GB的对象。

这样做的优势在于,大部分小文件的上传会成功。如果少数文件上传失败,只需重试这些特定的文件,而无需重新开始整个5 TB数据的上传过程。此外,客户端通常支持并行上传多个小文件,从而进一步提高效率。

许多数据集都采用这种分片存储的形式,每个分片大小通常在100 MB到1 GB之间。

方法二:使用S3分段上传API

对于确实需要存储为单个超大对象的情况,S3 API提供了一种更高级的“分段上传”功能。

其流程如下:

  1. 将大文件切割成多个部分。
  2. 分别上传每个部分到服务器。
  3. 在所有部分上传完成后,通知S3服务将这些部分按顺序组合成一个完整的对象。

这种方法虽然更可靠,但操作相对复杂,因此更常见的做法仍是第一种。


Azure Blob存储架构解析

由于亚马逊未公开S3的实现细节,我们可以通过微软Azure发布的论文来了解大型云存储系统的可能架构。Azure Blob存储与S3在高层概念上相似,但存在一些差异。

与S3的异同

  • 对象标识
    • S3使用 桶ID + 对象ID
    • Azure使用 账户 + 容器 + Blob 三层结构。容器类似于S3的桶,但由于加入了账户层,全球范围内的容器名称可以重复。
  • API与对象类型:Azure针对不同用途提供了三种“Blob”类型:
    • 块Blob:适用于存储数据集,支持高效查询,单对象上限高达约190 TB。
    • 追加Blob:专为日志记录等只追加场景优化。
    • 页Blob:用于支持虚拟机运行等场景。
  • 容量限制:Azure块Blob的单对象容量上限(190 TB)远高于S3(5 TB)。

内部架构:存储印记

Azure的数据中心内设有许多存储房间,每个房间称为一个“存储印记”。一个印记可存储约30 PB的原始数据。

为了保证数据可靠性,系统在存储印记内部会进行同步复制,即将同一份数据存储多份(例如三副本)。当你上传一个新对象时,系统会等待数据在印记内成功写入多个副本后,才向你确认上传完成。

此外,系统还会在不同地理区域的数据中心之间进行异步复制。这种复制在后台进行,旨在提供灾难恢复能力和更低的访问延迟,但不会影响用户的上传确认速度。

这种跨区域复制主要出于两个目的:

  1. 降低延迟:将数据副本放置在靠近用户的地理位置,可以极大减少访问延迟,提升用户体验。
  2. 灾难恢复:防止因火灾、地震等自然灾害导致单个数据中心数据完全丢失。

云存储与键值存储

虽然云存储(如S3)和键值存储(如DynamoDB)都使用基于键的扁平数据模型,但它们是针对不同场景设计的系统。

以下是两者的核心区别:

  • 对象大小
    • 云存储的对象通常很大,范围从GB到TB级
    • 键值存储的对象通常很小,在KB到MB级
  • 性能与延迟
    • 云存储的读写延迟较高,通常在几百毫秒级别。
    • 键值存储为交互式应用设计,追求极低延迟,通常在个位数毫秒级别。
  • 典型应用
    • 云存储:存储视频、图片、静态网站文件、大型数据集备份。
    • 键值存储:存储用户会话、购物车商品、社交媒体帖子和评论等需要快速读写的元数据。

简而言之,不能直接用S3来构建需要毫秒级响应的交互式数据库


主要云提供商概览

市场上主要的云提供商(亚马逊AWS、微软Azure、谷歌云)在高层服务上相似,都提供虚拟机、云存储、数据库等服务,但理念略有不同:

  • AWS:提供极其丰富的细分服务,像“自助拼图”。
  • Azure:服务数量相对较少,但单个服务(如Cosmos DB)的功能往往更集成、更强大。

总结

本节课中我们一起学习了:

  1. 处理大型数据集上传的实用技巧:优先将数据分割成多个小对象上传以提高可靠性。
  2. Azure Blob存储的架构原理:了解了数据在“存储印记”内的同步复制和跨区域的异步复制机制,及其对延迟和灾备的意义。
  3. 云存储与键值存储的根本区别:前者用于存储大对象,延迟较高;后者用于存储小对象,追求极低延迟,服务于不同的应用场景。

云存储是我们构建大数据处理流水线的基石。接下来,我们将在此基础上,继续探讨分布式文件系统。

007:分布式文件系统 (1/3)

📚 概述

在本节课中,我们将要学习分布式文件系统。我们将了解它与之前讨论过的对象存储有何不同,它的设计目标是什么,以及它如何解决大规模数据存储中的关键问题,特别是容错性和高吞吐量。


🔄 从对象存储到分布式文件系统

上一节我们介绍了对象存储,它适用于存储海量(数十亿个)但单个文件大小有限(如5TB)的对象。本节中我们来看看另一种存储系统——分布式文件系统。

分布式文件系统的目标同样是存储数据,但方式有所不同。我将解释其区别。

我们已经研究过用于存储数据的关系型数据库。我们也回顾了向外扩展的概念,即使用数千台机器而非单一大型机器。我们看到了云存储服务,特别是对象存储,它允许你存储与键关联的对象。对象存储的典型用例是存储数据集。

需要了解的是,有时你存储的数据集是原始数据集。例如,来自科学实验或传感器的数据。这是原始数据,你可以将其作为日志存入系统。此外还有衍生数据。衍生数据意味着你使用数据分析系统(如稍后将看到的MapReduce、Spark等)处理原始数据,进行分析后产生输出数据。这些输出数据也是数据,但它是衍生的,不再是原始数据。通常,人们也会将这些由Spark或MapReduce输出的衍生数据存回S3,或者如我们将要看到的,存回分布式文件系统。

这意味着我们不仅希望上传和读取数据,还希望在从系统获得新洞察后,能够将数据写回系统。


⚖️ 大数据的不同形态

但关键在于,大数据有不同的形态。

用一个高度概括的方式来对比对象存储和分布式文件系统:有时你可能拥有海量的大文件,有时你可能拥有大量的超大文件

对象存储属于前者。你可以有数十亿个TB级别的文件,数量巨大。这是一个面向全球数十亿用户的服务,用于存储他们的所有照片、视频等。这就是对象存储。

而我们将要探讨的分布式文件系统,则关乎数百万个PB级别的文件。你可能拥有的文件数量会比对象存储少一些,它无法扩展到对象存储那样多的文件数量。但文件的大小不再受5TB这样的限制。你可以超过5TB,甚至可以拥有比单台机器容量还大的文件。这完全没问题。我将向你解释其工作原理。

这是两者之间的对比:数十亿个TB级文件 vs. 数百万个PB级文件。更准确地说,左边是对象存储(键值模型),我们已经学习过。它是将键与对象关联起来的想法。今天我们将关注幻灯片的右侧部分,即分布式文件系统和块存储。


🏗️ 分布式文件系统的起源与核心理念

这一切始于大约20年前。当时大型公司开始拥有如此巨量的数据,以至于需要一种管理方式。因此,Google文件系统(GFS)被创建出来以解决这个问题。

当时的核心理念是:当你在笔记本电脑的单个驱动器上存储数据时,它可能会故障。但在数据中心,故障不是“可能”发生,而是“必然”发生。事实上,故障时刻在发生。数据中心有专人全职负责在集群中奔波,更换所有故障部件。

这意味着,构建系统的方式必须彻底改变。如果你必须时刻处理永久性故障,就必须有一种方式来监控运行状态、检测错误、自动发现硬盘崩溃。然后,你可以构建一个系统,使其能从这些崩溃中自动恢复。如果一个硬盘故障,完全没问题,系统继续工作。这就是自动恢复。在最高层面上,如果你实现了这一点,就拥有了容错性

容错性是指系统在内部某些组件崩溃或故障时,仍能继续正常工作的能力。想象一下这样的场景:数据中心里人们跑来跑去更换故障硬盘,但客户完全看不到这些,他们看到的只是一个持续工作的系统。这听起来像魔法,但实际上是可以构建出来的。


📋 系统设计假设与要求

构建这样的系统时,需要做一些假设和妥协。你无法拥有一切。

例如,在你的硬盘上,如果你使用电子表格软件或文字处理器,你可以随时点击保存,可以永久性地从硬盘读取和写入。这被称为随机访问能力。在你的笔记本电脑和智能手机上,你总是可以读写内存或磁盘上的任何内容。

但我们需要,也不想要分布式文件系统具备随机访问能力。我们不关心随机访问,也不关心高效访问任意位置的少量数据。相反,我们感兴趣的是扫描数据

你取一个文件,逐位遍历并扫描它。事实上,这正是我们在数据分析中喜欢做的事情。我们使用MapReduce等方式,核心就是扫描数据,读取整个数据集,逐位扫描文件。这与随机访问形成对比,后者可能是在一个TB级文件中,你只想读取这里的某个比特,或者在那里写入几个比特。

因此,我们现在构建的系统将针对扫描进行优化。我们想要扫描数据以进行读写,我们只想要一个扫描过程。

我们还希望支持追加。这也很好。你已经有一个文件,你只想在末尾扩展它,这就是追加。例如日志,你总是在末尾记录新内容。在最初的版本中,这些系统可能无法做到这一点。但如今,它们已经能够在文件末尾追加数据。这在创建数据或分析数据并不断生成新数据时非常方便。

追加不仅适用于新的衍生数据,也适用于原始数据。例如,假设你有遍布全国的气象站和温压传感器,你想每秒记录瑞士每个位置的当前温压数据,就可以通过不断在文件末尾追加新测量值来实现。

此外,我们的系统还有一个要求:与你的硬盘(只有你一个人使用)或家庭驱动器(可能只有五六个人访问)不同,我们希望数百人能够同时访问系统。这是一个更强的要求。我们希望它以某种方式扩展,并且我们不希望并发访问导致数据不一致。所以,如果有人正在某个位置写入,我们需要确保没有其他人同时在同一位置写入。这是另一个要求。

最后,我们对性能有要求。


⚡ 性能要求:吞吐量与延迟

这与吞吐量延迟有关。

我们基本上希望构建一个数据流动的系统,比特在其中流转。因此,我们希望系统的限制因素是吞吐量,即每秒移动的比特量。我们不希望受限于延迟。延迟意味着你花费大部分时间在等待。

我将在涉及数据块时解释其含义。为什么我要这样表述?这听起来有点奇怪,我们希望受限于吞吐量,而不希望受限于延迟。原因在于,正如我们将在几周后看到的,任何系统(无论是你电脑上的软件还是网站)总是受限于某些因素,而且通常主要受限于一个因素。这个因素可能是内存、网络速度、硬盘读取速度或CPU速度,这被称为瓶颈。

在正常情况下,一个正常运行的分布式文件系统,我们希望其瓶颈是吞吐量。我们希望受限于吞吐量,而不是延迟。用一种更直观的方式来说,我们只希望比特顺畅地流动,来回传输。

为了让你更直观地理解,如果系统受限于延迟会怎样?那将是一个没有太多事情发生的系统,比特没有真正流动,我们只是在等待。例如,等待磁盘旋转到读写头下的正确位置,或者等待比特在某个地方出现。这将非常糟糕,这不是分析数据的方式。如果你想分析数据,你需要比特流动起来。

关于聊天区的一个问题:这与流处理的关系。这是一个好问题。处理数据时,基本上有两种系统:批处理系统和流处理系统。本课程更侧重于批处理系统。然而,回答你问题的一种方式是:如果你看这种扫描文件的方式,它与流处理是兼容的,因为你可以有一个比特流被顺序写入文件。但这并非易事。在两三周后,当我们学习Bigtable时,我会展示其中的奥妙:在一个受吞吐量限制的系统中,我们如何仍然能以某种方式管理流并支持更新。这是一个悬念,我们几周后再来揭晓。


📈 技术演进与Hadoop

现在,将我所说的容量、吞吐量、延迟放在这个背景下。你可能记得,我们看到容量急剧增长,但读取速度却没有。因此,我们需要并行化。通常,限制我们的就是吞吐量。如果我们进行更多并行化,就能获得更高吞吐量,但这将成为限制因素。第二个限制因素——延迟,是通过批处理来解决的,这个我们同样留到几周后,在讨论HBase和Bigtable时再详细讨论。

今天我们的重点将更多地放在吞吐量和并行化上。

我之前提到了Google文件系统(GFS),但它是专有的。后来发生的是,几年后一些人创建了相同理念的开源版本,并称之为Hadoop。它得名于这只小黄象毛绒玩具,它是Hadoop创建者Doug Cutting儿子的玩具。这要追溯到2006年,已经是18年前了。

Hadoop由三个部分组成,分别对应Google的三篇论文:GFS(21年前)、MapReduce(20年前)、Bigtable(18年前)。Hadoop的对应部分是:HDFS(Hadoop分布式文件系统,我们今天要讲)、MapReduce(几周后讲)和HBase(也是几周后讲)。所有这些我们都会在适当的时候学习,它们统称为Hadoop。但今天我们专注于HDFS。

为了展示其演变,大部分发展实际上发生在2006年至2016年间。整个Hadoop生态规模不断增长。主要推动者是雅虎的搜索引擎,他们需要这类技术来索引网络和数据。你可以看到,机器数量从2006年的近200台,增长到我们推测在2016年11月达到了约10万台,这是一个数据中心能容纳的上限,对应着600PB的数据。

在那之后发生了什么?可以说遇到了一些瓶颈。由于数据中心的限制,我们在单个数据中心内能做的事情似乎达到了极限。唯一的解决方案是开始跨数据中心构建系统。但今天,我们止步于单个数据中心的极限,即最多10万台机器。


🎯 本节总结

本节课中,我们一起学习了分布式文件系统的引入和基本概念。我们对比了它与对象存储的区别,理解了它适用于存储数量相对较少但体积巨大的文件。我们探讨了其设计起源——解决数据中心内硬件必然故障的问题,从而引入了容错性的核心目标。我们还明确了这类系统的设计假设:优化于顺序扫描和追加写入,支持高并发访问,并且性能瓶颈应设计为受吞吐量限制而非延迟限制。最后,我们简要回顾了从Google GFS到开源Hadoop(特别是HDFS)的技术演进历程。

休息之后,我们将开始讲解分布式文件系统的具体模型。请记住,我几乎每周都以同样的方式进行:首先讲解逻辑模型(高层工作原理),然后再深入实现细节。对于S3,我讲解了其模型(桶ID+对象ID对应一个对象的扁平键值模型)。同样,休息后我将讲解分布式文件系统,特别是Hadoop分布式文件系统(HDFS)的模型。我们15分钟后见。

008:分布式文件系统 (2/3)

在本节课中,我们将要学习分布式文件系统(HDFS)的核心模型、架构和工作原理。我们将对比对象存储与分布式文件系统的区别,深入探讨块存储的概念,并了解HDFS中名称节点与数据节点如何协同工作。

模型对比:对象存储 vs. 分布式文件系统

上一节我们介绍了对象存储服务。本节中我们来看看分布式文件系统的模型,并与对象存储进行对比。

对象存储(键值模型)如左图所示,这是我们上周学习的内容。其核心思想是可以用一个键来标识每个对象。在S3中,这个键是 桶名 + 对象名;在Azure中,则是 账户名 + 容器名 + 对象名

然而,在分布式文件系统(通常指HDFS)中,我们拥有一个目录和文件的层级结构。这种层级结构大家应该很熟悉,它类似于个人电脑上的文件系统:目录可以递归地包含子目录,最终在树的末端是文件。这被称为文件层级结构

请注意术语上的区别:在键值模型中,我们称存储的值为“对象”;在文件层级结构中,我们使用“文件”一词。但本质上,它们都是最终存储数据集的东西。

这是第一个区别:从扁平结构变为层级结构。

第二个区别在于数据的组织方式。在对象存储中,对象是一个单一、庞大的整体块,最大可达5TB。然而,在分布式文件系统中,文件被划分为。例如,一个文件可能被分成8个块(1, 2, 3, 4, 5, 6, 7, 8)。这些块通过文件系统的API暴露给用户,你可以看到块1、块2等。这被称为块存储,与对象存储相对。

你可能会问,Azure Blob存储在一开始就提到了块,这是否意味着它已经是分布式文件系统?确实,Azure Blob存储在这方面已经具备了分布式文件系统的某些特性,因为它使用了块。但S3中没有“块”的概念。不要混淆S3 API中用于分块上传的“部分”和这里的“块”。上传时的“部分”是特定于上传方式的,一旦上传完成就会消失,最终只有一个大对象。而分布式文件系统中的“块”是API层面持久存在的部分。

所以,再次强调区别一:我们有了目录和文件的层级结构。区别二:我们将块暴露给HDFS的用户,这称为块存储。

现在,关于“块”这个词的一个现实情况是:在原始的Google文件系统论文中,他们称之为“块”。在MapReduce的上下文中,会使用“分片”一词。在S3中讨论将数据集分割成多个对象时,我用了“分片”这个词。数学家们称之为“分区”。因此,有很多不同的词可以用来表示同一件事:将一个大东西分成几个更小的部分。你可以称这些更小的部分为块、块、分片或分区,这并不重要。关键在于理解这个思想。如果你能记住在GFS的上下文中我们说“块”,在MapReduce中说“分片”,那很好。但我希望你们关注的是这个思想本身:将大的东西分割成我们称之为“块”的较小部分。

HDFS模型总结

以下是HDFS模型的单页总结:

我们拥有目录和文件的层级结构,每个文件对应一个块列表。块的顺序很重要。只有文件有块,目录没有块。目录的存在只是为了给文件系统提供层级结构。这就是HDFS的模型。

为什么需要块?

使用块有以下几个主要原因:

  1. 物理限制:如果一个文件有1PB大,它显然无法存放在单台机器上。在2024年的今天,我们还无法制造出容量为1PB的硬盘。因此,要存储一个无法放入单块磁盘的文件,除了将其分割成更小的块(或分片、分区)之外,别无他法。
  2. 并行处理的抽象层:当我们开始研究MapReduce和Spark如何处理这些文件时,块为我们提供了一个极好的抽象层,使得并行处理成为可能。如果只有一个大对象,并行处理并不明显。但有了块,我们就可以在不同的机器上并行处理这些块。
  3. 数据分布的“流动性”:从逻辑层面看,整个HDFS系统可能有数百万或数十亿个块。我们需要将这些块分布到数千台机器上。如果你有几十亿个小块,就很容易将它们均匀地分布到1000台不同的机器上,就像把水倒入不同的桶里一样。但前提是块的大小要合适。如果块太大(例如5TB),或者太小(例如1KB),这种“流动性”就会变差。块的大小会改变数据分布的灵活性。

块的大小应该是多少?

块在你的笔记本电脑上已经存在。你可能不知道,但你的笔记本电脑也是以块的形式存储文件的。这些块的大小大约是4KB。这意味着你的电脑从磁盘读写数据时,总是以大约4000字节为单位进行的。关系型数据库也是如此,虽然它们的块可能大到32KB,但数量级是相近的。

那么,考虑到我们之前提到的约束——我们希望系统是吞吐量受限而非延迟受限——你认为分布式文件系统中的块应该多大?

以下是几个选项及其可能的原因:

  • 32 KB:像本地文件系统一样。
  • 128 MB:在延迟和吞吐量之间一个良好的折衷。
  • 2 TB:因为可以放在本地磁盘上,是单机使用的最佳方式。
  • 512 MB:因为这是大数据,我们需要巨大的块来让整个系统工作。

正确答案是 128 MB。我来解释一下直觉。

首先,128 MB比本地文件系统的块大得多。我们可以通过考虑两个极端情况来推理:

  • 假设块大小为2 TB

    • 上传文件到这样的系统会非常不实用,网络错误会频繁发生。
    • 在集群中分布数据时缺乏“流动性”,因为块太大了。
    • 每台机器上可能只有一两个块,这在后续的MapReduce中不利于并行化。
    • 因此,2TB虽然能保证高吞吐,但实用性差。
  • 假设块大小为KB级别(如4KB)

    • 每台机器上会有数百万甚至数千万个块。
    • 当你查询或处理一个PB级的数据集时,这个数据集会被分成数万亿个块。
    • 每次访问一个块都会引入延迟。如果顺序访问这些块,所需时间将超过宇宙的年龄。
    • 这样的系统将完全是延迟受限的,大部分时间都在等待,无法有效工作。

因此,128 MB是一个在两者之间的“甜点”。它既足够小,可以在机器间灵活分布(具有“流动性”),又足够大,使得访问每个块时的等待时间不会成为主要瓶颈。

需要强调的是,你不必拘泥于128 MB这个精确数字。重要的是数量级。事实上,在Google文件系统中,块大小是64 MB。HDFS将其翻倍。而像Alluxio这样的系统,块大小已经达到了4 GB。随着计算机硬件的发展,硬盘容量变大,读取速度提升,块的大小也会逐渐增大。但正确的数量级大约在GB级别(十倍以内)。如果是100 GB或TB级别的块,那就不合适了。

关于碎片整理的问题

有同学提到了碎片整理。在本地文件系统中,随着文件的不断写入和删除,一个文件的块可能会分散在磁盘的不同位置。读取这样的文件时,磁头需要频繁移动,导致延迟增加。碎片整理就是重新组织磁盘上的块,使属于同一文件的块在物理上连续排列,从而提升顺序读取的性能。

在HDFS中,拥有大块可以被看作是一种逻辑上的“防碎片”机制。我们确保一个块本身在物理上是连续的(在单个数据节点的本地磁盘上),并且块作为一个整体不会被分割和分散到各处。这与碎片整理的概念有联系。当然,在现代系统中,由于缓存等技术,本地碎片问题已不那么突出,但这个原理有助于理解为什么大块在集群层面是有益的。

HDFS架构

现在我们已经了解了逻辑模型和块的大小,接下来看看HDFS的架构。我们如何连接成千上万的机器?

一种连接方式是点对点网络,例如区块链,每台计算机都可以直接与其他计算机通信。

但在大数据框架中,我们更喜欢另一种架构:一台计算机协调一切,而其他计算机执行所有工作。这种架构有很多名称(主/从、主/工作者、协调器/工作者等)。我们主要称之为协调器节点和工作器节点。

在HDFS的上下文中,我们给协调器节点起名为 名称节点,给工作器节点起名为 数据节点

因此,我们有一个名称节点和许多数据节点,它们以这种方式连接在网络中。块存储在哪里?它们存储在数据节点上,而不是名称节点上。这是一个简化示意图,实际上每台机器上存储着数千个块。

数据写入与复制

当我们有一个文件(例如1DB大小)要存入HDFS时,首先会将其分割成块(例如128 MB的块)。然后,我们不仅将这些块分布到各处,还会对它们进行复制。默认情况下,每个块会被复制三份,存储在三台不同的机器上。这些副本称为副本。它们没有主次之分,只是同一块的三个相同拷贝。

复制的好处:

  1. 容错:如果一块硬盘甚至整台机器故障,数据仍然可以从其他副本访问。
  2. 并行查询:可以从多个位置访问数据,这在MapReduce等处理中非常有用。

这与RAID(独立磁盘冗余阵列)有些类似,但请注意,如果你使用HDFS,就不需要再配置RAID了,因为复制功能已经内置。

名称节点内部

让我们深入看看名称节点内部有什么。主要有三样东西:

  1. 文件命名空间:这是一个树形结构,即文件的层级结构。它记录了目录和文件的组织关系。
  2. 访问控制:类似于Linux的POSIX权限,它定义了哪些用户和组可以读写数据。
  3. 文件到块的映射以及块的位置
    • 文件到块映射:记录每个文件由哪些块组成(按顺序,用块ID列表表示)。
    • 块位置信息:记录每个块存储在哪些数据节点上。

有了文件层级结构、文件到块的映射以及块的位置信息,我们就拥有了重建整个文件系统所需的所有信息。所有这些信息都存储在名称节点中。

数据节点内部

数据节点内部相对简单:存储着。这些块物理上存储在数据节点的本地磁盘上。实际上,每个HDFS块在数据节点的本地文件系统中就是一个独立的文件。但作为HDFS的用户,你无需关心这一点,你看到的只是逻辑上的“块”。重要的是区分全局的HDFS文件系统和每个数据节点上隐藏的本地文件系统。

块通过64位整数ID唯一标识,范围从0到2^64-1,足以标识海量的块。

网络协议与交互

现在介绍名称节点、数据节点和客户端之间如何通信。这是一个简化的视图(实际有数百个客户端和数千个数据节点)。

通信可以分为两类:

  • 控制流(图中黑色箭头):只传递元数据和指令,不传输实际数据块。
  • 数据流(图中其他箭头):传输实际的数据块。

具体协议包括:

  1. 客户端协议:客户端与名称节点之间的通信,用于所有元数据操作(创建目录、删除文件、获取文件块信息等)。
  2. 数据节点协议:名称节点与数据节点之间的通信,包括:
    • 注册:新数据节点加入集群时向名称节点报到。
    • 心跳:数据节点定期(如每3秒)向名称节点发送信号,表明自己存活。
    • 块报告:数据节点定期(如每6小时)向名称节点完整报告自己存储了哪些块以及剩余空间。
    • 指令响应:名称节点通过响应心跳的方式,向数据节点下达指令(如下载某个块的新副本)。关键点:名称节点永远不会主动连接数据节点,总是数据节点作为客户端连接名称节点服务器。
  3. 数据传递协议与复制管道:数据节点之间以及客户端与数据节点之间的通信,用于数据传输。在写入文件时,为了高效复制,客户端只将数据块发送到第一个数据节点,然后由数据节点之间自动形成一个复制管道,将块依次转发给第二、第三个副本节点,这比客户端分别连接三个节点更高效。

文件读取过程

让我们通过一个动画来理解读取文件的完整过程:

  1. 客户端连接名称节点,请求读取某个文件。
  2. 名称节点检查元数据,确认文件存在,并返回该文件包含的块ID列表以及每个块的副本位置(通常会按网络距离排序,建议客户端优先访问最近的数据节点)。
  3. 客户端获得这些信息后,开始顺序读取文件。在物理层面,客户端需要:
    • 连接到存有第一个块副本的某个数据节点(例如D1),下载整个块。
    • 然后连接到存有第二个块副本的某个数据节点(例如D2),下载第二个块。
    • 依此类推。
  4. 在实际编程中(如使用Java API),HDFS提供了一个输入流抽象。开发者只需像读取本地文件一样从这个流中读取数据,底层的复杂性(连接正确的数据节点、请求正确的块)都被封装了起来,对用户透明。

文件写入过程

写入文件的过程涉及我们提到的复制管道,这将在下一节课的动画中详细展示。其核心思想是客户端只与管道中的第一个数据节点交互,由数据节点负责将数据沿管道复制到其他副本节点,并在完成后进行确认。

关于名称节点故障

这是一个非常重要的问题,它为我们下一讲留下了悬念。名称节点作为存储所有关键元数据的单一节点,其故障确实是一个需要解决的挑战。我们将在后续课程中探讨HDFS如何应对名称节点故障(例如通过备用名称节点或高可用机制)。

本节课中我们一起学习了分布式文件系统HDFS的核心概念。我们对比了它与对象存储的区别,理解了块存储的必要性和块大小的设计考量。我们深入探讨了HDFS的架构,包括名称节点(存储元数据)和数据节点(存储数据块)的角色,以及它们之间通过客户端协议、数据节点协议和复制管道进行协作的方式。我们还概述了文件读取的基本流程。这些知识是理解后续大数据处理框架(如MapReduce和Spark)如何建立在分布式存储之上的基础。

009:分布式文件系统 (3/3) 🗂️

在本节课中,我们将完成对分布式文件系统(特别是HDFS)的探讨。我们将深入了解HDFS的写入协议、副本放置策略、NameNode的高可用性方案,以及如何使用HDFS的命令行工具。

回顾与提问

上一节我们介绍了HDFS的读取流程。本节中,我们来看看如何向HDFS写入文件。在开始之前,我们先回顾一个关键问题。

在HDFS中,客户端需要与哪些服务器通信?

  • 创建目录时,需要与NameNode通信。
  • 获取文件的数据块时,需要与DataNode通信。

因此,答案是:创建目录时与NameNode通信,获取数据块时与DataNode通信。客户端从NameNode获取的是标识数据块的ID,而实际的数据块内容则从DataNode获取。

HDFS数据模型回顾

让我们快速回顾一下HDFS的数据模型。HDFS采用类似本地文件系统的层次化结构,包含目录和文件。文件内容被组织成固定大小的数据块(Block),默认大小为128MB(可配置)。除了最后一个块可能较小外,所有块大小相同,这样可以避免空间浪费。这些逻辑上的HDFS块在DataNode的本地文件系统中,就是一个个128MB大小的本地文件。

写入文件流程 ✍️

现在,我们来看看如何向HDFS写入或创建一个新文件。

写入流程的核心步骤如下:

  1. 客户端发起创建请求:客户端首先连接到NameNode,告知其想要创建一个新文件。
  2. NameNode响应:NameNode检查文件路径是否合法且不存在,然后回复客户端“可以创建”,并返回第一个数据块的ID以及用于存储该块的三个DataNode的地址(例如IP地址)。这三个地址按与客户端的网络距离排序。
  3. 建立数据管道:客户端并不直接连接所有三个DataNode,而是只连接距离最近(列表中的第一个)的DataNode。客户端会告知这个DataNode另外两个DataNode的地址,由这个DataNode负责建立到下一个DataNode的写入管道,依此类推,形成一个数据管道。
  4. 传输数据块:客户端开始通过管道向第一个DataNode发送第一个数据块的数据。数据在网络上被分割成多个小数据包(例如64KB)进行传输。客户端持续发送数据包,并异步接收来自管道的确认信息。
  5. 写入后续块:第一个块写入完成后,客户端会向NameNode请求下一个块的ID和存储位置,然后重复步骤3和4,建立新的管道并传输数据。
  6. 关闭文件与释放锁:当所有数据块都发送完毕后,客户端会最后一次连接NameNode,告知文件写入完成,请求“关闭并释放锁”。在锁释放前,其他客户端无法写入该文件(读取可能被允许,但有风险)。锁释放后,其他客户端可以追加写入。
  7. 副本确认:NameNode在收到关闭请求后,会等待来自DataNode的心跳信息,以确认每个数据块至少有一个副本已成功写入(这称为“最小副本数”,默认为1)。一旦满足条件,NameNode便向客户端确认写入成功。之后,NameNode会在后台异步地确保所有数据块都达到预设的副本数(例如3个)。

副本放置策略 🧱

HDFS通过存储多个数据块副本(Replica)来保证可靠性。默认副本数为3。这些副本是对称的,没有主从之分。

NameNode负责决定每个数据块的副本放置在哪些DataNode上。其策略旨在平衡可靠性、写入带宽和读取性能。

以下是默认的副本放置策略:

  1. 第一个副本:放置在运行客户端的同一个节点上(如果该节点也是DataNode)。如果客户端不在集群内,则随机选择一个DataNode。
  2. 第二个副本:放置在与第一个副本不同机架(Rack)的某个DataNode上。
  3. 第三个副本:放置在与第二个副本相同机架的另一个DataNode上。
  4. 更多副本:如果副本数大于3,则随机放置在集群的其余节点上,但会尽量保证每个节点最多一个副本,每个机架不超过两个副本。

策略的直观解释:

  • 将第一个副本放在客户端本地,实现了最高效的写入。
  • 将另外两个副本放在另一个机架,而非客户端所在机架,是为了防止整个机架故障导致多个副本同时丢失。这比将两个副本都放在客户端本地机架更安全。
  • 这种策略将数据块分散在集群中,有利于负载均衡。

NameNode高可用性 🛡️

HDFS的架构存在一个单点故障风险:NameNode。如果唯一的NameNode发生故障,虽然DataNode上的数据块仍然存在,但失去了将数据块重新组装成文件的“地图”(即命名空间和块映射关系),数据将无法访问。

解决方案的演进:

  1. 备份与日志(基础方案)
    • 命名空间镜像:定期将NameNode内存中的元数据(文件系统命名空间和文件到块ID的映射)保存为一个快照文件,存储到可靠的远程存储(如网络附加存储)。
    • 编辑日志:在两次快照之间,将所有对元数据的修改操作顺序记录到编辑日志中。
    • 恢复过程:NameNode故障重启后,先加载旧的命名空间镜像,然后按顺序“重放”编辑日志中的所有操作,以恢复最新的元数据状态。最后,通过接收DataNode的心跳来重建块位置映射。这个过程可能耗时较长(例如30分钟)。

  1. 检查点:为了控制编辑日志的大小,从而缩短恢复时间,可以定期将编辑日志合并到命名空间镜像中,生成新的、更最新的镜像文件,然后清空或截断编辑日志。

  1. 备用NameNode:这是一个更先进的方案。备用NameNode像“副总统”一样,实时同步主NameNode的所有元数据变更。当主NameNode故障时,备用NameNode可以立即接管工作,实现几乎无中断的故障转移。

  2. 观察者NameNode:这是最新的演进。观察者NameNode除了具备备用NameNode的故障转移能力外,还可以分担主NameNode的读请求负载。客户端可以向观察者NameNode查询元数据,但写操作仍需由主NameNode处理。

  3. 联邦HDFS:类似于Linux文件系统的分区挂载,可以将HDFS的命名空间划分为多个部分,每个部分由一个独立的NameNode负责。这允许集群水平扩展,以支持更大的文件系统。

使用HDFS 🖥️

在实践中,使用HDFS与使用本地文件系统非常相似,主要通过命令行工具。

基本命令格式为:

hadoop fs -<command> [arguments]

常用的命令与Linux命令类似:

以下是常用命令示例:

  • hadoop fs -ls <path>:列出目录内容。
  • hadoop fs -cat <file>:显示文件内容。
  • hadoop fs -mkdir <path>:创建目录。
  • hadoop fs -rm <path>:删除文件/目录。
  • hadoop fs -put <local_src> <hdfs_dst>:从本地文件系统上传文件到HDFS。
  • hadoop fs -get <hdfs_src> <local_dst>:从HDFS下载文件到本地。

需要注意的是,由于涉及网络通信,HDFS命令的延迟会比本地命令高,通常会有1-2秒的等待时间。

相关工具与术语对照 🔧

还有一些工具常与HDFS配合使用:

  • Apache Flume:用于高效地收集、聚合和移动大量日志数据到HDFS。
  • Apache Sqoop:用于在HDFS和关系型数据库之间高效地传输批量数据。

最后,了解不同系统间的术语对照有助于阅读相关文献:

  • HDFS中的 Block 在GFS中称为 Chunk
  • HDFS中的 NameNode 在GFS中称为 Master
  • HDFS中的 DataNode 在GFS中称为 Chunk Server
  • HDFS中的 命名空间镜像 在GFS中称为 检查点镜像
  • HDFS中的 编辑日志 在GFS中称为 操作日志

总结

本节课中,我们一起深入学习了HDFS的核心机制。我们掌握了HDFS写入文件的完整流程,理解了其通过数据管道高效传输数据的原理。我们探讨了副本放置策略如何权衡性能与可靠性。针对NameNode的单点故障问题,我们回顾了从备份恢复、检查点到备用NameNode和观察者NameNode的演进解决方案。最后,我们学习了如何使用熟悉的命令行工具与HDFS进行交互,并了解了一些相关的生态工具。

至此,我们对分布式文件系统HDFS的讨论就告一段落了。接下来,我们将开始学习如何存储和表示数据。

010:语法 (1_2) 🧩

在本节课中,我们将要学习数据语法的基础知识。我们将从字符编码开始,了解数据如何从比特转换为文本,然后深入探讨两种核心的文本数据格式:CSV和JSON。我们将理解为什么在大数据场景下,我们常常需要打破传统数据库的规则,使用能够表示嵌套和异构数据的格式。

从比特到字符:编码基础

上一节我们介绍了大数据存储的基础设施。现在,我们知道可以将大量数据存储在如Amazon S3或HDFS这样的系统中。但这些系统存储的是比特(0和1)。为了处理数据,我们需要知道这些比特代表什么。本节中,我们来看看如何将比特转换为人类可读的字符。

首先,我们需要一种方法将字母、数字和符号(统称为字符)编码为比特。在计算机早期,这通过ASCII(美国信息交换标准代码)完成。

ASCII是一个包含128个符号的目录。每个字符被分配一个从0到127的数字。然后,这个数字可以用二进制(基数为2)表示,即用7个比特(0或1)来表示。

公式:一个ASCII字符 c 可以映射为一个7位的二进制数:encode(c) -> b6 b5 b4 b3 b2 b1 b0

然而,ASCII只适用于英语。世界上有许多语言和特殊符号(如重音符号),因此出现了许多其他编码(如ISO-8859-1用于西欧语言)。为了统一,Unicode应运而生。

Unicode是一个庞大的符号目录,为世界上几乎所有的字符和符号(包括表情符号)分配了一个唯一的数字。将Unicode数字转换为比特的常见方式是UTF-8编码。UTF-8是“弹性”的:简单的英文字符可能只用8位(1字节),而更复杂的字符可能需要16位或24位。

核心概念字符 -> Unicode编号 -> (UTF-8) -> 比特序列

一个常见的编码问题是,当你用错误的编码方式(例如,用ISO-8859-1去打开一个UTF-8编码的文件)查看文本文件时,会出现乱码(如奇怪的符号或问号)。

现在,我们解决了将字符存储为比特的问题,可以暂时忘记底层的0和1,在更高的层次上——即字符和符号的层面——来讨论数据表示。

表格的文本表示:CSV

现在我们可以处理字符了,让我们看看如何用文本来表示数据。我们从最熟悉的表格数据开始。CSV(逗号分隔值)是表格的一种常见文本表示形式。

在CSV中,通常第一行是列名(表头),随后的每一行代表一条记录(一个业务对象)。值之间用逗号分隔。

示例代码

ID,Name,Price
1,Phone,800
2,Laptop,1200

CSV将逻辑上的表格(行和列)映射为文本行。然而,CSV实际上非常复杂,因为有太多不同的约定:

  • 分隔符:可以是逗号、分号、制表符或空格。
  • 引号:如果值本身包含分隔符(如“General, Relativity”),需要用引号将整个值括起来。
  • 转义:如果值中包含引号,则需要通过双写引号等方式进行转义。

尽管有RFC 4180等标准,但在实践中,CSV文件格式多种多样,处理时需要小心。

超越扁平表格:为何需要新语法?

在课程开始时,我们提到了数据库的“范式”。范式(如第一范式、第二范式、BCNF)的目标是让数据结构化、扁平化,避免数据冗余和不一致,这在需要频繁更新和修改数据的在线事务处理系统中至关重要。

然而,在大数据分析的场景下,情况有所不同。

我们通常从网上下载一个数据集进行分析,而不是持续修改它。这是读密集型的操作。因此,我们不必严格遵守范式。我们可以进行反规范化

反规范化意味着:

  1. 允许嵌套:我们可以在表中嵌套表(打破第一范式)。
  2. 允许异构:行与行之间可以有不同的列,允许缺失值(打破关系完整性)。

这样做的好处是,将所有相关数据放在一个大的、嵌套的结构中,可以避免在分析时进行复杂的表连接操作,因为数据已经“预连接”好了。

对比

  • 规范化(SQL领域):多个扁平表,通过主外键关联。适合频繁写入。
  • 反规范化(NoSQL/大数据领域):单个(可能嵌套的)大表。适合一次性读取和分析。

一个表格可以看作是一组行的集合,每一行是一个元组。从数学上看,一行是一个从属性名(列名)到值的映射(部分函数)。

公式:一行数据可以表示为一个函数 f: Attributes -> Values,例如 {“产品”: “手机”, “价格”: 800, “客户”: “约翰”, “数量”: 1}

JSON:表示反规范化数据的语法

如何用文本来表示这种嵌套的、异构的数据呢?CSV很难做到,但JSON可以轻松应对。JSON(JavaScript对象表示法)正是这种映射的文本表示。

一个JSON对象使用花括号 {},里面包含一系列的“键-值对”,键是字符串,值可以是多种类型。这完美对应了表格中的一行。

示例代码(对应一行数据)

{
  “产品”: “手机”,
  “价格”: 800,
  “客户”: “约翰”,
  “数量”: 1
}

JSON的威力在于其值的多样性。以下是JSON的六大构建块:

  1. 字符串:由双引号包围的文本,支持转义字符(如\n换行)和Unicode字符(如\u0041表示‘A’)。
  2. 数字:整数或浮点数,支持科学计数法(如1.23e+4)。
  3. 布尔值truefalse
  4. 空值null
  5. 数组:由方括号 [] 包围的有序值列表,值之间用逗号分隔。例如 [1, true, “hello”]空数组 [] 是允许的
  6. 对象:由花括号 {} 包围的键-值对集合,键必须是字符串,键-值对之间用逗号分隔。例如 {“name”: “Alice”, “age”: 30}

利用数组和对象,我们可以轻松表示嵌套结构(表中有表)和异构数据(某些键缺失)。

示例代码(嵌套表格的JSON表示)

{
  “产品”: “手机”,
  “价格”: 800,
  “销售记录”: [
    { “客户”: “约翰”, “数量”: 1 },
    { “客户”: “彼得”, “数量”: 2 },
    { “客户”: “玛丽” } // 注意“数量”键缺失,表示异构性
  ]
}

在实际的大数据集中,通常每行是一个独立的JSON对象,这种格式称为JSON Lines。例如“伟大语言游戏”数据集,包含了1600万行这样的JSON记录。

关于JSON键的注意事项:在同一个对象中,键应该是唯一的。虽然JSON规范未严格禁止重复键,但绝大多数处理程序会将其视为错误,因此在本课程中我们要求键必须唯一。

XML简介:另一种树形语法

另一种广泛使用的、特别是企业和政府领域常见的语法是XML(可扩展标记语言)。XML比JSON更复杂,但功能强大且标准化。

XML的核心构建块是元素。一个元素由开始标签、内容和结束标签组成,用尖括号 < > 标识。

示例代码

<city>Zurich</city>

<city> 是开始标签,Zurich 是文本内容,</city> 是结束标签。

如果一个元素没有内容,可以简写为自闭合标签:

<emptyElement />

这等价于 <emptyElement></emptyElement>

XML通过元素的嵌套也能很好地表示树形结构数据。我们将在下一讲中继续学习XML的更多细节,如属性和文本节点。

总结

本节课中我们一起学习了数据语法的核心概念。

  1. 我们了解了字符编码(ASCII, Unicode/UTF-8)是如何将文本转换为比特的基础。
  2. 我们回顾了CSV作为扁平表格的文本表示,并认识到其在复杂嵌套数据上的局限性。
  3. 我们探讨了在大数据分析中反规范化的必要性,即允许数据嵌套和异构,以优化读取性能。
  4. 我们深入学习了JSON语法,掌握了其六种构建块(字符串、数字、布尔值、空值、数组、对象),并学会了如何使用JSON来表示反规范化的、树形结构的数据。
  5. 我们简要介绍了XML,了解了其以元素为基础表示树形数据的方式。

通过掌握JSON和XML,我们拥有了处理大数据中常见的半结构化数据的强大工具。在接下来的课程中,我们将学习如何查询和处理这些格式的数据。

011:语法 (2_2)

在本节课中,我们将完成对XML语法的学习,并与JSON进行对比。之后,我们将介绍一种更强大的数据存储格式——列式存储,它能够以比L存储或HDFS更结构化的方式存储包括JSON和XML在内的大量数据。

在开始之前,我们先通过一个互动问题来回顾一下HDFS的知识点。

以下是关于NameNode存储内容的问题。在HDFS中,以下哪一项存储在NameNode上?

  • 文件命名空间
  • 从文件路径到数据块ID的映射
  • 从数据块ID到数据节点列表的映射
  • 数据块的副本

正确答案是:数据块的副本。NameNode存储的是文件的元数据信息,而数据块的实际内容(包括其副本)存储在DataNode上。其他三项都是重建文件所必需的元数据。

现在,让我们回到语法部分。上一节我们介绍了JSON的六种基本构建块。本节中,我们来看看XML的语法。

XML的构建块

XML比JSON更复杂,但我们将聚焦于存储数据所需的核心部分。XML的主要构建块如下:

1. 元素

元素是XML的基础单元,类似于JSON中的对象或数组。它由开始标签和结束标签定义,标签之间的内容可以嵌套其他元素或文本。

<element>内容</element>

如果元素为空,可以使用简写形式:

<element />

2. 属性

属性是附加在元素开始标签或空标签内的键值对,用于描述元素的额外信息。每个元素的属性键必须唯一。

<element key1="value1" key2="value2">内容</element>

3. 文本

文本是出现在元素开始和结束标签之间的字符序列。在语法层面,XML只识别文本,不区分数字、布尔值等类型,这些类型的识别在数据建模层面处理。

4. 注释

注释用于在文档中添加人类可读的说明,处理器可以保留注释信息。

<!-- 这是一个注释 -->

5. 文档声明

文档声明通常出现在XML文件的开头,用于指定XML版本和字符编码,它是可选的。

<?xml version="1.0" encoding="UTF-8"?>

格式良好性规则

一个格式良好的XML文档必须遵守以下规则:

  • 单一根元素:文档必须有且仅有一个顶级元素。
  • 正确的嵌套:元素必须正确嵌套和关闭,不能交叉。
  • 属性位置:属性只能出现在元素的开始标签或空标签内。
  • 文本位置:文本只能出现在元素标签之间,不能出现在顶级或开始标签内部。
  • 特殊字符转义:在文本和属性值中,某些字符(如 <, &)必须使用预定义的实体引用来转义。

以下是预定义的五个XML实体引用:

  • &lt; 代表 <
  • &gt; 代表 >
  • &amp; 代表 &
  • &apos; 代表 '
  • &quot; 代表 "

元素命名规则

元素名称需遵循以下规则:

  • 可以包含字母、数字、连字符、下划线和点号。
  • 不能以数字、连字符或点号开头。
  • 不能包含空格。
  • 不能以任何大小写形式的“xml”开头(此前缀保留给标准使用)。

命名空间

当不同领域使用相同的元素名称可能造成冲突时,可以使用命名空间来区分。命名空间通过URI标识,并使用前缀来引用。

<root xmlns:math="http://www.w3.org/1998/Math/MathML">
    <math:sin>...</math:sin>
</root>

xmlns:math 定义了前缀 math 与一个URI的绑定,之后使用 math: 前缀的元素都属于该命名空间。

XML与表格数据的映射

了解了XML语法后,我们可以将其用于存储数据。对于扁平、同质的表格数据,可以很容易地映射到XML。

以下是一个将销售表格映射为XML的例子。表格包含多行数据,每行有产品、价格、客户和数量四列。

对应的XML结构如下:

<sales>
    <sale>
        <product>Apple</product>
        <price>1.5</price>
        <customer>Alice</customer>
        <quantity>10</quantity>
    </sale>
    <sale>
        <product>Banana</product>
        <price>0.8</price>
        <customer>Bob</customer>
        <quantity>15</quantity>
    </sale>
</sales>

这里,<sales> 是根元素(表名复数),每个 <sale> 子元素代表一行数据(表名单数),行中的每一列则表示为 <sale> 内的子元素。

XML的强大之处在于可以轻松表示嵌套的、非规范化的数据,这是CSV格式无法做到的。例如,可以在一个销售记录中嵌套其相关的所有订单。

其他文本格式:YAML

除了JSON和XML,YAML是另一种流行的数据序列化格式。它设计的目标是易于人类阅读和编写。

person:
  name: John Doe
  age: 30
  hobbies:
    - hiking
    - reading

YAML使用缩进来表示结构,避免了JSON的大括号和XML的标签,在某些场景下(如配置文件)非常受欢迎。其核心概念(对象、数组、嵌套)与JSON相似。

数据类型与模式验证

需要注意的是,在JSON和XML的语法层面,值(尤其是XML的文本)的类型(如日期、布尔值)并未被严格定义。类型约束和验证通常通过模式来定义,这是在数据被解析之后进行的步骤。在大数据生态中,通常采用“先摄取后清洗验证”的灵活方式。

工具推荐:Oxygen XML Editor

为了练习和验证XML/JSON文档,推荐使用Oxygen XML Editor。这款软件可以解析、验证、美化格式并高亮显示这些文档的结构,对于学习和项目开发都很有帮助。


本节课中我们一起学习了XML的核心语法、格式良好性规则、命名空间概念,并了解了如何用XML表示表格和嵌套数据。同时,我们也简要对比了JSON和YAML格式。掌握这些文本格式的语法是理解和处理大数据中半结构化数据的基础。在接下来的课程中,我们将为这些数据添加模式验证,并最终引出数据帧的概念。

012:宽列存储 (1/3) 🗄️

在本节课中,我们将学习宽列存储,这是一种用于存储海量结构化或半结构化数据的关键技术。我们将从逻辑模型开始,了解其与传统数据库的区别,并初步探索其物理架构。

概述

我们已经学习了如何将数据(如XML、JSON、CSV文件)存储到S3、Azure Blob或HDFS等分布式文件系统中。现在,我们将探讨一种更结构化的存储方式——宽列存储。它看起来像一个巨大的表格,能够高效地存储数十亿行、数百万列的数据,并支持随机访问和更新。本节课将重点介绍其逻辑模型和核心概念。

逻辑模型:从规范化到反规范化

上一节我们介绍了如何将数据以文件形式存储。本节中我们来看看如何通过“反规范化”来构建一个巨大的表格,以优化查询性能。

在传统的关系型数据库(如PostgreSQL)中,我们遵循规范化原则,将数据拆分到多个表中,并通过连接操作来组合数据。然而,连接操作在分布式环境下成本高昂,因为它需要在集群节点间移动大量数据。

宽列存储的核心思想是反规范化。我们通过将多个小表合并成一个大表来避免连接操作。虽然这会引入数据冗余,但换来了查询性能的巨大提升。这正是“大表”名称的由来。

以下是一个简单的反规范化示例:

  • 规范化(多表):
    • 表A: (1, a), (2, b), (3, c)
    • 表B: (a, X), (a, Y), (b, Z)
    • 需要JOIN操作来关联数据。
  • 反规范化(大表):
    • 单表: (1, a, X), (1, a, Y), (2, b, Z)
    • 数据已预连接,无需额外JOIN。

宽列存储的核心概念

了解了反规范化的思想后,我们来具体看看宽列存储的逻辑模型是如何定义的。

宽列存储的逻辑视图是一个巨大的稀疏表格。它具有以下核心组成部分:

  • 行键:每行数据都有一个唯一的Row ID作为主键,用于快速定位该行。它可以是任意二进制数据。
  • 列族与列:列被分组到列族中。一个列由列族名:列名来唯一标识(例如 info:name)。列族通常在创建表时定义,而列则可以动态添加,无需预定义模式。
  • 单元格:行与列的交点称为单元格,用于存储值。所有值都是二进制数据,没有数据类型(如整数、字符串)的限制,这类似于对象存储。
  • 稀疏性:由于列可以动态添加,且并非每行都需要所有列,因此这个表格是稀疏的,许多单元格为空。

一个典型的宽列存储表结构如下所示:

Row ID 列族: cf1 列族: cf2
row1 cf1:colA = value1 cf2:colX = valueX
row2 cf1:colB = value2 cf2:colY = valueY
row3 cf2:colZ = valueZ

代码描述:在HBase Shell中,插入数据的命令类似于 put ‘table_name‘, ‘row1‘, ‘cf1:colA‘, ‘value1‘

数据操作接口

现在我们已经知道了数据如何组织,接下来看看能对它们进行哪些操作。宽列存储通常提供四种基本操作:

以下是主要的API操作:

  1. Get:根据指定的行键,快速读取一行或其中部分列族的数据。这实现了高效的随机访问。
    • 示例:获取 Row ID = 0xa1 的所有数据。
  2. Put:插入或更新数据。可以插入新行,或为已有行添加/更新特定列的值。
    • 示例:为 Row ID = 204 插入 {cf1:col1=val1, cf2:col2=val2}
  3. Scan:扫描表中一个连续的行键范围。可以指定起始和结束行键,并选择特定的列族。这是进行全表或范围数据分析的基础。
    • 示例:扫描 Row ID1016 的所有行,仅读取 cf2 列族。
  4. Delete:删除指定行,或删除某行中特定列族/列的数据。

物理架构初探

理解了用户视角的逻辑模型后,我们开始深入其内部,看看系统如何实现存储与扩展。这是本节课最复杂的部分,但其中蕴含的分布式设计思想在大数据领域非常普遍。

宽列存储(如HBase)的物理架构依赖于HDFS这样的分布式文件系统。其核心设计是将逻辑上的大表进行切割,并分布到集群中。

首先,系统对数据做两种切割:

  1. 水平切割:按行键的范围将表切分成多个 Region。例如,Row ID 0-999 在一个Region,1000-1999在另一个。
  2. 垂直切割:按列族进行切割。

一个Region和一个列族的交集,称为一个 StoreStore是数据持久化的基本单元。每个Store包含一个特定行键范围和特定列族的所有数据。

架构上,与HDFS类似,采用主从模式:

  • HMaster:主节点,负责管理元数据(如表创建)、分配Region给具体的Region Server,以及负载均衡和故障恢复。
  • Region Server:从节点,负责处理分配给它的Region的读写请求。每个Region Server上会运行多个Region。

关键点:数据实际存储在HDFS上。Region Server只管理数据的访问和缓存,持久化层由HDFS保证可靠性和可用性。因此,当一个Region Server故障时,HMaster可以将其负责的Region重新分配给其他健康的Region Server,而数据不会丢失。

数据持久化与版本控制

最后,我们来了解数据是如何最终落到磁盘上的。每个Store中的数据会持久化到HDFS上的一种特定格式文件中,称为 HFile

HFile内部,数据按单元格的字典序(Row ID, 列族, 列, 时间戳)排列存储。每个单元格包含其坐标信息和二进制值。

此外,HBase支持多版本控制。每次对单元格执行Put操作,都会生成一个新的版本(带时间戳)。默认情况下,读取会获取最新版本的数据,但也可以读取历史版本。在HFile中,同一单元格的多个版本按时间戳倒序排列(最新的在前)。

公式描述:一个单元格的逻辑标识可表示为:(RowKey, ColumnFamily:ColumnName, Timestamp) -> Value

总结

本节课中我们一起学习了宽列存储的基础知识。我们首先了解了其反规范化的逻辑模型,它通过一个包含行键列族和动态列的稀疏大表来组织数据。然后,我们学习了其四种基本操作:Get、Put、Scan、Delete。最后,我们初步探讨了其物理架构,了解到大表如何被切割成RegionStore,并依托HDFS进行存储,通过HMasterRegion Server协同工作以实现扩展性和可靠性。我们还提到了数据的多版本控制特性。下节课我们将继续深入物理架构的细节。

013:宽列存储(2/3) 🗄️

在本节课中,我们将继续学习宽列存储,特别是 HBase——Bigtable 的开源实现。我们将深入探讨其物理存储结构、数据组织方式以及如何高效地处理读写操作。

回顾:HBase 的数据模型

上一节我们介绍了 HBase 宽列存储的基本概念。本节中,我们来看看其物理存储的细节。

HBase 表包含数十亿行和数百万列,是一个稀疏矩阵。列被分组为列族,列族被存储在一起。表在水平方向上被划分为多个区域,每个区域包含一段连续的行键范围。一个区域和一个列族的交集,构成了一个“存储”。

物理存储:从单元格到键值对

在物理层面,每个“存储”最终被持久化到 HDFS 上。其核心思想是将所有数据单元(单元格)转换并存储为一个有序的键值对列表。

以下是其组织方式的关键步骤:

  • 单元格排序:首先,所有单元格按行键升序排列。接着,在同一行内,按列名升序排列。最后,对于同一单元格,按版本号降序排列(最新版本在前)。
  • 转换为键值对:每个单元格及其版本信息被编码为一个键值对。
    • 行键 + 列族 + 列名 + 版本 组成,用于唯一标识一个值。
    • 就是单元格中的实际数据。
  • 存储格式:这些键值对被顺序写入文件(称为 HFile)。为了能在读取时正确解析,每个键值对前面会存储其键的长度和值的长度。这样,系统就知道需要读取多少字节来获取完整的键和值。

高效查找:HFile 与索引块

如果 HFile 只是一个巨大的、有序的键值对列表,查找特定键将非常低效(可能需要遍历整个文件)。为了加速查找,HFile 内部引入了索引结构。

以下是 HFile 的内部组织:

  • 数据块:键值对被分组到多个“HBlock”中。每个块的目标大小是 64KB,但如果一个键值对很大,块的实际大小会弹性增加以容纳完整的键值对。
  • 索引:在 HFile 的开头,会存储一个索引。这个索引记录了每个 HBlock 的起始键。当需要查找某个键时,系统首先在内存中加载这个索引,通过二分查找快速定位到目标键可能位于哪个 HBlock 中,然后只需读取那个特定的块即可,避免了全文件扫描。

写入难题与内存存储

HDFS 不支持随机修改文件,只能在文件末尾追加。但 HBase 需要支持随时插入或更新数据,这似乎产生了矛盾。HBase 通过巧妙的“内存存储”设计解决了这个问题。

写入操作的处理流程如下:

  1. 写入预写日志:任何新的写入请求到达时,首先被顺序追加到一个称为“预写日志”的特殊 HDFS 文件中。这个日志按到达时间排序,确保即使系统崩溃,操作也不会丢失。
  2. 写入内存存储:随后,该键值对被插入到对应 Region Server 的内存中的一个有序数据结构里,这个区域称为“MemStore”。MemStore 始终保持键的排序状态。
  3. 刷写到磁盘:当 MemStore 的大小达到一定阈值时,系统会触发“刷写”操作。此时,MemStore 中所有已排序的键值对会被一次性、顺序地写入 HDFS,形成一个新的 HFile。这个过程非常高效,因为它利用了 HDFS 的顺序写入优势。
  4. 清理日志:刷写完成后,对应的预写日志条目就可以被安全地删除,因为数据已经持久化到 HFile 中。

读取操作与文件合并

读取数据时,系统需要查找可能分布在多个地方的数据。

以下是读取时需要查询的位置:

  • 所有已刷写到磁盘的 HFile。
  • 内存中的 MemStore。

系统会并行查询这些位置,收集所有符合条件的键值对(包括不同版本),然后根据时间戳等规则将最新结果返回给用户。

随着时间推移,刷写操作会产生大量小 HFile,导致读取性能下降(需要检查的文件太多)。为了解决这个问题,HBase 会定期执行“压缩”操作。

压缩操作的过程如下:

  • 系统选取多个较小的 HFile。
  • 将它们合并成一个更大的、内部有序的新 HFile。
  • 删除旧的小文件。

由于每个 HFile 内部都是有序的,合并多个有序列表的算法复杂度是线性的,效率很高。这有效控制了 HFile 的数量,保持了读取性能。

总结:LSM 树架构

本节课我们一起学习了 HBase 核心的存储引擎设计,它遵循 LSM 树 的架构模式。

其核心思想可总结为:

  1. 利用内存缓冲:先将写入操作缓存在内存(MemStore)中,实现高速写入。
  2. 顺序刷写磁盘:将内存中的数据批量、有序地写入磁盘(HFile),规避随机写入的性能瓶颈。
  3. 后台合并优化:通过后台的压缩进程,将多个小文件有序合并为大文件,优化读取性能。

这种设计在读写之间取得了出色的平衡,特别适合大数据场景下的海量数据存储与访问。下一节,我们将探讨 HBase 的集群架构与故障恢复机制。

014:宽列存储 (3_3)

在本节课中,我们将深入探讨HBase中日志结构合并树(LSM-Tree)的核心工作原理,特别是其刷新与合并机制。我们将理解这种设计如何优化大数据环境下的读写性能,并介绍一些关键的优化技术。

上一节我们介绍了LSM-Tree的基本概念,本节中我们来看看其核心的刷新与合并过程是如何具体运作的。

性能瓶颈:寻道时间与传输时间

在理解LSM-Tree的设计优势前,需要先区分两种性能瓶颈。

  • 寻道时间/延迟限制:大部分时间花在等待上。例如,请求数据后需要等待磁盘寻址和旋转。
  • 传输时间限制:大部分时间花在数据传输上。数据比特在网络或磁盘间持续流动。

传统关系型数据库的B+树索引是为寻道时间限制的环境优化的。而LSM-Tree是为大数据中更常见的传输时间限制环境优化的,它通过使数据存储更密集来优化性能。

LSM-Tree的层级结构

LSM-Tree将数据组织在多个层级中,数据随着时间推移从高层级向低层级移动并合并。

以下是数据存储的层级:

  • C0 (内存):最新写入的键值对,存储在内存(MemStore)中。
  • C1 (磁盘):MemStore写满后,数据被顺序刷新到磁盘,形成HFile。
  • C2及更低层级 (磁盘):当某一层级的HFile数量达到阈值,它们会被合并(Compaction)到下一层,形成更大、更少的HFile。

这种设计使得系统花费大量时间在传输数据(刷新和合并)上,而非等待,从而更适合传输密集型的大数据场景。

刷新与合并的生命周期动画

为了清晰展示这个过程,我们通过一个简化动画来观察键值对的生命周期。假设触发刷新的条件是MemStore满,触发合并的条件是某一层有2个HFile。

  1. 初始状态:一个新的表被创建,7个键值对写入MemStore。
  2. 第一次刷新:MemStore满,数据被顺序写入磁盘,形成第一个HFile(C1层)。对应的预写日志(WAL)被清除。
  3. 继续写入与第二次刷新:新数据持续写入MemStore。MemStore再次满,刷新形成第二个HFile(C1层)。
  4. 第一次合并:C1层现在有2个HFile,触发合并。这两个文件被合并成一个更大的HFile,并移动到C2层。
  5. 循环往复:此过程持续进行。MemStore满则刷新到C1层;C1层文件数超限则合并到C2层;C2层文件数超限则继续向更低层级合并。

这个合并过程在数学上类似于二进制计数。每个层级的HFile数量就像二进制的一位(0或1),合并操作就像进位。这种设计保证了合并操作是对数级别的,效率很高,这也是“日志结构”(Log-Structured)中“日志”一词的由来,代表对数(Logarithmic)复杂度。

关键优化技术

除了核心的LSM-Tree结构,HBase还采用了其他优化来提升性能。

缓存(Cache)

在内存中,除了存储新鲜数据的MemStore,还可以设置缓存。

  • 缓存存储的是从磁盘HFile中读取的键值对的副本,目的是避免频繁访问磁盘。
  • 缓存对于重复读取相同数据的场景非常有效。
  • 缓存对于批量全表扫描完全随机的数据访问模式则效用甚微。

布隆过滤器(Bloom Filter)

随着系统运行,HFile数量会增多。读取一个键时可能需要查找大量HFile。布隆过滤器是一种概率数据结构,用于快速判断一个键是否肯定不存在于某个HFile中。

  • 如果布隆过滤器说“键肯定不在”,那么可以安全地跳过该HFile,节省I/O。
  • 如果布隆过滤器说“键可能在”,那么仍需查找该HFile(可能会发现没有,即误判)。
  • 这是一种用少量内存换取大量磁盘I/O节省的经典优化。

元数据表(Meta Table)

一个HBase表可能被划分为多个区域(Region)分布在多台服务器上。客户端如何知道数据在哪里?

  • HBase有一个特殊的元数据表,它记录了所有Region的信息,例如每个Region负责的RowKey范围及其所在的服务器。
  • 客户端首先访问存放元数据表的服务器,查询目标键所在的Region和服务器位置,然后直接连接到该Region服务器进行读写。

数据本地化(Data Locality)

这是HBase与HDFS协同工作时一个巧妙且重要的优化。

  • 进程部署:在集群中,每台机器通常同时运行RegionServer进程(HBase服务)和DataNode进程(HDFS服务)。
  • 写入本地性:当RegionServer向HDFS写入数据(如刷新生成HFile或写WAL)时,作为HDFS客户端,其写入的第一个数据块副本默认会存放在本地机器的DataNode上。
  • 读取本地性:当RegionServer需要读取HFile时,如果所需数据块的第一个副本就在本地磁盘,它可以直接从本地读取,绕过网络传输,这称为短路读取
  • 通过合并维持本地性:随着时间推移,HDFS可能会为了负载均衡移动数据块,破坏本地性。但HBase的合并操作会创建新的HFile,而新文件的第一副本又会被写入本地机器,从而周期性地重建数据本地性,保持高效读取。

后续演进:Spanner

HBase的设计思想在其后继者中得到了延续和扩展。例如,Google开发的Spanner可以看作是HBase的“升级版”。

  • 规模更大:支持万亿甚至千万亿行数据,跨多个数据中心部署。
  • 数据模型扩展:主键可以包含多列,更像关系型数据库;时间戳可以作为显式列存储。
  • 管理单元:引入了“Universe Master”来管理跨数据中心的巨型集群。

本节课中我们一起学习了HBase核心存储引擎LSM-Tree的详细工作机制。我们理解了其通过顺序写入、多层存储和后台合并来优化写入性能的设计哲学,并探讨了缓存、布隆过滤器、元数据表和数据本地化等关键优化技术。最后,我们了解了该架构思想在Spanner等新一代系统中的演进。掌握这些原理对于理解宽列存储乃至许多现代大数据系统的设计至关重要。

015:数据模型 (1/4) 📊

在本节课中,我们将回顾大数据技术栈的核心思想,并总结迄今为止学到的十大关键概念。这些概念构成了大数据领域的基础,理解它们对于后续学习数据模型至关重要。


回顾大数据技术栈

上一节我们介绍了存储和编码语法。现在,让我们看看在当前大数据技术栈中所处的位置。

我们已经初步探讨了数据模型,接下来将深入展开,验证环节也将变得更为重要。


大数据十大核心思想 💡

以下是塑造整个大数据领域格局的十大通用核心理念。

1. 向最佳实践学习

当我们重建整个大数据技术栈时,心中始终铭记着20世纪70年代的关系型数据库管理系统。这意味着我们并非抛弃一切,而是保留了数据独立性、使用查询语言以及将逻辑层与物理层分离等核心思想。在从事研究或工程时,复查自己是否在重复造轮子至关重要。借鉴前人的经验,尤其是他们犯过的错误,可以节省难以想象的资金和资源,这是非常宝贵的经验。

2. 保持设计简洁

简洁的设计是实现大规模扩展的关键。例如,像S3这样的云存储极其简单,它基于键值对:(bucket, key) -> value,其中值可以是高达5TB的比特序列。HDFS的设计也很简洁:一个包含目录和文件的层次结构,以及对应的数据块序列。关系型数据库同样如此,只有包含行和列的表。因此,简洁性是我们的朋友。

3. 架构模块化

模块化架构对计算机科学家至关重要。否则,尝试进行整体式设计会让人不堪重负。这就像建造房屋:有人负责地基,有人负责砌砖,有人负责管道,有人负责电气等,所有环节都是模块化的。在大数据栈中,我们有专门存储文件和块的系统(如HDFS),有专门处理写入的系统等。每一层都基于下层的抽象进行设计,并为上层提供服务,这种模块化简化了我们的工作。

4. 宏观同质,微观异质

从宏观视角看,一切是同质的。例如,一个大表中可以有数十亿行,S3中可以有数十亿个对象,系统中可以有数万亿个数据块。在架构层面,存在大量同质的对象。然而,一旦深入微观视角,这些组件实际上是异质且多样的。这正是大数据的特点:我们可能不再总是拥有固定的模式。与关系表中所有行必须遵循相同模式不同,在文档存储中,同一集合中可以存在结构完全不同的文档,这完全没有问题。甚至在键值存储中也是如此,你可以在单元格中存储任何内容。

5. 逻辑模型与物理实现分离

从学期开始,每次介绍一个系统时,我都首先展示其简单明了的逻辑模型,然后才揭示其物理实现。要使用一个系统,你无需了解其物理实现,尽管了解它很有趣。逻辑视图就足够了,这在数据库系统世界中是标准做法。

6. 数据分片

得益于在超大规模下拥有非常同质的数据集合(数十亿、数万亿的数据块或对象),我们可以对数据进行分片。分片、分区、拆分、数据块——无论叫什么,本质都是对数据进行划分。

7. 数据复制

分片总是与复制携手并进。你的每一个分片、数据块、分区或对象都会被复制并存储多次(例如三次、四次、五次)。我们同时进行分片和复制。

8. 在众多机器上分布

复制之后,我们将所有数据分布到数据中心的众多机器上。这实际上是大量廉价硬件的集合,使用许多小型机器比使用少数大型机器成本更低。

9. 批量处理

这是本周新引入的概念。我们如何构建一个系统,使其看似能实时处理大量更新,同时又不会因写入速度慢而受影响?答案在于使用LSM树等技术进行批量写入。这意味着我们不是将每一个键值对都立即写入HDFS,而是先将它们累积在内存中,只有当内存写满时,才一次性批量写入。这就是批量处理的思想。


课程总结与展望

在过去的几周里,我们学习了上述内容。今天,更准确地说,是在期中假期后的两周,我们将继续学习数据模型。我们将基于XML、JSON等构建知识,并将大家带入数据框的精彩世界。是的,就是类似于Pandas的数据框。我们将会看到,数据框本质上是JSON对象的集合,但这里我先卖个关子。

非常感谢大家。请注意下周是期中假期,这里不会有人,我也在休假,你们也应该休息一下。我们两周后再见,继续后续的课程。祝大家练习愉快,再见。

016:数据模型 (2/4) 🗄️

在本节课中,我们将继续学习数据模型。我们将深入探讨JSON和XML文档在内存中的树形结构表示,理解数据模型与语法的分离,并初步了解数据验证和类型系统的概念。

从语法到逻辑模型

上一节我们介绍了数据模型的基本概念。本节中,我们来看看JSON和XML文档是如何从文本语法转换到内存中的逻辑模型的。

当我们处理一个CSV、JSON或XML文件时,首先看到的是文本。但计算机在内存中并不直接存储这些文本,而是将其转换为一种更高效、便于查询的结构。

  • 解析: 将文本格式(如CSV、JSON、XML)转换为内存中的逻辑表示(如表、树)的过程。
  • 序列化: 将内存中的逻辑表示转换回文本格式的过程。

对于CSV,逻辑模型是一个由行和列组成的表格。对于JSON和XML,逻辑模型是一棵树。

JSON与XML的树形结构

JSON和XML的文档结构在内存中都可以表示为树。理解这一点是高效查询和处理数据的关键。

JSON数据模型

JSON有六种基本构建块:字符串、数字、布尔值、null(原子值),以及对象和数组(结构值)。对象是字符串到值的映射,数组是值的列表。这种嵌套结构自然形成了一棵树。

示例: 对于JSON文档 {"foo": true, "bar": [{"baz": 1}, null]},其树形表示如下:

        Object (root)
        /          \
    key:"foo"    key:"bar"
       /              \
    value:true      Array
                   /      \
              Object      value:null
                 |
            key:"baz"
                 |
            value:1

树的根节点是一个对象。原子值(如 true, 1, null)是树的叶子节点。对象和数组是中间节点。对象边的标签是键名,数组边则没有标签。

XML数据模型

XML文档也表示为树。W3C的XML信息集规范定义了如何从XML文档构建树。主要节点类型包括文档、元素、属性和文本。

示例: 对于以下XML文档:

<?xml version="1.0"?>
<metadata>
  <title lang="en" year="2022">Systems Group</title>
  <publisher>ETH Zurich</publisher>
</metadata>

其树形结构如下:

            Document
                |
            Element: metadata
            /               \
    Element: title      Element: publisher
      /      |     \            |
Attribute: Attribute: Text:   Text:
  lang      year  Systems    ETH Zurich
   |         |     Group
 value:en  value:2022

元素名在节点上,属性名和文本内容是节点的属性或子节点。

JSON与XML树的关键区别: 在JSON中,对象键的名称位于指向子节点的边上,子节点本身不知道自己的“名字”。在XML中,元素的名字是节点自身的属性。

数据验证与模式

解析得到树形结构后,下一步通常是验证。验证是检查文档是否符合特定结构要求的过程,它与检查文档格式是否正确的“良构性”是两个独立的步骤。

  1. 良构性检查: 文档是否符合JSON/XML的基本语法规则?是/否。如果否,则无法解析。
  2. 有效性验证: 解析后的树形结构是否符合某个模式(Schema)的额外约束?是/否。

在关系型数据库中,模式是强制的,系统会阻止插入不符合模式的数据。而在JSON/XML生态中,验证是可选的。这带来了灵活性:

  • 无模式(异构集合): 文档结构可以各不相同,适用于数据尚未清洗或格式多样的场景(如某些机器学习数据集)。
  • 有模式(同构集合): 所有文档遵循相同的结构,便于进行规范化处理、高效查询和转换为数据框(如Pandas DataFrame)。

验证的一个重要功能是类型注解。例如,XML中的文本“2024”在解析后只是字符串。但如果模式规定该处应为整数,验证后,在内存中它会被转换为真正的整数类型(如32位二进制数),从而节省空间并提升处理效率。

数据类型系统概述

模式中会使用类型系统来定义约束。不同的技术(如XML Schema、JSON Schema、Python、Java)有各自的类型系统,但核心思想相通。类型通常分为原子类型和结构类型。

原子类型

以下是几种常见的原子类型:

  • 字符串: 字符序列。例如 "Hello"
  • 整数: 整数值。在计算机中,整数以二进制位存储,有不同的范围:
    • byte (8位): 0 到 255。
    • short (16位): 约 -32,768 到 32,767。
    • int (32位): 约 -21亿 到 21亿。
    • long (64位): 约 -9.22×10¹⁸ 到 9.22×10¹⁸。
    • 代码示例int viewCount = 1000000; (Java)
  • 小数: 包含小数点的数字。涉及两个概念:
    • 精度: 数字的总位数。
    • 标度: 小数点后的位数。
    • 公式DECIMAL(precision, scale),例如 DECIMAL(5,2) 可以存储 123.45
  • 浮点数: 用于近似表示实数,采用科学计数法原理,在固定位数(32位单精度或64位双精度)内存储一个很大范围的数。这是计算机硬件广泛支持的高效格式。
    • 公式值 = 尾数 × 2^指数

本节课中我们一起学习了JSON和XML文档在内存中的树形逻辑模型,理解了数据验证(有效性检查)与语法检查(良构性)的区别,并初步认识了数据类型系统的基本分类,特别是整数和小数的不同存储方式与约束。下一节我们将继续深入探讨结构类型和数据验证的具体实现。

017:数据模型(第三部分)

在本节课中,我们将继续学习数据模型,深入探讨数据类型、模式验证以及不同系统间类型系统的比较。

数据类型概览

上一节我们介绍了数值和字符串等基本类型,本节中我们来看看其他几种重要的原子类型。

布尔类型

布尔类型只有两个值:truefalse。根据具体的方言或语言,其字面量可能有不同的表示形式,例如 yes/noon/off1/0 等。但从技术上讲,它始终是关于真与假的布尔代数。

公式Boolean ∈ {true, false}

日期与时间类型

日期和时间看似简单,实则非常复杂,其支持工作常常被低估。在企业界,只要开展业务,日期和时间就极其重要。

  • 日期:通常使用公历,包含年(可为正或负)、月、日。世界上还有许多其他历法(如太阳历、农历),有些系统甚至支持多种历法。
  • 时间:采用六十进制系统,包含时、分、秒,然后使用点号表示毫秒、微秒等。虽然有过改为十进制时间的尝试,但并未流行。
  • 时区:时区使问题变得复杂,你需要指定时区信息(如 UTC+2、CEST)。
  • 时间戳:如果你只关心时间点而不在乎时区,最简单的方法是使用时间戳。其惯例是存储自 1970 年 1 月 1 日以来的毫秒数,这在内存中表示非常紧凑。

时间间隔类型

时间间隔也可能变得棘手。例如,“两年四个月”或“三小时十四分钟”。问题在于混合月份和天数时会遇到困难,例如“三个月零一天”具体是多少天并不明确,因为月份有 28、29、30 或 31 天。一些系统允许这样做,但为避免歧义,应谨慎跨越月份和天数之间的“墙”。

二进制数据类型

二进制数据是指尝试将图像、视频等作为值存储在数据库中(甚至可以是关系数据库、XML 或 JSON 中)。有比纯 0 和 1 更高效的存储方式:

  • Base 16(十六进制):使用数字 0-9 和字母 A-F,每个字符代表 4 位。
  • Base 64:使用大小写字母、数字和两个额外字符,共 64 个字符,能更紧凑地表示二进制数据。

空值类型

空值(null)的约定因系统而异。有些系统认为 null 等同于值缺失,有些则认为它是一个特殊值,还有些系统甚至将 null 视为只包含一个值(即 null)的类型。在 Python、Java、JSON 等中都能见到它。

词法空间与值空间

所有这些类型的共同点是,它们都有 值空间词法空间 的概念。

  • 词法空间:是值的文本表示,例如你在 XML、JSON、命令行或 SQL 中看到或键入的内容。例如,数字 4 可以表示为 "4""+4e0"(科学计数法)或 "100B"(二进制)。
  • 值空间:是值的数学抽象或内部表示。一旦你解析并验证了 JSON 或 XML 文档,你就离开了词法空间,进入了值空间。在查询数据时,你应该在值空间中思考,原始文档使用何种词法表示已不重要。

示例

  • 日期 "2017-08-01"(词法空间)在值空间中表示 2017 年 8 月 1 日。
  • 建议遵循 ISO 8601 标准来表示日期和时间(如 "2017-08-01T14:30:00Z"),以避免混乱。
  • 持续时间 "P1Y2MT3H"(词法空间)在值空间中表示 1 年 2 个月 3 小时。

子类型

许多系统有子类型的概念。例如,整数小数 的子类型,因为每个整数都是小数,但并非每个小数都是整数。子类型通常是值空间的限制,而超类型具有更大的值空间。

原子类型总结

这是一个原子类型的菜单。并非所有系统都拥有所有类型,有些多,有些少,且对应关系并非总是完美。但这是你将遇到的基本概念。

结构类型

在许多系统中,结构类型主要有两种:映射列表

  • 映射:在 JSON 中称为对象,也可视为 XML 元素中属性名到属性值的映射。数据框中的结构体也是映射。
  • 列表:在 JSON 中称为数组。XML 元素(包含嵌套元素和文本)也可视为有序列表。数据框中的数组也是列表。

映射和列表是理论计算机科学术语,在实际上下文中可能会互换使用对象、结构体、数组等名称,但本质相同。

基数性

许多系统(并非全部)有 基数性 的概念。在 Python 或 Java 等语言中,函数每次只返回单个值。但在像 SQL 这样的系统中,可以返回序列(如整个表)。

以下是典型的基数性表示:

  • 恰好一个:通常无符号表示。
  • 零个或多个:任意长度,常用星号 * 表示。
  • 零个或一个:可选,常用问号 ? 表示。
  • 一个或多个:至少一个,常用加号 + 表示。

有些系统使用形容词,如 required(恰好一个)、repeated(零个或多个)、optional(零个或一个)。这些符号与正则表达式中的符号相同。

类型系统比较

以下是一个不同类型系统(如 JSONiq、JSON Schema、XML Schema、Spark SQL、PostgreSQL、Python 等)的对比尝试。通过对比可以发现:

  1. 存在对应关系:例如,double 在 JSONiq 中,对应 JSON Schema 的 number,Spark SQL 的 DoubleType,SQL 的 DOUBLE PRECISION,Python 的 float
  2. 并非完美对应:有些类型在某些系统中不存在(如 JSON Schema 中没有 float)。
  3. 并非总是一对一:例如,shortlong 在 JSON Schema、PostgreSQL 或 Python 中没有区别,Python 只有 int

应将它们视为方言。掌握这种通用理解后,学习任何新技术都会很快,你只需查阅文档,了解类型的对应关系即可。

模式验证简介

接下来,我们看看如何使用模式语言来验证数据。验证一个文档首先意味着它是格式良好的。

验证需要两个东西:

  1. 实例文档:你想要验证的数据文档。
  2. 模式:定义约束条件的规则。模式本身通常也是一个 JSON 或 XML 文档,这很直观。

使用 JSound 进行验证

JSound 是一种为教学设计的模式验证语言,易于入门。

基本概念

  • 模式定义了文档必须满足的约束。
  • 如果实例文档满足所有约束,则它是 有效 的。

示例 1:简单验证

// 实例文档
{"last": "Einstein"}

// 模式
{"last": "string"}

模式检查:1) 文档是一个对象;2) 如果存在键 "last",其关联值必须是字符串;3) 默认不允许额外键。此实例有效。

示例 2:多个必需键

// 模式(紧凑语法)
{"last": "string!", "first": "string!"}

! 表示该键是必需的。实例必须同时包含 "last""first" 键,且值均为字符串。

示例 3:类型验证
模式可以指定值的类型,如 "integer""boolean""null"。在 JSound 上下文中,验证时只关心词法表示,因此带引号的 "142" 也能通过整数验证。

示例 4:数组验证

  • "item" 类型可以接受任何值。
  • ["item"] 表示一个可以包含任何类型元素的数组。
  • ["integer"] 表示一个整数数组。
  • [{"foo?": "boolean"}] 表示一个对象数组,每个对象最多有一个键 "foo",且其值必须是布尔值。

额外字段控制

  • 默认情况下,禁止额外字段
  • 在 JSound 的详细语法中,可以使用 "closed": false 来允许模式中未定义的额外键。

模式语言的功能总结

  1. 限制类型:约束允许的值和词法表示。
  2. 类型转换:将词法表示(如带引号的数字)转换为内部类型值。
  3. 要求/可选键:指定键是否必须存在。
  4. 控制额外字段:决定是否允许未在模式中定义的键。
  5. 默认值:如果实例缺少某个键,自动添加默认值。
  6. 主键:在嵌套表(数组对象)中定义主键,防止重复。

实际示例
“伟大语言游戏”数据集的模式定义了每个游戏记录的结构:"country""guess""target" 等字段的类型和必要性。

数据框友好模式
设计模式时,应尽可能具体(避免使用宽泛的 "item"),这样更容易转换为高效的数据框结构。例如,模式 {"a": "integer!", "b": "boolean!", "c": [{"foo?": "string"}]} 定义的结构就非常清晰,利于优化。

JSON Schema 简介

JSON Schema 是一种非常流行的模式语言。它的语法比 JSound 的紧凑语法更详细,但功能类似。

主要区别

  • 语法:更接近 JSound 的详细语法。例如,{"type": "object", "properties": {"last": {"type": "string"}}}
  • 必需字段:使用 "required" 列表来指定,而不是 !
  • 额外字段:默认 允许 额外字段。可以通过 "additionalProperties": false 来禁止。
  • 类型集合:可用的内置类型比 XML Schema 或 JSound 少。
  • 模式匹配:支持使用 "pattern" 等关键字进行更复杂的约束(如数值范围)。

尽管语法和默认约定不同,但 JSON Schema 具备相同的核心功能。理解了 JSound 后,学习 JSON Schema 会很快。

总结

本节课我们一起学习了数据模型中的关键概念:

  1. 多种原子类型:包括布尔型、日期时间型、间隔型、二进制型和空值型,理解了它们的复杂性和重要性。
  2. 词法空间与值空间:区分了数据的文本表示和内部抽象,这是理解数据验证和查询的基础。
  3. 类型系统比较:认识到不同技术(如 JSONiq、JSON Schema、SQL)有相似但不完全相同的类型系统,建立了通用的类型理解框架。
  4. 结构类型与基数性:了解了映射和列表这两种基本结构,以及如何表示数据的数量约束(恰好一个、零个或多个等)。
  5. 模式验证:通过 JSound 和 JSON Schema 的实例,掌握了模式如何定义数据结构、约束类型、控制字段必要性及额外字段,并理解了设计“数据框友好”模式的重要性。

掌握这些通用概念后,你将能够快速学习和适应任何新的数据技术或模式语言。

018:数据模型与验证 📚

在本节课中,我们将要学习数据模型的核心概念,特别是如何通过模式(Schema)来验证XML和JSON文档的结构。我们将理解“格式良好”与“有效”的区别,探索XML Schema的基础知识,并最终建立起数据框架(Data Frame)与已验证JSON文档集合之间的重要联系。


格式良好 vs. 有效

上一节我们介绍了XML的基本结构,本节中我们来看看如何判断一个XML文档的正确性。这里有两个关键概念:格式良好有效

  • 格式良好:指文档的语法是否正确。例如,所有标签是否闭合、属性值是否用引号括起来、是否只有一个根元素等。这是一个二元判断,无需外部信息即可确定。
  • 有效:指文档的内容和结构是否符合某个预定义的模式(Schema)的规则。这需要依赖外部模式文件来判断。

一个文档可以是格式良好但无效的(符合语法但不符合业务规则),但一个有效的文档首先必须是格式良好的。

核心公式
有效(文档) = 格式良好(文档) AND 符合模式(文档, 模式)


XML Schema 基础

为了验证XML文档的有效性,我们需要使用XML Schema语言。XML Schema本身也是一个XML文档,它使用特定的命名空间(通常是 xs:xsd:)来定义规则。

声明简单元素

一个简单元素只包含文本内容,没有子元素或属性。以下是声明一个名为 who 的字符串类型元素的模式:

<xs:element name="who" type="xs:string"/>

对应的有效XML实例如下:

<who>Albert Einstein</who>

声明复杂元素与序列

复杂元素可以包含子元素和/或属性。使用 <xs:complexType><xs:sequence> 可以定义子元素必须出现的顺序。

以下模式定义了一个 person 元素,它必须按顺序包含 lastfirst 两个子元素:

<xs:element name="person">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="last" type="xs:string"/>
      <xs:element name="first" type="xs:string"/>
    </xs:sequence>
  </xs:complexType>
</xs:element>

对应的有效XML实例如下:

<person>
  <last>Einstein</last>
  <first>Albert</first>
</person>

控制元素出现次数

使用 minOccursmaxOccurs 属性可以控制子元素出现的次数。默认值均为1。

以下模式允许 item 元素出现2到4次:

<xs:element name="item" type="xs:string" minOccurs="2" maxOccurs="4"/>

maxOccurs=”unbounded” 表示出现次数无上限。

处理混合内容

混合内容允许元素内部同时包含文本和子元素,这在出版业(如标记文档中的段落和格式)中很常见。通过设置 mixed=”true” 来实现。

以下模式允许 p 元素内混合文本和 bi 子元素:

<xs:element name="p">
  <xs:complexType mixed="true">
    <xs:sequence>
      <xs:element name="b" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
      <xs:element name="i" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
    </xs:sequence>
  </xs:complexType>
</xs:element>

对应的有效XML实例如下:

<p>这是一段 <b>加粗</b> 和 <i>斜体</i> 混合的文本。</p>

声明与验证属性

属性在 <xs:complexType> 内部,紧接在子元素序列之后使用 <xs:attribute> 声明。

以下模式为 person 元素添加了一个 birth 属性,其类型为日期:

<xs:element name="person">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="last" type="xs:string"/>
      <xs:element name="first" type="xs:string"/>
    </xs:sequence>
    <xs:attribute name="birth" type="xs:date"/>
  </xs:complexType>
</xs:element>

对应的有效XML实例如下:

<person birth="1879-03-14">
  <last>Einstein</last>
  <first>Albert</first>
</person>

从已验证文档到数据框架

理解了如何用模式验证单个文档后,我们来看一个更强大的概念。一个数据框架本质上可以看作是一个符合相同模式的JSON文档集合的可视化表格形式。

数据框架是有效的文档集合

假设我们有一个简单的JSON Schema,它定义了包含 id(整数)、name(字符串)和 living(布尔值)的对象。

一个符合该模式的数据框架可视化如下:

id name living
1 Einstein false
2 Meitner false
3 Turing false
4 Lovelace false

这个表格中的每一行,都对应一个有效的JSON对象:

{"id": 1, "name": "Einstein", "living": false}

数据框架支持嵌套结构

数据框架比传统的关系型数据库表更强大,因为它可以优雅地表示嵌套结构,这正是JSON等格式所擅长的。

  1. 嵌套数组:如果 name 在模式中被定义为字符串数组,数据框架可以这样表示:
    id name living
    1 [“Albert”, “Einstein”] false
    2 [“Lise”] false

  1. 嵌套对象:如果 name 是一个包含 firstlast 键的对象,数据框架会通过分层表头来展示:
    id name.first name.last living
    1 Albert Einstein false

  1. 对象数组(嵌套表):如果 who 是一个对象数组,每个对象有 nametype 字段,数据框架会将其显示为嵌套表:
    id who.name who.type living
    1 Albert first false
    1 Einstein last false
    2 Lise first false

关键约束:为了能稳定地映射到数据框架,背后的JSON Schema通常需要满足一些条件,例如不允许过于泛化的类型(如 item),并且通常要求是“封闭对象”(不允许未在模式中定义的额外键)。


数据格式的分类

最后,让我们从更高视角对不同的数据存储格式进行分类。任何数据集,无论是XML、JSON还是其他格式,都可以抽象为 “映射的列表”(List of Maps),即一系列记录(行),每条记录是一个键值对集合(列)。

我们可以从三个维度对数据格式进行分类:

以下是主要的分类维度:

  1. 结构:扁平 vs. 嵌套
    • 扁平格式:如CSV,每个“单元格”都是原子值(字符串、数字等),不支持嵌套结构。
    • 嵌套格式:如JSON、XML、YAML、Parquet、Protocol Buffers,允许值本身是数组或对象,形成树状结构。

  1. 编码:文本 vs. 二进制

    • 文本格式:如JSON、XML、CSV、YAML。人类可读,可以用文本编辑器打开,但通常体积较大。
    • 二进制格式:如Parquet、Avro、Protocol Buffers。机器优化,体积小,读写速度快,节省存储空间和I/O带宽,但不可直接阅读。
  2. 模式:是否需要模式

    • 模式可选:如JSON、XML、YAML。没有模式也能使用,灵活性高,常用于数据交换。
    • 模式必需:如Parquet、Avro、Protocol Buffers。必须预定义模式才能读写,这种约束带来了高效压缩、强类型检查和更好的性能,常用于数据存储和处理。

重要实践:从网络获取的文本格式(如JSON)数据集,如果需要在系统中反复查询分析,将其转换为二进制格式(如Parquet)通常是提高性能的最佳实践。


总结

本节课中我们一起学习了数据模型验证的核心思想。我们明确了文档“格式良好”与“有效”的根本区别,并深入了解了如何使用XML Schema来定义和验证XML文档的结构。更重要的是,我们建立了数据框架与已验证JSON文档集合之间的深刻联系,认识到数据框架实质上是具有一致结构的半结构化数据的表格化视图。最后,我们从结构、编码和模式需求三个维度对常见数据格式进行了分类,理解了不同格式的适用场景,特别是二进制格式对于大数据处理的重要性。这些概念为我们后续学习数据框架操作和MapReduce等分布式处理框架奠定了坚实的基础。

019:MapReduce (1/3) 🗺️➡️🧹

在本节课中,我们将要学习MapReduce,这是一种用于大规模数据处理的编程模型。我们将从一个生动的例子开始,理解其核心思想,然后深入探讨其工作原理、数据格式要求以及它在分布式系统中的运行方式。

概述

在之前的课程中,我们学习了数据建模、数据框架和模式验证。其核心价值在于,当你从互联网下载文本格式的数据集时,如果能创建一个模式来验证数据,就可以将整个数据集转换为数据框架。一旦拥有数据框架,就可以将其保存为二进制格式。这意味着你可能下载了1TB的JSON数据,但最终可以将其压缩到10GB的Parquet格式。这种压缩对于数据科学家来说非常实用,因为它能极大地提升性能——将数据处理时间从一小时缩短到六分钟。

现在,我们将开始查询数据。我们将使用MapReduce这种并行处理模型来大规模地查询数据。

从实例理解MapReduce

为了生动地解释MapReduce,我们使用一个“宝可梦计数”的例子。

假设我收集了很多宝可梦,我想知道每种宝可梦(比如皮卡丘)各有多少只。手动计数可能需要两小时。

我们可以用并行化的方式更快地完成:

  1. 第一轮(Map阶段):我将几袋宝可梦分给一些志愿者。每位志愿者负责一袋,他们需要数出自己袋中每种宝可梦的数量,并将结果写在纸上。例如,纸上会记录 皮卡丘: 2 这样的键值对。
  2. 中间阶段(Shuffle阶段):志愿者们将纸上的每个键值对剪下来。然后,所有对应同一种宝可梦(例如所有“皮卡丘”键)的纸片会被收集起来,交给第二轮中专门负责该宝可梦的志愿者(例如,爱丽丝负责皮卡丘)。这个过程可能有些混乱,因为第一轮的每位志愿者都需要与第二轮的每位志愿者交接数据。
  3. 第二轮(Reduce阶段):像爱丽丝这样的志愿者,会收到所有关于“皮卡丘”的纸片。她将上面的数字相加,得到皮卡丘的总数(例如8),并写下最终结果 皮卡丘: 8。所有第二轮志愿者并行完成各自负责宝可梦的求和。

这个过程将原本需要两小时的工作缩短到了二十分钟。效率的提升来自于并行化:第一轮的计数和第二轮的求和都是并行进行的。中间混乱的交接过程称为Shuffle

这个过程的正式名称就是 MapReduce

  • Map(映射):将原始数据(一袋宝可梦)映射为中间键值对(宝可梦: 数量)。
  • Reduce(归约):将具有相同键的所有中间键值对归约为一个最终的键值对(宝可梦: 总数量)。
  • 更准确地说,应该叫 Map-Shuffle-Reduce,以体现中间阶段。

MapReduce的数据基础

在计算机中实现MapReduce,数据通常存储在分布式文件系统(如HDFS)或数据库(如HBase)中。以下是可能存储的数据格式示例:

  • 纯文本:例如,数十亿行莎士比亚著作。
  • CSV文件:结构化的表格数据,例如包含年份、日期、持续时间和计数的观测数据。
  • 固定宽度文本:气象学中常见的格式,通过固定字符位置而非逗号来分隔字段,以节省空间。
  • 键值对文本:简单的格式,例如用空格分隔键和值:key value
  • JSON Lines:每行都是一个独立、格式良好的JSON对象。
  • 二进制数据:如图片、视频,或经过模式验证后存储的高效格式(如Parquet文件)。

在实际存储时,我们很少将海量数据存为单个巨大文件,而是将其分片存储。例如,将包含十亿行JSON的数据集,分成一千个文件,每个文件包含一百万行。这些文件通常被命名为 part-00001, part-00002 等。分片(也称为分区、块)是并行处理的基础。

MapReduce的并行化思想

理想情况下,如果查询可以完全独立地应用于每个数据分片,那么并行化将非常简单:每个机器处理一个分片,速度提升倍数等于机器数量。

然而,现实中的查询往往更复杂,数据分片之间可能存在依赖,形成“意大利面条”式的复杂关系,难以直接并行。

MapReduce的洞察在于,许多现实查询介于“完全独立”和“完全纠缠”之间。它们可以被组织成:一个可并行化的部分,接着一个需要全局协调(“意大利面条”)的部分,然后再接一个可并行化的部分。

MapReduce模型正好对应了这种结构:

  1. Map阶段(可并行):每个输入分片被独立处理。
  2. Shuffle阶段(全局协调):根据键重新组织中间数据。
  3. Reduce阶段(可并行):对分组后的数据进行归约。

通过将多个MapReduce作业串联,可以处理更复杂的查询。

MapReduce的正式模型

现在,我们抛开宝可梦的例子,从抽象层面形式化地理解MapReduce。

MapReduce要求所有数据都以键值对的形式处理。

  • 输入数据是键值对。
  • 中间数据是键值对。
  • 输出数据也是键值对。

以下是MapReduce的逻辑步骤:

  1. 输入与分片:输入是海量键值对。首先将其分割成多个分片,以便并行处理。在HDFS上,分片可以直接对应数据块。

  1. Map阶段:对每个输入键值对,应用一个Map函数。该函数接收一个输入键值对,并输出0个、1个或多个中间键值对。这个阶段在所有分片上并行执行。

    map(k1, v1) -> list(k2, v2)
    
  2. Shuffle与排序:收集所有Map任务产生的中间键值对。在逻辑上,系统会按键进行排序,然后将所有具有相同键的键值对分组在一起。

  3. Reduce阶段:对每个分组(即所有具有相同键的中间键值对),应用一个Reduce函数。该函数接收一个键及其对应的所有值列表,输出0个、1个或多个最终输出键值对。这个阶段也是并行执行的,每个分组由一个Reduce任务处理。

    reduce(k2, list(v2)) -> list(k3, v3)
    

在80%的情况下,Reduce函数只是对值进行聚合操作(如求和、求最大值),并且输出键值对的类型与中间键值对相同。

MapReduce的物理架构

MapReduce通常运行在由一台主节点和多台工作节点组成的集群上。

  • 主节点:运行 JobTracker 进程,负责协调整个MapReduce作业,调度任务。
  • 工作节点:运行 TaskTracker 进程,负责执行具体的Map或Reduce任务。同时,这些节点通常也是HDFS的DataNode,存储着数据块。

MapReduce遵循 “将计算移至数据” 的原则。为了最小化网络传输,系统会尽量将Map任务调度到存储着对应输入数据分片的那台工作节点上执行。这样,Map函数可以直接读取本地磁盘的数据,非常高效。

总结

本节课我们一起学习了MapReduce的核心概念。我们从宝可梦计数的生动例子入手,理解了Map(映射)、Shuffle(混洗)和Reduce(归约)三个阶段如何协作,将一个大问题分解为可并行处理的小任务。我们探讨了MapReduce所处理的各种数据格式,以及通过数据分片实现并行化的思想。最后,我们形式化地定义了Map和Reduce函数,并了解了MapReduce在分布式集群上的运行架构。MapReduce是一种强大而通用的模型,尽管如今更高级的查询引擎(如Apache Spark)为我们隐藏了这些细节,但理解其底层原理对于掌握大数据处理至关重要。

020:MapReduce 物理架构与核心概念 🧠

在本节课中,我们将要学习 MapReduce 的物理架构,了解其内部如何执行任务,并探讨如何将各种数据格式映射到键值对模型。我们还将学习一些关键的优化技术,如合并器(Combiner),并澄清 MapReduce 中函数、任务、槽位和阶段等核心概念的区别。

物理架构与执行流程

上一节我们介绍了 MapReduce 的逻辑模型(Map-Shuffle-Reduce)。本节中我们来看看它的物理架构是如何实现这一模型的。

MapReduce 采用集中式架构,类似于 HDFS 和 HBase。其核心思想是 将计算带到数据所在之处

  • 主节点:运行着 HDFS 的 NameNode 进程和 MapReduce 的 JobTracker 进程,负责协调整个作业。
  • 工作节点:每个节点运行着 HDFS 的 DataNode 进程(存储数据块)和 MapReduce 的 TaskTracker 进程(执行具体的 Map 和 Reduce 任务)。

数据以块(Block)的形式存储在 HDFS 中,并分布在整个集群的工作节点上。MapReduce 将每个数据块(或稍作调整的“分片”,Split)作为一个独立的处理单元。这样,Map 任务可以直接在存储该数据块的本地机器上启动,避免了大量不必要的数据网络传输,极大地提高了效率。

以下是 MapReduce 作业的详细执行步骤:

  1. Map 阶段:每个 Map 任务槽位(Slot,可理解为 CPU 核心)读取一个分片,并对其中的每条记录调用用户定义的 map 函数,生成中间键值对。这些键值对首先被写入内存缓冲区。
  2. 溢写与合并:当内存缓冲区满时,系统会将数据“溢写”(Spill)到该工作节点的本地磁盘(而非 HDFS)。在溢写前后,可以可选地使用 合并函数 对具有相同键的中间结果进行本地聚合(例如,将多个 (word, 1) 合并为 (word, 3)),这能减少写入磁盘和后续网络传输的数据量。多次溢写可能产生多个小文件,系统会像 LSM 树一样对它们进行合并(Compact)。
  3. Shuffle 阶段:这是网络密集型阶段。每个 Reduce 任务槽位需要获取所有 Map 任务产生的、其负责处理的键(例如,负责处理单词“hello”的 Reducer 会向所有 Mapper 请求标记为“hello”的键值对)。数据通过网络(通常基于 HTTP 协议)从各个 Map 节点拉取到相应的 Reduce 节点。此阶段确保所有相同键的键值对最终都汇聚到同一个 Reduce 节点上。
  4. Reduce 阶段:当某个键的所有值都到达 Reduce 节点后,Reduce 任务槽位会调用用户定义的 reduce 函数处理该组数据(例如,对所有的“1”进行求和)。处理是逐个键组顺序进行的。
  5. 输出:每个 Reduce 任务将其输出写入 HDFS 或其他存储系统的一个独立文件中。因此,MapReduce 作业的最终输出通常是一个包含多个文件(分片)的目录。

数据到键值对的映射

MapReduce 处理的一切都是键值对。但原始数据(如文本、数据库表)并非天然以键值对形式存在。那么,如何映射呢?

好消息是,MapReduce 框架或相关库提供了便捷的方式来完成这种转换。其核心思想是:任何数据集都可以被看作记录的集合,而每条记录都可以被映射为一个键值对

以下是几种常见数据格式的映射方式:

  • 关系型表 / HBase 表
    • :通常使用表的主键(关系型)或行键(HBase)。
    • :该行/记录的所有其他列数据。
    • 公式(row_key, (col1, col2, ...))
  • 文本文件(按行)
    • :该行在文件中的起始字节偏移量(Offset)。
    • :该行的文本内容。
    • 公式(line_offset, line_text)
  • 文本文件(键值分隔符)
    • 如果文本行本身包含特定分隔符(如制表符、逗号),则可以直接拆分。
    • :分隔符之前的部分。
    • :分隔符之后的部分。
    • 公式split(line, delimiter) -> (key_part, value_part)
  • 结构化文件(JSON, CSV, Parquet 等)
    • 首先通过相应解析器将文件内容转换为记录集合。
    • 然后为每条记录分配一个键(如行号、ID)和值(记录内容)。

使用 MapReduce 实现基本操作

理解了映射方式后,我们可以用 MapReduce 模型实现类似数据库的操作。

  • 筛选:在 map 函数中,检查每条输入记录是否满足条件。如果满足,则原样输出该键值对;否则,不输出。reduce 函数可以设置为恒等函数(直接输出输入),甚至可以直接省略 Reduce 阶段。
    • 类比 SQLSELECT * FROM table WHERE condition
  • 投影:在 map 函数中,解析每条记录(如 JSON 对象),只提取所需的字段,然后输出新的键值对。同样,reduce 阶段通常不需要。
    • 类比 SQLSELECT column1, column2 FROM table
  • 单词计数
    • Map:读取文本行,分割成单词,为每个单词输出 (word, 1)
    • Shuffle:将相同单词的 (word, 1) 对分组到一起。
    • Reduce:对每个单词对应的所有“1”进行求和,输出 (word, total_count)

优化:合并器

在单词计数的例子中,如果同一个 Map 任务处理的数据块中单词“hello”出现了 100 次,它会输出 100 个 (hello, 1)。这会在 Shuffle 阶段产生大量网络传输。

合并器 是一个可选的优化函数,它在 Map 端本地运行,作用类似于一个迷你的、本地的 Reduce。它会在 Map 任务将中间结果溢写到磁盘之前或合并小文件时,对相同键的值进行局部聚合。

  • 作用:减少需要写入本地磁盘和通过网络 Shuffle 的数据量。
  • 要求:为了不改变最终结果,合并函数必须是 可交换可结合 的。例如,加法、求最大值都满足这个条件,而求平均值则不满足(因为平均值的平均值不等于总平均值)。
  • 代码示例:在单词计数中,合并器可以将 (hello, 1), (hello, 1), (hello, 1) 合并为 (hello, 3)。通常,可以直接将 Reduce 函数用作合并器。

核心概念辨析

为了避免混淆,清晰区分以下概念至关重要:

  • 函数:是纯数学或编程逻辑。
    • map 函数(k1, v1) -> list((k2, v2))
    • reduce 函数(k2, list(v2)) -> list((k3, v3))
    • combiner 函数:一个优化用的、符合结合交换律的 reduce 函数。
  • 任务:是一个工作单元。
    • Map 任务:负责对一个输入分片中的所有记录顺序调用 map 函数。
    • Reduce 任务:负责对一组相同键的所有值顺序调用 reduce 函数。
    • 不存在“Combiner 任务”,它只是 Map 任务内部的一个优化步骤。
  • 槽位:是硬件资源,代表一个 CPU 核心及其内存。一个槽位顺序地执行一个又一个任务(Map 或 Reduce)。
  • 阶段:是并行执行的范畴。
    • Map 阶段:所有 Map 任务槽位并行地执行各自的 Map 任务。
    • Reduce 阶段:所有 Reduce 任务槽位并行地执行各自的 Reduce 任务。
    • Shuffle 阶段:数据在网络上重新分配。

类比:想象你和朋友要写 50 张明信片。

  • 函数:是“在明信片上写字”这个动作规则。
  • 任务:是“写完这 10 张明信片”这个具体工作包。
  • 槽位:是你们这几个“成年人”,每个人一次只能拿一个任务包顺序地写。
  • 阶段:是“所有成年人同时开始写各自手里的明信片”这个并行过程。
  • Combiner:类似于你在写一个任务包的明信片时,发现有三张都是寄给 Alice 的,于是你决定把它们合并成一个包裹寄出,而不是分三封信。

本节课中我们一起学习了 MapReduce 的物理执行架构,掌握了将不同数据源映射到键值对模型的方法,并使用该模型实现了筛选、投影和聚合等基本操作。我们还探讨了通过合并器来优化作业性能的原理,并厘清了函数、任务、槽位和阶段这些核心概念的区别,这对于深入理解和高效使用 MapReduce 至关重要。

021:MapReduce 核心概念与实现细节 🧠

在本节中,我们将深入探讨 MapReduce 框架中一些关键但容易混淆的概念,特别是任务、槽位、函数之间的关系,以及数据分片与 HDFS 块之间的微妙差异。理解这些细节对于掌握 MapReduce 的内部工作原理至关重要。

上一节我们介绍了 MapReduce 的基本流程,本节中我们来看看其内部执行模型和一些需要特别注意的细节。

核心概念澄清:函数、任务与槽位

首先,必须明确区分“函数”、“任务”和“槽位”这三个概念。网络上常见的“Mapper”、“Combiner”、“Reducer”等术语容易产生误导,因为它们可能指代不同的东西。当你看到这些术语时,应该问自己:它指的是一个函数,一个任务,一个槽位,还是一个处理阶段?保持这种清晰的区分,能帮助你更好地理解在线资料。

以下是这些核心概念的定义:

  • 函数:这是一个纯粹的数学或编程概念。在代码中,它就是一个 Java 或 Python 函数。例如,map 函数和 reduce 函数。
    • 公式/代码表示map(k1, v1) -> list(k2, v2)reduce(k2, list(v2)) -> list(v3)
  • 任务:这是对一个函数的一次顺序、重复的调用。例如,一个 Map 任务就是对一个数据分片调用一次 map 函数。Combiner 是一个特殊的函数,它也可能被任务调用。
  • 槽位:这是执行任务的物理或逻辑单元。一个槽位会顺序地、一个接一个地处理任务。当一个任务完成后,槽位会立即获取下一个任务。只有当所有任务都完成后,槽位才会进入等待状态。在单个槽位内部,所有处理都是顺序执行的

真正的并行性发生在槽位之间。每个槽位通常对应一个 CPU 核心或虚拟核心(实际上是以线程方式运行)。因此,MapReduce 的并行是通过多个槽位同时处理不同的任务来实现的,而在单个槽位内则是串行的。

作为用户,你无需关心底层的槽位和任务调度。你的责任仅仅是:

  1. 实现 mapreduce 函数。
  2. 决定数据的读取和写入位置。
  3. (可选)实现一个 combiner 函数。

其余所有工作,包括任务拆分、调度、容错等,都由 MapReduce 框架自动完成。

系统架构视图

下图展示了 MapReduce 作业在集群中的执行视图:

  • 节点:代表集群中的物理机器。
  • 槽位:每台机器上有多个槽位(因为现代机器通常有多个CPU核心)。图中用蓝色和红色方块表示。
  • 任务类型:槽位被静态地预先分配为 Map 槽位(蓝色)或 Reduce 槽位(红色)。一个 Map 任务只能在 Map 槽位中运行,Reduce 任务亦然。
  • 执行流程:每个槽位顺序处理分配给它的任务(Map 或 Reduce)。框架动态地将任务分配给空闲的槽位,先完成任务的槽位会优先获取新任务。

一个关键细节:分片与 HDFS 块

现在,我们来探讨一个容易混淆但非常重要的技术细节。之前提到,每个 HDFS 块(默认128MB)通常会生成一个 MapReduce 分片,从而对应一个 Map 任务。但这并非绝对精确。

让我们先回顾基本关系:

  • 每个 Map 任务 处理一个 分片
  • 每个 分片 通常对应一个 HDFS 块

问题在于:

  • HDFS 块 是基于字节划分的。HDFS 将文件严格地按128MB(或更小,对于末尾块)的字节边界进行切割,它只看到0和1,不关心数据的逻辑结构。
  • MapReduce 分片 则是基于键值对记录划分的。一个分片必须包含完整的键值对,分片的边界必须在某个键值对结束之后。

这就导致了“阻抗不匹配”。一个键值对很可能被HDFS的字节边界切割开,横跨两个物理块。例如,一个块中的最后一个键值对可能开始于当前块,但结束于下一个块。

那么 MapReduce 如何处理?

  1. 负责处理该分片的 Map 任务会从“主”块(即分片起始块)所在机器进行本地读取(遵循“数据本地性”原则)。
  2. 为了读取那个被跨块分割的、不完整的最后一个键值对,该任务可能需要从存储下一个块的另一台机器上通过网络读取缺失的几个字节(而不是整个128MB的块)。

好消息是:这一切对 MapReduce 用户是透明的。Hadoop 和 Google 的工程师已经处理了这种复杂性。你只需要知道分片和块之间存在这种细微差别即可。

本节课中我们一起学习了 MapReduce 执行模型的核心概念,明确了函数、任务和槽位的区别,并深入了解了数据分片与物理存储块之间微妙但重要的差异。理解这些底层细节,有助于你更有效地使用和调试 MapReduce 程序。

022:Spark (1/5) 🔥

在本节课中,我们将要学习大数据处理框架的演进,从MapReduce的局限性到其下一代解决方案——Apache Spark。我们将探讨资源管理框架YARN如何解决MapReduce v1的瓶颈,并详细介绍Spark的核心概念:弹性分布式数据集(RDD)及其处理模型。


从MapReduce到Spark的演进 🚀

上一节我们介绍了MapReduce的基本模型。本节中我们来看看它的局限性以及后续的改进方案。

最初的MapReduce架构(称为v1)存在几个核心问题。其作业跟踪器承担了过多职责,包括资源管理、任务调度和监控,这使其成为单点瓶颈。此外,集群中的映射槽归约槽数量固定,导致在映射阶段归约槽闲置,在归约阶段映射槽闲置,造成资源浪费。

一个MapReduce作业的流程可以概括为:

输入 -> 映射(Map) -> 洗牌(Shuffle) -> 归约(Reduce) -> 输出

这种模型对于复杂的数据处理流水线(如需要迭代的机器学习算法)显得笨拙,用户需要手动串联多个MapReduce作业。

资源管理:YARN (Yet Another Resource Negotiator) ⚙️

为了解决MapReduce v1的问题,工程师们设计了YARN。YARN采用了相同的中心化架构,但将职责进行了分离。

以下是YARN的核心组件:

  • 资源管理器:运行在协调节点上,负责整个集群的资源管理和调度。
  • 节点管理器:运行在每个工作节点上,负责管理本节点的资源并向资源管理器报告。
  • 容器:资源抽象单元,代表一定量的CPU和内存资源。一个物理节点可以运行多个容器。
  • 应用主节点:每个提交的作业(如MapReduce作业、Spark作业)会由一个专用的应用主节点管理。它负责向资源管理器申请容器来运行任务,并监控这些任务的执行。

这种架构的优势在于:

  1. 职责分离:资源管理器只负责资源调度,作业的生命周期监控由应用主节点负责。
  2. 可扩展性:解决了v1版本中作业跟踪器的瓶颈问题。
  3. 资源利用率高:容器是动态申请的,在作业的映射阶段不会预先占用归约资源。
  4. 多租户与通用性:YARN本身不关心运行的是MapReduce、Spark还是其他计算框架,它可以管理一个公司范围内被多种应用共享的集群。

YARN使用心跳机制来监控节点管理器的存活状态。关于容器调度策略,存在多种算法,例如考虑多资源(CPU、内存)公平分配的主导资源公平性算法。

Apache Spark 核心概念 💡

在解决了底层资源管理问题后,Apache Spark在数据处理模型上进行了重大革新。Spark可以被看作是MapReduce的通用化版本。

MapReduce可以看作是一个特定的有向无环图(DAG):映射 -> 洗牌 -> 归约。而Spark允许用户定义任意的有向无环图来进行数据转换,这极大地增强了表达能力,无需手动串联多个作业。

Spark的核心抽象是弹性分布式数据集

RDD = Resilient Distributed Dataset

一个RDD本质上是一个分布在集群多个节点上的大型数据集合。它具有两个关键特性:

  • 分布式:数据被分区存储在不同机器上。
  • 弹性:如果部分数据丢失,Spark可以根据其血缘关系图自动重新计算恢复。

与MapReduce的键值对不同,RDD可以包含任意类型的元素(字符串、整数、JSON对象等)。

RDD 的生命周期与操作 🔄

RDD遵循一个清晰的生命周期,主要包括三种操作:

1. 创建
RDD可以通过多种方式创建:

  • 从分布式存储系统(如HDFS、S3)读取。
  • 从本地集合(如Python列表)并行化创建。

2. 转换
转换操作以一个RDD为输入,生成一个新的RDD。Spark提供了丰富的转换操作“菜单”,远不止mapreduce。例如:filter, flatMap, groupByKey, join等。这些转换会构建出RDD之间的依赖关系图(DAG)。

3. 行动
行动操作触发实际的计算,并返回结果给驱动程序或写入存储系统。例如:

  • collect():将整个RDD数据拉取到驱动程序(仅适用于小数据集)。
  • count():返回RDD中的元素个数。
  • saveAsTextFile(path):将RDD写入HDFS等存储系统。

Spark采用惰性求值策略。当定义一系列转换时(如 rdd.map(...).filter(...)),Spark并不立即执行,而是记录下这个计算图(DAG)。只有当遇到一个行动操作(如 collect())时,Spark才会根据DAG优化并执行整个计算任务。


本节课中我们一起学习了大数据处理框架的演进历程。我们首先分析了MapReduce v1的架构瓶颈,然后介绍了通过YARN实现资源管理与作业监控的职责分离。最后,我们深入探讨了Apache Spark的核心思想,即通过弹性分布式数据集(RDD)和通用的有向无环图(DAG)模型,提供了比MapReduce更灵活、更强大的数据处理能力。下一节我们将继续探索Spark丰富的转换操作和编程实践。

023:Spark (2_5) - 核心概念与操作 🚀

在本节课中,我们将深入学习Apache Spark的核心概念,特别是弹性分布式数据集(RDD)及其操作。我们将从回顾MapReduce开始,逐步过渡到Spark更通用和强大的数据处理模型。

概述

上一节我们详细探讨了MapReduce的工作原理及其物理执行过程。本节中,我们将看到Spark如何在此基础上进行扩展,提供更灵活、更通用的数据处理能力。我们将学习RDD的概念、Spark的惰性执行机制,并通过Python示例了解基本的转换和行动操作。

RDD:弹性分布式数据集

RDD是Spark中最基本的数据抽象,代表一个不可变、可分区、可并行计算的元素集合。

  • 弹性:RDD通过血缘关系图记录其生成过程。如果部分数据丢失,Spark可以利用这些信息重新计算,从而提供容错能力。
  • 分布式:RDD的数据被划分为多个分区,分布在整个集群的节点上。
  • 数据集:它是一个包含多个数据项的集合。

从逻辑层面看,你可以将RDD视为一个非常大的、分布式的集合,集合中的元素可以是任何类型,而不仅仅是键值对。

Spark与MapReduce的对比

Spark比MapReduce更通用,主要体现在三个方面:

  1. 数据模型:MapReduce处理的数据必须是键值对。而Spark的RDD可以包含任意类型的数据。
  2. 执行模型:MapReduce只有固定的MapReduce两个阶段。Spark允许你构建一个由多个转换操作组成的有向无环图,形成复杂的处理流水线。
  3. 操作丰富度:MapReduce只提供mapreduce两种核心操作。Spark提供了数十种转换和行动操作,如filtergroupByjoinsort等。

因此,MapReduce可以看作是Spark的一个特例:当RDD是键值对RDD,并且只进行mapreduce操作时。

惰性执行与行动操作

Spark的一个关键特性是惰性执行。当你定义一系列RDD转换操作时(例如mapfilter),Spark并不会立即执行计算,而只是记录下这些操作,构建一个血缘关系图

真正的计算只会在你调用一个行动操作时触发。行动操作会向集群提交一个作业,启动计算并返回结果给驱动程序或写入外部存储系统。

代码示例:惰性执行

# 创建RDD(转换操作,不立即计算)
rdd1 = sc.parallelize(["hello world", "hello there"])
rdd2 = rdd1.flatMap(lambda x: x.split(" ")) # 另一个转换操作

# 此时,rdd2只是一个逻辑上的RDD,没有实际数据

# 调用行动操作,触发实际计算
result = rdd2.countByValue() # 行动操作
print(result) # 输出:{'hello': 2, 'world': 1, 'there': 1}

Spark编程接口

Spark主要支持Scala、Java、Python和R语言。本课程使用Python(PySpark)进行教学。

你可以通过两种方式使用Spark:

  1. 交互式Shell:打开Spark Shell(或集成Spark的Jupyter Notebook),逐行输入命令并立即看到结果,适合数据探索和调试。
  2. 提交应用程序:编写完整的Spark应用程序代码,打包后使用spark-submit命令提交到集群执行,适合生产环境。

以下是使用PySpark进行单词计数的简单示例,它演示了创建RDD、转换操作和行动操作。

代码示例:单词计数

# 初始化SparkContext (sc)
from pyspark import SparkContext
sc = SparkContext("local", "WordCountApp")

# 1. 从本地集合创建RDD(转换操作)
lines_rdd = sc.parallelize(["hello world", "hi spark", "hello spark"])

# 2. 切分单词(转换操作:flatMap)
words_rdd = lines_rdd.flatMap(lambda line: line.split(" "))

# 3. 映射为(单词, 1)对(转换操作:map)
pairs_rdd = words_rdd.map(lambda word: (word, 1))

# 4. 按单词聚合计数(转换操作:reduceByKey)
word_counts_rdd = pairs_rdd.reduceByKey(lambda a, b: a + b)

# 5. 收集结果(行动操作:collect)
results = word_counts_rdd.collect()

for (word, count) in results:
    print(f"{word}: {count}")
# 输出类似:
# hello: 2
# world: 1
# hi: 1
# spark: 2

总结

本节课中我们一起学习了Apache Spark的核心概念。我们了解到RDD是Spark处理数据的基石,它具有弹性、分布式的特性。与MapReduce相比,Spark提供了更通用的数据模型、更灵活的执行流程和更丰富的操作集。Spark采用惰性执行策略,只有在调用行动操作时才会触发实际计算。最后,我们通过Python示例体验了Spark编程的基本模式。在接下来的课程中,我们将进一步探索Spark丰富的转换和行动操作“菜单”。

024:Spark (3_5) - 核心概念与物理执行 🚀

在本节课中,我们将深入学习Spark的核心概念,包括各种转换行动操作,并探讨其背后的物理执行模型。我们将从逻辑层面的操作菜单开始,逐步深入到物理层面的任务调度与执行。

逻辑层:转换与行动操作

上一节我们介绍了RDD的基本概念。本节中,我们来看看Spark提供的丰富操作,它们就像餐厅的菜单,允许你灵活地处理和转换数据。

转换操作

转换操作将一个RDD转换为另一个RDD。它们是惰性求值的,意味着在触发行动操作前,不会真正执行计算。

以下是常见的转换操作:

  • Filter:根据一个返回“是/否”的函数过滤RDD中的元素。其逻辑是:filter(rdd, func),其中func对每个元素返回TrueFalse
  • Map:将输入RDD中的每个元素通过一个函数映射为输出RDD中的一个新元素。这是一个一对一映射:map(rdd, func)
  • FlatMap:将输入RDD中的每个元素通过一个函数映射为0个、1个或多个输出元素,然后将所有结果扁平化成一个新的RDD。这是实现MapReduce中Map阶段的等效操作。
  • Distinct:去除RDD中的重复元素。
  • Sample:随机抽取RDD中指定比例的元素子集(无放回抽样)。
  • Union:将两个RDD合并(串联)成一个新的RDD。
  • Intersection:返回两个RDD中都存在的元素(交集)。
  • Subtract:返回存在于第一个RDD但不存在于第二个RDD中的元素(差集)。
  • Cartesian:计算两个RDD的笛卡尔积,输出所有可能的元素对。需谨慎使用,可能导致数据量爆炸。

行动操作

行动操作会触发所有累积的转换操作进行计算,并返回一个结果(到驱动程序)或将结果写入外部存储系统。

以下是常见的行动操作:

  • Collect:将RDD中的所有数据收集到驱动程序(本地机器)的内存中,返回一个Python列表。需确保数据量适合本地内存。
  • Count:返回RDD中元素的总数。这是一个高效且安全的操作。
  • CountByValue:返回一个Python字典,记录RDD中每个唯一值及其出现的次数。需注意唯一值的数量。
  • Take:返回RDD中的前n个元素。
  • Top:返回RDD中的后n个元素(基于排序)。
  • TakeSample:随机抽取RDD中指定数量的元素,并返回到驱动程序。
  • Reduce:使用一个关联函数(如加法、求最大值)聚合RDD中的所有元素。

键值对RDD的专用操作

当RDD的元素是键值对时,Spark提供了一些专用操作。

以下是针对键值对RDD的转换和行动操作:

  • Keys / Values:分别提取所有键或所有值,形成新的RDD。
  • ReduceByKey:将具有相同键的值分组,并使用一个归约函数进行聚合。这是MapReduce中Reduce阶段的等效操作。
  • GroupByKey:将具有相同键的值分组,但不进行聚合,输出中每个键对应一个值列表。
  • SortByKey:根据键对RDD进行排序。
  • MapValues:对每个键值对中的值应用一个函数,而不改变键。
  • Join:对两个键值对RDD进行内连接,输出键匹配的所有键值对组合。
  • SubtractByKey:返回第一个RDD中那些键不在第二个RDD中的键值对。
  • CountByKey:行动操作,返回一个字典,记录每个键出现的次数。
  • Lookup:行动操作,返回给定键对应的所有值。

物理层:执行模型

了解了逻辑操作后,我们来看看Spark如何在物理集群上执行这些操作。理解MapReduce的执行模型将有助于理解Spark。

窄依赖与阶段

在介绍转换操作时,我们提到了“意面图”(spaghetti)的概念。在物理层面,这对应着依赖关系

  • 窄依赖:输出RDD的每个分区只依赖于输入RDD的少量分区(通常是一个)。例如mapfilterflatMap。这类转换没有“意面图”,可以高效地并行化。
  • 宽依赖:输出RDD的分区依赖于输入RDD的多个分区。例如groupByKeyreduceByKeyjoin。这类转换会产生“意面图”,需要进行Shuffle

Spark会将一系列连续的窄依赖转换聚合在一起,形成一个阶段。在一个阶段内,计算可以被分解成许多任务,每个任务处理数据的一个分区,并且这些任务可以完全并行地在集群的各个执行器上运行。

执行器是YARN分配的容器,每个执行器可以包含多个CPU核心(槽位)。任务被调度到这些槽位上执行。通常,任务数量会远多于槽位数量,以实现良好的负载均衡。

Shuffle与阶段划分

当遇到一个宽依赖转换(需要Shuffle)时,Spark会划定一个阶段边界。Shuffle过程与MapReduce类似:需要将数据根据键重新分区并跨网络传输,以便具有相同键的数据能被聚集到同一个节点上进行下一步计算。

因此,一个典型的Spark作业由多个阶段组成,阶段之间由Shuffle分隔。执行流程类似于:Stage 1 -> Shuffle -> Stage 2 -> Shuffle -> Stage 3 ...

优化Spark作业的关键之一就是尽量减少Shuffle的次数和数据量,因为Shuffle是网络和磁盘IO密集型操作,非常耗时。

总结

本节课中我们一起学习了Spark的核心操作与执行模型。

  • 在逻辑层面,Spark提供了丰富的转换行动操作,允许像组合乐高积木一样构建数据处理流水线。转换是惰性的,行动会触发实际计算。
  • 在物理层面,Spark将作业划分为由Shuffle分隔的阶段。阶段内的窄依赖转换被合并并行执行,而宽依赖转换则触发Shuffle。任务在分布式的执行器上运行,由集群资源管理器调度。

理解这种逻辑与物理的分离,以及阶段划分的机制,对于编写高效的Spark程序至关重要。下一节,我们将探讨Spark的性能优化以及更高级的DataFrame和Spark SQL API。

025:Spark (4/5) - 数据框架与Spark SQL 🚀

在本节课中,我们将完成Spark章节的学习,并开始探讨数据框架(DataFrames)和Spark SQL。我们将了解为何需要这些概念,以及它们如何使大数据处理变得更高效、更易用。


课程互动与回顾

首先,我们通过一个互动问题来回顾之前的知识。在MapReduce和Spark中,我们讨论了任务(Task)与可用计算槽位(Slot)的数量关系。一个简单的方法是每个槽位分配一个任务。例如,有1000个槽位就调度1000个任务。但这种方法存在一个问题:由于不同任务完成时间不同,速度快的槽位在完成后会空闲,而整个作业需要等待最慢的任务完成。因此,我们通常希望任务数量多于槽位数量(例如,1000个槽位,5000个任务),这样能更好地平衡负载,提高整体效率。


Spark核心概念回顾

上一节我们介绍了Spark的基本模型。现在我们来快速回顾一下。

与仅包含Map和Reduce两阶段的MapReduce不同,Spark更为通用。

  1. Spark可以处理任意类型的集合,而不仅仅是键值对。
  2. Spark的计算过程由一个有向无环图(DAG)表示,图中的每个节点称为一个弹性分布式数据集(RDD),它是分布在集群中的不可变数据集合。

Spark采用惰性求值。只有当需要某个RDD的结果(即触发一个动作,如保存或打印)时,才会计算整个依赖链。

在物理执行层面,Spark会进行优化。当连续的转换操作(如mapfilter)之间没有数据混洗(Shuffle)需求时(即窄依赖),它们会被合并到一个阶段(Stage)中。这样,数据可以在每个分区内被连续处理,中间结果无需物化,从而提升效率。

相反,当操作需要数据混洗时(如groupBysort,即宽依赖),就会产生新的阶段。混洗开销很大,但有时可以避免,例如,如果输入数据已经按照后续操作所需的方式进行了预分区。


性能优化:持久化RDD

考虑以下场景:我们有一个复杂的RDD依赖图,需要从图中三个不同的RDD触发动作(例如,写入HDFS、打印到屏幕、计数)。如果每次动作都从头计算其所有上游依赖,效率会很低,因为许多中间计算是重复的。

这里的瓶颈在于,多个动作共享同一个上游RDD。解决方案是利用RDD的弹性特性:我们可以将关键的中间RDD持久化在内存或磁盘中。

在代码中,你可以调用persist()cache()方法来标记一个RDD,请求Spark保留其计算结果。这样,当后续动作需要该RDD时,Spark可以直接复用已存储的数据,而无需重新计算。即使内存不足,Spark也会优先处理,最坏情况也只是回退到重新计算,而不会导致作业失败。


引入数据框架与Spark SQL

尽管RDD API功能强大,但在处理结构化或半结构化数据(如JSON)时,编写复杂的转换函数(尤其是涉及Lambda表达式和嵌套数据操作)会变得非常繁琐且难以阅读和维护。

这引出了数据框架(DataFrame)的概念。DataFrame是一种以为单位的分布式数据集合,但每一行都具有相同的模式(Schema)。这本质上就是一个带有列名和类型的表。

使用DataFrame带来两大优势:

  1. 存储高效:数据以列式格式(如Parquet)存储。同列数据具有相同类型,可以紧密排列,无需重复存储字段名,相比存储一堆JSON对象节省大量内存。
  2. 编程高效:我们可以使用熟悉的SQL语言或DataFrame API来操作数据,这比编写复杂的RDD转换函数直观得多。

Spark可以自动从多种数据源(如CSV、JSON Lines)推断模式并创建DataFrame。对于Parquet或关系数据库(通过JDBC)等本身包含模式的数据源,读取速度更快。


Spark SQL 与执行优化

使用Spark SQL时,你只需编写标准的SQL语句(SELECT ... FROM ... WHERE ... GROUP BY ... ORDER BY ...)。Spark会在底层自动将其转换为优化的RDD操作执行计划。

例如,考虑以下查询:

SELECT country, COUNT(*) as cnt
FROM people
WHERE age > 18
GROUP BY country
HAVING cnt > 100
ORDER BY country

在这个查询中:

  • WHERE子句是窄转换,不会引起混洗。
  • GROUP BYORDER BY都可能引起混洗。但是,由于它们都是按country字段操作,Spark可以优化:在执行GROUP BY将相同国家的数据聚集到一起后,数据实际上已经按照country分区和排序了。因此,后续的ORDER BY country可能完全不需要额外的混洗操作。这是利用预分区避免不必要混洗的典型例子。

数据类型与局限

DataFrame支持丰富的数据类型,包括基本类型(整型、字符串、布尔型等)和复杂类型(数组、结构体)。这些类型与其他系统(如Python、Java、JSON Schema)中的类型有直接的对应关系,学习起来并不陌生。

尽管DataFrame和Spark SQL非常强大,但它们并非万能。它们主要针对结构化或半结构化数据的批处理查询进行了优化。对于某些复杂的、非结构化的数据处理逻辑,或者需要更细粒度控制的场景,可能仍需回归到RDD API。


总结

本节课中我们一起学习了:

  1. 任务调度优化:通过设置多于槽位数量的任务来平衡负载。
  2. Spark执行模型回顾:包括RDD、DAG、惰性求值、阶段划分以及窄依赖与宽依赖。
  3. RDD持久化:如何通过缓存中间结果来避免重复计算,提升多作业性能。
  4. 数据框架(DataFrame)的引入:将其视为有模式的分布式表格,带来了存储和编程效率的双重提升。
  5. Spark SQL:使用声明式的SQL语言进行数据查询,Spark会自动进行优化(如利用预分区减少混洗)。
  6. 数据类型:了解了DataFrame支持的类型系统及其与其他编程语言的对应关系。

通过引入DataFrame和SQL接口,Spark使得大规模结构化数据处理变得更加高效和易于上手,同时其底层依然基于灵活且强大的RDD模型。

026:Spark (5/5) - 局限性与总结 🧠

在本节课中,我们将要学习Spark SQL的强大功能及其在处理结构化数据时的优势,同时也会探讨其局限性,特别是在处理嵌套和异构数据时遇到的挑战。

概述:从RDD到DataFrame的演进

上一节我们介绍了Spark RDD和Python API,以及通过DataFrame和SQL进行优化的方法。本节中我们来看看Spark SQL的边界在哪里,以及当数据变得复杂时,它会遇到哪些问题。

我们从一个包含RDD和Python API的系统开始。这个系统比MapReduce更通用,允许更灵活的数据流模式。然而,当编写大量链式转换的Python代码,特别是使用Lambda函数来导航JSON对象时,代码会迅速变得复杂和繁琐。

我们通过引入DataFrame解决了这个问题,并同时提升了性能和效率。DataFrame适用于RDD中的数据基本上是JSON对象,并且这些对象遵循相同模式的情况。DataFrame带来了两个主要好处:首先,它们占用的磁盘和内存空间更少(例如,以列式存储);其次,它们非常直观,是关系表的泛化形式,但可以嵌套。DataFrame支持类似SQL的高级声明式查询语言,如 SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT ...,并且我们知道 GROUP BYORDER BY 操作会在集群中引发数据混洗。

到目前为止,一切都很顺利。但现在,我想展示这个方案的局限性。了解一个方案在何处开始失效,何时会变慢或难以使用,是非常重要的。

Spark SQL的扩展:处理嵌套数据

Spark SQL在大多数情况下(约80%)表现优异,但在某些情况下会变得繁琐。

首先,DataFrame可以嵌套。例如,一个DataFrame的列可以是一个字符串数组。这不再是传统意义上的扁平关系表,但它仍然是一个有效的DataFrame,代表了一组符合相同JSON模式的对象。

我们可以用SQL查询它,但SQL最初并非为嵌套数据设计。因此,像Spark SQL这样的系统扩展了语言以处理数组和对象。

处理数组:EXPLODE函数

以下是处理数组列的核心扩展功能之一:

SELECT name, explode(countries) AS country FROM people_df;

EXPLODE 函数可以作用于一个字符串数组列。它会“展开”数组,将每个元素分离出来,每个元素单独成一行。同时,原始行的其他列会被复制到每个新生成的行中。

更通用的方法:LATERAL VIEW

EXPLODE 功能的一个更通用的等价形式是 LATERAL VIEW。它的概念可能有些复杂。

在SQL中,FROM 子句可以连接(JOIN)多个表。LATERAL VIEW 所做的是将一个列(特别是数组列)假装成一个独立的虚拟表,然后与主表进行连接。

从效果上看,EXPLODE 就像是与一个不存在的虚拟表进行连接。LATERAL VIEW 正是这种“连接”的抽象表达。

多层嵌套的挑战

当数据具有多层嵌套时,例如“人员”包含“大洲”列表,而每个“大洲”又包含“国家”列表,情况会变得更复杂。

这时,你可以连续使用两次 EXPLODE(或 LATERAL VIEW):先展开大洲数组,再展开每个大洲下的国家数组。这相当于进行了一个三表连接(人员表、虚拟的大洲表、虚拟的国家表)。

虽然这种方法可行,但你会感觉到语言并非为此设计,实现起来显得笨拙和复杂。

处理对象:点号表示法

对于嵌套对象(在DataFrame中表现为结构体类型),Spark SQL采用了类似Python或Java的点号表示法来访问内部字段。

例如,如果 name 列是一个包含 firstlast 键的对象,你可以这样查询:

SELECT name.first, name.last FROM people_df;

这样,我们就能处理数组和对象了,尽管过程可能有些令人头疼,但终究是可行的。

Spark SQL的极限:异构数据

现在,让我们探讨真正让Spark SQL“停止工作”的情况:异构数据。

想象一下,你有一个JSON数据集,但其模式不一致,无法放入一个统一的DataFrame。这种情况是真实存在的,例如一家公司拥有30年的日志数据,但日志格式每5年或10年就改变一次。

对于这种无法用单一模式验证的异构JSON数据集合,Spark有一个“好消息”和一个“坏消息”。

“好消息”是:如果你将异构的JSON集合喂给Spark,它不会报错或拒绝。它会用一把“大锤”强行将其塞入一个DataFrame。

具体做法是:Spark会尝试推断一个最宽泛的类型。例如,如果一个字段有时是整数,有时是整数数组,Spark可能会选择将其统一视为字符串类型。整数 3 变成字符串 "3",数组 [3, 4] 变成字符串 "[3, 4]"

从表面看,这很棒,数据成功导入了。

但“坏消息”或真正的挑战是:现在如何处理这些数据变成了你的问题。你无法再轻松地使用Spark SQL来处理这些被强制转换为字符串的复杂结构。你必须编写复杂的Python代码来解析这些字符串,判断其内容(例如,是否以方括号开头?),然后将其转换回可用的数据结构(整数或数组)。

Spark把处理数据异构性的复杂性抛回给了用户。当数据存在嵌套时,Spark SQL尚可应对(尽管头疼);但当数据变得异构时,问题就变得非常严重且难以管理。

核心要点:SQL是为表格设计的。要处理这种树状、异构的复杂数据,我们需要其他能够理解树形结构的查询语言。

RDD与DataFrame的相互转换

最后,需要了解的是,DataFrame在底层会被Spark自动转换为RDD执行。但你也可以显式地进行转换。

  • DataFrame 转 RDD:你可以将一个DataFrame转换为一个由行(Row)对象组成的RDD。
  • RDD 转 DataFrame:你也可以将一个RDD转换为DataFrame,但这需要你提供一个模式(Schema)。Spark在从CSV或JSON读取数据时可以自动推断模式,但对于一个任意的RDD,你需要明确指定其结构。

总结与问答环节

本节课中我们一起学习了Spark SQL的优势与局限。我们回顾了从RDD到DataFrame的演进,学习了如何使用 EXPLODELATERAL VIEW 和点号表示法来处理嵌套数据。更重要的是,我们探讨了Spark SQL在处理深层嵌套和异构数据时的边界,认识到对于“脏”的、未清洗的原始数据,需要更擅长处理树形结构和异构性的语言或工具来进行数据清洗和准备。

在随后的师生问答中,进一步明确了:

  1. 现实中的数据往往是不干净、非结构化的(原始数据),而用于机器学习等任务的数据需要是清洗过的、结构化的(策展数据)。
  2. 数据清洗本身是一个重要的步骤,而Spark SQL在清洗高度异构的原始数据时可能不是最合适的工具。
  3. 关于 LATERAL VIEW,可以将其虚拟表理解为跨整个数据列的单一表(当数据同构时),也可以理解为每个单元格内都有一个独立的表(当数据异构时)。前者更高效易用,后者则复杂且许多系统不支持。

因此,理解工具的适用范围至关重要。Spark SQL是处理大规模结构化数据的利器,但在面对极端嵌套和模式不一的复杂数据时,我们需要寻找更合适的解决方案。

027:文档存储 (1/3) 🗃️

在本节课中,我们将要学习文档存储的基本概念。我们将探讨为何传统的关系型数据库在处理嵌套和异构数据时会遇到困难,并了解文档存储如何作为解决这一问题的方案。

概述

上一节我们介绍了SQL和数据框在处理某些数据时的局限性。本节中,我们来看看文档存储如何解决这些问题。

文档存储将数据存储为文档的集合,例如JSON对象或XML文档的集合。这与关系型数据库将数据存储为行的集合(即表)不同。

文档存储与数据湖的区别在于,文档存储提供了更集成的管理方式,类似于PostgreSQL,而不仅仅是像数据湖那样存储原始文件。

文档存储的物理实现

以下是关于文档存储(如MongoDB)如何在物理层存储文档的几种可能方式:

  1. 作为HDFS上的文本文件:以JSON Lines等格式存储在HDFS上,查询时自动转换为MapReduce或Spark作业。
  2. 作为本地硬盘上的优化二进制格式:使用名为BSON的格式高效存储JSON类型、数组和对象。
  3. 作为本地磁盘上的文本文件:使用即时解析等高效技术进行解析。
  4. 作为一种分布式存储的比喻:可以想象为一个组织有序的文档中心。

技术栈回顾

目前,我们已经在集群上重建了处理表格数据的技术栈。我们可以使用CSV语法、数据框和模式进行数据建模和验证,并能使用Spark处理和SQL查询海量表格数据。

但是,问题在于如何处理XML和JSON数据?上一讲末尾我们展示了,当数据不再是扁平的,而是包含嵌套结构,甚至是异构的、不“干净”的JSON时,处理起来会非常棘手。

因此,我们仍需解决关于原生支持查询语言和索引的文档存储系统的问题,这部分内容将是本学期剩余时间的重点。

数据模型的演进

关系型数据库的世界要求先有模式(Schema),然后才能存入数据。这是一个结构严谨的世界。

而在Excel和JSON的世界里,我们更加灵活。我们不强制要求预先定义模式,因为数据可能因多年积累而不“干净”。验证通常是可选的,目标通常是先清理数据,使其符合某个模式,然后再进行验证。

尝试将树形数据塞入表格

当遇到新型数据时,一个自然的思路是:能否复用现有系统(如表格/数据框)来处理它?具体来说,就是尝试将JSON/XML这类树形结构“塞入”关系型表格中。

简单情况:扁平且同构的数据
当一个树形文档是扁平的(即没有嵌套),并且多个文档结构相似(同构)时,可以轻松地将其映射为表格中的记录。XML模式或JSON模式中的元素名和类型可以直接对应到关系数据库的列名和数据类型。

复杂情况:嵌套与异构
然而,当数据不“干净”时,问题就出现了:

  1. 嵌套性:文档中包含数组或对象的数组。
  2. 异构性:文档之间没有统一的模式,结构各不相同。

对于嵌套数据,一种方法是进行规范化,创建多个扁平表来存储嵌套对象。对于异构数据,可以为缺失字段填充NULL,为额外字段创建新列。

但是,当数据结构非常复杂时(例如,某个字段有时是字符串,有时是字符串数组),这种“生搬硬套”的方法会变得异常繁琐和低效。这就像试图把方钉打入圆孔。

阻抗不匹配与文档存储的动机

这种将一种形状的数据强行适配到为另一种形状设计的系统中的问题,被称为“阻抗不匹配”。

因此,我们需要一种能够原生支持树形集合的系统,避免这种不匹配。这就是文档存储的动机。

什么是文档存储?📄

在文档存储中,我们拥有文档的集合。每个文档(如JSON或XML)都是一棵小树。我们可以将其规模扩展到数百万甚至数万亿个文档,总数据量可达TB乃至PB级。

主要有两类文档存储:JSON文档存储和XML文档存储,它们的功能非常相似。

核心思想是:处理大量的小型树形文档。单个文档的大小通常在10MB左右,但文档的数量可以极其庞大。

文档存储可以看作是关系数据库管理系统的泛化。当文档恰好是扁平且同构时,它就退化成了关系表。因此,文档存储扩展了对异构性嵌套性的支持。

文档存储的能力与特点

与PostgreSQL这类关系数据库相比,文档存储的查询语言通常也能很好地支持投影、选择和聚合操作,并且能扩展到海量数据。

然而,连接(Join)操作是许多文档存储的弱项,有些甚至完全不支持,需要用户自行在应用层编码实现。

另一个关键特点是,文档存储不强制立即进行数据验证。你可以先存入原始数据,在清理之后再执行验证,这提供了更大的灵活性。

总结

本节课中我们一起学习了文档存储的核心概念。我们了解到,文档存储是一种用于管理海量小型树形文档集合的系统。它解决了将嵌套、异构的树形数据强行存入关系型表格时产生的“阻抗不匹配”问题。文档存储是关系数据库的泛化,在数据模式灵活多变或结构复杂的场景下尤为适用。


下一讲,我们将继续深入文档存储,并开始学习用于查询树形数据的特定语言。

028:文档存储 (2/3) 📄

在本节课中,我们将学习文档存储的核心概念,特别是以 MongoDB 为例,了解其数据模型、查询方式、系统架构以及索引机制。我们将看到文档存储如何通过嵌套结构和异构性来扩展传统表格模型的能力。

数据模型回顾

上一节我们介绍了数据的不同形态,从表格到数据框。本节中我们来看看文档存储所处理的数据模型——树形结构。

文档存储的核心是处理树形结构的集合。与关系型数据库的表格相比,树形结构有两个关键特征:

  1. 嵌套性:数据可以有多层嵌套,类似于“表格中的表格”,但实际上是“对象数组中的对象数组”。如果一个对象数组中的所有对象都符合相同的模式且是扁平的,那么它本质上就是一个表格。
    • 公式表示:Array[Object],其中 Object 可以是另一个 Array[Object]
  2. 异构性:如果放弃模式约束,集合中的对象可以拥有完全不同的结构。

正是这两个特性构成了 NoSQL(不仅仅是 SQL)的广阔世界。

文档存储:MongoDB 的逻辑视角

现在,我们需要一个类似于 PostgreSQL 但用于处理树形数据的系统,这就是文档存储。

在文档存储(如 MongoDB)中,我们拥有树的集合,就像 PostgreSQL 中拥有表格一样。这些集合可以包含数百万甚至数十亿棵树,但每棵树的尺寸有限(例如 MongoDB 限制为 16MB)。因此,我们处理的是海量的中型对象集合。

与表格和数据框的关系

理解嵌套性和异构性后,我们可以发现:

  • 表格是树集合的一个特例:当树是扁平的(所有值都是原子值)且同构的(所有记录拥有完全相同的键和类型)时,我们就得到了一个关系型表格。
    • 核心概念:Table = Homogeneous Collection of Flat Trees
  • 数据框处于中间地带:数据框允许嵌套性,但要求同构性(即拥有模式)。
    • 核心概念:DataFrame = Homogeneous Collection of Possibly Nested Trees

基本操作与查询

在文档存储中,我们同样可以实现关系代数中的基本操作:投影、选择、聚合、排序。然而,连接操作不那么常用,因为树形数据通常是反规范化的。嵌套的对象数组本身就相当于预计算好的连接结果。

关于模式,MongoDB 提供了灵活性:

  • 可以无模式运行,直接存入异构的树。
  • 也可以定义模式,在存入时或之后进行数据验证和清理。

MongoDB 生态系统

文档存储领域有许多产品,如 MongoDB、CouchDB(基于 JSON)、Elasticsearch(基于 JSON,主要用于搜索)、BaseX(基于 XML)等。它们的原理相似。本课程将重点介绍最流行的 MongoDB。

物理存储与查询 API

我们之前学习的数据湖(如 S3、HDFS)允许直接查看文件。而像 MongoDB 这样的系统需要先导入数据,其物理存储布局对用户是隐藏的。MongoDB 使用 BSON 格式在磁盘上存储数据,它是一种二进制的、更紧凑的 JSON 表示。

低级 CRUD API

MongoDB 提供了一个低级的、基于语言的 API(如 JavaScript 或 Python)来进行操作,遵循 CRUD(创建、读取、更新、删除)范式。这与拥有独立查询语言(如 SQL)的体验不同。

以下是使用 JavaScript API 进行查询的一些示例,并与 SQL 进行对比:

查询所有文档(相当于 SELECT *

db.scientists.find({})

此命令返回 scientists 集合中的所有文档。

带条件的查询(相当于 WHERE

db.scientists.find({“theory”: “relativity”})

此命令查找 theory 字段等于 “relativity” 的文档。

投影(选择特定字段)

db.scientists.find({}, {“first”: 1, “last”: 1})

此命令仅返回 firstlast 字段。使用 0 则表示排除该字段。

组合条件(AND)

db.scientists.find({“last”: “Einstein”, “century”: 20})

此命令查找同时满足两个条件的文档。

或条件(OR)

db.scientists.find({$or: [{“last”: “Newton”}, {“last”: “Einstein”}]})

使用 $or 操作符表示或条件。

比较操作符

db.scientists.find({“publications”: {$gte: 200}})

使用如 $gte(大于等于)、$lte(小于等于)等操作符进行比较查询。

处理嵌套字段

db.scientists.find({“name.first”: “Albert”})

使用点号 . 来访问嵌套字段。注意,{“name”: {“first”: “Albert”}} 是要求精确匹配整个 name 对象。

其他操作

  • count(): 计数。
  • sort({“field”: 1}): 排序(1升序,-1降序)。
  • skip(30).limit(10): 分页。
  • distinct(“last”): 去重。
  • aggregate([...]): 聚合管道,功能强大,类似于一系列的数据转换操作,概念上类似 Spark 的转换。

插入、更新、删除

  • insertOne({...}) / insertMany([...])
  • updateMany({...}, {$set: {...}})
  • deleteMany({...})

需要注意的是,MongoDB 将 null 值和字段缺失视为相同。此外,每个文档都有一个自动添加的唯一 _id 字段。

这种 API 方式在简单查询时直观,但对于复杂查询,其语法会变得繁琐且需要记忆大量特定操作符(以 $ 开头)。这体现了低级 API 与高级声明式查询语言的区别。

系统架构:复制与分片

与之前的分布式系统(如 HDFS)类似,MongoDB 也采用复制分片来保证可靠性与扩展性。

  • 复制:创建数据的多个副本,防止数据丢失。副本组成了副本集
  • 分片:将数据集合水平分割成多个部分(称为分片),分布到不同机器上,实现并行处理。

MongoDB 的架构特点是:每个分片本身又是一个副本集。每个副本集内部有一个主节点和多个从节点。写入数据时,可以配置需要等待多少个节点确认后才向客户端返回成功,从而实现可调的持久性保证。

索引机制

索引是物理层面的优化技术,用于加速查询,其原理类似于书籍末尾的索引。

在 MongoDB 中,可以对文档的字段创建索引。例如,在 century 字段上创建索引:

db.scientists.createIndex({“century”: 1}) // 1 表示升序索引

索引创建后,系统会维护一个排序的键值列表(如所有 century 值)以及指向对应文档的指针。当执行基于该字段的查询(特别是点查询 field = value 或范围查询 field > value)时,数据库可以直接在索引中快速定位,而无需扫描整个集合,从而极大提升查询速度。

总结

本节课中我们一起学习了文档存储的核心内容。我们首先回顾了文档存储的树形数据模型及其嵌套性、异构性特点,并理解了它与表格和数据框的关系。接着,我们深入探讨了 MongoDB 的低级 CRUD API,通过大量示例了解了如何进行查询、投影、筛选等操作,同时也认识到这种 API 方式的复杂性。然后,我们考察了 MongoDB 的分布式架构,包括其通过副本集实现高可用、通过分片实现水平扩展的机制。最后,我们介绍了索引的基本原理及其对查询性能的巨大提升作用。文档存储为处理灵活、半结构化的数据提供了强大的工具,是现代大数据生态系统中的重要组成部分。

029:文档存储(3_3) - 索引详解 🗂️

在本节课中,我们将深入学习两种核心的数据库索引技术:哈希索引和树索引(B+树)。我们将了解它们的工作原理、各自的优缺点,以及如何在实际应用中有效地使用它们来加速数据查询。

哈希索引的工作原理

上一节我们提到了索引的重要性,本节中我们来看看第一种索引——哈希索引是如何构建和工作的。

我们有一个数据集合,需要为其构建一个哈希索引。如下图所示,左侧是一个类似表格的结构,但它并非关系型数据库的表,而是一种用于组织索引的数据结构。

这个结构的左列是索引的值(例如“世纪”),右列则是指向对应记录的指针。例如,对于“20世纪”,我们会存储值“20”以及指向该世纪相关记录的指针。

哈希索引的核心是一个被称为哈希函数的“魔法”函数。这个函数速度极快且具有确定性,其内部通常包含加法、乘法、取模等运算,最终输出一个整数。你可以输入任何想要索引的值,函数会输出一个整数,这个整数决定了该值在索引数组中的位置。

例如,输入值“20”,哈希函数 H(20) 输出 0,那么“20”及其指针就被放在数组的第0个位置(计算机科学家通常从0开始计数)。

哈希函数的一个重要特性是,尽管它是确定性的,但其输出看起来是随机的,能够将输入值均匀地“散布”在整个数组空间中。这有助于减少“碰撞”的发生,即两个不同的输入值产生了相同的输出位置。一个好的哈希函数应尽可能避免碰撞。

以下是哈希函数的一个抽象表示:

位置 = H(索引键值)

现在,我们继续构建索引。输入“19世纪”到哈希函数,得到位置4;输入“公元前世纪”,得到位置3;输入“-6世纪”,得到位置1。注意,“20”再次输入时,H(20) 依然输出0,这体现了哈希函数的确定性。

构建索引可能需要很长时间,特别是当集合很大时,可能需要数小时甚至一天。构建过程可以选择阻塞整个数据库系统,或者允许系统在后台缓慢构建。然而,一旦索引构建完成,查询速度将得到质的飞跃。

当有人执行点查询(例如查找“19世纪”)时,系统只需将“19”输入哈希函数 H,快速得到位置4,然后根据该位置的指针直接找到对应文档。这个过程非常快,几乎是瞬间完成的。

哈希索引的局限性

然而,哈希索引也存在一些局限性。

首先,它不支持范围查询。例如,查询“所有大于15的世纪”。由于哈希函数将值随机散布,你无法确定某个范围的值集中在数组的哪个连续区域。你只能分别查找15、16、17等每一个值。

其次,哈希函数并非完美,碰撞可能发生。为了尽量减少碰撞,需要分配一个足够大的数组,这会占用大量内存空间。

此外,在密码学等领域,哈希函数难以逆向破解的特性是其安全性的基础,但这与数据库索引的上下文不同。

树索引(B+树)简介

鉴于哈希索引的局限性,我们引入第二种索引——树索引,它特别擅长处理范围查询。这里我们主要介绍B+树。

B+树是一种复杂的物理数据结构,不同于JSON或XML的逻辑树。其底部叶子节点(通常用蓝色表示)按顺序存储了所有被索引的值。这种结构就像字典的目录,可以快速定位目标,而无需扫描全部内容。





B+树的实现非常复杂,但核心思想是:树中的每个节点都对应磁盘上的一个数据块。因为从磁盘读取数据时,按块(例如一次读取4000字节)读取远比按位读取高效。因此,树节点被设计为可以容纳尽可能多的值,以填满一个磁盘块。



构建B+树索引

让我们通过一个例子直观地看看B+树是如何构建的。假设我们限制每个节点最多存放两个值(仅为演示简化)。

  1. 首先插入“20”,形成一个包含单个值的节点。
  2. 插入“21”,节点变为 [20, 21]
  3. 插入“19”。由于节点已满(超过2个值),需要进行分裂。将 [19, 20] 放在左子节点,[21] 放在右子节点。根节点则存储一个边界值“21”,表示左子树所有值 <21,右子树所有值 >=21
  4. 继续插入“-4(40)”、“-6”等值,树会根据规则不断分裂和重组,以保持平衡和有序。

构建这样的索引同样耗时,但完成后,查询效率极高。例如,执行查询“世纪 >= 19”,系统可以快速在树中定位到19,然后获取其右侧的所有值(即>=19的值),并跟随指针找到对应文档。

这正是像电商网站这样的系统能够实现即时搜索的原因——它们使用了B+树这类索引结构。虽然构建索引需要时间,但之后的海量查询因此变得极快。

索引的维护与查询优化

如果没有索引,查询时必须扫描整个集合,在数据量巨大时(例如数十亿条记录)将非常缓慢。索引通过直接指向所需数据在磁盘上的位置,极大地减少了需要读取的数据量,从而提升了速度。

索引构建后,对集合的增删改操作会触发索引的更新。每次更新会导致索引发生微小调整(如节点重组),这会使写操作稍慢一些,但通常可以接受。有些系统(如早期谷歌)采用另一种策略:在后台构建全新索引,完成后替换旧索引,这会导致数据更新在索引中可见的延迟。

索引的使用可以非常灵活。以下是一些关键点:

  1. 部分过滤:即使查询条件并非全部被索引覆盖,索引依然能加速查询。例如,索引在“职业”字段上,查询条件是“职业=物理学家 AND 理论=相对论”。系统可以先利用索引快速找到所有“物理学家”,然后在内存中从这个子集中筛选出“理论为相对论”的记录。这比全表扫描快得多。

  2. 复合索引:可以在多个字段上建立树索引,例如 (出生年份, 去世年份)。索引首先按第一个字段排序,第一个字段相同时再按第二个字段排序。这种索引对以下查询有效:

    • 出生年份=1987 (因为1987年的记录在索引中是连续的)
    • 出生年份>=1946 (范围查询)
    • 出生年份=1987 AND 去世年份=2020 (精确匹配)
      但是,对于 去世年份=1887 这样的查询,该复合索引无法提供有效帮助,因为索引的首要排序键是“出生年份”。
  3. 索引设计策略:在实际工程中,设计索引是一门艺术。你需要分析应用程序发出的查询模式,然后创建数量最少但覆盖最广的索引。应避免创建冗余索引。例如,如果已有索引 (A, B, C),那么索引 (A)(A, B) 在很大程度上就是冗余的,因为第一个索引的前缀已经涵盖了后两者的功能。创建过多索引会浪费存储空间,并降低写操作性能。

总结

本节课中我们一起学习了数据库的两种核心索引技术。

  • 哈希索引:通过哈希函数实现极快的点查询,但不支持范围查询,且可能存在碰撞问题。
  • 树索引(B+树):支持高效的点查询和范围查询,结构复杂但非常适合磁盘存储特性,是大多数数据库系统默认或常用的索引类型。

我们了解到,索引通过预先构建额外的数据结构,牺牲一定的存储空间和写操作性能,来换取读操作(查询)性能的巨大提升。设计良好的索引是构建高性能数据应用的关键。在实际工作中,需要根据查询负载仔细选择和设计索引,在查询速度和更新成本之间取得最佳平衡。

030:查询树结构数据 (1/4) 🗂️

在本节课中,我们将要学习查询语言与API的核心区别,并引入一种专门用于处理嵌套、异构树结构数据(如JSON)的查询语言——Jsoniq。我们将理解为何在处理真实世界的不规整数据时,传统的SQL和API方法会面临挑战,以及Jsoniq如何通过其声明式、函数式和基于集合的特性来简化这一过程。

查询语言与API的区别 🤔

上一节我们介绍了不同的数据处理系统。本节中,我们来看看用户与这些系统交互的两种主要方式:查询语言和API。

理解查询语言和API之间的区别至关重要。

  • 查询语言:如SQL或Jsoniq,是一种独立的全新语言。它专门为表达数据查询而设计,与Python、Java等通用编程语言无关。
  • API:如PySpark的DataFrame API或MongoDB的JavaScript驱动,是对现有通用编程语言(如Python、Java)的扩展。它通过引入新的函数库,让该语言能够与特定的数据库系统(如Spark、MongoDB)进行通信。

使用查询语言通常更容易学习,因为用户只需掌握该语言本身的语法(如SQL的SELECT ... FROM ... WHERE)。而使用API则意味着用户需要同时学习通用编程语言(如Python)和该API的具体函数,语法上也可能更繁琐(例如大量的括号)。

为何需要新的查询语言? 🚧

上一节我们明确了语言与API的差异,本节中我们来看看当数据不符合理想的“干净”格式时,传统工具会遇到什么问题。

SQL非常适合处理扁平、同构的关系型表格数据。然而,现实世界的数据往往是嵌套和异构的,可能包含缺失值、额外字段、无效值或嵌套表格。

以下是传统方法在处理此类数据时的局限性:

  • SQL的局限:SQL并非为原生处理嵌套和异构数据而设计。虽然可以通过LATERAL VIEWEXPLODE等操作进行转换,但查询会变得非常复杂且难以理解。
  • DataFrame API的局限:当Spark的DataFrame遇到混合类型(例如,同一字段有时是字符串,有时是对象)时,它会强制进行类型转换(例如,将所有值转为字符串)。这会将数据清洗的负担转移给终端用户,后续在Python中操作会非常困难。

因此,我们需要一种能够原生、优雅地处理树形结构(嵌套、异构)数据的查询语言。

引入Jsoniq:树结构数据的查询语言 🌲

认识到传统工具的不足后,本节我们将认识一种专为树结构数据设计的查询语言——Jsoniq。

Jsoniq是一种声明式、函数式、基于集合的查询语言,专为JSON等嵌套、异构数据设计。

以下是Jsoniq作为查询语言的三个核心特征:

  1. 声明式:用户只需描述“想要什么”(例如,选择哪些数据,满足什么条件),而无需指定“如何一步步获取”。
  2. 函数式:查询由可以相互嵌套的表达式构成,类似于数学中的函数组合。但Jsoniq的语法经过精心设计,对用户而言显得轻量且直观。
  3. 基于集合:单个查询可以处理并返回包含海量数据(如数十亿条记录)的集合,这得益于其在集群上的高效实现。

Jsoniq基础:数据计算器 🧮

了解了Jsoniq的定位后,本节我们来学习它的一些基础语法,你可以将其视为一个强大的“数据计算器”。

Jsoniq语法简洁而强大,以下是一些基本示例:

  • 基本运算

    1 + 1
    

    (返回 2

  • 布尔运算

    2 < 5
    

    (返回 true

  • 绑定变量
    let $i := 2
    return $i + 1
    
    (返回 3

  • JSON原生支持:任何合法的JSON本身也是有效的Jsoniq查询,会返回自身。

    {"name": "Alice", "age": 30}
    

    (返回该对象)

  • 导航与提取

    {"foo": "bar"}.foo
    

    (返回 "bar"

    [3, 4, 5][1]
    

    (返回 3,注意索引从1开始)

  • 组合操作

    {"foo": [3, 4, 5]}.foo[1] + 3
    

    (返回 6

  • 解构数组(Unboxing)

    [3, 4, 5][]
    

    (返回序列 3, 4, 5

  • 序列与迭代

    1 to 4
    

    (返回序列 1, 2, 3, 4

    for $i in 3 to 5
    return { "key": $i, "value": $i * $i }
    

    (返回三个对象:{"key":3,"value":9}, {"key":4,"value":16}, {"key":5,"value":25}

  • 读取数据与函数调用

    keys(json-file("s3://bucket/data.json"))
    

    此查询会读取S3上的JSON文件,并返回该数据集中所有对象中出现过的键的集合。json-filekeys都是Jsoniq的内置函数。

总结与预告 📚

本节课中我们一起学习了:

  1. 查询语言(如SQL、Jsoniq)与API(如PySpark API)的根本区别:前者是独立语言,后者是现有语言的扩展库。
  2. 传统SQL在处理真实世界嵌套、异构数据时面临的复杂性和局限性
  3. Jsoniq查询语言的引入:它是一种声明式、函数式、基于集合的语言,专为高效查询树形结构数据而设计。
  4. Jsoniq的基础语法:包括基本运算、JSON导航、序列生成、迭代以及读取外部数据源。

下一讲,我们将继续深入Jsoniq,学习更复杂的查询操作,并完成这部分内容。在学期最后一周,我们将进行课程总结并准备期末考试。


注意:本教程根据提供的视频字幕内容整理,保留了原讲师的讲解逻辑和核心示例,删除了语气词,并按照要求进行了结构化编排和格式化。文中的代码块(如1 + 1)用于直观展示Jsoniq语法。

031:查询树结构数据 (2/4) 🌳

在本节课中,我们将学习如何查询树形结构数据,特别是使用JSONiq语言。我们将了解其数据模型、基本语法和核心操作,并理解为何它比传统SQL更适合处理半结构化数据。


数据模型:一切皆是序列

上一节我们讨论了为何需要专门的语言来查询树形数据。本节中,我们来看看JSONiq的核心基础——其数据模型。

JSONiq操作的基本单位是序列。一个序列是一个有序、扁平的列表,可以包含零个、一个或多个

  • 可以是:
    • 原子值:如字符串、整数、布尔值。
    • 复杂结构:如对象(字典)或数组(列表)。
  • 序列是扁平的:序列不能嵌套在另一个序列中。如果你组合两个序列,结果是一个扁平的单一序列。公式表示为:(a, b, c) + (d) = (a, b, c, d)
  • 异构性:序列中的项可以是完全不同的类型,例如 (42, “hello”, true, {“key”: “value”}) 是一个有效的序列。
  • 通用性:关系型表格可以看作是所有项都具有相同键和类型的对象序列。因此,JSONiq的序列模型比关系模型更通用,能够处理从高度结构化到完全非结构化的所有数据。

这种设计提供了强大的数据独立性。无论数据是存储在本地文件、S3对象存储还是通过HTTP访问,无论数据规模是KB级还是PB级,查询逻辑都保持一致。


数据导航与探索

理解了数据模型后,我们来看看如何访问和探索数据。

JSONiq使用点号 . 来导航对象中的属性,使用双括号 [[ ... ]] 来展开数组。这类似于许多编程语言中访问字典和列表的方式。

以下是一个导航示例:

let $doc := { "countries": [ {"code": "CH", "name": "Switzerland"}, {"code": "FR", "name": "France"} ] }
return $doc.countries[[ ]].name

这段代码会返回序列:(“Switzerland”, “France”)

我们可以使用谓词(过滤器)来筛选数据。谓词写在 [ ... ] 内,使用比较运算符。变量 $$ 代表当前正在处理的项。

例如,要查找代码为“CH”的国家名称:

json-file(“data.json”)[$$.code = “CH”].name

这相当于在Spark中执行一个filter转换。

对于模式未知的大规模数据集,我们可以使用内置函数进行探索:

  • keys($object):返回对象的所有键。
  • distinct-values($sequence):返回序列中去重后的值。
  • count($sequence):返回序列中的项数。

以下是一个数据发现查询示例,用于找出数据集中所有不同的type值:

distinct-values(json-file(“huge_dataset.jsonl”).type)

无论底层数据格式是JSON还是Parquet,无论数据量多大,这个查询都保持不变。在Parquet格式下,由于存在模式信息,此类查询可能无需扫描数据本身,执行效率会高得多。


构造数据与基本运算

除了查询,我们经常需要构造新的数据或进行运算。

在JSONiq中,任何JSON值本身就是一个有效的查询,它会返回自身。例如,查询 “Hello”42 会直接返回这些值。

以下是构造数据的方法:

  • 构造序列:使用逗号 ,。例如:(1, “a”, true)
  • 构造数组:使用方括号 []。例如:[1, 2, 3]
  • 构造范围序列:使用 to 操作符。例如:1 to 100 会生成一个包含100个整数的序列。系统会自动并行化处理大型范围。

JSONiq支持丰富的运算:

  • 算术运算+, -, *, div (整除), mod。这些运算要求操作数是单一项或空序列。
  • 字符串运算|| 用于连接,concat() 函数,substring()string-join()(非常实用,用于在多个字符串间插入分隔符)。
  • 比较运算=, !=, <, <=, >, >=
    • 当对序列进行比较时,执行的是存在量化。即,如果能在左序列和右序列中各找到一个项满足比较条件,则返回 true。例如:(1, 2, 3) = 3 返回 true(1, 2) > (5, 6) 返回 false
  • 逻辑运算and, or, not


总结

本节课中,我们一起学习了JSONiq查询语言的基础知识。

  1. 我们首先了解了其核心数据模型:一切操作都基于序列,序列可以包含原子值或嵌套的树形结构(对象和数组)。
  2. 接着,我们学习了如何使用点号和双括号进行数据导航,以及如何使用谓词进行过滤。这对于探索未知模式的数据集至关重要。
  3. 最后,我们掌握了如何构造新数据,并学习了包括算术、字符串、比较和逻辑运算在内的基本运算。特别需要注意的是,对序列的比较执行的是存在性检查。

JSONiq作为一种声明式、函数式的语言,允许我们以简洁的方式表达复杂的数据处理逻辑,同时将具体的执行细节(如并行化)委托给底层引擎(如Spark),完美体现了数据独立性的原则。在下一节中,我们将深入探讨更高级的查询组合能力。

032:查询树(3/4) 🧩

在本节课中,我们将深入学习JSONiq查询语言的核心概念,特别是如何构建复杂的表达式、理解FLWOR表达式以及执行关系型操作。我们将通过具体的例子和公式来阐明这些概念,确保即使是初学者也能跟上。


概述

本节课程将介绍JSONiq查询语言的高级特性。我们将看到如何像搭积木一样组合表达式,理解FLWOR表达式(类似于SQL的SELECT-FROM-WHERE)的强大功能,并探索如何在JSONiq中执行选择、投影、连接等关系型操作。最后,我们会简要了解查询在引擎内部是如何被处理和执行的。


表达式组合:像搭积木一样构建查询

上一节我们介绍了JSONiq的基本导航操作。本节中我们来看看如何将简单的表达式组合成更复杂的查询。

在JSONiq中,你可以将任何表达式嵌套在任何其他表达式中。最坏的情况是组合出的表达式没有意义,你会得到一个错误。但原则上,你可以在任何表达式中嵌套任何其他表达式。

以下是一个复杂表达式的例子:

2 + 2 . {"a": 4, "b": [1, 2, 4]} .b [] .a

这个表达式的计算步骤如下:

  1. 2 + 2 得到 4
  2. {"a": 4, "b": [1, 2, 4]} .b 提取出数组 [1, 2, 4]
  3. [] 操作符(解包)打开数组。
  4. .a 尝试从数组元素中提取键 "a" 的值。由于数组元素是数字(1, 2, 4),没有 "a" 键,因此这一步对每个元素都返回空序列。
  5. 最终结果是三个空序列的序列。

你可以像解数学公式一样,逐步计算这个表达式。

注意运算符优先级:与数学中乘法优先于加法类似,JSONiq也有运算符优先级规则。如果你不确定优先级,或者想覆盖默认优先级,最安全的方法是使用括号 () 来明确指定执行顺序。


动态JSON与FLWOR表达式

表达式组合的一个好处是你可以创建“动态JSON”。这意味着你可以构建一个看起来像JSON的结构,但在其中嵌套查询。

例如,你可以创建一个对象,其某个键的值是通过查询动态计算出来的:

{
  "at": "fubar",
  "value": 6,
  "array": [ for $i in 1 to 10 return $i ]
}

这里,"array" 的值不是一个静态数组,而是一个FLWOR表达式的结果,它会生成一个包含1到10的数组。这就是一个动态JSON。

现在,让我们引入更多“积木块”。

条件表达式 (if-then-else)

if ($condition) then $then-value else $else-value

这是一个函数式的条件判断。如果条件为真,返回 $then-value;否则返回 $else-value。它类似于C++或Java中的三元运算符 ? :

Switch-Case表达式

switch ($value)
  case "I" return "Italian"
  case "F" return "French"
  default return "Unknown"

你可以通过一连串的 if-then-else 实现相同功能,但 switch-case 在某些情况下更方便。

Try-Catch表达式

try { $expression } catch * { "Recovered from error" }

用于从错误中恢复。例如,尝试将字符串转换为整数,如果失败则返回一个默认值。


导航、过滤与函数应用

让我们回到具体的数据集(例如Github存档数据)进行操作。

假设我们想获取所有提交记录中的作者邮箱:

$github-archive.payload.commits[].author.email
  1. $github-archive.payload.commits 获取一个对象数组的序列。
  2. [] 解包操作符打开所有数组,将其扁平化为一个对象序列(所有提交)。
  3. .author.email 从每个提交对象中提取作者邮箱。

我们也可以使用函数,例如 size() 来获取数组大小,并进行过滤:

$github-archive[][size($.payload.commits) >= 5]

这个查询会过滤出那些 payload.commits 数组大小至少为5的事件。

要访问数组中的特定元素,使用双括号 [[index]]

$github-archive.payload.commits[[0]].author.email

[[0]] 获取每个提交数组的第一个元素(即第一次提交),然后提取其作者邮箱。


使用LET分步编写查询

对于更复杂的查询,使用 let 子句分步编写可以提高可读性。

let $events := json-file("github-archive.json")[]
let $actors := $events.actor
let $logins := $actors.login
let $distinct-logins := distinct-values($logins)
return $distinct-logins

每个变量都依赖于前一个变量。这种风格与直接将表达式嵌套在一起是等价的,但更易于人类阅读和理解。许多函数式语言(如Haskell, F#)都有类似的 let 语法。

for 子句用于显式迭代:

for $event in json-file("github-archive.json")[]
let $nb-commits := size($event.payload.commits)
return {"id": $event.id, "commits": $nb-commits}

这个查询遍历每个事件,计算其提交数量,并返回一个只包含ID和提交数量的新对象列表。


深入理解FLWOR表达式 🏵️

既然我们看到了 forlet,现在让我们全面了解FLWOR表达式。FLWOR代表:

  • For
  • Let
  • Where
  • Order by
  • Return

以下是一个包含所有子句的示例:

for $x in 1 to 10
let $square := $x * $x
where $x - 2 > 5
order by $x descending
return {"number": $x, "square": $square}

执行流程(直观理解)

  1. for:将 $x 依次绑定为1到10。
  2. let:为每一行绑定计算 $square = $x * $x
  3. where:过滤,只保留满足 $x - 2 > 5(即 $x > 7)的行。所以保留 $x 为 8, 9, 10 的行。
  4. order by:按 $x 降序排列。
  5. return:为最终每一行返回指定的对象。

结果将是:

[ {"number": 10, "square": 100},
  {"number": 9, "square": 81},
  {"number": 8, "square": 64} ]

与SQL的关键区别:在SQL中,子句顺序是固定的(SELECT...FROM...WHERE...)。在JSONiq的FLWOR表达式中,除了必须以 forlet 开头、以 return 结尾外,其他子句可以按任意顺序排列!这提供了极大的灵活性。

形式化语义(元组流)
在引擎内部,FLWOR表达式的执行可以理解为对一个“元组流”或“数据帧”的操作。

  • for/let 子句会向流中添加列(绑定变量)。
  • where 子句过滤行。
  • order by 子句对行排序。
  • group by 子句将行分组,创建新的“包”序列。
  • return 子句基于最终的流生成输出序列。
    这种实现方式使得JSONiq能够利用Spark等引擎高效处理海量数据。


在JSONiq中实现关系代数

JSONiq足够强大,可以支持完整的关系代数操作。假设我们有两个类表结构的JSON集合:

产品 (products.json):

[ {"id": 1, "type": "Laptop", "store": 5},
  {"id": 2, "type": "Phone", "store": 3} ]

商店 (stores.json):

[ {"id": 5, "country": "CH"},
  {"id": 3, "country": "DE"} ]

以下是各种关系操作在JSONiq中的实现:

选择 (Selection) - 使用 where

for $p in collection("products.json")
where $p.type eq "Laptop"
return $p

投影 (Projection) - 使用 project 函数或直接构造对象:

for $p in collection("products.json")
return project($p, ("id", "type"))
-- 或
for $p in collection("products.json")
return {"id": $p.id, "type": $p.type}

排序 (Ordering) - 使用 order by

for $p in collection("products.json")
order by $p.id
return $p

分组与聚合 (Group by & Aggregation)

for $p in collection("products.json")
group by $store := $p.store
return {"store": $store, "count": count($p)}

注意,在JSONiq中,你不仅限于聚合,还可以在分组后保留嵌套的原始数据,这是比SQL更灵活的地方。

内连接 (Inner Join) - 使用两个 for 和一个 where

for $p in collection("products.json")
for $s in collection("stores.json")
where $p.store eq $s.id
return {"type": $p.type, "country": $s.country}

左外连接 (Left Outer Join) - 使用 allowing empty

for $p in collection("products.json")
for $s allowing empty in collection("stores.json")[$$.id eq $p.store]
return {"type": $p.type, "country": $s.country}

如果某个产品没有对应的商店,$s 会被绑定为空序列,在结果中 country 字段会显示为 null

反规范化/嵌套连接 (Denormalization/Nested Join)

for $s in collection("stores.json")
let $products := collection("products.json")[$$.store eq $s.id]
return {
  "country": $s.country,
  "products": [ $products.type ]
}

这个查询将产品列表嵌套到了每个国家对象中,生成了嵌套的JSON结构,这是传统SQL难以直接实现的。

关于 $$:在过滤谓词(如 [$$.store eq $s.id])中,$$ 代表当前正在被过滤的序列中的“上下文项”。它避免了为简单的lambda函数显式定义变量。


类型系统与数据验证

JSONiq拥有丰富的类型系统,可用于数据验证。

类型声明与检查

let $path as string := "/data/file.json"
let $events as object* := json-file($path)[]
...

as 关键字用于声明变量的预期类型。如果运行时类型不匹配,会抛出错误。类型指示符包括:

  • integer:恰好一个整数。
  • boolean?:0个或1个布尔值(? 表示可选)。
  • array+:1个或多个数组(+ 表示至少一个)。
  • object*:0个或多个对象(* 表示任意数量)。

类型测试与转换

$value instance of integer*   -- 测试$value是否全是整数
$value castable as double     -- 测试$value能否转换为double
$value cast as double         -- 将$value转换为double

使用模式验证数据
在清理数据后,可以定义模式并验证输出,这通常会将其转换为更高效的数据帧格式。

validate type local:histogram {
  "commits": integer,
  "count": integer
}
{
  for $event in $github-archive[]
  let $commits := (size($event.payload.commits), 0)[1]
  group by $commits
  order by $commits
  return {"commits": $commits, "count": count($event)}
}

验证后的结果可以高效地保存为Parquet、CSV等格式。


查询执行内部原理 ⚙️

最后,我们简要了解JSONiq查询是如何被执行的。这个过程对用户透明,但有助于理解其能力。

  1. 解析 (Parsing):查询文本首先被解析成抽象语法树,这是查询在内存中的结构表示。
  2. 编译 (Compilation):AST被转换为表达式树,其中每个节点对应一个JSONiq表达式(如函数调用、操作符)。
  3. 优化 (Optimization):引擎对表达式树进行优化,例如移除未使用的变量、简化表达式等。
  4. 代码生成 (Code Generation):优化后的树被转换为可执行的迭代器树。你可以想象每个迭代器像一个阀门,当你从最顶层的迭代器“拉取”数据时,它会递归地从下层迭代器拉取数据项,并以流式方式输出。
  5. 执行模式
    • 物化执行:将所有中间结果和最终结果一次性加载到内存。适用于小数据量。
    • 流式执行:数据项被逐个处理并产出,内存占用小。适用于大数据量,类似于视频流。
    • 并行执行:将数据和计算分布到多台机器上,同时进行流式或物化执行,以处理海量数据。

正是基于Spark等分布式计算框架,JSONiq(通过RumbleDB实现)才能在海量JSON数据上高效运行。


总结

本节课中我们一起深入学习了JSONiq查询语言。

  • 我们掌握了如何像组合乐高积木一样,将基础表达式嵌套成复杂的查询。
  • 我们深入理解了FLWOR表达式的构成与强大灵活性,它能够以任意顺序组织forletwhereorder bygroup byreturn子句,完成迭代、绑定、过滤、排序、分组和结果构建。
  • 我们探索了在JSONiq中实现关系代数操作(选择、投影、连接、聚合等)的方法,并看到了它超越SQL的能力,例如直接生成嵌套的、反规范化的JSON输出。
  • 我们了解了JSONiq的类型系统,以及如何利用它进行数据验证和清洗。
  • 最后,我们窥探了查询引擎内部的执行原理,包括解析、优化以及流式与并行执行,理解了它为何能处理大规模数据集。

通过结合这些概念,你可以编写出强大、灵活且高效的查询,来处理各种结构化与半结构化的JSON数据。

033:查询树(4/4) 🧠

在本节课中,我们将学习RumbleDB查询引擎的物理实现层。我们将了解一个高级查询语言(如JSONiq)如何在不同规模的数据集上,通过不同的底层执行模式(如迭代器、RDD、DataFrame、Spark SQL)透明且高效地运行。


上一节我们介绍了JSONiq查询语言的逻辑层面。本节中,我们来看看这些高级查询在物理层面是如何被执行的。关键在于,用户看到的只是简单的查询语法,而引擎在幕后自动选择最佳的执行策略。

物理执行模式概览

JSONiq是一种高级语言,类似于SQL。这意味着无论查询是在笔记本电脑上运行,还是在数据中心处理PB级数据,无论数据源是JSON、Parquet还是CSV,查询语句看起来都完全一样。

这种一致性意味着所有复杂的工作都隐藏在物理实现层。用户唯一能感知到的可能是查询速度的快慢。实现层提供了多种执行模式。

以下是主要的执行模式:

  1. 直接Java方法调用:适用于可以预先确定只返回单个结果的简单表达式。

    • 示例1 + 2 * 3
    • 实现:直接作为简单的Java方法执行,无需复杂框架。
  2. 火山迭代器:适用于数据量较小(例如数千到百万条)且可在单机上处理的情况。

    • 核心APIopen(), hasNext(), next(), close()
    • 工作原理:逐个拉取和处理数据项,实现流式处理。
    • 示例(1 to 10)[$$ mod 2 = 0] 会逐个检查数字是否为偶数。

  1. Spark RDD转换:当处理来自数据湖的大型、可能异构的JSON文件时使用。
    • 过程:将文件作为字符串RDD读入,解析为JSON对象(允许异构),后续的查询操作(如 .countries)被转换为 flatMap 等RDD转换操作。
    • 特点:用户完全看不到RDD和转换细节,查询逻辑保持不变。

  1. Spark DataFrame转换:当数据是同构的或已被验证(例如Parquet文件)时使用。

    • 优势:DataFrame比RDD更节省空间,执行速度更快。
    • 用户感知:逻辑上仍是一个对象序列,只是物理执行更快。
  2. Spark SQL转换:在某些特定情况下(如对Parquet文件进行迭代和过滤),查询会被进一步优化并转换为Spark SQL执行,以获得最佳性能。

所有这些执行模式的选择对用户都是透明的,由RumbleDB引擎自动完成。

FLWOR表达式的执行

FLWOR表达式通过子句操作元组流。为了实现并行化执行,引擎采用以下策略:

  1. 如果数据源是异构的,会先创建一个RDD,然后将其转换为一个单列的DataFrame。该列对应FLWOR中的变量,列中的值是序列化后的二进制对象。
  2. 随着FLWOR子句的执行(如forletwhere),会相应地在DataFrame上添加列或应用过滤转换。
  3. 每个FLWOR子句都有对应的DataFrame转换方式。
  4. 查询结束时,DataFrame会被转换回RDD或结果序列。

同样,如果FLWOR处理的数据量很小,引擎则会退回到本地的迭代执行模式,避免启动Spark的开销。这一切均由系统自动判断和选择。

引擎的自动优化

RumbleDB引擎接收查询后,会生成执行计划树,并自动完成以下工作:

  • 分析数据序列和元组流。
  • 决定在何处使用DataFrame,在何处使用RDD。
  • 选择要应用的转换操作。
    引擎每年都会发布更新,在保持接口不变的前提下持续进行性能优化。

在现代数据架构中的应用

RumbleDB与现代数据架构(如数据湖湖仓一体)完美集成。

  • 数据清洗与验证:可以使用JSON Schema定义数据结构,并通过 validate 表达式进行验证。验证通过的数据会自动提升为DataFrame,从而加速后续查询。
  • 格式转换:可用于读取JSON,验证后保存为Parquet、Avro等高性能列式存储格式。
  • 机器学习:虽然超出本课程范围,但RumbleDB能够构建包含特征和标签的对象序列,并通过函数项支持机器学习模型的训练。
  • 未来支持:即将发布的版本将支持通过Delta Lake等技术对数据进行修改操作。


本节课中我们一起学习了RumbleDB查询引擎的物理实现。我们了解到,一个简单的高级查询背后,引擎会根据数据规模、结构和类型,智能地在本地迭代、Spark RDD、DataFrame乃至Spark SQL等多种执行模式间进行选择和切换。这一切优化都对用户透明,使得开发者能够用统一的语言轻松处理从KB到PB级的数据,并融入现代数据湖和湖仓一体架构。

034:课程总结 (1/2) 🎯

在本节课中,我们将对《工程师的大数据》课程的核心内容进行总结。我们将回顾所学的关键概念、数据形状、技术架构以及未来的发展趋势,帮助你构建一个完整的大数据知识体系。

课程核心回顾 📚

首先,我们从宏观层面回顾整个课程的内容。尽管我们学习了数据反规范化、树、图、立方体等多种数据形状,但需要明确一个事实:对于行业中的新项目,表格仍然是主流。在80%的情况下,你需要的仍然是关系型系统。

许多人的数据可以存储在一台机器上,并且是结构化的。在这种情况下,使用经典的关系型数据库就足够了。即使是大数据,你仍然可能使用表格,这正是数据框架和Hive等技术的核心。因此,不要低估表格的力量。

树形结构适用于数据不干净或需要进行反规范化的场景。但归根结底,大量数据实际上仍然具有表格形状。

数据形状的演变 🌳

上一节我们强调了表格的持久性,本节中我们来看看其他数据形状。我们在这门课中研究最多的形状是。我们现在知道,反规范化的表格就是树的集合。一个表格实际上是扁平且同质的树集合。

我们未在本课程中涵盖图数据库(例如Neo4j),这在大数据硕士课程中会涉及。立方体在《工程师信息系统》课程中涵盖。文本数据则有多种模型,现在我们甚至有了向量数据库。我曾考虑为向量数据库创建第六种形状,但后来意识到这本质上仍是对文本数据的一种建模方式。

系统设计原则:简单性 ⚙️

我希望你们在整个学期中看到的一个原则是简单性。扩展到数千台机器的唯一方法是保持简单。例如,拥有一个巨大的树集合(数十亿甚至数万亿个对象),每个对象都是一棵树,这就是一个简单的事物。就像S3的键值对模型,或者对象存储桶中的黑盒比特。理解我们所构建的东西至关重要,否则复杂度会让我们的大脑爆炸。

数据形状的范式与组件 🧩

非常重要的一点是,这五种数据形状中的每一种都有一个范式(即关于它们的故事)、一个或多个数据模型、一种语法查询语言、实现系统的架构,并且它们之间可以相互映射。

以下是每种形状的核心操作:

  • 表格:选择(Select)、投影(Project)、重命名(Rename)、分组(Group)、连接(Join)、并集(Union)。这就是经典的SQL。
  • 立方体:切片(Slice)、切块(Dice)、聚合(Aggregate)、上卷(Roll-up)、透视(Pivot)。这在《工程师信息系统》课程中会涉及。
  • :查找(Look up)、导航(Navigate)、规范化(Normalize)、反规范化(Denormalize)。当我们将它们转换为嵌套数据或规范化为扁平数据时,也可以进行选择、投影和分组。
  • :(未在本课详述)遍历(Traverse)、寻找模式(Find patterns)、选择(Select)、排序(Sort)等。
  • 文本:过滤(Filter)、映射(Map)、分组(Group)、聚合(Aggregate)。现在借助向量数据库,我们甚至可以查找相似文档。

语法与查询语言 📝

每种数据形状都有其语法和查询语言。

以下是常见的数据序列化格式:

  • 表格:CSV、序列化脚本。
  • :JSON、XML、BSON(MongoDB使用的二进制格式)、Parquet(若已规范化)、Avro、Protocol Buffers。
  • 立方体:例如金融报表中全球使用的XHTML。
  • :RDF over XML、Turtle等。
  • 文本:就是文本本身。

以下是主要的查询语言:

  • 表格:SQL。
  • :JSONiq(用于JSON)、XQuery(用于XML,是JSONiq的“表亲”)。
  • :SPARQL、Cypher、GQL。图查询语言正在标准化,目前有一个成功的标准化进程,是SQL的扩展,非常出色。
  • 文本:可以是编码查询,也可以想象成与ChatGPT用自然语言对话,因此对于文本,查询语言也可以是自然语言。

技术进步与挑战 🚀

我们见证了数据存储量的巨大进步,如今数据湖的存储容量绝对庞大。甚至出现了DNA存储的进展,可以在信用卡大小的介质上存储PB级数据。与50年代相比,我们的单位体积存储容量增长了数十亿倍。

然而,读取速度(吞吐量)的增长并没有那么快。而写入延迟的改善甚至更小。正因为如此,我们在这门课中构建的所有东西都依赖于并行化。为了解决容量和吞吐量增长不同步的问题,解决方案是并行化。为了解决高延迟问题,解决方案是批处理

我们使用数百MB的块(chunks/blocks),并采用日志结构合并树(LSM-Tree)等架构(如HBase),允许我们在写入系统时一次性写入大量数据,而不是逐位写入。

扩展与数据模型 🌐

正因为如此,我们将树的集合扩展到多台机器上。如今我们拥有数千台机器的数据中心,并将树分布到这些机器上。这就是我们所说的横向扩展(Scale-out)。纵向扩展(Scale-up)指构建更昂贵的机器,横向收缩(Scale-in)则相反。

我们通过切换到树的集合来实现异构性和嵌套性,但这是在单个记录级别。现在每个记录都是一棵树,而不仅仅是扁平记录,并且记录可能具有不同的列和类型。

数据类型的普遍性 🔤

我们看到数据类型或多或少是通用的。无论你看什么新系统,其类型大致相同。我们有字符串、各种数字(整数、小数、双精度数)、二进制数据、日期时间,以及结构化数据(通常是对象和数组)。对象有时也可以是映射(Maps)或结构体(Structs)的形式。

因此,你将能轻松学习任何新的系统、模式语言或格式,因为范式是相同的,你只需要学习新的名称和语法列表。

序列化与反序列化 🔄

我们看到了处理数据的两个方向。你可以序列化数据,即将其存储到磁盘。例如,可以将树序列化为XML、JSON等格式。然后可以使用XML Schema、JSON Schema等进行验证,使用XQuery、JSONiq等进行查询。

记住这些术语:序列化意味着将内存中的东西作为比特存储到磁盘。相反的过程称为解析

数据形状间的映射 🗺️

我们未深入涵盖但很重要的一点是,我们实际上可以在不同数据形状之间进行映射。这意味着我们可以将树存储在表格中,也可以将表格存储在树中。将表格存储在树中实际上非常自然,因为表格只是扁平的树集合。

树很容易存储为图,因为图可以有环,而树没有。所以图比树更通用。图可以存储为表格,因为图只是节点和边的集合。你可以将其存储为一个包含源节点、目标节点和属性的三列表格。

一堆文本也可以存储为表格,甚至可以作为向量数据库存储,即表格的列中包含向量。

我们这样做是因为有时我们只有特定的系统可用,或者某个系统扩展性很好,但找不到处理其他形状的系统。通常,人们在实现第一个原型时,会使用现有系统并实现这样的映射。几年后,他们可能会切换到原生系统,但这当然需要更多工作。图数据库或文档存储的早期版本实际上就是在关系表上实现的,后来才出现了树和图的原生版本。向量数据库也是如此,现在有关系数据库的向量扩展,但我们将转向针对大语言模型的向量集合的原生支持。

架构与数据独立性 🏗️

现在,我希望我已经说服你,由于数据独立性,存储数据的方式应该完全隐藏。用户不应该关心数据在底层的样子。用户看到的是一个数据模型,他们用数据模型思考,看到的是表格、树或图。

执行层也应该被隐藏。你可能使用类似MapReduce或Spark的系统,但最终用户不应该学习Spark或直接使用Spark。Spark或MapReduce通常应该只供工程师实现系统。最终用户应该拥有一个高级的声明式语言,如SQL、JSONiq、用于立方体的MDX、用于图的Cypher等。这样可以使系统对用户更易用。我甚至可以补充说,借助大语言模型,最终可能会走向自然语言交互的方向。

这就是语言格局:在软件工程领域,有汇编代码,然后是Java、C、Python等高级语言。大数据领域也是如此,有低级的“准声明式”语言(如MapReduce API、Spark API),以及高级语言(如SQL、JSONiq)。机器学习领域同样如此,现在有TensorFlow、PyTorch等机器学习库,但这些是API。就像在Spark中可以有更高级的查询语言一样,机器学习领域也有人在设计更高级的语言,让你可以直接声明想要以某种方式训练某个数据集。

分片与复制 🔀

我们看到的另一个重要概念是数据的分片复制。许多大规模系统都这样做。分片是指将数据分成块(GFS中的块、MapReduce中的分割、Spark RDD中的分区),从而将数据分布到多台机器上。

然后是数据的复制,意味着每一个分片或块都会被复制。通常,我们喜欢复制三份,这是一个不错的默认值。你可以根据需求调整复制因子。这凸显了备份的重要性。事实上,最近有新闻提醒我们,跨云提供商进行备份也很重要,而不仅仅是在单一云上。一些拥有更多客户或规模更大的公司,实际上会同时使用多个云提供商,不仅用于备份,也用于生产环境。

批处理 ⏳

批处理是指我们喜欢将待处理的数据分组为批次或块。这就是我们在HBase中的做法,需要将新数据写入HDFS时,我们不是每次发生事件都写入,而是在内存中累积数据,只有当内存满了(因为需要排序)才刷写数据。然后我们不断合并(flush, compact, flush, compact...)。

如何选择技术方案 🤔

学习了所有内容后,你应该能够学习新产品,因为它们的工作原理大同小异。如果你了解Spark,你就会了解等效的产品。学习新的模式、格式等应该很容易。

你现在应该有能力选择使用哪种产品,因为你可以查看其特性,特别是你的数据形状、需要存储的数据量等。你需要PostgreSQL还是数据湖?甚至对于计算机科学家来说,可能不需要设计全新的技术,因为你会发现现有产品已经足够。只有在市场上没有产品能满足你的需求时,才需要考虑设计新产品。

要做出这些决策,你需要审视数据的形状和大小:数据是否能装进你的笔记本电脑?是否无法装进笔记本电脑?我见过很多初创公司在早期成功地将所有数据压缩到单台机器上,这样他们甚至不需要操心云、集群和Spark等。在考虑上云之前,先认真考虑是否可以用单台计算机完成,这非常重要。

你还需要考虑想用数据做什么,这决定了你要构建的索引类型。并且要经常问自己:你是否在重复造轮子?人们经常用Python或Java编写代码,但实际上他们编写的代码已经被无数人写过了。在很多时候,你完全可以利用现有的技术和语言。每当你觉得自己在发明新东西时,先检查一下它是否已经存在。

我坚持强调先使用一台笔记本电脑。通常,只有在你知道如何使用第一台计算机后,才应该开始使用第二台计算机甚至机器集群。你会惊讶于如今这些小型笔记本电脑(现在有8核或16核)的强大能力。

大数据的定义与未来展望 🔮

最后,回顾一下我的定义:大数据是我们为了解决容量、吞吐量和延迟之间的不匹配问题而需要发明全新技术(如数据湖、GFS、MapReduce、Spark等)的领域。

关于未来,我有一些思考。我坚信Spark、MapReduce等并不适合最终用户。最终用户不应该处理这些,他们应该使用更高级的查询语言,这是数据独立性的精神,也是SQL和关系数据库最终实现的目标。

希望人们能认识到数据形状的重要性,因为它关系到你需要投入多少资源和金钱,关系到生产力和性能。选择正确的数据形状对你的系统速度有巨大影响。未来可能会有新的数据形状被发现。事实上,向量数据库就可以算是一种新的数据形状。我还没有把它列为第六种形状,仍然将其归在文本下,但也许有一天我会将其独立出来。这意味着这些形状不是一成不变的,未来可能会出现新的形状。但请记住,对于每一种形状,故事都是相似的:会有语法、数据模型、语言、实现。最初的实现可能建立在其他形状之上,最终会有其自身的原生实现。

查询语言有望实现标准化。表格的SQL已经标准化,图的查询语言也正在标准化。树的情况则更复杂,可以用流行的网络漫画XKCD来概括:我们陷入了竞争性标准的困境。我甚至保留了一个列表,现在可能有30多种语言。时不时地,某家公司的工程师意识到需要一种树查询语言,于是他们发明了一种新的,或者他们想统一现有语言,因为他们认为自己更懂。结果就是又多了一种语言,风险不断增长。这很可惜,因为技术上XQuery已经存在20年了,它是为XML设计的,并且已经标准化。JSONiq只是XQuery的JSON版本,也遵循相同的标准。许多新语言没有规范,只有在线文档、一个实现和一些例子。人们低估了制定一个精确规范、处理所有边界情况和错误所需的多年工作和大量人力。设计一门语言的成本高达数百万美元。因此,公司在发明新语言之前,应该认真考虑是否已有可以采纳的现有标准。功能都是相似的,学习了JSONiq后,你会发现可以轻松学习任何其他类似语言。

随着ChatGPT等技术的发展,我们与数据库对话的趋势越来越明显。几年前我展示《星际迷航:航海家号》中角色与计算机对话的场景时,大家可能会笑,但现在没人会觉得可笑了。这正在成为常态。短期内的发展速度可能比我们想象的要慢,但在未来10年、20年,变化通常比我们想象的要快。

数据可能变得可互换。这与将数据存储在分布式账本(如区块链)上的努力有关。当你考虑像图这样的集合时,它只是节点和边的集合,因此可以轻松地分片和共享。这与数据的可互换性和流动性概念直接相关。这最终与加密货币的原理有关(我并非推荐任何特定的加密货币投资),其原理在于认识到所有权或拥有某物最终是人类之间达成共识的问题。当你们就谁拥有什么达成一致时,就归结为跟踪谁拥有什么,而这最终就是数据。当然,这伴随着风险,因此数据保护和法律方面的话题现在变得非常重要,甚至在欧盟和瑞士等地有法律强制要求。仅仅因为你能做所有这些事情,并不意味着你应该不加小心地去做。你需要记住,数据可能是敏感的,或者可能属于他人。

机器学习和数据库之间有一些有趣的互动。有一个持续的笑话:不知道是机器学习吞噬数据库,还是数据库吞噬机器学习。这个方向的一些有趣发展包括,例如,人们正在构建基于机器学习的索引,而不是哈希索引或B+树索引。索引就是一个机器学习模型,它通过学习告诉你数据在哪里。这是目前正在进行的一些令人印象深刻的工作。例如,MIT正在研究的“学习型索引”。量子计算也在发展中。


本节课总结
在本节课中,我们一起回顾了《工程师的大数据》课程的核心内容。我们从表格的持久重要性出发,探讨了树、图等多种数据形状及其范式。我们深入理解了系统设计的简单性原则、数据形状的组件(语法、查询语言、架构)以及它们之间的映射关系。我们回顾了推动大数据技术发展的根本挑战——容量、吞吐量与延迟的不匹配,以及由此催生的并行化、批处理、分片与复制等核心技术。最后,我们展望了未来,包括查询语言的标准化、自然语言交互、数据可互换性以及机器学习与数据库的融合等趋势。希望这门课程为你构建了一个坚实的大数据知识基础,使你能在面对实际工程问题时,做出明智的技术选型和架构决策。

posted @ 2026-03-26 08:20  布客飞龙II  阅读(0)  评论(0)    收藏  举报