UNSW-COMP9315-PostgreSQL-笔记-全-
UNSW COMP9315 PostgreSQL 笔记(全)
001:课程介绍与预备知识 🎯

在本节课中,我们将要学习COMP9315课程的整体介绍、学习目标以及学习本课程所需的预备知识。我们将通过一个简单的数据库示例来回顾SQL查询,并明确课程对编程和数据结构基础的要求。
大家好,我是John Shepherd。这是我的个人网站,我相信你们中有些人已经看过了,因为你们已经填写了相关的调查问卷。
我是计算机科学与工程学院的讲师。这是我的办公室,也是我将进行答疑咨询的地方。
关于咨询的具体安排,详细信息已经发布在课程网站上,你们可以去查看。

咨询时间安排在每周四的讲座之后。本课程没有助教,只有我本人。希望我能顺利度过这个学期。

那么,这门课程是关于什么的?你们来到这里是为了学习关系型数据库的架构。我们将以PostgreSQL作为具体的学习载体,后续我会详细讨论PostgreSQL。
我们需要一个数据库来进行实践。去年的第一个作业就是围绕PostgreSQL进行一些操作。在第一个作业结束时,我提到第二个作业会涉及更多PostgreSQL的内容,但出乎意料的是,很多同学的反馈是“不,我们觉得PostgreSQL的代码太多了”。所以今年我可能会尝试相同的策略。

第一个作业将涉及操作PostgreSQL。第二个作业则要求编写一些C语言代码,这些代码是独立的,但旨在说明数据库查询处理的某些方面。

这门课程并不全是关于PostgreSQL的。它更多地是讨论在处理大量数据时有用的各种数据结构及其相关算法。
当然,我们仍然需要了解关系型数据库的工作原理。我们将学习对象如何表示、操作符如何实现。研究这些的一个原因是,关系型数据库自70年代中期就已存在,我将在未来的讲座中详细讲述这一点。
从70年代至今,人们在实现所需的各种操作符方面做了大量有趣的工作,并且技术一直在发展。新的数据库应用场景不断出现,催生了新的算法来解决新问题。同时,也出现了一些新挑战,例如谷歌面临的问题,其数据规模据称太大,以至于传统关系型数据库难以处理,因此谷歌实现了自己的文件系统及其上的操作符。
考虑到只有十周的时间,我可能无法涵盖这些更现代的创新。但我认为我们将要讨论的所有内容仍然非常有用。
此外,我们还需要关注并发控制,这一点非常重要。因为关系型数据库被广泛使用,并且经常在许多人同时访问数据库的场景下使用。

因此,本课程的目标是让你们在课程结束时掌握这些技能。
我假设你们都熟悉C语言。去年有些同学直到课程结束显然还不懂C语言,并在期末考试中遭遇了失败。如果你的C语言编程能力不强,现在放弃还来得及。第一个作业用C语言,第二个作业用C语言,考试也需要C语言编程。明白了吗?
我假设你们至少学习过一门扎实的、涉及C语言的数据结构课程。我也假设你们学习过数据库入门课程,因此接触过SQL。鉴于这类课程是本课程的先修课,我认为你们都学过。
此外,如果你们学习过COMP1521(操作系统)课程,那会很有帮助,虽然可能不是所有人都学过。该课程涉及许多低层操作系统功能,这些对于实现数据库系统显然是必需的,因为我们需要在相当低的层次上与磁盘或文件系统打交道。所以我们需要关注这类问题。

我还假设你们学习过算法课程,因此知道什么是快速排序,什么是哈希。


快速排序对于关系型数据库来说不那么相关,但哈希技术绝对是相关的。
正如我所说,如果你不具备这些知识,现在放弃还来得及。好吧,让我们看看谁真的还记得这些内容。
这是一个简单的数据库模式,包含三个表:students、courses和enrolment,这是标准的设计。
students表包含学生ID、姓名,以及为了增加一些其他信息而加入的学位信息(在实际中,这应该是一个外键,引用一个degrees表,但在此练习中不重要)。courses表包含一个内部随机整数ID、标准的UNSW课程代码、学期以及课程标题。enrolment表基本上是一个连接学生和课程的表,实现了一个多对多的关系,因为一个学生可以选修多门课程,一门课程显然也有多名学生。
那么,谁能写出一个SQL查询来找出所有学生的选课情况?没有人举手?看来得由我来做了。在黑板上写可能不是个好主意,因为视频可能拍不清楚,但如果我在屏幕上写,就没问题了。


本节课中,我们一起学习了COMP9315课程的概况、学习目标以及所需的C语言、数据结构、SQL和算法基础。我们回顾了一个简单的数据库模式,并明确了本课程将深入探讨数据库内部实现原理,包括数据表示、操作符实现和并发控制等核心主题。准备好迎接挑战吧!
002:数据库操作与系统架构概述 🗄️
在本节课中,我们将要学习关系型数据库的核心功能、数据定义语言(DDL)与数据操作语言(DML)的区别,以及PostgreSQL如何通过系统目录(Catalog)来管理数据库的元数据。我们将通过简单的示例来理解这些概念。
考试与课程资料说明 📝
上一节我们介绍了课程的基本信息,本节中我们来看看考试的相关安排。

考试期间,考生不能携带个人笔记或任何纸质材料。课程致力于无纸化,因此考试中的所有资料都将显示在屏幕上。目前屏幕上可能只显示部分内容(A),但所有必要的资料都将在学期结束前提供,以便学生充分理解。如果在考试中遇到不确定的问题,可以查阅这些笔记以获取帮助。
关系型数据库核心功能 🔧
关系型数据库允许用户维护大量数据集合,同时也允许维护关于数据本身的信息(即元数据)。我们将在后续讨论系统目录时详细展开。
约束是关系型数据库的一个重要方面,它确保输入的数据满足特定属性,从而防止无效数据进入数据库。
以下是关系型数据库提供的主要功能列表:
- 数据查询:使用标准的SQL语言。
- 视图:部分数据库支持。
- 触发器:部分数据库支持。
- 存储过程:部分数据库支持。
- 查询重写:PostgreSQL通过规则(Rules)实现,允许将一种查询自动转换为另一种形式。
- 索引:任何完善的数据库都应具备索引能力,以提升查询性能。
- 并发控制:支持多用户同时访问的数据库必须具备。
- 事务处理:确保一系列数据库操作作为一个原子单元执行,对于关键计算应用至关重要。
所有关系型数据库都基于关系模型,并提供某种版本的SQL。然而,不同数据库厂商通常会添加自己的扩展功能,因此完全遵循SQL标准的语句基本通用,但包含特定扩展的SQL语句则可能在不同数据库间存在差异。
数据定义语言(DDL) 📐
数据定义语言用于定义数据库结构。最常见的语句是创建表(CREATE TABLE),也可能包括创建域(CREATE DOMAIN)。
PostgreSQL及其他许多关系型数据库允许用户基于现有数据类型定义新的数据类型,并附加约束。例如,我们可以定义一个表示“波浪高度”的新类型 Wch:
CREATE DOMAIN Wch AS float
CHECK (VALUE >= 0.0 AND VALUE <= 100.0);

此代码定义了一个名为 Wch 的新域,它基于 float 类型,但约束其值必须在0到100之间。随后,我们可以在定义表属性时使用这个类型:
CREATE TABLE Students (
id INTEGER PRIMARY KEY,
name TEXT,
wave_height Wch
);
当我们尝试向 Students 表插入或更新数据时,数据库会自动检查 wave_height 字段的值是否在定义的范围内。
执行 CREATE TABLE 这类DDL语句时,并不会直接向数据库添加实际数据,而是添加元数据。这些信息(如表名、属性、主键等)需要被存储在某个地方。
系统目录(Catalog)与DDL执行流程 🗂️
现代数据库标准做法是使用一组特殊的表来存储关于所有其他表、类型、约束等的信息,这组表称为系统目录。这听起来有些递归,但运行良好。
数据库管理系统(DBMS)的架构可以简化为:用户输入DDL语句(如 CREATE TABLE),DBMS连接到磁盘上的各种存储结构。执行DDL语句主要影响系统目录部分,它不会添加数据元组或填充索引,而只是在目录中添加一条记录,声明存在一个具有特定属性的新表。执行完成后,通常会返回一个状态信息。
让我们通过实际操作PostgreSQL来观察这个过程。
首先,连接到PostgreSQL(命令是 psql):

psql
连接成功后,可以创建一个简单的表:
CREATE TABLE R (x INTEGER, y INTEGER);
如果执行成功,将返回类似 CREATE TABLE 的状态信息,表明表已创建,且目录已被更新。现在,我们可以查看数据库中是否存在这个表:
\dt



此命令会列出所有表,其中应包含刚创建的 R 表及其所有者信息。要查看表的详细结构(即元数据),可以使用:

\d R

这将显示表 R 包含两个整数列 x 和 y。这些列名和类型的信息同样被存储在系统目录中。

因此,DDL语句主要影响系统目录。
数据操作语言(DML)与数据影响 📝

除了DDL,另一类重要的语句是数据操作语言(DML),用于改变表中的实际数据,主要包括插入(INSERT)、更新(UPDATE)和删除(DELETE)。

例如:
INSERT INTO Students VALUES (1234, 'John Doe', 2.5);(插入单行)UPDATE Students SET wave_height = 5.0 WHERE id = 1234;(更新单行或多行)DELETE FROM Enrollments WHERE student_id = 1234;(删除一行或多行)

DML语句的执行会直接影响表中的数据。如果这些表上建有索引,那么相应的索引也会被更新以保持一致性。执行后,PostgreSQL会返回一个状态信息。

让我们回到之前创建的 R 表进行操作:
INSERT INTO R VALUES (10, 20);
执行成功后,会返回类似 INSERT 0 1 的状态。这里的 1 表示有一行被修改。0 表示PostgreSQL没有为这行数据生成内部对象标识符(OID)。我们可以配置PostgreSQL为每个插入的元组生成一个全局唯一的OID,如果启用,这里会显示一个大的数字作为OID。我们将在后续课程中更详细地讨论OID。
现在,可以查看表中的数据:


SELECT * FROM R;

我们也可以删除数据:
DELETE FROM R WHERE x = 10;
总结 🎯
本节课中我们一起学习了:
- 关系型数据库的核心功能,包括数据存储、约束、查询及事务管理等。
- 数据定义语言(DDL) 用于定义数据库结构(如表、域),其执行主要影响存储元数据的系统目录。
- 数据操作语言(DML) 用于增删改实际数据,其执行直接影响表数据及相关索引。
- 通过简单的PostgreSQL实操,我们观察了DDL和DML语句的执行过程及其反馈的状态信息,初步理解了数据库系统如何区分和管理元数据与真实数据。

理解DDL与DML的区别,以及系统目录的作用,是深入学习数据库内部实现原理的重要基础。
003:Postgres简介与课程安排 📚
在本节课中,我们将了解课程的整体安排,并深入探讨我们将要使用的数据库系统——Postgres的背景与特点。
课程安排与资源获取 📅
上一节我们介绍了课程的基本情况,本节中我们来看看如何获取课程资源以及本周的安排。
在大多数讲座中,我都会展示类似这样的幻灯片,让大家了解课程进度、今天以及接下来一周的计划。需要指出的一点是,Echo360平台的录播视频会在讲座结束后几小时上线。而我个人录制的视频,则要等到第二天早上我来到学校后才能上传。虽然我可以从家里上传,但耗时太长,所以不这么做。

如果你现在访问课程网站,可以看到昨天的讲座视频已经可用。视频会以这样的形式呈现。如果你觉得幻灯片不如我本人有趣,也可以选择只看视频画面。

以下是关于视频下载的说明:
- 很多人询问下载事宜。我怀疑下载后得到的就是这种分屏效果,因为这似乎是Echo360平台处理的特性,下载的文件可能也是如此。

无论如何,我们不需要纠结于此。总结来说,Echo360视频会即时处理并在讲座后几小时提供;我的视频则会在次日早晨上传。如果这个小摄像头足够可靠,也许未来我会停止个人录制,完全依赖Echo360。

实践任务与小组组建 💻
接下来,我们谈谈本课程的实践部分。我已经布置了第一个实践练习,内容是安装Postgres服务器。请大家尽快完成,趁其他课程还不忙的时候着手。同时,请开始组建作业小组。
组建小组的途径如下:
- 我设置了一个特殊页面,方便不认识其他同学的人发帖交流、获得回复并组建小组。
- 但正式创建小组,你需要进入指定的链接进行操作。
本周核心内容:深入Postgres 🗄️
在介绍了课程资源与安排后,本节我们将聚焦于本周的核心内容——Postgres数据库系统。
今天我将会比昨天更深入地讨论Postgres。下周,我们将开始学习数据库管理系统提供的存储管理等核心内容。
你可能已经注意到幻灯片底部有一些奇怪的信息,它们指明了教材、对应的章节和章节。如果任何信息过时了,请告诉我。你可能会发现有些教材不涵盖特定主题。正如昨天提到的,所有教材都大致覆盖了DBMS,但有些(如旧的Maz教材)不讨论目录,而其他教材则会讨论。有些教材会用整章来阐述某个主题,而另一些可能只用一小节。因此,你可以从这里了解查找某个主题的最佳去处。
数据库简史与人物 👨💼
在深入技术细节之前,了解历史背景总是有益的。这些技术从何而来?我们为何关心数据库?这位友善的老人又是谁?
没人知道吗?他与埃迪·鲍尔走得很近。不知道他的名字是查尔斯·巴赫曼。他之所以有趣,是因为他提出了整个数据库的概念——虽然不是我们如今所认识的形式,但确实是关于拥有一个结构化数据集合、可永久存储在磁盘上、并能进行查询和更新等操作的整体理念。可以说,很多事情在很久以前就发生了。
他在密歇根大学完成了本科学习,之后在陶氏化学公司工作,从事过程控制。在那里,他意识到需要一种方法来组织与过程控制相关的所有数据,并提出了他称之为“集成数据存储”的想法。如果你看看它所实现的所有功能,会发现它与现代数据库系统所做的非常相似。它肯定不是关系型数据库,因为关系模型的概念在当时尚未提出,那是后来的事。
最终,关系作为数据库结构机制的概念出现了,随后IBM的一些人研究出了如何基于关系模型实现一个系统。但所有这些关于存储、检索、事务等的想法,他在1964年就提出了解决方案。之后他继续从事咨询工作。
因此,在每周的讲座中,我都会穿插介绍一些数据库领域有趣人物的信息。
为什么选择Postgres?🤔
现在,让我们回到正题,解释为什么本课程选择Postgres作为教学系统。
我们使用Postgres,而不是Oracle,因为你无法获取Oracle的源代码;我们也不使用MySQL,因为它的源代码结构较为分散。MySQL是通过从各处拼凑存储管理器等方式构建的。而Postgres至少是由一个团队作为一个单一系统实现的,拥有一个相对合理、一致的代码库。
事实上,PostgreSQL起源于90年代初伯克利的一个名为Postgres的研究项目。该项目旨在成为一个融合了面向对象思想的关系数据库后继者。原始的Postgres拥有极其优美的代码库(如果你能找到的话),但它没有SQL前端。其初衷是使用某种面向对象的语言来访问Postgres处理的数据。后来,一些学生接手了这个优秀的代码库,为其添加了SQL前端,并逐渐演变成我们今天所知的PostgreSQL。因此,你有时会看到它被称为对象-关系数据库管理系统。系统中还保留着原始系统面向对象特性的一些微小痕迹,但不多。
它为你提供了一个标准的关系代数引擎,对关系操作进行了相当高效的实现。它具有良好的事务处理能力,特别是在处理并发更新方面。
本节课总结

在本节课中,我们一起学习了以下内容:
- 课程资源的获取方式,包括Echo360录播和个人录制的视频。
- 首个实践练习(安装Postgres)和组建作业小组的要求与途径。
- 本周的核心内容是更深入地了解Postgres系统,并预告了下周将开始的存储管理核心主题。
- 通过教材引用信息了解了如何查找扩展阅读资料。
- 回顾了数据库的简史,认识了数据库概念的先驱查尔斯·巴赫曼。
- 明确了选择Postgres作为本课程实践系统的原因,包括其代码库的一致性、清晰的历史渊源(由研究项目演化而来)以及它作为对象-关系数据库管理系统的特性。
004:系统目录

在本节课中,我们将要学习PostgreSQL如何管理其内部的各种对象,例如数据库、表、视图、函数等。核心在于理解系统目录,它是一组特殊的表,用于存储数据库的元数据。
系统目录的作用
上一节我们介绍了PostgreSQL中的基本对象。本节中我们来看看这些对象是如何被系统管理和表示的。



系统目录是PostgreSQL用来记录所有数据库对象信息的内部表。例如,有一个目录表记录了存在哪些数据库、它们的所有者以及访问控制权限。类似地,也有目录表用于记录模式、表空间、表、属性、约束、视图、存储过程、触发器和规则。

大多数对象都有名称,并且几乎都有一个唯一的对象标识符,即OID。

OID与目录表的关系



以下是关于OID和目录表交互的关键点:

- OID的可见性:并非所有表的OID都默认可见。例如,用户创建的表可能没有OID列,除非特别指定。但系统目录表本身通常包含OID。
- 目录表引用:目录表之间的外键引用通常指向另一个目录表中元组的OID值。例如,
pg_database表中的datdba字段(数据库所有者)就是一个OID,它引用了pg_authid(角色表)中的一个条目。 - 查询处理的基础:当执行SQL查询(如
SELECT * FROM students;)时,查询处理器必须查阅目录表来验证students表是否存在、位于哪个模式、包含哪些属性等元信息。




系统目录表在PostgreSQL中通常以pg_为前缀,例如pg_class表存储了关于表和类似对象(“关系”)的信息。




标准目录与PostgreSQL特定目录

关于目录标准,有以下需要注意的方面:
- 历史原因:标准化的目录模式(如SQL标准中的
INFORMATION_SCHEMA)在2000年左右才出现。而像PostgreSQL这样的数据库出现得更早,因此它们拥有自己特定的目录结构。 - 两种视图:
pg_catalog:这是PostgreSQL原生的系统目录模式。information_schema:这是一组按照SQL标准定义的视图,它们底层基于pg_catalog中的表。使用information_schema可以提高应用程序在不同数据库系统间的可移植性。

目录的维护与更新
目录表本身也需要被更新。这种更新发生在执行数据定义语言操作时。




以下是触发目录变更的几种主要操作:



- 创建对象:当使用
CREATE TABLE或CREATE TYPE等语句时,系统会在相应的目录表中插入新记录。 - 删除对象:当使用
DROP TABLE等语句时,系统会从目录表中删除对应的记录。 - 修改对象:当使用
ALTER TABLE等语句时,系统会更新目录表中的相应记录。 - 权限管理:当使用
GRANT或REVOKE语句时,系统会更新与访问控制相关的目录表。


例如,当执行DROP TABLE groups;时,实际上是在pg_class等目录表中删除了与groups表相关的记录。
总结


本节课中我们一起学习了PostgreSQL系统目录的核心概念。我们了解到系统目录是一组存储所有数据库对象元数据的特殊表,它们使用OID作为对象间引用的主要机制。目录通过pg_catalog模式直接访问,同时也通过标准的information_schema视图提供兼容性。最后,我们看到了目录数据如何随着CREATE、DROP、ALTER等DDL命令而动态更新,这是数据库管理自身结构的基石。
005:存储子系统


在本节课中,我们将学习PostgreSQL的存储子系统。上一节我们介绍了系统目录,它帮助我们查找表、类型和属性等对象的名称。本节中,我们来看看如何将这些逻辑名称转换为文件系统中的物理文件,并了解数据如何被加载到内存中进行处理。

课程概述

存储子系统负责将数据库中的逻辑对象(如表)映射到物理存储文件。当我们执行查询时,例如评估一个WHERE子句,必须将相关的数据页加载到内存中。为了提高效率,数据库系统广泛使用缓冲机制。本节课将探讨这些核心概念。
环境配置与常见问题
在深入技术细节前,先解决一些实践中的常见问题。

- 截止日期提醒:第一次测验将于明天截止。目前有127人已完成,意味着仍有约100人未完成。请尽快完成,不要等到最后一刻。
- 学生状态检查:系统会定期同步选课记录。如果发现你不在最新的选课名单中,可能会将你的状态标记为“非正式学生”或“已离校”。如果你在访问测验时遇到权限问题,请及时联系我,可以很容易地将你重新添加回来。
- 项目分组:请确保你已加入或创建了项目小组。
- 编译与运行环境:强烈建议所有PostgreSQL相关操作都在
grieg服务器上进行。在其他机器(如wagner或工作站)上编译的代码可能因库文件不兼容而无法在grieg上运行。反之亦然。 - 使用正确的命令版本:确保你使用的是自己编译的PostgreSQL命令(位于
~/server/bin/目录下),而不是系统预装的版本。可以通过设置PGHOST等环境变量来指向你的私有服务器实例。 - 服务器管理:使用完毕后,请关闭你的PostgreSQL服务器进程。每次登录时,你需要重新设置环境变量并启动服务器。主要步骤是执行你的环境设置脚本(例如
source ~/env),然后启动服务器。
存储子系统核心
现在,让我们回到存储子系统的主题。
一个PostgreSQL表在文件系统中体现为一个具体的文件。这些文件位于PGDATA/base/目录下,每个数据库都有一个以其OID(对象标识符)命名的子目录,表文件则位于相应的数据库目录中,并以数字命名。

存储子系统的核心任务之一,就是将我们通过目录查到的逻辑表名(如student或enrollment)转换并定位到对应的物理文件。
当我们执行查询时,例如判断一行数据是否满足WHERE子句的条件,必须先将包含该行数据的页面从磁盘加载到内存中。如果每次需要数据时都临时从磁盘加载,数据库性能会非常低下。


因此,所有数据库系统都大量使用缓冲池(Buffer Pool)。缓冲池是内存中的一块区域,用于缓存从磁盘读取的数据页。当需要访问某个页面时,系统首先检查它是否已在缓冲池中。如果在(称为缓冲命中),则可以直接使用,避免了昂贵的磁盘I/O操作;如果不在(称为缓冲未命中),则需要从磁盘读取,并通常替换掉缓冲池中某个现有的页面。

在数据库上下文中,缓冲管理是一个复杂而关键的组件,它极大地影响着数据库的整体性能。


总结

本节课我们一起学习了PostgreSQL的存储子系统。我们了解到,逻辑表最终对应着文件系统中的物理文件。为了高效处理数据,系统必须将这些文件页面加载到内存中,并通过缓冲池机制来最大限度地减少磁盘访问,从而提升查询性能。理解存储和缓冲的基本原理,是深入学习数据库内部工作机制的重要一步。
006:查询执行成本分析示例
在本节课中,我们将通过一个简单的查询示例,学习如何分析数据库操作的执行成本。我们将重点关注磁盘I/O(读写)与内存中计算(如元组检查)之间的成本差异,并理解为什么磁盘操作通常是性能瓶颈。
查询场景与参数设定
上一节我们介绍了成本分析的基本概念,本节中我们来看看一个具体的例子。假设我们有一个非常简单的查询:从一个表 R 中读取所有元组,并将其插入到另一个表 S 中。这本质上就是复制整个表。

为了进行成本分析,我们设定以下参数:
- 表
R中的总元组数T= 10,000 - 每个元组的大小
R= 200 字节 - 数据库页面大小
P= 4 KB (4096 字节) - 从硬盘读取一个页面的时间
T_r= 10 毫秒 - 向硬盘写入一个页面的时间
T_w= 10 毫秒 - 在内存中检查一个元组是否满足条件的时间
C_t= 1 微秒 (0.001 毫秒) - 在内存中构建一个结果元组的时间
C_r≈ 0 (可忽略不计)

计算存储需求
以下是计算存储表 R 所需页面的步骤:
首先,我们需要计算每个页面能容纳多少个元组。这取决于页面大小和元组大小。
公式:tuples_per_page = floor(P / R)
代入数值:floor(4096 / 200) = floor(20.48) = 20。因此,每个数据页可以紧密存放 20 个元组。
接下来,计算存储所有元组需要多少个页面。
公式:pages_needed = ceil(T / tuples_per_page)
代入数值:ceil(10000 / 20) = 500。所以,表 R 总共占用 500 个数据页。
分析查询执行成本
现在,我们来分解执行 INSERT INTO S SELECT * FROM R 这个查询的各个步骤及其成本。
1. 读取数据页的成本
要扫描整个表 R,必须读取它的所有数据页。
- 需要读取的页面数
B_read= 500 - 读取总时间
Cost_read=B_read * T_r=500 * 10 ms= 5000 ms = 5 秒

2. 检查与构建元组的成本
对于读取的每一个元组,数据库可能需要检查它(尽管在这个简单查询中,SELECT * 意味着选择所有元组,但检查操作在逻辑上仍然存在)。然后,需要为结果集构建新的元组。
- 检查元组的总时间
Cost_check=T * C_t=10000 * 0.001 ms= 10 ms - 构建结果元组的总时间
Cost_result≈ 0 ms (因为所有 10000 个元组都进入结果集,但内存操作成本极低)

可以看到,内存中的计算成本(10毫秒)与磁盘读取成本(5000毫秒)相比微乎其微。
3. 写入结果页的成本
查询结果需要被写入到新表 S 中。由于我们复制了所有 10000 个元组,并且每个页面能容纳 20 个元组,所以:
- 需要写入的页面数
B_write=ceil(10000 / 20)= 500 - 写入总时间
Cost_write=B_write * T_w=500 * 10 ms= 5000 ms = 5 秒

4. 总成本汇总

将以上所有成本相加,得到查询执行的总时间:
总时间 = Cost_read + Cost_check + Cost_result + Cost_write
= 5000 ms + 10 ms + 0 ms + 5000 ms
= 10010 ms ≈ 10.01 秒
核心结论与常用参数
通过这个例子,我们可以得出一个关键结论:在数据库操作中,磁盘I/O(读取和写入页面)的成本通常远高于内存中的CPU计算成本。即使进行了上万次的元组处理,其时间消耗与几百次的磁盘访问相比也几乎可以忽略。
在后续的成本分析中,我们会频繁使用以下关系参数:
T: 关系(表)中的元组总数。R: 每个元组的(平均)大小。B: 关系占用的数据页总数。P: 数据库页面大小。


它们之间的关系可以概括为:
公式:B = ceil(T / floor(P / R))

总结

本节课中我们一起学习了如何对一个简单的全表扫描查询进行成本分析。我们通过计算读取页数、写入页数以及内存操作时间,清晰地展示了磁盘I/O是数据库操作的主要性能瓶颈。理解这些基本成本构成,是后续学习查询优化和高效存储结构的基础。
010:页面结构与容量计算
在本节课中,我们将学习Postgres中页面(Page)的基本结构,并理解如何计算一个页面能容纳多少条记录(Record)。我们将明确几个核心概念的定义,并通过一个具体的例子来演示容量计算的过程。
核心概念澄清
上一节我们介绍了数据在磁盘和内存中的基本组织形式。本节中,我们来看看几个关键术语的准确定义,这对于后续理解至关重要。
- 记录(Record):一个单纯的字节序列。它通常存储在磁盘上,但当一页数据被加载到内存后,记录也指代该页面内特定位置的一个字节序列。公式表示为:
Record = Sequence of Bytes - 元组(Tuple):一组允许我们解释记录字节的信息。它可能意味着我们已经从字节中提取出了数据,并得到了一系列对应的属性值。元组具有属性名,且属性值是特定域中的值,而不仅仅是字节序列。因此,元组是我们在内存中可用的、类似数据库元组的结构。Postgres对此有明确的区分。
- 页面(Page):磁盘文件中的一个页面的副本。在Postgres中,一个页面的大小是固定的 8KB(8192字节)。代码表示为:
PageSize = 8192 bytes - 页面ID(Page ID):一个文件内页面的索引。要找到页面的文件偏移量,只需将页面ID乘以页面大小。例如:
FileOffset = PageID * PageSize - 元组ID(Tuple ID / TID):一个页面内记录的索引。它是指向页面内目录(Directory)的索引,通过目录项可以找到页面内实际的记录字节。
- 记录ID(Record ID):由页面ID和元组ID组成的一对标识符
(PageID, TID)。它告诉我们记录位于哪个页面,以及在该页面内的具体位置,从而可以找到记录字节并将其转换为元组。
为了定位记录,我们访问页面的目录,目录会给出特定记录(即代表元组的字节序列)在页面内的偏移量。
页面容量分析
了解了页面的基本构成后,一个自然的问题是:一个页面能容纳多少条记录?这取决于页面大小、记录大小以及页面内部管理结构(如目录)所占用的空间。
显然,记录数量取决于页面大小。一个8KB的页面通常能容纳的记录数量大约是4KB页面的两倍(假设记录大小相同)。记录的大小则由创建表时的定义决定。
以下是几种常见情况:
- 小型记录:约64字节
- 中型记录:几百字节
- 大型记录:约500字节或更大(非常大的记录不会直接存储在数据文件中,后续课程会介绍)。
一个页面除了存储实际数据记录,还包含一些管理信息。主要包括:
- 页头(Page Header):包含用于管理页面状态的信息(例如,空闲空间指针、页面校验和等)。在我们的示例中,它占用固定空间。
- 目录(Slot Directory):对于定长记录,可能是一个简单的槽目录;对于变长记录,则是更复杂的目录结构。每个目录项对应一条记录,指向其在页面内的位置。

所有这些组成部分(页头、目录、实际记录数据)都必须容纳在页面内。因此,页面的有效容量就是能“堆叠”在页面上的记录数量。


容量计算示例
让我们通过一个具体的例子来计算页面容量。假设我们有一个 1KB(1024字节) 的页面,并且定义了如下结构的记录:
CREATE TABLE Example (
W CHAR(5),
X VARCHAR(20),
Y INTEGER,
Z REAL
);
已知条件如下:
- 所有记录按 4字节边界 对齐。
W字段(CHAR(5))实际存储可能需要填充到满足对齐要求。X字段(VARCHAR(20))最大长度为20字节,但平均长度为16字节。为简化计算,我们假设所有X字段都恰好为16字节,且不需要为对齐而填充。- 页面有 32字节 的页头信息。
- 初始时页面为空,我们将不断插入元组直到页面填满。

以下是计算页面能容纳多少条记录的过程:


-
计算单条记录的大小:
W (CHAR(5)): 5字节,但为了确保下一个字段Z从4字节边界开始,需要填充到8字节。X (VARCHAR(20)): 按平均16字节计算。Y (INTEGER): 4字节。Z (REAL): 4字节。- 单条记录总大小:
8 + 16 + 4 + 4 = 32字节。
-
计算页面可用于存储记录和目录的总空间:
- 页面总大小:1024字节。
- 减去页头:
1024 - 32 = 992字节。
-
建立空间方程:
设n为页面能容纳的记录数。
每条记录占用32字节数据空间,同时需要在目录中有一个对应的条目(假设每个目录项占2字节)。
因此总占用空间为:n * 32 + n * 2 = n * 34字节。
这个总占用空间必须小于等于可用的992字节:n * 34 <= 992

- 求解记录数
n:n <= 992 / 34 ≈ 29.17- 因此,最大整数
n = 29。
结论:这个1KB的页面最多可以容纳 29 条给定结构的记录。
页面布局图示
最后,我们通过一个简单的图示来回顾页面的典型布局,这有助于直观理解上述计算:
+-----------------------+
| Page Header | (32 bytes)
+-----------------------+
| Slot Directory | (n * 2 bytes)
+-----------------------+
| Free Space |
+-----------------------+
| Stored Record 1 |
+-----------------------+
| Stored Record 2 |
+-----------------------+
| ... |
+-----------------------+
| Stored Record n |
+-----------------------+
(图示:页面从上到下依次为页头、目录、空闲空间和存储的记录)




本节课中我们一起学习了Postgres中页面、记录、元组等核心概念的准确定义,并深入分析了页面的内部结构。通过一个具体的计算示例,我们掌握了如何根据记录大小、对齐方式和页面管理开销来计算一个页面的数据容量。理解这些基础知识对于后续学习数据存储、索引和查询优化至关重要。
011:记录与元组

在本节课中,我们将继续探讨数据库存储的核心概念,重点区分“记录”与“元组”这两个关键术语,并学习如何将磁盘上的字节序列解释为有意义的数据库数据。
上一节我们介绍了页面(Page)的基本结构和记录ID(Record ID)的构成。本节中,我们来看看存储在页面中的具体数据——记录(Record),以及如何将其转换为程序可操作的元组(Tuple)。

记录 vs. 元组
首先,我们需要明确两个核心概念的区别:
- 记录(Record):存储在磁盘页面中的原始字节序列。它本身只是一串
0和1,没有内在含义。 - 元组(Tuple):在内存中,根据关系模式(Schema) 对记录字节序列进行解释后得到的数据结构。它包含了具体的属性值(如学生ID、姓名等)。
简单来说,记录是物理存储,元组是逻辑视图。要从记录得到元组,必须依据关系模式进行“解码”。
记录操作:通过ID访问
对记录最常见的操作是通过其 记录ID(Record ID) 进行访问。记录ID通常由 页面ID(Page ID) 和 槽位ID(Slot ID) 组成。
访问一个记录的流程如下:
- 根据记录ID中的 页面ID,计算该页面在文件中的偏移量:
offset = page_id * PAGE_SIZE。 - 通过系统调用(如
lseek和read)将整个页面数据读入内存的缓冲区。 - 根据记录ID中的 槽位ID,在页面的目录(Directory) 中找到对应条目,从而定位记录在该页面内的具体起始位置和长度。
- 调用类似
get_record()的函数,获取该位置的字节序列(即记录)。
需要注意的是,数据库管理系统(DBMS)总是以页面为单位进行磁盘I/O,无法直接读取单个记录。
从记录到元组
获取到记录的字节序列后,DBMS需要根据已知的关系模式将其转换为元组。

以下是转换过程的简要说明:
- 模式定义了每个属性的数据类型、长度和顺序。
- 例如,对于一个
Students(id INTEGER, name CHAR(20))的关系,DBMS知道记录的前4个字节是整数类型的ID,后续20个字节是定长字符串类型的姓名。 - 通过这种解释,原始的字节流就被赋予了意义,变成了一个包含具体属性值的元组数据结构。
一旦获得了元组,DBMS就可以在其上执行各种关系操作,如选择、投影和连接。


总结
本节课中我们一起学习了:
- 记录(Record) 是存储在磁盘上的原始字节序列。
- 元组(Tuple) 是依据关系模式对记录解释后得到的内存数据结构。
- 通过记录ID访问记录需要先读取整个页面,再利用页面内的目录定位。
- 核心的转换过程是:磁盘页面 -> 记录(字节流)->(根据模式解释)-> 元组(数据结构)。

理解记录与元组的区别及其转换过程,是理解数据库存储层如何工作的基础。下一节,我们将开始探讨如何在这样的存储模型上实现具体的关系代数操作。
012:Postgres中的元组结构
在本节课中,我们将学习PostgreSQL数据库内部如何表示和存储数据的基本单元——元组。我们将了解元组头的结构、其包含的元数据,以及PostgreSQL如何利用这些信息来管理事务、空值和版本控制。

上一节我们介绍了Postgres中“堆”文件的概念。本节中,我们来看看构成这些堆文件的基本元素——堆元组的具体结构。

PostgreSQL提供了一些函数来创建和销毁元组,类似于我们之前讨论的make tuple操作。
PostgreSQL将存储元组的页面序列称为“堆”,其中的元组则被称为“堆元组”。一个堆元组包含以下信息:

当一个对象被加载到Postgres中并开始使用时,元组会包含一些伪属性。这些属性作为元组头数据的一部分存储。例如,OID存储在其他地方,而Xmin和Xmax则直接存储在元组头中。
元组在特定事务的上下文中创建。一个事务由一系列SQL语句(在Postgres中称为“命令”)组成。因此,元组由特定事务中的特定命令创建。
Postgres保留Xmin和Xmax,但只保留Cmin或Cmax中的一个。一旦元组被删除,Cmin就变得无关紧要,因此通常存储的是Cmax。
当更新一个元组时,Postgres不会就地修改它,而是创建一个包含更新内容的新元组。旧元组会包含一个指向新版本元组的引用,从而将两个版本链接起来。
元组头需要记录该元组有多少个属性。此外,它还包含一系列标志位。其中一个重要标志是hasnulls。
hasnulls标志并不直接指明哪些属性是空值,它只是表明此元组包含空值,因此需要以不同的方式处理。真正指明空值位置的是一个位图。
位图是一个比特序列。如果第一个比特被设为1,则表示第一个属性是空值,依此类推。
如果hasnulls为假,则意味着元组没有任何空值,因此不需要位图。元组的结构会根据此标志而改变。
由于位图的长度未知,并且位图的大小会随属性数量而变化,我们需要知道数据值实际从哪里开始。数据值从位图流的末尾开始,而hoff字段(即头部偏移量)告诉了我们这个位置。
以上是Postgres中元组结构的基础。


Datum是Postgres中表示值的最低层级类型。考虑到一个Datum可以是字符串、整数或浮点数等,我们无法使用一个通用的数据类型。因此,Postgres使用指针来引用这些值,指针在一定程度上可以是通用的。


还记得我们之前提到的字段描述符吗?这里我们有“元组描述符”。
元组描述符提供了元组内容的概览,包含对目录数据的引用,以及一些从目录中提取并存储在此处的信息。显然,我们需要记录属性的数量等信息,这些信息与堆元组一起存储。


以下是堆元组的结构示意图,它借鉴了Postgres的风格:

一个堆元组包含以下信息:
- 以长度开头,即数据的总大小。
- 一个“自引用项目指针”,其具体用途尚不明确。
- 指示元组来自哪个表的OID。
- 堆元组头,实际上是一个指向缓冲区的指针。

所有元组最终都会存储在缓冲区池的某个缓冲区中。

因此,堆元组头与其余数据一起存在于缓冲区中。
元组头中包含我们在前面幻灯片中看到的信息:隐藏在堆元组字段中的CTID、Xmin和Xmax,以及Cmax字段。可以看到,结构体中嵌套着结构体,有些东西实际上是指向其他结构的指针,这可能有点令人困惑。
这里我们看到了一个变长数组的例子,我们无法确切知道它的长度。我们之前看过的元组可能只有三四个属性,因此位图可能只是一个8位的字节。但理论上,一个元组可以有数百个属性,那么指示哪些属性为空的位图流就可能长达数百位。
Postgres处理这个问题的方法是:它会分配一个堆元组头数据结构,并分配比头部实际所需多十倍的空间。末尾的这些额外空间将构成位图的一部分,用于指示空值的位置。


我们看到的堆元组头字段包括Xmin、Xmax,以及第三个字段。这个第三个字段与我们之前看到的略有不同,它要么是Cmin值,要么是Cmax值,具体取决于元组是否被删除。它还可以告诉我们一些关于相对于“清理”操作的状态信息,即这个元组是否已被删除。无论如何,堆元组字段中的这第三个值只是一个单一的值,我们可以用多种不同的方式来解释它。

以上是对Postgres元组结构的一个极其简要的概述。如果你想了解令人抓狂的细节,可以去查阅源代码,同时也可以看看相关文档。



本节课中,我们一起学习了PostgreSQL中堆元组的核心结构。我们了解了元组头如何存储事务ID(Xmin, Xmax)、命令ID以及空值位图等关键元数据。这些设计使得Postgres能够高效地支持多版本并发控制(MVCC)、空值处理以及元组的版本更新。理解这些底层结构是深入掌握Postgres存储和事务机制的基础。
013:作业1详解与常见陷阱
在本节课中,我们将详细讲解作业1(Assignment 1)的要求,并重点分析一个在实现自定义数据类型时常见的、可能导致服务器崩溃的陷阱。理解这些概念对于成功完成作业至关重要。
作业概览与时间安排

上一节我们介绍了课程的基本结构,本节中我们来看看当前的学习任务安排。
- 第二份测验已于上周四发布。
- 作业1(Assignment 1)将于下周结束前截止。
- 下周是期中假期,没有课程安排。为了给大家更多时间完成作业,将安排额外的答疑时间。具体时间会发布在课程网站上。
作业1核心任务:实现Email地址数据类型
作业的核心目标是在Postgres中创建一个名为emailaddr的新数据类型。这涉及到在源代码文件中使用CREATE TYPE命令,并为其实现必要的函数。
以下是实现此数据类型需要完成的关键步骤:
- 定义类型:使用
CREATE TYPE命令,通常只需要三个参数即可,无需过度关注存储类型等复杂选项。 - 实现输入/输出函数:
email_in:接收一个字符串,将其转换为内部存储格式。此函数必须验证输入是否为有效的电子邮件地址。email_out:将内部存储格式转换回可打印的规范形式(例如,可以选择统一转换为小写后存储)。
- 实现比较操作符:编写一系列函数来比较两个
emailaddr值(如小于、等于、大于),这是使该类型可被索引的基础。
为了帮助你理解,可以参考PostgreSQL官方文档中关于“用户定义数据类型”的部分以及CREATE TYPE命令的说明。此外,实践练习(Prac Exercise)中安装现有复数数据类型的例子也可作为模板,但需注意其存储结构是固定长度(8字节),而电子邮件地址是可变长度的,这会影响存储方式。

关键陷阱:内存指针与持久化存储
在实现email_in函数时,一个极其常见且严重的错误与内存中数据结构的持久化有关。许多学生每年都会掉入这个陷阱。
错误做法示例:
假设你在内存中定义了一个结构体来表示电子邮件地址,其中包含两个指向字符串(如本地部分和域名)的指针。
typedef struct {
char *local_part; // 指向动态分配内存的指针
char *domain; // 指向动态分配内存的指针
} EmailAddr;
当你使用palloc(Postgres的内存分配器)创建这样一个对象并让email_in函数返回它时,Postgres会尝试将这个对象写入磁盘。
问题在于:写入磁盘的不仅是结构体本身,还包括那两个指针的值(即内存地址)。这些内存地址仅在当前服务器进程的上下文中有效。当Postgres服务器重启后再次从磁盘读取这条记录时,结构体里的指针指向的是无效的、随机的内存位置。任何尝试使用这些“垃圾指针”的操作都极有可能导致Postgres服务器崩溃。
正确做法:
你必须构建一个自包含的(self-contained) 对象,使其内部直接存储电子邮件地址的各个组成部分(例如,使用字符数组而不是指针),而不仅仅是指向它们的引用。
typedef struct {
char local_part[MAX_LEN];
char domain[MAX_LEN];
} EmailAddr;
这样,当这个对象被写入磁盘再读回时,所有必要的数据都完整地包含在对象内部,可以安全地重新使用。
总结与求助指南
本节课中我们一起学习了作业1的具体要求,并深入剖析了实现自定义Postgres数据类型时一个关键的内存管理陷阱:确保存储到磁盘的数据结构是自包含的,而不是包含指向临时内存的指针。

如果你在完成作业时遇到问题,请注意:
- 在论坛提问时,请具体描述你遇到的问题和已尝试的步骤,而不是仅仅说“我的服务器崩溃了”。
- 如果需要分享代码,请通过电子邮件发送,不要直接贴在公开论坛上。
- 充分利用安排的额外答疑时间。

避免上述陷阱是成功完成本作业的重要一步。祝你好运!
014:投影操作

在本节课中,我们将要学习数据库查询处理中的最后一个基础操作:投影。我们将探讨其基本概念,并重点分析当使用 DISTINCT 关键字时需要消除重复元组所带来的挑战。我们将介绍两种解决重复问题的方法:基于排序的投影和基于哈希的投影,并比较它们的实现原理与成本。
投影操作的基本概念
上一节我们介绍了排序和扫描操作,本节中我们来看看投影操作。投影操作的基本任务是从关系中选取用户指定的属性列,并生成一个新的结果集。
你可能会认为这很简单:只需遍历原始文件,挑出用户请求的字段,写入另一个文件即可。这个新文件显然会比原始文件小。这确实是投影的基本形式。

但是,如果查询中加入了 DISTINCT 关键字,情况就发生了变化。因为此时可能需要消除结果中的重复元组。
例如,执行一个包含 DISTINCT 的投影操作,可能会得到 (John, 32)、(Jane, 39),然后再次得到 (John, 32)。关键问题在于:如何知道已经见过 (John, 32),从而避免再次写入这个重复的元组?这就是投影操作在使用 DISTINCT 时的核心挑战。

消除重复的两种策略
以下是解决投影中重复元组问题的两种主要方法:
- 基于排序的投影:先进行包含所有重复项的投影,然后对结果文件排序。由于排序后重复项会相邻出现,扫描时很容易识别并忽略它们。
- 基于哈希的投影:利用哈希函数,将可能重复的元组映射到相同的位置,从而在生成过程中直接检测并消除重复。
基于排序的投影

基于排序的投影方法非常直观。其核心思想是:先进行包含所有重复项的投影,然后对结果文件排序。由于排序后重复项会相邻出现,扫描时很容易识别并忽略它们。

具体算法步骤如下:
- 执行投影,生成包含所有元组(含重复项)的临时结果文件。
- 对这个临时结果文件进行排序,排序键是所有被选中的属性。
- 扫描排序后的临时文件。
- 如果当前元组与前一个写入的元组相同,则它是重复项,将其忽略。
- 如果不同,则它是一个新元组,将其写入最终结果,并记住这个刚写入的元组。
- 继续处理下一个元组。
这种方法虽然直接,但需要对整个临时结果文件进行排序操作。
成本分析
现在我们来分析一下基于排序的投影操作的成本。其成本主要来自以下几个步骤:

- 扫描原始关系:需要读取原始关系的所有页面。成本为 b_R(原始关系的页数)。
- 写入临时结果:将投影后的元组写入临时文件。由于投影后元组通常更小,临时关系的页数 b_T 通常会远小于 b_R。成本为 b_T。
- 排序临时关系:对临时关系进行排序。这里使用的排序成本公式与之前相同,其中 b_0 是临时关系的大小 b_T。成本涉及对 b_T 的多次读写,包括初始排序趟和后续的归并趟。
- 扫描排序后的临时关系:为了消除重复,需要再次扫描排序后的临时文件。成本为 b_T。
- 写入最终结果:将去重后的最终结果写入磁盘。成本为最终结果的大小。
将所有成本相加:读取原始关系 + 写入临时关系 + 排序临时关系 + 扫描排序后的临时关系 + 写入最终结果。总成本显然会受到可用缓冲区数量 n 的影响。缓冲区越多,排序阶段的成本(尤其是读写次数)可能越低。
基于哈希的投影
基于哈希的投影方法略有不同。在理想情况下,它可能只需要两趟处理,而基于排序的方法可能需要三趟、四趟或更多,这取决于缓冲区数量。
以下是哈希投影的工作原理:
第一趟(分区趟):
- 我们一次读入一页原始数据。
- 对于每个元组,先进行投影操作,剔除不需要的属性。
- 然后对剩余属性(或整个投影后的元组)应用一个哈希函数 h1。
- 根据哈希值,将元组放入对应的输出缓冲区。
- 当一个输出缓冲区填满时,就将其写入磁盘。最终,我们得到一系列哈希分区文件,每个分区包含哈希到相同值的元组。
- 一个重要的特性是:任何两个重复的元组,必定会被哈希到同一个分区中。
第二趟(去重趟):
- 现在我们逐个处理这些分区。
- 将一个分区的数据读入内存。
- 对于该分区内的每个元组,使用另一个哈希函数 h2(或在内存中使用不同的数据结构,如另一个哈希表或直接比较)进行处理。
- 我们将其放入内存中的输出缓冲区。在放入之前,先检查该输出缓冲区中是否已存在具有相同值的元组。
- 因为重复元组在第一趟保证进入了同一分区,在第二趟也保证会进入相同的输出缓冲区进行检查。
- 如果发现重复,则忽略该元组;否则,将其加入输出缓冲区。
- 当输出缓冲区填满时,将其写入最终结果文件。最终从这些缓冲区写出的数据保证不包含任何重复项。
算法与条件
以下是算法的简要描述:
- 第一趟:决定元组所属的分区,将元组写入相应的分区文件,继续直到处理完所有输入。
- 第二趟:对每个分区,在内存中检查并消除重复项,然后写入最终结果。
这种方法在最优条件下效率很高:
- 哈希函数能将数据相对均匀地分布到各个分区。
- 拥有足够多的缓冲区。具体来说,缓冲区的数量 n 最好大约等于原始文件页数 b_R 的平方根(即 n ≈ sqrt(b_R))。
- 如果满足这些条件,并且在第二趟有足够的内存来容纳一个分区的所有唯一元组(或使用哈希表高效查重),那么就可以在生成过程中有效消除重复,避免分区文件溢出到磁盘带来的额外开销。
总结

本节课中我们一起学习了数据库查询处理中的投影操作。我们了解到,简单的投影只需选取属性,但加入 DISTINCT 后则需要消除重复元组。我们深入探讨了两种消除重复的策略:
- 基于排序的投影:通过排序使重复项相邻,然后扫描去除。其成本包括扫描、写入临时文件、排序以及再次扫描。
- 基于哈希的投影:利用哈希函数将可能重复的元组分组,分两趟处理,在第二趟于内存中直接去重。在哈希函数均匀且缓冲区充足(约 sqrt(b_R))的理想情况下,此法可能更高效。

理解这些基础操作的实现原理与成本,是构建高效数据库查询执行引擎的关键。
015:文件搜索与优化


在本节课中,我们将学习如何在已排序的文件结构中高效地搜索特定数据。我们将探讨标准的二分查找方法,并分析其成本。随后,我们将讨论如何通过存储额外的元数据(如最小值和最大值)来优化搜索过程,以减少不必要的磁盘读取操作。
上一节我们介绍了文件扫描和选择操作的基本概念。本节中,我们来看看如何在已排序的文件中执行具体的搜索。
二分查找示例



标准的二分查找算法使用低(low)和高(high)两个边界索引来确定搜索范围。在文件搜索的上下文中,这些边界对应的是我们感兴趣的页面索引。

以下是搜索过程的步骤:
- 初始化
low = 0,high = N-1(N为总页数)。 - 计算中点
mid = (low + high) / 2。 - 读取第
mid页。 - 扫描该页中的所有元组,寻找目标键值。
- 如果找到,则搜索成功。
- 如果未找到,则根据目标键值与该页中存储的键值范围(最小值和最大值)的比较结果,更新
low或high边界,将搜索范围缩小一半。 - 重复步骤2-6,直到找到目标或确定其不存在。
让我们通过几个具体例子来理解这个过程。
以下是几个搜索案例的分析:

- 搜索 K=24:从中间页(第2页)开始,扫描后直接找到目标。成本:1次页面读取。
- 搜索 K=3:先读取中间页(第2页),未找到。由于3小于该页的最小值,搜索范围缩小至前两页(第0、1页)。接下来读取第0页,扫描后找到目标。成本:3次页面读取(第2页、第0页及其可能的溢出页)。
- 搜索 K=14:先读取中间页(第2页),未找到且14小于其最小值。搜索范围缩小至前两页。读取第0页,发现14大于其最大值。因此,唯一可能的是第1页。读取第1页及其溢出链进行扫描,最终未找到目标。成本:多次页面读取。
- 搜索最大K值:由于文件按属性K排序,最大值必然在最后一页。可以直接读取最后一页进行查找。成本:1次页面读取。
搜索优化策略
在上述搜索K=14的例子中,为了判断目标值是否可能存在于某个桶(页面链)中,我们需要扫描整个溢出链来获取该链的最小值和最大值。这增加了额外的I/O开销。
我们可以考虑一种优化方法:在每个桶的主数据页(即链表的第一个页面)中,额外存储该桶所有数据(包括溢出页)的最小键值和最大键值。

优化后的搜索逻辑如下:


- 读取目标桶的主数据页。
- 检查目标键值是否介于该页存储的
min和max值之间。- 如果不在这个范围内,则可以立即排除整个桶,无需读取任何溢出页。
- 如果在这个范围内,则继续扫描该桶的主数据页和所有必要的溢出页以寻找确切匹配。

例如,在搜索K=14时:
- 读取第1页的主数据页,发现其存储的
min=10,max=19,14在此范围内。 - 因此,我们只需要扫描第1页及其溢出链即可,避免了读取第0页溢出链的不必要操作。
这种方法显著减少了为确定搜索方向而进行的溢出页读取次数,尤其是在目标值不在某个桶的范围内时。然而,我们仍然需要读取每个候选桶的主数据页来获取这些元数据。

本节课中我们一起学习了在排序文件上进行二分查找的基本方法及其I/O成本。我们了解到,通过在每个数据页头部维护其对应整个数据链(包括溢出页)的最小值和最大值,可以有效地在搜索初期排除不可能的页面,从而优化搜索性能,减少磁盘访问次数。这是一种典型的以空间(存储额外元数据)换取时间(减少I/O操作)的优化策略。
016:动态哈希

在本节课中,我们将学习如何调试Postgres服务器中的代码,并深入探讨动态哈希技术,特别是线性哈希,以解决静态哈希文件在数据增长时性能下降的问题。
调试Postgres服务器代码
上一节我们介绍了作业1的相关内容。本节中我们来看看如何调试在Postgres服务器中运行的C代码。当服务器崩溃时,直接向屏幕输出信息是无效的,因此需要将调试信息写入日志。
以下是两种可用于在服务器中报告信息的关键函数:
ereport:此函数用于向客户端(如psql控制台)报告错误信息。例如,当尝试插入一个已存在的主键值时,客户端会收到错误消息。elog:此函数用于将信息写入服务器日志文件。日志文件在启动postgres服务器时配置,是记录调试信息的主要位置。


这两个函数都接受一个表示错误严重程度的参数,范围可以从简单的调试信息到导致服务器崩溃的致命错误。它们的语法较为特殊,需要查阅相关手册来了解具体用法。


当服务器崩溃时,应立即使用这些调试辅助函数来收集信息。
哈希文件中的删除操作
现在,让我们继续讨论哈希文件的操作。在哈希文件中执行删除操作时,流程如下:


- 使用哈希函数定位到相应的数据页。
- 在该页(及其溢出链)中搜索匹配的元组。
- 找到匹配的元组后,通过设置其
xmax值将其标记为已删除。 - 如果对页面进行了任何修改,必须将其写回磁盘。

对于非唯一键,需要扫描整个溢出链,并删除所有匹配的元组。对于唯一键,在找到并删除目标元组后即可返回。
静态哈希的局限性
上述操作在数据量可控时是有效的。然而,正如之前示例所示,如果持续向一个固定大小的哈希文件中插入数据,溢出链会变得越来越长。虽然哈希函数将搜索范围缩小到总数据的一部分(例如四分之一),但当数据总量达到10万时,四分之一仍有2.5万元组,搜索效率依然很低。
一个自然的想法是扩大哈希文件(增加桶的数量)。例如,如果有1万个桶来存放1万个元组,在理想均匀分布下,每个页只存放一个元组,这会造成空间浪费。但更关键的问题是:如果我们使用简单的取模函数(如 hash_value % B,其中B是桶的数量),并且在文件创建后增加了B的值,那么哈希函数就完全改变了。虽然部分元组的新旧哈希值可能相同,但许多元组的哈希值会发生变化,导致我们无法再找到它们。
动态哈希解决方案
因此,我们需要能够动态调整大小的哈希方法。内存哈希中解决冲突的方法(如二次哈希、线性探测)在这里不适用。以下是三种适用于文件系统的动态哈希技术:
- 可扩展哈希:使用一个目录。目录大小与哈希值使用的位数相关。当需要增加文件大小时,增加哈希位数,目录大小随之翻倍。目录中存储的是指向数据页的指针(页ID)。
- 动态哈希:同样使用目录结构,原理与可扩展哈希类似。
- 线性哈希:不需要目录。它可以一次只增加一个页面来扩展文件大小。虽然它可能仍有溢出链,但随着文件扩展,线性哈希的机制会逐渐减少溢出链的长度。
这些方法(尤其是可扩展哈希和动态哈希)通过“分裂”已满的页来避免溢出链。当目录翻倍时,可能会出现多个目录项指向同一个页面的情况。线性哈希因其良好的扩展性和相对简单的实现,成为了一个非常实用的基于文件的哈希方案。
哈希值的处理
在Postgres的上下文中,哈希函数通常生成一个32位的值。在实际应用中,几乎不需要使用全部32位来寻址,因为那意味着需要2^32个页面,这是一个极其庞大的数量。
通常的做法是,我们只取这个哈希值的低k位来定位页面,其中k根据文件当前的大小动态决定。


本节课中我们一起学习了如何调试Postgres服务器进程,分析了静态哈希文件在数据增长时面临的挑战,并介绍了可扩展哈希、动态哈希和线性哈希这三种动态哈希技术。我们重点了解了线性哈希无需目录、可渐进扩展并能管理溢出链的特性,这使其成为解决数据库文件中哈希索引动态扩展问题的有效方案。
017:选择操作的实现策略
在本节课中,我们将学习如何高效地实现数据库中的选择操作。我们将回顾不同的文件结构(如堆文件、排序文件和哈希文件)如何影响查询性能,并探讨如何利用索引等额外信息来加速选择操作。这对于理解数据库查询优化的核心至关重要。

上一节我们介绍了不同的文件结构和基本的关系操作。本节中我们来看看如何具体实现选择操作,特别是带有WHERE子句的查询。
课程回顾与作业提醒
第一份作业已经结束。第二份作业将要求我们在Postgres之外,使用纯C语言来实现查询求值的一些方面,例如连接操作。这会是更具挑战性的实践。
另外,请勿忘记完成在线测验,它将于明天截止。
测验题简要解析
以下是关于嵌套循环连接成本计算和页面存储计算的测验题解析:
- 第一题:计算了嵌套循环连接在最小缓冲区配置下的成本。需要为两个关系各分配一个输入缓冲区,以及一个输出缓冲区。其总成本是关系R和关系S的页面数之和,即
Cost = Pages(R) + Pages(S)。 - 第二题:探讨了当有足够缓冲区能容纳整个较小关系时的最佳情况。此时,连接成本同样为两个关系页面数之和,但避免了重复读取。
- 第三题:计算了在页面头部使用位图数组(而非Postgres的每元组头部信息)来标记元组存在性时,一个页面能存储的最大元组数
C。需要求解满足ceil(C/8) + C * tuple_size <= Page_Size的最大整数C。

数据库领域人物:Laura Haas
这位是著名的数据库科学家Laura Haas。她长期在IBM阿尔马登实验室工作,现在任职于马萨诸塞大学。她因在模式集成方面的开创性工作而闻名,即如何将来自不同数据库、具有不同结构模式的数据整合,使其能像一个统一的大型数据库一样工作。她的研究成果获得了ACM颁发的“时间检验奖”等奖项。
本周内容概览
截至目前,我们已经学习了以下内容:
- 如何表示磁盘页面中的元组。
- 如何管理内存中的页面集合(缓冲区池)。
- 如何管理页面内的元组。

我们正在研究如何实现各种访问方法。目前,我们正在讨论如何扫描关系和表,特别是在执行带有WHERE子句的SELECT语句时,如何找到满足条件的元组。
接下来,我们将讨论连接等关系操作符的实现。访问方法更多关乎底层文件结构,而关系操作符则是在此之上如何实现查询逻辑。
我们已经看过的文件结构包括:
- 堆文件:元组简单地填入页面。
- 排序文件:如果精心组织,我们可以确保元组按顺序存入页面,这有助于通过二分查找更快地定位数据。
- 哈希文件:我们可以使用哈希来确定元组属于哪个页面,并在查询时利用此信息快速定位到特定页面,但这通常只对特定类型的查询有效。
我们也初步了解了各种关系代数操作符的实现,例如扫描、排序、投影和选择。

选择操作的重要性与优化策略
选择操作是极其常见的操作。在PostgreSQL中,带有WHERE子句的SELECT语句被频繁使用。因此,高效地实现它至关重要。
用户会提出各种不同类型的查询,我们可以将其分类为:
- 点查询
- 范围查询
- 部分匹配检索查询

对于每一种查询类型,不同的文件结构都有其对应的优化策略。因此,在一定程度上,你需要了解数据库将被如何使用。如果你有权选择文件结构,可以根据预期的查询负载来选择最合适的结构。
总结

本节课中,我们一起学习了选择操作在数据库中的核心地位。我们回顾了堆文件、排序文件和哈希文件如何支持不同类型的查询,并强调了了解查询模式以选择最佳底层存储结构的重要性。我们还简要解析了关于连接成本和页面存储的测验题,并认识了数据库领域的杰出研究者Laura Haas。下一节,我们将深入探讨如何具体实现高效的连接算法。
018:多维查询处理

在本节课中,我们将要学习如何处理涉及多个属性的数据库查询,即多维查询。我们将探讨其定义、挑战以及可能的处理策略。
多维查询的定义
上一节我们介绍了基于单一属性的查询处理。本节中我们来看看当查询条件涉及多个属性时的情况,这被称为多维查询。

以下是一些多维查询的例子:
- 部分匹配检索:查询条件包含多个属性,且均为等值比较。例如,查找所有男性经理。
- 空间查询:查询条件包含多个属性,且每个属性都涉及一个取值范围。例如,查找年龄在20到30岁之间且工资在5万到7万之间的所有员工。
从概念上讲,可以将一个表中的所有元组视为多维空间中的点。一个多维查询的目标就是选取该空间中特定区域内的所有点。



基础处理方法


处理多维查询最直接的方法是扫描整个堆文件。

以下是具体步骤:
- 读取堆文件中的每一个数据页。
- 检查页中的每一个元组。
- 判断该元组是否满足查询中的所有条件。
- 如果满足,则将其加入结果集。


其成本公式为:
Cost = b,其中 b 是堆文件中的数据页总数。
如果存在溢出页,则成本为 Cost = b + sum(overflow_pages)。
这是一种最后的手段,通常效率最低。

使用多个索引的策略
另一种策略是为查询中涉及的每个属性都建立索引。理论上,可以为所有可能的查询组合都创建索引,但这会带来显著的存储和维护成本。
当面对一个涉及多个属性的查询时,如果这些属性上都有索引,我们可以采用以下两种策略之一:
策略一:使用选择性最强的单个索引
- 选择预计返回结果最少的那个属性上的索引(即选择性最强的索引)。
- 使用该索引找到所有匹配的元组。
- 从磁盘获取这些元组后,再在内存中检查它们是否满足查询的其他条件。

策略二:使用多个索引的交集
- 分别扫描查询中涉及的每个属性的索引。
- 每个索引扫描会收集到一批可能包含匹配元组的数据页ID。
- 对这些数据页ID集合取交集。
- 最终,只有出现在所有集合中的那些数据页,才可能包含完全满足所有查询条件的元组。
- 最后读取这些页并获取元组。
选择哪个属性索引作为“入口”取决于其选择性。例如,在“性别”和“姓名”两个属性上都有索引的查询中,“姓名”通常具有更高的选择性(因为重复值少),因此可能优先使用“姓名”上的索引。

本节课中我们一起学习了多维查询的概念、基础的全表扫描处理方法,以及利用多个索引进行优化的两种策略。理解这些策略有助于在数据库查询优化中做出更明智的选择。
019:多属性哈希与查询处理 🗄️


在本节课中,我们将要学习多属性哈希(Multi-attribute Hashing)的实现原理,以及如何利用它来处理包含多个属性的部分匹配查询。我们将从回顾多属性哈希的基本概念开始,然后深入探讨如何计算复合哈希值,并最终学习如何利用这些哈希值来高效地执行数据库查询。
回顾:多属性哈希

上一节我们介绍了单属性选择操作。本节中,我们来看看如何处理涉及多个属性的选择操作。
多属性哈希的核心思想是:为元组的每个属性分别计算哈希值,得到一个比特串。然后,从一个称为“选择向量”的结构中,提取每个属性哈希值的特定比特位,并将这些比特位组合起来,形成一个复合哈希值。

公式:复合哈希值 H_combined 的构建可以表示为:
H_combined = (bit_from_attr1 << pos1) | (bit_from_attr2 << pos2) | ...
其中,bit_from_attrN 是从第N个属性的哈希值中提取的特定比特,posN 是该比特在复合哈希值中的目标位置。

选择向量决定了如何组合这些比特。它是一个数组,其中每个元素是一个数对 (a, b):
a指定使用哪个属性(例如,属性1、属性2)。b指定从该属性的哈希值中提取第几位(例如,最低有效位是第0位)。
计算复合哈希值
理解了概念后,我们来看看如何具体计算一个元组的复合哈希值。
以下是计算复合哈希值的逻辑步骤:



- 初始化:将复合哈希结果
res初始化为0。 - 计算属性哈希:遍历元组的所有属性,为每个属性计算其独立的哈希值,并存储在一个数组中。
- 组合比特位:根据我们需要的哈希值位数
D(这通常取决于文件大小,例如,如果文件有2^5页,则需要5位地址),进行D次循环:- 从选择向量中获取第
i个数对(attr_idx, bit_pos)。 - 根据
attr_idx找到对应属性的哈希值hash_val。 - 从
hash_val中提取第bit_pos位的值(0或1)。 - 将这个比特值左移
i位(即放入复合哈希值的第i位),然后通过“或”操作设置到res中。
- 从选择向量中获取第
代码:核心计算逻辑的伪代码如下:
int computeCompositeHash(Tuple t, ChoiceVector cv, int D) {
int res = 0;
int attr_hash[NUM_ATTRS];
// 步骤1: 计算每个属性的哈希
for (int a = 0; a < NUM_ATTRS; a++) {
attr_hash[a] = hash(t.attributes[a]);
}
// 步骤2: 根据选择向量组合比特
for (int i = 0; i < D; i++) {
int attr_idx = cv[i].attr_index;
int bit_pos = cv[i].bit_position;
// 提取属性哈希值的特定位
int bit = (attr_hash[attr_idx] >> bit_pos) & 1;
// 将该位设置到复合哈希的对应位置
res |= (bit << i);
}
return res;
}
利用多属性哈希进行查询
现在我们已经知道如何为数据构建哈希,接下来看看如何利用它来回答查询。


我们主要针对部分匹配检索查询,即查询条件是多个属性上等值条件的合取,但可能只指定了其中一部分属性的值。例如,一个查询可能指定了 branch='Brighton' 和 name='Green',但没有指定 account_number 和 balance。

给定一个查询,我们可以为已知属性值计算其哈希位。对于未知的属性,其对应的哈希位我们用一个特殊的符号(例如星号 *)来表示,表示该位可能是0或1。
处理流程:
- 生成查询模式:根据选择向量,为查询中已知的属性值生成确定的比特位,为未知的属性生成
*。 - 确定候选页:这个查询模式定义了一个“可能页”的集合。我们需要检查所有那些在确定位上与查询模式匹配,并且在
*位上可以是任意值的页面。 - 具体来说:如果哈希地址有
D位,其中K位是已知的(0或1),U位是未知的(*),那么我们需要检查的页面数量是2^U。我们需要生成所有U个未知比特位的可能组合(0或1),从而得到2^U个具体的页号,并检查这些页面。
示例:假设我们有一个3位的哈希地址(对应8个页面),查询模式是 1**(即最高位是1,其余两位未知)。那么我们需要检查的页面就是所有最高位为1的页面,即页号4(100)、5(101)、6(110)、7(111)。
总结
本节课中我们一起学习了多属性哈希在数据库查询处理中的应用。我们首先回顾了通过选择向量组合多个属性哈希值来构建复合哈希的方法。接着,我们详细探讨了如何为元组计算这种复合哈希值。最后,我们学习了如何利用这种机制来处理部分匹配查询:通过为已知属性生成确定比特、为未知属性生成通配符来形成查询模式,并据此确定需要访问的所有可能数据页。这种方法能够有效地将查询范围缩小到有限的几个页面,从而提升查询性能。

下周我们将讨论连接操作。
020:多维索引结构
在本节课中,我们将要学习两种用于多维数据(例如地理坐标)的索引结构:Kd树和四叉树。我们将了解它们如何组织数据、如何处理查询以及如何进行数据插入。

课程笔记与幻灯片
上一节我们介绍了多维索引的基本概念,本节中我们来看看课程资料的构成。
课程中讨论的所有内容都旨在通过课程笔记提供。部分内容尚未完成,因此不会公开。幻灯片基本上是课程笔记的摘要。如果查看课程笔记,会发现其中包含许多幻灯片上没有的细节。讲座中的幻灯片是课程笔记的摘要,外加一些练习。
Kd树查询处理
现在,让我们深入了解Kd树是如何处理不同类型查询的。
以下是基于一个示例Kd树的查询分析。该树按属性A1和A2交替分割数据。
查询1:查找点 (M, 1)
- 在根节点,查看属性A1(值为M),决定进入左侧分支。
- 在下一节点,查看属性A2(值为1),决定进入右侧分支。
- 返回查看属性A1,根据其与值J的关系,决定进入左侧分支,最终在特定的数据页中找到点(M,1)。
查询2:查找 A1 = A 的点(A2未知)
- 从根节点开始,已知A1=I,因此进入左侧分支。
- 由于A2的值未知,必须检查当前节点的两个子节点。
- 进入第一个子节点,查看属性A1(值为A),进入左侧分支,到达一个数据页,并在其中找到符合条件的元组。
- 无需检查另一个子节点对应的数据页,因为该页所有元组的A1值都大于或等于D,不符合条件。
- 返回检查另一个子节点,同样查看属性A1(值为A),进入左侧分支,到达另一个数据页,找到另一个符合条件的元组。
查询3:查找 A2 = 1 的点(A1未知)
- 从根节点开始,A1未知,因此必须检查两个子节点。
- 进入左侧子节点,查看属性A2(值为1),因此只需检查其右侧分支。
- 到达一个内部节点,此时A1未知,因此需检查其两个分支,这导向两个不同的数据页,需要读取并扫描以寻找A2=1的元组。
- 返回检查根节点的右侧子节点,查看属性A2(值为1),因此只需检查其左侧分支。
- 到达一个内部节点,A1未知,需检查其两个分支,这又导向两个数据页。
- 最终,需要检查四个数据页来获取结果。

查询4:查找所有点(无查询条件)
对于此类查询,索引结构没有帮助。最优策略是直接顺序扫描整个数据文件。
Kd树插入操作
了解了查询,我们来看看如何向Kd树中插入新数据。
插入操作始于一次搜索。由于插入时所有属性值已知,因此会沿着一条确定的路径到达一个数据页。例如,插入点(B, E)会到达包含该区域的数据页。
如果目标数据页已满,有两种处理方式:
- 不调整树结构,仅添加一个溢出页。
- 调整树结构,在当前分支下增加一个新的节点(可能再次基于属性A2进行分割)。

Kd树不一定是平衡的。分割决策的基本目标是尽量使每个数据页包含的元组数量大致相同。例如,在一次分割后,如果两个分区分别有2个和3个元组,可能就认为足够均衡,无需进一步分割。

四叉树
接下来,我们看看另一种多维索引结构——四叉树,它采用不同的分区方法。
四叉树采用规则的空间分区。每次分区时,它将一个区域(或子区域)划分为四个象限:西北(NW)、东北(NE)、东南(SE)、西南(SW)。划分是通过沿两个维度将区域对半分割来实现的。
在构建树时,我们持续细分,直到每个象限包含的元组数量大致可接受。例如,某个象限可能因为元组过多而需要进一步分区,而其他象限则可能因为元组很少而无需再分。
在四叉树中,一个叶节点(即不再进一步分区的象限)直接指向一个数据页。分区会持续进行,直到每个数据页负责的元组数量大致均衡。当然,由于元组在空间中的分布可能不均匀,无法做到完全均衡。

总结

本节课中我们一起学习了两种多维索引结构:Kd树和四叉树。Kd树通过交替使用不同属性对空间进行二分分割,而四叉树则每次将空间规则地划分为四个象限。我们了解了它们处理点查询和部分属性查询的路径选择逻辑,以及插入数据时的基本策略(如使用溢出页或节点分裂)。这两种结构的目标都是通过组织数据来减少查询时需要访问的数据页数量,从而提升查询效率。
021:作业预览与数据结构
在本节课中,我们将预览即将发布的作业2,并详细介绍其背后的核心数据结构设计。我们将了解如何表示关系、元组和页面,为后续实现扫描、排序和连接操作打下基础。
作业2预览
上一节我们讨论了数据库的基本操作,本节中我们来看看即将发布的第二个作业的具体内容。该作业要求你在一个已提供的框架基础上,实现几个核心的数据库操作命令。
作业框架已经提供了页面、元组和关系等抽象数据类型的定义,以及一些基础命令。
以下是作业中你需要实现的核心部分:
select命令:用于数据查询。sort命令:为避免与系统命令冲突,命名为SO,用于数据排序。join命令:为避免与系统命令冲突,命名为J,用于表连接。- 缓冲区池:管理数据页在内存中的缓存。
- 迭代器:实现扫描功能,能够按顺序获取下一个元组。
数据结构设计
理解了作业要求后,我们来看看支撑这些操作的数据是如何组织的。核心在于关系、文件和元组的结构。
文件结构
每个关系在存储时对应两个文件:
- 数据文件:存储实际的元组数据,文件名为
{关系名}.data。 - 元数据文件:存储该关系的描述信息,文件名为
{关系名}.info。
元数据文件中包含以下信息:
- 关系名称
- 属性数量
- 每个元组的大小
- 数据文件中的页面总数
- 数据文件中的元组总数
数据页与元组格式

数据文件的结构被设计得尽可能简单。每个数据页只是一个连续的字节序列,其中顺序存放着多个元组。

所有元组的大小是固定的,这简化了存储和检索。每个元组的结构如下:
- 以一个4字节的整数ID开头。
- 随后是固定数量的属性值。在本作业中,每个属性值是一个5字符的英文单词。
因此,对于一个具有n个属性的关系,其元组结构可以用如下代码描述:
struct Tuple {
int id; // 4字节的整数ID
char attr1[6]; // 第一个属性(5字符+结束符)
char attr2[6]; // 第二个属性
// ... 更多属性
char attrN[6]; // 第N个属性
};
虽然所有元组大小相同,但为了代码通用性,框架中可能使用可变长结构来定义元组头,以适应不同属性数量的关系。

本节课中我们一起学习了作业2的概览和核心数据结构。我们了解到作业需要实现选择、排序和连接操作,并辅以缓冲区池和迭代器。数据以关系和文件的形式组织,其中元组采用固定大小的格式存储,这为后续高效实现各种操作提供了基础。理解这些结构是完成作业的关键第一步。
022:基于签名的索引
在本节课中,我们将要学习基于签名的索引方法。我们将了解如何为元组生成签名,如何利用这些签名来高效地执行查询,以及如何权衡签名大小与查询性能。
上一节我们介绍了签名索引的基本概念,本节中我们来看看具体的实现细节和查询过程。
签名生成与查询
我们为每个元组生成一个对应的签名。每个签名是一个比特串,其中大约一半的比特位被设置为1。执行查询时,我们会扫描签名文件,确定哪些元组是相关的,然后定位到数据文件中对应的页面,只读取这些页面来获取实际数据。

以下是一个签名生成的例子。假设我们有一个包含多个属性的元组。


我们为第一个属性生成一个码字,它是一个小的比特串。例如,账户号“102”通过一个基于属性值的种子随机生成的哈希函数,会得到一个特定的码字。

对于元组中的每个已知属性,我们都生成一个码字。然后,我们将所有这些码字进行按位或(OR)操作,最终得到该元组的完整签名。这个签名中大约有一半的比特位是1。



基于签名的查询
那么,我们如何基于这些签名来回答查询呢?


我们首先为查询生成一个查询描述符。对于部分匹配检索,我们只知道部分属性的值。

我们为查询中每个已知的属性生成码字,然后将它们进行按位或(OR)操作。未知属性的码字被视为全0,不参与计算。这样就得到了该查询的查询描述符。



查询过程如下:我们扫描所有的签名(即元组描述符)。对于一个给定的元组签名,我们将其与查询描述符进行匹配。
匹配的条件是:查询描述符中的所有1比特位,在元组签名中对应的位置也必须是1。这可以表示为:
(query_descriptor & record_signature) == query_descriptor
其中 & 是按位与操作。
如果满足这个条件,该元组就是一个潜在的匹配项。我们从该元组的ID(TID)中提取出它所在的页面ID。



我们收集所有潜在匹配项所在的页面ID,然后只去读取这些页面。在页面内部,我们再逐一检查每个元组,进行精确的属性值匹配,以确认是否为真正的匹配项。

假匹配问题
这种方法的潜在问题是可能出现假匹配。

一个元组可能因为其不同属性的码字恰好设置了查询描述符所需的比特位,从而被误判为匹配,即使它并不包含查询所指定的属性值。

假匹配会导致我们读取不必要的页面,增加I/O开销。


优化签名参数
为了降低假匹配的概率,我们可以采取以下措施:
- 增大签名长度(M):签名比特串越长,可编码的不同状态就越多,不同属性组合导致比特位冲突的概率就越低。
- 为每个属性使用独立的哈希函数:这可以防止不同属性中相同的值(或不同值经哈希后)产生相同的比特模式,从而减少冲突。
- 选择合适的码字重量(K):即每个属性码字中设置为1的比特数。理论表明,当每个码字中大约一半的比特为1时,签名的区分度最好。
当然,增大M会使签名文件变大,增加扫描签名文件时的读取开销。而K太小会导致哈希碰撞过多,K太大则会使签名过早地被填满1,降低区分度。因此,需要在假匹配概率和扫描开销之间取得平衡。


参数计算公式
理论上存在M和K的最优组合。我们可以根据可接受的假匹配概率,使用公式来推导出推荐的M和K值。
例如,如果我们希望假匹配概率低于万分之一(1/10,000),我们可以将这些值代入公式进行计算。
公式通常涉及以下变量:
M: 签名总长度(比特数)K: 每个属性码字中1的比特数n: 元组中属性的数量p: 目标假匹配概率
一个常用的近似公式是:
p ≈ (1/2)^(M/n)
但更精确的公式会考虑K的选择。确定了M和K之后,我们所有的签名都将按照这个配置来生成。

性能成本分析
最后,我们来分析一下使用叠加码进行查询的实际成本。


成本主要来自两部分:
- 扫描签名文件:需要顺序读取整个签名文件,进行快速的比特位比较操作。这部分成本与签名文件大小成正比。
- 读取数据页面:需要读取所有潜在匹配项(包括真匹配和假匹配)所在的数据页面。假匹配越多,这部分I/O成本就越高。
因此,优化的目标就是通过选择合适的M和K,在控制签名文件大小的同时,最小化假匹配的数量,从而使总查询成本最低。



本节课中我们一起学习了基于签名的索引技术。我们掌握了为元组生成签名的方法,理解了如何通过签名快速过滤出潜在匹配的元组,并深入探讨了假匹配现象的成因及其优化策略,即通过调整签名长度(M)和码字重量(K)来平衡查询精度与I/O开销。这种索引方法特别适用于部分匹配查询的场景。
023:连接算法与查询优化
在本节课中,我们将学习数据库查询处理中的连接操作,特别是嵌套循环连接及其变体。我们还将探讨如何通过优化内存使用和中间结果来提升多表连接查询的性能。
课程公告与回顾
上一节我们介绍了嵌套循环连接。本节中,我们来看看在更复杂的查询场景下如何应用和优化它。

首先是一些课程事务通知:
- 每周测验已发布,截止日期为下周四。
- 如有问题,请发送至课程论坛或课程专用邮箱
CS9315,避免发送至讲师个人邮箱,以确保问题能被及时处理。
今日主题:连接与查询评估
今天我们将深入讨论连接操作,并可能开始涉及查询评估,这部分内容将持续到下周。
数据库轶事:Grace数据库机
在进入正题前,分享一个数据库历史轶事。东京大学的团队在80年代建造了一台名为 Grace 的专用数据库机器。它的设计理念是利用并行处理和智能哈希连接算法(我们稍后会讨论)来高速执行关系代数运算。

尽管这是一个有趣的研究构想,并为其设计者赢得了SIGMOD(数据管理国际会议)奖项,但像许多80、90年代的特殊用途机器一样,它最终并未投入实际生产。

回顾:块嵌套循环连接
我们之前讨论了嵌套循环连接及其优化版本——块嵌套循环连接。其核心思想是:
- 将外层关系(如表R)的一个数据块读入内存缓冲区。
- 顺序扫描内层关系(如表S)的每一页。
- 将内存中R块的每个元组与S页中的每个元组进行比较,匹配则形成连接结果。
理想情况是,如果较小的表能完全放入内存,则连接成本最低,仅需对两个表各进行一次扫描。
实际情况受限于可用内存缓冲区数量(B)和关系大小。所需扫描S表的次数取决于R表被分成多少块。执行连接所需的最小缓冲区数量为 3(两个输入缓冲,一个输出缓冲)。
复杂查询示例:三表连接
现在,我们考虑一个更复杂的查询,它涉及三个表:Student(学生)、Enrolment(选课)和 Subject(课程)。查询目标是获取学生姓名和课程全称(而不仅仅是课程代码)。

一种执行策略是分两步进行连接:
- 首先连接
Student和Enrolment表,生成一个中间结果。 - 然后将这个中间结果与
Subject表进行连接,最终获得所需数据。

优化:减少中间结果大小
在处理多表连接时,中间结果的大小对性能至关重要。一个关键的优化点是:在生成中间结果时,只保留后续连接和最终输出所必需的属性。
对于我们的示例查询:
- 从
Student和Enrolment的连接中,我们只需要保留student name和course code。 course code用于下一步与Subject表连接。student name和从Subject获取的course title是最终输出结果。
通过这种“投影下推”的优化,可以显著减少中间结果的数据量,从而降低磁盘I/O和内存消耗,提升查询效率。
假设我们有 B = 202 个内存缓冲区,我们可以基于优化后的中间结果大小,来计算整个查询执行计划的总页面读取成本。
总结
本节课中我们一起学习了:
- 块嵌套循环连接 的工作原理及其性能受内存缓冲区数量影响。
- 处理多表连接查询的一种策略:分步执行。
- 一个重要的查询优化技巧:尽早过滤和投影,只传递必要的属性以减少中间结果规模,从而提升连接操作的整体效率。

理解这些基础算法和优化思想,是掌握数据库查询处理和性能调优的关键。
024:Grace哈希连接算法详解

在本节课中,我们将要学习一种更高效的哈希连接算法——Grace哈希连接。我们将了解其工作原理、成本分析,并通过一个具体例子来加深理解。
上一节我们介绍了简单的哈希连接,本节中我们来看看一种改进的算法。
算法核心思想
Grace哈希连接的核心思想是:一次性为整个关系文件构建哈希分区,然后依次处理这些分区。这听起来可能前期成本更高,但实际上并非如此。

其最佳情况下的成本与关系R的块数BR和关系S的块数BS相关。具体公式为:
最佳成本 ≈ 3 × (BR + BS)
为了实现这个最优成本,我们需要有足够的内存缓冲区,以便能够将外关系(通常是R)的最大分区完全装入内存。由于我们先分区,再逐个读取分区进行处理,因此所需缓冲区数量大约与关系总页数的平方根相当。

如果缓冲区不足,或者哈希函数分布不均匀,我们可能需要多次扫描关系S。但与简单哈希连接不同,在Grace哈希连接中,我们不需要扫描整个S,而只需扫描S的对应分区。
算法执行步骤

以下是Grace哈希连接的两个主要阶段。
第一阶段:分区

我们为两个输入关系R和S分别构建哈希分区。
- 逐页读取输入关系(例如R)到输入缓冲区。
- 使用一个哈希函数
h1,决定每个元组应放入哪个输出缓冲区。每个输出缓冲区代表输入数据的一个分区。 - 将填满的分区写入磁盘上的输出文件。
如果我们有M个缓冲区,最终会得到M-1个分区(其中一个缓冲区用于输入)。 - 对关系S重复完全相同的过程,使用同一个哈希函数
h1。

分区完成后,我们得到R的一系列分区文件,以及S的一系列分区文件。由于使用了相同的哈希函数h1,我们可以确定:R的第i个分区中的元组,只可能与S的第i个分区中的元组产生连接匹配。
关于分区成本的一个细节是:由于分区页可能未被完全填满,分区文件的总页数可能会略多于原始输入文件的页数,但不会多太多。
第二阶段:连接
现在,我们逐个处理对应的分区对(例如R的分区0和S的分区0),以找出连接匹配。
- 将R的一个分区(例如分区i)完全读入内存,并使用另一个哈希函数
h2在内存中为其构建哈希表。- 为什么需要第二个哈希函数? 如果使用相同的
h1,该分区内所有元组可能会被散列到同一个桶中,这无助于减少比较次数。h2的作用类似于简单哈希连接中的哈希函数,用于减少需要检查的元组数量。
- 为什么需要第二个哈希函数? 如果使用相同的
- 逐页读取对应的S分区(分区i)。
- 对于读入的每个S元组,使用相同的哈希函数
h2计算其哈希值,定位到内存哈希表中的相应桶,并扫描该桶以寻找匹配的R元组。 - 如果找到匹配,则将连接结果放入输出缓冲区。当输出缓冲区填满时,将其写入最终结果文件。

重复此过程,直到处理完所有对应的分区对。
成本分析

我们来分析一下Grace哈希连接的总成本。假设分区文件的总页数与原始关系页数大致相同。
- 分区阶段成本:
- 读取关系R并写出其分区:
2 × BR - 读取关系S并写出其分区:
2 × BS
- 读取关系R并写出其分区:
- 连接阶段成本:
- 对于每一对分区,我们需要读入R分区,并扫描对应的S分区。因此,总成本约为读取所有R分区和所有S分区的成本:
BR‘ + BS’(其中BR‘和BS’是分区后的总页数,略大于BR和BS)。
- 对于每一对分区,我们需要读入R分区,并扫描对应的S分区。因此,总成本约为读取所有R分区和所有S分区的成本:
因此,总成本 ≈ 2BR + 2BS + (BR‘ + BS’) ≈ 3 × (BR + BS)(在近似情况下)。这个成本是相当不错的。

示例分析
让我们通过一个具体例子来看看成本可能是多少。

假设我们有42个内存缓冲区(M = 42)。在分区阶段,我们将使用1个缓冲区作为输入,其余41个作为输出缓冲区,因此会创建41个分区。


对于关系R,假设其总元组数均匀分布到41个分区中,那么每个分区大约包含2页数据,每页大约有25个元组。因此,R分区文件的总页数BR‘约为 41分区 × 2页/分区 = 82页。
对于关系S,其元组可能更小、数量更多。经过相同的哈希函数h1分区后,假设每个S分区大约有3页数据。那么,S分区文件的总页数BS‘约为 41分区 × 3页/分区 = 123页。


根据我们的成本公式,总I/O操作次数大约为 2BR + 2BS + 82 + 123。如果原始BR和BS分别接近82和123,那么总成本大约在 3×(82+123)=615 次I/O左右。

本节课中我们一起学习了Grace哈希连接算法。我们了解到,该算法通过先对两个关系进行分区,再对对应分区进行连接的方式,降低了对内存大小的依赖,并能在缓冲区数量约为总页数平方根时获得较好的性能。其核心在于使用同一个哈希函数确保匹配的元组进入相同编号的分区,并在连接阶段使用第二个哈希函数进行高效匹配。
025:查询执行与优化 📊

在本节课中,我们将学习数据库查询的执行与优化过程。上一周我们讨论了期中考试,本周我们将专注于数据库系统的核心操作。
概述
本节课将介绍查询执行与优化的基本概念,并详细讲解一个新的课程项目——多属性线性哈希系统的实现。我们将从项目框架开始,逐步理解如何扩展一个简单的单属性哈希系统,使其支持多属性和线性扩展功能。



项目介绍:多属性线性哈希系统



上一节我们介绍了查询执行的基本概念,本节中我们来看看本课程的新项目。该项目要求你实现一个多属性线性哈希系统。目前提供的代码框架是一个单属性哈希系统,且文件不会扩展。你的任务是填补缺失的代码部分,使其具备多属性和线性扩展的能力。
系统操作关系(表),其结构类似于上周讨论的内容。我们有以下组件:
- 一个信息文件(
.info) - 一个数据文件(
.data) - 由于哈希桶可能被填满,我们还有一个溢出文件(
.ovflow)
项目提供了一个完整的C语言代码框架。你需要编写大约几百行C代码来完成核心功能。
以下是项目框架的基本操作流程:
- 构建项目:在解压项目文件后,运行
make命令进行编译。 - 创建关系(表):使用
create命令初始化一个新表。需要指定属性数量、初始页数以及一个选择向量。- 选择向量:定义了如何从多个属性中提取比特位来共同构成最终的哈希值。其格式为
属性索引, 比特位索引。例如,0,0表示使用第0个属性的最低有效位(比特0)。
- 选择向量:定义了如何从多个属性中提取比特位来共同构成最终的哈希值。其格式为
- 查看关系信息:使用
stats命令可以查看表的详细信息,如属性数量、页数、元组数、文件深度和分裂指针位置。 - 插入数据:使用
insert命令可以从标准输入读取符合表结构的元组并插入数据库。系统会根据哈希值计算目标页。 - 批量插入:为了便于测试,项目可能提供从文件批量插入数据的功能。
选择向量详解
选择向量决定了哈希值的构成方式。例如,对于一个3属性的表,初始有8页(需要3位哈希值),可以指定如下选择向量:
0,0 1,0 2,0 0,1
这表示:
- 哈希值最低位(比特0)来自:属性0的比特0。
- 哈希值下一位(比特1)来自:属性1的比特0。
- 哈希值再下一位(比特2)来自:属性2的比特0。
- 哈希值再下一位(比特3)来自:属性0的比特1。
如果选择向量指定的比特位数少于所需的哈希值位数(例如文件扩展后需要更多位),系统会用默认规则(例如从属性0的最高有效位开始)自动填充剩余的位。
当前框架的限制与你的任务
目前的代码框架存在以下限制,需要你来完善:
- 非多属性:哈希值仅基于单个属性计算。
- 非线性:当桶满时,仅创建溢出页,不会触发文件扩展(即线性哈希的分裂操作)。
- 分裂指针无效:
stats命令显示的分裂指针当前被忽略。
你的实现任务可以按任意顺序进行,例如:
- 先实现正确的多属性哈希函数。
- 再实现线性哈希扩展机制(使用分裂指针,在桶满时分裂文件,而不仅仅是添加溢出页)。


总结


本节课我们一起学习了数据库查询执行与优化的引入,并详细剖析了COMP9315课程的新项目——多属性线性哈希系统的实现。我们了解了项目框架的结构、核心操作命令以及选择向量的工作原理。目前该系统仅是一个基础的单属性静态哈希,你的核心挑战在于为其添加多属性哈希支持与线性扩展能力,使其成为一个真正可扩展的多属性线性哈希数据库组件。
026:查询优化与成本估算



在本节课中,我们将要学习查询优化器如何为查询计划选择具体的物理访问方法,以及如何估算这些操作的成本。理解这些概念对于设计高效的数据库查询至关重要。
课程概述与前期问题
上一节我们介绍了查询计划的基本概念。本节中我们来看看优化器如何选择具体的实现方法。

首先,需要说明课程材料中的一个问题。Makefile文件最初遗漏了一个编译标志 -std=c99,这可能导致在某些环境(特别是名为“Greek”的CSE服务器)下编译失败。该问题已在更新的材料中修复。请注意,由于库的差异,建议不要在“Greek”服务器上完成本课程作业,在其他CSE工作站或Mac系统上均可正常运行。

选择物理访问方法
查询优化器的核心任务之一,是为关系代数操作选择最有效的物理实现方法。选择依据主要是关系上已有的数据结构。
以下是优化器进行选择时遵循的一些基本规则:
- 如果关系在筛选属性上建有索引,则优先使用索引扫描。
- 如果关系在连接属性上已排序,则考虑使用排序归并连接。
- 如果关系在筛选属性上建立了哈希结构,则可以使用哈希搜索。
- 如果以上结构都不存在,则只能回退到线性扫描,这是效率最低的备选方案。
操作流水线化

为了提升效率,数据库系统会采用流水线技术。该技术将一个关系代数操作的输出结果直接放入缓冲区,供下游操作立即消费,从而避免将中间结果写入磁盘带来的高昂I/O开销。
例如,对于一个包含两个条件的查询(如 name='John' AND age>21),如果 name 列有索引,优化器可能会将其拆分为两个顺序操作:
- 使用B树索引快速找出所有
name='John'的元组,并将结果放入缓冲区。 - 下游操作从缓冲区中读取这些元组,并通过线性扫描筛选出满足
age>21的元组。
连接操作的选择策略
对于连接操作,选择策略同样依赖于对关系特性的了解。

以下是针对不同情况的连接方法选择:
- 块嵌套循环连接:如果已知其中一个表能完全放入内存缓冲区,则使用此方法,并扫描另一个表。
- 排序归并连接:如果已知两个表都在连接属性上排序,则此方法非常高效。
- 哈希连接:对于等值连接,这是一个常用的高效方法。
- 嵌套循环连接:当没有其他优化结构可用时,这是最后的备选方案,相当于连接操作中的“线性扫描”。
输出结果大小估算

估算一个操作产生的输出结果大小至关重要,因为它直接影响下游操作的成本。我们无法通过实际执行来获取精确大小,因此需要进行估算。
估算所依赖的统计信息包括:
- 关系的元组总数。
- 元组的平均大小,这决定了输出的数据量。
- 属性的不同值数量,这有助于估算选择查询的输出基数。
- 属性的最小值和最大值,这对于估算范围查询(如
>)的结果集大小很有帮助。
投影与选择操作的估算
对于不同类型的操作,估算方法有所不同。

投影操作估算
投影操作的结果大小相对容易估算:
- 元组数量:如果不使用
SELECT DISTINCT,输出元组数量与原关系相同。因为SQL默认采用包语义,允许重复元组。 - 元组大小:输出元组的大小就是所选属性的总大小加上元头开销。
- 去重估算:如果使用了
SELECT DISTINCT,估算输出大小会变得复杂,因为这取决于数据的重复程度。精确估算较为困难。
选择操作估算
对于选择操作,我们通常基于均匀分布的假设进行估算。
- 主键上的等值查询:输出结果最多为一个元组。
- 非主键上的等值查询:输出元组数量 ≈ 总元组数 / 该属性不同值的数量。
- 范围查询:结合最小值、最大值和均匀分布假设,可以估算满足条件的元组比例。例如,查询条件接近最大值时,结果集较小;接近最小值时,结果集较大。

课程总结

本节课中我们一起学习了查询优化中两个关键环节:物理访问方法的选择和操作成本的估算。我们了解到,优化器会根据已有的索引、排序等数据结构,为扫描、连接等操作选择最高效的实现算法。同时,利用系统收集的统计信息,优化器能够估算每个操作步骤的中间结果大小,从而评估整个查询计划的执行成本,并最终选出最优计划。掌握这些原理,有助于我们编写出更能被数据库高效执行的查询语句。
027:查询评估收尾与作业说明
在本节课中,我们将快速回顾查询评估的剩余内容,并重点说明作业2(Simon2)的编译注意事项以及小测验4的题目解析。


作业2(Simon2)编译说明
上一节我们介绍了查询评估的整体流程,本节中我们来看看作业2的具体实现细节。首先,确保你的代码能够顺利编译。
以下是编译作业2时需要注意的几个关键点:


- 字符串复制函数:由于C99标准弃用了
strdup函数,代码中已使用自定义的copyString函数替代。提交作业时,请确保你使用的是copyString而非strdup。 - 随机数生成器:代码中使用了
rand函数生成随机数据。如果在你的机器上编译失败,可以尝试将其改为random函数。此修改仅影响数据生成,对功能无碍。 - 编译测试:请务必在提交前测试代码能否成功编译。已有同学反馈在特定环境下编译成功。
关于作业分组,请确认在系统中能选择“assignment2”作为小组类型。如有问题,请及时反馈。
小测验4题目解析

接下来,我们回顾一下小测验4中的两道题目,帮助大家理解相关概念。
题目一:关系与索引大小计算
本题考察如何计算数据页和索引页的数量。已知条件如下:
- 页面大小:8KB
- 记录总数:10,000条
- 每条记录大小:200字节
- 每个索引条目大小:8字节
- 数据页头大小:192字节
首先,计算每个数据页能存放多少条记录:
可用空间 = 页面大小 - 页头大小 = 8192 - 192 = 8000 字节
每页记录数 = 可用空间 / 记录大小 = 8000 / 200 = 40 条
因此,所需数据页总数为:
数据页总数 = 总记录数 / 每页记录数 = 10000 / 40 = 250 页
接着,计算索引页。由于数据文件已排序,我们只需为每个数据页创建一个索引条目(指向该页的最小键值),而非每条记录一个。因此,索引条目总数为250个。
每个索引页能存放的条目数为:
每页索引条目数 = 页面大小 / 索引条目大小 = 8192 / 8 = 1024 个
所以,所需索引页总数为:
索引页总数 = 索引条目总数 / 每页索引条目数 = 250 / 1024 ≈ 1 页(向上取整)
关键点在于理解“数据已排序”这一条件,它让我们可以采用稀疏索引,从而减少索引大小。
题目二:B+树插入操作
本题考察向B+树插入键值21后的根节点内容。原树结构为:
[13]
/ \
[5,10] [15,20,25]
插入21后,它应进入右叶子节点[15,20,25],导致该节点溢出。根据提示“中间值被提升”,我们需要分裂该节点。
分裂过程如下:
- 原节点键值为
[15,20,25],插入21后变为[15,20,21,25]。 - 找到中间值。对于4个元素的数组,提升的通常是第二个元素(索引1),即
20。 - 将
20提升到父节点(根节点)。 - 分裂后的叶子节点变为
[15]和[21,25]。

因此,新的根节点内容为[13,20]。部分同学可能通过不同的分裂策略得到了[19],这两种答案在本课程语境下均可接受。


本节课中我们一起学习了作业2的编译要点,并通过解析小测验4的题目,巩固了数据存储计算和B+树插入操作的核心概念。理解这些基础原理对后续的数据库实现工作至关重要。
028:事务处理

在本节课中,我们将要学习数据库事务处理的基本概念。事务是数据库应用中确保数据一致性和操作原子性的核心机制。我们将探讨事务的定义、状态以及它在实际应用中的重要性。
上一节我们介绍了数据库引擎内部的各种机制。本节中我们来看看数据库如何被应用程序使用,特别是当多个用户并发操作时,如何保证操作的完整性和一致性。首先,我们需要理解应用程序级别的操作。

在应用程序中,有些操作需要被视为一个单一的工作单元。例如,在两个账户之间转账。

- 转账操作示例:从账户A转出200元到账户B。
- 关键点:这个单一的应用级操作需要由多个数据库操作(如扣款、存款)共同完成。
事务的概念由此产生。事务将对数据库的状态进行更改。我们之前见过的SQL命令,如UPDATE、DELETE,都会改变数据库状态。重要的是,执行这些操作时,会进行大量的约束检查,以确保数据库始终满足我们定义的所有属性(如外键、唯一性约束)。
事务开始时,我们假设数据库处于一个所有约束都得到满足的状态(状态S)。事务开始执行数据库操作。这些操作中的某些步骤可能会暂时使数据库处于不满足所有约束的状态。当然,每个单独的操作本身会进行约束检查,但跨表的关联操作可能存在时间差。
以下是跨表操作可能产生临时不一致的例子:
- 场景:有一个
courses表,其中包含一个记录学生人数的enrolment_count字段。实际的选课记录存储在enrolments表中。 - 目标:确保
enrolments表中对某门课的记录总数,与courses表中该课程的enrolment_count值相等。 - 事务过程:在一个学生退课的事务中,首先从
enrolments表中删除该学生的记录,然后更新courses表中对应课程的enrolment_count(例如减1)。 - 临时不一致:在删除
enrolments记录之后、更新courses表之前,数据库处于临时不一致状态(enrolments记录数已减,但count值未变)。即使用触发器自动执行,这两个操作之间也存在微小的时间差。
完成所有操作后,提交事务,数据库进入一个新的、所有约束再次得到满足的状态(状态S‘)。如果操作因任何原因失败(例如,尝试退课的学生不存在),则需要撤销(回滚)事务中已执行的所有操作,使数据库恢复到事务开始前的状态(状态S)。因此,一个回滚的事务应对数据库状态没有影响。
上一节我们介绍了事务的基本目标和“全有或全无”的特性。本节中我们来看看一个事务在其生命周期中可能经历的具体状态。
事务可以处于以下几种状态:
- 活动状态:事务正在执行读/写操作。这里的“读/写”通常指的是在内存缓冲区中进行操作。数据先从磁盘加载到由多个进程(可能对应多个事务)共享的内存缓冲区中。“写”操作会更改缓冲区内容,最终这些更改会被提交并永久写入磁盘。
- 部分提交状态:事务的所有操作都已完成,但更改仍在内存缓冲区中,尚未写入磁盘。此时执行
COMMIT命令。 - 提交状态:成功将缓冲区的所有更改持久化到磁盘后,事务进入提交状态。此时更改永久生效。
- 失败状态:事务无法继续正常执行(例如,遇到错误或违反约束)。
- 中止状态:事务失败后,数据库系统将回滚该事务已做的所有更改,使数据恢复到事务开始前的状态。
- 终止状态:事务已完成(无论是提交还是中止)并退出系统。

需要特别注意“部分提交”到“提交”的过渡。在COMMIT命令执行后、数据实际写入磁盘前,有一个短暂但确定的时间窗口,此时内存中的数据与磁盘上的数据是不一致的。数据库系统必须确保即使在此阶段发生故障,也能通过恢复机制(如日志)保证事务的原子性和持久性。

本节课中我们一起学习了数据库事务的核心概念。我们了解到事务是将多个数据库操作捆绑为一个逻辑工作单元的方法,它保证了操作的原子性(要么全部完成,要么全部撤销)和一致性(事务将数据库从一个有效状态转换到另一个有效状态)。我们还探讨了事务在其生命周期中可能经历的各种状态,特别是提交过程中数据在内存与磁盘间同步的关键阶段。理解这些是掌握并发控制和故障恢复等高级主题的基础。
029:事务调度与可恢复性

在本节课中,我们将要学习数据库事务调度中的关键概念,特别是如何避免级联回滚,以及不同调度类型(如可恢复调度、严格调度)如何影响数据库的并发性和一致性。我们将通过具体的场景来理解为什么某些调度会导致问题,以及如何通过调度规则来保证数据库处于一致状态。
级联回滚问题
上一节我们介绍了事务并发执行可能带来的问题。本节中我们来看看一个具体问题:级联回滚。

当一个事务读取了另一个未提交事务写入的数据(脏读),而后者随后又中止了,就会产生问题。读取了无效数据的事务,其计算结果也变得无效,因此它也必须中止。这可能导致一连串的事务都需要回滚。

例如,考虑以下场景:
- 事务 T2 读取了事务 T1 写入但未提交的数据
x。 - 随后,事务 T1 中止了,
x的值被回滚。 - 此时,T2 基于无效的
x值计算出的y值也是无效的。 - 为了保持数据库一致性,T2 也必须中止。
更糟糕的情况是,如果事务 T3 读取了 T2 写入的 y,那么 T1 的中止将导致 T2 和 T3 都需要级联回滚,即使 T3 与最初的 T1 并无直接关系。

避免级联回滚

为了避免级联回滚,一个核心原则是:事务只能读取已提交事务写入的数据。

这意味着,如果一个事务想要读取某个正被其他事务修改的数据项,它必须等待,直到那个事务完全结束(无论是提交还是中止)。等待结束后,它可以安全地读取最终确定的值(提交后的新值或中止后的旧值)。
这种方法消除了读取“脏数据”(可能被回滚的数据)的可能性,从而避免了级联回滚。遵循此规则的调度被称为 可避免级联回滚(Avoids Cascading Rollbacks, ACR) 的调度,或称 可恢复(Recoverable) 调度。

调度类型的层次结构
除了可恢复性,调度还可以具有其他属性,例如严格性(Strictness)。一个严格的调度要求:事务不能写入(覆盖)另一个未提交事务已写入的数据项。

不同的调度属性构成了一个层次结构:

- 可串行化调度(Serializable Schedules): 保证并发执行的结果等价于某种串行执行顺序。这是最严格的保证。
- 严格调度(Strict Schedules): 是可串行化调度的子集,提供强一致性保证。
- 可避免级联回滚调度(ACR Schedules): 是可串行化调度的另一个子集,保证了可恢复性。
- 所有可能调度(All Schedules): 范围远大于可串行化调度。

权衡: 调度的属性越严格(如可串行化),对数据库一致性的保证就越强,但允许的并发度(Concurrency) 可能就越低。许多数据库系统允许用户根据需求选择隔离级别,例如接受可恢复调度以换取更高的并发性能,但这会带来一定的风险。
并发控制与调度器


那么,数据库如何确保事务遵循这些规则呢?这通过并发控制机制和调度器(Scheduler) 来实现。
事务管理器会发送一系列的读/写请求。调度器的职责是决定这些操作的执行顺序,以确保最终产生的调度具有我们期望的属性(如可串行化、可恢复),而不会产生无效的结果。调度器排序后的操作会与缓冲区池管理器交互,以获取实际的数据对象。
需要强调的是,如果两个事务操作的是完全不同的数据对象,那么它们的操作可以任意交错执行,不会产生任何问题。并发控制主要关注那些操作存在冲突(Conflict)(例如,读写同一数据项)的事务。
可串行化详解

我们再次深入探讨可串行化的定义。其核心思想是等价性(Equivalence)。
- 调度等价: 如果两个调度执行后,数据库处于相同的最终状态,则这两个调度是等价的。
- 可串行化调度: 一个并发调度,如果它的执行结果与某个串行调度(即事务一个接一个顺序执行)的结果等价,那么这个并发调度就是可串行化的。


注意,不同的串行顺序(如先T1后T2,或先T2后T1)可能导致不同的最终数据库状态。但只要并发调度的结果等价于其中任何一种串行顺序的结果,我们就认为它满足了可串行化要求,从而保证了数据一致性。
判断可串行化主要有两种方法:
- 冲突可串行化: 通过分析事务间的冲突操作(如对同一数据的读-写、写-写)是否按特定顺序发生。
- 视图可串行化: 通过分析哪个事务产生了某个数据的最终值,以及数据值在生产者和消费者之间的传递关系。


本节课中我们一起学习了事务调度中的关键问题——级联回滚,以及如何通过让事务只读取已提交数据来避免它(即可恢复调度)。我们还了解了调度类型的层次结构,包括严格调度和可串行化调度,并认识到在一致性保证和系统并发度之间存在权衡。最后,我们介绍了数据库调度器的角色和可串行化的精确定义,即并发执行的结果必须等价于某种串行执行顺序。
030:事务隔离与恢复概述
在本节课中,我们将继续探讨数据库事务的隔离性实现,并初步介绍事务的持久性(Durability)与恢复(Recovery)机制。我们将回顾事务的核心属性,分析不同隔离级别的权衡,并了解一位在数据库恢复领域做出杰出贡献的研究者。

课程管理与作业说明
上一节我们介绍了事务隔离的基本概念,本节开始前,先对课程管理和作业提交进行几点说明。

首先,请确保在提交作业前完成小组组建。这应该很容易,因为你们可以在这里联系到我。
关于测验,我记不清具体发布时间,可能是下午4点,但截止日期是下周四。
今天我将更深入地讲解如何实现隔离。下周我们会讨论其他主题。
以下是关于作业提交和测试的重要说明列表:
- 提交内容:与其只提交部分选定的源文件,不如提交所有相关文件更为简便。这包括所有
.c、.h文件,当然不包括二进制文件或.o、.z文件。此举是为了避免过去发生的情况:有同学提交时遗漏了自己实现的关键部分(如选择因子)。 - 代码兼容性:即使你提交了自己的
select等文件版本,评分时我仍会使用原始版本进行测试。因此,你必须确保你提交的所有库代码能与原始版本协同工作。最简单的方法是:不要修改原始版本。 - 输出一致性:自动评分会面临一些挑战。对于正确的解决方案,并非所有输出都必然完全相同。
- 应相同的输出:在执行插入等操作时,多属性哈希值(multi-attribute hash values)的输出应该完全相同,不受运行机器的影响。如果你的哈希值与测试用例不同,可能需要检查。一个方法是:转储每个单独属性的哈希值,确保正确的位被提取并放置到多属性哈希值的正确位置。
- 可能不同的输出:如果你插入与我相同的一组元组后进行搜索,应该得到相同的答案集合,但顺序可能不同。这取决于你实现分割(splitting)的具体方式。只要答案集合相同,就是正确的。
- 统计信息输出:创建数据库并运行统计信息(stats)时,输出应该完全匹配,除非你修改了关系相关的代码。
- 分割时机的影响:如果你插入一千个元组后执行转储(dump),输出可能因分割时机而异。规范中提到,每插入 C 个元组后进行分割。是在插入前分割,还是插入后分割,可能会影响最终看到的结果。两种方式都不算错误,特别是当你能得到相同的答案集合时。很难在不透露过多实现细节的情况下,强制要求某种特定方式。
- 测试数据:我们最近发现,在希腊服务器和我的 Mac 上运行
gen_data会产生不同的结果。这让我有些惊讶,我原以为两个平台的随机数生成器(RNG)实现是相同的。但或许可以这样理解:只要它们是随机数生成器,在不同机器上是否产生相同的随机序列并不重要。因此,为了确保测试一致性,我将提供包含相同元组的文件供测试使用,而不是依赖gen_data生成。
著名数据库研究者介绍
在深入技术内容之前,我想介绍一位在数据库领域,特别是恢复机制方面极具影响力的研究者。


有人认识这位吗?看来没有。这说明你们还没有查阅相关材料。他的名字是莫汉(Mohan),姓氏是一个很长的印度名,我就不尝试发音了。他以在恢复领域的工作而闻名,这也是我今天要讨论的主题之一。


ACM SIGMOD 是数据库领域研究人员主要关注的专业组织,也是主要的数据库会议之一。大约每十年,他们会颁发一项奖项,表彰那些对研究和工业界产生重大影响的早期工作。莫汉因其设计的 ARIES 恢复系统而多次获得此类奖项。他在数据库的其他领域也有诸多贡献。


他最初在 IBM 阿尔马登研究中心(IBM Almaden)工作,那里也是 System R 的诞生地(实际上是在圣何塞实验室建造的)。之后他曾回到印度一段时间,后来又回到了阿尔马登。根据维基百科,他目前是中国的一名访问教授。这再次证明了他是一位极具影响力的数据库人物。
因此,他完全配得上“著名数据库极客”这个头衔。
事务属性与隔离级别回顾
好了,言归正传。昨天我们讨论了事务、可串行化以及用于并发控制的锁。我们这样做是为了让事务满足一系列特定属性:



- 原子性(Atomicity):整个事务要么全部发生,要么完全不发生。记住,事务由一系列独立的数据库操作组成。
- 一致性(Consistency):基本上意味着事务是正确的,它完成了你的预期,将数据库从一个有效状态转换到另一个有效状态。
- 持久性(Durability):我们今天稍后会讨论。
- 隔离性(Isolation):其基本目标是控制并发,使得每个事务都感觉自己是系统中唯一在运行的事务。
如果事务不并发运行,而是一个接一个地串行执行,那就没有问题。如果你能创建出等价于某个串行事务的并发事务,那将是理想的,并且肯定能避免我们昨天看到的一些更新异常。
可串行化是对隔离性相当严格的解释。规定越严格,你能获得的并发度就越低。因此,如果你希望稍微放宽要求,或许允许某些更新异常潜在发生,那么你可以获得更高的并发度。这里存在一种权衡。
有四种不同的隔离级别,但 PostgreSQL 实际上只实现了其中两种。它提供了设置其他级别的语法,但它们并不真正存在。我们昨天看到,你无法真正设置“读未提交”级别。
本节课总结

在本节课中,我们一起学习了关于作业提交和测试一致性的重要注意事项,认识了对数据库恢复机制有卓越贡献的研究者莫汉,并回顾了事务的 ACID 属性,特别是隔离性的不同级别及其在并发控制中的权衡。下一讲,我们将开始深入探讨事务的持久性和具体的恢复机制。
031:事务恢复与日志管理

在本节课中,我们将学习数据库事务的原子性和持久性是如何通过日志机制实现的。我们将探讨系统可能遇到的各种故障,并了解Postgres如何使用日志管理器来确保数据在崩溃后能够恢复到一致的状态。
作业提交与测试说明
上一节我们讨论了哈希索引的实现细节,本节我们来看看与课程作业相关的一些重要说明。
以下是关于作业提交和测试的具体要求:
- 作业规范中要求提交特定文件。过去要求学生提交特定文件时,总有一半的小组没有提交系统运行所需的全部文件。
- 因此,请提交所有文件,包括
Makefile。 - 关于使用
math.h库的问题:如果使用,则需要更新Makefile。但计算2的幂次有更高效的方法,不一定需要使用math.h。


以下是我们的测试流程:
- 我们将解压你提交的文件。
- 然后,我们会将我们的主程序(如
select.c和create.c等)覆盖到你的代码之上。 - 接着运行
make命令。如果你提交的文件齐全且正确,它应该能顺利编译。

关于测试数据的说明:
- 我们不再运行
gendata程序并将其输出导入insert程序。 - 改为从一个文件中读取数据。你可以通过点击链接获取该文件的实际文本内容,这将为你提供完全相同的元组。
- 因此,你应该得到完全相同的多属性哈希值。你可以使用
diff甚至cmp命令来比较结果。 - 同样,由于我设置的元组数量很少,预计不会发生分裂。到达此阶段时,你应该在一个桶中有几个元组,在另一个桶中有几个元组。
- 请注意,我们初始时有两个页。我在测试时发现了一个错误:如果你尝试从一个页开始,它会失败。如果你想查看代码,可以找出原因。代码中有一个假设,即文件的深度
D大于零。如果你只有一个页,深度就不大于零。 - 总之,如果你的多属性哈希实现正确,你也应该得到这个结果。
事务的原子性与持久性
现在,让我们继续前进。记住,我们希望事务具有原子性。
原子性基本上意味着:要么事务所有预期的效果都发生,要么所有预期的效果都不发生。

持久性则意味着:如果你执行了提交操作,那么即使数据库系统或运行它的计算机系统发生了灾难性故障,这些值在未来也应该是可用的。你可能需要在重启系统时进行恢复操作,修复数据——其中一些数据可能是提交操作的一部分,另一些可能不是。还有更微妙的系统故障,它们处理方式略有不同。
事实证明,原子性和持久性这两个特性的实现是紧密联系在一起的。
需要应对的系统故障
那么,我们需要担心哪些类型的系统故障呢?

- 位翻转:可能发生吗?容易修复,这就是我们使用奇偶校验位的原因。
- 存储介质故障:整个磁盘设备损坏。
- Postgres进程崩溃:可能不会破坏数据库,但可能会中断在该特定服务器实例上运行的事务。希望它不会破坏其他任何东西。
- 操作系统崩溃:在部分事务提交过程中,我们的写入系统宕机。
- 灾难性物理破坏:例如,存放服务器的机房发生火灾,所有存储在其中的数据都丢失了。这很不幸,但我们可以恢复——在一定程度上,因为我们有异地备份。
崩溃恢复场景
这是一个典型的场景:你有一批事务正在运行。
- 事务 T1 运行并提交。
- 事务 T2 运行并提交。
- 紧接着系统崩溃。

由于 T1 和 T2 已经完成,我们必须确保在从崩溃中恢复后,它们所做的任何更改最终都出现在数据库中。
对于 T3 和 T4,它们可能已经做了一些中间更改。我们希望确保所有这些更改都被撤销,就好像 T3 和 T4 从未实际运行过一样。
稳定的底层存储
首先,你必须有一个稳定的底层磁盘系统。
具体来说,我之前可能用过 put_page 和 get_page 来从缓冲池获取数据。但在这里,我建议 put_page 实际上是写入磁盘,get_page 是从磁盘读取。

关键在于:如果你向磁盘写入一批数据,随后在没有其他人写入的情况下将其读回,你应该看到与你写入时相同的内容。
我之前提到的所有其他类型的故障,都可以通过各种标准方式修复,例如奇偶校验、标记坏块。如果你足够聪明,你会在磁盘存储中设置一些冗余;对于计算机系统的物理破坏,你会有异地备份。
数据库管理系统需要处理的故障
我们需要真正担心并且可以由数据库管理系统处理的事情包括:

- 数据库管理系统本身故障。
- 操作系统崩溃。
- 在某种程度上,还包括事务失败。我们预计这种情况会不时发生,所以这并不特别糟糕。
管理这些问题的标准方法是:随时间推移,保存数据库如何更改的某种日志,并确保该日志被推送到磁盘并具有持久性。
通常,将日志推送到磁盘涉及推送相对较小的数据项,而更新数据库则涉及更大的更改。但是,如果你能将所有日志项推送到磁盘,你就可以利用它们来尝试恢复状态。对此有几种不同的策略。
恢复架构
以下是架构如何组合在一起的思路:
- 我们仍然有缓冲区管理器,所有来自数据库的数据都经过它。
- 我们可能有一个日志管理器,它可能会直接与缓冲区管理器通信(但为了使图表更简洁,这里没有画出)。
- 查询处理器基本上是之前幻灯片中展示的那一堆组件。
- 当然,还需要一个恢复管理器来利用日志管理器生成的内容进行恢复。它们不直接相互通信,恢复管理器会去读取磁盘上的日志。

数据的三个版本
当你运行一个事务时,一个特定的数据项实际上存在于三个不同的地方:

- 存储在磁盘上的版本。
- 加载到某个内存缓冲区中的版本。
- 可能还有一个版本存在于正在操作该数据项的事务的本地内存中。
因此,对于我们所谈论的同一数据项,可能存在三个完全不同的值。

本节课中,我们一起学习了数据库事务恢复的基本概念。我们了解了原子性和持久性的重要性,探讨了系统可能面临的各类故障,并介绍了Postgres通过日志管理器来确保数据一致性的核心架构。理解数据在磁盘、缓冲区和工作内存中的不同状态,是掌握事务恢复机制的关键。
032:课程总结与考试准备 🎓
在本节课中,我们将回顾课程进度,澄清作业细节,并开始讨论期末考试的相关主题。本节内容将涵盖课程管理事项、作业提交注意事项以及线性哈希索引的实现细节。
课程管理与作业澄清 📝
上一节我们结束了主要的技术讲解。本节中,我们来看看本学期的收尾工作。
首先,关于时间安排。本学期是第10周,也是最后一周。作业的截止日期已调整,以避免与短暂的假期冲突。建议尽早完成作业。
关于测验5,其发布时间可能较晚,这解释了为何参与人数较少。作业评分工作即将完成,预计在本周五公布成绩。对于服务器崩溃、无限循环或产生巨大核心文件的情况,处理会耗费额外时间。
课程反馈调查非常重要。请务必填写,高层管理者非常关注回复率。


关于作业分数,课程大纲和作业说明中存在不一致。以作业说明中的分数为准(13分和17分)。提前一周提交可获得额外分数(14分和18分),该政策从昨天开始生效。
对于小组作业,请确保你已在“作业二”小组中注册。如果未注册,系统将默认作业由提交者独立完成。事后补充说明合作情况可能导致成绩问题。请提交所有代码。
线性哈希索引实现细节 ⚙️
在实现线性哈希索引时,有几个关键点需要注意,这些点会影响测试结果但不会影响功能的正确性。
以下是作业测试中需要关注的几个方面:

- 元组位置:使用
dump命令可以查看元组位于数据页还是溢出页。重要的是元组在正确的桶中,具体在哪种页面不影响正确性。 - 分割时机与顺序:分割操作的执行频率和顺序会导致
dump输出不同。测试时使用stats命令的结果会更可靠,因为它统计的是整体数据。 - 随机数生成器:系统的随机数生成器会影响数据分布,进而影响分割行为。
分割策略有多种实现方式,它们都有效,但会导致内部状态不同。
以下是几种可行的分割策略:
- 固定阈值分割:例如,每插入200个元组后执行一次分割。这会导致平均溢出链较长。
- 动态负载因子分割:基于页面平均填充度(如75%)触发分割,能更有效地利用空间。
- 插入前后检查:在插入新元组前或后检查并执行分割,如果新元组正好落在待分割的桶中,结果会略有不同。
- 页面处理顺序:分割时,处理数据页和溢出页的顺序不同,可能影响元组最终是落在新数据页还是溢出页上。
所有这些策略都能确保元组被分配到正确的桶中,因此实现具有灵活性,但也给自动化测试带来了挑战。
PostgreSQL核心贡献者 👥
数据库领域,尤其是PostgreSQL,拥有众多杰出的贡献者。

以下是部分知名的PostgreSQL核心贡献者:

- Tom Lane:PostgreSQL项目的核心领导者之一,负责审核和集成大部分代码变更。
- Josh Berkus:重要的核心开发人员之一。
- Bruce Momjian:知名的PostgreSQL布道者,经常在会议和书籍中发表演讲或撰写文章。
- 其他贡献者:图中还包括一位澳大利亚籍的贡献者,他曾在约十年前为本课程做过讲座。
社区中还有许多其他贡献者,共同维护和发展着PostgreSQL系统。
总结 📚
本节课中我们一起学习了本课程的收尾安排,澄清了作业评分和提交的重要细节。我们深入探讨了线性哈希索引实现中分割策略的多样性及其对测试结果的影响,并简要认识了PostgreSQL背后的部分核心贡献者。接下来,我们将继续讨论期末考试的相关内容。
033:分布式数据库系统概述
在本节课中,我们将要学习分布式数据库系统的基本概念,包括其两种主要类型:并行数据库与联邦数据库。我们将探讨它们如何工作、为何需要它们,以及在实际应用中的一些例子。
上一节我们介绍了如何在数据库中利用并行性。本节中我们来看看如何将数据分布到不同的节点上。
一种方式是将一个表的部分数据存储在一个节点,另一部分存储在另一个节点。这种方式是将原始表中的元组集合进行拆分,但这只是拆分关系的一种方法。在分布式数据库的语境下,我们还会看到其他方式。
基本上,这里讨论的分布式数据库主要有两种类型。
第一种是并行数据库。在这种系统中,你有多个特定数据库的实例,它们都遵循一个定义良好的模式,所有实例都在操作这个模式。这类似于我们刚刚讨论过的并行数据库的广域网版本。

第二种是联邦数据库。在这种系统中,每个节点拥有自己的数据块。这些数据通常与其他节点上的数据相关,但未必来自相同的模式或数据源,甚至可能由不同的数据库管理系统运行。例如,一个节点可能运行Oracle,另一个节点运行Postgres。这种系统的核心思想是,无论后端是多个数据库还是单个数据库服务器,它都能让它们看起来像一个统一的数据库。

无论后端看起来如何,用户最终看到的前端都像一个单一的模式。无论这个模式是源自特定服务器下的真实单一模式,还是由一堆具有不同模式、被集成或联邦在一起以呈现单一服务器外观的数据库服务器所组成,这都不重要。


那么,为什么我们需要分布式数据库呢?以下是几个原因:

- 数据整合:我们可以拥有多个数据库,并在需要时将它们全部合并。
- 可靠性:显然,数据复制有助于提高系统的可靠性。
- 并行查询评估:如果我们需要从这里的Oracle数据库收集一批数据,同时从那里的Postgres数据库收集另一批数据,这两件事显然可以并行运行。

由于数据库可能具有不同的模式,必须存在某种映射机制,将后端数据映射到我们呈现给用户的全局模式视图中。因此,进行这种映射会产生成本。

此外,还存在网络成本,因为数据库分布在网络上。有趣的是,在上周的语境中,我们还必须解决如何保持跨多个服务器(可能还有多个不同的DBMS)分布的事务的ACID属性。
以下是一些可能应用此类系统的例子:
- 银行系统:一家拥有多个分行的银行,可能将每个分行的账户数据存储在该分行的服务器上。同时,人力资源信息、所有分行列表等数据可能存储在一个中央服务器上。此外,可能还有一个与客户相关的中央注册表。银行希望看到所有这些信息的概览。
- 连锁百货商店:商店可能在本地存储交易数据,然后为公司提供一个可以查看所有数据汇总的视图。

如今,你可能会觉得所有数据都将保存在中央数据库中,因为网络足够快。但在理论上,系统可以按照上述方式构建。
在这两种场景中,我们都希望系统能提供一个单一的企业模式视图。
以会计为例,每个分行维护自己的数据,但银行需要了解所有账户的全部信息。

我们在并行数据库的语境下讨论过的分区概念在这里也适用。有时分区是自然发生的,因为这里有一个Oracle数据库,那里有一个Postgres数据库,它们显然拥有不同的数据集。

但即使没有这种情况,我们也可能希望将一个表的部分数据放在一个节点,另一部分放在另一个节点。
- 水平分区:即我们之前在并行数据库中讨论的那种分区,将元组子集分布在不同节点。
- 垂直分区:将部分列存储在一个服务器,另一部分列存储在另一个服务器。
如果你确实拥有具有不同模式的数据库服务器,但这些模式需要合并,那么很可能这里的列和那里的列构成了一个更大实体的一部分。
当然,还存在事务性的问题。

如果我们有一个表被水平分区,部分元组在这里,部分在那里,我们可以用多种不同的方式拆分表,但需要确保每个元组在某个地方都有表示,并且我们可以通过合并所有分区来重建原始表。

本节课中我们一起学习了分布式数据库系统的核心概念。我们区分了并行数据库与联邦数据库,探讨了它们的目标、优势(如数据整合、可靠性和并行处理)以及面临的挑战(如模式映射、网络成本和事务管理)。我们还通过银行和零售业的例子了解了其实际应用,并回顾了水平与垂直分区在数据分布中的作用。理解这些基础是设计和实现高效、可靠的分布式数据存储系统的关键。
034:Hadoop分布式文件系统(HDFS)详解

概述
在本节课中,我们将深入学习Hadoop分布式文件系统(HDFS)的核心架构与工作原理。HDFS是支撑大规模数据处理的关键组件,其设计理念与PostgreSQL等传统数据库系统有显著不同。我们将重点解析其如何管理元数据、存储数据块,以及如何通过分布式架构实现高可靠性与高吞吐量。

课程内容回顾与引入


上一节我们介绍了分布式系统的基本概念。本节中,我们来看看Hadoop分布式文件系统(HDFS)的具体实现细节。HDFS旨在运行在由大量相对简单的计算节点组成的集群上。

HDFS 核心设计目标
HDFS的设计围绕几个核心目标展开。
以下是其主要设计目标:
- 运行于大规模简单节点集群:集群通常初始结构为数据中心内的机架,但节点也可分布于全球不同数据中心。
- 支持超大文件:支持比常规操作系统(如Linux)所能处理的更大文件。
- 数据分布与复制:将文件数据块分布到多个节点,并进行复制。其假设是,在任何给定时间点,都可能会有节点失效,因此需要确保每个数据块有多个副本,以保证数据始终可用。
- 优化MapReduce支持:HDFS是包含MapReduce实现的软件套件的一部分,其设计旨在为使用MapReduce的应用程序提供最优支持。
- “一次写入,多次读取”模型:文件不能被中间更新(即不能修改文件中间部分),但可以写入一次并追加内容。这种“写一次,读多次”的假设简化了确保所有副本一致性的工作。
- 高吞吐量顺序访问:确保一旦开始访问文件,就能非常快速地顺序读取。通过从不同节点并行获取数据块,将磁盘I/O负载分散到所有节点,从而实现高速流式读取,这对于处理超大文件至关重要。
HDFS 架构组成
HDFS的架构围绕集群概念构建。


以下是其核心组件:


- 名称节点(NameNode):作为主节点,存储整个文件系统的所有元数据。它知道文件(如“Fred”)由哪些数据块组成,以及这些数据块位于哪些数据节点上。
- 数据节点(DataNode):存储实际的数据块。数据块以固定大小的块形式,存储在数据节点本地操作系统的普通文件中。
- 客户端(Client):访问HDFS的应用程序。

工作流程简述:
当客户端要打开一个文件时,请求首先发送到名称节点。名称节点根据其维护的元数据,确定该文件的数据块位于哪些数据节点上,并将这些信息(例如数据块位置列表)返回给客户端。之后,客户端可以直接与相应的数据节点通信进行读写操作。

数据节点与名称节点的协作
数据节点不仅存储数据,还与名称节点紧密协作以维持系统状态。
以下是数据节点的关键职责:
- 执行数据操作:实现针对数据块的读取和追加操作。一旦客户端通过名称节点打开文件,后续的读取等操作将直接发送到名称节点提供的特定数据节点。
- 定期向名称节点报告:数据节点必须周期性地向名称节点发送报告,告知其自身状态以及所存储数据块的信息。这对于名称节点掌握集群全局视图、进行故障检测和副本管理至关重要。
文件与数据块的组织
在HDFS中,文件被切分为固定大小的数据块。

以下是其组织方式的关键点:
- 一个HDFS文件是数据块(Blocks) 的集合。
- 这些数据块分布在集群的所有节点上。
- 集群可能位于单个数据中心,也可能部分组件位于世界各地的不同数据中心,以实现地理分布。
总结

本节课中,我们一起学习了Hadoop分布式文件系统(HDFS)的核心原理。我们了解到HDFS通过名称节点(NameNode) 集中管理元数据,通过分布在众多数据节点(DataNode) 上的数据块副本实现可靠存储,并采用“一次写入,多次读取”模型来优化大规模顺序处理。其设计核心在于通过数据分布、复制和节点间协作,在由廉价硬件组成的大型集群上实现高可靠性与高吞吐量的数据存储与访问,为上层计算框架(如MapReduce)提供了坚实的基础。理解HDFS的架构是掌握现代大数据处理技术栈的重要一步。

浙公网安备 33010602011771号