ETHZ-大数据笔记-全-
ETHZ 大数据笔记(全)
1:引言与概述 🚀
在本节课中,我们将开启大数据课程的学习。首先,我们将了解课程的总体安排,并探讨“大数据”这一概念的核心定义、历史背景及其重要性。我们还会通过一些有趣的类比,帮助你理解处理海量数据所面临的挑战。
欢迎与课程介绍
欢迎大家来到课堂,无论是现场、通过Zoom在线参与,还是未来观看录像的同学。今天是本学期的第一堂课,我们将不会立即深入技术细节,而是先为大家准备一份“开胃菜”。我将带领大家回顾数据库的精彩历史,探讨大数据的驱动力,并简要介绍本学期我们将要学习的内容。


从下周开始,我们将正式学习云存储、对象存储等核心材料。此外,本周我们还将复习SQL。许多同学在本科阶段已经学习过关系型数据库管理系统和SQL,但为了确保所有同学,包括那些可能没有相关背景的同学,都能跟上进度,我们将进行复习。因为在课程中,我们会大量使用SQL。


什么是大数据?🤔


我们主要将学习两件事:
- 如何查询海量的、可能并非完全结构化或表格化的数据。
- 解释这些数据处理引擎背后的“魔法”是如何实现的。当你执行一个查询时,结果看似神奇地出现,但这背后是数十年来无数工程师努力的成果。学完本课程,你将了解这些系统幕后的工作原理,甚至可能为下一代系统做出贡献。
课程中会涉及一些关键技术名词,例如Hadoop、Apache Spark,以及由苏黎世联邦理工学院开发的RumbleDB等。

规模的概念:从宇宙到数据 🌌
在大数据中,“规模”是一个核心概念。我们可以通过探索宇宙的尺度来类比理解数据的规模。



从我们身边的米级尺度开始,向外扩展:地球的周长约4万公里(即4万千米)。继续向外,太阳到地球的距离是1.5亿公里,即150吉米(Gigameter)。再到木星的距离则达到了太米(Terameter)级别。整个太阳系的规模大约是拍米(Petameter)级别。


10拍米大约等于1光年。银河系的尺度大约是10艾米(Exameter)或1泽米(Zettameter)。而可观测宇宙的尺度达到了138尧米(Yottameter)。

这个探索过程揭示了一个重要道理:研究宏观宇宙的科学家,同样需要研究微观粒子(如夸克、电子)。这和数据科学非常相似:我们既要审视数据的宏观规模,也要深入探究其微观结构。数据操作与化学中处理分子的方式有异曲同工之妙。
课程背景调查 📊
为了了解大家的背景,我们将使用一个由苏黎世联邦理工学院开发的课堂互动应用EduApp。请通过浏览器访问指定地址或安装该应用,并回答关于你所在专业的问题。
调查结果显示,绝大多数同学来自计算机科学专业,其次是数据科学和计算生物学等专业。
本课程主要面向计算机科学、数据科学和计算生物学硕士项目的学生。其他项目的同学可以选修春季学期开设的《工程师大数据》课程,或者本学期的《工程师信息系统》课程,后者涵盖了数据库的基础知识。




数据科学与物理学的类比 ⚛️


我们可以通过一个类比来理解数据科学:理论计算机科学类似于数学,是基于逻辑和推理的;而数据科学则类似于物理学,它基于从现实世界收集的实验数据(即数据)进行分析,以发现世界的运行规律。因此,数据科学可以说是“计算机科学中的物理学”。

数据库简史 📜
数据管理的历史悠久:
- 史前时期:通过口述故事来传递信息,但容易出错和遗忘。
- 书写革命:数千年前,人类发明了文字并将其刻在泥板上,使信息得以长期保存。
- 最早的“数据库”:一块4000年前的泥板,它实际上是一个具有行列结构的表格,用于记录库存和贸易信息。这表明表格是人类理解数据最自然、最通用的形式之一。
- 印刷术:解决了信息复制和规模化传播的问题。
- 计算机时代:带来了数据处理能力的飞跃。早期计算机使用打孔卡进行编程和数据输入。
- 文件系统:1960年代,人们通过目录和文件在硬盘上管理数据。
- 关系型数据库革命:埃德加·科德(Edgar Codd)提出,应该将数据以表格的形式呈现给用户,而将底层的复杂性隐藏起来。这标志着1970年代初关系型数据库时代的开始。
- 后续发展:出现了键值存储、图数据库、文档存储等多种数据模型,以应对不同场景的需求。



定义大数据:三个V 📈


“大数据”是一个流行词,其核心特征通常用“三个V”来概括:
- 数据量(Volume):数据规模巨大,可达PB(Petabyte)甚至EB(Exabyte)级别。
- 驱动力:存储成本下降、云基础设施普及。
- 数据价值:数据能产生洞察,提升生产力。合并多个数据集(连接操作)能产生“1+1>2”的价值。
- 数据完整性:某些服务(如航班预订)需要完整的数据集才有意义。
- 规模前缀:你需要熟悉表示数据规模的国际单位前缀,从千(Kilo, 10³)到尧(Yotta, 10²⁴)。记住这些前缀的一个技巧是联想希腊数字(如Tera对应“四”,Peta对应“五”,指三零的组数)。

根据课堂调查,大多数人认为数据量达到PB级别时,就开始进入“大数据”范畴,因为这通常意味着数据无法存储在单台机器上。
- 多样性(Variety):数据具有多种形态。
- 表格:行列结构。
- 树形:层次化结构。
- 图形:网络关系结构。
- 立方体:用于商业智能的多维分析。
- 非结构化/文本:如今,向量数据库常被用于处理此类数据,特别是在大语言模型场景中。

- 速度(Velocity):涉及数据生成和处理的速度。这里需要理解三个关键指标:
- 容量(Capacity):存储设备能 hold 多少数据,单位是字节(Bytes)。
- 吞吐量(Throughput):读取或传输数据的速度,单位是字节/秒(Bytes/s)。
- 延迟(Latency):开始接收数据前需要等待的时间,单位是秒(s)。


通过对比1956年(5MB容量,600ms延迟)和2024年(26TB容量,4ms延迟)的硬盘,我们发现:容量的增长远远超过了吞吐量的增长,而延迟的降低相对有限。

这种不均衡的进化导致了核心问题:如果今天要读完整个硬盘的数据,所需时间相比几十年前呈爆炸式增长。这正是驱动大数据技术发展的根本矛盾。
大数据的解决方案 💡

为了解决上述矛盾,主要发展出两种思路:
- 并行化(Parallelization):将巨大的任务(如读取那本“未来的书”)分割成小块,分发给数百万台机器或人员同时处理。这样,总处理时间可以大幅缩短。数据中心使用大量机器正是为了并行处理。
- 批处理(Batch Processing):将大量操作集中起来,一次性高效处理,这将在后续课程中详细讲解。
因此,大数据可以定义为为了解决容量、吞吐量和延迟进化不均衡问题而诞生的一系列技术方案。

大数据的应用领域 🌍


大数据技术已广泛应用于各个领域:
- 科学领域:例如欧洲核子研究中心(CERN)使用大数据技术处理粒子对撞产生的海量数据,希格斯玻色子的发现本质上就是一个数据库查询问题。
- 天文学:系统性地编录天空中的所有天体,寻找系外行星和生命迹象。
- 基因组学:编辑DNA、合成蛋白质(如mRNA疫苗),甚至将DNA作为一种极具潜力的未来数据存储介质。

课程内容与团队介绍 👨🏫👩🏫



本课程聚焦于数据科学和大数据中的数据库层面。我们将学习:
- 数据存储与编码:如S3、HDFS、JSON、Parquet、Avro等格式。
- 数据模型:如HBase、Cassandra等键值存储,以及DataFrame的严格定义。
- 数据处理流程:第一代的MapReduce,资源管理,第二代的Apache Spark。
- 数据管理:如MongoDB等文档数据库。
- 查询语言:SQL、JSONiq等,区分查询语言与API(如Pandas是API)。
我们的教学团队由多位助教组成,他们将负责组织练习课、提供支持并参与考试相关工作。
此外,我们还有一个特殊的“学生”——Ethel。它是一个基于教科书训练的大型语言模型,你可以通过ETH账户登录相关平台向它提问课程内容相关问题。

课程安排与考核 📅

- 每周讲座:周二14:00-16:00。
- 练习课:从下周开始,每周三和周五,每次2小时(本周无练习课)。我们将扩容以满足需求。
- 自学与实践:预计每周投入3-4小时进行阅读、练习和实验。
- 期末考试:3小时笔试,最多60道题(选择题、填空题等)。会提供往年试题参考。
- 加分机会:本学期共有25次在线小测验(Module Quizzes)。每通过一次可获得0.01分的加分。这些加分会在最终成绩计算前加入,提高你获得更好成绩的可能性。

本节课总结:我们一起开启了大数据课程的学习。我们了解了课程的目标是学习处理海量、多样数据的方法及其背后原理。通过宇宙尺度的类比,我们理解了“规模”的意义。回顾了从泥板到现代数据库的演进历史,并深入探讨了定义大数据的三个V(Volume, Variety, Velocity),特别是容量与吞吐量进化不均衡所带来的核心挑战及其解决方案(并行化与批处理)。最后,我们预览了课程的主要内容、认识了教学团队,并清楚了课程的时间安排与考核方式。下节课我们将开始更具体的技术学习。
2:引言 (2/2) 📚
在本节课中,我们将继续介绍《大数据》课程的相关信息,包括课程教材、实践练习工具、在线交流平台以及课程材料获取方式。
课程教材 📖
上一节我们介绍了课程的基本信息,本节中我们来看看课程教材的安排。
几年前我开始这门课程时,在互联网上没有找到合适的教科书。因此,我参考了大量书籍和章节,并编写了一本教材。由于学生普遍希望有一本教科书,我便为这门讲座编写了一本,并将其发布在网上。你可以直接下载PDF版本,无需任何费用。
对于喜欢纸质版教材以便打印和阅读的人,我也将其放在了亚马逊上。这是截至八月底的第二版。如果你喜欢印刷版,可以选择购买,但由于使用了彩色墨水印刷,价格会稍高一些。不过,你完全不必购买,直接下载PDF即可。
PDF版本的一个好处是,当你指出错误时,我可以持续更新。我会修正错误,并在致谢部分添加指出错误的学生姓名。如果你想留下名字,就请帮忙找出错误。这个在线版本将始终保持最新,而纸质书则难以做到这一点。
目前教材尚未完全完成。图数据库章节的前半部分已经完成,后半部分仍在编写中;数据立方体章节也尚未加入。我希望能在本学期将其添加到在线教材中。

以下是获取教材的步骤:
- 访问提供的URL。
- 下载PDF文件。它存放在ResearchGate上,这是一个存放研究论文的网络平台,但你可以通过链接直接访问。
教材大约有400多页,厚度可观。
实践练习与Docker 🐳
接下来,我们谈谈实践练习。我们将使用一个名为Docker的工具。
Docker本质上是一种解决助教团队头疼问题的方法,即无需在不同操作系统上支持软件的安装。我们的做法是将所需的所有软件打包到一个Docker文件中。你可以在笔记本电脑上下载Docker桌面应用程序,然后运行一个简单的命令,它就会为你创建好所需的环境。这就是我们将要使用的方式。
其中一个Docker镜像是考试Docker,它包含了过去所有考试的数据集。此外,还会为练习创建其他Docker镜像供你们使用。
如果遇到任何问题,请及时告知我们。我们使用Docker已有一段时间,对大多数人来说应该都能正常运行。早些时候,苹果M系列芯片的电脑可能有些问题,但新版本的Docker应该已经解决了。所以,请尽管尝试,如果本周我们提供了相关材料,你可以提前测试以确保在你的笔记本电脑上运行正常。
关于“自带笔记本电脑”的规定:从法律角度我不太确定,但技术上,第一年的硕士生可能有义务配备笔记本电脑。我不确定第二年的学生是否也有此要求。无论如何,即使你是第二年学生,如果没有笔记本电脑,也可以在ETH的计算机房运行相关软件。
看起来每个人都有笔记本电脑,这很好。如果有任何问题,请告诉我们。基本上,Docker是我们将使用的主要工具,所有内容都会打包在里面。Docker这个名字来源于航运中用于全球运输货物的集装箱。
在线交流平台 💬


现在,我们来介绍在线交流平台。

请访问这个页面:chat.ethz.ch。点击右侧的“学生聊天”,使用你的ETH用户名和密码登录。正常情况下,你应该能看到一个名为“Town Square BDHS 2024”的房间。这是本讲座的聊天室。
你可以在这里交流、分享表情包。前几年,这里分享了很多关于大数据的表情包,这完全没问题。我知道你们可能也使用其他服务器,如Discord等,学生使用那些也完全可以,但我们不直接参与其中。如果你想与助教和我们互动,这里就是合适的地方。
此外,还有Moodle论坛,你也可以在那里提问。助教团队会告知他们在哪个渠道保证回复,可能是这个聊天室,也可能是Moodle,但两者你都可以使用。
如果无法使用,请告诉我们。我手动邀请了440人,可能有些邀请没有成功送达。请检查是否对您有效,如果无效请告知。
顺便提一下,既然我看到聊天室有活动,我应该告诉你们,你们可以随时提问并打断我,因为这是讲座,你们在学习。没有所谓的愚蠢问题。在Zoom上也可以这样做:如果你在Zoom聊天中键入问题,我们的助教负责人Zika会代表你打断我,向我转述问题,然后我可以回答。这样,讲座就不仅是面向讲堂里的人,也是互动的。如果你害羞,也可以像这样举手,或者在Zoom上打字,举手也是完全可以的。
对于在YouTube上观看我们视频的人,你可以通过发送电子邮件给助教团队或在Moodle论坛发帖来提问。
顺便说一句,我不知道是否有人在考前几周,比如一月份,以倍速观看视频来备考。我们在这里是为了帮助你们,所以请给我们发邮件,查看Moodle论坛。当然,我希望你们大多数人在本学期跟随讲座学习,这对大脑长期保持知识要好得多。
课程材料与问答 🗂️
最后,关于课程材料。
课程目录上有一个链接,所有材料都放在课程的Moodle页面上。在那里,你可以找到讲义的PDF幻灯片、练习、以及Ehel的链接(如果尚未提供)。这是整个讲座互动的中心点。

如果点击没有反应,那可能是因为内容正在上传。我想第一部分就到这里。到目前为止有什么问题吗?
(关于学生聊天室是否工作的提问与解答环节)
如果聊天室对某些人无效,我们会想办法解决。如果你们中有几个人能私下给我发邮件说明情况,以便我调查,这可能与我生成ID的方式有关。请给我们发邮件,我可能会收到200封邮件,但我们会尽力解决,让所有人都能连接。
还有其他问题吗?在Zoom上,课程也是如此。如果没有问题,我将继续讲座的第二部分。

(讲师说明需要停止并重新开始录制,以便在YouTube上保持内容组织有序。)


本节课中我们一起学习了课程教材的获取与特点、实践练习将使用的Docker工具、在线交流平台的使用方法以及课程材料的集中获取位置。这些是保障课程顺利进行的基础设施,请务必熟悉。
3:经验教训 (1/3) 📚
在本节课中,我们将回顾关系型数据库的核心概念。这些知识虽然源于几十年前,但对于理解现代大数据技术至关重要。通过学习历史经验,我们可以避免重复过去的错误,并加速对新知识的掌握。


上一节我们介绍了大数据的基本概念,本节中我们来看看关系型数据库的基础知识。






数据独立性:现代数据库的基石 🧱





埃德加·科德被认为是现代关系型数据库之父。他的核心贡献是提出了数据独立性的概念。







数据独立性是指将数据的逻辑视图(如表)与物理存储和实现细节分离开来。用户只需与表格交互,就像使用Excel等电子表格软件一样,而无需关心数据在硬盘上是如何存储或实现的。







这个原则可以用一个简单的分层模型来表示:
- 逻辑层:用户看到的表格和SQL查询。
- 物理层:数据在磁盘上的存储方式(如C++、Java实现)。


数据独立性的美妙之处在于,你可以在不同的物理层上实现相同的逻辑层。无论是单机硬盘、数据中心集群,甚至是DNA存储,只要逻辑层保持不变,用户体验就是一致的。这个原则将贯穿我们整个学期的学习。






聚焦表格:关系模型的核心 📊




在众多数据形态(树、表、立方体、图、非结构化文本)中,本节课我们专注于表格。表格的处理可以概括为四个层次:
- 存储层:以比特形式存储数据。
- 计算层:如CPU,负责处理数据。
- 模型层:描述表格是什么。
- 语言层:如SQL查询语言。





模型层是数据独立性发挥作用的地方,它清晰地划分了逻辑路径和物理路径。





定义一个数据模型需要两个核心组件:
- 数据结构:描述数据看起来是什么样子(例如,一个表格)。
- 数据操作:定义可以对数据做什么(例如,查询、筛选、连接)。





这类似于烹饪:你需要定义食材(数据结构)和烹饪方法(数据操作,如煎、炒、煮)。








数据独立性的演进:从单机到大数据 🚀





数据独立性如何工作?让我们看看它的演进:
- 20世纪70年代:在单台计算机、单个硬盘和CPU上实现关系表和SQL。
- 近10-20年:保持顶部的逻辑层和模型层不变,但将底部的存储和计算层替换为数据中心。用户体验不变,但性能大幅提升。
- 大数据时代:我们延续了这一思想,继续在更复杂的物理架构上构建相同的逻辑抽象。


在本科数据库课程中,你主要学习的是单机架构。而本课程将探索如何将同样的逻辑模型应用于分布式的大数据环境。





解剖一张表:基本构成 🧩






一张表看起来非常直观。它也被称为关系、集合或关系表。





以下是表格的核心组成部分:
- 行:水平方向的一组值。也称为记录、业务对象、元组、实体或文档。
- 列:垂直方向的一组值。也称为属性、字段或键(注意与“主键”区分)。
- 单元格:行与列的交点,存储单个值。
- 主键:一个或多个能唯一标识表中每条记录的列。例如,社会保障号、车牌号(可能由“州”和“号码”两列共同构成)。在NoSQL中常被称为
_id。



关系的数学定义与更优定义 🧮





为什么叫“关系”型数据库?一种常见的教科书定义是:一个表是域的笛卡尔积的一个子集,即一组元组的集合。
- 元组:从每个域中取一个值构成的有序序列。
- 关系:一组元组的集合。






但这个定义有局限性(例如,它隐含了列的顺序,而实际数据库中列的顺序不重要)。我更喜欢另一种定义,它更能自然地过渡到大数据领域。





一个关系表可以定义为:
- 模式:一组属性的集合,例如
{PID, Last, First, Country}。 - 外延:表的内容,即一组元组的集合或列表。






元组本质上是一个将属性映射到值的函数。例如,元组 {PID: 2, Last: “Ramanujan”, First: “Srinivasa”, Country: “India”}。





关于“一组元组”的语义,有三种可能:
- 集合:元组无序且不重复。(纯关系模型理论)
- 包:元组无序但允许重复。
- 列表:元组有序。(实际数据库系统中更常见)




我们可以简单地说:一个表是行的集合。





关系表的三大完整性规则 ✅


并非任何元组集合都能构成有效的关系表。必须满足三条完整性规则:


以下是三条核心的完整性规则:
- 关系完整性:表中所有元组必须具有完全相同的属性集。这确保了表结构的统一性。
- 原子性完整性:每个单元格中的值必须是原子的,即不可再分的基本数据类型(如字符串、数字、日期),而不能是另一个表、对象或数组等嵌套结构。
- 域完整性:同一列(属性)下的所有值必须属于相同的数据类型(如整型、字符串型)。



这三条规则的交集,定义了经典的、严格的关系型数据库世界(如SQL)。然而,在本课程中,我们将逐一打破这些规则,探索NoSQL等更灵活的数据模型。




关系代数:操作表格的“数学” 🔢


对于满足上述三条规则的“规整”表格,我们有一套形式化且强大的操作工具,称为关系代数。就像数字有加减乘除,表格也有其专属的操作。



以下是一些核心的关系代数操作:
- 选择:根据条件筛选出特定的行(类似于Excel中的筛选)。
- 投影:选择特定的列,隐藏其他列。
- 分组:根据某列的值将行分组,并对组内数据进行聚合计算(如求和、求平均)。这是数据分析中极其重要的操作。
- 连接:将两个或多个表基于相关列合并在一起。




我们将在后续课程中详细探讨这些操作。




总结 📝


本节课我们一起学习了关系型数据库的基石。我们首先理解了数据独立性这一核心设计原则,它分离了逻辑视图与物理存储。接着,我们剖析了关系表的构成(行、列、主键),并从数学和实用角度探讨了其定义。我们明确了构成有效关系表的三大完整性规则:关系完整性、原子性完整性和域完整性。最后,我们简要介绍了用于操作表格的关系代数。这些经典概念为我们后续学习大数据技术,特别是理解如何以及为何要突破这些传统规则,奠定了坚实的基础。








下节课,我们将继续深入,探讨数据库设计中的“范式”理论。
4: 关系数据库与SQL的经验教训 (2/3) 📚


在本节课中,我们将继续学习关于关系数据库管理系统和SQL的核心概念。我们将回顾关系代数的基本操作,探讨数据库设计理论中的范式,并介绍SQL语言的基础知识。本节课旨在为后续的大数据处理学习打下坚实基础。



关系代数回顾 🔄

上一节我们介绍了关系模型的基础。本节中,我们来看看关系代数中用于操作表格的几个核心原语。

以下是关系代数中最主要的几种操作:

- 选择:从表格中选取满足特定条件的行。
- 投影:从表格中选取特定的列。
- 分组/聚合:将表格按某列分组,并对每组计算聚合值(如计数、求和)。
- 排序:根据一列或多列的值对表格中的行进行重新排列。排序操作仅在列表语义下有意义,因为顺序是重要的。
- 笛卡尔积:将两个表格
R和S的所有行进行组合,生成|R| * |S|行的新表。公式表示为:R × S。此操作需谨慎使用,因为它可能产生巨大的结果集。 - 连接:连接是笛卡尔积的一个子集,只保留满足特定匹配条件的行。例如,等值连接只保留两个表中指定列值相等的行。连接是表格操作中非常重要但相对昂贵的操作。
数据库设计理论与范式 🏗️

理解了如何操作数据后,我们需要考虑如何有效地组织数据。数据库设计理论提供了一系列最佳实践,以避免设计错误。



如果数据组织不当,可能会出现各种异常,例如更新异常(数据重复存储导致更新不一致)。

核心概念



- 函数依赖:如果表中列
A的值能唯一确定列B的值,则称B函数依赖于A。例如,如果知道“课程代码”总能确定“课程名称”,则存在函数依赖:课程代码 → 课程名称。 - 超键:能唯一确定表中所有其他列的一组列。
- 候选键:最小的超键(即不能再移除任何列)。
- 主键:从候选键中选择一个作为表的主要标识符。
范式


范式是衡量数据库设计规范化程度的一系列标准。以下是一些关键范式:


- 第一范式:要求表中的每个字段都是原子的,不可再分。即不允许表中嵌套表。
- 第二范式:在满足第一范式的基础上,要求任何非主属性都必须完全依赖于整个候选键,而不能依赖于候选键的一部分。
- 第三范式:在满足第二范式的基础上,要求任何非主属性都不传递依赖于候选键。即非主属性不能依赖于其他非主属性。
- BCNF范式:比第三范式更严格。要求表中存在的任何函数依赖,其决定因素都必须包含候选键。

学习这些范式通常需要数周时间,并涉及复杂的数学证明。

范式在大数据中的角色转变 🚀
对于之前未接触过范式的同学,这里有一个好消息:在本大数据课程中,我们将抛弃这些严格的范式约束。



在需要频繁更新的事务型数据库(如网站后台)中,遵循范式至关重要。然而,在大数据分析场景中,我们主要任务是读取和分析数据,而非频繁更新。因此,我们可以接受反规范化的设计。




反规范化意味着:
- 将数据存储在包含大量信息的大宽表中。
- 允许表中存在函数依赖。
- 甚至可能打破第一范式,允许嵌套结构(如表中有表,形成树形结构)。


这样做的好处是能让数据更易于在集群上分布和查询。我们将在后续课程中详细探讨反规范化的具体实践。



SQL:声明式查询语言 🗣️


SQL是与数据库交互的核心语言。在大数据技术栈中,SQL的身影也随处可见。
SQL的定位

在软件工程中,我们有低级语言(如汇编)和高级语言(如Java)。在数据库查询领域,也存在类似划分:
- “汇编”级别:指通过API查询数据的语言,如Pandas、MongoDB查询语法或Spark API。
- “高级”级别:指像SQL这样的声明式、函数式查询语言。SQL允许用户专注于“要什么”,而不是“如何做”。

SQL简史与特点

SQL起源于20世纪70年代初的IBM。它是一种声明式、基于集合的语言,允许用户一次性操作数十亿行数据。SQL的发音有“S-Q-L”和“sequel”两种,源于早期的商标问题。


SQL基础语法


SQL的核心是SELECT ... FROM ... WHERE ...结构。



以下是SQL查询中常见的子句及其作用,它们必须按固定顺序书写:

- SELECT:指定要输出的列(投影)。
- FROM:指定数据来源的表。
- WHERE:指定过滤行的条件(选择)。
- GROUP BY:指定分组依据的列。
- HAVING:对分组后的结果进行过滤。
- ORDER BY:指定结果排序方式。
- LIMIT:限制返回的行数。
- OFFSET:指定返回结果开始的行(用于分页)。



记忆口诀:SELECT FROM WHERE GROUP BY HAVING ORDER BY LIMIT OFFSET。
多表操作

- 集合运算:
UNION(并集)、INTERSECT(交集)、EXCEPT(差集)。 - 连接:
JOIN ... ON ...:根据指定条件连接两个表(θ连接)。NATURAL JOIN:自动根据两个表中同名的列进行连接,需谨慎使用。LEFT/RIGHT/FULL OUTER JOIN:外连接,可以保留未匹配的行,并用NULL值填充缺失部分。
嵌套查询

与Haskell等函数式语言类似,SQL支持嵌套查询,即一个查询的结果可以作为另一个查询的输入。



关于NULL值
NULL表示缺失或未知的值。在关系理论中,对NULL的处理存在两种观点:
- 将其视为“无值”,这会破坏关系完整性。
- 将其视为域中的一个特殊值,这可以保持理论一致性。
在实践中,NULL非常有用,但需要小心处理,我们将在后续关于JSON的课程中详细讨论。


总结 📝

本节课我们一起学习了关系数据库的核心知识。我们回顾了关系代数的基本操作,了解了数据库设计中的范式理论及其在大数据时代的局限性(反规范化)。最后,我们深入介绍了SQL语言的历史、特点及基础语法,它是连接用户与数据系统的强大声明式工具。

掌握这些基础概念,将为我们后续学习分布式大数据处理技术奠定坚实的基础。
5:经验总结与SQL回顾 🗂️
在本节课中,我们将继续复习SQL的核心概念,并初步了解云存储。课程将从几个互动问题开始,然后回顾关系型数据库的基础知识,包括数据独立性、SQL查询结构、ACID属性等,最后引出大数据领域将如何打破这些传统规则。
欢迎与课程安排 👋
欢迎大家来到课堂,也欢迎在Zoom上以及未来的同学们。

今天的课程安排是:我们将继续SQL复习,然后开始探讨云存储。
互动问答环节 ❓
首先,我们通过几个问题来回顾和引入一些概念。
以下是关于数据基本形态的问题。哪一种不属于数据的基本形态?
- 立方体
- 表格
- 树
- 圆形
答案是圆形。我们会在云存储中看到圆形和环形的概念,但这并非数据的基本形态。立方体是数据分析中常见的形态,而表格绝对是基础。
下一个问题:对一张表进行反规范化后,通常会得到什么形态的数据?是立方体、树、图还是文本?
答案是树。这正是我们打破关系型数据库规则、进入NoSQL世界时会得到的结果。本课程将花大量时间探讨这一点。

课程工具与环境设置 ⚙️
关于Magic Box(可能指Docker环境),如果大家在使用中遇到问题,请在专门的Docker支持频道中反馈,助教团队会提供帮助。常见解决方案包括删除镜像、容器和卷,然后重新运行 docker-compose up。

此外,今年我们引入了JupySQL的语法高亮功能,这是由一位硕士生实现的改进,能让SQL关键字更清晰地显示。大家使用的Docker环境即将更新为Jupyter Lab版本,它比传统的Jupyter Notebook在文件导航上更方便。我们也会提供往期考试的完整Notebook环境,供大家提前熟悉。

关于考试时间,之前口误说成了8月,正确时间是在1月20日至2月14日之间,具体日期将在11月或12月公布。

积分测验说明 📝

我们有一个奖励积分系统。你共有25次测验机会,每次测验通过可获得0.01分的考试加分。

积分计算方式是:从考试原始分数(介于4到6分之间)开始,加上你获得的奖励积分,然后四舍五入到最近的0.25分。因此,奖励积分越多,你最终获得更高ETH成绩的概率就越大。
请注意,这些计分测验与不计分的练习册是独立的。测验通常在Moodle上进行,为选择题。如果你对评分有异议,可以与助教团队讨论。为了鼓励理解而非盲目尝试,通常每周有两次测验机会,每次允许尝试1到2次。如果需要更多尝试,需邮件联系助教团队。

核心概念回顾:关系型数据库与SQL 🔄



上一节我们通过问答互动引入了数据形态的概念,本节我们来系统回顾关系型数据库的核心思想。
数据独立性

这是关系型数据库一个非常重要的概念,其核心思想是将物理存储层与逻辑表示层解耦。用户看到和操作的是表(逻辑层),而底层的存储方式(物理层)——无论是在数据中心、量子计算机还是其他介质上——对用户是完全隐藏的。

关系表的约束

当我们定义关系表(由元组/记录组成的集合)时,遵循三条核心约束:
- 关系完整性:所有元组拥有相同的属性集,从而能整齐地排列成表格形状。
- 域完整性:每一列中的数据都具有相同的数据类型(例如,一列全是整数,另一列全是日期)。
- 原子性完整性:表中不能嵌套表(即不允许“表中的表”)。
这三条规则共同构成了传统SQL的美丽世界。而当我们打破这些规则时,就进入了NoSQL的领域。
SQL查询骨架

以下是必须掌握的SQL基本查询结构,其执行顺序如下:
SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT ... OFFSET ...
它包含了选择(WHERE)、投影(SELECT)、分组(GROUP BY)、分组后筛选(HAVING)、排序(ORDER BY)和分页(LIMIT/OFFSET)等所有操作。大家需要通过大量练习来熟悉它。

SQL的功能远不止于此,它支持查询嵌套。你可以在FROM子句或WHERE子句中嵌套另一个SELECT查询。一个典型用例是计算“最大值对应的记录”(argmax),例如在WHERE子句中嵌套一个求最大值的子查询。这允许构建非常复杂、甚至长达数百行的查询。

关系代数与三值逻辑

在数学上,关系代数常用希腊字母表示,如 σ (sigma) 代表选择,γ (gamma) 代表分组,π (pi) 代表投影。SQL就是对这些关系代数操作的程序化实现。数据库管理系统会将你的SQL查询转换为一个迭代器树(也称查询计划),并可能进行重排优化以提升速度,但由于数据独立性,你无需关心这些底层细节。
SQL中的逻辑是三值的:真(TRUE)、假(FALSE)和未知(UNKNOWN)。这符合常识,例如 FALSE OR UNKNOWN 的结果是 UNKNOWN,而 TRUE OR UNKNOWN 的结果总是 TRUE。这与 NULL 值的处理密切相关。

DML、DDL与索引
- DML:数据操作语言,用于查询和操作数据(如SELECT, INSERT)。
- DDL:数据定义语言,用于定义数据的结构或模式(如CREATE TABLE, ALTER COLUMN)。

索引是数据库中用于加速查询的“魔法”结构,类似于书籍末尾的索引。它通过创建额外的数据结构,能将查询时间从数小时缩短到毫秒级,对用户透明且功能强大。
OLTP 与 OLAP
数据库有两种主要使用场景:
- OLTP:在线事务处理。特点是写入密集,例如电商网站不断处理新订单、更新库存。
- OLAP:在线分析处理。特点是读取密集,例如下载一个数据集后主要进行查询分析,而不修改数据。在大数据中常见,这种场景允许我们对数据进行反规范化以优化读取性能。



ACID 属性
传统关系型数据库(如PostgreSQL)通过ACID属性保证可靠性:
- 原子性:事务中的所有操作要么全部完成,要么全部不完成。例如,ATM取款时,扣款和出钞必须同时成功或失败。
- 一致性:事务必须使数据库从一个一致状态转变到另一个一致状态。所有预定义的数据约束(如账户余额不能为负)在事务前后都必须成立。
- 隔离性:并发执行的事务彼此隔离,互不干扰。即使有成千上万人同时订票,每个用户也感觉自己是唯一操作者。
- 持久性:一旦事务提交,其对数据的修改就是永久性的,即使系统故障也不会丢失。
在大数据领域,由于规模巨大,完全保证ACID属性变得非常困难,我们将看到范式的转变。
迈向大数据:打破规则的三个维度 🚀
上一节我们回顾了关系型数据库的经典规则,本节我们来看看大数据如何从三个维度上突破这些限制。
传统SQL数据库在处理以下情况时会面临挑战,而这正是大数据的用武之地:
- 海量行:表有数百万、数十亿甚至数万亿行。
- 海量列:表有数百万列,且数据非常稀疏(大部分单元格为空)。
- 深度嵌套:数据具有复杂的嵌套结构(“表中的表”)。
本课程后续的每周内容,基本上都将围绕这三个维度中的一个或两个展开:
- 对象存储、分布式文件系统 → 解决海量行的问题。
- 列式存储 → 解决海量行和海量列的问题。
- 数据模型、文档存储 → 解决深度嵌套的问题。
- 大规模文件处理 → 解决海量行的问题。
- 嵌套数据查询 → 解决深度嵌套的问题,并引入如JupySQL等新的查询语言。


总结 📚
本节课中,我们一起学习了以下内容:
- 回顾了关系型数据库的核心理念:数据独立性和关系表的三大约束。
- 掌握了SQL查询的基本骨架和嵌套查询的强大能力。
- 理解了数据库的两种主要场景:OLTP(写密集)和OLAP(读密集)。
- 学习了保证数据可靠性的ACID属性(原子性、一致性、隔离性、持久性)。
- 探讨了大数据时代突破传统规则的三个方向:处理海量行、海量列和深度嵌套数据。


这为我们后续深入探讨云存储、NoSQL数据库及各类大数据技术奠定了坚实的基础。
6:云存储 (1/4) 💾

在本节课中,我们将要学习如何存储海量数据。我们将从一个具体问题出发:如何存储和处理像宇宙地图这样包含数百TB数据和数亿个文件的数据集。这标志着我们从单机关系型数据库转向分布式存储和处理系统的开始。


从单机到多机:存储的挑战
上一节我们介绍了关系型数据库。这些数据库运行在单台机器上。单台机器的存储容量有限,例如,可能最多只有30TB。这对于存储273TB的宇宙地图数据来说远远不够。
所以,我们首先得出的结论是:我们需要多台机器。





继承与革新:关系型数据库的遗产

虽然我们将转向一个全新的、不同于传统关系型数据库的系统,但好消息是,我们仍然可以复用这些系统的许多核心思想。我甚至可以说,我们之前学到的99%的知识都可以被复用。

以下是我们可以继续使用的概念:
- 操作:选择(Selection)、投影(Projection)、分组(Grouping)、连接(Joining)等操作在处理海量数据时依然存在。
- 结构:具有行、列、主键的表结构依然可以存在。
- 规则:数据库设计的三大范式(1NF, 2NF, 3NF)依然有意义,你可以选择遵守或打破它们。
- 语言:像SQL这样的声明式查询语言依然适用。
- 优化:查询计划的优化、执行引擎等概念依然至关重要。



这意味着你在本科阶段学习的关于关系型数据库的知识,在大数据领域依然有价值。








然而,有一些东西我们将无法完全带入这个新领域:
- 完整性约束:关系完整性、域完整性、原子性完整性等约束可以被舍弃。我们将考虑没有这些约束的系统,这通常被称为 NoSQL。
- 范式化:第二范式、第三范式、BCNF等范式化的要求可以放宽。
- 数据结构:我们将处理异构数据(没有固定模式的数据)和嵌套数据(表中有表),这可以被视为打破了原子性完整性。我们还将大量使用反规范化技术。






系统架构:从单体到模块化


关系型数据库被认为是单体式的,即它是一个安装在单台机器上的单一软件。我们将以模块化的方式重建一个功能类似的系统,但让它运行在数据中心上。
以下是我们要重建的整个技术栈组件:
- 存储
- 语法与数据模型
- 验证
- 处理
- 索引
- 数据存储
- 查询
- 用户界面
在本学期,我们将自底向上地学习这些组件,最终构建出一个功能相近(略有差异)的系统。今天,我们首先聚焦于存储。

数据存储的两种范式


在存储海量数据时,主要有两种范式。


第一种范式:数据库导入
这种范式类似于你在 PostgreSQL 中的操作。你需要使用 ETL(提取、转换、加载)过程将数据导入数据库。之后,数据库系统负责管理数据在磁盘上的存储方式,这对用户是透明的。这是传统的存储方式。
第二种范式:数据湖
这是一种更现代的方法。在数据湖中,你直接将数据(如 CSV、XML、JSON 文件)存储在文件系统上,将其视为文件和目录。然后,你可以直接查询这些数据,而无需先将其导入另一个系统。这种方法也被称为 In-situ 查询。
数据湖非常适用于只读场景。然而,修改数据会变得非常困难,原因如下:
- 一致性维护困难:直接操作文件容易导致数据不一致。
- 随机写入性能差:大数据系统擅长流式写入大文件,但不擅长在大型文件中随机修改一小部分数据。
- 并发控制复杂:处理多用户同时访问和修改需要复杂的事务机制,而这正是传统数据库的强项。
注意:存在像 Delta Lake 这样的系统试图解决数据湖的更新难题,但这本身是一项复杂的技术。
本节课,我们将重点关注数据湖范式,即如何“倾倒”数据并直接在其上进行操作。
总结


本节课中,我们一起学习了面对海量数据存储挑战时,从单机关系型数据库转向分布式系统的必要性。我们了解到,许多关系型数据库的核心概念(如SQL、查询优化)依然可以复用,但需要放弃严格的完整性约束并接受反规范化。我们介绍了构建模块化大数据系统技术栈的蓝图,并重点对比了数据库导入和数据湖两种核心数据存储范式,分析了数据湖在只读场景下的便利性以及在数据修改方面的挑战。从下一节课开始,我们将深入探讨具体的存储系统。
7:云存储 (2/4) 💾




在本节课中,我们将学习云存储的基本概念,特别是对象存储模型。我们将探讨如何通过横向扩展(Scaling Out)来构建大规模存储系统,并深入了解亚马逊S3服务的逻辑模型、关键特性以及相关的分布式系统理论。



从本地存储到云存储



上一节我们介绍了数据存储的基本形式。本节中我们来看看存储方式如何从单机演进到云环境。


早期的数据存储在单个硬盘上,采用文件存储模式。数据以文件和目录的层次结构进行组织,这构成了文件系统的基础。



这种硬盘存储的另一个特点是,内容存储在块中。每个块的大小大约为4KB。

存储可以有不同的范围:
- 本地存储:仅限于本地机器访问。
- 网络附加存储:硬盘连接到网络,可从多个位置访问。
- 广域网存储:例如整个机构的网络。
然而,直接将硬盘连接到大型网络(如校园网)供所有人访问是不可行的,因为它无法支持高并发访问。

另一个限制与文件数量有关。处理数千或数百万个文件尚可,但处理数十亿个文件则会超出单机能力。这在进行机器备份时尤为明显。


对象存储:横向扩展的解决方案
为了解决处理海量文件的问题,我们借鉴了电池的设计思路:将大量小型、廉价的组件组合起来。
我们的第一个云存储设计将抛弃传统的层次结构,这被称为对象存储。

以下是对象存储的核心设计:
- 抛弃层次结构,采用扁平的文件(对象)列表。
- 使用简单的数据模型:键值模型。




在键值模型中:
- 键 是对象的唯一标识符(ID)。
- 值 是对象本身(可以视为文件)。


我们可以拥有数万亿个这样的键值对。当我说“对象”而非“文件”时,主要是术语上的区别:“文件”通常与层次结构关联,而“对象”更常用于扁平的键值对列表。


这是一个全局键值模型,意味着它是一个世界范围的服务(如亚马逊S3),你可以通过ID从任何地方获取对象。











扩展策略:纵向扩展 vs. 横向扩展

当系统变慢时,通常有两种解决方案。
纵向扩展:购买更昂贵、性能更强的计算机(更多CPU、更多内存)。这就像是让教授买一台更好的电脑。

横向扩展:保留现有机器,但购买更多台机器,可能是成千上万台。

纵向扩展的反面是纵向缩减,横向扩展的反面是横向缩减。在云服务中,你可以动态地增加或删除机器。




从成本角度比较两者,你会发现:
- 横向扩展的成本是线性的。
- 纵向扩展的成本是非线性的,且很快会达到物理极限。


例如,想要一台拥有100TB硬盘和1PB内存的计算机,其成本将是天文数字,因为这需要顶尖的研发和可能数十年的时间,甚至由于物理限制而无法实现。

而横向扩展则没有问题:只需购买数百万台普通机器即可。我们的数据中心里已经有数百万台机器。
因此,横向扩展是更现实且成本更低的解决方案。
然而,在考虑横向扩展之前,首要任务是编写更高效的代码。优化代码、分析性能、减少内存使用是一门艺术和科学。很多时候,人们发现他们的需求实际上可以通过优化,在一台机器上得到满足。不应该在尚未充分利用第一台计算机时,就急于使用第二台。


当然,在本讲座中,我们将主要探讨如何利用成千上万台机器进行横向扩展。


数据中心硬件概览
要进行横向扩展,我们需要进入称为数据中心的设施。
以下是数据中心集群的大致规模:
- 机器数量:单个数据中心通常有数万到十万台机器。十万似乎是一个物理极限,主要受电力消耗和冷却能力的制约。
- 单台服务器:一台服务器(或称为节点、机器)可能拥有多达128个甚至200个CPU核心。
- 存储:单个机械硬盘(HDD)的容量上限目前约为26TB,并在不断增长。固态硬盘(SSD)的容量通常较小。
- 内存:单台服务器的内存可以从8GB一直到24TB。如今内存容量与硬盘容量达到同一数量级,这使得内存数据库越来越流行。
- 网络:数据中心内部网络带宽可高达200 Gb/s(比特每秒)。


在数据中心,计算机不是放在桌子上,而是堆叠在标准的机架中。机架高度单位称为“机架单元”。标准机架通常有42个机架单元的高度。服务器、磁盘存储单元、网络交换机等设备都按照标准尺寸设计,以便堆叠。节点安装在机架中,机架安装在房间里,房间之间通过网络交换机连接。这种物理布局对性能有重要影响。


亚马逊 S3 服务模型
亚马逊最初是一家在线书店。他们发现销售有季节性高峰(如圣诞节),需要更多服务器来处理订单,但在淡季这些服务器又会闲置。于是他们产生了将闲置计算资源出租给其他公司的想法,这便催生了亚马逊网络服务。



AWS提供了对计算资源和抽象服务的访问。其中一项核心抽象服务就是 S3。
S3的逻辑模型非常简单:
- 你有存储桶。
- 每个存储桶有一个全局唯一的桶ID。
- 在桶内,你可以存放对象。
- 每个对象通过对象ID在桶内进行访问。
这是一个全球性服务,桶ID + 对象ID 的组合保证了全球唯一性,你可以通过它来读写对象。




对象的最大尺寸是5TB。这个限制暗示了对象可能存储在一台机器上。
每个账户默认最多可创建100个存储桶。
S3提供了一些服务级别保证:
- 持久性:高达99.999999999%(11个9)。这意味着,每存储1000亿个对象,每年最多只会丢失一个。
- 可用性:例如99.99%的可用性意味着一年中累计不可用时间不超过1小时。
- 性能:通常通过“多少个9的情况下,响应时间低于X毫秒”来描述。例如,“99.9%的请求响应时间低于10毫秒”。S3的实际访问延迟大约在几百毫秒。



这些保证被写入服务级别协议(SLA),主要是为了明确责任和法律依据,建立在信任关系之上。








CAP 定理与 PACELC 框架

在大数据领域,我们通常无法提供完整的ACID保证,而是采用不同的权衡框架,即 CAP定理。





CAP定理指出,在分布式系统中,以下三者不可兼得:
- 分区容错性:当网络发生分区(如断网)时,系统仍能继续运行。
- 可用性:每个请求都能收到响应(不保证是最新数据)。
- 一致性:所有节点在同一时刻看到的数据是相同的(强一致性)。


当网络分区发生时,你必须在 可用性 和 一致性 之间做出选择:
- 选择 可用性:系统继续服务,但不同分区可能返回不同版本的数据(最终一致性)。
- 选择 一致性:系统停止服务,直到分区恢复,数据同步完成。





需要警惕的是,有些系统声称三者兼备,这通常是在假设“网络分区永远不会发生”的前提下,实际并不可信。
CAP定理的一个扩展是 PACELC 框架:
- 如果发生分区,你必须在 可用性 和 一致性 之间选择。
- 如果没有分区(系统正常运行),你则需要在 延迟 和 一致性 之间权衡。
- 例如,LinkedIn的评论功能可能选择低延迟,你发布评论后刷新页面不一定立即看到,但体验流畅;而强一致性则可能带来更高的延迟。







REST API:系统访问接口


REST 是一种架构风格,它利用HTTP协议实现机器对机器的通信。对于数据库系统,除了通过终端、驱动程序或Notebook连接,还可以通过REST API发送查询并接收响应。





REST的核心是使用 URI 来标识资源。一个URI的格式如下:
scheme://authority/path?query#fragment
例如:https://www.example.com/path/to/resource?key=value#section

在S3中,每个桶和对象都关联一个URI,你可以通过类似 s3://bucket-name/object-key 或 https://bucket-name.s3.amazonaws.com/object-key 的URI来访问它们。
一个REST请求由 方法 和 URI 构成。主要的HTTP方法有:
- GET:获取资源的表示,不应产生副作用。
- PUT:上传资源到指定URI。PUT之后,GET应能取回相同内容。
- DELETE:删除指定URI的资源。
- POST:最通用的方法,通常用于创建资源或执行复杂操作。

S3提供了完整的REST API,你可以使用这些方法来创建/删除桶、上传/下载/删除对象等。

需要注意的是,S3不是文件系统,它是扁平的。对象ID中的斜杠 / 只是一个普通字符,没有任何层次含义。不过,用户界面为了友好,可能会将其显示为目录结构。

S3的常见用途包括:
- 托管静态网站(成本低廉)。
- 存储大型数据集。数据集通常被分割成多个“块”,每个块作为一个对象存储,所有块共同构成完整数据集。



总结




本节课我们一起学习了云存储的基础。我们从单机文件存储的局限性出发,引出了通过横向扩展和对象存储模型来解决海量数据存储问题的思路。我们深入探讨了亚马逊S3服务的键值对逻辑模型、其高持久性与可用性保证,并理解了分布式系统中的核心权衡框架——CAP定理和PACELC。最后,我们介绍了通过REST API访问云服务的方式。这些概念是构建和理解现代大规模数据存储系统的基石。
8:云存储 (3/4) 💾


在本节课中,我们将继续学习云存储,重点是键值存储。我们将了解它与对象存储的区别、其核心设计原理以及如何实现高可用性和可扩展性。

对象存储回顾与对比

上一节我们介绍了S3这类对象存储。它是一种扁平的键值对模型,可存储高达5TB的对象,适用于存储图片、视频等大型文件。

然而,对象存储对于需要毫秒级响应的数据库类应用来说太慢了。为此,我们引入了键值存储。
键值存储同样基于键值对模型,但存储的对象要小得多(例如上限为400KB),并且设计目标是实现毫秒级的低延迟访问。
以下是对象存储与键值存储的对比:


- 对象存储 (如S3):存储大对象(至5TB),延迟较高(数百毫秒),适用于存档、备份、流媒体。
- 键值存储 (如DynamoDB):存储小对象(至400KB),延迟极低(几毫秒),适用于实时查询、会话存储、元数据管理。






键值存储的核心挑战与设计目标

键值存储需要满足几个关键要求:

- 可扩展性:能够将数据分布到数千台机器的集群中。
- 弹性:支持动态添加或移除机器,系统能无缝适应。
- 对称性:所有机器运行相同的软件,扮演相同的角色。
- 去中心化:没有单一的协调节点,是一个对等网络。
- 异构性:能够容纳具有不同内存和CPU能力的机器。
这与区块链网络有相似之处,但关键区别在于键值存储环境是受信任的(所有机器都在你的数据中心内),因此无需应对恶意节点。


一致性哈希:数据分布的核心算法

为了实现上述目标,键值存储使用了一种称为一致性哈希的算法来决定哪个机器存储哪个键值对。

其工作原理如下:
- 哈希环:想象一个由
0到2^128 - 1的数字首尾相连构成的环。 - 机器定位:集群中的每台机器通过哈希其ID(或随机生成)在环上获得一个随机位置。
- 键定位:对于每个要存储的键,也计算其哈希值,该值会落在环上的某个点。
- 数据归属:环上顺时针方向遇到的第一个机器节点,就是负责存储该键值对的机器。
公式表示:
对于键 K,找到机器节点 N,满足:hash(K) <= hash(N),且 N 是环上顺时针方向满足此条件的第一个节点。
这种设计的好处是,当机器加入或离开时,只需要移动环上一小部分数据,而不是全部重新分布。
处理机器故障:冗余与参数


如果机器崩溃,其负责的数据就会丢失。为了解决这个问题,我们引入冗余。




系统不再只将数据存储在环上顺时针的第一个节点,而是存储在顺时针的前 N 个节点上。N 称为复制因子。这意味着每个数据都有 N 个副本。








由此,我们引出了三个关键参数:
- N:每个数据项的副本总数。
- W:写操作成功前,必须确认写入的副本数量。
- R:读操作成功前,必须读取的副本数量。
为了保证数据的一致性(最终一致),系统通常要求 R + W > N。这意味着读和写的副本集合必然有重叠,从而能检测到最新的写入。









处理负载不均衡:虚拟节点
简单的一致性哈希可能导致负载不均衡:某些机器可能负责环上很大一段范围,存储更多数据;或者性能强的机器和性能弱的机器承担相同负载。
解决方案是引入虚拟节点。









- 每台物理机器不再对应环上一个点,而是对应多个虚拟节点(令牌)。
- 环上分布着大量虚拟节点,每个虚拟节点负责环上一小段范围。
- 虚拟节点再被分配给物理机器。性能强的机器可以分配更多虚拟节点,从而承担更多负载。




这样,数据分布的粒度更细,负载更容易在各机器间均衡,也便于在机器加入或离开时更精细地迁移数据。





系统访问流程
作为一个用户或客户端,如何访问这个去中心化的系统?









以下是典型的访问流程:



- 连接入口:客户端通过负载均衡器随机连接到集群中的任一节点。
- 协调节点:接收请求的节点根据一致性哈希算法,确定负责请求键的“协调节点”(通常是偏好列表中的第一个节点)。
- 执行操作:
- 对于写操作:协调节点将数据发送给偏好列表中的前
W个节点,收到W个确认后向客户端返回成功。 - 对于读操作:协调节点向偏好列表中的前
R个节点请求数据,采用诸如“最新时间戳”或“向量时钟”等机制确定最新值后返回给客户端。
- 对于写操作:协调节点将数据发送给偏好列表中的前




键值存储的局限
尽管键值存储非常强大,但它也有其局限性:
- 仅支持点查询:只能通过精确的键来查找值,不支持范围扫描(如查找键在
A到Z之间的所有数据)。 - 无数据完整性内置保障:需要应用程序自己处理业务逻辑的一致性。
- 无高级安全模型:假设运行环境是可信的。
总结


本节课我们一起学习了云存储中的键值存储。

- 我们首先对比了对象存储和键值存储的不同适用场景。
- 然后深入探讨了键值存储为实现高可用、可扩展和弹性而面临的设计挑战。
- 我们学习了一致性哈希这一核心算法,它如何将数据分布到环上,并优雅地处理节点的加入和离开。
- 为了容错,我们引入了数据冗余(复制因子N) 和读写参数 W 与 R,通过
R + W > N的策略在可用性和一致性之间取得平衡。 - 为了解决负载不均问题,我们了解了虚拟节点的概念。
- 最后,我们概述了客户端与这个去中心化系统交互的流程,并指出了键值存储的主要局限性。

键值存储是大数据基础设施中用于实时访问的关键组件,理解其原理对于设计可扩展系统至关重要。
9:云存储 (4/4) ☁️

在本节课中,我们将要学习分布式键值存储系统如何处理网络分区和一致性冲突,特别是通过向量时钟(Vector Clocks)这一机制。我们还将回顾云存储的核心概念,并比较不同云服务提供商的特点。
课程回顾与最佳实践 🎯
上一节我们介绍了分布式哈希表(DHT)和一致性哈希。本节中,我们来看看在分布式系统中处理故障和数据一致性的高级机制。
首先,我们来回顾一个关于系统扩展的最佳实践问题。
问题: 当系统性能不足时,最佳实践的执行顺序是什么?
- 先尝试横向扩展(Scale Out),不行再优化代码,最后纵向扩展(Scale Up)。
- 先尝试优化代码,然后横向扩展,最后纵向扩展。
- 先尝试纵向扩展,然后横向扩展,最后优化代码。
- 顺序无关紧要,可以尝试所有方法。
正确答案是第二种:先优化代码,再横向扩展,最后纵向扩展。

以下是具体解释:
- 优化代码: 首先应在本地(如笔记本电脑)使用一小部分数据(如1/100的数据集)优化代码效率。如果优化后代码能在单机上运行,这是最经济的方法。
- 横向扩展(Scale Out): 如果数据无法在单机容纳,则扩展到多台廉价机器上并行处理。许多云服务商(如Amazon AWS)可以自动根据负载动态调整机器数量。
- 纵向扩展(Scale Up): 为单机增加更多内存、CPU等资源。这是最后考虑的手段,因为成本增长通常是非线性的(例如,将内存从32GB升级到320GB的成本远高于10倍)。
这是一个通用指导原则,实践中需要根据具体情况(如成本、现有硬件、任务复杂度)进行批判性思考。例如,如果只是略微超出旧笔记本的能力,租用云机器一小时可能比购买新笔记本更划算。技术上,增加一台新机器是横向扩展,替换一台更强大的机器是纵向扩展。
处理节点故障与弹性 🛡️


现在回到我们的一致性哈希环。通过引入“虚拟节点”或“令牌”(Tokens),我们可以让一台物理机器对应哈希环上的多个点(令牌)。
公式表示:
假设机器 M_i 的性能权重为 w_i,那么它可以被分配大约 w_i 个令牌,随机分布在哈希环上。
这样做的好处是:
- 负载均衡: 令牌分布更均匀,数据分布也更均匀。
- 弹性: 高性能机器可以分配更多令牌,承担更多负载;低性能机器分配较少令牌。
- 容错: 当一台机器(如图中的绿色节点)失效时,它的令牌所负责的数据区间会被环上顺时针方向的下一个有效节点接管。由于数据通常在不同机器的多个令牌上有副本(复制因子 > 1),数据不会丢失,新节点可以从其他副本同步数据。
- 扩容: 添加新机器时,只需将现有的一部分令牌重新分配给新机器即可。
CAP定理与最终一致性 ⚖️
上一讲我们提到了CAP定理,它指出在网络分区(Partition)发生时,系统只能在一致性(Consistency)和可用性(Availability)中二选一。



在像Dynamo这样的系统中,选择的是 AP(可用性和分区容错性),牺牲了强一致性,实现了最终一致性。







这意味着在网络分区期间,系统不同部分的数据版本可能发生分歧,形成“分支”。这打破了线性时间轴的概念,引入了类似时空的结构。为了解决这种多版本冲突,我们需要一个能表达部分顺序(Partial Order)的机制,这就是向量时钟。





向量时钟:追踪因果历史 🕰️


向量时钟由苏黎世联邦理工学院(ETH)的教授提出,被Amazon Dynamo等系统广泛采用。它不是单一计数器,而是为系统中每个可能处理数据的节点维护一个计数器序列。









工作原理:
- 每个数据版本都关联一个向量时钟。
- 当节点
N_i首次创建或更新某个数据时,它将向量时钟中对应自己的分量c_i加1。 - 向量时钟记录了数据版本被哪些节点修改过的历史。


代码/结构表示:
一个向量时钟可以表示为一个字典或元组:
VC = {‘N1’: 2, ‘N2’: 1, ‘N3’: 1}
这表示数据被节点N1处理过2次,被节点N2和N3各处理过1次。
比较规则(形成偏序关系):
对于两个向量时钟 VC_a 和 VC_b:
- 如果
VC_a在所有节点上的计数器值都大于或等于VC_b,并且至少在一个节点上严格大于,则VC_a>VC_b(VC_a是更新的版本)。 - 如果
VC_a和VC_b在某些节点上互有大小,则两者无法比较。这表示发生了冲突,即数据出现了分支版本。

在网络分区恢复后,系统可能会同时发现两个无法比较的向量时钟(即两个“最大元素”),这时就需要解决冲突。

Dynamo操作流程示例 🔄


让我们通过一个动画示例(此处用文字描述)来理解Dynamo的 get 和 put 操作如何利用向量时钟。



系统设置: 复制因子为3,键 K1 由节点 N1, N2, N3 负责。
- 初始写入:
put(K1, value=A)- 请求由
N1处理。N1创建向量时钟{N1:1},存储(A, {N1:1})。 N1将数据复制到N2,N3。
- 请求由
- 正常更新:
get(K1)返回(A, {N1:1})。用户据此发起put(K1, value=B, context={N1:1})。N1处理,验证上下文匹配最新版本。创建新向量时钟{N1:2},存储(B, {N1:2})并复制。- 后续
get(K1)会收集所有版本,但通过比较向量时钟,只返回最新的(B, {N1:2})。
- 网络分区与冲突:
- 用户通过
N1再次put(K1, value=C, context={N1:2}),创建(C, {N1:3})。 - 此时发生网络分区,
N1与N2、N3断开。(C, {N1:3})无法复制到N2,N3。 - 另一个用户从仍可访问的
N2获取数据,得到(B, {N1:2}),然后发起put(K1, value=D, context={N1:2})。 N2处理,创建(D, {N1:2, N2:1})并在分区内复制到N3。
- 用户通过
- 分区恢复与冲突解决:
- 网络恢复。此时系统存在多个版本:
A({N1:1}),B({N1:2}),C({N1:3}),D({N1:2, N2:1})。 - 用户发起
get(K1)。系统收集所有版本,并寻找“最大元素”。 C和D的向量时钟无法比较({N1:3}vs{N1:2, N2:1}),因此两者都被作为冲突版本返回给用户。- 冲突由用户(应用层)解决。用户决定新值应为
E。
- 网络恢复。此时系统存在多个版本:
- 提交解决方案:
- 用户发起
put(K1, value=E, context=merge({N1:3}, {N1:2, N2:1}))。合并上下文取各节点计数最大值,得到{N1:3, N2:1}。 - 假设由
N1处理,它将自己的计数加1,最终写入(E, {N1:4, N2:1})。这个版本在偏序关系上大于所有之前版本,成为新的唯一最大元素,冲突得以解决。
- 用户发起




系统可以定期清理那些不是最大元素的旧版本数据(如A和B)。


主要云提供商对比 🌐


最后,我们简要对比一下主要云服务提供商在存储服务方面的风格:





- Amazon Web Services (AWS): 倾向于提供大量单一功能、细粒度的服务。例如,S3(对象存储)、RDS(关系数据库)、EC2(虚拟机)、Route 53(DNS)等,各有专门的服务。用户通过控制台组合使用这些服务。
- Microsoft Azure: 风格略有不同,提供一些功能更集成、更统一的产品。例如,Azure Cosmos DB 作为一个多模型数据库,可以处理文档、键值、图、宽列等多种数据形态,代表了将不同数据模型统一起来的研究方向。


本节总结 📝
本节课中我们一起学习了分布式键值存储如何实现高可用性和最终一致性。
- 扩展最佳实践是优先优化代码,其次横向扩展,最后纵向扩展。
- 通过虚拟节点(令牌) 可以实现更好的负载均衡和弹性伸缩。
- 根据CAP定理,Dynamo类系统选择AP,允许网络分区期间出现数据不一致。
- 向量时钟是追踪数据版本因果历史、识别冲突的核心机制。它形成一个偏序集,可以判断版本的新旧或检测冲突。
- Dynamo的
get和put操作需要携带上下文(即向量时钟),用于验证和协调更新。冲突的最终解决通常需要用户/应用层干预。 - 不同的云提供商(如AWS和Azure)在服务设计哲学上有所不同,有细粒度单功能服务和集成多功能服务之分。


至此,关于云存储的核心内容就介绍完毕了。
10:分布式文件系统 (1/4) 📂


在本节课中,我们将要学习分布式文件系统的基本概念、设计需求以及它与之前所学存储模型的区别。我们将从单机数据库扩展到集群存储,并探讨如何设计一个能够处理海量数据、容忍机器故障且适合大数据分析场景的文件系统。

从单机到集群存储 🔄
上一节我们介绍了关系型数据库和单机存储。现在,我们来看看如何通过横向扩展,将存储从单台机器扩展到整个集群。
在横向扩展的模型中,我们可以存储海量的对象或键值对。需要注意的是,键值模型既适用于对象存储(如S3),也适用于键值存储(如Dynamo)。但需注意,虽然两者都使用键值模型,但“键值存储”通常特指存储较小值、具有低延迟特性的系统。人们通常不将S3称为键值存储,因为其存储的对象过大。不过,其底层模型依然是键值模型,即你为数据分配一个键。
这种存储可以作为驱动器或数据集存储。当你开始查询和提取数据信息时,通常会经历以下流程:原始数据可能来自传感器或其他来源,这是你积累的初始数据。随后,你执行查询并得到衍生数据,这是通过你自己的代码对原始数据进行修改后得到的结果。

现在的问题是,衍生数据应该存放在哪里?尤其是当它也有PB级别时。答案是,它可以存回与原始数据相同类型的存储介质中。例如,原始数据在S3上,衍生数据也可以写回S3。我们稍后在讨论数据处理系统时会再回到这一点。核心思想是,你从云存储(例如)读取数据,并将结果写回云存储。



“大数据”的不同形态 📊
但是,“大数据”也有不同的形态。为了简化理解,你可以拥有海量的大文件,也可以拥有大量的超大文件。这两种情况对你将要使用的系统架构有实际影响。


以下是两种形态的对比:

- 海量大文件:指的是数十亿个TB级别的文件。例如,Netflix作为全球服务存储在S3上的剧集,这就是对象存储。文件数量巨大,因为全球用户都使用同一个存储介质。
- 大量超大文件:指的是数百万个PB级别的文件。文件本身可以大得多,但文件数量相对较少。这种文件存储不会像S3那样是全球性的存储系统,而是你部署在自己集群上、由自己负责的系统。这是你本地的文件存储,可以存储数百万个PB级文件。
重申一下,顶部(海量大文件)是我们上周讨论的内容,基本上是Amazon S3或对象存储。我们今天要看的是文件存储,处理的是大量超大文件。




存储模型的演变:从对象到文件系统 🗂️

为了在脑海中清晰地整理这些概念,我们进行如下对比:
当我们使用Amazon S3时,它采用扁平化的键值模型,没有层级结构。用户界面中的“/”并非模型的一部分,键名本身就是扁平的。这被称为对象存储,因为数据是高达5TB的“黑盒”。
而我们现在要看的系统,将拥有一个类似传统文件系统的层级结构,包含目录和文件。它不再是扁平的键值模型,并且将涉及块存储的概念,就像你笔记本电脑硬盘上的4KB数据块一样,块在这里会变得很重要。


分布式文件系统的起源与需求 🏗️

这一切实际上始于21世纪初,大约2004年,在Google。他们开始注意到在存储数据时遇到了麻烦,当时没有技术能够将数据存储在更大的驱动器上。他们达到了单个驱动器的极限,于是思考:我们需要一种方法来模拟一个巨大的驱动器,但将其分散到我们的机器集群中。通过横向扩展,我们可以拥有1000台机器,并需要模拟出一个单一文件系统的外观,让用户感觉不到本地计算机文件系统和集群文件系统之间的差异。他们称之为Google文件系统(GFS)。

然而,这并不像看起来那么简单,因为有些情况发生了变化。例如,你的笔记本电脑可能偶尔会崩溃,有些人可能因此丢失过数据。但是,当你拥有数百、数千甚至数万台机器的集群时,问题不再是“它是否会故障”,而是“它何时会故障”。机器会持续故障,数据中心有全职员工四处奔波更换故障部件。这意味着在设计系统时,必须将这些因素考虑在内。
基于我们最近几周所学,你可能已经有一些想法,例如复制数据。如果将数据存储在多个位置,就可以从故障中恢复。这一点我们稍后会详细讨论。
核心设计目标 🎯
我们想要一个系统,即使机器偶尔故障,它也能持续工作。因此,首先需要一种监控故障的方法,以便感知错误。其次,需要一种能够自动从这些错误中恢复的方法,因为故障随时都在发生。


这被称为容错性,意味着它是自动的,不需要人工干预来替换故障部件。系统应该持续工作,当人们用新硬盘替换旧硬盘后,系统就能继续工作,甚至拥有更多硬盘。恢复必须是自动的,无需人工干预。


以下是系统需要满足的其他要求,主要涉及访问模式:
- 随机访问 vs. 顺序扫描:在你的笔记本电脑上,你可以使用电子表格软件或编辑文件,可以访问硬盘上的任何位置。硬盘是为随机访问设计的。但对于我们用于大数据分析的分布式文件系统,我们真的需要随机访问吗?实际上,在大数据场景中,我们通常不需要随机访问。大数据意味着我们下载数据,将其存放在某处,然后并行处理,最后输出结果。当你写入查询输出时,你不需要随机访问,你只是从头到尾线性地写入一个新文件。所以,对于用于数据分析的系统,答案是我们不需要随机访问。这实际上很棒,因为如果不需要随机访问,系统设计会更容易。
- 高效顺序读写:我们设计的系统在随机访问方面会表现很差,效率低下。但我们希望它在扫描数据或从头到尾写入文件时非常高效。
- 高效追加写入:我们还希望它能高效地追加数据。你有一个文件,想在末尾写入更多内容。想象一下日志文件,你只是不断在末尾追加。这也是我们想要的功能。




为什么需要追加写入?考虑一下数据来源的用例:数据可能来自传感器、对撞机(如LHC)、日志等。这些数据持续流入系统,每秒都在产生新数据。因此,你希望你的文件能够被追加写入,不断添加新数据。一旦数据被写入,它就是不可变的,但你仍然希望添加新数据。
考虑带有温度和压力等传感器的物联网设备,你每秒都在向数据中添加新内容。中间数据也将以这种方式工作,我们只是不断写入输出。在学习了MapReduce等内容后,你会更好地理解这一点。


并发与性能考量 ⚙️
现在,我们有了更多需求。但关于扫描数据和追加数据的整个操作,必须在数百人同时进行时仍然有效。

如果你有数百人(也许是所有收集数据的传感器)同时写入,它必须能工作。请注意,我说的是数百,而不是数百万或数十亿。这同样不是一个像S3那样全球数十亿人访问的服务。这只是一个服务,比方说,一个公司规模的集群。每个公司都有自己的这种集群。连接到这个系统的将是公司的员工,例如数百人。操作需要是原子性的,你要么成功追加数据,要么完全不执行。
接下来是性能考量。还记得两周前我提到的容量、吞吐量和延迟吗?我展示了从50年代到今天,容量增长了很多,吞吐量增长了一些但没那么多,而延迟下降得更少。吞吐量和延迟的概念在考虑大数据系统时至关重要。
作为提醒:
- 吞吐量:想象数据流经网络电缆的比特数,即单位时间内流动的数据量。
- 延迟:从发起请求到收到第一个响应之间的等待时间。
通常,其中一个是瓶颈。要么是延迟问题(下载很快但等待时间长),要么是吞吐量问题(等待时间短但数据传输慢)。对于我们想要构建的分布式文件系统,在正常巡航模式下,我们希望看到的是数据在电缆中流动,这是我们希望看到的。我们不希望看到电缆上没有数据流动,所有人都在等待数据,那将是延迟问题,不是我们想要的。直观地说,我们希望通过让尽可能多的比特流过电缆来优化,以充分利用网络带宽。
这只是对我两周前展示内容的回顾。最终,我们将通过这个系统做两件事:
- 并行化:以解决容量和吞吐量之间的差距。
- 批处理:以解决延迟和吞吐量之间的问题。
关于并行化,你将在MapReduce中看到;关于批处理,我们将在HBase中看到。届时,我会花时间详细讲解其工作原理。

Hadoop:开源实现 🐘
最后简单提一下,Hadoop是相同理念的开源版本。在Google开发相关技术大约两年后,Yahoo启动了Hadoop项目(名字来源于一只玩具象)。它始于2006年,由三个组件构成:
- GFS的开源版本称为 HDFS。
- MapReduce的开源版本称为 Hadoop MapReduce。
- Bigtable的开源版本称为 HBase。
它们只是名称不同,但本质上是开源实现。我们将使用这些开源实现进行学习。


总结 📝

本节课中,我们一起学习了分布式文件系统的基本概念。我们了解了从单机存储扩展到集群存储的必要性,区分了对象存储(海量大文件)和分布式文件存储(大量超大文件)的不同应用场景。我们探讨了分布式文件系统的核心设计目标:容错性、适合顺序读写和追加写入的访问模式、支持数百并发以及优化吞吐量。最后,我们简要介绍了Hadoop作为相关技术的开源实现。下一节,我们将深入探讨HDFS的具体架构和工作原理。
11:分布式文件系统 (2/4) 📂






在本节课中,我们将继续学习 Hadoop 分布式文件系统。我们将深入探讨 HDFS 的设计逻辑、核心概念以及其物理实现,特别是文件如何被分割成块(blocks)以及这些块如何分布在集群中。
逻辑模型:文件与块
上一节我们介绍了 Hadoop 是 Google 文件系统的开源实现。本节中,我们来看看 HDFS 的逻辑模型。
HDFS 的逻辑模型与传统的文件系统类似,它包含目录和文件的层次结构。然而,与对象存储(如 S3)将整个对象作为一个大块存储不同,HDFS 采用块存储。





在 HDFS 中,文件被分割成固定大小的块(blocks)。这是为了支持远超单机容量的超大文件(例如 PB 级别),并为后续的并行计算(如 MapReduce 和 Spark)提供更简单的数据抽象层。

核心概念可以表示为:
文件 -> 块序列
每个文件本质上是一个有序的块序列。







块大小的设计考量




那么,HDFS 的块应该是多大呢?这与吞吐量(throughput)和延迟(latency)的权衡有关。


想象一下复制大量小文件与复制单个大文件的区别。复制大文件时,数据流是连续的,我们主要受吞吐量限制。而复制大量小文件时,需要在每个文件间频繁寻址,大部分时间花在了等待(延迟)上,实际传输数据的时间很短。
HDFS 的设计目标是最大化吞吐量,最小化延迟的影响。因此,它的块尺寸远大于本地文件系统(如 4KB)。HDFS 的默认块大小是 128 MB。
选择这个大小的原因如下:
- 足够大:使得通过网络传输数据时,大部分时间花在数据传输上,而不是建立连接和寻址上,从而避免被延迟限制。
- 足够小:可以均匀地分布在集群的许多机器上,实现良好的负载均衡和并行性。如果块太大(如 5TB),每个机器上的块数会很少,不利于并行处理。
物理架构:主从模式
理解了逻辑模型后,我们来看看 HDFS 的物理实现。HDFS 采用集中式架构,这与 Dynamo 的对等网络不同。
以下是该架构中的核心组件:
- NameNode(名称节点):作为集群的“大脑”或协调者。它管理文件系统的命名空间(目录和文件结构)以及文件到数据块的映射关系。它本身不存储文件数据。
- DataNode(数据节点):作为工作节点。它们负责在本地磁盘上实际存储数据块。
- 客户端(Client):用户或应用程序通过与 NameNode 交互来执行文件操作(如创建、读取),然后直接与相应的 DataNode 进行数据传输。


NameNode 的职责
NameNode 作为中央协调者,主要维护以下元数据:
- 文件系统命名空间:整个目录和文件的树状结构。
- 文件到块的映射:记录每个文件由哪些块(通过块ID标识)按顺序组成。
- 块的位置信息:跟踪每个数据块副本存储在哪些 DataNode 上。默认情况下,每个块会有三个副本存储在不同的机器上,以实现容错。
如果某个块的副本数量因机器故障而低于设定值(如从3个变为2个),NameNode 会发起复制指令,在另一台机器上创建新的副本。




DataNode 的职责



DataNode 的职责相对简单:
- 在本地文件系统上存储和检索数据块。
- 定期向 NameNode 发送心跳信号,报告其状态和存储的块列表。
- 执行来自 NameNode 的指令,如块的创建、删除和复制。






需要理解的是,在 DataNode 的本地视角,每个 HDFS 块就是一个普通的文件。例如,一个 72MB 的块在本地磁盘上就是一个 72MB 的文件,不会浪费 128MB 的完整块空间。这体现了数据独立性——逻辑上的“块”在物理层就是本地文件。





问答与深入探讨
在讲解过程中,同学们提出了一些关键问题,帮助我们更深入地理解 HDFS:





关于单点故障:NameNode 是单点故障吗?是的,这是一个关键问题。如果 NameNode 宕机,整个文件系统将无法访问,因为元数据丢失了。HDFS 通过Secondary NameNode 和 High Availability 机制来解决这个问题,这将在后续课程中详细讨论。




HDFS 与 S3 的对比:为什么有了 S3 还需要 HDFS?两者都是优秀的存储层,但各有侧重。S3 是云服务,易于使用且可扩展。HDFS 通常部署在自有或租用的集群上,其最大优势在于数据本地性——计算任务(如 MapReduce)可以直接在存储数据的同一台机器上运行,避免了网络传输开销,从而速度更快。


块在本地磁盘的存储:HDFS 块是如何存储在 DataNode 本地磁盘的?DataNode 机器上的磁盘通常使用 ext4 或 XFS 等标准文件系统格式化。HDFS 块作为普通文件写入这些磁盘。因此,从操作系统层面看,一个 128MB 的 HDFS 块在磁盘上可能由许多个 4KB 的物理磁盘块组成,但这对于 HDFS 用户来说是透明的。



总结




本节课中我们一起学习了 HDFS 的核心架构。我们了解到:
- HDFS 采用块存储模型,将大文件分割成固定大小(默认为 128MB)的块,以支持超大文件和并行计算。
- 其设计通过增大块尺寸来优化吞吐量,减少延迟的影响。
- 物理上采用主从(Master-Slave)架构,由 NameNode 管理元数据,DataNode 存储实际数据块,并通过多副本(默认3份)机制实现容错。
- 我们探讨了 HDFS 与 S3 的差异,并指出了 NameNode 单点故障等挑战,为后续课程埋下伏笔。

理解 HDFS 的这种以吞吐量为导向、数据分块、集中式元数据管理的设计思想,是掌握整个 Hadoop 生态及其并行计算框架的基础。
12:分布式文件系统 (3/4) 📚

在本节课中,我们将深入学习HDFS的物理架构、通信协议以及数据读写流程。我们将重点关注客户端、名称节点和数据节点之间如何交互,并探讨数据块复制的策略。



物理架构回顾 🏗️
上一节我们介绍了HDFS的基本数据模型。本节中,我们来看看其物理架构的具体实现。
HDFS的数据模型可以概括为:目录和文件以树形层次结构组织,包含访问控制。文件被分割成固定大小的块(Block),默认大小为128 MB,远大于个人电脑上的块大小。


这些文件块(在Google GFS中称为Chunk,在HDFS中称为Block)会被复制并分散存储到不同的数据节点上。默认情况下,每个块有三个副本,这些副本之间没有主从之分,只是三个完全相同的副本。HDFS没有像云存储那样的5TB单文件大小限制,实践中可以通过创建多个小于此限制的文件来规避。
HDFS采用集中式架构,包含一个名称节点(NameNode)和多个数据节点(DataNode)。名称节点是协调者,负责管理整个系统的元数据。
在名称节点内部,主要存储三部分信息:
- 文件命名空间:整个目录和文件的树形层次结构以及访问控制信息。这是系统的瓶颈,限制了HDFS无法像S3那样成为全球性服务,通常用于公司内部。
- 文件到块ID的映射:将每个文件映射到其组成块的ID列表(块ID为64位)。
- 块的位置信息:记录每个块(通常有三个副本)存储在哪些数据节点上(即数据节点的IP地址)。

需要说明的是,“数据节点”通常既指运行该进程的物理机器,也指进程本身。严格来说,数据节点是运行在机器上的一个进程。一台机器上通常只运行一个数据节点进程。





通信协议 🤝

理解了基本架构后,我们来看看系统中的各个组件是如何通信的。


整个系统包含名称节点、数据节点和客户端。客户端可以是外部的笔记本电脑,也可以是数据中心内的一台机器(用户通常通过SSH连接到其中一台机器进行操作)。客户端与名称节点和数据节点通信,而实际的数据流只发生在客户端与数据节点之间,以及数据节点相互之间,名称节点不处理任何实际数据。

以下是系统中使用的关键协议:
- 客户端协议:用于客户端与名称节点之间的通信。
- 数据节点协议:用于名称节点与数据节点之间的通信。
- 数据传输协议:用于实际的数据块传输。
- 复制管道:用于在数据节点之间复制数据块,这与之前学习的块存储复制协议类似。
接下来,我们详细探讨每一个协议。
客户端协议
客户端协议是用户执行任何操作时的第一入口。与Amazon Dynamo的随机负载均衡不同,HDFS中客户端总是首先连接到唯一的名称节点。
客户端向名称节点发送请求,例如创建目录、删除文件、读取文件等。名称节点则响应客户端所需的信息,例如数据块的位置、块ID等。这个协议通常通过Java实现(使用远程过程调用RPC),但也提供REST API等其他接口。
数据节点协议
数据节点协议管理着名称节点与数据节点之间的通信。在计算机通信中,总是由客户端发起连接到服务器。在这里,数据节点是客户端,名称节点是服务器。数据节点会定期主动连接名称节点。
以下是数据节点与名称节点交互的关键操作:
- 注册:新数据节点加入集群时,会向名称节点注册。
- 心跳:每3秒,数据节点向名称节点发送心跳,确认其存活。如果名称节点长时间未收到某个数据节点的心跳,则认为该节点故障,其上的数据块可能已丢失。
- 块报告:每6小时,数据节点向名称节点报告其存储的所有数据块列表。名称节点据此构建块位置映射。
- 块接收确认:当数据节点从客户端接收到新数据块时,会向名称节点确认。
- 名称节点指令:名称节点可以要求数据节点执行操作,例如复制副本不足的块。重要:名称节点从不主动发起新连接下达指令,而是在数据节点发送心跳时,将指令作为心跳响应返回。
数据传输与复制
客户端如何与数据节点通信并写入数据呢?





客户端将数据块(128 MB)通过网络发送给一个数据节点。为了实现复制,客户端会指示该数据节点将块传递给其他两个数据节点,从而形成一个复制管道。第一个数据节点负责组织管道,将块传递给第二个,第二个再传递给第三个。每个新块都会选择三个不同的数据节点组成新的管道。



关于安全性的问题:HDFS设计用于可信的内部环境(如公司防火墙内),它假设所有机器和软件都是受控且善意的。访问控制由名称节点管理,客户端在从名称节点获得授权后,可能会获得一个安全令牌,用于在连接数据节点时证明其权限。数据节点本身不直接验证客户端是否与名称节点通信过,但未经授权的块上传是无效的,因为块ID必须由名称节点分配并映射到文件才有意义。


文件读写流程详解 📖
了解了协议之后,我们通过动画来具体看看文件的读取和写入过程。
读取文件
以下是客户端读取一个已存在文件的步骤:
- 联系名称节点:客户端向名称节点发送请求,意图读取指定路径的文件。
- 获取元数据:名称节点检查客户端权限(可能涉及用户身份和加密令牌)。若授权通过,则回复该文件所有块的ID及其存储位置(多个数据节点地址)。位置列表按网络距离排序(距离最近的数据节点排在最前),以提高效率。
- 注意:“按距离排序”在客户端位于数据中心内部时才最有意义。如果客户端是外部笔记本电脑,则所有数据节点距离都远,此优化效果有限。
- 读取数据块:客户端根据列表,依次从每个块对应的最近数据节点下载数据块(128 MB)。如果第一个节点失败,则尝试列表中的下一个。
- 抽象层:在Java客户端库中,这一切被封装在一个高级的
InputStream中。对用户来说,就像在读取一个连续的巨型文件,而库内部则处理了与不同数据节点连接、按块切换的复杂逻辑。
写入文件
现在,让我们看看写入一个新文件的步骤:
- 创建意图:客户端联系名称节点,请求创建新文件。
- 获取授权与资源:名称节点进行访问控制检查。若通过,则为文件的第一个块分配一个新ID,并指定三个数据节点作为复制目标,可能同时提供一个安全令牌。
- 建立管道并传输:客户端联系第一个数据节点,组织复制管道,开始发送数据。数据被分成网络数据包(如64 KB)传输。管道中,数据从客户端到第一个节点,再到第二个,最后到第三个。确认信息也沿管道返回。
- 写入后续块:如果文件大于一个块,客户端重复步骤2和3,为第二个、第三个块等获取新的块ID和新的三个数据节点(通常不同),并建立新的管道。这个过程不断重复。
- 关闭与确认:所有块发送完毕后,客户端通知名称节点关闭文件(释放写锁)。名称节点等待每个块达到最小复制因子(默认为1),即至少有一个数据节点确认成功写入。
- 最终确认与异步复制:一旦每个块都至少有一个确认,名称节点就同步回复客户端“写入完成”。之后,系统在后台异步地将所有块的副本数补充到预设值(如3)。如果过程中有节点故障导致副本不足,名称节点也会在后台调度新的复制任务。



关于确认的时机:名称节点对客户端的最终确认发生在所有块都至少有一个副本确认之后,这是在客户端调用“关闭”操作时同步完成的。而达到完整副本数(如3)则是异步的、持续进行的过程,即使几天后有机柜故障导致副本数下降,系统也会自动修复。
数据块复制策略 🔄
我们知道了数据会被复制,但具体复制到哪些机器上呢?这有一套智能的策略。
默认复制因子为3。复制策略需要考虑网络带宽、数据分布和容错。核心思想是避免数据过度集中,同时优化写入和读取性能。
策略基于“节点距离”的概念,距离通常由网络跳数(经过的交换机和电缆数量)衡量。例如,同一机柜内的两个节点距离近,不同机柜的节点距离远,不同数据中心的节点距离更远。
以下是默认的三副本放置策略:
- 第一个副本:放在客户端所在的节点上(如果客户端是集群内的一个数据节点)。这样写入最快。如果客户端在集群外,则随机选择一个节点。
- 第二个副本:放在与第一个副本不同机柜的某个节点上。
- 第三个副本:放在与第二个副本相同机柜的另一个节点上。
此外,还有两个基本原则:
- 一个节点最多放一个副本:防止该节点故障导致多个副本同时丢失。
- 一个机柜最多放两个副本:防止整个机柜故障导致数据不可用。
为什么这样设计?
- 将第二、三个副本放在同一机柜,可以优化它们之间的复制管道速度(网络距离短)。
- 避免将所有副本或前两个副本都放在客户端所在机柜,是为了防止数据过度集中在该机柜,形成单点风险。通过将副本分散到不同机柜,提高了数据的可靠性和读取的并行性。




总结与预告 📝
本节课我们一起深入探讨了HDFS的物理架构和通信机制。我们学习了:
- HDFS的集中式架构,包括名称节点(管理元数据)和数据节点(存储数据块)。
- 客户端、名称节点和数据节点之间用于元数据操作、心跳、数据传输和复制的各种协议。
- 文件读取和写入的详细步骤,从客户端发起请求到数据块的实际传输与确认。
- 智能的数据块复制策略,它平衡了性能、可靠性和数据分布。
我们还遗留了一个重要问题:名称节点作为单点,如果发生故障怎么办?这将是下一节课要解决的“悬念”。
课后思考/作业:
- HDFS访问控制的具体实现细节是什么?客户端如何向数据节点证明其已获得名称节点授权?
- 在建立复制管道时,是客户端直接联系所有三个数据节点,还是只联系第一个,由第一个节点负责联系后续节点?



我们将在后续课程或资料中探讨这些问题。
13:分布式文件系统 (4/4) 📚

在本节课中,我们将要学习HDFS(Hadoop分布式文件系统)如何解决单点故障问题,并了解其高可用性架构和联邦部署模式。我们还将学习如何使用HDFS的命令行工具。


概述

上一节我们介绍了HDFS的基本架构,并指出了NameNode作为单点故障的问题。本节中,我们来看看HDFS如何通过高可用性(HA)和联邦(Federation)等机制来解决这个问题,并学习如何与HDFS进行交互。
解决NameNode单点故障 🛡️
NameNode是HDFS的协调者,存储着所有文件的元数据。如果它崩溃,整个系统将无法访问。我们需要一种方法来恢复它。
NameNode存储的关键信息
NameNode在内存中维护着三种关键信息:
- 文件命名空间:整个目录和文件名的树状结构,包括访问控制等信息。
- 文件到块ID的映射:记录每个文件由哪些数据块(Block ID)组成。
- 块位置映射:记录每个数据块存储在哪些DataNode上。
为了在NameNode崩溃后能够恢复,我们需要持久化存储部分信息。


以下是需要持久化的信息及其原因:


- 命名空间文件和块ID映射:必须持久化。如果丢失,我们将不知道如何将分散的数据块重新组装成完整的文件。
- 块位置映射:无需持久化。因为当NameNode重启后,它可以主动向所有DataNode请求
块报告来重新构建这份映射。

恢复机制:命名空间镜像与编辑日志

HDFS使用两种文件来持久化元数据:
- FsImage(命名空间镜像文件):这是某一时刻完整的命名空间和文件到块ID映射的快照。
- EditLog(编辑日志):这是一个只追加的日志,记录了自上一个FsImage创建以来所有对命名空间的更改操作(如创建文件、追加块等)。
恢复过程如下:
- NameNode重启后,首先将FsImage加载到内存中,还原到过去的某个状态。
- 然后按顺序“回放”EditLog中的所有操作,将命名空间更新到最新状态。
- 最后,等待DataNode发送块报告,重新构建块位置映射。


这个过程可能需要约半小时,因为EditLog可能非常庞大,回放需要时间。
优化:定期创建检查点






为了减少恢复时间,HDFS会定期执行检查点操作:
- 将当前的命名空间状态(内存中的信息)写入一个新的FsImage文件。
- 清空旧的EditLog,并开始一个新的EditLog。





这样,重启时只需要加载最新的FsImage并回放较短的EditLog,从而加快恢复速度。




高可用性(HA)架构 🔄
为了彻底避免冗长的恢复时间,HDFS引入了高可用性架构,使用多个NameNode。
NameNode的角色
在高可用性设置中,存在三种类型的NameNode进程:
- Active NameNode:处理所有客户端读写请求的活跃节点。
- Standby NameNode:备用节点。它同步Active NameNode的状态,但不处理任何请求。当Active节点故障时,它可以被快速提升为新的Active节点。
- Observer NameNode:观察者节点。它同步Active NameNode的状态,并且可以处理客户端的读请求,用于分担读负载。写请求仍然必须由Active NameNode处理。
状态同步:QJM(Quorum Journal Manager)
Standby和Observer节点如何与Active节点保持状态同步?它们通过一个名为 QJM(法定人数日志管理器) 的组件来同步EditLog。
- QJM由一组(奇数个)JournalNode进程组成。
- Active NameNode将EditLog条目发送给JournalNode集群。
- 只要大多数(法定人数)JournalNode确认写入,操作就被认为是持久的。
- Standby/Observer NameNode从JournalNode集群读取EditLog并应用到自己的内存状态中,从而保持同步。
当Active NameNode故障时,Standby NameNode可以立即接管,因为它已经拥有了最新的命名空间状态和块映射信息。



联邦HDFS(Federation) 🌐





随着集群规模增长,单个NameNode的内存可能成为瓶颈。联邦HDFS允许水平扩展命名空间。
联邦的核心思想






在联邦架构中,可以有多个独立的NameNode,每个NameNode管理文件系统命名空间的一部分(例如,不同的目录子树)。
- 命名空间卷:每个NameNode负责的命名空间部分。
- 共享的DataNode池:所有DataNode向所有NameNode注册,并存储来自不同命名空间卷的数据块。块存储是共享的。



例如:
- NameNode-1 管理
/user和/data目录下的所有文件。 - NameNode-2 管理
/project目录下的所有文件。 - 所有DataNode同时为这两个NameNode存储数据块。





联邦与高可用性的结合






可以同时使用联邦和高可用性。即,每个命名空间卷(如负责/user的NameNode逻辑组)自身都可以配置为一套Active-Standby-Observer的高可用集群。

与HDFS交互:命令行工具 💻
用户可以通过与本地文件系统类似的命令来操作HDFS。
Hadoop FS Shell



Hadoop提供了一个通用的文件系统Shell命令 hadoop fs,它支持多种后端存储。
以下是常用命令示例:
# 列出HDFS根目录下的文件 (默认连接到配置的HDFS)
hadoop fs -ls /




# 在HDFS上创建一个新目录
hadoop fs -mkdir /user/bigdata





# 从本地文件系统复制文件到HDFS
hadoop fs -copyFromLocal localfile.txt /user/bigdata/




# 从HDFS复制文件到本地文件系统
hadoop fs -copyToLocal /user/bigdata/hdfsfile.txt ./





# 查看HDFS上的文件内容
hadoop fs -cat /user/bigdata/hdfsfile.txt


# 删除HDFS上的文件或目录
hadoop fs -rm -r /user/bigdata/olddir

关键特性
- 统一接口:
hadoop fs命令通过URL scheme(如hdfs://,file://,s3a://)自动适配不同的文件系统(HDFS、本地FS、Amazon S3等)。 - 配置默认值:通常通过配置文件设置默认的文件系统(如
fs.defaultFS),这样在命令中就可以直接使用/path而不需要写完整的hdfs://namenode:port/path。 - 延迟感知:由于涉及网络通信,这些命令的响应速度会比操作本地磁盘慢一些。

术语差异与演进说明 📝






需要注意的是,不同的分布式文件系统或HDFS的不同版本/发行版中,术语可能有所不同:
- GFS (Google File System) 中使用 Chunk(块)、Chunk Server(数据节点)、Master(名称节点)。
- HDFS 中使用 Block(块)、DataNode(数据节点)、NameNode(名称节点)。
- 块大小的默认值也在演进(如早期64MB,后来128MB,某些云版本可能更大)。




核心概念是相通的,但具体实现和参数会随着硬件和需求的发展而变化。

总结


本节课中我们一起学习了HDFS如何解决其架构中的关键单点故障问题。我们探讨了通过FsImage和EditLog进行恢复的机制,并介绍了更先进的高可用性(HA)架构,该架构利用Active、Standby和Observer NameNode以及QJM来实现快速故障转移。此外,我们还了解了联邦HDFS如何通过多个NameNode水平扩展命名空间。最后,我们学习了使用 hadoop fs 命令行工具与HDFS及其他兼容的文件系统进行交互的基本方法。理解这些机制对于设计和运维可靠的大数据存储系统至关重要。
14:语法(1/3)🌲







在本节课中,我们将要学习数据表示中的语法,特别是文本格式。我们将探讨为何在“大数据”场景下,我们倾向于使用如JSON和XML这类半结构化格式,而不是传统的、高度规范化的表格。课程将从字符编码的基础知识开始,逐步深入到JSON的具体语法。
概述:为何需要语法?
在上一节中,我们介绍了数据存储的基础设施。本节中,我们来看看如何将数据表示为文本。



将数据存储为文本主要有两个目的:
- 人类可读性:人类可以直接阅读和修改文本文件,这非常方便。从长远来看,文本格式也更容易被未来的研究者解读。
- 网络传输:文本格式(如XML和JSON)是许多网络协议(如REST API)的标准数据交换格式。
当然,也存在更高效的二进制格式,但对于需要长期存档或广泛交换的数据集,文本格式通常是首选。



字符编码基础






在将文本存储为文件(即比特流)之前,我们需要一个将字符映射到比特的协议,这被称为字符编码。




以下是几种重要的编码方式:


- ASCII:最早的编码之一,仅包含128个字符,基于英文字母表。
- Latin-1等扩展:为了支持其他语言(如西欧语言)而出现的多种扩展编码。
- Unicode:一个旨在包含全世界所有字符的庞大目录。它为每个字符分配一个唯一的码点(一个数字)。
- UTF-8:一种对Unicode码点进行编码的变长方式。它是目前最推荐使用的编码,因为它兼容ASCII,并且能高效地表示各种字符。





UTF-8编码规则:
- 如果码点值小于等于7位,则用1个字节(8位)编码。
- 如果码点值小于等于11位,则用2个字节编码。
- 如果码点值小于等于16位,则用3个字节编码。
字节中的特定比特位(如上文图中的红色标记)用于指示这是单字节字符,还是多字节字符的起始或后续字节。






对于新软件,强烈建议从一开始就使用UTF-8编码,以避免后续迁移的麻烦。



从表格到树:反规范化


在传统数据库(OLTP)中,我们通过规范化将数据拆分到多个表中以避免冗余和异常。但在大数据分析(OLAP)场景下,我们通常执行一次写入、多次读取的操作。

因此,我们倾向于反规范化数据:将多个表合并成一个大表,甚至是一个嵌套的结构。这样做的好处是:
- 避免连接操作:连接操作在分布式系统中可能非常昂贵。一个大的、扁平化或嵌套的表可以直接被查询。
- 适应数据异构性:真实世界的数据(如日志文件)可能随时间变化模式(Schema),嵌套结构能更好地容纳这种不一致性。







反规范化使我们从扁平、同质的关系表世界,进入嵌套、异质的半结构化数据世界。JSON和XML正是为表示这类数据而设计的。







JSON语法详解


JSON(JavaScript Object Notation)是当前非常流行的半结构化数据格式。它由六种基本语法构件组成。





原子值
以下是JSON中的四种原子值类型:

- 字符串:必须使用双引号包围。特殊字符需要使用反斜杠转义。
- 公式:
"<characters>" - 示例:
"Hello\nWorld","\u0041"(表示字符 ‘A’)
- 公式:



- 数字:直接书写,无需引号。支持整数、小数和科学计数法。
- 公式:
[”-“]<digits>[”.”<digits>][(“e”|”E”)[”+”|”-“]<digits>] - 示例:
42,-3.14,2.9979e8
- 公式:




-
布尔值:直接使用
true或false。 -
空值:使用
null表示。

复合结构
为了实现嵌套,JSON提供了两种复合结构:

- 数组:表示一个有序的值列表,使用方括号
[]定义,值之间用逗号分隔。数组内的值可以是任何JSON类型,允许异质。- 代码:
[<value1>, <value2>, ...] - 示例:
[1, true, "text", {"key": "value"}]
- 代码:


- 对象:表示一个键值对集合(映射),使用花括号
{}定义。键必须是字符串,值可以是任何JSON类型。- 代码:
{"<key1>": <value1>, "<key2>": <value2>, ...} - 示例:
{"name": "Alice", "age": 30, "hobbies": ["reading", "hiking"]}
- 代码:




JSON重要规则与最佳实践
以下是关于JSON格式的一些关键点:










- 键名引号:对象中的键必须是带双引号的字符串。虽然有些解析器较宽松,但生产数据时应严格遵守。
- 键的唯一性:在同一个对象内,键名不应重复。标准中此为“建议”而非“强制”,但为保障兼容性,应视作强制规则。
- 格式化:为了人类可读,JSON可以包含换行和缩进(美观打印)。但在存储为数据集时(如JSON Lines格式),通常每行只放一个紧凑的JSON对象,以节省空间并便于流式处理。
JSON数据示例




一个扁平表(如CSV)可以用JSON Lines表示,即每行一个JSON对象。




一个嵌套表(违反第一范式)可以自然地用JSON表示,其中子表被表示为父对象中某个键对应的数组,数组内包含多个对象。



格式标准化的重要性
XML和JSON之所以重要,不仅因为其技术特性,更因为它们是标准。W3C标准化了XML,ECMA标准化了JSON。
标准化的好处在于:
- 互操作性:有成千上万的库和工具支持这些标准,确保数据能在不同系统间交换。
- 生态成熟:尤其是XML,在企业级和政府系统中有着深厚且成熟的生态系统。
概念辨析:格式良好 vs 有效





在本课程中,区分以下两个概念至关重要:
- 格式良好:一个文档是否符合JSON或XML的基本语法规则。这是一个“是/否”问题,由解析器检查。
- 有效:一个格式良好的文档是否符合某个特定的模式(Schema)定义(例如,值是否在特定范围内)。这将在后续课程中讨论。




目前,我们只关注语法层面的“格式良好”。






总结






本节课中我们一起学习了数据表示语法的核心概念。我们从为什么需要文本格式开始,回顾了字符编码(尤其是UTF-8)的基础。然后,我们深入探讨了在大数据背景下反规范化数据的动机,这引出了对半结构化数据(如树形结构)的需求。接着,我们详细剖析了JSON的六种语法构件:字符串、数字、布尔值、空值、数组和对象,并通过示例展示了如何用JSON表示扁平表和嵌套表。最后,我们强调了标准化格式(如JSON和XML)的重要性,并厘清了“格式良好”与“有效”这两个关键术语的区别。






理解这些语法基础,是我们后续学习如何查询和处理这些半结构化数据的必要前提。
15:语法 (2/3) - XML详解 📚
在本节课中,我们将学习XML(可扩展标记语言)的语法。XML是一种用于存储和传输数据的标记语言,广泛应用于企业、政府和标准化组织中。我们将探讨XML的基本构建块、语法规则以及命名空间的概念。


概述 📋

上一节我们介绍了JSON语法,本节我们将深入探讨XML语法。XML在大数据环境中扮演着重要角色,尤其是在数据湖场景中,因为它既是人类可读的,也是机器可读的。与JSON不同,XML具有更严格的结构规则,并支持命名空间,这使得它在处理复杂和异构数据时非常有用。










XML的基本构建块 🧱




XML文档由几个核心构建块组成:元素、属性、文本、注释和XML声明。
元素 (Elements)
元素是XML文档的基本单位,由开始标签、内容和结束标签组成。开始标签和结束标签用尖括号(< 和 >)括起来。
示例:
<element>内容</element>
如果元素没有内容,可以使用空元素标签作为快捷方式:
<element />
属性 (Attributes)
属性是元素中的键值对,位于开始标签或空元素标签内。属性值必须用双引号或单引号括起来。
示例:
<element attribute="value" />
文本 (Text)
文本是元素的内容,直接放在开始标签和结束标签之间,不需要引号。


示例:
<element>这是文本内容</element>

注释 (Comments)




注释用于在XML文档中添加说明,不会被解析器忽略。注释以 <!-- 开头,以 --> 结尾。
示例:
<!-- 这是一个注释 -->
XML声明 (XML Declaration)
XML声明位于文档顶部,用于指定XML版本和编码方式。它是可选的,但建议包含。
示例:
<?xml version="1.0" encoding="UTF-8"?>

XML的语法规则 📏



XML文档必须遵循严格的语法规则,称为“格式良好”(well-formed)。以下是主要规则:

1. 只有一个顶级元素
XML文档必须有且仅有一个顶级元素,所有其他元素必须嵌套在其中。








正确示例:
<root>
<child>内容</child>
</root>
错误示例:
<element1 />
<element2 />






2. 元素必须正确嵌套
元素的开始标签和结束标签必须正确嵌套,不能交叉。


正确示例:
<parent>
<child>内容</child>
</parent>
错误示例:
<parent>
<child>内容</parent>
</child>
3. 属性名称必须唯一
在同一元素中,属性名称不能重复。


错误示例:
<element attr="value1" attr="value2" />

4. 特殊字符必须转义
XML中的特殊字符(如 <、>、&、"、')必须使用转义序列表示。



以下是XML的转义序列:
<转义为<>转义为>&转义为&"转义为"'转义为'



示例:
<element>1 < 2</element>



5. 元素和属性名称规则
元素和属性名称必须遵循以下规则:
- 不能以数字开头。
- 可以包含字母、数字、下划线、连字符和点号。
- 不能包含空格或其他特殊字符。

正确示例:
<element_name attribute-name="value" />
错误示例:
<123element />
命名空间 (Namespaces) 🌐


命名空间用于避免元素名称冲突,类似于编程语言中的包或模块。它通过URI(统一资源标识符)唯一标识一组元素。


命名空间的声明
命名空间可以在根元素中声明,并绑定到一个前缀。
示例:
<root xmlns:prefix="http://example.com/namespace">
<prefix:element>内容</prefix:element>
</root>
默认命名空间
如果不使用前缀,可以声明默认命名空间。
示例:
<root xmlns="http://example.com/namespace">
<element>内容</element>
</root>

命名空间的作用
命名空间的主要作用是:
- 避免元素名称冲突。
- 将元素与特定的模式(schema)关联,用于验证文档结构。
- 提高文档的可维护性和可读性。
XML与HTML的区别 🔍





XML和HTML都源自SGML,但它们在语法和用途上有显著区别:


语法严格性
- XML语法非常严格,必须遵循所有规则(如必须闭合标签、属性必须加引号)。
- HTML语法较为宽松,允许某些省略(如不闭合标签、属性不加引号)。








用途
- XML主要用于存储和传输数据。
- HTML主要用于构建网页结构。





示例对比
XML示例:
<book>
<title>XML指南</title>
<author>张三</author>
</book>





HTML示例:
<html>
<head>
<title>网页标题</title>
</head>
<body>
<h1>欢迎</h1>
</body>
</html>





总结 📝



本节课中,我们一起学习了XML的基本语法和规则。XML是一种强大的数据格式,适用于存储和传输复杂数据。它的严格语法和命名空间支持使其在企业环境中广泛应用。下一节我们将探讨如何在存储层中高效地存储XML和JSON数据。

关键要点:
- XML由元素、属性、文本、注释和XML声明组成。
- XML文档必须格式良好,遵循严格的嵌套和命名规则。
- 命名空间用于避免元素名称冲突,提高文档的可维护性。
- XML与HTML在语法和用途上有显著区别。

希望本节课的内容能帮助你更好地理解XML语法!🚀
16:XML语法(3/3)📚
在本节课中,我们将完成对XML语法的学习,重点探讨命名空间的概念、其实际应用,以及如何将数据表示为XML格式。我们还会将XML与JSON等其他数据格式进行比较,并介绍一个实用的XML编辑器。
概述 📋
本节课我们将深入理解XML命名空间,了解它们如何在全球范围内帮助组织和区分不同的数据标准。我们还将学习如何使用XML表示表格和嵌套数据,并比较XML与JSON在不同场景下的适用性。

命名空间的用途与类比 🔗
上一节我们介绍了XML的基本结构。本节中,我们来看看命名空间。命名空间用于避免来自不同领域(如数学公式、音乐乐谱)的XML元素名称发生冲突。
可以将XML命名空间理解为Python中的模块导入。

- 带前缀的命名空间 类似于
import pandas as pd。在XML中,我们声明一个前缀(如m:)并将其绑定到一个唯一的URI(如MathML的命名空间)。<m:math xmlns:m="http://www.w3.org/1998/Math/MathML"> <m:mi>x</m:mi> </m:math> - 默认命名空间 类似于
from pandas import *。它为该元素及其所有子元素(除非被覆盖)设置一个默认的命名空间,无需使用前缀。<math xmlns="http://www.w3.org/1998/Math/MathML"> <mi>x</mi> </math>
添加命名空间后,XML处理器可以依据该命名空间对应的模式(Schema)来验证文档结构的正确性,我们将在后续课程中详细学习。



命名空间的实际应用示例 🌐
以下是一个在HTML网页中嵌入MathML公式的实例,展示了多命名空间的混合使用:
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Example</title></head>
<body>
<h1>一个公式</h1>
<math xmlns="http://www.w3.org/1998/Math/MathML">
<msup><mi>a</mi><mn>2</mn></msup>
<mo>+</mo>
<msup><mi>b</mi><mn>2</mn></msup>
<mo>=</mo>
<msup><mi>c</mi><mn>2</mn></msup>
</math>
</body>
</html>

- 整个文档默认命名空间是XHTML。
<math>元素及其子元素则位于MathML的命名空间中。- 浏览器能识别并正确渲染出公式 a² + b² = c²。
推荐的命名空间使用模式 ✅

为了避免混乱,请遵循以下清晰的模式:



以下是几种清晰且推荐的命名空间使用方式:
- 无命名空间:用于简单的、不涉及外部标准的数据。
- 默认命名空间:整个文档主要遵循一种标准时使用。
- 单一前缀命名空间:引入一个外部标准时使用(如
pd对应pandas)。 - 多前缀命名空间:引入多个外部标准时使用,每个都有清晰的前缀。
切勿在同一个文档中为同一个命名空间使用多个不同的前缀,或随意混合默认命名空间和前缀命名空间,这会导致代码难以理解和维护。

实用工具:Oxygen XML Editor 🛠️

处理XML时,一个强大的编辑器非常有用。苏黎世联邦理工学院为学生提供了 Oxygen XML Editor 的站点许可证。

你可以从IT商店(itshop.ethz.ch)免费下载。它不仅能高亮显示语法,还能:
- 检查XML的格式是否良好(Well-formedness)。
- 帮助修复常见错误,例如标签不匹配、属性未加引号、使用了无效的转义字符等。




在XML中表示数据表 📊




理解了语法和工具后,我们来看看如何用XML表示关系型数据。



一个简单的销售数据表:
| product | price | customer | quantity |
|---|---|---|---|
| Apple | 5 | Alice | 10 |





在XML中,通常按行进行嵌套表示,并使用复数/单数形式来区分集合与个体:

<sales> <!-- 表名(复数),表示整个数据集 -->
<sale> <!-- 行(单数),表示一条记录 -->
<product>Apple</product>
<price>5</price>
<customer>Alice</customer>
<quantity>10</quantity>
</sale>
<!-- 可以添加更多 <sale> 元素来表示其他行 -->
</sales>




在XML中表示嵌套数据 🌲


对于嵌套数据(例如,一个订单包含多个商品项),XML通过多层嵌套元素来自然地表示:




<table>
<sale>
<product>Laptop</product>
<orders> <!-- 嵌套的列表 -->
<order>
<component>RAM</component>
<quantity>1</quantity>
</order>
<order>
<component>SSD</component>
<quantity>2</quantity>
</order>
</orders>
</sale>
</table>


这种结构对应了JSON中的对象内嵌数组,或数据模型中的树形结构,是CSV等扁平格式无法直接表达的。

XML vs. JSON vs. YAML ⚖️


作为计算机科学家,不应武断地认为某种格式更好,而应根据使用场景选择。
以下是几种数据格式的简要比较:
- XML:
- 优势:非常适合表示带标记的文本流(如书籍、文档),生态系统成熟,企业级工具支持完善。
- 示例场景:出版业、政府数据标准(如Swiss eGovernment)、需要严格验证的复杂数据交换。
- JSON:
- 优势:语法与编程数据结构(对象、数组)高度一致,非常适合表示业务数据记录,在Web API中占主导地位。
- 示例场景:Web服务接口、配置文件、与JavaScript交互。
- YAML:
- 优势:对人类阅读和编辑非常友好,通过缩进表示结构,无需大量括号。
- 示例场景:Kubernetes配置、Docker Compose文件、强调可读性的配置文件。
从数据模型的角度看,它们都能表示树形结构,本质上是相通的。
总结 🎯
本节课中我们一起学习了:
- XML命名空间的核心概念及其与Python模块导入的类比,理解了它如何通过URI唯一标识数据标准。
- 如何遵循清晰的命名空间使用模式来编写可维护的XML文档。
- 如何使用 Oxygen XML Editor 来高效地编辑和验证XML。
- 如何将表格数据和嵌套数据转换为XML格式。
- 对 XML、JSON和YAML 进行了比较,明确了它们各自适用的场景,核心在于根据数据特性(是文档文本还是业务记录)和生态系统需求来选择。


关键结论是:当我们需要表示超越扁平表格的树状结构数据时,XML、JSON和YAML等格式提供了强大的能力,而CSV则仅限于平面表格。
17:宽列存储 (1/4) 📊









在本节课中,我们将要学习一种新型的数据存储模型——宽列存储。我们将从理解其诞生的背景和动机开始,然后深入探讨其逻辑模型和基本操作。这种模型旨在解决传统数据存储系统在可扩展性、吞吐量和灵活性方面的局限性。
概述:为何需要宽列存储?🤔
上一节我们介绍了各种数据存储格式,本节中我们来看看数据模型。传统的关系型数据库管理系统(RDBMS)设计用于单机环境,使用如SQL这样的高级语言,并管理着严格的模式。然而,当数据量超过单台机器的存储能力时,就会遇到瓶颈。
虽然可以通过升级硬件(纵向扩展)来应对,但这终究会遇到物理极限。大约20年前,Google和Yahoo等公司开始尝试另一种方法:购买数百台普通商用计算机,并在每台机器上安装PostgreSQL等关系数据库,然后手动编写程序来跨机器分布和复制数据。这类似于GFS(Google文件系统)的理念。
但这种方法设置复杂、维护成本高昂,并非长久之计。工程师们意识到,需要一种原生为集群环境设计的数据存储系统。于是,Google的BigTable应运而生。它在运行GFS的集群之上,提供了一个逻辑上类似于关系表的数据模型。由于BigTable和GFS是专有技术,开源社区随后发展出了Hadoop生态系统,其中HDFS是GFS的开源版本,而HBase则是BigTable的开源版本。
现有系统的局限性 ⚠️

为了理解宽列存储的价值,我们先看看现有系统的不足。以下是各类存储系统的关键短板:
- 对象存储:缺乏低延迟访问,因为更新大对象通常需要重写整个对象,不支持原地修改和随机访问。
- 关系型数据库管理系统(RDBMS):缺乏高吞吐量(虽然支持毫秒级的随机读写),且最初的版本难以扩展到单机之外。
- HDFS:缺乏低延迟(操作如提交命令可能有数秒延迟),不支持原地更新(必须删除并重写文件),读取时的随机访问性能极差。
- 键值存储:缺乏高吞吐量(主要设计用于简单的
get、put、delete操作),并且不支持存储过大的值(通常限制在几百KB以内)。
可以看到,没有一个现有系统能满足所有需求。而像HBase和BigTable这样的宽列存储系统,目标正是同时提供高吞吐量、低延迟、原地更新、支持大值存储和随机访问能力。

逻辑模型:从反规范化开始 🧱
现在,让我们采用与讲解HDFS时类似的“数据独立性”方法,首先忽略集群和物理实现,专注于HBase的逻辑模型。
其核心思想是:为了获得高吞吐量和高效查询,我们需要将那些会被一起访问的数据存储在一起。这其实并不新鲜,其本质就是避免连接(Join)操作。在传统RDBMS中,为了满足高阶范式(如第三范式),数据被拆分到多个表中。当需要查询时(例如,在网页上显示订单及其产品信息),就必须通过连接操作将多个表的数据合并。
连接操作成本较高。因此,对于需要极快响应的场景(如网站),进行反规范化(Denormalization)通常是有益的。反规范化可以粗略地理解为预先计算连接,将来自不同源的数据“挤压”到同一张表中。
虽然这会导致不符合传统的关系型设计范式(例如,出现不期望的函数依赖),但对于数据查询和分析的目的而言,我们并不关心。相反,反规范化能显著加快查询和分析速度。当然,运行网站的事务处理可能仍需要在传统的RDBMS集群上进行。
这种反规范化的结果就是“宽表”——它不仅行数多,列数也很多。这就是“BigTable”(大表)名称的由来之一。
深入模型细节 🔍
宽列存储的逻辑模型看起来像一张表,但有一些关键区别。
行键(Row ID)
表中的一列是特殊的,称为行键(Row ID)。与PostgreSQL可以选择主键不同,在HBase中,你必须有一个专门的行键列,且每个表只有一个。表中的记录按照行键进行排序。行键本质上是一个字节序列。
注意:HBase只使用一个列作为主键(行键)。其他系统如Cassandra(HBase的竞争对手)支持由多个列组成的复合主键。
从某种意义上说,这已经是一个键值模型:行键是键,该行的其余部分(所有列的值)共同构成了值。


列族(Column Families)
与传统关系表的主要区别在于,列被分组到列族(Column Families)中。你可以将列族理解为反规范化之前那些独立表的遗留。例如,合并前的左表可能对应黄色的列族,右表对应红色的列族。反规范化后,它们被放在同一张宽表中,但仍保留着列族的区分。
列名(Column Names)
列名本身也是字节序列(在实践中常编码为UTF-8字符串)。一个强大的特性是:在创建表并存入大量数据后,你可以随时动态地创建新的列名,而不会带来任何性能损失。


这与传统关系数据库形成鲜明对比:在传统数据库中,向大表添加新列可能需要很长时间(例如一小时)。在HBase中,插入新记录时,可以即时插入之前不存在的列名,这完全没问题。
这会导致表变得非常稀疏,很多单元格是空的,因此它不遵循传统的关系完整性约束。
关于列族与列名的操作区别
在已存在的列族内,动态创建新列名非常容易。然而,创建或删除列族本身是可能的,但这不是弹指之间就能完成的操作,它相对更“重”一些。


一切都是字节 🔢
需要再次强调:在这个模型中,所有组成部分都是二进制值,即字节序列。
- 行键是字节序列。
- 列族是字节序列。
- 列名是字节序列。
- 单元格的值也是字节序列。
换句话说,它不像Java这样的高级编程语言或SQL这样的查询语言,提供字符串、整数、日期等多种数据类型。HBase底层只有字节。当然,访问HBase的库和驱动会提供便利的函数和方法,帮助你将整数、字符串等类型转换并存入,但在物理层面,它们最终都只是字节。
关于大小限制
- 值(Value):在传统RDBMS中,值通常很小(符合第一范式)。在HBase中,你可以轻松地将值的大小推到10MB甚至更大。你可以存放图片、XML/JSON文档、YAML文件、HTML页面等。这本身也可以看作一种反规范化,因为一个10MB的BLOB很难被认为是“原子”值,这甚至不符合第一范式。但HBase可以很好地处理,性能在10MB范围内仍然不错。
- 行键、列族、列名:对这些部分的大小需要更谨慎。我们将在学习物理实现时看到,它们在数据存储中被大量重复,因此不应让它们变得过大(例如10MB),而应保持相对“原子”和小巧。
基本操作:低层级的API ⚙️
由于模型本身比较接近物理层(操作对象是字节),HBase提供的查询接口不是一个完整的SQL语言,而是一个相对低层级的访问方式,类似于键值存储的Get/Put/Delete以及REST API。
以下是主要的四种操作:
1. Get(获取)
Get操作允许你根据特定的行键检索数据。
Get(row_id)
你可以获取该行所有列族和列的值。也可以进行定制,只获取特定列族(如紫色或黄色列族)的值,这通常更快,因为列族在物理上是分组存储的。
2. Put(放置)
Put操作用于插入新数据或更新现有数据。
Put(row_id, values)
你可以插入一个具有新行键的新行及其值,也可以更新已存在行的部分值。同样,Put操作也可以只针对特定的列族进行。

3. Scan(扫描)
Scan操作是面向大数据分析的新功能。它允许你遍历多行数据。
Scan(start_row_id, end_row_id)
你可以控制扫描的行数范围(通过指定起始和结束行键),也可以限制只扫描特定的列族。然后,你可以通过迭代器的方式逐批获取结果。


4. Delete(删除)
Delete操作用于删除数据。你可以删除整行数据,也可以删除行中特定列族或列的数据。
所有这些操作(Get, Put, Delete, Scan)都可以通过高级语言(如Java、Python)或Shell中的API来调用。



关键术语总结 📝
在结束本节之前,我们来澄清几个关键术语:
- 键值模型(Key-Value Model):我们已经看到,行键(Row ID)作为键,整行数据作为值,这符合键值模型。S3、各种键值存储、JSON对象的属性、XML元素的属性等都遵循这种模型,本质上它就是一种映射或关联数组。
- 列式存储(Column-Oriented Storage):不要与“宽列存储”混淆。列式存储指的是在物理磁盘上,数据按列(而不是按行)的方式存储。新一代的数据库系统采用这种方式,当查询只涉及特定列时,可以带来巨大性能优势。
- 宽列存储(Wide Column Stores):这就是我们本节讨论的核心。其特点是拥有列族的概念,可以看作是列式存储的一种扩展,允许在列族内动态创建大量列,形成非常“宽”的表。
本节总结 🎯
本节课中我们一起学习了宽列存储的初步知识。我们首先了解了传统数据存储系统在扩展性上的局限,以及宽列存储(如Google BigTable和Apache HBase)诞生的背景和动机。接着,我们深入探讨了其核心逻辑模型,包括:
- 强制性的行键(Row ID)及其排序特性。
- 数据按列族(Column Families)分组的概念。
- 可以动态创建的列名(Column Names)。
- 所有组成部分(行键、列族、列名、值)本质上都是字节序列这一重要事实。
最后,我们介绍了HBase提供的四种基本低层级操作:Get(检索)、Put(插入/更新)、Scan(范围扫描)和Delete(删除),并区分了“键值模型”、“列式存储”和“宽列存储”这几个关键术语。

下一节,我们将揭开神秘面纱,探讨HBase是如何在物理上实现这个强大的逻辑模型的。
18:宽列存储(2/4)📊






在本节课中,我们将要学习宽列存储(Wide Column Stores)的物理层实现,特别是HBase如何构建在HDFS之上,以及数据是如何被分区、存储和管理的。这是整个学期最复杂的部分,我们将逐步拆解,确保你能理解其核心机制。






概述







上一节我们介绍了宽列存储的逻辑模型,包括行键、列族和基本的查询操作。本节中,我们来看看其物理层的实现。我们将深入探讨HBase如何利用HDFS进行数据存储,理解“区域”(Region)和“存储”(Store)的概念,并解析数据在磁盘上的实际存储格式(HFile)。


物理层:数据分区与架构

在物理层面,首要任务是将数据分区,使其能够分布到集群的各个节点上。这与我们在HDFS中切割文件成块(Chunk)的思路一致。




最自然的分区方式是基于行(Row)或基于列(Column)。实际上,HBase同时采用了这两种方式。
水平分区:区域(Regions)








数据表在水平方向上被分割成多个区域。每个区域由一段连续的行键(Row Key)区间定义。
- 区间表示:通常表示为
[起始行键, 结束行键),即起始键包含在内,结束键不包含在内。这种约定使得一个区域的结束键正好是下一个区域的起始键,形成无缝衔接。 - 区域数量:一个表可以拥有成千上万个区域。
垂直分区:列族(Column Families)



数据表在垂直方向上按列族进行分区。列族在创建表时定义,每个列族内的列可以动态添加。







存储单元:存储(Stores)


一个区域与一个列族的交集,被称为一个存储。你可以将其想象成一个三维数据立方体被同时进行了水平和垂直切割后得到的一个数据块。每个存储是数据管理和持久化的基本单元。



HBase 系统架构



HBase的架构与HDFS类似,采用了主从(Master-Slave)模式。



核心进程
以下是HBase集群中的关键进程:



- HMaster:运行在协调节点上,通常与HDFS的NameNode在同一台机器。它负责元数据操作(DDL),例如创建/删除表、管理列族等。它还会监控RegionServer的状态并在必要时进行区域重分配。
- RegionServer:运行在数据节点上,与HDFS的DataNode进程在同一台机器。每个RegionServer负责管理分配给它的多个区域(Regions)。它处理客户端的读写请求,是实际进行数据操作的角色。
- 备份HMaster:为了防止单点故障,可以配置多个备份HMaster(最多9个),在主HMaster失效时接管工作。

数据分布与区域分配

HMaster负责将表的各个区域分配给集群中的RegionServer。一个RegionServer通常被分配多个区域(实践中可达上千个)。

- 动态分裂:当一个区域因数据不断写入而变得过大时,负责它的RegionServer会将其分裂成两个新的区域。最初,这两个新区域仍由同一个RegionServer管理。
- 负载均衡:HMaster会监控集群负载,并可能将某些区域从一个RegionServer重新分配到另一个,以实现负载均衡。






关键点:区域被“分配”给RegionServer,这是一种管理职责,而非数据存储位置。数据实际存储在底层的HDFS中。
数据存储:HBase 与 HDFS 的协作
理解HBase和HDFS的分层关系至关重要。
分层模型


- 逻辑层:HBase,提供宽列数据模型和API。
- 物理层:HDFS,提供分布式文件存储。
- 关系:HBase使用HDFS作为其所有数据的存储介质。每个RegionServer同时也是一个HDFS客户端,会向HDFS读写数据。








数据位置与职责转移




- 数据存储:所有表数据最终以文件(HFile)形式存储在HDFS上,并由HDFS管理其副本(通常为3份)。
- 职责与数据分离:当将一个区域的管理职责从一个RegionServer转移到另一个时,底层HDFS上的数据文件不会移动。新的RegionServer可以直接通过HDFS访问这些文件。这使职责转移变得非常轻量和快速。
- 数据本地性:如果一个RegionServer长期管理某个区域,它写入的HDFS数据块的第一副本通常会落在本地机器(DataNode)上,从而获得最佳读取性能。当区域被重新分配后,新的RegionServer最初可能需要通过网络读取数据,但随着它开始写入新数据,数据本地性会逐渐恢复。




为什么区域不需要多副本? 因为数据冗余已由HDFS在块级别通过多副本机制保证。RegionServer故障时,只需将其负责的区域重新分配即可,数据不会丢失。













数据文件格式:HFile



每个“存储”(Store)的数据在HDFS上体现为一个或多个HFile。HFile是存储键值对(Key-Value)序列的二进制文件。
从逻辑表到HFile






逻辑视图中的每个单元格(Cell,由行键、列族、列限定符和时间戳唯一标识)在HFile中对应一个键值对。
- 键(Key):单元格的坐标,即
行键 + 列族 + 列限定符 + 时间戳 + 键类型。 - 值(Value):单元格的实际内容。





因此,一个HFile本质上是一个按行键排序的键值对序列。




版本控制与行锁





- 版本控制:HFile不仅存储单元格的最新值,还会保留一定数量的历史版本(类似于Git),通过时间戳区分。
- 行级锁:HBase通过行级锁保证对同一行的修改是串行化的。这确保了同一行数据版本更新的全序性,避免了Dynamo风格的数据版本冲突。



HFile 内部结构详解





为了在二进制流中明确每个部分的边界,HFile使用了长度前缀的编码方式。






键值对(KeyValue)结构




每个KeyValue由以下部分组成,按顺序存储:
- 键长度(Key Length):固定长度(如32位),表示后续“键”部分的字节数。
- 值长度(Value Length):固定长度(如32位),表示后续“值”部分的字节数。
- 键(Key):可变长度,具体结构见下文。
- 值(Value):可变长度,即单元格数据。



通过先读取两个固定长度的字段,解析器就能知道需要读取多少字节来获取完整的键和值。
键(Key)的内部结构



键本身也是一个可变长结构,包含:
- 行键长度(Row Length):固定长度,表示行键的字节数。
- 行键(Row Key):可变长度。
- 列族长度(Column Family Length):固定长度,表示列族的字节数。
- 列族(Column Family):可变长度。
- 列限定符(Column Qualifier):可变长度。其长度可通过
键长度 - (行键长度+列族长度+时间戳长度+键类型长度)计算得出,因此无需单独存储长度。 - 时间戳(Timestamp):固定长度(如64位)。
- 键类型(Key Type):固定长度,用于标识此键值对是插入、删除等操作。




设计启示:行键和列名的长度应尽可能短,因为它们会作为键的一部分被重复存储无数次,影响存储效率。







总结



本节课我们一起深入学习了宽列存储系统HBase的物理层架构:
- 我们理解了数据通过区域和列族进行水平与垂直分区,形成基本管理单元——存储。
- 我们剖析了HBase的主从架构,包括HMaster和RegionServer的角色。
- 我们明确了HBase与HDFS的分层关系:HBase是逻辑层,使用HDFS作为物理存储层;数据管理职责与物理存储位置分离。
- 我们详细解析了数据在磁盘上的存储格式HFile,了解了其键值对结构、版本控制机制以及内部二进制编码方式。

这部分内容是理解分布式宽列存储如何实现高效、可靠数据管理的核心。下节课我们将继续探讨数据写入、读取和压缩合并等过程。
19:宽列存储 (3/4) 📚

在本节课中,我们将深入学习HBase的物理存储模型,特别是其核心的Key-Value结构、读写流程以及用于实现高吞吐量的LSM树(Log-Structured Merge Tree)架构。我们将从HBase的物理存储单元开始,逐步深入到其内部的数据组织、写入、读取和合并机制。
概述:HBase的物理存储单元
上一节我们介绍了HBase的逻辑视图,本节中我们来看看其物理存储的核心单元——Key-Value(KV)。
在HBase中,用户看到的是表和行,但物理上存储的是称为Key-Value(KV)的单元。一个KV是存储的最小物理单元。它的键(Key)是一个多维结构,包含了以下信息:
- 行ID (Row ID)
- 列族 (Column Family)
- 列限定符 (Column Qualifier)
- 版本 (Version)
这不同于简单的键值存储,后者的键通常是单一维度的。HBase的键是二维的(行和列),并且具有版本控制,这些版本是全序的(通过锁机制保证),而不是像最终一致性系统中的向量时钟那样形成有向无环图(DAG)。
KV在物理上被组织在称为“存储(Store)”的单元中,一个Store是特定区域(Region,行的区间)和特定列族的交集。这些KV最终被持久化到HDFS上的HFile中。

架构与协调:ZooKeeper的作用


HBase的架构与HDFS类似,采用主从(Master-Worker)模式。HMaster是协调者,Region Server是工作节点。
一个关键问题是节点间的协调与心跳检测。早期版本的HBase使用自定义的心跳机制,但这容易引入竞态条件等问题。为了解决这个在分布式系统中反复出现的协调问题,HBase(以及Hadoop生态中的其他系统)采用了ZooKeeper。


ZooKeeper是一个专为分布式系统提供协调服务(如命名、配置管理、同步)的独立系统。Region Server不再直接向HMaster发送心跳,而是向ZooKeeper注册自己。HMaster通过查询ZooKeeper来获知哪些Region Server是存活的。这种方式更加健壮和通用。













HFile的内部结构:块与索引

现在,让我们深入一个Store在HDFS上的持久化文件——HFile。
HFile内部存储了一系列按Key排序的KV。为了提高读取效率,这些KV并非逐个读取,而是被组织成更大的块,称为HBase块(HBlock),默认大小为64KB。
以下是HFile的内部结构:

- 数据部分:由多个HBlock组成,每个HBlock包含一组完整的、有序的KV。
- 索引部分:一个内存友好的索引,记录了每个HBlock的键范围(例如,从Key11到Key17)。当需要查找某个键时,首先在内存中加载索引,快速定位到目标键所在的HBlock,然后只需读取那个特定的HBlock,而不是整个HFile。
需要注意的是,HFile作为文件存储在HDFS上,因此它本身又会被分割成多个HDFS块(默认128MB)。一个HDFS块内可能包含许多HBase块。Region Server会尽量通过“短路读取”直接从本地磁盘读取数据,从而绕过HDFS的某些开销,实现高效访问。


写入流程:内存、预写日志与刷新




HFile是只追加且内部有序的,这引出了一个核心问题:如何实现随机写入和更新?



答案是:不直接修改HFile。HBase的写入流程是一个多级缓存和合并的过程。


以下是写入数据时涉及的组件:



- 写入内存(MemStore):当用户执行
Put操作插入或更新一个KV时,该KV首先被写入Region Server内存中的一个有序数据结构(如跳表或B树),称为MemStore。这保证了极低的写入延迟。 - 预写日志(Write-Ahead Log, WAL):为了保证持久性,在KV被写入MemStore之前,它会被先追加写入一个称为HLog(或WAL)的文件。HLog按到达顺序记录所有操作。这样,即使服务器崩溃,MemStore中的数据丢失,也能通过重放HLog来恢复。
- 刷新到磁盘(Flush):当MemStore的大小达到阈值时,其内容会被刷新(Flush) 到HDFS,生成一个新的、有序的HFile。由于MemStore中的数据已经有序,刷新过程是线性时间复杂度O(n) 的,非常高效。刷新后,MemStore被清空,对应的HLog段可以被安全删除。






读取流程:合并视图
读取数据时,系统需要提供一个统一的、包含最新数据的数据视图。





当用户请求读取某个键时,Region Server需要:
- 检查当前的MemStore。
- 检查磁盘上该Store对应的所有HFile(可能有很多个)。



系统会扫描所有这些位置,收集所有版本的KV,然后根据时间戳返回最新的版本。如果HFile很多,读取性能会下降。




文件合并:Compaction与LSM树



为了解决HFile数量膨胀导致的读放大问题,HBase引入了压缩(Compaction) 操作。


压缩会将多个较小的HFile合并成一个更大的、有序的HFile,并在此过程中清理已删除或过期的数据(旧版本)。合并多个已排序文件的算法也是线性时间复杂度O(n) 的(归并排序)。

这种将数据先写入内存,再顺序刷新到磁盘,并分层合并的存储结构,被称为日志结构合并树(Log-Structured Merge-Tree, LSM-Tree)。
以下是LSM-Tree的工作模式,类似于游戏“2048”:










- 第0层(L0):MemStore,活跃的内存缓冲区。
- 第1层(L1):从MemStore刷新产生的小HFile。
- 更高层(L2, L3...):由下层文件合并产生的更大、更老的HFile。
当某一层积累了一定数量(例如,达到阈值T)的文件时,这些文件会被合并到下一层的一个更大文件中。数据随着时间推移,从上层“沉降”到下层,文件也越来越大。这种结构特别适合写吞吐量高、读延迟要求相对宽松的场景(与B+树适用于低延迟读的场景形成对比)。


更新和删除在LSM-Tree中也被视为特殊的写入:更新是插入一个带新版本号的新KV;删除是插入一个标记删除的“墓碑(Tombstone)”记录。这些无效数据会在后续的压缩过程中被物理清除。




总结


本节课中我们一起学习了HBase物理存储的核心机制:

- 物理模型:数据以多维Key-Value为单元存储,通过Region和Column Family进行分区。
- 协调机制:使用ZooKeeper进行可靠的节点协调,取代了直接的心跳。
- 存储格式:HFile内部由数据块(HBlock)和索引组成,以实现高效的点查。
- 写入路径:写入先到WAL保证持久性,再到MemStore保证速度,最后批量刷新到HFile保证有序性。
- 读取路径:读取需要合并MemStore和多个HFile的视图,返回最新数据。
- 合并优化:通过Compaction合并小文件,控制文件数量,优化读取性能。
- 核心架构:上述流程共同构成了LSM-Tree,这是一种为高吞吐量写入而设计的经典存储结构。



理解这些底层原理,有助于我们更好地设计HBase表结构、配置参数以及诊断性能问题。
20:宽列存储 (4/4) 📚
在本节课中,我们将完成关于宽列存储(即 HBase 或 BigTable)的讲解。之后,我们将转向数据建模,特别是数据框架和验证。
概述

上一节我们介绍了 HBase 的低延迟之谜。本节中,我们将深入探讨其内部机制,包括缓存、优化、启动流程、数据本地性,并简要了解其演进方向。
缓存机制详解

HBase 的低延迟读写能力,建立在其数据存储于 HDFS 之上这一事实。HDFS 本身不具备低延迟特性,那么 HBase 是如何实现的呢?

关键在于,最近写入的数据(新鲜数据)被保存在内存中的 MemStore 里。只有当内存满了,MemStore 才会将排序后的键值对序列一次性刷新(Flush)到 HDFS 上。随后,系统会通过合并(Compaction)将大量小文件整合成越来越大的文件。
上一节有同学问及这与 CPU 的 L1/L2 缓存有何关联。你可以将 MemStore 视为一种“写缓存”,它暂存了尚未持久化到 HDFS 的数据。



同样,系统也存在“读缓存”。一些经常被访问或最近访问过的、已经存储在 HDFS 上的旧数据块(HFile Block)会被保留在内存中。这样,读取时就不必再从 HDFS 加载,从而提升了速度。需要注意的是,缓存是以 HFile 块 为单位进行的,而非单个键值对。

缓存失效的场景
然而,读缓存在某些场景下可能完全无效。
以下是两种主要情况:
- 批处理:当你需要读取整个数据集(例如使用 MapReduce)时,所有数据都必须被访问一次,缓存无法提供帮助。
- 完全随机访问:如果数据访问没有热点(即某些区域被频繁读写),访问模式是完全随机的,那么缓存命中率会很低,同样无效。




因此,是否需要以及如何利用缓存,完全取决于你的具体用例和访问模式。
读取优化策略
由于读取时可能需要在 MemStore 和所有 HFile 中查找目标键值,性能可能成为瓶颈。为了优化,HBase 采用了两种主要策略来跳过无关的 HFile。

以下是两种优化方法:
- 键值范围信息:每个 HFile 都记录了其包含的键值范围(最小键和最大键)。如果要查找的键不在这个范围内,就可以安全地跳过整个文件。
- 布隆过滤器:布隆过滤器是一种概率数据结构,它可以确定地告诉你某个键“肯定不在”某个 HFile 中(真阴性)。如果它说“可能在”,那只是一个“可能”的答案(可能为真阳性,也可能是假阳性)。这允许我们安全地跳过那些肯定不包含目标键的文件。
布隆过滤器通常使用多个哈希函数和一个位图来实现。其核心特性是:如果查询结果为“否”,则键一定不存在;如果结果为“是”,则键可能存在。
# 布隆过滤器原理示意(非实际代码)
class BloomFilter:
def __init__(self, size, hash_functions):
self.bitmap = [0] * size
self.hash_funcs = hash_functions
def add(self, key):
for h in self.hash_funcs:
index = h(key) % len(self.bitmap)
self.bitmap[index] = 1
def might_contain(self, key):
for h in self.hash_funcs:
index = h(key) % len(self.bitmap)
if self.bitmap[index] == 0:
return False # 肯定不存在
return True # 可能存在

这种利用额外信息来避免读取所有文件的优化思想,在大数据领域非常普遍。
系统启动与元数据表
理解了数据存储和读取后,一个自然的问题是:客户端如何知道哪个 RegionServer 负责哪个数据区域(Region)?
直觉上,你可能认为 HMaster 掌管这一切,就像 HDFS 中 NameNode 记录数据块位置一样。但事实并非如此。HMaster 主要负责表的创建、删除等管理任务,而记录 Region 位置信息的职责被委托给了一个特殊的表——元数据表。

元数据表(Meta Table)也是一个 HBase 表,但它有两个关键不同点:
- 它非常小(通常只有几MB),因此可以完整地存放在一台机器上,无需分片。
- 集群中的所有节点都知道这个表的位置。这是通过一个名为 ZooKeeper 的分布式协调服务实现的。ZooKeeper 使用类似 Paxos 的共识算法,让集群能够就“元数据表在哪里”这个事实达成一致。
因此,客户端要查询数据时,流程如下:
- 通过 ZooKeeper 找到持有元数据表的 RegionServer。
- 查询元数据表,获取目标键所在 Region 及其对应的 RegionServer 地址。
- 直接连接到该 RegionServer 进行读写操作。


元数据表的结构大致如下,它存储了所有 Region 的映射信息:
- Region 所属的表
- Region 的键值范围(起始行键)
- 负责该 Region 的 RegionServer 地址
- 该 Region 的状态(如是否正在分裂)
Region 分裂的高效处理
当某个 Region 变得过大时,RegionServer 会将其分裂成两个。这个过程被设计得非常高效,并非原子性地立即完成。

分裂过程大致如下:
- RegionServer 决定分裂一个 Region。
- 它首先更新元数据表,标记原 Region 为“正在分裂”,并添加两个新 Region 的条目及其负责的 Server(可能暂时还是自己)。
- 客户端查询元数据表后,会看到分裂状态,并根据要访问的键,连接到对应新 Region 的(临时)地址。
- 此时,新的 RegionServer 实际上仍从旧的、未分裂的 HFile 集合中读取数据,只是会忽略不属于自己那部分键范围的数据。
- 分裂操作是异步的。在后续的 Flush 或 Compaction 过程中,旧的 HFile 会被逐步清理,并真正为两个新 Region 生成独立的 HFile。
这种方式避免了在分裂时立即进行大量数据移动,保证了服务的持续可用性和高性能。



数据本地性

HBase 的另一个重要性能优势是数据本地性。HBase 的 RegionServer 和 HDFS 的 DataNode 通常部署在同一组机器上。
当 RegionServer 执行 Flush 或 Compaction 写入新的 HFile 到 HDFS 时,HDFS 客户端(即 RegionServer 本身)会遵循“第一个副本写入本地”的策略。因此,这些数据块的第一份副本就存储在该 RegionServer 所在的本地磁盘上。


当该 RegionServer 需要读取这些数据时,它可以直接从本地磁盘读取,无需经过网络。这被称为短路读取,它打破了 HDFS 客户端必须通过 DataNode 协议读取数据的常规,但由于它们同属 Hadoop 生态,这种“作弊”是被允许的,能极大提升读取速度。






当然,HDFS 的负载均衡机制可能会将数据块副本移动到其他机器,破坏本地性。但下一次该 Region 发生 Compaction 时,新写入的文件又会将第一副本拉回本地机器,从而重新获得本地性优势。








演进方向:Spanner 简介
HBase 和 BigTable 的设计理念持续演进。例如,Google 的 Spanner 就是 BigTable 的进化版,它解决了一些 HBase 的限制,并支持全球级分布式部署。
以下是 Spanner 的一些关键改进:
- 多列主键:HBase 的行键(RowKey)是单一列。Spanner 支持由多列组成的复合主键。
- Tablet 而非 Region:Spanner 的管理单元是 Tablet。一个 Tablet 可以包含多个不连续的键值区间,这比 HBase 的连续区间 Region 更灵活,便于分裂与合并。
- 全球分布:Spanner 设计用于跨多个数据中心乃至全球部署,通过一个全局的“Universe Master”进行协调,能够管理数万亿行数据、数百万台机器构成的集群。


















问答精选
问:为什么需要如此巨大的表(如 Spanner),而不直接使用对象存储?
答:宽列存储与对象存储的关键区别在于数据索引的维度。对象存储(如键值存储)通常是一维的(桶ID+对象ID)。而宽列存储将数据组织在一个二维网格中(行键 × 列族/列限定符),适合存储大量稀疏的、结构类似但内容不同的中小型数据(如 JSON、XML 文档)。对于真正的海量单体大文件(如视频),对象存储更合适。








问:将元数据表放在一个 RegionServer 上,是否引入了单点故障?
答:不会。首先,元数据本身也存储在 HDFS 上,数据不会丢失。其次,如果持有元数据表的 RegionServer 宕机,ZooKeeper 的选举机制会迅速在集群中选出另一台可用的 RegionServer 来接管并服务元数据表。这个过程很快,确保了高可用性。之所以不放在 HMaster 上,是因为 HMaster 进程内没有服务表数据的代码逻辑,而 RegionServer 本身具备服务任何表(包括元数据表)的能力。

总结
本节课中,我们一起深入学习了 HBase 的核心机制。我们探讨了其实现低延迟的缓存策略(MemStore 和读缓存),了解了通过键值范围和布隆过滤器进行的读取优化。我们解析了客户端如何通过 ZooKeeper 和元数据表定位数据的启动流程,以及 Region 分裂的高效异步处理方式。我们还分析了 HBase 利用数据本地性实现短路读取的性能优势。最后,我们展望了宽列存储的演进方向,以 Google Spanner 为例,了解了多列主键、灵活 Tablet 和全球级分布式等高级特性。
重要的是,学习这些具体系统时,应着眼于其高层设计思想和权衡取舍,因为技术总是在不断演进。






21:数据模型(1/4)🌳

在本节课中,我们将学习数据模型的概念,特别是如何将XML和JSON等语法结构抽象为逻辑上的树形模型。我们将从回顾已学知识开始,逐步理解语法与数据模型之间的关系,并最终掌握将JSON和XML视为树形结构的方法。
回顾与高层次总结
在深入数据模型之前,我们先从高层次总结一下从HDFS和HBase中学到的经验。
以下是十个关键经验:

第一课:借鉴过去
即使我们试图构建替代传统关系型数据库(如PostgreSQL)的系统,我们仍然保留了数据独立性的思想。例如,我们仍然使用表的概念,在HBase中我们也会看到编程语言等。借鉴过去是基础。XML和JSON的发展就是一个例子:最初因为觉得XML太复杂而独立开发了JSON,但后来发现JSON同样需要模式、指针、导航和查询等功能。这导致JSON的发展可能延迟了十年。因此,借鉴过去的经验可以加速发展。
第二课:保持设计简单
以对象存储(如S3)为例,其模型非常简单:桶(Bucket)和对象ID(Object ID)标识一个对象,操作只有获取、放置和删除。这种简单性在构建大规模系统时尤为重要,也是数据独立性精神的体现——暴露简单的API,而将复杂的物理实现隐藏起来。

第三课:架构模块化
作为计算机科学家,我们知道模块化能使代码更优雅、程序更好。通过模块化,我们可以重用组件,并以人类大脑能够理解的方式组织系统。例如,我们有独立的存储、语法、验证和查询语言等层次,它们相互构建。就像硬盘连接到运行Linux操作系统的计算机,再通过网络在数据中心连接,之上是HDFS,再之上是HBase。这种分层设计远比将所有功能塞进一个单一的代码库要清晰和可管理。
第四课:大规模同质性与小规模异质性
为了扩展到超大规模,系统需要在高层保持同质性。例如,从远处看HDFS,你只看到文件和目录组织块;看S3,只看到与键关联的对象;看HBase,只看到行ID、列族和列值。但当你放大看一个特定对象或HBase表中的单元格时,会发现它们与邻居不同,这就是小规模的异质性。例如,HBase的同一列中可以存放具有不同模式的XML或JSON文档,S3的对象可以存放数据集、文件、图片等任何内容。这种高层同质性与底层细节多样性的对比,赋予了系统在大规模下的灵活性。
第五课:数据独立性
我们需要抽象出一个独立于物理实现的逻辑模型。这意味着,当你看关系表时,不应关心比特如何存储在磁盘上,你只看到行和列。看HDFS时,不应关心块如何实际存储在磁盘上,你只看到逻辑块和目录文件的逻辑层次结构。这些细节被数据节点在幕后隐藏处理。同样,HBase暴露列族和行ID,独立于HDFS。当你处理HBase时,不再关心HDFS。有趣的是,我们上周视为逻辑层的HDFS,相对于HBase突然变成了物理层。这说明了层次之间的相对性。保持层次间清晰的API对于维持系统清晰度至关重要。



第六课:数据分区(分片)
我们到处都在构建数据分区,即分片。在HDFS中,我们有128MB的块;在HBase中,我们按区域和列族进行分区。分片有很多名称:分片、块、拆分、分区等。我们不仅将数据切割成这些块,还会复制每个块。在HDFS中,默认复制因子是3。然后,我们将所有这些块分散到众多机器上。想象一下,将数据切成许多小块(如100MB),复制它们,然后像将水倒入杯子一样,让这些块均匀地散布在整个集群的机器上。这种粒度使得每台机器上都有数千个块,从而实现平滑的数据分布。

第七课:使用大量廉价机器
我们不需要更强大的单个机器,而是需要大量机器。在一个数据中心,你可能拥有成千上万台机器,甚至数十万、百万台。关键是使用大量廉价硬件。我们将复制的分片分散到这些廉价硬件上,这是实现大规模处理的基础。
第八课:批处理
我们采用批处理。例如,在HBase中,我们不是每次用户创建新单元格时就写入磁盘(这样效率很低),而是将键值对累积在内存中,直到形成足够大的批次,然后一次性写入(刷新)到HDFS。这就是批处理。我们稍后会在MapReduce和Spark中再次看到这种模式。

语法与数据模型的联系
现在,我们来看看语法和数据模型之间的联系。以CSV为例,这是最直观的。



当你有一个CSV文件时,它是文本。通常,它使用逗号分隔单元格,使用换行符分隔行。但如果你以抽象的视角看,你应该看到一个表格。这正是电子表格软件打开此类文件时显示的内容:一个抽象的表格。

本讲座的目标是,让你在看完后,能够像看CSV看到表格一样,看XML或JSON时能看到树。



将JSON视为树🌲

首先,我们以JSON为例,因为它是最简单的。

回想一下关于JSON的语法,它有六个构建块:字符串、布尔值、数字、null、对象和数组。




两周前,我将它们呈现为语法构建块,展示了对象使用花括号{},数组使用方括号[]。但现在,我希望你能从中看到一棵树。



我的第一个问题是:在座的各位,谁已经能看到一棵树了?(现场和Zoom上都有一些人举手)。谁还看不到树?没关系,在接下来的时间里,我会展示如何从JSON构建出树形结构。我的目标是让这成为你的本能——只要你看到JSON或XML,就会自发地看到一棵树,因为从本质上讲,它们就是树。而且,我们将看到这是关系表的一种非规范化形式。




总结
本节课中,我们一起回顾了从HDFS和HBase中学到的十个高层次经验,包括借鉴过去、设计简单化、架构模块化、平衡同质性与异质性、数据独立性、数据分区、使用廉价硬件和批处理。接着,我们探讨了语法(如CSV文本)与逻辑数据模型(如表格)之间的联系。最后,我们引入了本节课的核心目标:学习将JSON和XML语法抽象为树形数据模型。在接下来的部分,我们将深入具体方法,掌握这种重要的数据视角。

注意:课程内容在此处暂停,教授宣布休息15分钟,后续将继续讲解如何具体将XML和JSON视为树形结构。
22:数据模型 (2/4) 🌳
在本节课中,我们将学习如何将JSON和XML文档视为树形结构,理解它们在内存中的表示方式,并初步了解数据验证(Validation)和模式(Schema)的概念。













从JSON/XML语法到内存中的树







上一节我们介绍了JSON和XML的基本语法。本节中我们来看看这些语法结构在计算机内存中是如何表示的。



当我们处理一个JSON或XML文件时,第一步通常是使用解析器(Parser)将其读入内存。解析器会根据语法规则分析文本,但它在内存中构建的并不是文本本身,而是一个树形结构。











将JSON对象可视化为树







让我们通过一个具体的JSON例子来理解这个过程。


{
"foo": true,
"bar": [{"a": 1}, null]
}





这个JSON对象可以转化为以下树形结构:






- 根节点是一个对象(用紫色矩形表示)。
- 这个对象有两条边(箭头)指向它的值:
- 标签为
"foo"的边指向一个原子值true(用黄色矩形表示)。 - 标签为
"bar"的边指向一个数组(用红色矩形表示)。
- 标签为
- 这个数组包含两个元素:
- 第一个元素是一个对象(紫色),它有一条标签为
"a"的边指向原子值1。 - 第二个元素是原子值
null(黄色)。
- 第一个元素是一个对象(紫色),它有一条标签为










核心概念:在JSON的树模型中,键(Key)是边的标签,而节点(矩形)代表值(对象、数组或原子值)。这是一个有根树,箭头从父节点指向子节点。




将XML文档可视化为树






XML文档同样可以转化为树。例如下面这个XML:






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






其对应的树形结构如下:



- 根节点是一个文档节点。
- 文档节点的子节点是
<metadata>元素节点。 <metadata>有两个子元素节点:<title>和<publisher>。<title>节点:- 拥有属性节点
lang="en"和year="2022"。 - 拥有一个文本子节点,内容为
"Sys. Group"。
- 拥有属性节点
<publisher>节点拥有一个文本子节点,内容为"ETH Zurich"。



核心概念:在XML的树模型(如XML信息集)中,元素名和属性名是节点本身的属性,而不是边的标签。这与JSON模型的一个关键区别。
解析与序列化 🔄
理解了树形结构后,我们需要两个重要的动词来描述数据在磁盘和内存之间的转换过程。
以下是这两个过程的核心描述:

- 解析 (Parsing):将磁盘上的JSON/XML文本语法,通过解析器读入内存,构建成树形结构的过程。
- 公式表示:
文本语法 -(解析)-> 内存中的树
- 公式表示:
- 序列化 (Serializing):将内存中的树形结构转换回JSON/XML文本语法,并写入磁盘或网络的过程。
- 公式表示:
内存中的树 -(序列化)-> 文本语法
- 公式表示:



重要认识:程序在内存中处理数据时,操作的是树节点及其关系,而非原始的文本字符串。语法只是用于交换和存储的格式。



正式的数据模型:XML信息集示例

为了精确地在内存中表示XML文档,W3C制定了 XML信息集(XML Infoset) 标准。它定义了树中可能出现的节点类型、它们的属性以及访问方法。



我们主要关注以下四种节点类型:
- 文档信息项:代表整个文档。
- 元素信息项:代表XML元素。
- 属性信息项:代表元素的属性。
- 字符信息项:代表元素或属性内的文本内容。

每个信息项都有其属性。例如,一个“元素信息项”可能拥有以下属性:
local name:元素名称(如"title")。children:子节点列表。attributes:属性节点列表。parent:指向父节点的引用。


这些属性(如 parent 和 children)定义了树中的边,使我们能够在节点间双向导航。这种正式的定义是构建查询语言的基础。

良构性与验证 ✅
处理文档时,有两个层次的质量检查:

- 良构性 (Well-formedness):这是一个“是/否”问题。检查文档是否符合JSON/XML的基本语法规则(如标签闭合、引号匹配)。如果文档不是良构的,解析器将报错并无法构建树。
- 公式表示:
文档 -(解析器)-> 成功构建树? (是/否)
- 公式表示:








- 验证 (Validation):在文档良构的基础上,进一步检查其结构是否符合某个特定的模式(Schema)。模式定义了数据应遵循的约束,例如:
- “
person对象必须包含age字段,且其值必须为数字。” - “
title元素必须包含lang属性。” - 公式表示:
内存中的树 -(依据模式Schema检查)-> 有效? (是/否)
- “




验证的附加作用:注解 (Annotation)
验证过程常常伴随“注解”。例如,模式可能规定某个字段必须是整数。验证器在确认文本 "42" 符合整数格式后,可以将内存中对应的文本节点“注解”或转换为一个真正的整数类型节点,从而提升后续数值运算的效率。












模式与数据集合








在“大数据”场景中,我们通常处理的是海量文档的集合。




- 无模式的异构集合:集合中的文档结构各异。这提供了灵活性,适合处理历史日志等自然演变的数据,但可能牺牲一定的处理性能。
- 有模式的同构集合:所有文档都通过同一个模式进行验证。这确保了数据结构的一致性。
- 优势:同质化的数据允许系统使用更高效的内存布局和存储格式(如列式存储),从而大幅提升查询和分析性能。
- 权衡:写入数据时需要额外的验证开销,但读取性能会得到优化。这是一种典型的“空间/时间”或“写/读”权衡。


模式语言(如JSON Schema, XML Schema)允许我们定义丰富的约束,从简单的类型检查(“必须是字符串”)到复杂的结构断言(“数组的第一个元素是对象,且必须包含id字段”)。











总结
本节课我们一起学习了:
- 核心模型:JSON和XML文档在内存中被表示为树形结构。JSON的键在边上,XML的节点名在顶点上。
- 关键过程:解析将文本转为内存树,序列化将内存树转回文本。
- 正式定义:以XML信息集为例,了解了如何正式定义数据模型的节点类型、属性和访问器。
- 质量层次:区分了良构性(语法正确)和验证(结构符合模式)。
- 模式价值:引入模式进行验证,可以规范数据结构,将异构集合变为同构集合,从而为高性能数据处理奠定基础。

理解数据如何从文本变为内存中的树,是掌握后续所有数据查询、转换和分析技术的基础。
23:数据模型 (3/5) - 类型系统基础
在本节课中,我们将学习数据建模中类型系统的基础知识。我们将探讨原子类型与结构类型的区别,了解各种常见的数据类型,并介绍如何通过模式(Schema)来验证数据。这些概念是理解JSON Schema、XML Schema等具体技术的基础。

课程回顾与引入
上一节我们讨论了数据建模,最重要的结论是:JSON或XML等语法只是数据的物理存储层。当处理这些数据时,我们首先将其解析为基于树结构的内存表示,后续所有操作都基于这个内存表示进行。序列化则是相反的过程。
我们还看到,一旦数据加载到内存,就可以进行验证。我们可以为数据定义约束或预期结构(即模式),然后回答两个问题:文档格式是否良好?文档是否有效(符合模式)?
验证数百万个对象后,如果模式约束足够强,整个集合就会变得同质化。所有文档都具有相同的结构,这使得处理效率更高,因为可以进行内存压缩,甚至以更节省空间的二进制格式写回文件。
现在存在许多不同的验证技术,不仅限于JSON和XML。这看起来令人望而生畏,但好消息是:所有这些技术的类型系统看起来都非常相似。学习其中几个,就相当于学会了全部,因为核心原则是通用的。
因此,本节内容在技术上是通用的,几乎适用于所有模式技术。
原子类型与结构类型 🧱

所有类型系统共有的第一个特征是区分两种类型:原子类型和结构类型。
这并不令人意外。在第一范式中,我们希望所有类型都是原子的,没有嵌套结构。在JSON中,有六个构建块:四个原子类型(字符串、数字、布尔值、null)和两个结构类型(对象、数组)。XML也是如此。
可以将原子类型视为树表示中的叶子节点,将结构类型视为中间节点(非终结符)。
原子类型详解
以下是通用的原子类型分类,它们在不同技术中名称可能不同,但逻辑概念一致。
1. 字符串
字符串是字符序列。在内存中,它们通常已经是Unicode码点(如UTF-8)。从数学角度,字符串形成一个幺半群结构,可以连接,空字符串是中性元素。
- 公式/逻辑表示:
String = Sequence(Char)


2. 数字
数字类型本身就是一个“动物园”,从数据库视角有以下分类:
- 固定位整数:对应存储的物理限制,逻辑上表示一个区间。
uint8: 0 到 255int8: -128 到 127int32: -2,147,483,648 到 2,147,483,647int64: -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
- 任意精度十进制数:数据库系统中常见,可以指定总精度和小数点后精度。
- 逻辑表示:任何可以用有限位数十进制表示的实数。
- 浮点数:遵循IEEE 754标准,高效的科学计数法表示。
float(32位): 约7位十进制精度。double(64位): 约15位十进制精度。- 注意:在Python中,
float对应其他语言的double。

3. 布尔值
只有两个值:true 和 false。
4. 日期与时间
在涉及客户的系统中非常重要。
- 日期:年、月、日(例如格里高利历)。
- 时间:时、分、秒,可精确到毫秒或微秒。
- 时间戳:日期和时间的组合。
- 逻辑值:人类可读的日期时间。
- 内部表示:通常为自1970年1月1日以来的毫秒数(整数)。




5. 持续时间
两个时间点之间的差值。可以用年、月、日、时、分、秒表示。
- 注意:跨越“月”和“日”的表示(如“4个月3天”)是模糊的,因为每月的天数不同。理想情况下,应避免跨越此界限。
6. 二进制类型
字节序列。可以是图片、视频等任何内容。
- 逻辑表示:
Binary = Sequence(Byte) - 文本表示:Base64或十六进制(Hex)编码。





7. 空值
在JSON和XML中,null 作为一个特殊值,表示值的缺失。在许多系统中,null 与值完全缺失是不同的概念。可以认为存在一个 null 类型,它只有一个值 null。

词法空间与值空间 🔄

JSON和XML是文本文件。当我们说期望一个“日期”类型时,文本中并没有“日期”,只有字符串、数字等。这是通过词法值(或字面量)的概念实现的。
类型系统是数学抽象。例如,“日期”类型的值空间是所有可能日期的集合。“整数”类型的值空间是所有整数的集合。我们指的是数学概念上的值。
当在文本中存储时,我们存储的是词法值。它本质上是一个字符串,但可以被解码回实际的值(反序列化),或从值编码而来(序列化)。

示例:数字 4
- 词法值/字面量:
4,+4,4.0,100(二进制,b表示二进制) - 类型值:数学概念上的
4 - 在JSON或XML中读取时,得到的是词法值。一旦使用模式验证,我们就在内存中将其视为类型值,可能不再保留原始词法值。
示例:日期
- 词法值:遵循ISO 8601标准,如
2024-10-23 - 类型值:2024年10月23日这个逻辑日期
- 重要建议:在项目中编码日期时,请使用ISO 8601等标准格式,不要自己发明,以确保互操作性。
示例:时间戳
- 词法值:ISO 8601格式,如
2024-10-23T14:30:00Z(Z表示GMT) - 类型值:特定的日期时间点

示例:持续时间
- 词法值:ISO 8601格式,如
P1Y2M3H(P表示周期,1年2个月3小时) - 类型值:一段时长







每个原子类型都有三个组成部分:
- 值空间:所有逻辑值的集合。
- 词法空间:所有可以表示其中一个类型值的词法表示(字符串)的集合。
- 映射:在词法空间和值空间之间来回转换的函数。映射可能不是单射的(多个词法值可能对应同一类型值),因此通常有一个规范词法值作为首选的序列化格式。






超类型与子类型 📊
在许多系统中,存在超类型和子类型的概念。
- 如果定义得当,子类型的值空间是超类型值空间的子集。
- 这是面向对象编程中继承概念的基础,但在类型系统中更严格地指值集合的包含关系。





结构类型 🗂️
从抽象角度看,结构类型通常只有两种:列表和映射。在不同技术中名称可能不同。
- 在JSON中,列表是数组,映射是对象。
- 在XML中,元素的子节点列表可以视为列表,元素的属性集可以视为映射。
- 在数据帧(如Pandas)中,也对应列表和映射(结构)。







列表和映射是最通用的结构类型。许多系统允许通过定义具有特定键和值类型的映射来创建用户自定义类型,并为其命名。有些系统还支持集合(Set)等更多结构类型,但列表和映射几乎无处不在。


基数性 🔢


基数性概念在许多系统中都存在。它指定了某个元素可以出现多少次。
- 在JSON中,指数组中可以有多少个元素。
- 在XML中,指一个元素可以拥有多少个具有特定名称的子元素。


常见的基数性有四种,通常用正则表达式符号表示:
- 零个或多个:
* - 零个或一个:
? - 一个或多个:
+ - 恰好一个:(默认,或无符号)

其他技术可能使用不同术语,例如Google的Protocol Buffers中使用 required(恰好一个)、repeated(零个或多个)、optional(零个或一个)。但根本上是这四种基数性。





跨技术类型名称对照 🌐


不同技术中的类型名称可能不同,但核心概念相同。以下是一个简化的对照表示例:
| 概念 | Python | PostgreSQL (SQL) | Spark SQL | JSON Schema | XML Schema | JSo (教学用) |
|---|---|---|---|---|---|---|
| 字符串 | str |
text, varchar |
string |
string |
xs:string |
string |
| 32位整数 | int (有限) |
integer |
int |
integer |
xs:int |
integer |
| 64位整数 | int (大数) |
bigint |
bigint |
integer |
xs:long |
integer |
| 双精度浮点 | float |
double precision |
double |
number |
xs:double |
double |
| 布尔值 | bool |
boolean |
boolean |
boolean |
xs:boolean |
boolean |
| 日期 | datetime.date |
date |
date |
string (格式) |
xs:date |
date |
| 时间戳 | datetime.datetime |
timestamp |
timestamp |
string (格式) |
xs:dateTime |
dateTime |
| 二进制数据 | bytes |
bytea |
binary |
string (格式) |
xs:base64Binary |
base64Binary |
关键点:类型的基本思想大同小异。有些类型可能在某些系统中缺失,名称也可能不同,但本质上是一致的。了解这个通用框架后,学习任何新的模式技术都会容易得多。
JSo:一个教学用模式语言 🎓




为了直观理解模式,我们首先看一个为教学设计的简化模式语言:JSo。它的语法非常接近JSON本身,便于建立直觉。之后我们会学习实际中广泛使用的JSON Schema和XML Schema。



在JSo中:
- 模式:也是一个JSON文件,定义了数据的约束。
- 实例:需要根据模式进行验证的JSON文档。
- 术语类比:在Java中,类是模式,对象是类的实例。同样,一个模式可以有多个符合它的JSON实例。
JSo 基础示例


示例1:验证字符串
- 模式:
{"last": "string"} - 实例:
{"last": "Doe"} - 解释:模式规定,如果存在键
"last",其关联值必须是字符串类型。实例中"Doe"是字符串,验证通过。如果值是对象、数组、数字或布尔值,且未被引用,JSo会尝试将其强制转换为字符串(例如数字42会变成字符串"42")。



示例2:多个可选字段
- 模式:
{"last": "string", "first": "string"} - 实例1:
{"last": "Doe", "first": "John"}(有效) - 实例2:
{"last": "Doe"}(有效,"first"缺失,默认可选)
示例3:必需字段
- 模式:
{"last!": "string", "first!": "string"}(使用!表示必需) - 实例:
{"last": "Doe", "first": "John"}(有效) - 无效实例:
{"last": "Doe"}(缺少必需的"first")




示例4:验证数字、布尔值和null
- 模式:
{"age": "integer", "active": "boolean", "nickname": "null"} - 实例:
{"age": 30, "active": true, "nickname": null}(有效) - 注意:验证时关注的是词法值。即使数字被引号包围(
"30"),只要词法值能映射到整数类型,验证也会通过。




关于引号和词法值的说明
JSON语法本身只有四种原始类型:字符串、数字、布尔值和null。模式验证是在内存表示之上添加的一层。我们不想将自己限制在这四种类型,而是希望使用日期、二进制数据等丰富类型。
因此,设计上更清晰的做法是:只关注词法值本身,而不关心它在原始JSON中是否有引号。我们检查JSON中的词法值是否位于所期望类型的词法值空间内。
例如,日期必须放在引号中作为JSON字符串,否则JSON本身格式就不正确。验证时,我们查看内存中该字符串的词法值,判断它是否符合日期格式。


JSo 中的类型列表




JSo支持W3C标准中定义的多种类型,包括各种数字、日期时间、持续时间、二进制类型等。名称直观,易于理解。





处理数组和“任意”类型



还记得昨天提到的异构数组吗?在模式中,可以使用 item 类型来表示“任何东西”。
- 模式:
{"tags": ["item"]} - 实例:
{"tags": ["red", 42, true, {"id": 1}]}(有效,因为item接受任何类型)


item 是表示任意类型的通用名称。你也可以指定更具体的类型,例如数组中的每个元素都必须是对象。
示例:对象数组
- 模式:
{"people": [{"name": "string", "age?": "integer"}]} - 实例:
{"people": [{"name": "Alice"}, {"name": "Bob", "age": 25}]}(有效)

JSo的语法非常直观,看起来很像实例本身。实际上,你可以很容易地将一个实例“转换”成一个宽松的模式,只需用类型名称替换具体的值即可。

总结 🎯
本节课我们一起学习了数据建模中类型系统的通用基础知识。
我们首先回顾了数据验证的重要性,即通过模式定义约束,使数据集合同质化,从而提高处理效率。


接着,我们深入探讨了类型系统的核心分类:原子类型(如字符串、数字、布尔值、日期等)和结构类型(主要是列表和映射)。我们理解了每个原子类型包含值空间、词法空间以及两者间的映射关系,这是数据序列化与反序列化的基础。

我们还了解了基数性的概念,它描述了元素出现的次数(零个/一个/多个)。



最后,我们通过教学语言 JSo 初步实践了如何编写模式来验证JSON实例,看到了模式与实例在结构上的相似性,为后续学习更复杂的JSON Schema和XML Schema打下了坚实的基础。



记住,尽管不同技术的类型名称可能各异,但其核心思想是相通的。掌握了这些通用原则,你就能更快地学习和应用任何新的数据模式技术。






下一讲我们将继续深入,学习实际工业标准——JSON Schema的具体细节和应用。
24:数据模型(4/5)🌳





在本节课中,我们将完成对数据模型的讨论。我们将重点学习如何为树形结构数据(如XML和JSON)定义和使用模式(Schema),理解模式验证的核心概念与功能,并比较几种主流模式语言(如JSON Schema和XML Schema)的异同。













上一节我们介绍了XML和JSON本质上是树形结构。本节中,我们来看看如何为这些树形数据定义结构规则,即模式验证。








模式验证允许我们在确认一个文档格式良好(well-formed)并解析为内存中的树形表示后,进一步检查它是否符合模式所规定的结构。模式定义了数据的预期形状和类型。

以下是模式语言通常具备的核心功能:








-
类型限制:可以指定某个键(key)对应的值必须是特定类型。
- 公式/代码示例:
"last": string或"age": integer
- 公式/代码示例:
-
隐式类型转换(注解):验证过程不仅检查值,还可能将其从文本形式(词法值)转换为内存中的特定类型。
- 公式/代码示例:词法值
"42"(字符串) 在通过integer类型验证后,在内存中被转换为整数42。
- 公式/代码示例:词法值



-
字段必选/可选:可以指定某个键是必须存在的(必选)还是可以省略的(可选)。默认设置是可选还是必选,是模式语言的设计选择。
-
控制额外字段:可以指定是否允许实例中出现模式中未定义的额外字段。同样,默认允许或禁止也是设计决策。



- 默认值:可以指定当实例中缺少某个可选键时,系统应自动填充的默认值。


- 主键定义:对于对象数组(类似于表格),可以指定哪个或哪些字段构成主键,并确保其唯一性。




模式语言设计:静态与动态语法
设计模式语言时,语法可以是静态的或动态的。





- 静态语法:模式中的键(属性名)是预定义、固定的。用户只能改变这些键对应的值。这使得语法可预测且易于工具处理。
- 动态语法:模式中的键可以由模式设计者自由定义,更接近实例本身的结构,可能更直观,但灵活性可能导致设计复杂。










许多模式语言(如JSON Schema)混合使用了这两种方式。














JSON Schema 示例





JSON Schema 是一个广泛使用的模式语言。其模式本身也是一个JSON文档。






以下是一个简单的JSON Schema示例,它要求一个对象必须有 "last" 字段(字符串类型),并可选地包含 "first" 字段(字符串类型):






{
"type": "object",
"properties": {
"last": { "type": "string" },
"first": { "type": "string" }
},
"required": ["last"],
"additionalProperties": false
}


"type","properties","required","additionalProperties"是静态的关键字。"last"和"first"是动态定义的属性名。"required": ["last"]使last字段成为必选。"additionalProperties": false禁止出现任何未在properties中定义的额外字段。



XML Schema 示例
XML Schema 用于定义XML文档的结构,其模式本身也是XML格式,并且完全采用静态语法。
以下是一个简单的XML Schema示例,它定义了一个 person 元素,其中必须按顺序包含 last 和 first 子元素,且两者都是字符串类型:
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="person">
<xs:complexType>
<xs:sequence>
<xs:element name="last" type="xs:string"/>
<xs:element name="first" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
schema,element,complexType,sequence,minOccurs都是预定义的静态元素和属性。minOccurs="0"使first元素成为可选的。
类型系统与映射
理解数据模型时,一个重要概念是类型系统映射或阻抗不匹配。



- 词法空间 vs. 值空间:原始数据(如JSON中的
"42")是词法值。模式验证检查该词法值是否属于目标类型(如integer)的词法空间。通过后,它被转换为该类型的值(整数42)。 - 跨语言差异:不同的编程语言(Python, Java, JavaScript)和数据处理框架(Spark, Pandas)拥有不同的类型系统。将一种模式语言(如JSON Schema)定义的类型映射到具体执行环境的类型时,需要仔细定义映射关系。例如,一个没有小数点的数字可能被映射为Python的
int或Java的Long,这取决于具体实现。






总结










本节课中我们一起学习了数据模型的核心环节——模式验证。





我们了解到,模式验证为树形数据(JSON/XML)提供了结构、类型和约束的定义能力。无论是JSON Schema、XML Schema还是其他模式语言,它们都共享诸如类型约束、必选/可选字段、控制额外字段、默认值等核心功能。这些语言在语法设计上可能有静态和动态之分,但其目标一致:确保数据符合预期结构。






理解模式语言的关键在于把握其设计理念,而不仅仅是记忆特定语法。这包括如何处理默认行为、类型转换以及在不同系统间的类型映射。掌握这些概念,将有助于你在未来轻松学习和应用各种数据定义与验证工具。



25:数据模型 (5/5) 📊


在本节课中,我们将学习XML Schema的高级特性,并深入探讨数据帧(Data Frame)的概念。我们将看到如何将JSON文档集合映射为表格形式,并理解不同数据格式(如Parquet)背后的核心思想。



上一节我们介绍了XML Schema的基础。本节中,我们来看看XML在出版业等文本导向场景中的应用。




在XML Schema中,可以使用 mixed="true" 来定义混合内容类型。这允许元素内部既包含文本,也包含其他子元素,类似于LaTeX中的标注方式。
<xs:complexType mixed="true">
<xs:sequence>
<xs:element name="B" type="xs:string" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
上面的Schema定义了一个复杂类型,其中可以包含字符串类型的元素 B,并且允许在 B 元素周围存在自由文本。maxOccurs="unbounded" 表示 B 元素可以出现任意多次。这是XML在处理出版物或富文本文档时的一大优势。
接下来,我们探讨如何验证XML属性。

属性的类型与元素的原子类型相同,因为属性不能包含复杂类型。定义方式如下:
<xs:element name="person">
<xs:complexType>
<xs:sequence>
<xs:element name="lastName" type="xs:string"/>
<xs:element name="firstName" type="xs:string"/>
</xs:sequence>
<xs:attribute name="birth" type="xs:date"/>
</xs:complexType>
</xs:element>

在这个例子中,person 元素是一个复杂类型,包含一个由 lastName 和 firstName 元素组成的序列,并拥有一个 birth 属性,其类型为 date。这与只包含文本的简单内容元素使用相同的类型系统。




为了将概念具体化,我们以一个“语言游戏”的数据为例,并将其转换为XML格式。


以下是转换后的XML示例结构:
<games>
<game>
<guess>...</guess>
<target>...</target>
<country>...</country>
<choices>...</choices>
<date>...</date>
</game>
</games>

在定义Schema时,需要做出设计选择:是使用元素还是属性来存储数据。例如,sample 在这里被定义为属性,因为它像是游戏的元数据。但请注意,这没有绝对的对错,一旦做出选择,就可以用Schema来定义和约束它。



对应的Schema定义如下:
<xs:element name="games">
<xs:complexType>
<xs:sequence>
<xs:element name="game" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="guess" type="xs:string"/>
<xs:element name="target" type="xs:string"/>
<xs:element name="country" type="xs:string"/>
<xs:element name="choices">
<xs:complexType>
<xs:sequence>
<xs:element name="choice" type="xs:string" maxOccurs="4"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="date" type="xs:date"/>
</xs:sequence>
<xs:attribute name="sample" type="xs:hexBinary"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>




这里,sample 属性使用了 hexBinary 类型。这是一个很好的例子,用于说明词法值(lexical value)和类型值(typed value)的区别。在文档中,它可能写作 “F8F9” 这样的十六进制字符串,但经过Schema验证并在内存中处理后,它会被转换为一串字节。






一个有趣的事实是,XML Schema本身也有一个用于验证其他Schema的元模式(meta-schema)。这意味着Schema可以验证自身,这同样适用于JSON Schema。这虽然更多是一种理论上的完备性体现,但非常酷。








现在,让我们转向数据帧(Data Frame)的概念。如果你通过Pandas或Spark了解过数据帧,本节将从另一个角度——即有效JSON文档的集合——来阐释它。




假设我们有一个JSON Schema,它定义了三个键:id(整数)、name(字符串)、living(布尔值),并且默认是封闭的("additionalProperties": false),不允许额外属性。




{
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"living": { "type": "boolean" }
},
"additionalProperties": false
}



同时,我们有以下四个符合该Schema的JSON文档:
{"id": 1, "name": "Einstein", "living": false}
{"id": 2, "name": "Newton", "living": false}
{"id": 3, "name": "Turing", "living": false}
{"id": 4, "name": "Lovelace", "living": false}




这些文档可以非常直观地表示为一个表格(数据帧):



| id | name | living |
|---|---|---|
| 1 | Einstein | false |
| 2 | Newton | false |
| 3 | Turing | false |
| 4 | Lovelace | false |

核心观点:如果一个JSON文档序列能够根据某个Schema成功验证,那么我们就可以从表格的视角来看待这些相同的数据。数据帧本质上就是符合特定Schema的JSON对象集合。
这也是为什么在JSON Schema中,我们默认将对象设置为“封闭”的。因为在数据帧中,你不能有额外的列。如果允许额外属性,就无法将其映射到规整的数据帧结构。
数据帧可以处理比简单表格更复杂的嵌套结构。
示例1:数组字段
如果 name 字段是一个字符串数组,数据帧的表示会有一列包含多个值,这违反了数据库的第一范式。



对应的JSON文档示例:
{"id": 1, "name": ["Albert", "Einstein"], "living": false}


示例2:嵌套对象
如果数据包含嵌套对象,例如 name 包含 first 和 last 子字段,在数据帧中会通过多级表头来表现。

对应的JSON文档示例:
{"id": 1, "name": {"first": "Albert", "last": "Einstein"}, "living": false}

示例3:对象数组
如果有一个字段是对象数组(例如 who),数据帧会通过展开的方式来表示,形成一种“不完全贯穿”的表格形式。
对应的JSON文档示例:
{
"id": 1,
"who": [
{"name": "Albert", "type": "first"},
{"name": "Einstein", "type": "last"}
],
"living": false
}

重要的是,存在一种约束较少的JSON结构(使用平行的 name 和 type 数组),也能映射到完全相同的数据帧视觉表现。这体现了两种表示形式之间的对偶性,在实践中可以根据需要相互转换。


理解数据帧是有效JSON对象的集合后,我们就知道有比纯JSON更高效的存储方式。

如果你从网上下载了一个符合特定Schema的巨大JSON数据集,并且可以将其转换为数据帧,那么你应该做的第一件事就是验证它,并将其转换为Parquet之类的列式存储格式。这是因为:
- 磁盘空间更小:二进制格式比文本格式(如JSON)更紧凑。
- 读取效率更高:列式存储便于压缩和选择性读取,优化了查询性能。
- 支持复杂嵌套:像Google的Dremel论文中描述的格式,能高效地存储和读取嵌套数据。






我们可以从几个高级维度对数据进行分类:


- 扁平 vs. 嵌套
- 扁平数据:符合第一范式,是规整的表格。
- 嵌套数据:包含数组或嵌套对象,在数据帧中表现为“未完全贯穿”的列。

- 同构 vs. 异构
- 同构数据:所有记录具有完全相同的列(属性)和类型。
- 异构数据:记录间可能缺少某些属性,或包含额外的、模式中未定义的属性。传统数据帧不支持异构,但一些新系统(如Snowflake的Variant类型)开始引入支持。


-
文本格式 vs. 二进制格式
- 文本格式:如CSV、JSON、XML、YAML。人类可读,但体积大,解析慢。
- 二进制格式:如Parquet、Protocol Buffers、Avro。体积小,读写快,但不可直接阅读。
-
是否需要模式(Schema)
- 无模式:如灵活的NoSQL文档存储。灵活,但性能可能较低。
- 有模式:如Parquet、Protocol Buffers。提供结构保证,能实现高性能和低内存占用。


以下是一些常见格式及其特性:
- CSV:扁平,文本,通常无严格模式。
- JSON Lines:可嵌套,文本,可有可无模式。
- XML:可嵌套,文本,可有可无模式。
- Parquet:可嵌套,二进制,需要模式。
- Protocol Buffers:可嵌套,二进制,需要模式。
- Avro:可嵌套,二进制,需要模式。






好消息是,你现在已经掌握了理解所有这些格式的核心概念。当你遇到一个新格式时,只需问自己几个问题:
- 它支持嵌套数据吗?
- 它是文本还是二进制的?
- 它需要预定义模式吗?
- 它的原子类型和结构类型是什么?(你会发现它们与JSON Schema或XML Schema中的概念惊人地相似)

例如,查看Parquet或Protocol Buffers的文档,你会看到熟悉的整数、字符串、布尔值等类型,只是名称可能略有不同。结构方面,它们也使用数组(或列表)和映射(或对象)的概念。



本节课中我们一起学习了:
- XML Schema处理混合内容和属性的方法。
- 数据帧的本质:它是符合特定约束的JSON文档集合的表格化视图。
- 数据可以根据扁平/嵌套、同构/异构等特性进行分类。
- 从灵活的、无模式的JSON文档,到有模式的、高效的数据帧(如Parquet格式),是一个在性能与灵活性之间权衡的过程。
- 大数据和NoSQL生态的魅力在于,你可以根据需求,动态地在这些表示形式之间进行转换。



掌握这些核心数据模型概念,是理解和高效处理各种大数据格式与技术的基础。
26:MapReduce (1/3) 🗺️➡️🧮


概述


在本节课中,我们将通过一个生动有趣的“宝可梦计数”课堂活动,来学习大数据处理的核心范式——MapReduce。我们将看到如何将一项庞大的计数任务分解、并行处理,并最终汇总结果。


从串行到并行的思维转变

上一节我们讨论了数据的存储与验证。本节中,我们来看看如何查询和分析大规模数据。查询数据是大数据中最精彩的部分,而这一切从MapReduce开始,它也被称为大规模并行处理。
假设我捕获了数百只宝可梦,需要统计每种宝可梦的数量。如果我自己一只一只地数,会非常耗时。但我有500名学生,我可以利用这个“基础设施”来并行处理这个任务。
MapReduce 实战演练:课堂活动解析



我们通过一个课堂活动来模拟MapReduce过程。活动需要两类志愿者:Mapper(映射器)和Reducer(归约器)。
第一步:映射阶段
以下是映射阶段的任务说明:
- 将全部宝可梦卡片分发给8位Mapper志愿者,每人获得一个数据块。
- 每位Mapper需要独立统计自己手中每种宝可梦的数量。
- 统计完成后,将结果(例如“皮卡丘: 5”)写在纸条上。


此时,所有Mapper在并行工作,这是速度提升的关键。
第二步:洗牌阶段
映射阶段完成后,进入洗牌阶段。我们还需要8位Reducer志愿者。
- 每位Reducer被分配负责一种(或两种)特定的宝可梦。
- Mappers需要将记录着统计结果的纸条,按照宝可梦种类“撕开”,并分发给对应的Reducer。
- 例如,所有包含“妙蛙种子”数量的纸条片段都需要交给负责“妙蛙种子”的Reducer。
这个阶段会出现“意大利面条式”的混乱通信,每个Mapper都需要与每个Reducer通信。其算法复杂度是O(n²),在大数据场景中,这常常是性能瓶颈。

第三步:归约阶段

以下是归约阶段的任务说明:
- Reducer收集齐所有属于自己负责的宝可梦的纸条片段。
- 然后,Reducer将所有片段上的数字相加,得到该种宝可梦的最终总数。
- 最后,将所有Reducer的最终结果汇总给我。

在这个例子中,归约操作(求和)是可结合且可交换的,这允许Reducer在收到部分数据时就可以开始累加,而不必等待所有数据到齐。


整个活动耗时约12分钟,比我自己一个人计数快得多。
关键观察与讨论
活动结束后,我们总结了以下重要观察点:
关于瓶颈
洗牌阶段是主要的瓶颈。Mapper和Reducer之间的全连接通信导致了二次方的复杂度。
关于负载均衡
并非所有Mapper同时完成工作。有些人的数据块可能更大或更复杂,导致他们花费更长时间。这被称为“拖后腿者”问题。
关于进度监控
活动中的进度条是粗略估计。在真实集群中,可以通过已完成Mapper任务的比例来更精确地显示进度。
关于资源分配
- Mapper数量:通常设置为数据分块数量的数倍(例如3-5倍),以更好地平衡负载,避免因个别慢任务阻塞整个流程。
- Reducer数量:通常与中间键(本例中的宝可梦种类)的数量相关。Reducer数量不应少于键的数量,但可以动态调整任务分配以优化性能。
总结

本节课中,我们一起通过一个生动的人类模拟实验,学习了MapReduce的基本工作流程。我们看到了映射、洗牌、归约这三个核心阶段,并讨论了其中的性能瓶颈(洗牌)、负载均衡以及资源分配策略。MapReduce的核心思想是 “分而治之”,通过将大任务分解为可在多台机器上并行执行的小任务,来高效处理海量数据。下一讲,我们将深入探讨如何在计算机集群中实现这一范式。
27:MapReduce (2/3) 🗺️➡️🧮
在本节课中,我们将学习MapReduce框架如何从逻辑概念走向物理实现。我们将探讨数据在分布式系统中的存储形态,理解MapReduce的逻辑工作流程,并深入了解其在大规模集群上的物理执行架构。
数据存储回顾 📂
上一节我们介绍了MapReduce的基本思想。在深入其实现细节之前,我们先回顾一下数据在分布式系统中的存储方式,因为一切计算都始于数据存储。

数据可以存储在各种文件系统中,例如HDFS、S3或HBase。这些系统是存储无关的,MapReduce可以在它们之上运行。
数据文件的内容可以是多种格式:
- 纯文本:例如,包含数十亿行的文本文档。
- 结构化文本:例如CSV(逗号分隔值)文件,每行代表一条记录。
- 固定宽度格式:某些领域(如气象学)使用固定字符长度的格式,无需分隔符。
- 键值对文本:每行包含一个键和一个值,用空格等分隔。
- JSON行:每行是一个独立的JSON对象,支持异构数据结构。
- 二进制列式存储:例如Parquet格式。当数据具有稳定模式时,可以转换为内存中的数据框(DataFrame),并存储为Parquet文件。这种格式更紧凑,且支持按列读取,能显著提升性能。
一个数据集通常不会只是一个单一的大文件。

以下是数据集通常被分片(Sharded)存储的两个主要原因:
- 便于上传:上传一个超大文件(如5TB)到S3或HDFS时,网络中断可能导致整个上传失败。而将其分片成许多小文件后,可以单独上传和重试失败的部分,更为可靠高效。
- 并行输出:许多数据集本身就是并行计算(如MapReduce或Spark作业)的输出结果。这些计算框架会自然地将结果并行写入多个文件。


因此,数据集在HDFS上通常表现为一个包含许多分片文件(如 part-00001, part-00002)的目录。

另一种存储方式是HBase,它可以看作是一个二维的数据索引。我们可以在HBase的单元格中存储各种数据,包括XML、JSON文档甚至二进制文件。例如,可以将每行数据存储为一个XML文档,并使用模式(Schema)进行验证。





MapReduce 逻辑工作流程 🧠


回顾了数据存储后,我们回到今天的核心:查询处理。MapReduce的关键在于并行处理,这非常自然,因为数据本就分布在集群的成千上万台机器上。
MapReduce诞生于约20年前,其核心目标是提供一种通用、简单的并行计算模式。它采用声明式视角:给定输入数据,应用某个查询,得到输出数据。
理想情况下,我们希望每个输入分区能独立映射到一个输出分区,实现100%的并行化。这对于投影(Projection)和过滤(Filter)这类操作是可行的。
然而,对于排序、分组或聚合这类操作,每个输出分区可能依赖于多个输入分区,无法实现完全独立的并行。
MapReduce的巧妙之处在于,它发现许多计算可以分解为两个阶段:
- 一个完全并行的阶段(映射阶段)。
- 一个需要数据重组的阶段(混洗阶段)。
- 另一个完全并行的阶段(归约阶段)。

这就是 Map(映射) -> Shuffle(混洗) -> Reduce(归约) 的模式。它简单却异常强大,足以解决大量数据处理问题,例如为整个互联网建立索引。
在MapReduce范式中,所有数据都被视为键值对(Key-Value Pairs)的集合。输入、中间数据和输出都是键值对集合,尽管它们的键值类型可以不同。
以下是MapReduce逻辑工作流程的详细步骤:




步骤 1: 映射 (Map)
- 定义:
map: (k1, v1) -> list((k2, v2)) - 系统对输入集合中的每一个输入键值对
(k1, v1)应用映射函数。 - 映射函数会输出零个、一个或多个中间键值对
(k2, v2)。


步骤 2: 排序与分组 (Sort & Group)
- 系统收集所有映射阶段产生的中间键值对。
- 然后,系统根据中间键
k2对所有中间键值对进行逻辑排序。 - 排序后,拥有相同键
k2的所有值会被分组在一起。
步骤 3: 归约 (Reduce)
- 定义:
reduce: (k2, list(v2)) -> list((k3, v3)) - 系统对每一个唯一的中间键
k2及其对应的值列表list(v2)调用归约函数。 - 归约函数会输出零个、一个或多个最终输出键值对
(k3, v3)。
在约80%的情况下,中间键值对 (k2, v2) 和输出键值对 (k3, v3) 的类型相同。此时,归约函数通常保持键不变(k3 = k2),并对值列表进行聚合操作(如求和、求平均、求最大值等),公式可简化为:reduce: (k, list(v)) -> (k, aggregate(list(v)))。


MapReduce 物理实现 ⚙️
理解了逻辑模型后,我们来看MapReduce如何在真实的数据中心集群上物理执行。我们以Hadoop MapReduce第一代架构为例。
物理架构建立在存储层之上。在HDFS集群中,我们有NameNode和DataNode进程。MapReduce框架会引入额外的进程:
- JobTracker:运行在协调节点(常与NameNode同机),负责协调整个MapReduce作业。
- TaskTracker:运行在每个数据节点(与DataNode同机),负责执行具体的计算任务。
这种“将计算带到数据身边”的架构至关重要,它避免了大规模数据移动,是性能的关键。
物理执行的核心概念是分片(Split)。输入数据在逻辑上被划分为多个分片,每个分片包含一批键值对。通常,一个HDFS数据块(Block,如128MB)就对应一个分片。
以下是物理执行步骤:
步骤 1: 映射阶段执行
- JobTracker将各个输入分片分配给空闲的映射槽位(Map Slot)(即TaskTracker上的执行线程)。
- 分配时会优先考虑“数据本地性”,即尽量将分片分配给存储该分片数据副本的机器上的映射槽位。
- 每个映射槽位读取其分配的分片,并为分片中的每个键值对调用映射函数。
- 产生的中间键值对首先缓存在内存中。当内存缓存满时,会被排序后溢写(Spill)到本地磁盘(非HDFS),形成有序的临时文件。
步骤 2: 混洗阶段执行
- 每个归约槽位(Reduce Slot) 被分配负责处理一部分中间键(例如通过哈希)。
- 归约槽位需要从所有映射槽位那里“拉取”(Pull)属于自己负责的键的中间键值对。
- 由于映射槽位输出的临时文件已按键排序,归约槽位可以高效地通过网络(HTTP)从各个映射槽位获取所需数据。这个过程就是混洗(Shuffle),是网络密集型操作。
步骤 3: 归约阶段执行
- 归约槽位从各个映射槽位拉取到所有相关的中间键值对后,在本地进行合并和排序,确保每个键对应的所有值都分组在一起。
- 然后,归约槽位对每个键及其值列表调用归约函数。
- 最终,每个归约槽位将输出结果写入HDFS,通常每个槽位生成一个输出文件(如
part-r-00000)。所有输出文件共同构成最终的分片数据集。




输入/输出格式适配 🔧
MapReduce要求输入和输出都是键值对集合。但现实中的数据格式多样,因此需要“输入格式”和“输出格式”来进行适配。
以下是如何将常见格式转化为键值对的示例:
- 数据库表:很直接,可以将行ID(或主键)作为键,整行数据(或其他列)作为值。
- 文本文件:一种常见方式是将文件的字节偏移量作为键,将每一行的文本内容作为值。
- 键值对文本文件:如果每行已经是“key value”格式,可以直接解析。
- SequenceFile:这是Hadoop的一种二进制格式,专门用于高效存储键值对序列,类似于HFile。
通过定义不同的 InputFormat 和 OutputFormat,MapReduce可以灵活地处理各种数据源和数据目的地。

总结 📝




本节课我们一起深入学习了MapReduce框架。
- 我们首先回顾了数据在HDFS、S3、HBase等系统中的存储方式,理解了数据集分片的原因。
- 接着,我们剖析了MapReduce的逻辑工作流程,即“Map -> Shuffle -> Reduce”三个阶段,以及其将一切数据视为键值对的核心抽象。
- 然后,我们探讨了MapReduce的物理实现架构,包括JobTracker和TaskTracker的角色,以及映射、混洗、归约三个阶段在集群中是如何具体执行的,特别是“数据本地性”和“溢写排序”等关键优化。
- 最后,我们了解了输入/输出格式如何将各种数据源适配到MapReduce的键值对模型。

MapReduce通过一个简单而强大的抽象,实现了对海量数据并行处理的通用支持,是大数据处理领域的基石之一。
28:MapReduce (3/3) 🧩
在本节课中,我们将继续并完成关于 MapReduce 的讨论,然后转向 Spark。我们将学习如何将不同类型的数据转换为键值对,探索更多 MapReduce 的应用实例,并深入理解其内部机制,如组合器函数和任务调度。


数据输入格式与键值对转换



上一节我们介绍了 MapReduce 的基本模型。本节中我们来看看如何将现实世界的数据适配到这个模型中。
MapReduce 的输入、中间结果和输出都是键值对的集合。然而,现实中的数据并不总是这种格式,因此我们需要将其转换为键值对。

以下是几种常见的转换方式:






- 文本文件:可以将文本的每一行作为一个键值对。通常,键是行偏移量(即该行之前的所有字符数),值是整行文本内容。
- 分隔符文件:可以使用特定分隔符(如逗号、制表符)来分隔每一行。分隔符左侧的部分作为键(字符串),右侧部分作为值。
MapReduce 应用实例


现在,我们通过几个具体例子来看看如何使用 MapReduce 执行常见操作。
单词计数 📊


目标:统计文档中每个单词的出现次数。





- Map 函数:将每一行文本分割成单词(分词)。对于每个单词,输出一个中间键值对,其中键是单词,值是 1。
- 代码示例(伪代码):
map(line) -> for each word in line: emit(word, 1)
- 代码示例(伪代码):
- Reduce 函数:接收同一个单词对应的所有值(即一堆 1),将它们求和。
- 代码示例(伪代码):
reduce(word, list_of_ones) -> emit(word, sum(list_of_ones))
- 代码示例(伪代码):




这样,最终输出就是每个单词及其总出现次数。

数据筛选(选择) 🔍





目标:根据给定的条件(谓词)过滤数据行。

- Map 函数:对每一行应用谓词判断。如果条件为真,则原样输出该键值对;如果为假,则不输出。
- Reduce 函数:使用恒等函数,即直接输出接收到的键值对,不做任何改变。



这实质上实现了关系代数中的选择操作(类似于 SQL 中的 WHERE 子句)。



数据投影 📏

目标:从结构化的数据行(例如 JSON 对象)中选取特定的字段。
- Map 函数:
- 将输入的字符串(如 JSON 行)解析为程序可操作的对象。
- 从对象中选取所需的字段。
- 将结果序列化回字符串并输出。
- Reduce 函数:同样使用恒等函数。

这实现了关系代数中的投影操作(类似于 SQL 中的 SELECT 特定列)。选择和投影操作在 MapReduce 中成本较低,因为它们通常不需要 Shuffle 阶段。

组合器函数:优化网络传输 🚀
在单词计数的例子中,Map 阶段会为每个单词生成大量 (word, 1) 的中间结果,导致 Shuffle 阶段网络传输量巨大。为了优化,我们可以使用组合器函数。

- 作用:在 Map 阶段本地提前聚合中间结果,减少需要通过网络传输的数据量。
- 原理:组合器在 Map 任务输出到磁盘前或内存刷新时被调用,它将多个具有相同键的中间值合并为更少的键值对。
- 关键要求:为了不改变最终结果,组合器函数必须是可交换的和可结合的。常见的满足条件的操作包括求和、求最大值、最小值等。
- 使用方式:通常,如果 Reduce 函数使用的聚合操作(如加法
+)满足交换律和结合律,那么可以直接将 Reduce 函数作为组合器使用。

这种“尽早计算以减少数据量”的思想,在数据库系统中称为下推优化。


MapReduce API 与实现细节 ⚙️


MapReduce 原生使用 Java 实现,但也支持通过标准输入/输出流使用其他语言(如 Python)。


以下是核心组件的 Java 接口概念:



- Map 函数:接收一个输入键值对,可以输出零个、一个或多个中间键值对。
- Reduce 函数:接收一个键和该键对应的所有值的列表,可以输出零个、一个或多个最终键值对。
- 作业配置:用户需要指定 Map 类、Reduce 类、输入路径、输出路径等。若要使用组合器,则额外设置 Combiner 类。
需要避免使用“Mapper”、“Reducer”这类模糊术语,而应使用更精确的概念:
- Map 函数 / Reduce 函数 / Combiner 函数:指代具体的数学或编程函数。
- Map 任务 / Reduce 任务:指顺序执行 Map/Reduce 函数处理一批数据的工作单元。
- Map 槽位 / Reduce 槽位:指集群中用于执行任务的 CPU(虚拟)核心。一个槽位依次顺序执行分配给它的多个任务。



任务由集群动态调度给空闲的槽位执行,以此实现并行。通常,Map 任务数量远多于 Map 槽位数,以实现负载均衡。对于 Reduce 任务,常见的经验配置是每个 Reduce 槽位对应 0.95 或 1.75 个任务,以优化集群利用率。


一个棘手的问题:Split 与 HDFS Block 的错位 🔧




MapReduce 的输入被划分为 Split,而 HDFS 中的数据存储在 Block 中。两者定义不同:

- HDFS Block:是固定大小的数据块(如 128MB),切割点不考虑数据内容。
- Split:是逻辑上的记录集合,包含完整的键值对。


因此,一个 Split 中的最后一个(或第一个)记录可能会跨越两个 HDFS Block。这意味着,处理某个 Split 的 Map 任务在读取完本地 Block 后,可能还需要从另一个节点(远程或本地)读取下一个 Block 的开头部分,以获取完整的记录。这是 MapReduce 框架在实现时需要处理的一个工程挑战。

本节课中我们一起学习了如何将数据适配到 MapReduce 模型,实现了计数、筛选和投影操作,认识了用于优化传输的组合器函数,并剖析了 MapReduce 的 API 设计、任务调度原理以及底层数据划分的细节。这些知识为我们理解大规模分布式数据处理奠定了基础。
29:从MapReduce到Spark (1/4) 🚀





在本节课中,我们将要学习大数据处理框架的演进,从MapReduce的局限性出发,了解其改进方案YARN,并最终引入下一代处理框架Spark的核心概念。




从MapReduce到YARN 🔄







上一节我们介绍了MapReduce的基本执行模型。本节中我们来看看其早期版本(v1)在架构上存在的一些问题。





MapReduce v1采用主从架构,包含一个JobTracker进程(与NameNode协同部署)和多个TaskTracker进程(与DataNode协同部署)。其核心理念是“将计算带到数据身边”,即Map任务应尽量在存储其输入数据块的机器上执行,以实现本地读取。


然而,这个设计存在几个关键问题:
以下是MapReduce v1的主要问题:
- JobTracker职责过重:它同时负责资源管理、任务调度、故障监控和作业生命周期协调,这导致其成为单点瓶颈和性能瓶颈。
- 可扩展性有限:该架构大约能扩展到4000个节点和40000个任务。
- 资源利用低效:Map槽位和Reduce槽位是静态预先分配的。在Map阶段,Reduce槽位闲置;在Reduce阶段,Map槽位闲置。
- 职责混杂:调度和监控是完全不同的任务,由一个进程处理难以做到完美。




YARN:通用的集群资源管理器 🧠








为了解决上述问题,出现了YARN。YARN是一个通用的集群资源管理平台,其核心思想是将资源管理与具体的应用逻辑解耦。
YARN的架构包含以下核心组件:
- ResourceManager (RM):整个集群的资源总管,负责最终的资源分配。
- NodeManager (NM):每个节点上的代理,负责管理本节点的资源并向RM报告。
- Container:资源的抽象单位,封装了特定量的CPU核心、内存等资源。应用在Container中运行。
- ApplicationMaster (AM):每个应用特有的“管家”。当客户端提交作业后,RM会为一个Container授予AM角色。AM随后向RM申请运行任务所需的其他Container,并负责监控和管理这些任务。
这种设计的优势在于:
- 职责分离:RM只做资源调度,AM负责应用管理。
- 多租户与资源共享:多个应用(如MapReduce、Spark)可以共享集群,动态申请和释放资源。
- 资源利用率提升:资源按需动态分配,避免了MapReduce v1中的静态槽位闲置问题。
- 可扩展性增强:可支持约10000个节点和100000个任务。

关于资源分配的公平性,YARN采用了如主导资源公平性等算法。其基本思想是:对于每个用户,计算其请求的各类资源(CPU、内存)占集群总资源的比例,其中比例最大的资源称为“主导资源”。调度器会确保每个用户获得的主导资源份额是公平的。




引入Spark:超越MapReduce ⚡







尽管YARN改善了资源管理,但MapReduce编程模型本身仍有局限:一次作业只能进行一次Shuffle。复杂的算法往往需要串联多个MapReduce作业,编写和管理不便。




Spark应运而生,它提出了更通用的有向无环图数据处理模型。








在Spark中,核心抽象是弹性分布式数据集。RDD是一个不可变、可分区的数据集合,可以跨机器分布式存储。


Spark程序的逻辑通常遵循以下模式:
- 创建RDD:从数据源(如HDFS文件、本地集合)创建初始RDD。
- 转换RDD:通过一系列转换操作,从一个RDD派生出新的RDD。转换是惰性的,只记录计算逻辑,不立即执行。
- 触发计算:通过行动操作(如将结果输出到屏幕、保存到文件)触发整个DAG的实际计算。







以下是一个简单的PySpark示例,展示了创建、转换和行动:





# 1. 创建RDD (Creation)
# 将一个本地Python列表并行化为一个RDD,此时并未真正分发数据
rdd1 = sc.parallelize(["hello world", "hi"])





# 2. 转换RDD (Transformation)
# 使用flatMap转换,将每个字符串拆分为单词。此时仍是惰性计算
rdd2 = rdd1.flatMap(lambda line: line.split(" "))










# 3. 行动操作 (Action)
# countByValue是一个行动操作,它会触发前面所有转换的实际执行
# 结果会返回到驱动程序并打印出来
print(rdd2.countByValue())







Spark的核心转换操作 🛠️




Spark提供了丰富的转换操作符。以下是几个基本且重要的转换:






filter转换:根据条件过滤数据。它接受一个返回布尔值的函数,只保留函数返回True的元素。
# 保留所有大于10的元素
rdd_filtered = rdd.filter(lambda x: x > 10)








map转换:对RDD中的每个元素应用一个函数,产生一个一对一映射的新RDD。
# 将每个元素加1
rdd_mapped = rdd.map(lambda x: x + 1)



flatMap转换:对每个元素应用一个函数,该函数返回一个序列(0个、1个或多个元素),然后将所有序列“压平”成一个新的RDD。这对应了MapReduce中的Map函数。
# 将每行文本拆分为单词,一行可能对应多个单词
rdd_words = rdd_lines.flatMap(lambda line: line.split(" "))








总结 📚







本节课中我们一起学习了大数据处理框架的演进路径。
- 我们首先分析了早期MapReduce(v1)在可扩展性、资源利用和架构职责上的局限性。
- 接着,我们深入了解了YARN如何通过引入ResourceManager、NodeManager、Container和ApplicationMaster等概念,将资源管理与应用逻辑解耦,实现了更好的多租户资源共享和可扩展性。
- 最后,我们引入了Spark作为下一代处理框架。Spark的核心在于RDD抽象和DAG执行模型,它通过丰富的转换和行动操作提供了比MapReduce更灵活、更强大的编程接口,并且计算是惰性执行的,直到遇到行动操作才会触发实际计算。

下一讲,我们将继续深入Spark,探索其更丰富的API、执行优化以及高级特性。
30:Spark (2/4) 🚀







在本节课中,我们将继续学习Apache Spark的核心概念,重点介绍RDD的各种转换(Transformations)和行动(Actions)操作,并深入探讨其物理执行模型,特别是阶段(Stage)的划分与窄依赖(Narrow Dependency)的重要性。











概述:RDD操作菜单与物理执行




上一节我们介绍了RDD的基本概念。本节中,我们将详细查看Spark提供的丰富操作“菜单”,并理解这些逻辑操作在集群中是如何被高效执行的。


RDD转换操作(Transformations)

转换操作将一个RDD转换为另一个RDD,它们是惰性执行的。





作用于单个RDD的转换
以下是几个关键的转换操作。






FlatMap 🗺️


flatMap 操作与MapReduce中的Map阶段最为对应。每个输入值可以被映射为零个、一个或多个输出值。
公式: flatMap: RDD[T] => RDD[U],其中函数 f: T => TraversableOnce[U]。
与MapReduce的一个区别是,在Spark中,输入不一定必须是键值对,可以是任意类型的值。
Distinct 🔍
distinct 转换用于去除RDD中的重复元素。它通过比较元素的相等性来实现。
代码示例: rdd.distinct()
需要注意的是,这个操作通常涉及数据混洗(Shuffling),因为它需要将相同值的元素汇聚到一起进行比较。
Sample 🎲


sample 转换根据给定的比例随机抽取RDD中的一部分数据。它接受一个分数作为参数,决定保留多少比例的数据。
代码示例: rdd.sample(withReplacement=False, fraction=0.1)



这个操作基于伪随机数生成器,不涉及数据混洗,因为每个元素独立地根据概率被选择。



作用于两个RDD的转换
在关系代数中,我们可以对表进行并集、连接等操作。Spark RDD也支持类似的二元操作。

以下是几个基于集合操作的转换:


- Union(并集): 将两个RDD合并,不消除重复元素。它不涉及数据混洗。
- Intersection(交集): 返回两个RDD中共有的元素。此操作需要数据混洗。
- Subtraction(差集): 返回存在于第一个RDD但不在第二个RDD中的元素。此操作也需要数据混洗。

Cartesian Product(笛卡尔积) ⚠️




笛卡尔积会生成所有可能的元素对,一个来自左侧RDD,一个来自右侧RDD。
公式: RDD[A] × RDD[B] => RDD[(A, B)]


这是一个非常危险的操作。如果左侧RDD有一百万个元素,右侧有十亿个,结果将产生一千万亿个元素。因此,Spark默认禁止此操作,需要显式配置参数才能启用。


RDD行动操作(Actions)

行动操作会触发实际计算,并返回结果给驱动程序或将其写入外部存储系统。
Collect 与 Materialization 💾

collect 操作将分布在整个集群中的RDD数据拉取到驱动程序,形成一个本地集合(如Python列表)。
代码示例: rdd.collect()



这个过程称为 物化(Materialization)。只有当RDD数据量足够小,能够放入驱动程序内存时,才应使用此操作。





Count 🔢



count 操作统计RDD中元素的总数。它是无害且高效的,因为每个机器先进行本地计数,然后只传输计数结果进行汇总。
代码示例: rdd.count()




Take 与 Top 📥




take(n): 返回RDD中的前n个元素。top(n): 返回RDD中的后n个元素(基于默认排序)。
两者都将结果作为本地列表返回。

Reduce ➕

reduce 操作使用一个满足结合律和交换律的二元运算符,将RDD中的所有元素归约为一个值。用户需要提供该运算符的“零值”(中性元)。
代码示例: rdd.reduce(lambda a, b: a + b)
对于加法,零值是0;对于最大值,零值是负无穷。




键值对RDD(Pair RDD)的专用操作




当RDD中的元素是键值对时,可以使用一些专用操作。


键与值的提取



keys(): 返回一个仅包含键的RDD。values(): 返回一个仅包含值的RDD。



ReduceByKey 与 GroupByKey

reduceByKey: 对每个键关联的所有值进行归约操作(如求和)。这对应于MapReduce中的Reduce阶段。
代码示例:pairRdd.reduceByKey(lambda a, b: a + b)groupByKey: 将每个键关联的所有值分组到一个集合(如列表)中。与SQL的GROUP BY不同,它不进行聚合。
代码示例:pairRdd.groupByKey()


这两个操作以及 sortByKey 都需要数据混洗,因为需要将相同键的数据发送到同一台机器。



Join 🤝



join 操作接收两个键值对RDD,并基于相同的键进行连接,输出三元组(键,左值,右值)。
代码示例: rdd1.join(rdd2)
它允许键重复,如果左RDD和右RDD有多个相同键,会输出所有组合。






Spark物理执行模型 ⚙️







理解了逻辑操作后,我们来看看Spark是如何在物理集群上执行这些操作的。






窄依赖与任务执行





如果一个转换操作的每个输出分区只依赖于单个输入分区,则称为窄依赖(Narrow Dependency),例如 map、filter。
窄依赖允许在同一个机器上(甚至同一个CPU核心上)以流水线(pipeline)方式连续执行多个转换,无需数据移动,效率极高。




阶段(Stage)划分







Spark将作业(Job)划分为多个阶段(Stage)。阶段的边界就是需要数据混洗的地方(宽依赖,如 reduceByKey、join)。
在一个阶段内部,所有由窄依赖连接的转换会被合并,并在同一个任务(Task)中连续执行。中间RDD不会被物化,数据在CPU寄存器或内存中流式传递,直到需要输出或混洗。




这种优化是Spark比MapReduce模型更高效的关键原因之一。




执行器(Executor)与资源分配

任务在执行器(Executor) 中运行,执行器通常是YARN容器。每个执行器可以分配多个CPU核心和一定量的内存。
通过 spark-submit 提交作业时,可以指定执行器数量、每个执行器的核心数和内存量,从而进行集群资源规划。


总结
本节课中我们一起学习了:
- Spark RDD丰富的转换和行动操作“菜单”,包括针对普通RDD和键值对RDD的操作。
- 理解了
reduceByKey与MapReduce的对应关系,以及groupByKey、join等操作的含义。 - 深入探讨了Spark的物理执行模型,核心在于窄依赖与阶段划分。通过将多个窄依赖转换流水线化在一个阶段内执行,避免了不必要的中间结果物化和数据移动,从而大幅提升性能。
- 了解了作业通过执行器在集群中运行的基本资源分配概念。


掌握这些逻辑和物理层面的知识,有助于编写出更高效、合理的Spark应用程序。
31:Spark (3/4) 🚀

在本节课中,我们将继续学习Spark,深入探讨其核心执行模型,特别是RDD的依赖关系、阶段划分以及性能优化技巧。随后,我们将介绍Spark中更高级的数据抽象——DataFrame,并了解其如何通过结构化数据带来性能提升和更便捷的查询方式。





窄依赖与阶段执行

上一节我们介绍了RDD和转换操作。本节中我们来看看转换操作之间的依赖关系如何影响Spark的执行。






窄依赖转换是指输出中的每个数据项仅依赖于输入中的一个数据项。例如 map 和 filter 操作。




// 窄依赖转换示例
val rdd2 = rdd1.map(x => x * 2) // 每个输出只依赖一个输入
val rdd3 = rdd2.filter(x => x > 10) // 每个输出只依赖一个输入







由于是窄依赖,多个转换可以被组合并在一个阶段内并行执行。Spark的Executor(对应YARN容器)包含多个任务槽(对应虚拟核心),任务会被动态分配给空闲的槽执行。



宽依赖与Shuffle





并非所有转换都是窄依赖。许多转换操作,如 groupBy、join、sort 等,会产生宽依赖,其执行过程像“意大利面条”一样交织。




当遇到宽依赖时,Spark需要进行Shuffle操作。因为数据需要根据键重新分布到不同的节点上进行计算。

Shuffle会创建新的阶段。一个Spark作业的逻辑执行计划(DAG)会被划分为多个阶段,阶段边界就是Shuffle发生的地方。与MapReduce只有一个Shuffle不同,Spark作业可以包含多个Shuffle阶段,这使得Spark比MapReduce更通用。

以下是Spark作业执行的图形化表示:


- 阶段:由一组具有窄依赖关系的转换操作组成,可以并行执行。
- Shuffle:阶段之间的数据重分布过程。
- 任务:一个阶段被分割到集群不同节点上执行的工作单元。


Spark调度器负责协调所有阶段的执行。整个集群先并行执行第一个阶段的所有任务,然后进行Shuffle,接着再并行执行下一个阶段,依此类推。
对于需要多个输入的转换(如 join),可能涉及多个上游阶段,每个上游阶段输出都需要经过Shuffle,然后才能进行计算。
核心概念关系梳理
理解Spark中的逻辑概念和物理概念之间的关系很重要。




- 逻辑层面(用户可见):用户编写转换操作,并触发一个作业的执行。
- 物理层面(集群执行):Spark将转换操作按依赖关系分组为阶段,每个阶段被拆分为多个任务在集群上执行。
转换和阶段没有直接的一对一关系。一个阶段可能包含多个窄依赖转换。一个作业则是由一系列阶段组成的有向无环图。
性能优化技巧
理解执行模型有助于我们优化Spark应用性能。
缓存(Caching)



观察以下执行计划,三个动作(Action)有共同的计算路径。




如果内存充足,Spark会自动将中间结果(如RDD4)缓存在内存中,供后续动作复用,避免重复计算。如果内存不足,缓存可能会被溢出到磁盘,甚至被清除,导致需要重新计算。







作为高级用户,你可以通过固定(Pinning) RDD来显式控制缓存。你可以指定缓存级别(如仅内存、内存和磁盘),给予其更高的缓存优先级,确保其不被轻易移除。这也体现了RDD“弹性”的含义:即使数据丢失,也能根据血统图重新计算。




减少Shuffle




Shuffle是昂贵的操作,应尽量减少。





有时,数据本身已经以有利于后续操作的方式分布。例如:
- 如果数据已经按照你想要的键排序,那么
sortByKey操作可能不需要Shuffle。 - 如果数据已经按照
groupBy的键进行了分区,且每个组都完整地位于某个分区内,那么groupBy也可以避免Shuffle。







你可以通过两种方式利用这一点:
- 显式预分区:使用
partitionBy等方法预先对RDD进行分区。 - 隐式优化:Spark有时足够智能。例如,先进行
groupByKey,紧接着对同一个键进行sortByKey,Spark可能只会执行一次Shuffle。



对于 join 操作,如果一侧的数据已经按照连接键分区好了,那么这一侧就无需Shuffle。


关于Shuffle的数据传输:并非所有宽依赖都需要将所有数据发送到所有节点。例如 groupBy,数据只需根据键的哈希值发送到对应的目标节点,传输量是线性的。但像 distinct 或 intersect 这类操作,可能需要更全局的信息。


引入DataFrame
Spark早期只有RDD API,后来因性能等原因引入了DataFrame。





DataFrame的核心思想是针对结构化数据进行优化。当数据是同构的(遵循相同的模式)时,可以将其从RDD of Objects(如Python字典)转换为更高效的DataFrame(即RDD of Rows)。


为何DataFrame更高效?



- 列式存储:数据在内存和磁盘(如Parquet格式)上按列组织。这带来了巨大优势:
- 谓词下推:如果查询不需要某些列,可以直接跳过读取,减少I/O。
- 压缩高效:同列数据类型一致,便于压缩。
- 空间节省:相比存储完整的JSON字符串或Python对象,DataFrame的二进制格式占用内存更少,能容纳更多数据,计算更快。








使用DataFrame

创建DataFrame非常方便,Spark可以原生读取JSON、CSV等格式,并推断(infer) 模式。当然,如果你已知模式,显式提供会避免扫描开销,效率更高。



# 读取JSON文件为DataFrame
df = spark.read.json("path/to/file.json")

有了DataFrame,你可以选择两种查询方式:
- Spark SQL:使用熟悉的SQL语法。
df.createOrReplaceTempView("people") results = spark.sql("SELECT name, age FROM people WHERE age > 20") - DataFrame API:提供一套类似Pandas的链式转换API,适合喜欢编程式操作的用户。
results = df.select("name", "age").filter(df.age > 20)




DataFrame的类型系统与其他大数据系统(如Avro、Parquet)类似,包含基本类型(整型、布尔型、字符串等)和复杂类型(struct 对应JSON对象,array 对应数组)。Map 类型允许键不是字符串,这是对JSON的扩展。





DataFrame的执行




DataFrame在物理上仍然由RDD支持。当你编写SQL或DataFrame API代码时,Spark会经过一个复杂的优化器(Catalyst),生成最优的RDD执行计划。这实现了数据独立性,用户无需关心底层如何实现。





Spark SQL 详解




Spark SQL语法是标准SQL的超集,包括 SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, OFFSET 等。





我们可以将其映射回RDD操作以理解执行:
SELECT(投影)、WHERE(过滤)是窄依赖转换。GROUP BY、ORDER BY(除非键相同)通常需要Shuffle。LIMIT和OFFSET在分布式环境下实现起来比较复杂,可能涉及为数据添加索引(如zipWithIndex转换)再进行过滤。

处理嵌套数据




标准SQL是为扁平表设计的,而DataFrame支持嵌套结构(对象、数组)。Spark SQL提供了扩展来处理它们:
- 点号(.)访问:使用
column.nestedField访问对象内的字段。 - EXPLODE函数:将数组列“炸开”,为数组中的每个元素生成一行,并复制其他列的值。这实现了非第一范式到第一范式的转换。
- LATERAL VIEW:更强大的展开方式,允许在
FROM子句中展开多个数组列,类似于进行自连接,可以处理多级嵌套。






总结


本节课我们一起深入学习了Spark的核心执行机制和高级数据抽象。






我们首先回顾了RDD的窄依赖和宽依赖,理解了Spark如何通过划分阶段和进行Shuffle来执行作业。我们探讨了缓存和减少Shuffle等性能优化技巧。

随后,我们引入了DataFrame,认识到它对结构化数据的列式存储和高效压缩带来的性能优势。我们学习了如何使用Spark SQL和DataFrame API进行查询,并了解了其类型系统。最后,我们探讨了Spark SQL如何扩展以处理嵌套数据结构。






这些知识将帮助你编写出更高效、更优雅的Spark应用程序。
32:Spark (4/4) 🚀


在本节课中,我们将继续探讨Spark,特别是关注当数据不完全符合DataFrame的严格模式(Schema)要求时会发生什么。我们将看到Spark如何处理异构数据,并理解DataFrame和SQL的适用边界。



上一节我们介绍了DataFrame对嵌套和结构化数据的强大处理能力。本节中,我们来看看当数据变得“异构”时会发生什么。


你注意到这里的数据是异构的。本质上,你没有固定的模式(Schema),也无法定义一个模式。


这并不奇怪。如果非要定义一个模式,它可能会非常复杂,例如需要定义一个整数和数组的联合类型,或者一个可以容纳任何内容的“任意”类型。但这并不是一个能让DataFrame正常工作的有效模式。

这对Spark来说不是问题。Spark会采取一种强硬的方式处理,结果就是你得到了一个DataFrame。
但如果你仔细观察,这里发生的情况是:color字段是一个字符串。

并且这里的所有内容都是字符串。这个值是字符串,那个值也是字符串,所有内容都是字符串。


我加上引号是为了更清晰(引号本身不是字符串的一部分),但我想说明的是,即使是数组也被存储为字符串。我们知道,这个字符串的内容是数组的序列化表示(例如JSON字面量)。我们见过这种序列化方式。所以发生的情况是,这里的数组被序列化为一个字符串,然后所有内容都变成了字符串。
乍一看,这似乎很棒,因为我们确实把数据放进了DataFrame。看起来我们总能这样做。
问题在于,你基本上是在告诉用户:“这不是我的问题,是你的问题。”因为你现在是在说:“祝你好运处理它吧。”用户现在可能需要重新解析这些数据,可能得写一个Python脚本,判断如果能解析为字符串就做这个,如果能解析为数组就做那个。但这无法再用SQL完成,用户必须实际编写Python代码,可能需要10行代码来区分不同的情况,这变得相当复杂。




确实,你无法对任何内容进行“展开”(explode)操作,因为这里都是字符串。


这里没有数组。之前展开操作能成功,是因为我们处理的是数组类型。当你有数组类型时,你可以像这样展开它。
这里的根本问题是,没有数组,它只是一个序列化后的数组,但现在它是一个字符串,所以我们无法这样做。


我们稍后会看到,使用json_tuple之类的函数可以处理JSON字符串,但这里它确实只是一个字符串。你必须将其视为字符串,并需要在Python中做一些额外的工作。
这是一个例子,说明了我们正在触及DataFrame和SQL能力范围的边界。我们基本上是在尝试将DataFrame用于它们本不擅长的场景。

因为左边的数据显然不完全适合DataFrame。这将是我们在接下来几周要探讨的问题。我会向你们解释对于这类情况,我们有哪些其他的解决方案。
这是一个通用的提示,不仅适用于数据库,也适用于计算机科学。每当你学习一项技术、一个算法或一种范式时,你都需要理解它的“有用性领域”。
总有一些用例它表现得非常出色,而另一些用例你或许可以用“锤子”强行让它工作,但效果并不理想。因此,你需要理解的是:如果数据足够同构,DataFrame是非常出色的;嵌套结构也没问题,它很擅长处理嵌套(如果你接受这种表示方式的话)。但如果数据是异构的,情况就会复杂得多。
最终的解决方案真的取决于具体的用例。如果你以后在公司工作,没有通用的答案。SQL并非适用于一切,json_tuple并非适用于一切,Spark也并非适用于一切。你真的需要审视每一个具体的用例:数据量有多大、数据看起来是什么样子、存储格式是什么等等。然后你才能决定使用哪些技术,使其既高效又有良好的性能。
现在,最后这张幻灯片是为了说明,如果你愿意,可以将DataFrame转换为RDD,也可以将RDD转换为DataFrame。
将DataFrame转换为RDD很容易,因为它本质上就是一个由Row对象组成的RDD。这个路径很简单。
但反过来就不那么容易了。如果你有一些数据项想放入DataFrame,这需要更多努力。你需要确保数据项的结构符合你的要求,并且你需要提供一个模式(Schema)。

然后你才能将其转换为DataFrame。所以这个过程稍微复杂一些,不是自动完成的。不像加载JSON时Spark会自动推断模式,这里你真的需要做一些工作并明确指定模式。
我认为,这结束了关于Spark的这一章。有任何问题吗?
很好,这让我很高兴。那么我将在这里中断录制。



本节课总结

本节课我们一起学习了Spark在处理异构数据时的局限性。我们看到,当数据没有统一模式时,Spark会将其所有字段强制转换为字符串类型,这虽然能创建DataFrame,但丧失了数据的原始结构,使得后续处理(如展开数组)变得困难。这揭示了DataFrame和SQL并非万能工具,其“有用性领域”在于处理同构或结构良好的数据。对于高度异构的数据,可能需要结合其他技术或编写更复杂的代码。最后,我们了解到DataFrame和RDD之间可以相互转换,但从RDD创建DataFrame需要手动提供模式。理解每种技术的边界是选择合适工具的关键。
33:大规模性能优化 (1/2) 🚀
在本节课中,我们将学习如何分析和优化处理海量数据(如太字节或拍字节)的程序性能。我们将探讨性能瓶颈的概念、识别方法以及针对不同瓶颈的优化策略。
欢迎回来。现在我们进入一个有点特殊的新主题。这不是一项新技术,而是退一步思考,当你需要构建处理太字节或拍字节数据的程序,并使用 Spark、MapReduce 等技术时,如何使其运行得更快。我将重点讨论与计算相关的性能和生产力。


但在开始之前,我有一个问题要问大家。当尺度变得非常小时,世界会量子化。当速度非常快时,一些事物会扭曲。我基本上是在问关于海量数据的相同问题:如果你拥有巨大的数据量,它的行为是否仍然相同,还是开始以奇怪的方式发生?
以下是几个可能的答案:
- 作业的整体执行时间开始变得不可预测,导致结果和延迟出现巨大差异和不可预测性。
- 相反,由于微分区技术,执行变得更加平滑,均匀分布在机器上,并且可以预测,这得益于中心极限定理。
- 少数节点完成其任务执行所需的时间将明显长于平均水平。这被称为“尾部延迟”。
- 我们的架构设计得非常完美,一切都能平稳、顺利地扩展。


那么,你认为哪个是正确的?大多数人选择了第三个答案:少数节点花费的时间明显更长。这不是结果的可预测性问题,结果本身是正确的。这实际上是一个延迟问题。


我将在接下来的讲座中解释原因。你可能已经观察到,在我们进行现场 MapReduce 演示时,有些学生需要更长的时间来完成映射任务。今年不太明显,但在往年,通常有一两个学生花费了很长时间,这很好,因为这正是我想说明的观点。
现在,让我们开始学习“大规模性能优化”。到目前为止,我们已经涵盖了存储(包括语法、XML 和 JSON)、数据模型(XML 集合、JSON 数据模型)、数据帧、验证、JSON 模式、XML 模式、通用类型系统,以及使用 MapReduce 和 Spark 进行处理。我们即将结束本学期,已经用 SQL 进行了一些查询(仅针对表和数据帧),并将继续学习 JSONiq。
现在是时候稍作停顿,思考一下性能问题。
识别性能瓶颈 🔍
通常,当你决定向集群提交一个作业时(无论是 Spark、MapReduce,甚至只是一个本地 Java 程序,任何复杂度的计算机程序),可以肯定的是,某个地方总是存在瓶颈。
我们称之为瓶颈,是因为你可以在脑海中形成这样的视觉图像:系统的某些部分没有被充分利用,而其他部分则被完全用满,这正是导致性能问题的原因。
理解这一点是性能调试的基础——不是为了正确性,而是为了性能。你有一个计算机程序,想让它更快,你需要做的第一件事就是找到瓶颈。如果你在其他地方下功夫,而瓶颈依然存在,那完全是徒劳的。你必须找到瓶颈,并集中精力解决它。
如今,即使在计算机科学领域,也没有人能理解所有东西。系里有很多人研究硬件,但我对硬件一无所知。我只知道在抽象层面上,有内存、CPU、磁盘和网络。事实上,对于数据管理的目的来说,知道这些就足够了。基本上,这就是我们在大数据性能问题上关注的抽象层次:内存、CPU、磁盘、网络。


我可以肯定地告诉你,任何运行缓慢的作业,原因都出在这四个方面之一。你必须找出是哪一个。可能是 CPU 瓶颈,你怎么发现?例如,你可以通过 SSH 登录到机器,查看 CPU 使用率,发现它一直处于 100%。而内存使用率虽高但未满,磁盘似乎没有满负荷,从磁盘读取的带宽也正常,网络基本空闲。这就是 CPU 瓶颈的情况。我们使用的术语是 CPU 受限。这意味着你需要对你的代码做些调整,这是一个计算问题。
或者,你登录机器,发现 CPU 使用率是 80%、60%,没有完全利用;内存也没有完全利用;网络在传输数据,但没有达到网络带宽上限;但你注意到磁盘正在疯狂地读写,实际上是以可能的最大速度进行读写。这是 I/O 瓶颈,更具体地说是磁盘 I/O 受限。
也可能是内存受限。你连接到机器,CPU 使用率 60%,磁盘没有全速读写,网络电缆没有过载,但你的内存完全满了,满到开始使用交换空间,系统尽力执行你的作业。这是最糟糕的情况之一,因为当开始触及内存交换时,实际上会拖慢甚至终止你的执行。例如,在 MapReduce 或 Spark 中,可能开始将数据刷出内存,因为内存装不下了。
或者是网络 I/O 受限。这意味着 CPU 没有完全利用,内存没有满,磁盘没有以最大速度读写,但数据正在网络上疯狂地洗牌,这显然是通信或洗牌阶段的问题。
我可以肯定地告诉你(我不称之为定理,因为它更偏向实践),瓶颈总是这四个中的一个,不可能有两个瓶颈。同时出现两个瓶颈的几率非常小,总是有一个主要的瓶颈。它可能非常明显,也可能不那么明显,但总有一个瓶颈。

当你试图让作业更快时,只需识别出瓶颈,然后你就知道该修复什么。同样,如何识别?你真的必须登录到机器,查看 CPU、内存等,看看问题出在哪里。还有其他技巧,例如,当你尝试执行一个复杂的数据查询时,你想做的是:一方面,测量执行查询所需的时间;另一方面,测量另一个只读取数据的查询所需的时间(可能写回点什么,但仅此而已)。然后你观察两者的差异。如果你注意到存在显著差异,那么这可能不是磁盘 I/O 问题,很可能是 CPU 或内存问题。然而,如果你的查询速度(所花费的时间)与从磁盘读取数据的时间大致相同,那就意味着这是一个磁盘 I/O 瓶颈。这些是你可能得到的线索。这有点像侦探工作,你必须进行调查,找出瓶颈所在,这确实需要一些思考和跳出框框。顺便说一下,还有性能分析工具,可能会有帮助。
使用 MapReduce/Spark 的核心原因 💡
现在,我要说一些极其重要的话。这非常重要,以至于可能是使用 MapReduce 时的首要经验。这也是很多人没有理解的一点。
你应该使用 MapReduce 或 Spark 的首要原因是,如果你的问题是磁盘 I/O 瓶颈。这是你使用 MapReduce 或 Spark 的主要原因。为什么?因为一旦你达到了笔记本电脑上单个磁盘的极限,并且以最大速度读取它,你做的任何事情都是无用的。你可以改进代码,可以做任何你想做的事,但完全没用。所以你被磁盘 I/O 卡住了,这时转向集群才有意义,因为你可以并行地从多个磁盘读取,数据中心的磁盘 I/O 能力是叠加的。这一点非常重要。

因为有些人即使问题不是磁盘 I/O 瓶颈,也在使用 Spark 或 MapReduce。我想到的例子是气象学或气候学,当你尝试进行 CPU 密集型工作时,不应该使用 Spark 或 MapReduce,因为预测天气时,问题不是从磁盘读取数据,而是使用 CPU 计算模型,这属于高性能计算(HPC)范畴。你必须牢记这一点:如果你使用 Spark 或 MapReduce,但你的问题不是磁盘 I/O 瓶颈,那么可能有其他解决方案。你可以减少机器数量,可能节省一些钱来改进它。我只是想确保这一点非常清楚。



性能度量与数量级 📏


为了进行测量,当你在调查以找到瓶颈并了解是否以及如何修复它时,你需要考虑我之前告诉你的这些概念:延迟、吞吐量、容量。这些都是你需要牢记的。
关于延迟,当你开始接收数据时,我给了你这个完整的尺度。你还记得所有的前缀吗?现在应该有一半人能记住了。如果你不记得,仍然应该学习它们,但你必须熟记。这里还应该再添加两个,即 10^-27 和 10^-13,这些是微观层面的等效单位:毫、微、纳、皮、飞、阿、仄、幺。知道它们也是个好主意,尽管在大数据中你可能还看不到仄和幺,但在其他领域,比如飞米(femtocell)你可能听说过,阿秒在物理学中也是我们设法达到的时间尺度。
但无论如何,为了大数据的目的,你至少需要知道与计算机系统相关的这四个前缀。
现在,我将给出一些数量级,但你真应该把它们当作数量级来看,因为这确实取决于计算机、时间间隔等。但基本上,你必须记住,如果你查看 CPU 及其寄存器和汇编代码等,那么你基本上是在看纳秒级的典型时间。一旦你开始查看缓存,可能会增加一位数或更高。然后当你查看内存时,是几十或几百纳秒。接着是网络,是微秒级。你看,你现在看的是更大的尺度,网络已经是以米为单位,而不仅仅是在 CPU 内部。从内存读取比如 1 兆字节的数据,需要几百微秒。磁盘则更慢,当你从磁盘读取时,现在是毫秒级。如果你考虑数千公里,比如发送数据到北美再返回,那就是几百毫秒或更多。顺便说一下,这不是光速,互联网实际上比光速慢,因为它不是直线传播。
你必须记住这一点,这也是为什么在使用数据中心时,你希望尽量让数据在附近(比如在苏黎世),而不是在美国,如果你从这里处理的话。这些只是数量级,我不是要求你死记硬背,但至少如果我告诉你,你测量到某物往返美国只需一纳秒,那肯定是物理定律出了问题。你真的不应该不知道数量级,因为当你思考瓶颈时,你可以在餐巾纸上快速计算。你可以查看数据大小,做一些简单的除法(就像小学数学),快速估算出非常粗略的数量级,然后与实际发生的情况进行比较。这对于识别瓶颈非常有用。
然后是数据传输速度,这些只是一些例子,比如网络的典型速度。现在我们处于千兆比特时代,有 10 吉比特、100 吉比特,我认为甚至太比特网络也正在到来。对于硬盘和固态硬盘,现在可能甚至更高,但当我上次测量时,SSD 大约是每秒几百兆字节。当然,从驱动器读取和写入的速度也取决于它是顺序访问还是随机访问,这对于硬盘尤其是个问题,因为有一个旋转的磁头,对于 SSD 则问题较小。
好了,只是给你数量级的概念。磁盘速度在 MapReduce 和 Spark 的情况下可能最有用,因为知道读取速度,如果你有一个 1GB 或 10GB 的数据集,只需简单除法就能粗略估计读取全部数据所需的时间,这为你查询时间的测量提供了一个基线。因为如果你已经达到了这个基线,那么你就知道不可能比这更好了,除非你添加更多磁盘。
延迟与总响应时间 ⏱️
在词汇方面,你必须记住,“延迟”可以以不同的方式使用。我在讲座的上下文中使用“延迟”,这只是定义问题,指的是接收到第一个数据位所需的时间,即开始下载前的等待时间。有些人使用“延迟”指从发送请求到接收最后一个位所需的总时间,这只是约定俗成。但你必须心中有数,这里我们得到的是总响应时间,而不是延迟。总响应时间包括等待第一个位的时间,再加上传输时间。
当你查看瓶颈时,你感兴趣的是总响应时间,这通常是你想在论文中展示的漂亮图表中测量的东西。
性能改进与加速比定律 📈
现在,当你最终找到瓶颈并知道如何修复,然后去修复它时,你会想测量差异。所以你要测量改进前后的总响应时间。
然后你做一个除法:旧时间除以新时间。出于某种原因我在这里写了“延迟”,因为我脑子里想的是那些用它的人,也许我应该写成“总响应时间”。但我这里的意思确实是总响应时间除以总响应时间。
举个例子,假设在改进之前,你的查询总共花了 3 秒(从开始到结束的总响应时间)。在你实施修复或做了某些改进后,现在只需要 2 秒。那么你可以通过除法估算加速比。这里你会说加速了 50%,即 1.5 倍。
现在,如果我把它应用到一个特殊案例中:你有一个非最优的查询,你尝试通过并行化来修改你的代码。假设查询中 30% 的部分是可并行化的,70% 是不可并行的(可能是洗牌阶段)。在可并行化的部分,你通过某种方式(比如增加机器数量)使其速度提高了一倍。那么,这 30% 的部分会变短。现在你看到这个公式:之前以 1 为基准,改进后,我有 (1 - p),这是不可并行化的部分,加上 p / s,因为现在我提高了执行速度。所以不是 (1 - p) + p,而是 (1 - p) + p / s,这是我进一步并行化的部分。举个例子,如果 30% 的部分获得了 2 倍的加速,那么就是 1 / ((1 - 0.3) + 0.3/2) = 1 / (0.7 + 0.15) = 1 / 0.85 ≈ 1.18。你看,这没那么令人印象深刻了,因为一开始我告诉你我在可并行化部分使速度提高了一倍,听起来很厉害。但现在整体来看,只提高了 18%。原因是不是所有东西都是可并行化的。所以当你修复一个瓶颈时,要记住,如果你进行并行化,并非所有部分都会受到影响,只是一部分,所以这要相对来看。
这是阿姆达尔定律。但还有另一个人,古斯塔夫森先生,他说不,计算加速比应该是 (1 - p) + s 而不是 (1 - p) + p / s。让我们再用这个公式计算一下:(1 - 0.3) + 2 * 0.3 = 0.7 + 0.6 = 1.3。现在我得到了完全不同的公式,它们说的不是一回事。这里我得到 18% 的提升,这里得到 30% 的提升。那么问题来了,谁是对的?他们有两个公式,给出了不同的结果。事实上,答案是:在一种情况下(阿姆达尔定律),你有一个特定的问题(比如矩阵对角化),你找到了一种让它更快的方法,那么它可能从一小时变成 50 秒,你只是让这个特定问题更快解决了。如果是这种情况,那么使用阿姆达尔定律。古斯塔夫森定律有不同的视角,事实上,这是你在现实生活中观察到的:当你让某物更快时,人们会更多地使用它。所以发生的情况是,如果你设法提高了速度(例如,将气象模拟所需的时间减半),气象学家首先想做的可能就是提高精度,以便在原始时间内计算更多东西。所以现在,他们不是在一小时内得到答案,而是在半小时内得到相同的答案,而是仍然花费一小时(原始时间),但会得到更好的结果、更多的工作负载、更多的工作。如果你从这个角度来看,即计算能力保持不变,你继续使用整个集群一小时,但你设法做了更多事情,那么就应该使用这个公式。明白了吗?这就是区别:你是一个问题现在更快了,还是你用相同的资源做了更多事情?左边是恒定问题规模,右边是恒定计算能力。
优化策略:横向扩展与纵向扩展 ⚙️
如果你想调整以修复性能问题,你已经知道横向扩展(增加机器数量)对于并行化效果很好。纵向扩展(提升单机性能)也可能有效,在某些情况下,如果你用尽了选项并且内存是问题,但你需要更多资源,有时(尤其是在使用亚马逊云服务时)直接换一台更大的机器更容易。但你必须小心不要滥用,因为你应该总是先尝试改进代码。这些是简单的按钮:按一个按钮,你就有更多机器;按一个按钮,你就有更大的机器。这是最后的手段,尤其是因为这会花费更多钱。所以如果你是一家初创公司,从投资者那里获得资金,你必须非常小心,因为这会消耗更多资金。
但你能做的是先尝试改进代码。在很多地方,人们常常意识到,他们原本认为需要集群才能完成的工作,其实在笔记本电脑上就能运行。这在资源消耗上有相当大的差异。
基本上,如果你想避免纵向扩展(比如内存不足),你能做什么?一种方法是使用数据帧而不是 RDD,使用值对象,使用数据帧,然后你立即会看到内存消耗减少。另一种方法是投影掉未使用的字段。如果你的查询只使用了数据帧中的一半列(比如有 10 列,但只读取了 5 列),那就去掉它们,甚至不要从磁盘读取它们。这就是数据帧能够做到的。在面向对象编程中,你也需要发现那些被实例化数十亿次的类。例如,在 RumbleDB 中,我们有一个 Java 实现,我们花了很多时间仔细检查 Item 类,这个类存储原子值、对象和数组,因为有数十亿个它的副本。所以我们需要在这个类上花很多时间,因为我们从这个类中减少的每一个比特,在内存消耗上都会乘以十亿倍。
所以你可以,例如,花时间(这需要一些调查)找出在 Java 中哪种 Map 实现(TreeMap、HashMap 等)使用的比特数最少,这可能也取决于你的用例。或者,你可能有多余的东西,冗余可以使某些东西更快,但会占用内存。所以如果你移除冗余,可能会变慢,但至少你减少了内存消耗。
然后,我知道你们中许多人上过软件工程最佳实践课,当开始从事系统工作时,我们不得不告诉你们,这通常是一个巨大的失望。当然,软件工程很美好,它在可重用性、模块化等方面有很多好处。但问题是,当你在数据中心工作,处理被复制数十亿次的类,并且你关心性能时,你就没有这种奢侈了。然后你会做一些可能让某些人尖叫的事情,但你必须尽量避免使用过多的继承,避免使用多态。你会在一些系统代码实现中看到(比如 C++ 或 Java),人们会使用一个大的 switch 语句,而不是使用多态和派生类,这会让面向对象编程的人尖叫。但在系统中,这就是你的做法,因为这样才能让它更快,才能减少内存占用等等。这当然需要对所使用的语言有深刻的理解。所以花时间去理解内存中的结构,精确到每一个比特,是非常值得的。但这与软件工程中的一些最佳实践相比,是非常反直觉的。
关于 CPU,这里有很多技巧。例如,你可能有过多的方法调用。有这样一种说法,在系统中每个方法应该有 25 行。不一定每个方法调用都有成本,因为它并不总是被编译器内联。所以你必须确保在执行过程中没有太深的调用栈。特别是如果你有很多单行函数,这在工程上看起来很漂亮,但会导致巨大的调用栈,使执行变慢。调用被重写的方法是有成本的。是的,我想你喜欢提到编译器通常比任何程序员都更擅长做这些优化。绝对正确,很多编译器能够优化,但它们不一定能一直优化到底。在某些时候,当你为了节省最后一点内存或最后几微秒的执行时间而绞尽脑汁时,你必须手动做一些事情,不能完全盲目地信任编译器。但我同意编译器随着时间的推移会变得更好。但你知道,优化执行的问题从未解决,你总是可以做得更好、更好、更好。当你有一个特定的用例时,编译器是为广泛的通用用例设计的。当你想调整一个非常具体的系统到最后一个比特或最后一微秒时,就值得开始研究这些东西了。


在一些风格指南中,比如谷歌的 C++ 风格指南(可能不是),但在 Java 中,你可以使用接口但不能使用继承,你不能使用异常,除非它们真的是异常或错误,但你不能滥用异常。你必须小心类层次结构。据我所知,在苏黎世联邦理工学院的实验室里,当你们中的一些人开始编写系统代码时,常见的情况是代码运行缓慢,然后我们查看调用栈或类的层次结构,
34:大规模性能优化(第二部分)🚀

在本节课中,我们将继续探讨大规模数据处理时的性能优化策略。我们将深入理解如何配置Spark作业资源,以及如何应对在超大规模集群中出现的尾部延迟问题。




上一节我们介绍了性能瓶颈的四种类型(CPU、内存、磁盘I/O、网络I/O)以及通过二进制格式和谓词下推等基础优化手段。本节中我们来看看如何配置Spark集群资源,并理解一个在大规模集群中无法避免的数学现象。
集群资源配置 🧮



当你准备为一个Spark作业预订集群资源时,你需要决定需要多少台机器、每台机器需要多少内存和CPU核心。这需要对集群架构有直观的理解。


假设我们有一个由8个工作节点组成的集群,每个节点有16个CPU核心和128GB内存。那么总资源为:
- 总核心数:
8 节点 * 16 核心/节点 = 128 核心 - 总内存:
8 节点 * 128 GB/节点 = 1024 GB (1 TB)



在配置Spark执行器(Executor)时,有两种极端方式:








以下是两种极端的执行器配置策略:

- 一个大型执行器:在每个节点上只运行一个执行器。例如,每个执行器使用15个核心(留1个给系统)和100GB内存。这样你总共有8个执行器,每个能并行运行15个任务。
- 许多小型执行器:在每个节点上运行多个执行器,例如15个。每个执行器只使用1个核心和约8GB内存。这样你总共有120个执行器(8节点 * 15),每个执行器运行1个任务。
在实践中,这两种极端配置通常都不是最优的。第一种配置(大型执行器)可能导致内存浪费,因为任务对内存的需求不同,一个任务用不完的内存无法被其他任务利用。第二种配置(许多小型执行器)则会因为进程过多而引入不必要的开销。

一个经验法则是,将每个节点上的执行器数量设置为节点核心数的平方根左右。例如,对于16核的机器,可以配置大约4个执行器,每个执行器分配4个核心。这样可以在资源利用和开销之间取得平衡。


分区与并行度的平衡 ⚖️







任务分区数量与执行器槽位(Slot)数量的关系也至关重要。理想情况下,我们希望每个槽位处理一个分区,并且所有任务同时完成。但这在现实中几乎不可能,因为数据分布和处理难度总是不均匀的。




如果分区数等于槽位数,可能会出现“长尾任务”问题:大部分槽位很快空闲,而少数几个槽位需要处理特别慢的任务,导致整体资源浪费和作业时间延长。


为了解决这个问题,我们通常设置分区数量是槽位数量的数倍(例如3倍、5倍或10倍)。这样,当一个槽位完成当前任务后,可以立即从任务池中领取下一个任务。处理速度快的槽位会处理更多任务,从而动态平衡负载,避免因等待慢任务而造成资源闲置。


当然,分区也不是越多越好。如果分区过小、数量过多,启动和管理大量小任务的开销(任务调度、序列化等)会变得显著,反而降低整体效率。因此,需要在“分区过少导致负载不均”和“分区过多导致开销过大”之间找到平衡点。


尾部延迟:一个数学难题 📈



当我们把集群规模扩展到成千上万个节点,处理PB级数据和海量任务时,会遇到一个更深层次的问题:尾部延迟。这种现象表现为,绝大多数节点都很快完成了任务,但总有那么一两个节点“卡住”,需要异常长的时间,拖慢了整个作业。


你可能会想,通过优化代码或调整配置来解决。但关键在于,这个问题无法通过常规的应用层优化来根除。它是一个由概率和系统底层不可预测性导致的数学现象。

其根本原因在于大规模分布式系统中的“噪声”:机器上运行的其他进程、操作系统后台任务、Java垃圾回收、网络瞬时拥堵、共享资源争用、甚至电源管理策略等。这些因素使得单个任务完成时间存在不可预测的波动。


我们可以用概率模型来理解。假设单个任务在99%的情况下能在1秒内完成(成功概率 1-p = 0.99),但有1%的概率(p = 0.01)会超时。


- 对于单个节点,只有1%的几率出问题,影响不大。
- 对于10个节点,所有节点都成功的概率是
(0.99)^10 ≈ 0.904。这意味着有大约9.6%的概率至少有一个节点会超时。尚可接受。 - 对于100个节点,所有节点都成功的概率骤降至
(0.99)^100 ≈ 0.366。超过63%的概率会至少有一个节点出问题。 - 对于1000个节点,概率几乎为零。几乎可以肯定会有节点超时。








计算公式为:至少一个节点失败的概率 = 1 - (1 - p)^n。即使我们将单个节点的失败概率 p 降低到万分之一(0.0001),当节点数 n 增加到2000时,整体失败概率又会回升到约18%。问题的核心在于节点数量 n 在指数项上,只要规模足够大,小概率事件几乎必然发生。




应对尾部延迟的策略 🛡️
既然无法消除尾部延迟,工程师们设计了一些策略来缓解其影响:
以下是两种有效的策略:
- 任务对冲请求:主动将每个任务复制一份,发送到两个不同的节点上执行。哪个先完成就采用哪个结果,并取消另一个。这种方法效果最好,但代价是计算资源开销几乎翻倍。
- 任务推测执行:不主动复制任务。而是监控所有任务的执行进度。根据历史数据设定一个阈值(例如95分位点的完成时间)。当某个任务的执行时间超过这个阈值时,系统就认为它可能“掉队”,于是自动在另一个节点上启动一个相同的备份任务。最终取先完成的结果。这种方法只对少数“慢任务”进行重试,资源开销更小,是Spark等系统内置的机制。




这些策略的本质是利用冗余计算来对抗不确定性,它们是在系统架构层面,而非应用代码层面,解决大规模分布式计算固有问题的有效手段。



本节课中我们一起学习了如何合理配置Spark资源以平衡效率与开销,并深入探讨了在大规模集群中由概率决定的尾部延迟现象及其工程解决方案。理解这些底层原理,有助于我们更好地驾驭大数据处理系统,并在遇到性能瓶颈时,能准确判断问题所在并采取正确的优化方向。



核心概念公式总结:
- 总核心数:
节点数 × 每节点核心数 - 总内存:
节点数 × 每节点内存 - 尾部延迟发生概率:
P(至少一个慢任务) = 1 - (1 - p)^np: 单个任务执行过慢的概率n: 并行任务总数(或节点数)
35:文档存储(1/4) 🗄️
在本节课中,我们将要学习如何处理嵌套和异构数据。上一节我们介绍了Spark SQL在处理结构化数据时的应用,本节中我们来看看当数据变得复杂时,传统关系型模型面临的挑战,并引出文档存储(如MongoDB)这类新型系统的必要性。

概述:从关系型世界到半结构化世界


关系型模型在处理同质、扁平的数据时表现出色。其核心是关系代数,我们可以对表进行投影、选择、连接、分组等操作。




-- 关系代数操作示例:选择与投影
SELECT column1, column2 FROM table_name WHERE condition;



我们拥有成熟的语法(如CSV、SQL脚本)和查询语言(SQL)来处理这类数据。在Spark生态中,即使数据是扁平的DataFrame,我们也能使用Spark SQL进行类似操作。


然而,当数据是嵌套的或异构的时,情况就变得复杂了。


- 嵌套数据:数据内部包含其他数据结构,例如JSON对象中包含数组,数组内又包含对象。
- 异构数据:不同记录(行)的结构(模式)不一致。
Spark SQL虽然能通过LATERAL VIEW、EXPLODE等函数处理嵌套数据,但过程繁琐。对于完全异构的数据,Spark通常将其全部转为字符串,把解析的负担留给用户。这促使我们需要寻找更合适的工具。







半结构化数据的处理路径

进入嵌套/异构(也称为半结构化)数据的世界后,处理流程与关系型世界有所不同。

以下是两种数据处理路径的对比:
在关系型路径中,数据必须拥有一个预定义的模式(Schema),然后才能被装入表中进行查询。







在半结构化路径中,模式是可选的。你可以直接将未经校验的JSON或XML数据存入系统并进行查询。当然,你也可以选择先定义模式进行校验,这样数据会被更高效地组织(例如转为内存中的DataFrame),从而获得更快的查询速度。这本质上是一种在灵活性和性能之间的权衡。

即将学习的MongoDB就体现了这种灵活性。它允许你在创建集合时不定义模式,直接插入JSON文档;你也可以在后期指定JSON模式来校验数据,甚至强制后续插入遵守该模式。




为何不能简单沿用关系型数据库?
面对半结构化数据,一个自然的想法是:能否复用现有的关系型数据库(如PostgreSQL)?数据工程师通常会先尝试将新数据映射到已有的成熟系统上。

在某些简单情况下,这确实是可行的。


例如,一个扁平的JSON对象可以直接映射为一张关系型表的一行记录。一个包含多个相同结构JSON文档的集合,可以映射为一张具有固定列的表。XML模式也能找到对应的关系模式。


问题在于,当数据开始出现嵌套和异构时,这种映射就失效了。



人们尝试了各种修补方法:
- 对于嵌套但内部结构一致的数据(例如,一个对象内包含一个由相同结构对象组成的数组),可以将其拆分成多张表,并通过主外键关联。
- 对于异构的数据,可以为缺失的字段填充
NULL值。



但这些方法都有很大局限性。拆分成多表需要针对特定数据结构进行定制化设计,无法通用。而填充大量NULL值则非常低效,且难以处理深度嵌套或结构千差万别的数据。
最终,这种尝试被证明是“方枘圆凿”,难以通用。正是由于无法优雅地将半结构化数据映射到关系型模型,才催生了像MongoDB这样能够原生处理JSON等文档格式的新系统。





总结



本节课我们一起学习了从处理结构化数据到半结构化数据的思维转变。
- 关系型模型(如SQL、Spark SQL)擅长处理同质、扁平的数据,其核心是关系代数。
- 当数据变为嵌套或异构时,关系型模型处理起来会变得笨拙和低效。
- 半结构化数据(JSON/XML)的处理特点是模式可选,在灵活性和性能间取舍。
- 试图将复杂的半结构化数据强行映射到关系型表的做法,在通用场景下是行不通的。
- 这引出了对文档存储等新型数据库系统的需求,它们旨在原生地、高效地处理此类数据。


下一讲,我们将正式开始学习MongoDB,看看文档存储是如何解决这些问题的。





感谢大家的参与,祝本周学习顺利!
36:文档存储(2/4)📄
在本节课中,我们将继续探索文档存储。上一节我们介绍了当数据是嵌套和/或异构时,传统关系型数据库和Spark SQL的局限性。本节中,我们来看看专门为处理此类数据形状而设计的系统——文档存储。

概述:什么是文档存储?

文档存储是一种数据库范式,专门用于存储和管理大量文档。这些文档通常是树形结构,例如JSON或XML格式。与关系型数据库的表格不同,文档存储直接处理嵌套和异构的数据,避免了“阻抗不匹配”问题。

文档存储的核心概念
文档存储的核心是集合,其中包含大量文档。一个文档就是一个树形结构的数据单元。



数据规模与格式


文档存储可以处理海量文档,从数百万到数万亿个。然而,单个文档的大小通常有限制,例如MongoDB限制在16MB左右。这符合大数据场景中“大量中小型文档”的特点。

如果数据是巨大的单个对象(例如千个GB级文件),则应使用对象存储(如S3),而非文档存储。
文档存储的类型
市场上有两种主要的文档存储:
- JSON文档存储:例如 MongoDB。
- XML文档存储:例如 eXist-db 或 MarkLogic。

它们的核心理念相同,只是处理的序列化格式不同。

与关系型数据库的对比



关系型数据库处理的是扁平化、同构的数据(表格),这只是文档存储所能处理数据的一个特例。文档存储天然支持嵌套和异构的数据。








以下是文档存储与关系型数据库的一些关键区别:
- 操作支持:文档存储支持投影、选择和聚合操作。但大多数系统不原生支持连接(Join)操作,因为连接操作成本高昂,可能影响性能。用户通常需要在应用层通过API自行实现连接逻辑。
- 模式(Schema):关系型数据库必须有预定义的模式(
CREATE TABLE)。文档存储中,模式是可选的。你可以:- 无需模式直接插入文档。
- 在创建集合时定义模式,进行严格验证。
- 先插入数据,后期再添加模式进行验证,并可以灵活处理验证失败的情况。






MongoDB 简介 🍃

我们将以MongoDB为例进行讲解,它是目前最流行的JSON文档存储。理解MongoDB后,学习其他文档存储系统将非常容易。



需要明确的是,MongoDB是一个在线事务处理(OLTP)数据库,类似于PostgreSQL。你需要通过ETL过程将数据插入其中,而不是像在HDFS或S3这样的数据湖中直接存放文件。







存储格式:BSON

当文档插入MongoDB后,它并非以JSON文本形式存储,而是转换为一种称为BSON的优化二进制格式。BSON是Binary JSON的缩写,它在JSON模型基础上进行了二进制编码,体积更小,并且支持更多数据类型(如日期)。






对于用户而言,你通常只需要与JSON数据模型交互,BSON的细节由系统在后台处理。
基本操作:CRUD
与MongoDB交互的基础是CRUD操作(创建、读取、更新、删除),通过特定语言的API(如JavaScript、Python)实现。
核心的读取操作是 find() 函数。它返回一个游标(Cursor),你可以迭代遍历结果。

以下是一些基本查询示例及其近似SQL对比:





查询所有文档
db.scientists.find()
近似SQL:SELECT * FROM scientists;





带条件的查询(选择)
db.scientists.find({ "theory": "relativity" })
近似SQL:SELECT * FROM scientists WHERE theory = 'relativity';




投影(选择特定字段)
db.scientists.find({ "theory": "relativity" }).project({ "first": 1, "last": 1 })
使用 1 表示包含字段,0 表示排除字段。近似SQL:SELECT first, last FROM scientists WHERE theory = 'relativity';





注意:由于文档存储没有固定模式,project({“field”: 0}) 这种“排除字段”的投影方式非常有用,而这在已知所有列的关系型数据库中不常见。
查询谓词与操作符
查询条件本身也是一个JSON对象,这使得它非常直观。







AND 操作
将多个条件放入同一个对象即表示AND关系。
db.scientists.find({ "last": "Einstein", "theory": "relativity" })



OR 操作
需要使用以美元符号($)开头的操作符。
db.scientists.find({ $or: [ { "last": "Newton" }, { "last": "Einstein" } ] })
比较操作
同样使用$操作符。
db.scientists.find({ "publications": { $gte: 100 } })
$gte 表示“大于等于”。




处理嵌套字段
使用点号(.)来导航嵌套结构。
// 正确:查找 name 对象下 first 字段为 “Albert” 的文档
db.scientists.find({ "name.first": "Albert" })





// 错误:这要求整个 name 字段的值完全等于 {“first”: “Albert”} 这个对象
db.scientists.find({ "name": { "first": "Albert" } })







处理数组
如果字段的值是一个数组,查询该字段的特定值会检查该值是否存在于数组中。
// 查找 university 字段包含 “ETHZ” 的文档(university 可以是字符串或数组)
db.scientists.find({ "university": "ETHZ" })


其他操作符
MongoDB提供了大量操作符,如 $in(在集合中)、$nin(不在集合中)、$exists(字段存在)等。


其他常用方法
你可以像Spark中一样链式调用方法。
计数
db.scientists.find({ "theory": "relativity" }).count()
排序
db.scientists.find().sort({ "founded": -1, “last”: 1 })
1 表示升序,-1 表示降序。注意:在排序条件对象中,键的顺序是有意义的,这违反了JSON对象键序无关的一般原则,是API设计的一个特点。
分页
db.scientists.find().skip(30).limit(10)
相当于SQL的 OFFSET 30 LIMIT 10。



去重
db.scientists.distinct(“last”)



聚合框架









MongoDB的 aggregate() 函数功能强大,它提供了一个聚合管道,概念上与Spark的转换操作序列非常相似。你可以定义一系列阶段(如 $match 过滤、$group 分组、$sort 排序等)来处理数据。
db.scientists.aggregate([
{ $match: { “century”: “20th” } },
{ $group: { _id: “$university”, totalPubs: { $sum: “$publications” } } },
{ $sort: { totalPubs: -1 } }
])
如果你已经掌握了Spark,理解聚合管道会非常容易。




插入、更新与删除




插入文档
db.scientists.insertOne({
“first”: “Marie”,
“last”: “Curie”,
“theory”: “radioactivity”,
“century”: “20th”,
“publications”: 50
})
如果集合定义了模式,此时会进行验证。





更新文档
// 更新多个文档
db.scientists.updateMany(
{ “century”: “20th” },
{ $inc: { “publications”: 20 } } // 将 publications 字段增加20
)





// 更新一个文档
db.scientists.updateOne({ “last”: “Curie” }, { $set: { “nobelPrizes”: 2 } })




删除文档
// 删除多个文档
db.scientists.deleteMany({ “century”: “5th” })

// 删除一个文档
db.scientists.deleteOne({ “last”: “SomeName” })




总结




本节课我们一起深入学习了文档存储,特别是MongoDB。我们了解到:





- 文档存储是为处理嵌套和异构的树形数据而设计的数据库范式。
- MongoDB作为典型的JSON文档存储,通过集合和文档来组织数据,使用BSON格式进行高效存储。
- 与关系型数据库相比,文档存储具有模式灵活的特点,但通常不原生支持连接操作。
- 我们学习了MongoDB的基本CRUD操作,包括使用
find()进行复杂查询、利用$操作符构建谓词、以及链式调用sort(),limit(),count()等方法。 - MongoDB强大的聚合框架允许我们以管道方式处理数据,其概念与Spark类似。
- 需要特别注意,MongoDB的交互方式是基于API而非声明式查询语言,因此语法上存在一些特殊约定(如键的顺序在某些情况下有意义)。





理解文档存储的核心思想及其适用场景,对于在大数据环境中选择合适的数据存储和处理工具至关重要。
37:文档存储(3/4)📚











在本节课中,我们将学习MongoDB文档存储的进阶知识,包括类型排序、模式验证、架构、索引原理及其在查询优化中的应用。
课程回顾与问题解答 🔍




上一节我们介绍了MongoDB的基本操作和JSON模式。在课间休息时,我们研究了一些大家提出的问题。















以下是针对这些问题的解答:









- 关于类型的排序顺序:我们在MongoDB官方文档中找到了明确的排序规则。其顺序为:Null、数字、字符串、对象和数组、二进制数据、对象ID、布尔值、日期、时间戳、正则表达式,最后是
MinKey和MaxKey这两个哨兵值。这意味着,在比较时,布尔值被认为大于任何数字。 - MongoDB是否会追溯验证集合:答案是否定的。模式验证是面向未来的,只应用于未来的插入或更新操作,不会对集合中已存在的文档进行回溯性扫描。
- 如何检查现有文档的有效性:虽然模式验证不追溯,但你可以使用
find命令配合$jsonSchema操作符来检查。例如,db.collection.find({$jsonSchema: {...}})会返回所有符合该模式的文档。使用$not操作符则可以找出所有无效的文档,然后你可以根据_id去修复它们。 - 有效数据是否会以更优格式存储:根据我们的调查,MongoDB似乎始终使用BSON格式存储数据,即使数据符合模式定义。目前没有发现它会像数据框那样根据模式优化存储空间。这或许是一个未来的改进方向。













更新粒度与API设计 🧩







现在,让我们回到课程内容。文档存储的更新粒度与HBase类似,是以文档为单位的。这意味着同一文档无法被两个客户端同时更新,但可以同时更新同一集合中的不同文档。

我们之前注意到,MongoDB的API设计包含了许多特定选择,例如$关键字的使用、点号表示法、排序中1和-1的使用等。正因为底层API的这种特性,为了构建一个完整易用的产品,通常需要在之上提供一个更高级的查询语言。

许多文档存储系统都提供了这样的高级语言。例如,本课程将教授的JSONiq,还有UnQL、GraphQL等。目前,针对树状结构数据的查询语言尚未像SQL(针对表格)或某些图查询语言那样形成统一标准,市场上存在多种选择。我们将在后续课程中以JSONiq为例,深入探讨这种高级查询语言。













MongoDB的架构 🏗️









为了处理海量数据,MongoDB采用了分片和复制的架构,这与HDFS的思想类似。

数据根据一个键(通常也是索引键)被分割成多个分片。每个分片又由一个副本集来管理。一个副本集包含多台机器,其中一台是主节点,负责处理读写请求;其他是从节点,存储相同的数据副本。






写入数据时,客户端连接到目标分片的主节点。主节点应用更新后,会将更改同步传播给一定数量的从节点(数量由用户配置)。一旦达到这个最小成功数,操作就被认为成功,并返回给客户端。剩余的复制操作会在后台异步完成。这种“同步到一定数量,然后异步完成全部”的模式,我们在GFS中也曾见过。














索引:加速查询的魔法 ⚡





文档存储与数据湖的一个关键区别在于,它可以构建索引来极大加速查询。试想,在存有数十亿文档的数据湖中,使用Spark通过filter查找一个特定文档(点查询),需要扫描全部数据,即使并行处理也效率不高。







索引的原理类似于书籍末尾的索引。它建立一个额外的、高效的数据结构,让你能快速定位目标,而无需遍历所有数据。








哈希索引










哈希索引使用一个哈希函数 H,它能将任何输入值快速、确定性地映射到一个整数(数组位置)。MongoDB创建哈希索引的命令如下:
db.collection.createIndex({ field: "hashed" })
构建索引需要时间扫描集合。查询时,对查询值计算哈希H(value),直接定位到数组位置,然后通过指针找到文档,时间复杂度接近 O(1)。










哈希索引的局限性:
- 不支持范围查询(如
field > 100)。 - 可能存在哈希冲突。
- 需要较多内存空间来减少冲突。

B+树索引







B+树索引将数据以排序的方式组织在树结构中。每个节点有多个键和子节点指针,数据值只存储在叶子节点。MongoDB创建升序B+树索引的命令如下:
db.collection.createIndex({ field: 1 }) // 1 表示升序,-1 表示降序
在B+树中查找,时间复杂度为 O(log n)。它的巨大优势是高效支持范围查询。查找一个范围时,可以从起始点开始,顺序遍历叶子节点即可。







B+树的特点:
- 所有叶子节点在同一深度。
- 除根节点外,每个节点的子节点数量在一定范围内(如d+1 到 2d+1),以匹配磁盘块大小,优化I/O。







索引在MongoDB中的应用








MongoDB默认为 _id 字段创建一个B+树索引,称为主索引。用户在其他字段上创建的索引称为二级索引。




索引的核心好处是:减少磁盘读取。查询时,优化器会分析查询条件,选择使用一个或多个索引来快速定位候选文档,然后再在内存中进行必要的额外过滤,而不是读取全部数据。









查询示例:
- 完全匹配:如果查询条件恰好是索引字段的等值匹配,索引能直接返回结果,速度最快。
- 复合条件:如果查询是多个条件的“与”关系,且其中一个条件有索引,MongoDB会先用索引筛选出满足该条件的文档,再在内存中检查其他条件。
- 范围查询:B+树索引可以高效处理范围查询,直接定位到范围内的起始点。
- 复合索引查询:在
(birth, death)的复合索引上,查询仅包含第一个字段birth的范围是高效的。但仅查询第二个字段death则无法有效利用该索引。









MongoDB的查询优化器会评估各种可能的执行计划(包括使用不同索引的组合),并选择它认为成本最低的计划。














总结 📝








本节课我们一起深入探讨了MongoDB文档存储的多个核心方面。我们首先解答了关于类型排序和模式验证的疑问。接着,我们了解了其基于分片和副本集的分布式架构,以及写入数据的同步/异步复制过程。








课程的重点是索引。我们学习了哈希索引(O(1)查找,仅支持等值查询)和B+树索引(O(log n)查找,支持范围查询)的工作原理及其优缺点。最后,我们通过实例看到了索引如何在实际查询中发挥作用,显著提升性能,以及MongoDB查询优化器如何选择最佳执行计划。







理解索引是高效使用任何数据库系统的关键。在下一讲中,我们将完成文档存储部分的剩余内容。
38:文档存储(4/4)📄
在本节课中,我们将学习文档存储系统(如MongoDB)的物理存储机制、复合索引的工作原理及其在实际应用中的最佳实践。我们将重点关注索引如何提升查询性能,以及如何根据查询模式高效地设计索引。
上一节我们介绍了文档存储的基本概念和API。本节中,我们来看看文档在物理层面是如何存储的。


在开始之前,我有一个问题:像MongoDB这样的文档存储系统,是如何在物理层面存储文档的?

以下是几个可能的选项:
- 文本文件。JSON行文件,因为集合就是JSON对象的集合。
- MongoDB查询会自动转换为针对数据湖的MapReduce或Spark查询。
- 一种称为BSON的优化二进制格式,存储在本地硬盘上。JSON类型和数组对象被高效存储,基本原子类型也是。
- 文本文件JSON存储在本地磁盘上,通过一种称为即时解析的高效技术或众包方式动态解析。
- 在南极洲有一个文档中心,文档被整齐地分类存放在架子上。
当然,最后一个选项是开玩笑的。这是“邓宁-克鲁格效应”。


事实上,大多数人的选择是第三种:优化二进制格式。这正是MongoDB的实现方式。


我想强调的是,从数据独立性的角度来看,只要系统对外暴露的是文档集合和查询语言,前四个答案实际上都是可行的。如果你上过信息检索课程,就会知道我在南极确实有很多对概率计算非常有用的服务器。

现在,让我们回到幻灯片内容。
我们几乎要讲完了。这是我上次留下的内容,我们有一个索引。大家还记得这是一个哈希索引还是树索引吗?

如果我用这种方式创建索引,大家认为是什么?
db.collection.createIndex({ birth: 1, death: -1 })
是树索引,没错。这里的1和-1分别代表升序和降序。否则,它就会是字符串哈希索引。所以,这确实是一个树索引。
更准确地说,这是一个基于由多个相邻字段(birth和death)组成的复合键的树索引,我们使用了字典序。




这在一定程度上与JSON模型有些冲突,因为在JSON对象中,birth和death这两个键之间本应没有顺序。然而,正如我之前所说,这是模型的一个“泄漏”。在MongoDB的API中,实际上存在一个顺序,API会先看第一个键,再看第二个。

也许你们中有些人会疑惑,为什么下面的查询仍然有效,即使查询条件与索引的键不完全相同。我为此制作了一张新幻灯片来解释原因。






因为当你创建一个基于复合键(比如按顺序排列的A和B)的索引时,想象一下集合中所有不同的复合键都被放入索引中。创建这样的索引基本上意味着先按A排序,再按B排序。所以你会有所有A1的记录,然后在A1内部按B排序,接着是所有A2的记录,在A2内部按B排序,以此类推。这被称为字典序。
关键在于,因为我们这里有一个顺序,并且A排在前面,所以所有A1的记录在一起,所有A2的记录在一起,所有A3的记录在一起。这就是为什么如果你使用这个索引并专门查找A2,它们全都在这里。所以,A2实际上是一个范围,介于(A2, B1)和(A2, B4)之间。现在大家明白了吗?


好的,这就是为什么如果我只查询索引的一个前缀(比如只查询A),它仍然有效。所以下面的查询也有效,因为它本质上是对A的一个范围查询。
但是,如果像下面这样只查询第二个键B,它就不起作用。要理解为什么不起作用,你需要看到,当我查询索引的第二个键时,这些B值并没有聚集在一起。如果你查找B1,它们会分散在所有不同的A值中,这效率非常低,无法有效利用索引。
所以,它只在查询A时有效。更一般地说,你应该记住的核心要点是:




当你基于复合键创建树索引时,就相当于你也拥有了基于该复合键所有前缀的索引。
例如,如果你有一个基于(A, B, C)的索引,就相当于你拥有了基于(A)的索引和基于(A, B)的索引。只是需要注意,你不能改变1和-1所定义的排序方向。但基本思想就是这样。
这在实践中很重要,因为人们常犯一个错误。为了给你一些背景,现实中当你使用MongoDB时,你会向其中存储数据,可能是TB甚至PB级别。通常,人们不会仅仅通过连接到一个JavaScript shell并发出查询来操作数据。首先,你看到了API有多复杂。实际上,API是在计算机程序中被调用的。例如,你有一个网站,用户通过互联网访问,网站后端使用Node.js或Python等语言编写,所有对MongoDB的请求实际上都是由程序自动发出的,以处理那个API。
这意味着,根据你编写的网站,到达MongoDB的查询通常遵循特定的模式,这取决于你的网站。例如,如果你有一个电商网站,查询模式可能是很多用户搜索产品并分页浏览。因此,当你设置数据库系统时,未来的工程师(也就是你们)需要坐下来思考:为了让网站正常运行,我们需要创建哪些索引?
这意味着你必须弄清楚由你为网站编写的计算机程序通常运行哪些查询,并相应地设置索引。这是一门科学和艺术。你需要理解模式,需要合理数量的索引。你不能只是创建100个索引,首先是因为这会占用空间,硬盘空间不是无限的;其次,理想情况下,你希望索引能放入机器的主内存中,这样效率更高,因为你可以直接跟随内存指针在索引中查找。因此,你不能创建太多索引。
很多人犯的一个错误是,他们会创建一个基于(A, B, C)的索引,同时还会创建一个基于(A, B)的索引。这是一个错误,是资源浪费。如果你理解了我刚才展示的内容,就会知道如果你创建了基于(A, B, C)的索引,你就免费获得了基于(A, B)的索引。这样你就可以节省内存和资源,甚至不需要单独创建基于(A, B)的索引。
具体如何操作取决于你的网站,取决于你将使用Node.js或Python代码来发出查询,你必须找出能让查询变快的索引。
那么,我们来总结一下。使用像MongoDB这样的文档存储,我们得到了什么?
现在,如果你有TB或PB级别的数据,一次查询、一次查找可以在2毫秒内完成。只需2毫秒,你就可以通过索引(如果是哈希索引则更快)查找到数据。这就是它的魔力,是我们在MapReduce时代所没有的。因为之前我们在数据湖范式下使用MapReduce,必须进行并行全表扫描,虽然也是扫描,但所需时间远远超过2毫秒。这就是使用文档存储(通过ETL将数据放入其中)的好处。
MongoDB的缺点是它有一个API,但没有查询语言。这就是为什么下一讲是关于查询语言的。有了查询语言,你就不必再担心这些奇怪的API和任意约定,你可以像使用SQL一样在更高层次上思考,只不过它不是用于表,而是用于树状结构。
有任何问题吗?我看到你有问题。
是的,如果我们创建了像(A, B, C, D)这样的索引,我们是否也能免费获得(A, B, C)的索引?那我们是否能获得像(A, C)这样的索引呢?这是如何工作的?
只有前缀有效。 如果你创建了基于(A, B, C, D)的索引,你会免费获得基于(A)、(A, B)和(A, B, C)的索引。
你不能免费获得(A, C)索引的原因是,如果你查找A和C,会遇到和之前类似但更复杂的问题。因为如果你查找A和C,结果会分散在不同的B值中,又回到了同样的问题。明白了吗?


这就是为什么我使用“前缀”这个词。它必须是(A, B, C)意义上的前缀,即仅仅是A,或者A和B。
好的,那我们继续下一部分。

本节课中,我们一起学习了文档存储的物理存储原理,深入探讨了复合索引的字典序特性及其前缀查询的高效性。我们了解到,合理设计索引是平衡查询性能与存储资源的关键,并强调了在实际应用中根据查询模式创建索引的重要性。下一讲,我们将进入更高级的查询语言世界。
39:查询树结构 (1/4) 🌳



在本节课中,我们将学习如何查询树形结构数据,这是大数据处理中的一个重要主题。我们将回顾本学期学到的许多概念,并引入一种专门为处理嵌套和异构数据(如JSON)而设计的查询语言——JSONiq。
数据独立性的回顾
上一节我们介绍了数据处理的整体架构。本节中,我们来看看数据独立性这一核心原则。
数据独立性是由Ed Codd在1970年提出的原则。其核心思想是:用户应该只处理易于理解的数据模型(例如具有行和列的表、树的集合),并使用为该数据模型设计的查询语言。至于查询如何物理执行,则无关紧要。

例如,用户可以编写一个SQL查询。这个查询可以在PostgreSQL数据库上执行,也可以在HBase上通过Hive执行,甚至可以通过Spark SQL执行。关键在于,语言是相同的,这就是数据独立性的美妙之处:你可以切换底层实现,而无需改变上层的查询逻辑。




关于索引选择,存在一个优化权衡。创建索引(例如在字段A和B上)会占用额外的存储空间和维护成本。因此,决策取决于资源约束和实际需求:
- 如果查询只需要字段
A,那么只创建在A上的索引更节省空间。 - 每次对集合进行插入或更新时,所有索引都需要更新,因此索引越多,写操作越慢。

所以,索引策略是在内存、磁盘空间和读写性能之间的优化。

现实世界数据的挑战:超越扁平表格


在理想的关系型数据库世界中,数据是扁平且同构的。然而,在大数据场景中,我们常常需要处理数十年来积累的数据,其模式会发生变化,现实数据往往更加复杂。



现实数据可能具有以下特征:
- 某些对象有额外字段,而其他对象则缺少字段。
- 存在嵌套结构(表中有表)。
- 同一列中可能包含不同类型的值(异构性)。

这些复杂性使得传统的SQL处理起来非常困难。虽然可以通过EXPLODE、LATERAL VIEW等技术处理嵌套,但对于异构数据,SQL显得力不从心,代码会变得复杂且难以维护。
例如,在Spark中使用DataFrame API处理包含对象和字符串混合类型的JSON字段时,Spark会将其统一序列化为字符串类型。这迫使开发者编写额外的Python代码来解析和转换,增加了开发负担。


一个更具体的例子是,在GitHub事件数据中,一个简单的查询(如查找每个项目的顶级贡献者)在SQL中可能需要编写非常冗长和复杂的语句,这是因为SQL与嵌套的、半结构化的数据模型之间存在“阻抗失配”。



引入JSONiq:为嵌套数据设计的查询语言
为了解决上述问题,我们引入了JSONiq。它的理念与SQL类似:它是一种高级查询语言,拥有清晰的数据模型和设计,易于学习和使用,并且可以运行在Spark、HBase、Hadoop等多种引擎上,这得益于数据独立性。
在本课程中,我们将使用一个名为RumbleDB的引擎,它由苏黎世联邦理工学院开发,专为教学和展示JSONiq原理而设计。
JSONiq是一种声明式、函数式和基于集合的语言:
- 声明式:你只需描述“想要什么”,而不是“如何一步步获取”。
- 函数式:由表达式构成,每个表达式接受输入并产生输出,可以视为数学函数,通常没有副作用。
- 基于集合:能够同时操作数十亿条记录或文档,而不是一次处理一个值。

这与SQL的特性是一致的。
可以将JSONiq视为一个乐高游戏。它提供了约37种基础表达式(“乐高颗粒”),你可以以任意方式组合它们来构建复杂的查询。
RumbleDB引擎支持从多种数据源和格式读取数据:
- 数据源:S3, HDFS, HTTP, 本地文件系统等。
- 数据格式:文本文件、JSON行、CSV、Avro、Parquet等。


这使得RumbleDB像一个“数据计算器”或瑞士军刀,能够灵活地处理和转换不同来源、不同格式的数据。这种愿景符合“湖仓一体”的概念,即在一个系统中同时支持数据湖的灵活性和数据仓库的高效查询与管理能力。


JSONiq基础:语法初探



以下是JSONiq的一些基础表达式示例,它们展示了语言的直观性:




-
基本运算:就像计算器一样。
1 + 1结果:
23 + 2 * 4结果:
112 < 5结果:
true



- LET表达式(绑定变量):以声明式方式绑定变量。
结果:let $i := 2 return $i + 13


- 直接使用JSON值:任何有效的JSON本身就是一个JSONiq查询,会返回自身。
结果:{ "name": "John", "age": 30 }{ "name": "John", "age": 30 }

- 点操作符导航:访问对象中的字段。
结果:{ "a": { "b": 1 } }.a.b1






- 数组查找:使用双括号
[[ ]]按索引(从1开始)访问数组元素。
结果:[10, 20, 30][[1]]10

- 展开数组:使用单括号
[]将数组展开为值的序列。
结果:序列{ "food": [3, 4, 5] }.food[]3, 4, 5



- 范围表达式:生成一个数字序列。
结果:序列1 to 41, 2, 3, 4


-
FLOWR表达式:这是JSONiq的核心,用于迭代和转换序列,类似于SQL的
SELECT-FROM-WHERE。for $i in 3 to 5 return { "value": $i }结果:序列
{ "value": 3 }, { "value": 4 }, { "value": 5 } -
组合查询:表达式可以任意嵌套。
keys( for $item in { "foo": [ { "who": 1 }, { "bar": 2 } ] }.foo[] return $item )结果:序列
"who", "bar"(获取所有对象的键) -
读取外部数据:查询可以指向外部数据文件。
keys( json-file("s3://mybucket/data.json") )结果:返回该JSON文件中所有对象的键。
数据独立性的优势体现:同样的keys()查询,针对JSON文件和Parquet文件,虽然语法相同,但性能可能天差地别。对于Parquet这种列式存储格式,引擎可能只需要读取文件头部的元数据(schema)就能获知所有键,而无需扫描整个文件数据,这使得查询几乎可以瞬间完成。
JSONiq的起源与设计哲学
JSONiq并非凭空出现,它源于更早的XML查询语言标准——XQuery。随着JSON的流行,基于XQuery的设计理念和成熟基础,将其数据模型中的XML节点替换为JSON对象和数组,从而诞生了JSONiq。这种“站在巨人肩膀上”的设计哲学是一个重要经验:在制定新标准或API时,应尽可能复用已有的、经过验证的设计,以减少任意性并促进互操作性。
例如,数组索引从1开始而不是0,这个决定就直接继承了XQuery标准。虽然对于计算机科学家来说从0开始更自然,但标准化的首要目标是达成广泛共识,而非追求局部最优。
目前,针对JSON的查询语言众多,未来可能需要一个类似SQL那样的统一标准。但无论哪种语言胜出,只要掌握了JSONiq的核心思想(声明式、函数式、基于序列),学习其他类似语言都会非常容易。例如,后来出现的Google SQL的Pipes语法,就与JSONiq的FLOWR表达式思想高度相似。



JSONiq数据模型的核心:万物皆序列
在深入细节前,理解JSONiq的数据模型至关重要。其核心理念是:一切都是项目(Item)的序列(Sequence)。


- 项目:可以是六种JSON基本类型中的任何一种(对象、数组、字符串、数字、布尔值、null),在JSONiq中它们都被称为“原子值”,此外对象和数组也是项目。
- 序列:是项目的平坦的、有序的集合。序列可以包含零个、一个或多个项目。
- 异构性:一个序列中的项目可以是不同类型的,例如:
(1, "hello", { "a": 2 }, [3, 4])是一个有效的序列。 - 单项目序列等价于该项目:项目
1和序列(1)在大多数上下文中被视为等同。 - 空序列:不包含任何项目的序列,写作
()。


你可以将序列类比为Spark中的RDD,它代表了一个可能包含大量、可能异构的数据项的集合。所有JSONiq表达式都接受序列作为输入,并产生新的序列作为输出。





本节课中我们一起学习了查询树形结构数据的动机,认识了传统SQL在处理嵌套异构数据时的局限,并引入了JSONiq这一解决方案。我们探讨了JSONiq的声明式、函数式和基于集合的特性,通过多个例子初步领略了其简洁而强大的语法,并理解了其“万物皆序列”的核心数据模型。下一节课,我们将继续深入探索JSONiq语言的更多细节。
40:查询树结构 (2/4) 🌳
在本节课中,我们将深入学习JSONiq语言的核心概念,特别是如何查询和处理树状结构数据。我们将从JSONiq的基本数据模型开始,逐步介绍导航、构造、过滤和聚合等操作。

概述:JSONiq的数据模型


上一节我们介绍了大数据处理的基本框架。本节中,我们来看看JSONiq语言如何通过“序列”这一核心概念来处理数据。
JSONiq中最重要的概念是:每一个表达式都接收一个序列(sequence)作为输入,并返回一个序列作为输出。序列由零个或多个“项(item)”组成。





一个项可以是:
- 原子值:例如字符串、整数、布尔值。
- 对象(Object):即JSON对象。
- 数组(Array):即JSON数组。
- 函数(Function):高阶函数(本课程不深入讨论)。




关键点:
- 包含单个项的序列(例如,
[“一个字符串”])与该单项本身(“一个字符串”)被视为等同。 - 序列总是扁平的,不会嵌套。例如,
[ [1, 2], [3, 4] ]会被自动扁平化为[1, 2, 3, 4]。 - 空序列(
[])是有效的。







这种序列模型非常强大,因为它涵盖了从高度结构化到完全异构的各种数据形态:
- 一个由具有相同模式的对象组成的序列,等价于一个关系型数据库表。
- 一个由不同类型、不同结构项组成的序列,可以表示半结构化或非结构化数据。










导航数据:点运算符和谓词


理解了序列模型后,我们现在可以学习如何访问和查询数据。JSONiq的导航语法灵感来源于XPath和许多编程语言。







读取JSON文件:
使用 json-doc 函数读取标准的JSON文件(单个JSON值)。它返回一个包含单个项(该JSON值)的序列。
json-doc(“file.json”)




点运算符导航:
使用点(.)后接键名来访问对象内的值。
json-doc(“file.json”).countries
这将导航到JSON对象中的 countries 键,返回其关联的值(可能是一个数组)。
展开数组:
使用方括号 [] 来“展开”一个数组,将其内容转换为序列中的多个项。
json-doc(“file.json”).countries[]
现在,countries 数组中的每个对象都成为输出序列中的一个独立项。








并行导航:
即使序列中包含多个项,点运算符也能同时对每一项进行导航。
json-doc(“file.json”).countries[].name
这会对序列中的每个国家对象执行 .name 操作,返回一个国家名字符串的序列。









过滤数据:使用谓词





我们经常需要根据条件筛选数据。在JSONiq中,这通过在方括号 [] 内放置一个布尔表达式(谓词)来实现。






基本过滤:
以下查询找出 code 等于 “CH” 的国家。
json-doc(“file.json”).countries[][$$.code eq “CH”]
$$是一个特殊变量,代表“上下文项”,即当前正在处理的序列中的项。在这个例子中,$$会依次绑定到countries数组中的每一个国家对象。eq是JSONiq中的等于比较运算符。
组合查询:
你可以像搭积木一样组合这些操作。例如,先过滤,再导航:
json-doc(“file.json”).countries[][$$.code eq “CH”].name
这将返回瑞士(Switzerland)的名字。
关于方括号 [] 的说明:
方括号内的表达式根据其计算结果类型有不同的行为:
- 布尔值:用作过滤谓词。保留使表达式为
true的项。 - 整数:用作位置索引。返回序列中该位置的项(索引从1开始)。


处理JSON Lines文件




对于每行一个JSON值的文件(JSON Lines),使用 json 函数(注意不是 json-doc)。
json(“file.jsonl”)
这将返回一个序列,其中每一行JSON都是一个独立的项。之后的所有导航和过滤操作与处理单个JSON文件时完全相同。





探索与聚合函数
当拿到一个新数据集时,我们通常需要先探索其结构和内容。JSONiq提供了一系列函数用于此目的。





以下是用于数据探索的关键函数:






keys:返回对象中所有键(属性名)的序列(去重后)。用于发现数据结构。keys(json(“large-dataset.jsonl”))distinct-values:返回序列中所有不重复值的序列。用于了解某个字段有哪些可能的值。distinct-values(json(“large-dataset.jsonl”).type)count:返回输入序列中项的总数。count(json(“large-dataset.jsonl”))
性能提示:
- 在JSON Lines文件上运行
keys()或count()可能需要扫描整个文件。 - 如果数据存储在Parquet等列式格式中,相同的查询可能会快得多,因为系统可以直接从文件元数据(如模式)中获取信息,而无需扫描所有数据。查询语法保持不变,体现了数据独立性。



构造数据:字面量与序列
除了查询,我们经常需要创建新的数据。JSONiq的一个设计目标是成为JSON的超集。




构造基本JSON值:
任何有效的JSON字面量(字符串、数字、布尔值、对象、数组)本身就是一个有效的JSONiq查询,它会返回自身。
“Hello”
42
{ “name”: “Alice”, “age”: 30 }
[1, 2, 3]
构造其他数据类型:
对于JSON不原生支持的类型(如日期、时间、二进制数据),可以使用类型构造函数。
date(“2023-11-26”)
base64Binary(“SGVsbG8=”)


构造序列:
使用逗号 , 将多个表达式连接起来,可以创建序列。
“apple”, true, 42, { “id”: 1 }
这会返回一个包含4个异构项的序列。









生成整数范围序列:
使用 to 操作符可以快速生成一个连续的整数序列。
1 to 100
这会生成一个包含1到100的整数序列。




算术与字符串操作
JSONiq支持标准的算术和字符串操作,但需要特别注意其对序列的处理逻辑。


算术运算:
支持 +, -, *, div (除法), idiv (整数除法), mod (取模)等。
5 + 3
10 idiv 3 // 结果为 3




序列处理规则(算术运算):
- 如果运算符任意一侧的表达式结果为空序列,则整个运算结果为空序列。
- 如果任意一侧的表达式结果包含多于一个项,则会发生错误。
- 运算要求类型匹配(例如,不能将字符串与数字相加)。



字符串连接:
使用双竖线 || 进行字符串连接(类似于SQL)。
“Hello” || “ “ || “World”
此外,还有丰富的字符串函数库,如 concat, substring, string-join 等。
string-join((“a”, “b”, “c”), “-”) // 结果为 “a-b-c”


比较运算:
支持 eq, ne, lt, le, gt, ge 等比较运算符。


序列处理规则(比较运算):
与算术运算不同,当比较运算符的一侧或两侧为多项目序列时,采用存在量化逻辑:
- 表达式
(1, 2, 3) eq 2返回true,因为序列中存在等于2的项。 - 表达式
(1, 2) lt (3, 4)也返回true,因为存在左边的项(1)小于右边的项(3)。
这种逻辑为检查序列中是否包含某个值提供了便捷的语法。








总结



本节课中我们一起学习了JSONiq查询语言的核心部分。我们首先建立了序列(sequence of items) 这一核心数据模型,它是所有JSONiq表达式操作的基础。接着,我们学习了如何通过点运算符(.) 和方括号([]) 来导航和过滤JSON树结构数据。然后,我们了解了如何通过直接书写JSON字面量或使用构造函数来创建新的数据。最后,我们探讨了算术运算、字符串操作和比较运算,并特别强调了这些操作在处理序列时的特殊规则(如空序列、单值序列和多值序列)。掌握这些基础,是使用JSONiq有效处理各种结构化与半结构化数据的关键。
41:查询树(3/4)🌳










在本节课中,我们将继续学习JSONiq查询语言,深入探讨其表达式组合、逻辑运算、FLWOR表达式以及如何高效地处理异构数据。我们将看到JSONiq如何像搭积木一样组合查询,并理解其背后强大的数据操作能力。








表达式组合与逻辑运算




上一节我们介绍了JSONiq的基本表达式。本节中我们来看看如何将这些表达式像乐高积木一样组合起来,并理解其逻辑运算规则。


JSONiq允许将任何表达式嵌套到另一个表达式中。例如,一个复杂的查询可以逐步求值。





1 + ([2 + 2].b.a)
这个表达式的求值过程如下:
2 + 2得到4。[4]创建一个包含数字4的数组。[4].b尝试从数组中提取键b,但数组没有键,此操作返回空序列。([4].b).a从空序列中提取键a,仍然返回空序列。1 + (空序列)在JSONiq中,空序列在算术运算中通常被视为0(或根据上下文返回错误),但更常见的是,这种导航链会因为路径不存在而返回空序列,导致最终结果为空或错误。原例旨在说明组合性,实际路径需有效。

更实用的例子是动态构建数据:
{ "key": 6, "value": [ 1 to 10 ] }
这里,键"value"对应的值是通过表达式[ 1 to 10 ]动态计算生成的数组。






逻辑与比较运算的便捷性








JSONiq为比较运算设计了一个便捷特性:当比较运算符(如=, !=, <, >等)的一边是单值,另一边是序列时,它的含义是“存在至少一个序列中的元素满足该比较关系”。这是一种存在量词的简写。
例如,假设 adjacent_objects.name 是一个包含数百万字符串的序列:
adjacent_objects.name = "Switzerland"
这条查询的含义是:检查序列中是否存在至少一个名字等于“Switzerland”。如果存在,则返回 true。这相当于一个存在量词(∃)查询,使得代码非常简洁。



公式:sequence = value 等价于 ∃ x ∈ sequence (x = value)
这个设计的优势在于支持惰性求值。系统在遍历序列时,一旦找到第一个匹配项,就可以立即返回 true 并停止计算,无需检查剩余的亿万条数据,这极大地提升了查询效率。
反之,其否定形式“不存在任何元素满足条件”则等价于全称量词的否定。












公式:not(sequence = value) 等价于 ∀ x ∈ sequence (x ≠ value)




对于空序列,存在量词查询总是返回 false,因为空集中不存在任何元素。




逻辑运算符
JSONiq支持标准的逻辑运算符:and, or, not。
- 它们遵循短路求值原则。
- 与一些语言类似,JSONiq对非布尔值有灵活的“真值”判断:
- 数字
0视为false,非零数字视为true。 - 空字符串
""视为false,非空字符串视为true。
- 数字


运算符优先级

与算术运算一样,JSONiq的表达式有优先级。例如,乘法优先级高于加法。如果无法记住优先级,最安全的方法是使用括号()来明确指定运算顺序。
控制流表达式
为了构建更复杂的逻辑,JSONiq提供了多种控制流表达式,它们也是“乐高积木”的一部分。

以下是几种核心的控制流结构:
- 条件表达式 (
if-then-else):根据条件返回不同的值。if ($x > 10) then "large" else "small" - Switch表达式:基于一个值匹配多个条件,返回第一个匹配的分支结果。它比嵌套的
if-then-else更清晰。switch ($type) case "A" return "Type A" case "B" return "Type B" default return "Unknown" - Try-Catch表达式:以函数式风格捕获动态错误(如除零错误)。如果
try子句求值产生错误,则返回catch子句的结果。try { 10 div 0 } catch { "Error occurred" }









FLWOR表达式:强大的查询核心

FLWOR(读作“flower”)表达式是JSONiq进行复杂数据转换和查询的基石,其功能远超SQL的SELECT-FROM-WHERE。
FLWOR表达式由以下子句自由组合构成:
for:迭代一个序列,将变量依次绑定到每个元素。let:将变量绑定到一个表达式的结果(可以是序列)。where:过滤掉不满足条件的变量绑定。order by:对变量绑定进行排序。group by:根据指定键对变量绑定进行分组。return:为每个保留的变量绑定构造一个输出值。
FLWOR表达式必须以for或let开始,以return结束,中间的子句可以任意顺序、多次出现,这比SQL固定的子句顺序灵活得多。
FLWOR表达式示例



- 简单迭代与映射:将数字序列映射为其平方。
for $x in 1 to 10 return { "number": $x, "square": $x * $x } - 使用
where过滤:仅保留大于7的数字。for $x in 1 to 10 where $x > 7 return $x * $x - 使用
let绑定:计算中间值。for $x in 1 to 5 let $y := $x - 2 return $x * $y - 使用
group by分组:按奇偶性分组并列出数字。
结果会是:for $x in 1 to 5 group by $y := $x mod 2 return { "group": $y, "numbers": $x }{ "group": 1, "numbers": [1, 3, 5] }和{ "group": 0, "numbers": [2, 4] }。注意,分组后$x在每组内变成一个序列,这比SQL更灵活。









FLWOR的形式化理解






理解FLWOR执行过程的一个有效方法是将其想象为逐步构建和操作一个数据框。






考虑以下查询:
for $x in 1 to 10
where $x - 2 >= 5
return $x * $x
其内部执行可以可视化如下:
for子句:生成初始绑定集,像一张单列表。$x 1 2 ... 10 where子句:为每一行计算条件$x - 2 >= 5,得到一个布尔值数组,并过滤掉false的行。$x 条件 ($x-2>=5) 8 true 9 true 10 true return子句:为剩余每一行计算$x * $x并输出。结果 64 81 100



虽然用户看不到这个“数据框”,但RumbleDB在内部正是利用类似数据框的抽象,在Spark集群上并行处理数十亿行的绑定,实现了极高的效率和数据独立性。











在真实数据上应用查询








让我们将所学应用于一个真实的GitHub事件JSON数据集。

以下是几个查询示例:
- 筛选与统计:找出
payload.commits数组长度至少为5的事件数量。count( json-file("gharchive.json") [size(payload.commits) >= 5] ) - 提取与去重:获取所有事件中第一个提交者的邮箱,并统计不同的邮箱数。
这里,count( distinct-values( json-file("gharchive.json").payload.commits[[1]].author.email ) )[[1]]获取每个数组的第一个元素(如果存在)。 - 使用FLWOR进行转换:为每个事件创建一个新对象,包含ID和计算出的提交数。
for $event in json-file("gharchive.json") let $num_commits := size($event.payload.commits) return { "id": $event.id, "commits": $num_commits } - 模拟关系代数操作:
- 选择 (Selection):使用谓词过滤。
json-file("products.json")[$$.store_id = 1] - 投影 (Projection):使用
project函数选择特定键,甚至可以动态生成键。for $p in json-file("products.json") return project($p, ["id", "type"]) - 连接 (Join):通过双
for循环实现笛卡尔积,再用where子句定义连接条件。
尽管这种写法看起来是朴素的嵌套循环,但RumbleDB查询优化器能够识别出这是一个等值连接,并将其转换为高效的分布式连接算法执行,用户无需担心性能问题。for $product in json-file("products.json") for $store in json-file("stores.json") where $product.store_id eq $store.id return { "product_name": $product.name, "store_location": $store.location }
- 选择 (Selection):使用谓词过滤。







总结







本节课中我们一起深入学习了JSONiq查询语言的核心部分。我们了解到:
- JSONiq表达式具有强大的组合性,可以像搭乐高一样构建复杂查询。
- 比较运算对序列的支持提供了存在量词查询的便捷写法,并天然支持惰性求值以提升性能。
- FLWOR表达式是进行数据转换、过滤、分组和排序的通用且强大的工具,其灵活度超越了传统的SQL。
- 通过形式化地将FLWOR理解为对变量绑定“数据框”的逐步操作,我们可以清晰地推理任意复杂查询的执行过程。
- JSONiq能够以声明式、简洁的语法表达选择、投影、连接等所有关系代数操作,并且系统会在底层进行高效优化,处理从GB到TB乃至PB级别的异构数据。

掌握这些概念后,你就能利用JSONiq和RumbleDB的强大能力,轻松应对大规模、半结构化数据的查询与分析任务。
42:查询树的物理实现 (4/4) 🚀

在本节课中,我们将深入探讨JSONiq查询语言的物理实现,特别是RumbleDB如何在Apache Spark之上执行查询。我们将了解从查询语句到集群执行的完整流程,包括编译、优化和运行时执行策略。

概述 📋

上一节我们介绍了JSONiq的语法和功能。本节我们将聚焦于其物理实现,解释JSONiq语言与RumbleDB实现之间的关系,并详细说明一个查询如何从文本转换为在Spark集群上并行执行的任务。




JSONiq语言与RumbleDB实现
JSONiq是一种查询语言,就像SQL、Java或Python一样,它定义了与计算机交互的方式。RumbleDB 则是该语言的实现。具体来说,RumbleDB在Apache Spark之上实现了JSONiq语言。
查询执行流程 🔄


以下是RumbleDB处理查询的典型流程,大多数查询引擎都采用类似架构。




1. 语法分析与抽象语法树(AST)

当查询进入系统,首先进行的是语法分析。我们使用编译器构建工具(如ANTLR)和预定义的语法规则(EBNF格式)来解析查询文本。

结果:生成一个抽象语法树。AST非常接近查询的原始语法结构。




查询文本 -> 语法解析器 -> 抽象语法树 (AST)








2. 转换为表达式树






AST之后会被转换为表达式树。表达式树可以看作是AST的清理和整理版本,它从更抽象的层面代表了查询中的所有表达式。





在JSONiq中,共有37种表达式类型,例如:
- 加法表达式 (
AdditiveExpression) - 乘法表达式 (
MultiplicativeExpression) - 变量表达式 (
VariableExpression) - 各种字面量表达式等





转换方法:使用访问者模式遍历AST并生成表达式树。此时,查询只是一个高级的数学表示,尚未执行任何计算。




3. 优化阶段







在表达式树阶段,会进行多种静态优化:





- 查询重写:将低效的写法重写为更高效的形式。例如,自动检测并显式化查询中的连接操作。
- 静态类型推断:JSONiq是动态类型语言,但引擎会在编译时尝试推断表达式的类型和基数(返回序列的长度信息)。

类型系统基于序列和基数:
- 空序列类型
- 恰好一个整数的类型
- 一个或多个整数的类型 (
integer+) - 零个或多个整数的类型 (
integer*)
优化意义:知道一个表达式只返回0或1个项目(而不是数十亿个)是至关重要的信息。这意味着可以在本地机器上处理,无需分布式计算。反之,对于可能返回海量项目的表达式,则需要规划在集群上并行化执行。


4. 生成运行时迭代器树
这是将逻辑计划变为可执行计划的关键步骤。再次使用访问者模式,将表达式树转换为运行时迭代器树。
区别:迭代器树中的节点不仅包含逻辑信息,还包含了实际执行查询的计算代码。这是可以按下按钮触发评估的部分。



运行时执行模型 ⚙️
现在,我们聚焦于运行时迭代器树是如何被执行的。

迭代器模式
这些迭代器之所以被如此命名,源于一种常见的编程模式——火山迭代器模式。其API通常包含以下方法:
open(): 初始化hasNext(): 检查是否还有下一个项目next(): 获取下一个项目close(): 清理资源
在Java等语言中,这种模式非常普遍。
执行流程







执行从迭代器树的顶端(查询的最外层)开始。调用 hasNext() 和 next() 方法,逐个“拉取”结果项。这个调用会向下传播,协调数据流经整个迭代器树,最终计算出结果。





一个重要细节:在JSONiq中,大多数表达式返回的是项目序列。但在FLWOR表达式(类似SQL的SELECT-FROM-WHERE)的子句中(如for、let、where),流动的是变量绑定(也称为元组),它们类似于数据框中的行。

三种执行策略 🎯
对于相同的逻辑表达式,在物理层面有三种数学上等效但性能特征不同的执行策略。

1. 物化执行






策略:在内存中完全计算并存储中间结果。
示例:对于一个FLWOR表达式,for子句会先将所有绑定到变量的对象完整地保存在一个列表或数据框中,然后再传递给return子句处理。


缺点:
- 高内存消耗:如果处理数百万或数十亿对象,需要在内存中保存巨大的数据结构。
- 顺序执行:通常意味着串行处理。
2. 流式执行



策略:像Netflix流媒体一样,处理一个项目,输出,然后丢弃,再处理下一个。无需在内存中保存完整的中间结果。


优点:
- 低内存消耗:即使处理万亿级项目,也只需要很少的内存,因为同一时间只处理少量数据。
- 无硬性限制:不会因为数据超出内存而崩溃,只会执行时间变长。




缺点:
- 执行时间长:对于海量数据,顺序处理可能非常慢。


3. 并行执行


策略:利用集群(如Spark),将数据分块分布到多台机器上并行处理。





前提:数据通常已经分布式存储在HDFS或S3上(分块存储)。引擎利用这一点,在不同机器上并行处理不同的数据块,最后聚合结果。



权衡:并行化有固定开销(启动任务、网络通信)。对于小数据集,串行流式或物化执行可能更快;对于大数据集,并行执行优势明显。
RumbleDB的智能选择:RumbleDB会根据数据大小、类型推断等信息,在查询内部动态混合使用这三种策略,甚至在同一查询的不同部分采用不同策略,在保证结果正确的前提下,尽可能追求最快速度。




RumbleDB在Spark上的具体实现 🛠️





RumbleDB根据数据特征,选择不同的Spark底层API进行实现。




处理项目序列






- 本地执行:对于确定只返回单个或少量项目的表达式,直接使用Java方法调用或简单的火山迭代器,无需Spark介入。
- RDD执行:当读取杂乱的、无模式的JSON Lines文件时,RumbleDB使用Spark RDD。它将文本行转化为多态
Item对象(如ObjectItem,ArrayItem)组成的RDD。后续的导航、转换操作通过flatMap等RDD变换实现,其中包含序列化到集群的用户定义函数。 - DataFrame执行:当读取有模式的结构化数据(如Parquet文件)时,RumbleDB使用Spark DataFrame。DataFrame内存效率更高。查询操作被转换为DataFrame的列操作(如选择、过滤)。
处理FLWOR表达式(变量绑定)




FLWOR表达式中的变量绑定流天然类似于DataFrame(列是变量,行是绑定)。
- RumbleDB在内部为这些绑定流生成DataFrame。
- 即使数据是异构的,也可以通过将对象序列化为二进制列来放入DataFrame,虽有一定性能损耗,但保证了通用性。
for、let、where、return等子句被转换为DataFrame的添加列、过滤、投影等操作。- RumbleDB在幕后动态生成并执行Spark SQL查询来实现这些转换。


执行模式与Spark概念的对应



- 流式处理 对应 Spark中的窄依赖,可以在流水线阶段内完成。
- 物化处理 对应 Spark中的宽依赖/Shuffle。例如,
group by和order by子句必须收集所有相关数据才能进行分组或排序,因此必然导致物化和Shuffle。

总结与展望 📈

本节课我们一起学习了JSONiq查询引擎RumbleDB的物理实现原理。

核心总结:
- 语言与实现分离:JSONiq是语言,RumbleDB是其基于Spark的实现。
- 多层转换:查询经过
文本 -> AST -> 表达式树 -> 运行时迭代器树的转换与优化过程。 - 混合执行策略:系统智能选择物化、流式、并行策略,以平衡内存与速度。
- 多物理后端:根据数据是否结构化,选择使用RDD、DataFrame或本地Java执行,甚至直接编译为原生Spark SQL,对用户透明。
- 数据湖仓趋势:RumbleDB体现了现代数据系统向“湖仓一体”的融合趋势,一套系统既能高效处理规整数据,也能灵活应对杂乱数据。


RumbleDB的性能测试表明其具有竞争力。更有趣的扩展是,JSONiq的Item类型包含函数项,这使得将机器学习模型作为函数集成到查询中成为可能,实现了数据清洗、验证、训练和预测的流水线查询。





现代数据库系统正在融合,RumbleDB正是这一方向的实践:数据规整则自动加速,数据杂乱则仍保功能,为大数据处理提供了灵活而强大的统一平台。
43:图数据库 (1/3) 🧠
在本节课中,我们将要学习图数据库。我们将告别表格和条目,转而探索另一种数据结构——图。我们将了解图数据库的核心概念、数据模型以及它们为何在某些场景下比关系型数据库更高效。


课程概述
我们整个学期都在讨论表格和条目。今天,我们将暂时停止思考表格条目,转而关注另一种称为“图”的形状。这就是图数据库的精彩世界。
我完全重新设计了这次讲座,并尝试从与去年不同的视角进行讲解。我今天的目标有两个。第一个目标当然是向你们介绍图数据库,以便你们能够使用它们。第二个目标是在整个图数据库讲座中,与我们之前学习的树和表格建立联系。因为我希望你们看到,其原理是相同的:语法、建模语言、数据类型等等。下周我们将讨论数据立方体,这是另一种形状,但同样是数据独立性的相同理念。希望在学期结束时,你们能理解我们所学习的内容,尽管是针对特定技术和特定形状的,但实际上在未来几十年内都是有效的。因为几乎所有可能被发明或添加到组合中的形状都将遵循相同的一般原则。我希望你们在学习SQL和Jsoniq之后,会发现学习今天的语言——称为Cypher——实际上并不那么困难,因为你们会注意到你们已经完成了工作中最难的部分。


为什么需要图数据库?
上一节我们介绍了关系型数据库在处理复杂关系时的局限性,本节中我们来看看为什么需要图数据库。
关系型数据库基本上由表格组成,它被称为“关系型”是因为数学上的关系,表格基本上就是一个关系。但事实上,“关系”也意味着联系。通常,在本科阶段教授关系型数据库时,也会包含实体-关系模型。其思想是,你有表达实体的表格,比如课程、人员、产品等。然后你有将这些表格链接在一起的关系。关系本身也可以是表格。例如,讲师和课程。然后你有一个只有两列的表格,将讲师链接到课程。这就是一个关系。
这一切都有效,整个实体-关系模型之所以有效,是因为我们努力实现高范式,拥有许多各自独立工作的表格。然后你如何将这些表格组合在一起?你连接它们。在关系型数据库中,你必须连接所有表格,就像在SQL中使用JOIN子句一样。
问题是,连接操作可能很昂贵。当然,有些人可能会反驳说,连接操作可以在线性时间内完成。是的,等值连接如果使用哈希表,可以在线性时间内完成。但并非所有连接都如此,如果你有更通用的谓词,情况就不总是这样。而且它们在关系处理上效率相当低,因为连接的本质是什么?连接的本质是,例如,你有一个值,你想把它与另一个值关联起来,因为那可能是一个主键,而这是一个指向该主键的外键。所以连接的本质实际上是连接行与行。
但当你想要进行连接时,你实际上需要构建一个更大的表格,将所有表格合并在一起,技术上称为笛卡尔积,然后进行选择谓词,这就是你的连接。即使你可以高效地实现它,因为没有人会通过实际进行笛卡尔积和选择来实现连接。当然,你会使用更好的算法。但尽管如此,这仍然相当低效。你基本上是在用大炮打蚊子。


因此,为了处理关系,我们可以做得更好。为此,我们尝试基本上避免连接。

为什么规范化/反规范化不够?
上一节我们讨论了连接的效率问题,本节中我们来看看通过反规范化(使用文档存储)是否足够。



因为连接在关系处理上效率不高。这应该让你想起一些事情,因为这不是我第一次告诉你们我们想避免连接。为了避免连接,我们进行反规范化。然后你可能会想,好吧,我们使用树,因为当你反规范化时,你基本上得到的是树。所以我们可以直接反规范化,然后使用文档存储。问题就解决了,我可以回家了,我的讲座结束了。但事情没那么简单,因为当你反规范化时,反规范化基本上等同于在非常浅的层面上进行连接。但你有不止一种连接方式。所以当你像这样反规范化时,你是以特定方式进行的,因为你预期这是用户实际需要的连接。但实际上,如果你与行业参与者交谈,例如Databricks前几周的讲座,工程师Samuel解释的是,在许多情况下,你当然可以提前预测需要什么样的连接,然后通过反规范化预计算连接,但在某些情况下,你无法提前知道用户将需要什么样的连接,因为连接数据的方式太多了。所以这有其局限性,有时你必须在运行时动态进行,而不是在运行前静态地反规范化表格。我们需要一种动态连接单元格的方法。



更糟糕的是,在实践中,如果你开始拥有一个包含大量表格的非常大的数据库,你甚至可能遇到在同一查询中需要进行大量连接的使用案例。一个例子是你拿一个家谱,试图找到你的曾曾曾曾曾曾曾曾祖父母。现在,想象一下我们有一个人员表格,亲子关系只是通过自连接来实现。这就是你通常的做法。但如果你想找到你的曾曾曾曾祖父母,那涉及的连接次数就和你“曾”字的数量一样多。所以基本上,如果你试图同时连接这么多表格,性能会更差。试图为一个查询连接许多表格对数据库系统来说实际上是一个相当大的挑战。

还有更糟糕的情况,因为还有比这更糟糕的。当你进行这样的连接时,通常是因为你有一个外键和一个主键。例如,你有一个指向该主键的外键。然后你有一个指向该主键的外键。你有一个指向该主键的外键。如果你想做相反的事情怎么办?你有一个主键,你想搜索所有具有指向该主键的外键的记录。这是反向模式。你试图以相反的顺序遍历数据库。现在你可能会想,好吧,如果我们不寻找整个连接,而只是寻找特定的记录,我们如何处理?我们只想遍历,比如说,这里的这些特定记录。现在你可能会想,好吧,我们不需要连接它们。我们只使用索引。我们有一个索引。基本上,我们有那条记录。我们获取外键,使用哈希索引直接获取那条记录,O(1),哈希索引,然后O(1),等等。但问题是哈希索引只在一个方向上有效。在另一个方向上,通常你在主键上有一个哈希索引或树索引。但你可能在外键上没有哈希索引或树索引。这意味着在另一个方向上会很慢。当然,除非你在外键上建立索引。但那样你需要建立很多索引来准备。然后你面临的问题是它占用了大量空间。谁看到了我们在这里面临的挑战?我们被困住了。


所以基本上,这里的教训是关系型数据库对此不够好。但即使只是反规范化数据并使用文档存储,甚至可能使用哈希索引,仍然不够好。所以现在我们确实有理由想要做一些新的事情,因为现有系统就是无法完成这项工作。



图数据库的核心思想:指针



上一节我们探讨了现有方案的不足,本节中我们来看看图数据库的核心解决方案。


这就是为什么我们有一种方法让它更直接。在内存中,基本上找到与某些其他数据相关的数据的最直接方式是什么?在物理层面上。指针,正是如此。这就是图数据库的关键。它是指针。在内存中使用指针,而不是主键和外键,这在逻辑上是同一件事。想象一下,这里基本上,当我谈论外键和主键时,这不是一个指针。这不是内存中的指针,它只是我建立的一个逻辑连接,表示这些值是相关的。但如果这里,我们不仅仅是逻辑连接,而是内存中的实际物理指针,那么你明白了吗?当然,在相对意义上。但与实际进行连接和在索引中查找相比,仅仅在内存中跟随指针要快得多。谁看到了这一点?这非常重要,因为这是图数据库的核心动机。这就是我们使用内存指针连接数据,而不是主键、外键和反规范化数据的事实。这就是整个想法。




它有一个你可能想学习的很酷的名字。它叫做索引无关邻接。索引无关邻接只是一种花哨的说法,表示我们使用内存指针。邻接,因为它只是在内存中直接连接。现在你看到内存指针,它们双向都没有问题,你可以有两个方向的指针,这很有效。事实上,如果你想想在数据结构和算法中学到的双向链表,那正是它所做的。双向链表正是这种双向指针。但这只是这个想法的雏形,因为这很简单,你知道,我们使用内存指针。是的,但我们仍然需要小心如何设计它,因为如果我们现在开始设计这个想法,我的屏幕上有什么?我有一个图。它不是树。它不是表格。所以我们谈论的是不同的形状。这意味着我们需要一个数据模型,就像我们有关系表格的数据模型和Json、XML的树数据模型一样。我们需要一个图的逻辑数据模型。当然,这个逻辑数据模型将通过内存指针和结构在物理上实现。一旦我们有了数据模型,我们就需要一个查询语言,那个查询语言将是Cypher。这基本上与SQL和表格或Jsoniq和条目的原理相同。这就是我希望你们看到的。




图数据模型:带标签的属性图

上一节我们介绍了图数据库使用指针的核心思想,本节中我们来看看其具体的数据模型。


我将以Neo4j为例进行本次讲座。我们将使用的数据模型的名称是带标签的属性图。请记住,对于一个数据形状,并没有唯一的模型。例如,对于树,你有XML信息集,你有Json数据模型,你有带有条目序列的Jsoniq数据模型。你有很多模型,对吧?每个查询语言都有一个模型,但你可以有几个模型。在图数据库的情况下,基本上有两个高级竞争模型。基本上是带标签的属性图阵营和三重存储阵营。人们有争论。这是另一个引发论坛争论的话题。如果你问三重存储更好还是带标签的属性图更好,你会看到人们在这个话题上有多么两极分化。所以我试图同时讨论带标签的属性图和三重存储,以给你们一个概述。具体来说,我们将使用的技术是Neo4j,这是最著名的涵盖它们的数据库引擎之一。



现在还有另一个概念,实际上是正交的。你有原生图数据库的概念,以及在另一个数据库之上实现的数据库的概念。这实际上是我之前讨论文档存储、查看MongoDB之前简要提到的事情。我说过,如果你有一个树的集合,为什么不尝试在关系数据库系统中实现那个树的集合,并将所有内容编译成SQL?听起来是个好主意,但非常困难。这就是为什么MongoDB选择了基于树的数据模型的原生实现。这是一个原生实现。将树放入关系表的实现实际上存在。它们效率稍低,但那不是原生实现。它基本上只是一个多层架构,在另一个数据库系统之上实现一个数据库系统。就像你可以用一种语言编译另一种语言,例如,C++编译成C。在三重存储的情况下,实际上,三重存储很容易在只有三列的关系表中实现。三重存储,三列。当我讲到时会很明显为什么,基本上是因为一个三元组基本上是一个主语、属性和宾语,就像一个非常短的句子,每个三元组对应表中的一行。所以在这种情况下,那将不是原生数据库,因为它实际上是在关系数据库之上实现的。我们下周会看到,立方体也可以在关系数据库之上实现。所以你不需要原生的数据库系统来处理立方体。你可以,但不需要。好了,但让我们首先关注Neo4j和带标签的属性图。

带标签的属性图详解
上一节我们介绍了图数据模型有两种主要类型,本节中我们深入探讨带标签的属性图。

让我们稍微回顾一下,回到数据建模。当我告诉你们数据模型时,我说数据模型的要点是拥有一种描述现实的非常简单的方式。例如,在关系模型中,你说一切都是表格。一切都有行和列,非常简单,描述行和列、主键等等。在XML和Json或文档存储的情况下,那么你不是说一切都是表格,而是说一切都是树或一切都是树的集合。然后数据模型涉及树中父节点和子节点的概念等等,原子值的概念,结构化值的概念,如对象和数组或元素和属性。但仍然,解释数据模型是什么是相当简洁的。那么现在,对于带标签的属性图,你认为我们会把世界看作什么?这样一个模型的组成部分会是什么?在我展示之前,可以抛出一些想法。在图数据模型中会有什么?也许名字“带标签的属性图”对猜测有用。任何人。



你也可以在Zoom上写。是的,我看到了你举手。是的,绝对正确。所以你基本上说了,我为Zoom上的人重复一下。你说节点和边。正是如此。我们需要节点和边。事实上,这就是数学家定义图的方式。这实际上很美妙,因为每当你设计一个数据模型时,你希望数学家成为你的朋友。你真的想依赖一些抽象的东西。就像对于表格,我们依赖数学关系或实际函数的集合,这是高度数学化的。
数学家将图定义为一组节点,实际上可以是任何东西,只是一组节点,他们称之为V表示顶点。然后是一组边。边是什么?只是一对节点,他们称之为E。一个图,他们称之为G,是V和E。顶点集合和边集合。这就是图的数学定义。所以现在,如果我们从这个定义开始,那么我们烹饪的原料基本上就是我们需要一些节点和一些边。然后我们将用这些边连接这些节点,得到一个有向图。现在这变成了计算机科学,因为你可以开始以多种方式在内存中实现它。但在这一点上,它是纯数学的。当然,图也可以是无向的,只是意味着你去掉箭头方向。






这听起来很简单,但你无法想象图论中的挑战数量。ETH有一门关于图论的完整课程,可能非常困难和正式。也许数据科学学生。是的,我看到一些举手。非常困难。你有最大流最小割问题等等。实际上ETH最近在这方面有进展。但作为计算机科学家,你想存储这样的图。例如,如果你有三个节点和三条边,你可以把它存储为节点列表。然后你有一个数组,给出所有连接到的目的地。例如,A连接到空,B连接到A和C,C连接到A。这叫做邻接表。然后你有一个邻接矩阵,你基本上有节点。在行和列上。然后当它们连接时,你放一个1。例如,A没有连接到任何东西。B连接到A和C,标记为1,C连接到A。这叫做邻接矩阵。然后你有关联矩阵,其中不是节点在行和列上,而是只有节点在行上,边在列上。基本上,每条边,-1告诉你边的源,1给你目的地。所以从B到A,从C到A,从B到C。




但这并不是我们实现带标签的属性图的方式。所以这些是可能的方式。事实上,对于带标签的属性图,正如名称所示,我们需要添加两样东西:标签和属性。所以现在我们正在扩展我们的成分列表。我们有节点。我们有边。我们有属性。我们看到它们实际上非常接近Json对象。但我稍后会回到这一点。然后是我们在那里放的标签。当我们把所有东西放在一起时,我们得到一个带标签的属性图的实例。现在我将更详细地介绍这一点。



但你可能已经注意到这些是标签。基本上,标签可以在边上或节点上,属性也可以在节点上或边上。这就是这个想法。






节点:属性与标签

上一节我们介绍了图的基本构成,本节中我们具体看看节点的属性与标签。
现在让我们看一个属性。节点的属性应使用复数形式,节点的属性。例如,这是我们的集合V中的一个节点。它有三个属性。姓名是爱因斯坦。名字是阿尔伯特。职业是物理学家。现在,你可以从两个角度看它。也许你实际上只是看到一个像这样的数据库记录。谁看到了?希望每个人都看到了。你是对的。谁看到了一个对象?你也是对的,它可以被看作一个扁平的Json对象,但这并不新鲜,因为我们知道扁平的Json对象与扁平的关系记录相同。所以这基本上是属性的概念。重要的是什么。在这里,我把它与我们已知的东西联系起来,它是扁平的。它是扁平的。我们不允许嵌套。我们不允许具有嵌套的通用Json文档。它必须是扁平的。你只有顶层的属性。就是这样。有一个例外,我稍后会讲到。但一般来说,它是完全扁平的。所以里面没有嵌套。为什么?因为嵌套,我们通过内存指针处理并连接节点在一起。所以如果我们也允许在那里嵌套,那在我们的模型中将是冗余的。所以更容易的是只保持它扁平,并认为我们有一个扁平的结构。
现在,在同一个节点上,你将拥有属性和所谓的标签,一个或多个标签。例如,这个节点,其姓名是爱因斯坦,名字是阿尔伯特,职业是物理学家,具有标签“人”,并且具有标签“在瑞士”,例如,可能在某个特定时间点。
现在,与关系表相比,这看起来可能完全出乎意料且有点奇怪,就像这是我们在这里拥有的与Json或关系表完全不同类型的对象,但实际上我们可以在两者之间轻松建立联系。一旦你看到这一点,你就会理解图数据库背后的直觉,并理解为什么将任何关系数据库存储为图很容易,但反过来却不容易,因为图数据库实际上比关系数据库更通用。
标签与关系表的对应关系


上一节我们看了单个节点的属性,本节中我们来看看多个具有相同标签的节点如何对应到关系表。

这就是我希望你们在这里看到的。当你有绿色节点,或任意数量的节点,它们共享一个标签。它们可以有比那更多的标签,但它们都共享相同的标签“科学家”。如果你看属性,你会注意到这些是相同的属性名称。当然,值可能不同,但属性名称相同。这里看起来有点像模式,一些验证等等,它们具有相同的结构。当你看到相同的标签和相同的模式时,比如说一个没有额外键的封闭模式,这实际上等同于这个关系表。谁看到了?很好。所以你在这里看到的每条记录都是一个节点,记录的值基本上在属性中,关系表名称“科学家”是标签。所以这就是你如何识别它们基本上具有相同的标签。这对应于一个关系表。谁看到了反过来行不通?你可能无法重建关系表。




我们有一个问题。我认为在Zoom上,我也看到了一些事情发生。与关系记录不同,至少我不太明白在属性中是否有主键或类似东西的等价物。现在,如果由于某种原因你有多个具有相同属性的节点,你会将它们总结为一个节点并有多个边指向该节点,还是也可以有多个具有相同属性的节点?所以这是一个设计决策。基本上,你可以,有一个自然的映射。所以有一个直接的映射,但不是唯一的,你可以用多种方式实现相同的事情。通常,想法是,以同样的方式,一个高度规范化或高范式中的记录基本上对应于现实生活中的某个事物。例如,一条记录是一个人,一条记录是一门课程,一条记录是一个国家,同样的话或节点。基本上,每个节点代表现实生活中的一个事物、一个人或一个概念。这就是你思考它的方式。然后你在将其视为记录或将其视为数据库中的节点之间存在这种二元性。但这就是它对应的方式。现在,关于主键,我们在图数据库中也有类似的东西,因为我将在语言中展示。所以在SQL中,基本上是使用SELECT FROM WHERE,你使用WHERE和主键上的谓词,如果你有一个索引,那会很快。对于图,我们将有一种语言。但与其关注表结构和主键,我们关注图中的模式。我们寻找一个模式。如果你寻找一个名为爱因斯坦的特定人,那么你可以在语言中表达你寻找节点,其属性名等于爱因斯坦的节点,然后你也可以有加速这一过程的索引。它在逻辑层面之下。所以你仍然有索引。但你仍然需要看到你有内存指针,取代了以前的连接,你不需要索引。你只需跟随指针。但图数据库中索引的作用是快速定位你正在寻找的节点。当你刚开始查询并从零开始时,你需要一种直接获取你想要开始的节点的方法。这说得通吗?我想是的。希望随着我讲解材料,它会更有意义。


有一个问题。为什么标签的概念必须是一等公民?难道不可能简单地拥有一个或多个对应于标签的属性吗?我告诉过你三重存储与带标签的属性图是两极分化的。这正是三重存储阵营的人会对带标签的属性图阵营说的话,他们正是有这个论点。所以你绝对正确。这个论点也绝对完全有效。
44:图数据库(2/3)📊





在本节课中,我们将继续探索图数据库,特别是带标签的属性图模型。我们将学习其类型系统、查询语言Cypher的基本语法,以及如何将图模式与关系型数据库概念联系起来。

图模型与关系表的映射 🔗
上一节我们介绍了带标签的属性图的基本概念。本节中,我们来看看如何将关系型数据库的表结构映射到图模型上。


在带标签的属性图中,节点可以拥有标签(如“Person”)和属性(如“name: Albert”)。边(或称关系)也可以有类型(如“CHILD_OF”)和属性。

如果你想在图数据库之上实现关系表,本质上可以使用类似内存指针的链接,这类似于关系数据库中外键和主键之间的连接。这正是我们最初想通过使用内存指针来避免表连接的原因。





以下是一个映射示例:
- 一个标签为
Person、属性为name: Albert的节点。 - 另一个标签为
Person、属性为name: Hubert的节点。 - 一条从 Albert 指向 Hubert、类型为
CHILD的关系边。
由此可见,关系表可以轻松地在带标签的属性图(或文档存储)之上实现。但这种映射不是双向的,因为无法轻松地将任何带标签的属性图转换为这样的关系表。




Cypher 类型系统 🧱



现在,我们来探讨类型系统。这部分内容我们在几周前已经涉及过,目标是让你再次认识到它们本质上是相同的概念。


Cypher 是 Neo4j 图数据库的查询语言,用于操作带标签的属性图。其类型系统如下:
核心原子类型(用黄色标出):
INTEGERFLOATSTRINGBOOLEANDATETIMEDATETIMEDURATIONPOINT(用于地理信息系统 GIS)



POINT 类型是这里唯一相对较新的概念,用于处理空间位置信息(如地球坐标)。但许多现代系统(如 MongoDB)也支持类似功能。




结构类型(用蓝色标出):
LIST- 对应 JSON 中的数组 (array) 或 Python 中的列表 (list)。MAP- 对应 JSON 中的对象 (object) 或 Python 中的字典 (dict)。






图特有的类型:
NODE- 图中的节点。RELATIONSHIP- 图中的边(关系)。PATH- 由边连接的一系列节点,即图上的路径。




节点、关系和路径是图数据库中的一等公民,它们在 JSON 或 Python 中没有直接等价物。虽然理论上树是特殊的无环图,但在数据库的语境下,树形结构和图形结构的处理方式不同,因此不能简单等同。


节点与关系的属性限制 📝

节点和关系都可以拥有属性。但属性值的存储有严格限制:属性必须是“平坦”的。
具体来说,属性中只能包含以下内容:
- 原子类型的值(如整数、字符串、布尔值等)。
- 同质列表(
LIST),即所有元素类型必须相同的原子类型列表,例如:[1, 2, 3, 4](整数列表)["a", "b", "c"](字符串列表)[true, false, true](布尔值列表)













这是属性中唯一允许的嵌套形式。除此之外,所有属性都必须是平坦的。这意味着你不能在属性中直接存储一个 MAP(对象)或一个包含多种类型元素的异构列表。

重要区分:在查询语言(Cypher)中,你可以操作和处理所有类型(包括嵌套的 MAP 和异构 LIST)。但在存储层面,节点和关系的属性必须遵守上述平坦化规则。



跨模型的比较:类型排序与空值处理 🔄




了解了一个系统的特性后,将其与其他系统对比有助于加深理解。这里有两个跨数据模型的对比点。


1. 跨类型比较的顺序
在 MongoDB 中,可以比较不同类型的值,系统内部有一个任意的类型排序规则。Neo4j 的 Cypher 语言也是如此,它也有自己的类型排序规则,并且能够进行跨类型比较。需要注意的是,Neo4j 的类型排序与 MongoDB 的完全不同,但这无关紧要,因为这只是一个实现细节。开发者只需知道“可以比较不同类型的值”即可。
2. 空值(Null)的处理范式
在不同系统中,对空值(Null)的处理主要有两种范式:
- 视为特殊值:例如 SQL 中的
NULL,它是一个可以属于任何类型的特殊标记值。 - 视为值的缺失:例如 JSONiq 中的空序列(empty sequence)。




在 Neo4j 中,null 的行为更接近 SQL 的 NULL 或 JSONiq 的空序列。你应该将其理解为“值的缺失”。任何数据类型都可以有一个 null 值,但这实际上意味着该值不存在。在类型系统中,这被称为类型是“可空的”(nullable)。




在 JSONiq 中,使用问号(?)表示可空类型,例如 integer? 表示“要么是一个整数,要么是空序列”。这与 Neo4j 中可空类型的概念是相通的。








列表(LIST)与映射(MAP)详解 📚
理解了基本类型和限制后,我们进一步看看 LIST 和 MAP 的细节。
列表(LIST)
- 异构列表:在查询语言中,列表可以是异构的,例如
[2, 3, true, "blah"]。这在 JSONiq 和 MongoDB 中也很常见。 - 同质列表:在属性存储中,列表必须是同质的,例如
[1, 2, 3, 4]。 - 索引访问:在 Cypher 中,使用单方括号和从
0开始的索引来访问列表元素,例如list[0]。这与 JSONiq(从1开始)和 Python(从0开始)略有不同。


映射(MAP)
映射本质上就是 JSON 对象。
- 语法差异:在 Cypher 中,映射的键通常不加引号,例如
{name: "Alice", age: 30}。这与某些 JSON 解析库的宽松语法类似。 - 嵌套性:在通用查询语言中,映射可以任意嵌套,值可以是列表或其他映射。
- 属性限制:然而,当映射作为节点的属性存储时,它必须是平坦的,即其值只能是原子类型或同质原子类型列表。
为什么查询语言支持更复杂的结构?
既然属性存储限制严格,为什么查询语言还要支持嵌套映射和异构列表呢?这是为了提供最大的灵活性。例如,在查询时,你可能需要联合(union)来自不同节点、类型各异的属性值,或者需要在查询过程中构造复杂的数据结构进行中间计算。这种设计理念与 JSONiq 是一致的。

Cypher 查询语言入门:子句与导航 🧭




前面我们讨论了数据模型和类型系统。现在,我们开始接触 Cypher 查询语言本身,首先是基本的子句和导航操作。



基本导航
导航数据的语法与 JSONiq 非常相似:
- 点号(.)访问属性:
node.name类似于 JSONiq 中的对象查找。 - 方括号([])访问列表元素:
list[0]。Cypher 没有 JSONiq 中用于区分序列和数组的双重方括号概念,因为它的列表不会像 JSONiq 序列那样巨大。
核心子句
Cypher 的查询由一系列子句构成,其设计思想非常接近现代查询语言(如 JSONiq、Google SQL Pipe Syntax),允许子句以灵活的顺序组合(只要以 RETURN 结束)。数据以绑定元组流的形式在子句间传递。
RETURN:用于输出结果,相当于 SQL 的SELECT或 JSONiq 的return。WITH:用于将中间结果绑定到变量,并传递到下一个子句。它类似于 JSONiq 的let子句,但有一个关键区别:每个新的WITH子句只会保留其显式指定的变量,之前绑定的其他变量会被丢弃。如果需要保留,必须在新的WITH中重新列出。UNWIND:用于将列表“展开”为多行记录,相当于 JSONiq 的for子句。例如,UNWIND [1, 2, 3] AS num会生成三行数据,每行的num变量分别绑定为 1, 2, 3。WHERE:用于过滤,和 SQL 的WHERE类似,但可以出现在查询的多个位置,与其他子句灵活交织。





这种基于子句和绑定流的范式,使得 Cypher 在表达复杂的数据转换和流水线处理时,比传统的 SQL 更加灵活和强大。






图模式匹配:Cypher 的核心 🎯

现在,我们进入 Cypher 最核心、最强大的部分:图模式匹配。



你的数据库就是一个图。图查询的本质是识别图中的特定模式,然后从这些模式中提取数据。Cypher 使用一种直观的 ASCII 艺术 风格语法来描述这些模式。



基本模式匹配
假设我们想在一个图中找到如下模式:三个节点,其中第一个节点通过类型为 A 的关系连接到第二个节点,第二个节点通过类型为 B 的关系连接到第三个节点。





在 Cypher 中,你可以这样写:
(alpha)-[:A]->(beta)-[:B]->(gamma)
(alpha),(beta),(gamma)是为节点分配的变量名。[:A]和[:B]指定了关系的类型。冒号(:)后面是类型标签。- 箭头(
->)指明了关系的方向。




执行这个 MATCH 子句时,数据库会在图中查找所有符合该模式的子图。每找到一次,就将匹配的节点绑定到变量 alpha, beta, gamma 上,生成一条记录。


增加过滤条件
你可以在模式内部或使用 WHERE 子句来增加过滤条件:
- 过滤节点标签:
(beta:Yellow)要求节点beta必须有标签Yellow。 - 过滤节点属性:
(alpha {name: "Einstein"})要求节点alpha的name属性等于 “Einstein”。 - 两者结合:
(alpha:Blue {name: "ETH"})要求节点同时满足标签和属性条件。



更复杂的模式
Cypher 可以表达非常复杂的模式:
- 变长路径:
(alpha)-[:A*1..4]->(beta)查找从alpha到beta、通过类型为A的关系、路径长度在 1 到 4 之间的所有路径。这类似于正则表达式,在 SQL 中实现此类查询会非常复杂。 - 环状路径:
(alpha)-[:A]->(beta)-[:B]->(gamma)-[:C]->(alpha)查找一个回到起点的三角形环。 - 无方向边:如果你不指定箭头方向,模式将匹配任意方向的关系。
MATCH 子句的使用
图模式需要放在 MATCH 子句中使用。MATCH 子句会生成变量绑定,然后可以与其他子句(如 WITH, WHERE, RETURN)组合,形成完整的查询。例如:
MATCH (alpha)-[:A]->(beta)-[:B]->(gamma)
WHERE alpha.name = "Einstein"
RETURN gamma
这个查询会找到所有符合模式的子图,然后过滤出其中 alpha 节点名为 “Einstein” 的记录,最后返回对应的 gamma 节点。



数据更新与其他操作 ⚙️





与 SQL、MongoDB 和 JSONiq 一样,Cypher 也支持数据更新操作。



创建数据
使用 CREATE 子句可以创建新的节点、关系和属性。它是声明式的:你描述你想要的数据状态,数据库会使其成为现实。
CREATE (e:Person {name: "Einstein", first: "Albert"}),
(u:University {name: "ETH Zurich"}),
(e)-[:VISITED]->(u)

合并数据
MERGE 子句类似于 CREATE,但具有“存在即忽略,不存在则创建”的语义。它确保图中的某个模式存在,如果模式中的节点或关系已存在,则不会重复创建。MERGE 同样会绑定变量供后续子句使用。

其他子句
Cypher 还包含许多其他实用子句,例如:
DELETE:删除节点或关系。SET:更新属性。LIMIT/SKIP:限制返回结果的数量和偏移。ORDER BY:排序。UNION:合并结果集。
这些子句都可以与前面介绍的子句自由组合,为你提供强大的数据操作能力。







总结 🎓


本节课我们一起深入学习了图数据库的核心——带标签的属性图模型及其查询语言 Cypher。
我们首先了解了如何将关系表映射到图结构,并认识到这种映射的单向性。接着,我们系统学习了 Cypher 的类型系统,明确了节点和关系在属性存储上的限制(平坦化、同质列表)。通过对比 Neo4j 与 MongoDB 在类型比较和空值处理上的异同,我们加深了对不同系统设计理念的理解。

然后,我们详细探讨了 Cypher 查询语言。从基本的子句(RETURN, WITH, UNWIND, WHERE)和灵活的绑定流模型入手,我们看到了它与 JSONiq 等现代查询语言的相似性。最后,我们聚焦于 Cypher 最强大的功能:使用 ASCII 艺术风格的语法进行图模式匹配。我们学习了如何描述基本模式、增加过滤条件、表达复杂路径(如变长路径、环),并将 MATCH 子句与其他子句结合以构建完整查询。此外,我们也简要介绍了数据的创建(CREATE)、合并(MERGE)等更新操作。
掌握 Cypher 的关键在于理解其基于模式匹配和声明式子句流水线的核心思想。这使你能够直观而高效地查询和操作复杂的关联数据。
45:图数据库 (3/3) 🗺️

在本节课中,我们将学习图数据库的物理架构、存储机制,并介绍另一种重要的图数据库范式——三元组存储(Triple Stores)及其相关标准 RDF。





物理架构:复制与分片
上一节我们介绍了图数据库的逻辑模型和查询语言。本节中,我们来看看图数据库在物理上是如何组织和扩展的。
首先,与 MongoDB 类似,图数据库也采用复制机制。早期版本的 Neo4j 没有分片功能,只有多个服务器复制整个图的数据。这样做可以实现读取负载均衡,多个读取请求可以分散到所有机器上。
对于写入操作,通常有一个主节点负责处理更新,然后将更改传播到其他副本。后来,系统也允许在所有副本上写入,但这会引入 CAP 定理相关的复杂性问题。



公式表示复制:
数据副本数 > 1
主节点写入 -> 传播到副本节点







接下来是分片。分片曾经是图数据库领域的一个难题,但近年来已经得到解决。Oracle 团队的 PGX(分布式版本 PGXD)是首批成功实现图分片的系统之一。






分片的难点在于需要高效地遍历图中的路径。解决方法不是像表或树那样进行严格不重叠的分区,而是允许分区之间存在重叠。当遍历图时,如果路径到达另一个分区的数据,查询可以“跳转”到另一台机器上继续执行。这类似于将鼠标移动到屏幕边缘时,光标会移动到另一台电脑屏幕的效果。分片策略高度依赖于查询模式,非常复杂,但如今 Neo4j 等主流图数据库都已支持分片。

代码表示分片跳转逻辑:
# 伪代码:遍历时处理跨分片跳转
def traverse_graph(start_node, query_pattern):
current_machine = locate_shard(start_node)
result = []
for path in explore(current_machine, start_node, query_pattern):
if path.end_node in another_shard:
# 跳转到另一个分片机器继续遍历
switch_to_machine(locate_shard(path.end_node))
continue_exploration(path.end_node, query_pattern)
result.append(path)
return result






物理存储:内存与磁盘






现在,我们来探讨图数据在内存和磁盘上是如何存储的。


在内存中,图数据库利用 “索引无关邻接” 特性。这意味着标签属性图中的节点和边在内存中通过指针直接连接,类似于面向对象编程中对象相互引用的复杂内存结构。这种结构使得遍历边的操作非常高效。
公式表示内存中的指针关系:
节点A -(指针)-> 边1 -(指针)-> 节点B
当需要将数据持久化存储到磁盘时,系统会将各个组件分开存储。以下是存储文件的分类:
- 节点存储:存储所有节点。
- 关系存储:存储所有边(关系)。
- 属性存储:存储节点和边的属性值(原子值)。
- 标签存储:存储节点的标签。
- 动态字符串/数组存储:由于长度可变,字符串和数组被单独处理。
这种存储方式非常模块化。当从磁盘加载数据时,系统会重建内存中的图结构;当持久化时,又将内存结构写回这些独立的文件中。

在内存中,数据结构被优化以支持快速遍历。例如,一个节点的多个标签以链表形式存储。从一个节点出发的所有边和指向该节点的所有边,分别通过双向链表进行组织,这使得可以高效地迭代所有可能的路径方向。这些底层结构是执行 Cypher 查询模式匹配的基础。



此外,还可以在之上建立索引结构(如哈希或树索引),以加速查找具有特定属性的节点,无需搜索整个图。


图查询语言的标准化 🎯
关于 Neo4j,最后要分享一个非常好的消息:图查询语言正在走向标准化。




目前有两个重要的标准脱颖而出:
- GQL:这本质上是 Cypher 的标准化版本。多个行业参与者达成协议,协调各自的查询语言,其中大量内容来源于 Cypher。Neo4j 已宣布将逐步演进 Cypher 以对齐该标准。
注意:不要与 GraphQL 混淆。GraphQL 用于查询树状数据(是众多树查询语言之一),与图数据库无关。
- SQL/PGQ:这是 SQL 的扩展。它在已知的 SQL 语言基础上,增加了图模式匹配功能(即 ASCII 艺术的核心)。这适用于仍处于关系数据库环境但又需要处理图数据的用户。可以预期,像 PostgreSQL 或 MySQL 这样的关系型数据库管理系统将开始支持图类型和相关的 SQL 扩展。
行业标准化的出现令人欣喜,这意味着所有行业参与者可以聚焦并对齐到统一的标准上。


关于考试语言的说明:在考试中,你们只需要掌握四种语言:PostgreSQL 的 SQL、PySpark API、Spark SQL 和 JSONiq。对于其他如 MongoDB、Cypher 等,只会涉及理论性问题或代码理解,不需要编写代码。



第二种范式:三元组存储与 RDF



现在,我们转向图数据库的第二种范式:三元组存储,其标准是 RDF(万维网联盟 W3C 标准)。





三元组存储的核心思想是比标签属性图更加细粒度。在标签属性图中,一个节点可能拥有多个属性。而在三元组存储中,每个属性都被分解成一个独立的边和节点。因此,三元组存储只包含节点、边以及边上的标签。

之所以称为“三元组”存储,是因为其中的所有数据都表示为 (主体,属性,客体) 的形式,类似于英语中的“主-谓-宾”句子(例如,“ETH 位于 瑞士”)。整个数据库就是一个庞大的三元组列表,这些三元组共同构成了一个图。
公式表示三元组:
三元组 = (主体, 属性, 客体)

RDF 数据模型


在 RDF 中,节点和边的命名有三种方式:

以下是节点和边标识的规则:
- 主体:可以是 URI 或空白节点,不能是字面量。
- 属性:必须是 URI。
- 客体:可以是 URI、空白节点或字面量。

RDF 语法


与树(XML、JSON)和表(CSV)一样,RDF 也有多种语法来表示三元组列表,它们遵循相同的模型。







- Turtle:最流行的语法。在文件中每行一个三元组。可以使用前缀(
@prefix)和分号;、逗号,来简化重复的主体或属性书写。 - JSON-LD:JSON 格式的语法。将具有相同主体的所有属性-客体对组织在一个 JSON 对象中。
- RDF/XML:XML 格式的语法。


对于三元组存储,当看到 geo:locatedIn 这样的 QName 时,会将其前缀和本地部分拼接成一个完整的 URI(例如,http://example.com/geography#locatedIn)。

查询语言:SPARQL
三元组存储的查询语言是 SPARQL(不要与 Spark SQL 混淆)。SPARQL 用于查询 RDF 数据。

SPARQL 也使用子句结构,类似于 SQL。它的核心是图模式匹配,功能上类似于 Cypher 的 ASCII 艺术模式。例如,查询“所有位于瑞士的东西”或“所有位于美洲的国家内的学校”。
代码表示 SPARQL 查询示例:
# 查找所有位于瑞士的事物
PREFIX geo: <http://example.com/geography#>
SELECT ?thing
WHERE {
?thing geo:locatedIn <http://example.com/Switzerland> .
}
# 查找所有位于美洲国家内的学校
PREFIX geo: <http://example.com/geography#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT ?school
WHERE {
?school rdf:type <http://example.com/School> .
?school geo:locatedIn ?country .
?country geo:locatedIn <http://example.com/America> .
}
RDF 与知识图谱、人工智能
RDF 和三元组存储的一个重要应用领域是构建知识图谱和人工智能。




在机器学习盛行之前,人工智能的另一种方法是基于知识库和逻辑推理。RDF 三元组可以作为形式化的知识片段。通过在其上定义本体——即类(如国家、人物)和属性(如位于、人口)的正式规范,可以对存储的知识进行推理。
例如,通过“所有人都是会死的”和“苏格拉底是人”这两个三元组,系统可以推理出“苏格拉底是会死的”。这种基于符号逻辑的推理仍然是人工智能中一个重要且活跃的研究方向,与当前流行的语言模型形成互补。
在 RDF 之上定义本体和推理规则的层被称为 OWL。





总结









本节课中我们一起学习了:
- 图数据库的物理架构,包括通过复制实现高可用和读扩展,以及通过允许重叠的复杂分片策略实现写扩展。
- 图数据的物理存储,了解了“索引无关邻接”在内存中的高效指针结构,以及节点、边、属性等组件在磁盘上分离存储的模块化方式。
- 图查询语言的标准化进展,介绍了 GQL(基于 Cypher)和 SQL/PGQ(SQL 的图扩展)两大标准。
- 图数据库的第二种范式——三元组存储,深入探讨了 RDF 数据模型(主体-属性-客体)、多种语法(Turtle, JSON-LD, RDF/XML)以及查询语言 SPARQL。
- RDF 在知识图谱和人工智能中的应用,了解了其作为形式化知识库支持逻辑推理的作用。









我们看到了,除了树和表,图也是一种重要的数据形态,并且同样遵循着数据独立性的理念,拥有自己的语法、查询语言,并正在走向标准化。
46:数据立方体 (1/2) 📊










在本节课中,我们将要学习数据立方体。这是本课程介绍的最后一种数据形态。我们将首先了解数据立方体的历史背景,然后深入探讨其逻辑模型和基本操作,例如切片、切块和聚合。

课程安排与回顾 📅



上一节我们介绍了图数据。本节中,我们来看看数据立方体。


今天是本学期最后一节讲授新内容的课程。明天将有一场关于向量数据库的客座讲座。下周将没有新内容,我们将一起庆祝学期结束,回顾所学知识,并举行一场大型问答游戏。

OLTP 与 OLAP:两种数据处理范式 🔄

在深入了解数据立方体之前,我们需要理解两种关键的数据处理范式:在线事务处理 和 在线分析处理。

- OLTP 负责公司的日常业务运营,例如管理库存、订单和客户信息。其特点是高并发、大量写操作,关注单个记录的快速读写,强调数据一致性。典型的系统如 PostgreSQL。
- OLAP 则用于数据分析,以支持商业决策。其特点是大量读操作、复杂查询,关注数据的整合与聚合,通常处理历史数据。我们本学期学习的 MapReduce 和 Spark 本质上都属于 OLAP 范畴。


以下是两者的核心区别:





- 操作类型:OLTP 侧重写,OLAP 侧重读。
- 数据视角:OLTP 关注细节(单个记录),OLAP 关注整体(聚合数据)。
- 响应时间:OLTP 要求即时响应(毫秒级),OLAP 可以是交互式或批处理(分钟、小时甚至天)。
- 数据规范化:OLTP 需要保持高范式以避免更新异常;OLAP 可以接受反规范化以提升查询性能,因为数据通常是只读的。

简而言之,OLTP 是“运营系统”,而 OLAP 是“分析系统”。历史上,OLTP 常与关系表关联,OLAP 常与数据立方体关联。








OLAP 的核心特征 🎯


OLAP 系统通常用四个特征来描述:
- 面向主题:数据围绕特定分析主题(如销售、财务)组织,而非围绕应用程序。
- 时变性:数据包含时间维度,用于分析历史趋势和模式。
- 非易失性:数据一旦加载到分析系统中,通常不会被修改,只用于查询。
- 集成性:数据来自公司内多个异构的源系统(如ERP、CRM、数据库),经过提取、转换和加载过程整合到统一的数据仓库中。


这个过程就是 ETL:
- 提取:从源系统获取数据。
- 转换:清洗、合并、转换数据以适应目标模型。
- 加载:将处理后的数据载入数据仓库。
数据仓库是一个独立的系统,专门用于存储和分析集成后的数据,避免影响日常运营的OLTP系统。
数据立方体的逻辑模型 🧊



现在,让我们正式进入数据立方体的世界。从逻辑上看,一个数据立方体是一个多维数组。

核心概念





- 维度:观察数据的角度,如
时间、产品、地区。 - 成员:维度的具体取值,如
时间维度的“2024年”、“2025年”;地区维度的“瑞士”、“德国”。 - 事实:在特定维度成员组合(即“单元格”)处度量的数值,如“2024年在瑞士售出的服务器数量”。


一个三维立方体可以直观地表示为 时间 x 产品 x 地区。更高维度(四维、五维)在数学上完全可行,只是难以可视化。

事实表:立方体的表格化视图





为了方便人类理解和机器处理,我们通常将立方体“扁平化”为一张事实表。


例如,一个 2 x 2 x 2 的立方体(地区:德国/瑞士,时间:2021/2022,销售员:Peter/Mary)可以表示为以下事实表:

| 地区 | 时间 | 销售员 | 销售额(美元) |
|---|---|---|---|
| 德国 | 2021 | Peter | 1000 |
| 德国 | 2021 | Mary | 2000 |
| 德国 | 2022 | Peter | 3000 |
| 德国 | 2022 | Mary | 4000 |
| 瑞士 | 2021 | Peter | 5000 |
| 瑞士 | 2021 | Mary | 6000 |
| 瑞士 | 2022 | Peter | 7000 |
| 瑞士 | 2022 | Mary | 8000 |
每一行代表立方体中的一个单元格(事实)。事实表是理解立方体操作的基础。






立方体基本操作:切片、切块与聚合 ⚙️

1. 切片





切片 是指固定一个(或几个)维度的成员,观察剩余维度的数据。这相当于在关系代数中执行选择操作。

例如,对上述事实表进行切片:地区 = ‘瑞士’。结果是一个只包含瑞士数据的新立方体(子集)。


在SQL中,这对应于:
SELECT * FROM fact_table WHERE region = 'Switzerland';



2. 切块与交叉表




切块 是切片的一种特殊形式,它通常保留两个维度作为分析焦点,并将其他维度固定。结果可以方便地展示为交叉表。




例如,在切片地区=瑞士且货币=美元后,我们只剩下时间和销售员两个维度变化。我们可以将其组织成交叉表:


| 销售员 \ 时间 | 2021 | 2022 |
|---|---|---|
| Peter | 5000 | 7000 |
| Mary | 6000 | 8000 |






在交叉表中:
- 放在行上的维度称为行维度。
- 放在列上的维度称为列维度。
- 被固定的维度称为切片器。
这就是“切片和切块”一词的由来。用户界面常通过选项卡、滑块等方式处理第三个维度。




3. 聚合与维度层次结构








聚合 是指沿某个维度计算汇总值,如总和、平均值等。这通常涉及维度的层次结构。





例如,地区维度可能具有层次:世界 -> 欧洲 -> 瑞士 -> 苏黎世。聚合“瑞士”意味着对“苏黎世”、“日内瓦”等城市的数据求和。


在交叉表中,聚合结果常以“小计”或“总计”行/列的形式出现,并用L形边框或缩进来直观表示层次关系。



- 上卷:沿层次结构向上聚合,查看更概括的数据(如从“城市”到“国家”)。
- 下钻:沿层次结构向下展开,查看更详细的数据(如从“国家”到“城市”)。






总结 📝


本节课中我们一起学习了数据立方体的基础知识:
- 我们首先区分了 OLTP 和 OLAP 两种数据处理范式,理解了它们不同的设计目标和特点。
- 我们探讨了 OLAP 系统的四个核心特征:面向主题、时变性、非易失性和集成性,并介绍了 ETL 过程和数据仓库的概念。
- 我们深入学习了数据立方体的逻辑模型,包括维度、成员、事实等核心概念,以及用事实表表示立方体的方法。
- 我们掌握了立方体的三种基本操作:切片(选择特定数据子集)、切块/交叉表(以表格形式展示两个维度的数据)以及聚合(沿维度或层次结构计算汇总值)。







这些概念是理解商业智能和数据分析中多维数据建模的基石。在下半部分,我们将探讨如何在 SQL 和实际系统中实现这些操作。
47:数据立方体实现与查询 🧊



在本节课中,我们将学习数据立方体的两种主要实现范式,并重点探讨如何利用关系型数据库(特别是SQL)来实现和查询数据立方体。我们将了解如何将多维逻辑模型映射到二维表格,以及如何使用SQL的扩展功能来高效地进行切片、切块、上卷、下钻等操作。







上一节我们介绍了数据立方体的逻辑模型及其基本操作。本节中,我们来看看数据立方体的物理实现方式。






实现范式概览 🏗️

在高层面上,实现数据立方体主要有两种范式:
- MOLAP (多维联机分析处理):这是一种原生实现方式,专门为多维数据形状设计。
- ROLAP (关系型联机分析处理):这是一种“取巧”的实现方式,它将数据立方体构建在关系型表格之上,并将所有操作编译为SQL查询。







本课程将重点介绍ROLAP的实现方式。

ROLAP:基于关系表的实现 📊




为什么选择ROLAP?一个关键原因是,表示立方体的事实表可以非常自然地存储为SQL系统中的关系表。
核心概念:在ROLAP中,一个数据立方体通常被实现为一张事实表。这张表的结构是:每一列对应一个维度,最后一列则是对应的度量值。




| 年份 | 品牌 | 客户 | 销售额 |
|------|--------|--------|--------|
| 2023 | 苹果 | 张三 | 100 |
| 2023 | 三星 | 李四 | 150 |




多度量值与数据透视


有时,一张事实表可能包含多个度量值列(例如,收入、支出、利润)。这些共享相同维度组合的多个度量值列,可以通过数据透视(Pivot)和逆透视(Unpivot)操作进行转换。



转换原理:逆透视操作将多个度量值列“堆叠”起来,并添加一个新的“度量”维度列。这样就将多列结构转换成了标准的单度量值列结构,反之亦然。SQL语言甚至有专门的PIVOT和UNPIVOT扩展来完成此类操作。

维度表与模式设计







在事实表周围,通常会有维度表。事实表中的维度成员实际上是指向维度表主键的外键。
关于维度表的设计,主要有两种模式:
- 星型模式:每个维度对应一张维度表,直接与事实表连接。
- 雪花模式:在星型模式的基础上,对维度表进行进一步的规范化(例如,将地区维度拆分为城市、国家、大洲表),形成类似雪花的形状。


反规范化事实表
如果你不喜欢多表连接,可以选择反规范化。即将维度表中的属性(如国家、大洲)直接合并到事实表中。
优点与缺点:这样做会导致数据冗余(例如,“瑞士”和“欧洲”会重复出现)并引入函数依赖,但好处是你可以得到一个单一的表。这个表可以轻松导出为CSV文件并导入Excel,利用数据透视表功能进行直观的拖拽式分析,非常方便。





了解了数据立方体在数据库中的存储方式后,接下来我们看看如何查询它。
使用SQL查询数据立方体 🛠️




虽然存在专门的多维查询语言MDX,但本课程将展示如何用熟悉的SQL来实现数据立方体的操作。



我们以一个简单的事实表为例:








| 年份 | 品牌 | 客户 | 数量 |
|------|--------|--------|------|
| 2023 | 苹果 | 彼得 | 1 |
| 2023 | 苹果 | 玛丽 | 5 |
| 2023 | 三星 | 彼得 | 2 |
| ... | ... | ... | ... |


切片操作



在SQL中,切片操作非常简单,直接使用WHERE子句进行筛选即可。


-- 切片:获取2023年的数据(逻辑上得到一个 1 x 2 x 2 的子立方体)
SELECT * FROM fact_table WHERE 年份 = 2023;



上卷与下钻操作


上卷(聚合) 操作对应SQL的GROUP BY。你需要在GROUP BY子句中保留不想聚合的维度。



-- 上卷:按年份和品牌聚合,忽略客户维度(逻辑上挤压了客户维度)
SELECT 年份, 品牌, SUM(数量) AS 总数量
FROM fact_table
GROUP BY 年份, 品牌;



要下钻,只需在GROUP BY子句中添加回更细粒度的维度。





计算小计与总计








一个常见的需求是同时计算各维度组合的值、各维度的小计以及总计。一种朴素的方法是分别写多个查询再用UNION合并,并用NULL填充不存在的列。



-- 分别查询详细值、按年份的小计、按品牌的小计、总计,然后合并
SELECT 年份, 品牌, SUM(数量) AS 总数量 FROM fact_table GROUP BY 年份, 品牌
UNION ALL
SELECT 年份, NULL, SUM(数量) FROM fact_table GROUP BY 年份
UNION ALL
SELECT NULL, 品牌, SUM(数量) FROM fact_table GROUP BY 品牌
UNION ALL
SELECT NULL, NULL, SUM(数量) FROM fact_table;




SQL的扩展:GROUPING SETS, CUBE, ROLLUP

为了方便此类操作,SQL引入了扩展语法:
GROUP BY GROUPING SETS:明确指定多个分组集合的并集。GROUP BY CUBE:生成指定维度所有可能子集(幂集)的分组。如果有n个维度,将产生 2^n 个分组集。GROUP BY ROLLUP:按层次结构上卷,生成从最详细到总计的层级分组。如果有n个层次,将产生 n+1 个分组集。







-- 使用CUBE一次性生成所有小计和总计
SELECT 年份, 品牌, SUM(数量) AS 总数量
FROM fact_table
GROUP BY CUBE(年份, 品牌);
处理维度层次结构
当维度内部存在层次结构(如年->季度)时,可以使用GROUP BY ROLLUP来方便地计算各层级的聚合值。
-- 按年和季度层次上卷
SELECT 年份, 季度, SUM(数量) AS 总数量
FROM fact_table
GROUP BY ROLLUP(年份, 季度);
-- 结果将包含:(2023, Q1), (2023, Q2), (2023, NULL-年合计), (NULL, NULL-总计)
最后,我们了解一下为什么需要数据立方体的标准化语法。


数据立方体语法:XBRL 📄

我们需要一种机器可读的语法来交换数据立方体信息,一个重要的应用场景是财务报告。全球许多公司都被要求以电子格式提交财务、风险或可持续发展报告。

XBRL (可扩展商业报告语言) 就是这样一种基于XML的语法。在XBRL报告中,财务报表(本质上是一个具有维度层次和小计的数据立方体视图)中的每个数字都被标记了其在多维空间中的坐标(公司、期间、概念/度量等)。




工作原理:公司可以提交外观精美的HTML报告,但通过在HTML标签内嵌入XBRL标签,使计算机能够精准识别每个数字的含义和上下文。这样,监管机构或分析工具就能自动读取和分析报告数据,无需依赖OCR或复杂解析。

总结与练习 🎯






本节课我们一起学习了数据立方体的物理实现。我们重点探讨了ROLAP范式,即如何将多维数据映射到关系表,并利用SQL及其扩展(GROUP BY CUBE/ROLLUP)来执行切片、切块、上卷、下钻等操作。我们还简要了解了用于标准化报告的数据立方体语法XBRL。

动手练习建议:尝试将一个反规范化的事实表导出为CSV,然后导入Excel或类似电子表格软件。使用数据透视表功能,通过拖拽维度字段到行、列区域,并利用筛选器,直观地体验切片、切块和聚合操作。这是理解数据立方体逻辑最直观的方式之一。
48:大数据课程总结与展望 🎓
在本节课中,我们将回顾本学期所学的大数据核心原则,并展望未来的技术发展趋势。课程内容涵盖了数据处理的多种形态、系统设计原则以及新兴技术方向。
回顾:大数据十大设计原则 📚
上一节我们介绍了课程的整体目标,本节中我们将系统回顾贯穿本学期的十个核心设计原则。
1. 学习历史经验 📖
我们需要从过去的技术发展中学习经验。例如,关系型数据库管理系统在发展过程中积累了大量关于数据独立性和查询语言设计的经验。当开发新的数据形态(如图、立方体、向量)时,借鉴这些经验至关重要。XML社区早期的错误在JSON出现时本应被吸取,但实际情况并非总是如此。
2. 保持设计简洁 ✨

当数据规模扩展到PB甚至EB级别时,设计必须保持简单。例如,采用简单的键值模型,通过增加键值对的数量(而非增大键或值本身)来扩展,这使我们能够轻松地在集群中进行分区和分布式处理。
核心思想:系统扩展应仅沿一个维度(数量)进行。
3. 模块化架构 🧩

作为计算机科学学生,模块化是基本思想。由于人脑处理能力有限,我们必须将复杂系统(如从硬盘驱动到LLM交互的整个数据库管理系统)分解为多个层级。每个层级(如硬件、编码、语法、模型、验证、分布式处理、查询语言、UI)专注于特定任务,并通过上下层进行通信。
4. 数据形态的统一范式 🔄
所有数据形态(表、树、图、立方体、文本/向量)都遵循相似的范式结构:
- 范式:看待数据的方式(如行与列、节点与边)。
- 数据模型:对应的数学形式化描述(如JSON模型、属性图模型)。
- 语法:存储和传输数据的格式(如CSV、JSON、Parquet)。
- 查询语言:操作数据的语言(如SQL、GQL、自然语言)。
- 系统架构:不同形态的系统架构非常相似。
5. 宏观同质性与微观异质性 ⚖️


- 宏观同质性:在极大规模(数十亿记录)下,数据集合看起来是同质的,这便于分区和分布式处理。
- 微观异质性:当放大观察时,数据具有多样性。例如,文档数据库中的文档无需遵循相同模式,验证是可选的。这种灵活性使系统能够处理现实世界中多样化的数据,并为机器学习进行数据准备。
6. 嵌套数据结构 🌳
通过反规范化,我们可以得到嵌套数据。扁平的表行可以包含子表,从而形成树结构。因此,表可以广义地视为树的集合。
概念映射:表 → 扁平树的集合
7. 通用的原子数据类型 🔢
无论使用何种系统(JSON、XML、SQL、Python),底层的基本数据类型(如字符串、数字、布尔值、日期)都是相似的。学习新系统时,主要是熟悉其命名和特定限制。
8. 逻辑模型与物理存储分离 🛡️
数据独立性的核心是让用户只关心逻辑模型(如表、树、向量的集合),而无需关心数据在磁盘上的具体存储方式。物理存储的实现交由数据工程师负责。

9. 数据形态间的相互转换 ♻️


所有数据形态在表达能力上本质是等价的,可以在一定复杂度约束下相互转换:
- 立方体可以存储为关系表(事实表)。
- 表可以作为扁平树存储。
- 树可以存储为图(树是无环的图)。
- 图可以表示为三元组表(主体-属性-客体)。
- 文本正日益向量化。
10. 分布式处理核心策略 🚀


以下是实现大规模分布式处理的关键策略:
- 分片:将数据切割成块(分片、分区),分布到不同机器上。
- 复制:为避免数据丢失,将数据存储多个副本(如HDFS的块复制、MongoDB的副本集)。
- 使用廉价硬件:大数据处理依赖大量廉价商用硬件构成的集群,而非昂贵的超级计算机。
- 批处理:以批次为单位处理数据,而非逐条记录处理。这同样应用于存储层(如累积写入、刷写、合并压缩文件)。

未来展望与选择指南 🔮
上一节我们总结了核心设计原则,本节中我们来看看如何应用这些知识面对未来,并为技术选型提供指导。
面对未来项目
未来你们可能会面临不同层级的项目:
- 学习现有产品:加入已使用特定产品的公司。掌握核心概念后,学习具体产品会更容易。
- 进行技术选型:需要自主选择存储、处理系统(如S3 vs HDFS,关系数据库 vs 图数据库)。
- 设计全新系统:创造未来可能被教授的新技术。此时,站在巨人肩膀上并避免重复发明轮子至关重要。
技术选型关键问题
进行选择时,务必思考以下问题:


- 数据形态:我的数据最自然的形态是什么?(表、树、图、立方体、向量)
- 数据规模:数据量有多大?(GB级可在笔记本处理,TB/PB级可能需要集群)
- 处理需求:我想对数据做什么?(过滤、聚合、模式匹配等)
- 生产力与性能:需要在编写查询的便利性(生产力)和执行查询的速度(性能)之间取得平衡。
- 是否重复造轮子:是否有现成的、经过验证的方案可以复用?
重要提示:许多用例在单台笔记本电脑上即可处理,并非所有情况都需要数据中心。


未来技术趋势预测

预测未来是困难的,但我们可以观察到一些趋势:
- 底层API的隐藏:MapReduce、Spark等底层API应被高级声明式语言甚至自然语言取代,对终端用户不可见。
- 数据形态意识增强:系统将更明确地支持多种数据形态,并通过扩展SQL(如用于树、立方体、向量)来统一处理。
- 查询语言标准化:图像查询语言GQL的成功标准化是范例。希望树状数据查询语言(如JSONiq)也能尽快实现标准化,结束目前数十种语言并存的局面。
- 交互方式演进:从“无代码”UI向更自然的语音交互发展。LLM需要与具备精确查询和推理能力的数据库系统结合,以克服幻觉问题。
- 数据的可互换性:如同水电一样,数据可能变得更加“可互换”。三元组存储和数据立方体将数据分解为原子单元(三元组、单元格),为实现标准化的数据交换提供了可能。
- 数据主权与伦理:数据保护法规(如GDPR)要求更严格的数据控制。技术能力伴随责任,必须考虑数据处理的合法性、伦理性。
- 机器学习与数据库的融合:
- ML在DB之上:如向量数据库,在DBMS之上构建AI功能。
- DB在ML之中:尝试用机器学习组件(如学习型索引)替换传统DBMS组件,形成AI增强的数据库系统。
- 量子计算的审慎看待:量子计算利用量子比特并行处理,但存在输出结果随机且单一的局限。目前其解决的特定问题往往缺乏实际应用场景,需理性看待相关宣传。

总结 📝

本节课中我们一起回顾了大数据处理的十大核心设计原则,包括从历史中学习、保持简洁、模块化、理解数据形态的统一范式、平衡宏观同质与微观异质、利用嵌套结构、认识通用数据类型、分离逻辑与物理、掌握形态转换以及运用分片、复制、批处理等分布式策略。我们还探讨了如何将这些知识应用于未来的项目选型,并对查询语言标准化、AI与数据库融合等未来趋势进行了展望。掌握这些基础原理,将使你们能够更快地适应具体技术,并做出明智的架构决策。
49:展望与知识问答游戏 (2/2) 📚



在本节课中,我们将回顾课程的核心概念,了解课程评估反馈,并详细说明期末考试的形式与规则。最后,我们将通过一个紧张刺激的知识问答游戏来检验大家的学习成果。

宇宙作为数据库与计算机 🌌

上一节我们探讨了大数据系统的各种形态。本节中,我们来看看一个更宏大的视角:将宇宙本身视为一个数据库和计算机。
这个想法源于康奈尔大学 Johannes Gehrke 关于实时策略游戏(如《文明》)实现的研究。在这些游戏中,游戏状态的更新可以被视为数据库更新,整个游戏系统可以看作一个数据库系统。由此引申,我们所生活的世界是否也可能是一个拥有底层数据库的虚拟现实?将宇宙视为一台计算机,这是一个融合了哲学思考的有趣观点。

通常,熵增定律认为系统会趋向于越来越无序。但我们观察到,生命和文明却在不断构建结构。或许放眼未来数百万甚至数十亿年,宇宙实际上会变得越来越有组织。当然,这已经超出了数据库课程的范畴,仅作为一个有趣的脚注。
课程评估结果与反馈 📊
现在,我们来看看大家完成的课程评估结果。每年的评估重点会轮换,今年聚焦于课程本身。感谢大家的反馈,这能帮助我们未来改进。
评估结果通常会公开。作为一名教师,看到大家理解了所讲授的内容,是最好的回报。助教团队也获得了极高的评价,恭喜他们。一些助教还在评论中被点名表扬,我已经将这些积极的反馈转达给了他们。
此外,我很高兴看到大家能够向学弟学妹们解释课程材料,因为教学是最好的学习方式。

以下是评估中关于课程工作量和所需知识水平的反馈图示。我的个人目标是让课程稍微偏向“工作量较少”和“所需知识水平较低”的一侧,我对目前的结果感到满意。
我了解到大家的学习负担很重,苏黎世联邦理工学院(ETH)整体上也正在通过“Packet”改革等项目来应对这个问题,旨在调整学期安排、减轻学生压力、提高知识传授的质量。这涉及到将课程内容从14周压缩到13周等改变。
期末考试详细说明 📝
关于期末考试,以下是需要了解的所有信息。
考试范围与资料
- 学期内接触的所有材料都是考试范围,包括教科书、幻灯片、理论与实践练习以及课堂答题器问题。
- 考试将在ETH的计算机上进行,不是个人设备。系统为Windows,无需Docker,所有环境(包括PostgreSQL、Spark SQL、DataFrames、JSONiq)都已预装在虚拟机中。
- 我们会提前提供考试当天使用的Jupyter Notebook以及相关数据集,以便大家熟悉环境。
考试形式与评分
- 考试最多包含60道题,其中会有编程题。
- 对于编程题,我们会根据答案的正确性来评分。虽然要求大家粘贴查询语句,但这部分不计分,只是为了在出现多种答案时帮助我们理解思路并公正评分。
- 请尽量遵守答案格式要求(如使用英文句点而非逗号),这能帮助我们更准确高效地评卷。
技术问题处理
- 考试期间如遇技术问题,请立即举手。助教会提供一线支持,另有ETH数字考试团队提供二线支持。
- 我们会记录故障时间并在考试结束后补回。如果只是单个查询卡住,通常重启Jupyter内核即可解决。
常见问题解答
- 可以使用不同的语言答题吗? 可以。例如,如果题目要求用PySpark,但你用Spark SQL得出了正确答案,依然得分。但PostgreSQL题目必须用SQL连接数据库完成。
- 会有文档吗? 是的,我们会尽力在考试电脑上提供PostgreSQL、PySpark、JSONiq的文档(需满足50MB空间限制)。但无法进行全文搜索,因此熟悉文档结构很重要。
- 需要死记硬背吗? 虽然文档存在,但掌握核心语法和转换就像在脑中建立了哈希索引,能显著提高解题速度,因此建议熟记关键知识。
我们提供文档、数据和Notebook,是为了降低大家的考试压力,让大家在考场上更从容。


知识问答游戏 🎮

现在,进入轻松有趣的环节——知识问答游戏。游戏规则是:所有参与者起立,我提出问题,答错者坐下,坚持到最后的一位获胜者将获得奖品。同时,我们还将与助教团队进行对决。







以下是游戏过程中出现的一些题目与答案,看看你能答对多少:
题目1:JSONiq 放宽了哪些SQL条件?
- 答案:全部(关系完整性、域完整性、原子性完整性)。文档存储的世界放宽了所有约束。
题目2:哪种数据结构最适合搜索相关文本内容?
- 答案:向量(Vectors)。这是上节课的内容,可以将单词和文档编码为向量空间中的嵌入,然后查找相近的文档。


题目3:以下哪些数据结构有对应的SQL扩展支持?
- 答案:全部(树-Trees、图-Graphs、立方体-Cubes、向量-Vectors)。例如,PgSQL中的数组和对象类型、SQL/PGQ、GROUP BY CUBE以及向量数据类型。


题目4:以下哪个子句在PostgreSQL中不存在?
- 答案:GROUP BY GRID。其他如CUBE、ROLLUP、GROUPING SETS均存在。
题目5:在单个单元格中存储多大体积的数据不会导致性能问题?
- 答案:10兆字节(10 MB)。通常适用于JSON、XML、视频等。


题目6:Google继Bigtable之后使用的当前系统是什么?
- 答案:Spanner。




题目7:以下哪个不是原子数据类型?
- 答案:数组(Array)。


题目8:以下哪个不是查询引擎(如MongoDB)中的物理执行模式?
- 答案:物化消除执行(Dematerialized Execution)。其他如物化、流式、并行执行都是。
题目9:2014年Spark排序100TB数据耗时多久?
- 答案:23分钟。


题目10:“Hadoop”这个名字来源于?
- 答案:项目创始人儿子的大象玩具的名字。
题目11:文档存储是否需要模式(Schema)?
- 答案:可以拥有,甚至可以后期定义。需要区分格式正确(Well-formed)和有效(Valid)。


题目12:什么是索引邻接?
- 答案:使用原生指针或邻接关系来构建和链接数据,以替代连接操作。用于图数据库。



题目13:在集群中跨多个资源(CPU、RAM、网络)进行分配的机制叫什么?
- 答案:主导资源公平性(Dominant Resource Fairness)。




经过多轮激烈角逐,最终诞生了学生获胜者!恭喜他们。


总结与告别 👋




本节课中,我们一起回顾了将数据系统概念延伸至哲学思考的趣味视角,分析了课程评估的反馈结果,并详细了解了期末考试的格式、规则与备考建议。最后,通过一场精彩的知识问答游戏,在欢乐中检验了本学期的学习成果。

感谢大家整个学期的积极参与,这是一段非常愉快的时光。祝大家在期末考试中一切顺利,备考期间我们随时提供帮助。再见!

浙公网安备 33010602011771号