UCB-CS186-数据库导论笔记-全-

UCB CS186 数据库导论笔记(全)

数据库系统导论 P1:课程介绍与 SQL I 🗄️

在本节课中,我们将学习数据库系统的基本概念,了解为什么数据库在现代计算中至关重要,并初步接触关系模型与 SQL 语言的核心思想。

课程概述

这门课程将涵盖如何开发用于管理、维护、处理大规模数据并与数据进行交互的系统。我们将学习与数据交易、系统开发原理以及在管理数据背景下进行系统开发相关的所有内容。

为什么学习数据库系统?

以下是学习数据库系统的几个核心原因。

无处不在的应用

当你预订酒店、机票或 Airbnb,在社交媒体上点赞,寻找餐厅,在 Piazza 或 Slack 上发布信息,或进行金融交易和购物时,你很可能正在使用数据库系统。如果你考虑创业或加入一家大公司,你构建的应用程序很可能将由这类系统支持。

现代科学的支柱

在基因组学、天文学、神经科学、医学和气象学等领域,会生成或收集大规模的数据集。理解这些数据需要强大的系统支持。我们将要学习的系统,是解决未来一些最紧迫社会挑战(如气候变化和公共卫生)的关键。

数据成为计算的核心

数据增长将在未来继续超越计算能力,未来计算的关键瓶颈将在于数据处理。因此,面向大规模数据的系统是现代计算的核心。每分钟,大约有50万条推文被发送,2000万次天气频道预报请求,以及400万次谷歌搜索请求,这展示了惊人的数据生成速度。

例如,大型强子对撞机每年生成的数据量相当于100万拍字节,这迫使系统必须进行降采样处理。理想情况下,我们希望构建能够逐渐扩展处理极限的系统。

基础技术与研究机会

数据库领域已获得四个图灵奖,涉及关系模型、事务处理等核心概念。开发可扩展的数据系统是计算机科学研究中最激动人心的领域之一。本课程将教授的原则是超越时代的、可重用的思想和组件。

什么是数据库系统?

假设你正在从零开始构建一个银行数据管理系统。你需要担心以下方面:

  • 处理大量数据。
  • 快速处理查询。
  • 在系统故障时保证数据不丢失。
  • 支持多用户同时访问和更新。
  • 保持数据一致性。
  • 系统易于使用和安全。

数据库系统的方法是将所有这些数据管理功能集成并抽象为一个独立的层次(DBMS),供许多应用程序(如ATM、网页、手机应用)访问。

数据库系统的定义

数据库系统是一个提供高效、便捷和安全的多用户访问,以存储海量持久数据的系统。我们来拆解这个定义:

  • 海量数据:数据量非常庞大,可达PB级别。
  • 持久性:数据需永久保存,存储在硬盘或闪存等稳定介质中,而非内存。
  • 多用户:需要协调多个用户对同一数据的并发访问,确保正确性。例如,联合账户的余额检查。
  • 安全性:系统需在故障时保持安全(如事务的原子性),并防止恶意访问。
  • 便捷性:应有简单的命令进行操作,易于处理各种查询。
  • 高效性:数据检索和操作应快速,不随数据总量线性增加复杂度。

为什么需要专门的数据库系统?

早期的系统基于文件系统,但它无法保证崩溃时的数据一致性,不支持高效的快速访问,需要自定义代码维护数据结构。数据库系统则原生实现了这些功能:

  • 描述现实世界实体。
  • 存储和管理超越程序生命周期的持久化大数据集。
  • 支持高效的查询、更新和结构更改。
  • 以正确的方式处理并发更新。
  • 从崩溃中恢复。
  • 遵循安全性和完整性属性。

数据库管理系统(DBMS) 是存储、管理和促进数据访问的软件。数据库 是DBMS管理的大型、组织良好的数据集合。在表示上,数据库常用圆柱体符号,象征数据持久存储在磁盘上。

关系模型基础

我们将主要关注关系数据库系统(RDBMS),如 Oracle, SQL Server, PostgreSQL。关系模型的核心是 关系(Relation),也称为 表(Table)

一个关系包含两部分:

  • 模式(Schema):描述表结构的元数据,包括列名和类型。
  • 实例(Instance):存储在表中的一组具体数据。

以下是相关术语:

  • 属性(Attribute)/ 列(Column)/ 字段(Field):表中的垂直列,有名称和类型(如 TEXT, INTEGER)。
  • 元组(Tuple)/ 记录(Record)/ 行(Row):表中的水平行。
  • 基数(Cardinality):关系中元组的数量。

关系具有以下属性:

  1. 模式固定:列名和类型定义后是固定的。类型必须是 原子(原始)类型
  2. 行无序:行的顺序不重要。
  3. 行可重复与否:可以是集合(行唯一)或多重集合(行可重复)。
  4. 平面化:属性不能嵌套(必须符合 第一范式)。
  5. 物理数据独立性:数据的物理存储方式(如行存储、列存储)对上层应用透明。

键(Key)

是能够唯一标识表中每一行的一个或一组属性。我们主要使用 主键(Primary Key),每个表只能有一个主键。

在模式定义中,我们通过 下划线 来表示主键属性。例如:

  • Sailors(sid: integer, sname: string, rating: integer, age: real) 中,sid 是主键。
  • Reserves(sid: integer, bid: integer, day: date) 中,(sid, bid, day) 组合起来作为主键。

SQL 语言简介

SQL(Structured Query Language)是用于管理和操作关系数据库的标准语言。它始于1970年代的IBM研究,并最终成为最广泛使用的数据库查询语言。

SQL 语言主要包含两部分:

  1. 数据定义语言(DDL):用于定义和修改数据库模式,如创建、删除表。
  2. 数据操作语言(DML):用于查询和修改数据库中的数据实例。

SQL 是一种 声明式语言:你只需指定想要什么结果,而无需描述如何一步步实现。具体的执行策略由数据库系统的优化器决定。

使用 DDL 创建表

以下是一个创建 Sailors 表的 SQL 语句示例:

CREATE TABLE Sailors (
    sid    INTEGER,
    sname  VARCHAR(50),
    rating INTEGER,
    age    REAL,
    PRIMARY KEY (sid)
);

课程进行方式与期望

本课程将在线进行,包含同步讲座、讨论、小测验、编程项目和考试。我们鼓励通过 Piazza 进行课程交流,并提倡同学间互相解答问题。

课程工作量包括每周的讲座、小测验和若干编程项目。项目将涵盖 SQL 查询、索引、连接操作、查询优化和事务处理等内容。我们提供了缓冲时间以应对特殊情况,但建议同学们合理安排进度,避免期末压力过大。

学术诚信至关重要,我们对此有严格的政策。请独立完成作业,如有压力请及时与课程工作人员沟通。

总结

本节课我们一起学习了数据库系统的重要性及其广泛的应用场景。我们明确了数据库系统的定义和目标,并初步认识了关系模型的核心概念:关系、属性、元组、键以及第一范式。最后,我们介绍了 SQL 语言及其声明式特性,并展示了如何使用 DDL 定义表结构。

在接下来的课程中,我们将深入学习如何使用 SQL 的 DML 部分进行丰富的数据查询与操作。

数据库系统原理 P10:第10讲 迭代器与连接 I 🧩

在本节课中,我们将要学习数据库查询执行的核心机制——迭代器模型,并探讨几种基础的连接算法。我们将从查询计划的执行方式开始,逐步深入到迭代器接口的设计,最后分析简单嵌套循环连接、块嵌套循环连接、索引嵌套循环连接以及排序合并连接的工作原理和成本。


查询计划与数据流图

上一节我们介绍了SQL查询的编译过程,本节中我们来看看查询计划的具体形态。一个SQL查询会被编译成一个查询计划,它通常表示为一棵树。

这棵树编码了元组的流动方向。元组从树的底部(叶节点)开始向上流动,最终在根节点生成结果。树中的每个顶点代表一个操作符,底部的叶节点通常是表访问操作符(如扫描或索引查找)。

这种结构也被称为数据流图,因为它清晰地展示了数据的流动路径以及对数据执行的操作。数据流图并非数据库系统独有,在大数据、深度学习等系统中也广泛应用。

数据库系统中的查询执行器组件负责实例化这些操作符并执行查询计划。查询优化器则负责选择最优的查询计划。执行器通过创建操作符的实例来运行查询。

每个操作符实例都称为一个“迭代器”。例如,你可能有一个索引嵌套循环连接迭代器、一个投影迭代器或一个选择迭代器。叶节点可能是索引扫描迭代器,用于访问关系中的元组。

每个操作符实例都需要实现一套标准的迭代器接口方法,这确保了不同操作符之间可以统一地组合在一起,形成一个完整的查询计划。数据流从底部向上流动,最终在根节点产生结果。


迭代器接口

上一节我们提到了迭代器接口的重要性,本节中我们来看看这个接口具体包含哪些方法。迭代器接口本质上是一个抽象类,它定义了一组所有操作符都必须支持的公共方法。

以下是迭代器接口的核心方法:

  • init: 此方法用于进行初始化设置。它在开始处理元组之前被调用,通常用于初始化变量、设置状态等准备工作。
  • next: 此方法负责生成下一个元组。父操作符通过调用子操作符的 next 方法来请求新的元组。通过反复调用 next,可以遍历操作符产生的所有元组。“迭代器”一词正来源于此。
  • close: 此方法在操作符没有更多元组可生成时被调用,用于清理状态、关闭文件句柄等收尾工作。

这个迭代器接口基于“拉取”模型。执行从查询计划的根节点开始,根节点调用子节点的 next 方法来请求元组,子节点再向它的子节点请求,如此递归,直到从叶节点(存储的关系)拉取到数据。

next 方法可以支持两种处理方式:

  • 流式处理:操作符每接收到一个输入元组就立即处理并可能产生输出(如选择操作)。
  • 阻塞处理:操作符需要消耗完所有输入元组后才能产生输出(如排序操作)。

迭代器接口的优势在于:

  1. 可组合性:所有关系操作符都实现为同一接口的子类,因此任何迭代器都可以作为另一个迭代器的输入。
  2. 状态封装:每个迭代器可以维护大量的私有内部状态(如哈希表、计数器、游标、排序文件),这些状态对其他操作符不可见。

简单迭代器示例

理解了接口定义后,我们通过几个具体操作符的实现来加深理解。以下是几个基础迭代器的伪代码描述。

选择迭代器 (Select / σ)

选择操作符是一个流式操作符,它根据谓词条件过滤输入元组。

init 方法:

def init(self, predicate, child):
    child.init()          # 初始化子操作符
    self.pred = predicate # 存储过滤条件
    self.current = None   # 当前元组游标

next 方法:

def next(self):
    while self.current != EOF and not self.pred(self.current):
        self.current = self.child.next() # 从子节点获取下一个元组,直到满足条件
    if self.current == EOF:
        return EOF
    result = self.current
    self.current = None # 为下一次调用准备
    return result

说明next 方法循环从子节点拉取元组,直到找到一个满足谓词 pred 的元组或遇到文件结束标志。

close 方法:

def close(self):
    self.child.close() # 关闭子操作符

堆文件扫描迭代器 (Heap Scan)

此迭代器作为查询计划的叶节点,负责从堆文件中读取元组。

init 方法:

def init(self, relation):
    self.heap_file = open(relation.heap_file) # 打开堆文件
    self.current_page = get_first_page(self.heap_file) # 定位第一页
    self.current_slot = get_first_slot(self.current_page) # 定位第一页的第一个槽位

next 方法:

def next(self):
    if self.current_page is None:
        return EOF # 所有页面已读完

    # 1. 准备本次返回的记录ID(当前页面和槽位)
    record_id = (self.current_page, self.current_slot)

    # 2. 为下一次next调用推进指针
    self.current_slot = advance_slot(self.current_page, self.current_slot)
    if self.current_slot is None: # 当前页已无更多槽位
        self.current_page = get_next_page(self.heap_file, self.current_page) # 获取下一页
        if self.current_page is not None:
            self.current_slot = get_first_slot(self.current_page) # 定位新页的第一个槽位

    return record_id # 返回当前记录ID(也可直接返回元组)

说明:该方法返回当前记录,并同时将内部指针推进到下一个待读取的位置。

close 方法:

def close(self):
    self.heap_file.close() # 关闭文件句柄

复杂迭代器示例

接下来,我们看看两个更复杂的、可能涉及阻塞操作的操作符。

排序迭代器 (Sort)

我们以两趟排序算法为例。排序是一个阻塞操作符。

init 方法:

def init(self, sort_key, child):
    child.init()
    # —————— 第0趟:生成初始有序归并段 ——————
    self.runs = []
    while (tuple = child.next()) != EOF:
        add_to_current_run(tuple)
        if current_run_is_full():
            sort_and_save_run(current_run) # 排序并写入磁盘
            self.runs.append(new_run_file)
    # —————— 为第1趟合并做准备 ——————
    self.input_buffers = []
    for run_file in self.runs:
        buffer = load_page(run_file) # 为每个归并段加载一个页面到缓冲区
        self.input_buffers.append(buffer)

说明:初始化阶段完成了所有输入数据的读取、排序并写入磁盘生成有序归并段,然后为合并阶段加载每个归并段的代表页面。

next 方法:

def next(self):
    if all_buffers_empty(self.input_buffers):
        return EOF
    # 找出所有缓冲区中当前最小的元组
    min_tuple = find_min_tuple_across_buffers(self.input_buffers)
    # 从对应缓冲区移除该元组
    remove_tuple_from_buffer(min_tuple.buffer, min_tuple)
    # 如果该缓冲区变空,则从对应归并段文件加载下一页
    if buffer_is_empty(min_tuple.buffer):
        load_next_page_into_buffer(min_tuple.run_file, min_tuple.buffer)
    return min_tuple

close 方法:

def close(self):
    for run_file in self.runs:
        delete(run_file) # 删除临时归并段文件
    self.child.close()

分组迭代器 (Group By)

此例假设输入数据已按分组键排序。分组操作符是流式的。

init 方法:

def init(self, group_keys, aggregates, child):
    child.init()
    self.group_keys = group_keys
    self.aggregates = aggregates # 例如:['SUM', 'COUNT', 'AVG']
    self.current_group = None
    self.agg_states = {} # 为每个聚合函数维护状态,如 {'sum': 0, 'count': 0}

next 方法 (核心逻辑):

def next(self):
    result = None
    while result is None:
        next_tuple = self.child.next()
        if next_tuple == EOF:
            if self.current_group is not None:
                # 处理最后一组
                result = compute_aggregates(self.agg_states)
                self.current_group = None
            break

        tuple_group = extract_group_keys(next_tuple, self.group_keys)
        if self.current_group != tuple_group:
            # 遇到新组:输出上一组结果,并初始化新组状态
            if self.current_group is not None:
                result = compute_aggregates(self.agg_states)
            # 开始新组
            self.current_group = tuple_group
            init_aggregate_states(self.agg_states) # 重置聚合状态

        # 将当前元组的值合并到当前组的聚合状态中
        merge_tuple_into_aggregates(next_tuple, self.agg_states, self.aggregates)

    return result

说明:由于输入已排序,同一组的元组连续出现。迭代器只需在内存中维护当前组的聚合状态,当检测到组别变化时,输出上一组的结果并开始计算新组。这非常高效。

聚合状态示例:

  • COUNT: 状态为一个整数计数器。
  • SUM: 状态为一个累加和。
  • AVG: 状态需要同时维护 SUMCOUNT
  • MIN: 状态为当前看到的最小值。

close 方法:

def close(self):
    self.child.close()

查询计划的执行

综合以上操作符,我们现在可以理解整个查询计划的执行过程。目前我们考虑单线程执行模型。

查询计划从根节点开始执行。在调用 init 初始化整个计划后,根节点反复调用自己的 next 方法来获取结果。每次 next 调用会触发一系列向下传递的 next 调用,沿着查询计划树向下到达叶节点,然后将数据向上传递、处理,最终在根节点生成一个输出元组。

一些操作符(如排序)是阻塞的,会在 initnext 中消耗大量甚至所有输入后才产生输出。而另一些操作符(如选择、已排序输入的分组)是流式的。

迭代器框架的巧妙之处在于,元组通过调用栈在操作符之间“流动”,并非总是需要将中间结果物化到磁盘。这使框架本身非常轻量,尽管个别操作符(如排序)可能很重量级。


连接算法概述

从本节开始,我们将重点转向二元操作符,特别是连接操作。在深入算法之前,先定义一些符号:

  • [R]: 存储关系 R 的磁盘页数。
  • PR: 关系 R 中每页存储的记录数。
  • |R|: 关系 R 的基数(总记录数),|R| = [R] * PR

示例关系:

  • Reserves (R): [R] = 1000 页, PR = 100 记录/页, |R| = 100,000 记录。
  • Sailors (S): [S] = 500 页, PS = 80 记录/页, |S| = 40,000 记录。

在分析算法成本时,我们通常忽略写出结果的I/O成本,因为它对所有算法是常数,且结果可能直接被父操作符消费而无需写盘。我们主要关注读取数据的I/O成本。


简单嵌套循环连接

这是最基础的连接算法,适用于任意连接条件(θ连接)。

算法描述:

for each tuple r in R:
    for each tuple s in S:
        if condition_θ(r, s) is true:
            add (r, s) to output buffer

成本分析:

  • 扫描一次 R: 成本 = [R]
  • 对于 R 中的每个元组,扫描一次 S: 成本 = |R| * [S]
  • 总成本 = [R] + |R| * [S]

代入示例:1000 + 100,000 * 500 = 50,001,000 次 I/O。

连接顺序的重要性:如果交换 R 和 S 的角色(以 S 为外循环),成本变为 [S] + |S| * [R] = 500 + 40,000 * 1000 = 40,000,500 次 I/O。可见,选择较小的关系作为外循环可以显著降低成本。

简单嵌套循环连接的 I/O 效率很低,因为它以元组为单位进行循环。


块嵌套循环连接

磁盘I/O的基本单位是页面,因此改进思路是以页面为单位进行循环。

算法描述:
假设有 B 个内存缓冲区页。分配 1 页给 S(内关系),1 页给输出,剩下的 B-2 页给 R(外关系)。

for each chunk of (B-2) pages of R:
    load the chunk into memory
    for each page of S:
        load the page into memory
        for each tuple r in the R chunk:
            for each tuple s in the current S page:
                if condition_θ(r, s) is true:
                    add (r, s) to output buffer

成本分析:

  • 扫描一次 R: 成本 = [R]
  • 对于每 (B-2) 页的 R 块,扫描一次 S: 需要扫描 S 的次数 = ceil( [R] / (B-2) )
  • 总成本 = [R] + ceil( [R] / (B-2) ) * [S]

代入示例,假设 B=102:1000 + ceil(1000/100) * 500 = 1000 + 10 * 500 = 6,000 次 I/O。相比简单嵌套循环,性能提升了数个数量级。

块嵌套循环连接是处理非等值连接时常用的可靠算法。


索引嵌套循环连接

当连接条件是等值连接(如 R.id = S.id)且内关系 S 在连接属性上有索引时,可以使用此算法。

算法描述:

for each tuple r in R:
    use index on S to find all tuples s where s.id = r.id
    for each matching tuple s:
        add (r, s) to output buffer

成本分析:
成本 = 扫描 R 的成本 + 对每个 R 元组进行索引查找的成本。

  • 扫描 R: [R]
  • 索引查找成本: 对于每个 R 元组,成本包括:
    1. 遍历索引(B+树)到叶节点的成本(通常 2-4 次 I/O)。
    2. 根据索引指针(记录ID)获取实际数据页的成本。这取决于索引类型:
      • 聚簇索引:同一键值的元组物理上相邻,I/O 成本较低。
      • 非聚簇索引:可能需要为每个匹配的元组执行一次 I/O,如果匹配元组很多,成本会很高。

总成本约为:[R] + |R| * (索引遍历成本 + 取数据成本)

索引嵌套循环连接在匹配元组较少时非常高效,但当内关系匹配元组很多且索引非聚簇时,性能可能下降。


排序合并连接

该算法适用于等值连接。它首先对两个输入关系按连接键进行排序,然后像合并两个有序列表一样进行连接。

算法核心挑战:一个 R 元组可能匹配多个 S 元组,反之亦然。因此,合并过程比简单的归并排序更复杂,需要能够“回溯”。

算法直觉

  1. 排序阶段:分别对 R 和 S 按连接键排序。如果输入已有序(例如来自上游排序操作或索引扫描),则可跳过此阶段。
  2. 合并阶段
    • 用两个指针分别指向 R 和 S 的当前元组。
    • 比较两个指针所指元组的连接键。
    • 如果 R.key < S.key,则推进 R 的指针。
    • 如果 R.key > S.key,则推进 S 的指针。
    • 如果 R.key == S.key,则找到了一个匹配的“块”。此时需要:
      a. 标记 S 指针的当前位置(作为该匹配块的起点)。
      b. 输出 R 当前元组与 S 当前元组的连接结果。
      c. 推进 S 指针,继续输出 R 当前元组与 S 后续相同键值元组的连接结果。
      d. 当 S 指针越过该键值块后,将 S 指针重置回之前标记的起点。
      e. 推进 R 指针到下一个元组,重复比较过程。

标记的作用:当 R 中连续多个元组具有相同键值(如 R1.key=5, R2.key=5)时,对于 R1,我们遍历了 S 中所有键值为 5 的元组。当处理 R2 时,我们需要再次与 S 中所有键值为 5 的元组连接。标记让我们能快速将 S 指针重置到该键值块的起始位置,避免重新扫描 S。

排序合并连接在数据已排序或连接选择性较高时非常高效。其成本主要来自排序阶段(如果需要)和后续的线性扫描。


总结

本节课中我们一起学习了数据库查询执行的基石——迭代器模型,以及几种基础的连接算法。

  1. 迭代器模型:我们了解了查询计划如何通过实现了 init, next, close 方法的迭代器来执行。这种基于拉取的模型提供了良好的操作符封装性和组合性。
  2. 简单嵌套循环连接

课程 P11:迭代器与连接 II 🧩

在本节课中,我们将深入学习排序合并连接的成本分析,并探讨另一种强大的连接实现方式——哈希连接。我们还将简要介绍查询优化的基本概念,了解数据库系统如何选择最高效的执行计划。


排序合并连接的成本分析 💰

上一节我们介绍了排序合并连接的基本算法。本节中,我们来看看如何计算其执行成本。

排序合并连接的成本主要对应于对两个关系 RS 进行排序,然后按顺序读取它们以进行合并。我们通常不计算最终输出的写入成本。

以下是成本计算的核心部分:

  • 对关系 R 排序的成本:4 * |R|(两次读 + 两次写)
  • 对关系 S 排序的成本:4 * |S|
  • 合并连接阶段的成本:|R| + |S|(读取已排序的 RS

因此,排序合并连接的总成本公式为:

总成本 = 5 * |R| + 5 * |S|

其中 |R||S| 分别代表关系 RS 所占的页数。

最坏情况下,当连接键完全匹配(产生笛卡尔积)时,成本可能接近 |R| * |S|。但在实践中,这种情况很少见,通常成本是线性增长的。

关于缓冲区大小,为了能在两趟内完成排序,我们需要缓冲区页数 B 大于 sqrt(|R|)sqrt(|S|)


排序合并连接的优化 ✨

我们可以进一步优化排序合并连接,将排序的最后一次合并趟与连接趟结合起来。

这种优化需要满足一个条件:我们有足够的内存缓冲区来容纳 RS 的所有排序归并段(run)的代表页。这样,在最后一次合并排序的过程中,我们就可以同时进行连接操作。

优化后的成本变为:

优化后总成本 = 3 * (|R| + |S|)

这比标准的 5 * (|R| + |S|) 更为廉价。


哈希连接介绍 🔑

除了排序,哈希是另一种实现等值连接(如自然连接)的高效方法。哈希连接的目标与排序合并连接类似:将所有应该匹配的元组同时汇集到内存中。

一个简单的哈希连接算法要求较小的关系(假设为 R)能完全放入内存。其步骤如下:

  1. 将整个关系 R 读入内存,并基于连接属性构建内存哈希表。
  2. 逐页扫描关系 S,对于 S 中的每个元组,用相同的哈希函数计算其哈希值,并到内存哈希表中查找匹配的 R 元组。
  3. 如果找到匹配,则组合元组并输出。

该算法的内存要求是:关系 R 的大小必须小于 B - 2 页(B 为可用缓冲区页数),并考虑哈希表的填充因子。


Grace 哈希连接算法 🚀

当关系 R 太大而无法放入内存时,我们可以使用 Grace 哈希连接算法。该算法分为两个阶段:

第一阶段:分区

  • 使用一个哈希函数 h_p,分别将关系 RS 的元组散列到 B-1 个不同的磁盘分区中。
  • 具有相同哈希值的元组会被写入相同的分区。

第二阶段:连接(构建与探测)

  • 对于每一对对应的分区(如 R_iS_i):
    • 将分区 R_i 读入内存,并使用另一个哈希函数 h_r 构建内存哈希表。
    • 然后逐页读取分区 S_i,对于 S_i 中的每个元组,在内存哈希表中探测匹配的 R 元组并输出结果。

该算法的成本主要来自分区阶段和连接阶段的磁盘 I/O:

总成本 ≈ 3 * (|R| + |S|)

其核心要求是:每个分区 R_i 的大小必须能放入内存,即 |R| / (B-1) < B-2,推导可得 |R| < B^2。对于关系 S 的大小则没有限制。


连接算法对比与总结 📊

至此,我们一起学习了多种连接算法:

  • 嵌套循环连接:通用但效率低,适用于任意连接谓词。
  • 块嵌套循环连接:通过分块利用内存,减少 I/O。
  • 索引嵌套循环连接:在存在索引时能高效查找匹配元组。
  • 排序合并连接:适用于数据已排序或需要有序输出的场景,对数据偏斜不敏感。
  • (朴素)哈希连接:当较小关系能完全放入内存时非常高效。
  • Grace 哈希连接:可处理更大的关系,但需要多趟处理。

没有一种算法在所有情况下都是最优的。选择哪种算法取决于数据大小、是否已排序/散列、内存大小、是否存在索引以及数据分布等多种因素。这正是数据库查询优化器需要解决的复杂问题。


查询优化初探 🧠

最后,我们简要了解了查询处理的高层流程,为下一部分内容做铺垫。

当用户提交一个 SQL 查询后,数据库系统会依次进行以下处理:

  1. 查询解析:检查语法和权限,生成内部表示(如解析树)。
  2. 查询重写:将查询转换为更规范、更高效的形式(如视图展开、子查询转换)。
  3. 查询优化(核心):这是“魔法”发生的地方。优化器考虑所有可能的等价执行计划(查询计划),利用目录管理器中的统计信息估算每个计划的成本,并选择成本最低的一个。
  4. 查询执行:由查询执行器实例化优化器选定的计划,通过迭代器模型驱动数据流动,最终产生结果。

本节课中,我们深入分析了排序合并连接与哈希连接的原理与成本,并建立了对查询优化基本流程的认识。理解这些底层运算符的实现,是理解上层查询优化如何工作的基础。

课程13:查询优化成本与搜索 🧠

在本节课中,我们将学习查询优化的核心部分:如何估算不同查询计划的成本,以及如何在庞大的计划搜索空间中找到最优(或接近最优)的计划。我们将从回顾一个具体的查询计划及其成本计算开始,然后探讨如何使用索引来进一步降低成本。最后,我们将深入探讨查询优化器的工作原理,包括成本估算的多种方法和用于高效搜索的动态规划算法。


回顾查询计划与成本

上一节我们介绍了多种查询计划及其实现技巧。本节中,我们来看看一个结合了块嵌套循环、物化和谓词下推的优化计划,并计算其成本。

我们使用的查询涉及 ReservesSailors 两个关系。我们目前的最佳计划是:按 ReservesSailors 的顺序,应用选择谓词和投影,然后进行块嵌套循环连接,最后投影到 S.name

以下是该计划的成本计算过程:

  • 我们假设有5个缓冲区。
  • Reserves 关系有1000页。应用选择谓词 b=100 后,我们得到10页数据(假设有100艘船,每页100个元组)。
  • 投影操作后,每个元组仅剩ID属性(4字节),因此这10页数据被压缩为1页。
  • 对于块嵌套循环,我们以这1页为一块,扫描整个 Sailors 关系(500页)。成本为 1 * 500 = 500 I/O。
  • 总成本为扫描 Reserves1000 I/O 加上块连接的 500 I/O,即 1500 I/O

如果不使用索引,这几乎是最优计划,因为它必须访问两个关系的每一页(1000 + 500 = 1500)。


使用索引降低成本 🚀

上一节我们看到,仅扫描两个关系就有1500的成本。本节中,我们来看看如何通过使用索引来显著降低这个成本。

我们假设在 Reserves.bid 上有一个聚集索引,在 Sailors.sid 上有一个非聚集索引。

以下是使用索引嵌套循环连接的成本计算:

  • 使用 Reserves.bid 上的聚集索引直接找到 b=100 的元组。由于是聚集索引,这些元组连续存储在10页中。访问成本为 10 I/O
  • 对于找到的每一个 Reserves 元组(共1000个),使用 Sailors.sid 上的非聚集索引查找匹配的 Sailors 元组。每次查找的成本约为 1 I/O
  • 总成本为 10 + 1000 * 1 = 1010 I/O

通过使用索引,我们将成本从1500降低到了1010。这展示了索引在查询优化中的强大作用。


查询优化器概述 🤖

手动枚举和比较所有可能的查询计划是非常繁琐且容易出错的,尤其是当数据变化时。因此,数据库系统使用查询优化器来自动完成这项工作。

一个查询优化器通常需要以下组件:

  1. 计划空间:生成所有可能的查询计划(关系代数树及其物理实现)。
  2. 成本估算:估算每个计划的执行成本。
  3. 搜索算法:在庞大的计划空间中高效地找到成本最低的计划。

我们将重点介绍经典的 System R 优化器方法,它做出了许多简化但有效的假设。


成本估算基础 📊

为了比较不同计划的成本,我们需要估算每个操作符的输入、输出大小以及相应的I/O和CPU成本。我们已经了解I/O成本,现在重点是如何估算中间结果的大小。

估算依赖于数据库目录中维护的统计信息,例如每个表的页数、元组数、列的不同值数量等。

一个核心概念是选择性,它衡量一个谓词过滤掉多少数据。选择性定义为输出大小与输入大小的比值,范围在0到1之间。

注意:在数据库术语中,“高选择性”意味着比值接近1(即大部分数据通过),这与日常用语中的“选择性高”(指筛选严格)含义相反。

对于包含多个谓词的查询,我们通常假设各谓词之间是独立的,从而使用选择性相乘来估算整体选择性。


选择性估算公式

以下是几种常见谓词的选择性估算公式:

  1. 等值谓词(column = value

    • 假设数据均匀分布且无重复,选择性为 1 / NUM_KEYS(column)
    • 公式sel = 1 / N_keys
  2. 等值连接谓词(column1 = column2

    • 选择性为 1 / MAX(N_keys(column1), N_keys(column2))
    • 公式sel = 1 / MAX(N_keys1, N_keys2)
  3. 范围谓词(column > value

    • 假设数据在最小值(low)和最大值(high)之间均匀分布。
    • 选择性为 (high - value) / (high - low + 1)
    • 公式sel = (high - value) / (high - low + 1)

对于更复杂的谓词(如 AND, OR),在独立性假设下:

  • ANDsel = sel(p1) * sel(p2)
  • ORsel = sel(p1) + sel(p2) - sel(p1) * sel(p2)

使用直方图估算

当拥有直方图统计信息时,我们可以获得更精确的估算。例如,对于谓词 age < 26

  1. 找到 age 直方图中包含26的桶(例如桶范围为25-30)。
  2. 假设桶内值均匀分布,估算命中该桶的比例为 (26 - 25) / (30 - 25) = 0.2
  3. 该桶中的元组数占总数的比例乘以0.2,即为该桶的贡献。
  4. 将所有相关桶的贡献相加,得到总选择性。

对于连接谓词 column1 = column2,可以通过遍历两个直方图的桶,对匹配的值域计算选择性并求和。


搜索算法:动态规划 🔍

枚举所有计划(可能多达数百万个)是不可行的。System R 优化器采用动态规划算法,并应用启发式规则来缩减搜索空间。

主要启发式规则包括:

  • 只考虑左深连接树:连接树的右孩子总是基表。这有利于流水线执行。
  • 避免笛卡尔积:除非必要,否则不考虑产生笛卡尔积的计划。
  • 分块优化:将嵌套查询分解为独立的块进行优化。

动态规划算法步骤

该算法基于“最优子结构”假设:整体最优计划由子集的最优计划组成。

  1. 第一趟:为每个基表(单个关系)找到最佳访问路径(全表扫描、索引扫描等),并记录其成本和可能的有趣属性(如结果已排序)。
  2. 第二趟:考虑所有连接两个关系的方式。对于每一对关系,枚举所有连接方法(嵌套循环、哈希连接、排序合并连接等),并利用第一趟的结果计算成本。只保留每个关系子集的最佳计划(可能按不同有趣属性保留多个)。
  3. 后续趟:类似地,基于已找到的较小子集的最佳计划,构建更大子集(连接更多关系)的最佳计划。
  4. 最终:找到连接所有关系的最佳计划后,再考虑添加分组、聚合等操作。

通过只保留每个子集的最佳计划,动态规划极大地减少了需要存储和比较的计划数量。


算法的局限性

动态规划的“最优子结构”假设并非总是成立。例如,连接A和B的最佳方式,可能在连接A、B、C时并不是全局最优的,因为它可能没有产生对后续连接有利的排序属性。为此,算法需要为每个子集保留具有不同有趣属性(如已排序)的最佳计划,但这会增加存储开销。


总结 🎯

本节课中我们一起学习了查询优化的关键步骤:

  1. 成本估算:我们学习了如何通过选择性(使用公式或直方图)来估算查询中间结果的大小,并结合I/O模型计算计划成本。
  2. 索引的作用:我们看到合理使用索引可以大幅降低查询成本。
  3. 搜索策略:面对庞大的计划空间,我们介绍了System R优化器如何通过启发式规则(如左深树)和动态规划算法,高效地寻找近似最优的查询计划。

理解这些原理有助于我们设计更高效的查询,并理解数据库优化器背后的工作机制。

课程 P14:第14讲 事务与并发 I 🧩

在本节课中,我们将要学习数据库查询优化的收尾工作,并正式开启关于“事务”这一核心主题的讨论。我们将了解为什么需要事务,以及事务如何保证数据库在多用户并发访问和系统故障情况下的正确性与可靠性。


查询优化收尾 📊

上一节我们介绍了查询优化中成本估算和选择性因子的概念。本节中,我们来看看如何利用这些概念,通过动态规划算法高效地找到最优查询计划。

我们的目标是给定一个查询,找出执行它的最佳方式。“最佳”可以从时间、I/O、内存等多个维度衡量。本课程主要关注时间和I/O成本。

一个简单的优化器实现是枚举所有可能的计划,估算每个计划的成本,然后选择成本最低的。但问题在于可能的计划数量太多,无法全部枚举。因此,我们需要一种更智能的方法在计划空间中导航,而不是穷举所有计划。

成本估算回顾

查询操作符的成本由两部分组成:

  1. I/O成本:将数据从磁盘加载到主内存的成本。
  2. CPU成本:数据加载到内存后,处理元组所需的CPU时间。这部分成本可以估算为 需要处理的元组数量 × 一个CPU因子

为了计算成本,我们需要知道中间结果的大小。我们假设中间表的大小是输入大小乘以一个选择性因子。选择性因子是一个介于0和1之间的数。

以下是估算不同谓词选择性的方法:

  • 等值谓词(A = value)
    • 系统R方法选择性 = 1 / (A列的唯一值数量)
    • 直方图方法:找到包含value的桶,选择性 = (桶的高度) / (桶内不同值的数量)
  • 等值连接谓词(A = B)
    • 系统R方法选择性 = 1 / MAX(A列唯一值数量, B列唯一值数量)
  • 范围谓词(A > value)
    • 系统R方法选择性 = (范围内值的数量) / (整个范围的值数量 + 1)
    • 直方图方法:将所有满足条件的桶的高度相加,然后除以总行数。

对于更复杂的谓词(如AND, OR, NOT),我们可以基于独立性假设,使用概率公式组合基本谓词的选择性。

动态规划与最优性原则

为了快速找到最低成本的计划,我们采用动态规划算法,其核心是最优性原则:最优(成本最低)的查询计划由其子查询的最优计划构成。

这意味着,连接三张表(A, B, C)的最佳方式,要么是先以最佳方式连接A和B,再连接C;要么是先以最佳方式连接A和C,再连接B,等等。

算法步骤如下:

  1. 找出访问每个基表的最佳方式(如全表扫描或使用索引)。
  2. 基于已找到的最佳单表计划,找出连接任意两张表的最佳方式。
  3. 逐步扩展,找出连接更多表的最佳方式,每次都只保留成本最低的计划(除非该计划能产生“有趣的顺序”)。
  4. 最后,为整个查询树添加分组、聚合等操作符的最佳计划。

有趣的顺序

某些连接操作(如排序合并连接)会产生副产品,例如对结果按连接键排序。如果查询后续有ORDER BYGROUP BY子句,利用这个已排序的中间结果可以节省显式排序的成本。

因此,在动态规划表中,我们不仅记录计划成本,还记录它产生的“有趣的顺序”(如排序顺序)。这虽然增大了搜索空间,但可能找到整体更优的计划。这是一个典型的权衡:更精确的优化需要跟踪更多信息。


事务导论 🔄

现在,我们转换话题,开始讨论事务。这与我们之前讨论的查询处理与优化有本质不同。事务管理器是数据库系统的关键组件,负责处理多用户并发访问和系统故障,确保数据的正确性与持久性。

几乎所有现代服务(如社交媒体、银行应用、电商平台)都运行在数据库系统之上。除了执行单一查询,这些应用还需要数据库提供以下保证:

  • 原子性单元:将多个SQL语句(如转账涉及扣款和存款)作为一个不可分割的单元执行。
  • 并发控制:支持大量用户同时访问,且互不干扰。
  • 故障恢复:在系统崩溃、断电等故障后,能恢复到一个一致的状态。

并发访问的问题

如果没有事务管理,并发访问可能导致多种问题:

  • 丢失更新:两个事务同时读取并修改同一数据,其中一个的修改被覆盖而丢失。
  • 脏读:一个事务读取了另一个未提交事务修改的中间数据,后者随后回滚,导致前者读到了不存在的数据。
  • 不可重复读:同一事务内两次读取同一数据,结果不一致,因为中间被其他事务修改了。
  • 幻读:同一事务内两次执行相同查询,返回的结果集不同,因为中间有其他事务插入了新数据。

事务与ACID属性

事务是数据库系统提供的一种机制,它将一系列数据库操作(读/写)组合成一个逻辑工作单元。事务通过ACID属性来解决上述问题:

  • 原子性:事务中的所有操作要么全部完成,要么全部不完成。不存在中间状态。
  • 一致性:事务必须使数据库从一个一致的状态变换到另一个一致的状态。一致性通常由各种约束(如主键、外键)定义。
  • 隔离性:并发执行的事务彼此隔离,每个事务都感觉不到其他事务在同时执行。最终效果与串行执行这些事务相同。
  • 持久性:一旦事务提交,它对数据库所做的修改就会永久保存,即使系统发生故障。

应用程序通过BEGIN TRANSACTION、一系列SQL语句、COMMIT(提交)或ROLLBACK(回滚)来定义一个事务。

并发控制基础

隔离性是事务管理器通过并发控制来实现的。最简单的实现是串行执行所有事务,但这会严重降低吞吐量和资源利用率。我们的目标是允许操作交错执行,同时提供与串行执行相同的最终结果。

为此,我们形式化调度的概念。调度是多个事务操作的一个执行序列。

  • 串行调度:一个事务的所有操作完成后,再开始另一个事务的操作。这显然是正确的。
  • 可串行化调度:一个操作交错的调度,如果其最终效果等价于某个串行调度,则它是正确的。

判断两个调度是否等价,关键在于冲突操作的顺序。两个操作冲突,如果它们:

  1. 来自不同的事务。
  2. 操作同一个数据库对象(如元组、页面)。
  3. 至少有一个是写操作。

非冲突操作(如两个读操作)的交换顺序不会影响最终结果。因此,如果一个调度可以通过一系列交换非冲突操作,转换成某个串行调度,那么它就是可串行化的。我们将基于冲突的概念来构建更实用的并发控制协议(如下一讲将介绍的两阶段锁协议)。


本节课中我们一起学习了查询优化器的动态规划算法原理,并引入了数据库事务的核心概念。我们了解了为什么需要事务、ACID属性的含义,以及并发控制的基本目标——产生可串行化的调度。下一讲,我们将深入探讨实现可串行化的具体技术。

📚 课程 P15:第15讲 事务与并发 II

在本节课中,我们将继续学习数据库事务的隔离性保证。我们将深入探讨调度的正确性概念,特别是可串行化及其更实用的变体——冲突可串行化。我们还将了解如何通过两阶段锁定协议来实现这些概念,并讨论死锁的避免与检测方法。


🔍 回顾:调度与可串行化

上一节我们介绍了事务调度的基本概念。调度是一个或多个事务对数据项的操作序列。为了定义正确性,我们以串行调度为基准,即事务完全按顺序执行,互不干扰。

如果两个调度包含相同的事务集,且每个事务内部的操作顺序相同,并最终使数据库达到相同状态,则这两个调度是等价的。

一个调度被认为是可串行化的,如果它等价于某个串行调度。这意味着即使操作交错执行,其最终效果也与完全隔离的串行执行相同。


⚖️ 冲突可串行化

检查一个调度是否可串行化是困难的。因此,我们引入一个更保守但更易检查的概念:冲突可串行化

首先,我们定义冲突操作。如果两个操作满足以下条件,则它们冲突:

  1. 来自不同的事务。
  2. 操作同一个数据对象。
  3. 至少有一个是写操作。

冲突操作会引入事务间的排序依赖。非冲突操作的顺序交换不会影响数据库的最终状态。

基于此,我们定义冲突等价:如果两个调度涉及相同的事务和操作,且每一对冲突操作的执行顺序都相同,则它们冲突等价。

一个调度是冲突可串行化的,如果它与某个串行调度冲突等价。冲突可串行化是可串行化的一个子集,它更保守,但更容易通过算法进行检查。


如何检查冲突可串行化?

检查冲突可串行化的一个有效方法是构建优先图(或称冲突依赖图)。

以下是构建步骤:

  1. 为调度中的每个事务创建一个节点。
  2. 对于每一对冲突操作 Oi(来自事务 Ti)和 Oj(来自事务 Tj),如果 Oi 在调度中先于 Oj 执行,则添加一条从 Ti 指向 Tj 的有向边。

定理:一个调度是冲突可串行化的,当且仅当其优先图是无环的。

如果图是无环的,我们可以通过拓扑排序得到一个等价的串行调度顺序。


👁️ 视图可串行化

冲突可串行化虽然易于检查,但会排除一些实际可串行化的调度(假阴性)。视图可串行化是一个更宽松、假阴性更少的概念。

两个调度是视图等价的,如果满足:

  1. 每个事务读取的每个数据的初始值相同。
  2. 如果事务 Ti 在调度 S1 中读取了由事务 Tj 写入的值,那么在 S2 中 Ti 也必须读取由 Tj 写入的值。
  3. 每个事务对每个数据的最终写入相同。

视图可串行化包含了所有冲突可串行化的调度,并且允许一种称为“盲写”的操作(即写入一个值但后续没有任何事务读取它)。然而,视图可串行化的检查非常复杂,在实践中难以实现。


🔒 基于锁的实现

为了保证冲突可串行化,数据库系统常使用。基本思想是:事务在访问数据项前必须先获得其锁,访问完成后释放锁。

一个简单的锁协议是:每个数据项有一个唯一的锁,事务必须获取锁才能读写,如果锁被占用则等待。

然而,简单的加锁/解锁并不能保证冲突可串行化,不当的锁释放时机可能导致不可恢复的调度级联中止


两阶段锁定协议

为了解决上述问题,我们使用两阶段锁定协议

规则:在事务中,所有的锁请求必须在任何锁释放之前完成。这意味着事务有一个“增长阶段”(不断加锁)和一个“收缩阶段”(开始释放锁)。

2PL 能保证冲突可串行化。其证明思路是:如果存在一个非冲突可串行化的调度,那么在其优先图中必然存在环,而这与 2PL 的规则相矛盾。


严格两阶段锁定协议

为了进一步避免级联中止,我们使用严格两阶段锁定协议

规则:事务持有的所有排他锁(用于写操作)必须在事务提交或中止后才能释放。

这确保了其他事务不会读到未提交的、可能被回滚的数据(脏读),从而使调度不仅是冲突可串行化的,也是可恢复的。


⚠️ 死锁

使用锁会引入死锁问题,即两个或更多事务相互等待对方持有的锁,导致所有事务都无法继续执行。

以下是处理死锁的几种策略:

1. 死锁预防

通过强制规定锁的获取顺序来预防死锁。一种常见方法是基于事务的“时间戳”或“年龄”。

  • 等待-死亡方案:如果较老的事务请求较新事务持有的锁,则等待;如果较新的事务请求较老事务持有的锁,则较新事务中止。
  • 伤害-等待方案:如果较老的事务请求较新事务持有的锁,则较新事务中止;如果较新的事务请求较老事务持有的锁,则等待。

这些方案通过打破循环等待的可能性来预防死锁。

2. 死锁检测与恢复

如果允许死锁发生,系统需要能够检测并恢复。

  • 检测:周期性地构建等待图。图中的节点是事务,如果事务 Ti 等待事务 Tj 释放锁,则添加一条从 TiTj 的边。如果图中存在环,则说明发生了死锁。
  • 恢复:检测到死锁后,选择环中的一个事务作为“牺牲品”,将其中止并回滚,以释放其持有的锁,从而打破死锁。

🔧 锁管理器与锁粒度

在数据库系统内部,由锁管理器负责管理所有的锁请求和释放。它维护着一个数据结构(如哈希表),记录每个数据对象上的锁信息,包括锁模式、持有者列表和等待队列。

另一个重要概念是锁粒度,即锁定的数据单元大小。

  • 细粒度锁(如行级锁):允许高并发,但管理开销大。
  • 粗粒度锁(如表级锁或数据库级锁):管理开销小,但严重限制并发。

为了兼顾两者,数据库系统通常支持多粒度锁定,允许事务在不同层级(如数据库、表、页、行)上获取锁,并使用意向锁来高效地协调不同粒度锁之间的兼容性。


📝 总结

本节课我们一起深入学习了事务并发控制的核心机制:

  1. 我们明确了可串行化是调度的正确性标准,并引入了更实用的冲突可串行化概念及其检查方法(优先图)。
  2. 我们探讨了如何使用来实现隔离性,并通过两阶段锁定协议及其严格变体来保证冲突可串行化和可恢复性。
  3. 我们分析了使用锁可能导致的死锁问题,并讨论了死锁的预防检测/恢复策略。
  4. 最后,我们简要介绍了数据库系统中锁管理器的作用和锁粒度的权衡。

理解这些概念是构建和运用健壮数据库系统的基石。在接下来的课程中,我们将继续探索事务管理的其他方面。

数据库系统原理 P16:第16讲 事务恢复 I 🔄

在本节课中,我们将要学习数据库系统中事务恢复的基础知识。我们将首先回顾并发控制中基于锁的实现,特别是两阶段锁协议及其变体,然后探讨事务可能中止的原因,并开始介绍恢复管理器如何确保事务的原子性和持久性。


回顾:基于锁的并发控制

上一节我们介绍了事务和可串行化的概念。本节中我们来看看如何通过锁机制来实现冲突可串行化。

锁意味着对一段数据(如数据库、表、记录)的独占访问权。为了简化,我们将锁操作抽象为两种:lockunlock

两阶段锁协议

为了保证冲突可串行化,我们引入了两阶段锁协议。该协议规定:对于每个事务,所有的锁请求必须在所有解锁请求之前。这可以防止某些调度问题。

然而,即使遵守2PL,仍可能出现“不可恢复调度”问题。例如,一个事务中止后,另一个已提交的事务可能读取了其未提交的数据。

为了解决这个问题,我们引入了严格两阶段锁协议。该规则要求所有的解锁操作必须与事务的提交或中止操作一同进行。这确保了已提交的事务不会读取到未提交的数据。

死锁问题

坚持严格2PL规则可能导致死锁,即多个事务相互等待对方释放锁,都无法继续执行。

以下是处理死锁的几种方法:

  1. 预防:为所有锁对象设定一个任意的全局顺序,事务必须按此顺序获取锁。这可以避免循环等待,但在数据库环境中难以实施。
  2. 检测与处理:系统维护一个“等待图”来检测死锁循环。一旦检测到,可以选择中止某个事务来打破循环。常用策略有:
    • 等待-死亡:较老的事务等待较新的事务;若较新的事务需要等待较老的事务,则较新的事务“自杀”(中止并重启)。
    • 伤害-等待:较新的事务等待较老的事务;若较老的事务需要等待较新的事务,则较老的事务“伤害”(中止)较新的事务。

锁的粒度与效率

只使用一种粒度的锁(如整个数据库或单个元组)会在并发性和开销之间产生矛盾。为了提高效率,我们引入了多粒度锁层次结构(例如:数据库 -> 表 -> 页 -> 元组)。

在层次结构中,锁定一个高层节点意味着隐式锁定了其所有后代节点。为了协调不同事务在不同粒度上的操作,我们引入了意图锁

  • IS锁:意图共享锁,表示将在后代节点上加S锁。
  • IX锁:意图排他锁,表示将在后代节点上加X锁。
  • SIX锁:共享+意图排他锁,表示当前持有该节点的S锁,并可能在后代节点上加X锁。

获取锁时必须遵循从根到叶的顺序:在获取某个节点的S或X锁之前,必须先在其所有祖先节点上获得相应的意图锁。释放锁时则按相反顺序进行。

锁的兼容性由锁兼容性矩阵决定。例如,IS锁和IX锁在页面级别是兼容的,因为这意味着两个事务可以在同一页的不同元组上分别进行读和写操作。

幻读问题

当事务涉及插入或删除操作时,即使调度是冲突可串行化的,也可能出现“幻读”问题。即一个事务两次执行相同查询,中间由于另一个事务的插入操作而看到了新的“幻影”元组。

解决幻读的代价较高,常见方法包括:

  • 锁住整个表(简单但并发性差)。
  • 锁住相关索引。
  • 使用谓词锁或间隙锁。

引入恢复机制

上一节我们讨论了如何通过锁来保证事务的隔离性。本节中我们来看看恢复管理器如何确保事务的原子性和持久性。

事务需要满足ACID属性:

  • 原子性:事务中的所有操作要么全部完成,要么全部不发生。
  • 一致性:事务必须使数据库从一个一致状态转变为另一个一致状态。
  • 隔离性:并发执行的事务互不干扰。
  • 持久性:一旦事务提交,其对数据库的修改就是永久性的。

恢复管理器主要负责保证原子性持久性,并在系统崩溃后,回滚未提交的事务,重现已提交的事务。

事务中止的原因

事务可能因以下原因中止:

  1. 显式中止:应用程序主动请求回滚 (ROLLBACK)。
  2. 违反完整性约束:如违反主键、外键约束,数据库系统自动中止事务。
  3. 并发控制:如死锁处理中,系统选择中止某个事务。
  4. 系统故障:在事务提交前系统崩溃,恢复后该事务将被中止。

SQL中通过 BEGIN TRANSACTION, COMMIT, ROLLBACK 以及 SAVEPOINT 来控制事务。

为什么需要恢复?—— 系统崩溃

数据库可能因多种原因崩溃:

  • 操作错误:如管理员错误配置、意外断电。
  • 软件故障:数据库系统或操作系统本身的Bug。
  • 硬件故障:如内存错误、磁盘损坏。

恢复的挑战在于数据的修改发生在内存(缓冲池)中,而持久化存储位于磁盘。内存中的数据在系统崩溃时会丢失,因此需要在磁盘上维护额外的信息(日志)来保证能恢复到一致状态。

我们假设恢复机制建立在严格的2PL和就地更新(数据在缓冲池中被直接修改并写回磁盘原位置)的基础上。

缓冲区管理器回顾

恢复操作的基本单位是磁盘页。缓冲区管理器管理着内存中的缓冲池,池中包含多个帧,每个帧可容纳一个磁盘页。

涉及的基本操作有:

  • INPUT(A):将磁盘页A读入内存的缓冲帧。
  • READ(X, t):从缓冲池中读取数据项X到程序变量t。
  • WRITE(X, t):将程序变量t的值写入缓冲池中的数据项X。
  • OUTPUT(A):将内存中已修改的页A写回磁盘。

正是内存(易失性)和磁盘(非易失性)存储之间的这种差异,使得恢复机制变得必要且复杂。


总结

本节课中我们一起学习了事务恢复的基础。我们首先回顾了基于锁的并发控制,包括两阶段锁、死锁处理以及多粒度锁机制。然后,我们探讨了事务可能中止的各种原因,并引出了恢复管理器的作用——确保事务的原子性和持久性。最后,我们分析了为什么需要恢复机制,并回顾了缓冲区管理的基本操作,为下一讲深入探讨具体的恢复算法(如日志技术)奠定了基础。

恢复机制是数据库系统稳定性的基石,它确保即使在发生故障时,数据库也能保持一致和可靠的状态。

数据库恢复机制教程(P17)🔧

在本教程中,我们将学习数据库恢复机制的核心概念。恢复是确保数据库在发生故障(如系统崩溃、硬件错误等)后,仍能保持数据一致性和持久性的关键技术。我们将重点探讨如何保证事务的原子性和持久性,并介绍几种关键的日志恢复策略。


1. 恢复的动机与目标 🎯

上一节我们介绍了恢复的重要性,本节中我们来看看恢复机制需要达成的具体目标。

数据库可能因多种原因出错,包括操作员错误、配置错误、软件故障或硬件故障。此外,有时也需要回滚事务,例如在违反完整性约束时。恢复机制旨在从这些故障中恢复,并保证以下两个核心属性:

  • 原子性:事务的所有操作要么全部完成,要么全部不发生。公式表示为:事务T的效果 = 全部出现 OR 完全不出现
  • 持久性:一旦事务提交,其对数据库的更改必须是永久性的,即使后续发生系统故障。

我们将假设数据库使用严格的两阶段锁定(Strict 2PL)进行并发控制,并且更新是就地更新的,即数据页在缓冲池中被直接修改后写回磁盘。


2. 基本操作与问题示例 ⚙️

理解了目标后,我们需要明确恢复过程中涉及的基本操作。以下是四个核心操作:

  • read(X, t):将数据项X的值读入事务的局部变量t。
  • write(X, t):将事务局部变量t的值写入数据项X。
  • fetch(X):将包含数据项X的磁盘页面读入内存缓冲池。
  • flush(X):将包含数据项X的缓冲池页面写回磁盘。

考虑一个简单事务T:a=8, b=8,它执行 a = a*2; b = b*2;。期望结果是:若T提交,则 (a=16, b=16);若T未提交,则 (a=8, b=8)

以下是操作顺序示例:

  1. fetch(A)
  2. read(A, t); t = t*2; write(A, t)
  3. fetch(B)
  4. read(B, t); t = t*2; write(B, t)
  5. flush(A)
  6. flush(B)
  7. commit

如果在 flush(A)flush(B) 之间发生崩溃,磁盘状态将为 (A=16, B=8),这违反了原子性。如果在 flush(B) 之后、commit 之前崩溃,磁盘状态为 (A=16, B=16),但事务并未提交,这同样违反了原子性。这说明了在内存与磁盘之间,以及多个操作之间存在不连续性,是恢复需要解决的核心挑战。


3. 预写日志(WAL)—— 核心解决方案 📝

上一节我们看到直接管理数据页刷盘时机非常棘手,本节中我们来看看引入日志这一强大工具如何解决问题。

核心思想是预写日志:在将数据页的更改实际写入磁盘之前,先将这些更改描述记录到一个独立的、仅追加的日志文件中。这个日志文件通常存储在与数据文件不同的磁盘上。

日志记录事务的所有重要操作(如开始、更新、提交、中止)。系统崩溃后,通过重播日志,可以:

  • 重做已提交事务的更改(确保持久性)。
  • 撤销未提交事务的更改(确保原子性)。

使用WAL还有性能优势:

  1. 日志是顺序写入,比数据页的随机写入快得多。
  2. 日志可以非常紧凑,只记录数据项的增量变化(旧值和新值),而非整个页面。

4. 缓冲区管理策略与日志类型 🔄

预写日志是基础,但具体如何与缓冲区管理策略结合,衍生出了不同的日志恢复方案。以下是两个关键策略决策:

决策一:Steal / No-Steal(影响原子性)

  • Steal策略:允许缓冲管理器将未提交事务修改过的“脏页”刷回磁盘。
  • No-Steal策略:不允许刷出未提交事务的脏页。

决策二:Force / No-Force(影响持久性)

  • Force策略:要求事务提交前,其所有更新必须已强制刷入磁盘。
  • No-Force策略:事务提交时,不强制其更新刷入磁盘。

不同的组合对应不同的日志机制和性能权衡:

策略组合 所需日志操作 性能特点 日志类型
Steal & No-Force 既需要Undo,也需要Redo 最佳(灵活,内存利用率高) Undo/Redo日志
Steal & Force 需要Undo,不需要Redo 较差(提交延迟高) Undo日志
No-Steal & No-Force 需要Redo,不需要Undo 较差(内存占用高) Redo日志
No-Steal & Force 不需要Undo,也不需要Redo 最差(性能差,且恢复困难) (不实用)

最常用且性能最优的组合是 Steal & No-Force,它对应Undo/Redo日志。接下来我们分别看看Undo日志和Redo日志的工作原理。


5. Undo日志(Steal & Force) ↩️

上一节我们了解了策略矩阵,本节我们深入看看Undo日志如何工作。Undo日志用于Steal & Force环境,其核心是记录数据项的旧值,以便在事务未提交时进行回滚。

一条更新日志记录格式为:<T, X, V>,表示事务T将数据项X的值改为了V(这里是旧值)。

恢复规则:

  1. Undo规则:如果事务T修改了X,那么日志记录 <T, X, old_value> 必须在 flush(X) 之前写入磁盘。
  2. Force规则:如果事务T提交,那么所有 flush(X) 操作必须在 commit 日志记录写入磁盘之前完成。

恢复过程(系统崩溃后):

  1. 从日志末尾开始向前扫描。
  2. 识别所有未出现 commit 记录的事务(即未完成事务)。
  3. 对于这些未完成事务的每一个 <T, X, old_value> 记录,将磁盘上的X值重新设置为 old_value
  4. 已提交的事务无需处理。

优点:提交后无需保留脏页在内存中。
缺点:提交延迟高,因为必须等待所有数据页刷盘。


6. Redo日志(No-Steal & No-Force) ↪️

与Undo日志相反,Redo日志用于No-Steal & No-Force环境,其核心是记录数据项的新值,以便在事务提交后重做其更改。

一条更新日志记录格式为:<T, X, V>,表示事务T将数据项X的值改为了V(这里是新值)。

恢复规则:

  1. Redo规则:如果事务T修改了X,那么日志记录 <T, X, new_value> 以及T的 commit 记录,必须在任何 flush(X) 之前写入磁盘。

恢复过程(系统崩溃后):

  1. 确定所有已提交的事务(日志中有 commit 记录)。
  2. 从日志开头开始扫描。
  3. 重做所有已提交事务的更新:对于每个 <T, X, new_value> 记录(且T已提交),将磁盘上的X值设置为 new_value
  4. 未提交的事务无需处理(因为No-Steal策略保证其脏页未刷盘)。

优点:提交速度快,无需等待数据页刷盘。
缺点:内存占用高,因为未提交事务的脏页必须一直保留在缓冲池中。


7. Undo/Redo日志与检查点 🗂️

Undo日志和Redo日志各有局限。理想的Undo/Redo日志结合了两者,并采用 Steal & No-Force 策略。其日志记录同时包含旧值和新值:<T, X, old_value, new_value>

它遵循两条核心规则,从而同时保证原子性和持久性:

  1. WAL规则:在修改磁盘上的数据页之前,其对应的日志记录必须已刷入磁盘。
  2. 提交规则:在事务提交之前,其所有日志记录(包括提交记录)必须已刷入磁盘。

然而,无论是Undo、Redo还是Undo/Redo日志,恢复时都可能需要扫描大量日志。检查点 是一种优化技术,用于限制需要扫描的日志范围。

简单检查点流程:

  1. 暂停接受新事务。
  2. 等待所有当前活跃事务完成。
  3. 将所有脏数据页和日志缓冲区强制刷入磁盘。
  4. 向日志中写入一条 CHECKPOINT 记录并刷盘。
  5. 恢复事务处理。

崩溃恢复时,只需找到最后一个 CHECKPOINT 记录,然后处理该点之后开始的那些事务即可。CHECKPOINT 之前的所有更改都已持久化,无需恢复。但“停止一切”的检查点方式代价高昂,因此实践中常使用模糊检查点,允许在创建检查点的过程中系统继续运行。


总结 📚

本节课中我们一起学习了数据库恢复机制。我们从保证事务原子性持久性的目标出发,分析了数据操作的不连续性带来的挑战。引入预写日志作为核心解决方案后,我们探讨了缓冲区管理的Steal/No-StealForce/No-Force策略如何与Undo日志Redo日志以及功能全面的Undo/Redo日志相结合。最后,我们了解了检查点技术如何优化恢复过程的性能。理解这些基础概念是掌握现代数据库恢复算法(如ARIES)的关键。

课程 P18:恢复 II(续) - 深入理解ARIES算法 🔄

在本节课中,我们将继续深入探讨数据库恢复机制,特别是如何优化恢复过程以避免扫描整个日志。我们将重点介绍ARIES算法,这是一种广泛使用的恢复协议,它通过引入检查点和新的数据结构来提升恢复效率。


概述 📋

上一节我们介绍了撤销日志和重做日志的基本概念。本节中,我们来看看如何通过ARIES算法来优化恢复过程,避免在崩溃后需要扫描整个庞大的日志文件。我们将学习检查点的概念、模糊检查点的实现,以及ARIES算法中用于跟踪状态的关键数据结构。


1. 回顾:预写日志与日志记录类型

在深入新内容之前,我们先简要回顾一下关键概念。

  • 预写日志(WAL):这是一个核心原则,意味着在实际修改数据库页面之前,必须先将对应的日志记录写入持久化存储。这确保了即使系统崩溃,我们也有记录可以用于恢复。
  • 日志记录类型
    • 更新记录:记录数据元素修改前的值(旧值)和修改后的值(新值)。
    • 事务状态记录:如 BEGINCOMMITABORT
    • 检查点记录:标记数据库的一个一致性状态点。
    • 补偿日志记录(CLR):一种特殊的记录,用于记录撤销操作,我们稍后会详细解释。

恢复的目标始终是:将数据库恢复到最近一次崩溃前的、一致的状态。无论是使用撤销还是重做机制,最终结果应该相同。


2. 检查点:缩小恢复范围 🎯

为了减少恢复时需要处理的日志量,我们引入了检查点的概念。

2.1 简单检查点(完全检查点)

一个简单的检查点过程如下:

  1. 停止接受新事务。
  2. 等待所有当前活跃事务完成(提交或中止)。
  3. 将所有脏页(修改过的内存页)刷新到磁盘。
  4. 在日志中写入一条 CHECKPOINT 记录。
  5. 恢复接受新事务。

在检查点时刻,数据库处于一个“干净”的状态:所有已提交事务的修改都已持久化,所有未完成事务的修改都已回滚。因此,恢复时,我们只需要处理最后一个检查点之后启动的事务。检查点之前的日志可以被安全地截断或删除。

问题:简单检查点需要“停止世界”,这在生产环境中是不可接受的。

2.2 模糊检查点

为了解决上述问题,ARIES使用了模糊检查点。它允许在创建检查点时,数据库继续正常运行(接受新事务,页面可能仍是脏的)。

为了实现模糊检查点,我们需要在检查点时刻保存一些内存中的关键信息到磁盘,以便恢复时重建状态。这引入了两个核心数据结构:


3. ARIES的关键数据结构 🗂️

为了支持模糊检查点和高效的恢复,ARIES维护了以下内存表:

3.1 事务表

跟踪每个活跃事务的状态和信息。

| 事务ID | 状态 (运行中/提交中/中止中) | lastLSN (最后一条日志记录的序列号) |
|--------|-----------------------------|-----------------------------------|
| 100    | 运行中                      | 101                               |
| 101    | 提交中                      | 105                               |
  • lastLSN:指向该事务产生的最新一条日志记录。这让我们能快速找到事务的最近操作。

3.2 脏页表

跟踪缓冲池中哪些页面是脏的,以及它们是何时变脏的。

| 页面ID | recLSN (恢复日志序列号) |
|--------|--------------------------|
| 5      | 102                      |
| 7      | 101                      |
  • recLSN:记录该页面本次在缓冲池驻留期间,第一次被修改时对应的日志序列号(LSN)。这是重做阶段的起点。

3.3 日志记录的新字段

除了事务ID和修改数据,ARIES的日志记录还包含:

  • prevLSN:指向同一事务产生的上一条日志记录的LSN。这构成了一个按事务组织的日志链表,使得回滚单个事务时无需扫描整个日志。
  • pageIDpageLSNpageLSN 记录在页面头中,表示最后一次修改该页面的日志记录的LSN

4. 运行时操作与日志记录 📝

现在,我们看看在事务正常执行时,这些数据结构是如何更新的。

以下是关键操作步骤:

  1. 事务开始:写入 BEGIN 日志记录,在事务表中创建新条目。
  2. 页面更新
    • 预写日志:首先写入包含新旧值的 UPDATE 日志记录,并获得一个LSN(例如102)。
    • 更新内存结构
      • 日志记录的 prevLSN 设为该事务的 lastLSN(例如101)。
      • 修改内存中的页面,并将页面的 pageLSN 设为当前LSN(102)。
      • 更新事务表的 lastLSN 为当前LSN(102)。
      • 如果该页面是第一次变脏,在脏页表中设置其 recLSN 为当前LSN(102)。
  3. 事务提交
    • 写入 COMMIT 日志记录并强制写日志。
    • 事务状态改为“提交中”。此时事务已认为提交成功。
    • 异步地将所有脏页刷新到磁盘后,写入 END 日志记录,并将事务状态改为“完成”。
  4. 事务中止/回滚
    • 写入 ABORT 日志记录。
    • 从事务表的 lastLSN 开始,沿着日志记录的 prevLSN 指针依次执行撤销操作。
    • 每撤销一个操作,就写入一条补偿日志记录(CLR)。CLR描述了“为撤销LSN为X的操作,我们做了什么”。它也包含一个undoNextLSN字段,指向下一个需要撤销的记录的LSN。
    • 所有操作撤销完毕后,写入 END 记录。
  5. 检查点过程
    • 写入 BEGIN_CHECKPOINT 记录。
    • 继续正常处理事务。
    • 稍后,将当前的事务表脏页表的内容写入日志。
    • 写入 END_CHECKPOINT 记录。


5. ARIES恢复算法三阶段 🚀

当崩溃发生时,ARIES通过以下三个阶段进行恢复:

5.1 第一阶段:分析

目标:确定崩溃时哪些事务是活跃的(需要撤销),以及哪些页面可能是脏的(需要重做)。同时,找到重做阶段的起点。

步骤

  1. 从最后一个 BEGIN_CHECKPOINT 记录找到对应的 END_CHECKPOINT 记录,加载其中保存的事务表脏页表副本。
  2. BEGIN_CHECKPOINT 开始向后扫描日志,直到日志末尾:
    • 遇到新事务的 BEGIN,将其加入事务表。
    • 遇到事务的 END,从事务表中移除该事务。
    • 遇到 UPDATE 记录,如果其页面不在脏页表中,则将其加入,并设置其 recLSN 为当前记录的LSN。
    • 更新每个事务的 lastLSN
  3. 分析阶段结束后,我们得到了:
    • 崩溃时的活跃事务表(需要撤销)。
    • 崩溃时可能为脏的页面表(需要重做)。
    • 重做起点:脏页表中所有 recLSN 的最小值。

5.2 第二阶段:重做

目标:将数据库重放到崩溃发生时的状态。我们重复历史,但只重做那些必要的操作。

步骤
从分析阶段找到的重做起点开始,正向扫描日志,直到末尾。
对于每一条 UPDATECLR 记录:

  1. 如果其页面ID不在分析后的脏页表中 → 跳过(该页修改已持久化)。
  2. 如果记录的LSN 小于 脏页表中该页面的 recLSN跳过(该修改在页面变脏之前)。
  3. 如果记录的LSN 小于等于 磁盘上该页面的 pageLSN跳过(该修改已应用于页面)。
  4. 否则,重新执行该操作:将记录中的“新值”应用到指定页面,并更新页面的 pageLSN 为当前记录的LSN。

注意:重做阶段会重做所有事务(包括后来中止的)的操作,以确保页面状态与崩溃前完全一致。

5.3 第三阶段:撤销

目标:回滚所有在崩溃时仍未完成(活跃)的事务,使数据库达到一致状态。

步骤

  1. 从分析阶段得到的活跃事务表中,找出每个事务的 lastLSN
  2. 将这些 lastLSN 放入一个“待撤销”队列。
  3. 循环处理,直到队列为空:
    • 取出队列中最大的LSN(即最近的操作)。
    • 找到对应的日志记录。
    • 如果它是 UPDATE 记录,则执行撤销(将旧值写回页面),并写入一条 CLR 记录。
    • 如果它是 CLR 记录,则其 undoNextLSN 字段指明了下一步该撤销什么。
    • 根据记录的 prevLSNundoNextLSN,将下一个需要撤销的LSN加入队列。
  4. 所有活跃事务回滚完毕后,恢复完成。

总结 🎓

本节课中,我们一起学习了ARIES恢复算法的核心思想与步骤。作为对传统恢复机制的优化,ARIES通过引入模糊检查点脏页表事务表以及补偿日志记录(CLR) 等机制,实现了高效且灵活的恢复。

其核心三阶段——分析重做撤销——确保了:

  1. 恢复时无需扫描全部日志。
  2. 重做阶段将数据库恢复到崩溃前的精确状态。
  3. 撤销阶段只回滚未完成的事务,并且通过CLR避免了重复撤销。

ARIES算法是现代数据库管理系统实现恢复功能的基石,理解它对于掌握数据库内核原理至关重要。

课程19:数据库设计之实体关系模型 🗺️

在本节课中,我们将学习数据库设计中的一个重要概念——实体关系模型。这是一种用于描述现实世界中数据及其关系的图形化工具,能帮助我们更直观地设计数据库结构。


概述:从恢复机制到数据库设计

上一节我们介绍了ARIES恢复协议中的模糊检查点概念。本节中,我们来看看数据库设计的起点——实体关系模型。


数据库设计工作流

设计一个数据库通常遵循一系列步骤。以下是典型的工作流程:

  1. 需求分析:确定用户需要什么,数据库需要捕获现实世界中的哪些方面和信息。
  2. 概念设计:将需求转换为实体关系图。
  3. 逻辑设计:将ER图转换为关系模型中的一系列表。
  4. 模式细化:通过规范化等技术改进关系模式,减少冗余。
  5. 物理设计:决定数据在磁盘上的存储方式,例如使用何种索引。
  6. 安全设计:定义谁可以访问数据以及可以进行何种操作。

数据模型与数据独立性

在深入ER模型之前,我们需要理解数据模型的概念。数据模型是描述数据的一组概念。

我们熟悉的关系数据模型,其主要概念是关系,即具有行和列的表。一个数据库系统通常包含多个层次:

  • 视图模式:用户或应用程序看到的虚拟表。
  • 概念模式:数据库中的全局逻辑结构,即关系集合。
  • 物理模式:数据的实际存储方式,如文件和索引。

数据库系统提供了强大的数据独立性概念:

  • 逻辑数据独立性:概念模式的改变不会影响视图模式。例如,将一个关系拆分为两个,只需重新定义视图查询,应用程序无需修改。
  • 物理数据独立性:物理存储方式的改变不会影响概念模式。例如,添加索引不会改变表的结构。

这些特性使得数据库和应用程序能够长期演进,而互不影响。


实体关系模型基础

实体关系模型比关系模型更接近人类的思维方式,虽然不直接在数据库中实现,但更容易用于初始设计。

实体与实体集

  • 实体:现实世界中的一个可区分的对象。
  • 实体集:具有相同属性的相似实体的集合,用矩形表示。
  • 属性:描述实体的特性,用椭圆形表示。
  • 主键:能够唯一标识实体集中每个实体的属性(组合),用下划线标出。

例如,一个“雇员”实体集可能包含属性:ssn(主键)、namelot

关系与关系集

  • 关系:两个或多个实体之间的关联。
  • 关系集:相似关系的集合,用菱形表示。
  • 角色:当同一实体集多次参与一个关系集时,为每次参与赋予的名称。
  • 关系的属性:关系本身也可以拥有属性。

例如,“工作”关系集连接“雇员”和“部门”实体集,并可以拥有“开始日期”这个属性。


约束:丰富ER图的语义

我们可以为关系添加约束,以捕获更多现实世界的规则。

映射基数约束

箭头表示“至多一个”的约束,定义了多对一或一对多的关系。

  • 从“部门”指向“经理”关系的箭头表示:每个部门至多有一名经理(多对一)。
  • 没有箭头则表示“多对多”关系。

参与约束

加粗线双线表示“至少一个”的约束,定义了完全参与。

  • 在“雇员”和“工作”之间的加粗线表示:每个雇员必须至少在一个部门工作。
  • 箭头 + 加粗线 组合表示“恰好一个”的约束。

弱实体集

有些实体不能单独通过自己的属性来唯一标识,必须依赖于另一个“所有者”实体。这称为弱实体集。

  • 加粗矩形表示弱实体集。
  • 加粗菱形表示其与所有者实体集的支持关系
  • 弱实体集通常有一个部分键(用虚线下划线标出),它与所有者实体集的主键结合,形成完整的主键。

例如,“家属”是一个弱实体集,它依赖于“雇员”实体集。一个家属由pname(部分键)和其所属雇员的ssn共同唯一标识。


将多元关系转换为二元关系

ER图支持多元关系,但有些工具只允许二元关系。我们可以通过创建弱实体集来将多元关系转换为多个二元关系。

例如,一个“购买”关系连接“产品”、“商店”、“人员”三个实体。我们可以创建一个“购买记录”弱实体集,然后分别建立它到产品、商店、人员的三个二元关系。“购买记录”的主键由这三个关联实体集的主键组合而成。


类层次结构

ER模型支持类似面向对象中的继承概念,称为特化/泛化

  • 超类:更一般的实体集。
  • 子类:更特殊的实体集,用三角形指向超类。
  • 子类继承超类的所有属性,并可以拥有自己额外的属性。
  • 一个实体可以同时属于多个子类。

例如,“产品”是超类,“软件产品”和“教育产品”是其子类。软件产品拥有额外的“平台”属性,教育产品拥有额外的“适龄群体”属性。一个实体可以既是软件产品又是教育产品。


总结

本节课我们一起学习了数据库设计的核心工具——实体关系模型。我们了解了如何用实体、属性和关系来描述现实世界的数据结构,并通过映射基数约束、参与约束、弱实体集和类层次结构来丰富模型的语义。ER模型作为概念设计的利器,为我们下一步将设计转化为具体的关系模式打下了坚实的基础。

课程 P2:SQL I (续) + SQL II 🗃️

在本节课中,我们将继续学习关系模型和SQL语言。我们将深入探讨SQL的数据定义语言(DDL)和数据操作语言(DML),包括如何定义表、键约束,以及如何编写查询来检索和操作数据。课程内容从回顾上节课的概念开始,逐步过渡到更复杂的查询操作,如聚合、分组和连接。


回顾与声明 📢

上一节我们介绍了关系模型的基本术语和SQL的初步概念。本节中,我们首先进行一些课程相关的声明。

办公时间和助教答疑已于本周开始,请查看课程网站上的日历。零号项目(环境设置)、第一次维生素测验和学前测验都将在本周截止。第一个真正的技术项目也已发布,请务必查看课程日历了解细节。

关于课堂规则,如果你觉得舒适,请随时打开视频。如果你想提问,请举手或在 Piazza 论坛上发帖。


关系模型与SQL DDL回顾 🔑

我们首先回顾关系模型和SQL的数据定义语言(DDL)。关系具有固定的模式等属性,并且我们拥有物理数据独立性,这意味着我们不关心数据在磁盘上的实际存储方式,只要公开了关系接口这一抽象层即可。

SQL语言包含两个主要部分:用于定义和修改模式的DDL,以及用于操作数据的DML。我们使用示例数据库进行说明,其中包含 Sailors(水手)、Boats(船只)和 Reserves(预订)三个表。

定义表与主键

我们使用 CREATE TABLE 语句来定义表。主键(Primary Key)是表中唯一标识每个元组的列或列组合。

CREATE TABLE Sailors (
    sid INTEGER PRIMARY KEY,
    sname VARCHAR(20),
    rating INTEGER,
    age INTEGER
);

每个表只能有一个主键。主键可以由多列组成,只要这些列的组合能唯一标识每个元组。

CREATE TABLE Reserves (
    sid INTEGER,
    bid INTEGER,
    day DATE,
    PRIMARY KEY (sid, bid, day)
);

在上面的 Reserves 表中,(sid, bid, day) 的组合被定义为主键。

外键约束

为了确保数据的一致性,例如确保 Reserves 表中的 sid 确实指向 Sailors 表中存在的水手,我们使用外键(Foreign Key)。

CREATE TABLE Reserves (
    sid INTEGER,
    bid INTEGER,
    day DATE,
    PRIMARY KEY (sid, bid, day),
    FOREIGN KEY (sid) REFERENCES Sailors(sid),
    FOREIGN KEY (bid) REFERENCES Boats(bid)
);

FOREIGN KEY 约束使用 REFERENCES 关键字,它指向被引用表的主键。尝试插入或更新违反外键约束的数据会导致错误。

关于外键的级联操作(如删除时级联删除相关记录)可以在表定义时指定,这为数据一致性管理提供了灵活性。


编写SQL查询:SELECT基础 🔍

现在,让我们开始编写查询来从表中检索数据。这是SQL数据操作语言(DML)的核心。

基本SELECT查询

最基本的查询是选择满足特定条件的所有行。

SELECT *
FROM Sailors S
WHERE S.age = 27;

这个查询返回 Sailors 表中所有年龄为27岁的水手的所有信息。* 表示返回所有列。FROM 子句指定了查询的表,WHERE 子句指定了过滤条件。

选择特定列

如果我们只想返回特定的列,比如水手的名字和评级,可以这样写:

SELECT S.sname, S.rating
FROM Sailors S
WHERE S.age = 27;

使用DISTINCT去除重复

DISTINCT 关键字用于去除结果中的重复行。它应用于整个选择列表的列组合。

SELECT DISTINCT S.sname, S.rating
FROM Sailors S;

这个查询返回所有唯一的 (sname, rating) 组合。

使用ORDER BY排序

我们可以使用 ORDER BY 子句对结果进行排序。默认是升序(ASC),也可以指定降序(DESC)。

SELECT S.sname, S.gpa, S.age*2 AS double_age
FROM Students S
ORDER BY S.gpa DESC, S.sname ASC, double_age;

可以按多个列排序,并可以混合升序和降序。注意,ORDER BY 可以引用选择列表中定义的别名(如 double_age)。

使用LIMIT限制返回行数

LIMIT 子句用于限制返回的行数,通常与 ORDER BY 一起使用以获取“前N个”结果。

SELECT S.sname, S.gpa
FROM Students S
ORDER BY S.gpa DESC
LIMIT 3;

如果不使用 ORDER BYLIMIT 只是随机返回指定数量的行,这通常不是想要的行为。


聚合函数与分组 📊

上一节我们学习了基本的检索和排序。本节中,我们来看看如何使用聚合函数对数据进行汇总,以及如何使用 GROUP BY 进行分组计算。

聚合函数

SQL提供了诸如 AVG(平均值)、SUM(求和)、COUNT(计数)、MAX(最大值)、MIN(最小值)等聚合函数。

SELECT AVG(S.gpa)
FROM Students S
WHERE S.dept = ‘Computer Science’;

这个查询计算计算机科学系学生的平均GPA。

GROUP BY分组

GROUP BY 子句用于将表中的行按一列或多列的值分成组,然后对每个组应用聚合函数。

SELECT S.dept, AVG(S.gpa)
FROM Students S
GROUP BY S.dept;

这个查询按系(dept)分组,并计算每个系的平均GPA。

HAVING子句过滤分组

WHERE 子句用于过滤行,而 HAVING 子句用于过滤分组。HAVING 子句中的条件通常涉及聚合值。

SELECT S.dept, AVG(S.gpa), COUNT(*)
FROM Students S
GROUP BY S.dept
HAVING AVG(S.gpa) > 3.0;

这个查询只返回平均GPA大于3.0的系。

重要规则:在包含 GROUP BY 的查询中,SELECT 子句中只能出现分组列或聚合表达式。HAVING 子句中只能引用分组列或聚合表达式。


多表连接(JOIN)🔗

到目前为止,我们的查询都只涉及单个表。然而,现实世界的问题通常需要组合多个表中的信息。这就是连接(JOIN)操作的作用。

连接的概念与笛卡尔积

从概念上讲,多表查询首先计算 FROM 子句中所有表的笛卡尔积(Cartesian Product),即每个表的每一行都与其它表的每一行进行组合。

SELECT *
FROM Sailors S, Reserves R;

这会产生一个巨大的中间结果,其中包含 SailorsReserves 所有可能的行组合。

使用WHERE子句进行等值连接

我们几乎从不想要完整的笛卡尔积。通常,我们使用 WHERE 子句来指定表之间的连接条件,最常见的是等值连接。

SELECT S.sid, S.sname, R.bid
FROM Sailors S, Reserves R
WHERE S.sid = R.sid;

这个查询找到了所有预订过船只的水手,并返回他们的ID、名字以及预订的船只ID。WHERE S.sid = R.sid 确保了只组合相关联的水手和预订记录。

使用别名与自连接

当需要将同一个表连接到自己时(自连接),必须使用别名来区分表的两个实例。

SELECT X.sname, Y.sname
FROM Sailors X, Sailors Y
WHERE X.age > Y.age;

这个查询找出所有成对的水手,其中第一个水手比第二个水手年长。

连接中的复杂条件

连接条件不限于相等,可以包含算术表达式、字符串比较等。

-- 算术表达式
SELECT S1.sname, S2.sname
FROM Sailors S1, Sailors S2
WHERE 2 * S1.rating = S2.rating - 1;

-- 字符串匹配 (LIKE 和 正则表达式 ~)
SELECT *
FROM Sailors
WHERE sname LIKE ‘B%’; -- 名字以B开头
SELECT *
FROM Sailors
WHERE sname ~ ‘^B.*’; -- 同上,使用正则表达式

多表连接与布尔连接词

可以连接两个以上的表,并在 WHERE 子句中使用 ANDORNOT 等布尔连接词组合多个条件。

SELECT S.sname
FROM Sailors S, Reserves R, Boats B
WHERE S.sid = R.sid
  AND R.bid = B.bid
  AND (B.color = ‘red’ OR B.color = ‘green’);

这个查询找到了所有预订了红色或绿色船只的水手名字。


SQL查询的概念性求值顺序 🧠

理解SQL查询的一种方式是遵循一个概念性的求值顺序。这不是数据库实际执行查询的方式(实际方式更高效),但它有助于我们理解和推理查询。

对于一个包含 SELECTFROMWHEREGROUP BYHAVINGORDER BYLIMIT 的查询,概念性顺序如下:

  1. FROM:计算所有表的笛卡尔积。
  2. WHERE:应用 WHERE 子句中的条件,过滤掉不满足条件的行。
  3. GROUP BY:将剩余的行按分组列的值分成组。
  4. HAVING:应用 HAVING 子句中的条件,过滤掉不满足条件的分组。
  5. SELECT:计算选择列表中的表达式(包括聚合),并只保留指定的列。
  6. DISTINCT:如果指定了 DISTINCT,则去除重复行。
  7. ORDER BY:对结果进行排序。
  8. LIMIT:限制返回的行数。

记住,SELECT 子句中的别名(如 double_age)在 WHEREGROUP BY 子句中是不可见的,因为它们是在 SELECT 阶段才被计算和命名的。但 ORDER BY 子句可以引用这些别名。


总结 🎯

本节课中,我们一起深入学习了SQL的核心操作。

我们首先回顾并巩固了如何使用DDL定义表、主键和外键,以确保数据的完整性和一致性。

接着,我们系统地学习了DML:

  • 使用 SELECT ... FROM ... WHERE 进行基本的数据检索和过滤。
  • 使用 DISTINCT 去除重复。
  • 使用 ORDER BYLIMIT 对结果进行排序和限制。
  • 使用 AVGCOUNTSUM 等聚合函数对数据进行汇总。
  • 使用 GROUP BY 对数据进行分组,并使用 HAVING 对分组结果进行过滤。

最后,我们探讨了SQL中最强大的功能之一——多表连接(JOIN)。我们学习了如何通过等值连接、自连接以及包含复杂条件的连接,从多个相关的表中组合信息以回答更复杂的问题。

理解SQL查询的概念性求值顺序对于编写和调试复杂查询至关重要。请记住,SQL是一种声明式语言,你只需描述你想要的结果,而由数据库系统来决定最有效的执行方式。

在接下来的课程中,我们将继续探索SQL的更多特性,并开始了解数据库系统是如何在底层实现这些高效操作的。

课程20:数据库设计 - 函数依赖与规范化 🗃️

在本节课中,我们将学习如何将概念性的实体关系(ER)模型转化为具体的关系模式,并探讨如何通过函数依赖和规范化理论来优化数据库设计,以消除冗余和更新异常。


从ER模型到关系模式

上一节我们介绍了ER模型的设计原则。本节中,我们来看看如何将设计好的ER图转化为实际的关系模式。

将ER图转化为关系模式需要处理不同的情况。以下是一些核心的转换规则:

1. 实体集的转换

一个具有属性的实体集可以直接转换为一个关系。实体的主键成为关系的主键。

CREATE TABLE Product (
    product_id INT PRIMARY KEY,
    name VARCHAR(255),
    price DECIMAL
);

2. 关系集的转换

对于连接两个实体集的关系,通常创建一个新的关系表。该表的主键是所连接实体主键的组合。

CREATE TABLE Ships (
    order_id INT,
    shipper_name VARCHAR(255),
    PRIMARY KEY (order_id, shipper_name),
    FOREIGN KEY (order_id) REFERENCES Orders(order_id),
    FOREIGN KEY (shipper_name) REFERENCES Shippers(name)
);

3. 处理约束:一对一与一对多

对于“每个部门至多有一个经理”这类一对多关系,有两种建模方法:

  • 方法一:创建独立的Manages关系表,使用dept_id作为主键。
  • 方法二:将部门信息与经理信息合并到一张表中,并包含一个指向员工的外键。

选择哪种方法取决于具体的应用场景和查询需求。

4. 处理子类(继承)

对于具有继承层次的实体(如ProductSoftwareProductEducationalProduct的父类),一种常见的方法是采用“每个实体一张表”的策略。

CREATE TABLE Product (name VARCHAR(255) PRIMARY KEY, price DECIMAL);
CREATE TABLE SoftwareProduct (name VARCHAR(255) PRIMARY KEY, platform VARCHAR(255), FOREIGN KEY (name) REFERENCES Product(name));
CREATE TABLE EducationalProduct (name VARCHAR(255) PRIMARY KEY, subject VARCHAR(255), FOREIGN KEY (name) REFERENCES Product(name));

5. 处理弱实体

弱实体(如Dependent依赖于Employee)必须与所有者实体的主键结合才能唯一标识。在转换时,弱实体关系的主键是所有者实体主键与弱实体部分键的组合。

CREATE TABLE Dependents (
    essn INT,
    dependent_name VARCHAR(255),
    PRIMARY KEY (essn, dependent_name),
    FOREIGN KEY (essn) REFERENCES Employees(ssn) ON DELETE CASCADE
);

注意外键约束中的ON DELETE CASCADE,它确保了当员工被删除时,其所有家属信息也会被自动删除。


模式细化与函数依赖

将ER图转化为初始关系模式后,我们可能发现设计存在冗余或异常。本节中,我们引入函数依赖的概念,作为分析和改进模式设计的工具。

什么是函数依赖?

函数依赖描述了关系中属性之间的约束关系。如果知道一组属性A的值,就能唯一确定另一组属性B的值,则称B函数依赖于A,记作 A → B

公式定义:对于关系R中的任意两个元组t1和t2,如果 t1[A] = t2[A],则必有 t1[B] = t2[B]

函数依赖的推理:阿姆斯特朗公理

给定一组已知的函数依赖,我们可以推导出其他隐含的依赖。阿姆斯特朗公理是推理的基础:

  1. 自反律:若Y ⊆ X,则 X → Y
  2. 增广律:若 X → Y,则 XZ → YZ
  3. 传递律:若 X → YY → Z,则 X → Z

属性的闭包与超键

  • 属性集闭包:在给定函数依赖集F下,属性集X的闭包 X+ 是所有能由X推导出的属性集合。
  • 超键:如果属性集K的闭包包含了关系的所有属性,则K是一个超键。
  • 候选键:最小的超键(即其任何真子集都不是超键)。

规范化:BCNF范式

为了系统地消除冗余和异常,我们使用规范化理论。本节重点介绍最常用的Boyce-Codd范式(BCNF)。

BCNF的定义

一个关系模式R属于BCNF,当且仅当对于其每一个非平凡的函数依赖 X → YX都是R的一个超键

  • “非平凡”指Y不是X的子集。

BCNF分解算法

如果一个模式不属于BCNF,我们可以通过以下步骤将其分解:

  1. 找出一个违反BCNF的非平凡函数依赖 X → Y(即X不是超键)。
  2. 将原关系R分解为两个关系:
    • R1 = X ∪ Y
    • R2 = R - Y
  3. 检查R1和R2是否满足BCNF,若不满足,则递归分解。

关键启发式:在分解时,尽量让函数依赖的右边(Y)包含尽可能多的属性,这有助于减少不必要的分解步骤。

BCNF分解示例

假设有关系 StuInfo(SID, name, phone, city),存在函数依赖 SID → city,但SID不是超键(因为它不决定phone)。

  1. 它违反了BCNF。
  2. 将其分解为:
    • R1(SID, city)
    • R2(SID, name, phone)
  3. 检查R1和R2,现在它们都满足BCNF条件。


总结

本节课中我们一起学习了数据库设计的关键步骤。

  1. 我们首先回顾了如何将ER模型转化为关系模式,并处理了实体、关系、约束、子类和弱实体等不同情况。
  2. 接着,我们引入了函数依赖的概念,用于描述数据间的内在约束,并学习了通过阿姆斯特朗公理进行推理,以及如何计算属性闭包和超键。
  3. 最后,我们探讨了规范化的核心目标——消除冗余和异常,并详细讲解了BCNF范式的定义及其分解算法。一个好的BCNF设计能确保数据一致性,并简化更新操作。

通过结合ER建模的直观性与规范化理论的严谨性,我们可以设计出既符合业务需求又高效稳健的数据库模式。

课程21:并行查询处理 🚀

在本节课中,我们将学习数据库设计中的BCNF范式,并探讨并行查询处理的基本概念。我们将从BCNF的定义和分解算法开始,然后转向如何利用并行性来加速数据库操作。


BCNF范式分解 🧩

上一节我们介绍了规范化用于消除冗余和更新异常。本节中我们来看看Boyce-Codd范式。

BCNF规则指出:一个关系R在BCNF中,当且仅当对于每个非平凡的函数依赖 A1, A2, ..., An -> B,左侧属性集 {A1, A2, ..., An} 必须是关系R的超键

一个非平凡的函数依赖是指右侧属性B不在左侧属性集中。

为什么需要BCNF?

假设有一个关系R(A, B, C),其中键是B,但存在函数依赖 A -> C。由于A不是超键(它不能决定所有属性),这违反了BCNF。这会导致冗余:对于每个与A关联的B值,C的值都会被重复。

例如,函数依赖 SSN -> Name,但SSN不是超键(因为它不能决定PhoneNumber),这违反了BCNF。我们可以将其分解为两个关系:(SSN, Name)(SSN, PhoneNumber),两者现在都在BCNF中。

BCNF分解算法

以下是BCNF分解的步骤:

  1. 找到一个违反BCNF的函数依赖 A -> B
  2. 计算左侧A的闭包 A+(即由A决定的所有属性)。
  3. 将原关系R分解为两个关系:
    • R1 = A+(包含A和所有由A决定的属性)
    • R2 = (R - A+) ∪ A(包含A和原关系中其他所有属性)
  4. 分别在R1和R2上计算函数依赖,并递归地对它们应用此算法,直到所有关系都在BCNF中。

关键点:分解时,我们使用函数依赖的闭包(尽可能扩展右侧),这有助于避免不必要的细粒度分解。两个结果关系通过公共属性A重叠,这使得可以通过自然连接无损地重建原始关系。

一个复杂例子

考虑关系:Info(SSN, Name, Age, EyeColor, Phone, DraftEligible),具有两个函数依赖:

  1. SSN -> Name, Age, EyeColor
  2. Age -> DraftEligible

首先,SSN 的闭包是 {SSN, Name, Age, EyeColor, DraftEligible},不包括 Phone,因此 SSN 不是超键,违反了BCNF。

  • 第一步分解:使用 SSN -> SSN+。得到:
    • R1(SSN, Name, Age, EyeColor, DraftEligible)
    • R2(SSN, Phone)
  • 第二步分解:检查R1。Age -> DraftEligible 中,Age 不是R1的超键(它不能决定SSN等)。因此需要进一步分解R1:
    • R11(Age, DraftEligible)
    • R12(SSN, Name, Age, EyeColor)

最终得到三个BCNF关系:R11, R12, R2。

两个属性的关系

一个只包含两个属性A和B的关系R(A, B)总是在BCNF中。无论是否存在函数依赖 A->BB->A,或者两者都存在,都不会违反BCNF规则,因为单属性本身或两者都构成(候选)键。

算法总结与性质

BCNF分解算法能消除由函数依赖引起的冗余,并且分解是无损的,可以通过自然连接重建原关系。

然而,BCNF分解可能不是唯一的,选择不同的违反函数依赖进行分解,可能得到不同的最终结果。此外,BCNF可能无法保留所有函数依赖,使得某些依赖在分解后难以强制执行。

在性能上,BCNF减少了冗余,但查询时可能需要连接操作,这会增加开销。这是一种在空间(存储冗余)和性能(连接成本)之间的权衡。


并行查询处理 ⚡

上一节我们讨论了如何优化单个数据库模式。本节中我们来看看如何利用多台机器并行处理查询以提升性能。

为什么需要并行处理?

随着数据量增长,单机处理可能变得非常缓慢。例如,扫描100TB数据,假设磁盘读取速度为500MB/s,大约需要两天。如果使用100台机器并行扫描,时间可缩短至3-4分钟。

并行化的核心思想是将大任务分解为许多独立或尽可能独立的小任务,同时执行。

并行性类型

在并行数据库系统中,主要关注两种并行性:

  1. 流水线并行性:查询计划中的不同运算符同时处理数据流中的不同元组。例如,一个运算符在处理元组1时,下一个运算符可以同时处理已完成的元组2。
  2. 分区并行性:将数据划分为多个分区,相同的运算符实例在不同分区上并行执行。

此外,还有:

  • 查询间并行性:同时执行多个不同的查询。
  • 查询内并行性
    • 运算符间并行性:包括流水线并行性和丛生并行性(查询计划的不同子树并行执行)。
    • 运算符内并行性:即分区并行性,是提升单个运算符(如扫描、连接)速度的关键。

数据分区方法

为了实现分区并行性(运算符内并行),需要在多台机器间对数据进行分区。主要方法有三种:

以下是三种分区策略的对比:

  • 基于范围的分区
    • 描述:根据键值的范围分配数据(如A-E到机器1,F-J到机器2)。
    • 访问局部性:优秀。支持高效的范围查询。
    • 负载均衡:可能较差。数据分布可能不均匀。
  • 基于哈希的分区
    • 描述:对键应用哈希函数,根据哈希值分配数据。
    • 访问局部性:良好。支持高效的等值查询。
    • 负载均衡:通常较好。
  • 轮询分区
    • 描述:按顺序将元组循环分配给所有机器。
    • 访问局部性:差。元组随机分布。
    • 负载均衡:优秀。数据均匀分布。

在无共享架构中,网络通信是主要开销。因此,选择与查询工作负载匹配的分区方式至关重要,可以最小化机器间的数据传输。

并行连接操作示例:哈希连接

并行哈希连接可以高效地进行。其步骤类似于单机版本,但增加了数据重分布(洗牌)阶段:

  1. 重分区阶段:使用哈希函数 h_n 将关系R和S的元组重新分布到多台机器上。确保具有相同哈希值的元组到达同一台机器。
  2. 本地连接阶段:每台机器在接收到属于它的R和S的分区后,独立地使用单机哈希连接算法(构建本地哈希表,然后探测)完成连接。

这种方法提供了近乎线性的加速比和扩展性。需要注意的挑战包括处理数据倾斜(某些机器接收过多数据)以及同步各机器阶段进度。


总结 📚

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

  1. BCNF范式:一种通过分解关系来消除函数依赖冗余的规范化形式。我们掌握了其定义、分解算法以及该方法的优缺点(如无损分解但可能不保留所有依赖)。
  2. 并行查询处理:介绍了利用多台机器加速查询的基本原理。我们了解了流水线并行性和分区并行性,探讨了基于范围、哈希和轮询的数据分区策略及其权衡,并以并行哈希连接为例说明了运算符内并行的实现方式。

理解这些概念有助于设计更高效的数据库模式,并利用现代硬件并行能力构建可扩展的高性能数据库系统。

课程22:数据复制和分布式事务 I 🧩

在本节课中,我们将要学习在并行数据库上运行事务时会发生什么。这是一个非常有趣的话题,我们将探讨如何利用多台机器来提升性能,并深入理解分布式环境下的数据处理挑战。


复习:并行处理与数据分区

上一节我们介绍了并行处理的基本动机,即有效利用多台机器以避免资源浪费并提升性能。现在,我们来回顾一下并行计算中常见的架构和数据分区方法。

在并行计算中,主要有三种架构:

  • 共享内存架构:多个CPU或核心共享同一块RAM(主内存)。
  • 共享磁盘架构:每台机器有自己的RAM,但共享同一块磁盘。
  • 共享无架构:机器之间不共享内存或磁盘,所有通信和数据访问都通过网络进行。

本节课我们将重点讨论共享无架构

为了在多台机器间分配数据,我们讨论了三种主要的分区方案:

  • 范围分区:根据记录的某个属性(如姓氏首字母)设定范围,将不同范围的记录分配到不同机器。
  • 哈希分区:使用一个哈希函数处理每条记录,根据哈希结果决定记录存放的机器。
  • 轮询分区:在所有可用磁盘间轮流分配新到达的记录。

选择分区方案时,主要考虑两个因素:访问局部性负载均衡。轮询分区在负载均衡方面最有效,因为它能最公平地分配数据;但在访问局部性方面最差,因为很难预测特定记录在哪台机器上。相反,范围分区和哈希分区能提供更好的访问局部性。


并行关系操作符

在了解了数据如何分布后,本节我们来看看如何在多台机器上并行执行我们熟悉的关系操作,例如连接、排序和聚合。

并行连接操作

连接是数据库中最核心且复杂的操作之一。在并行环境中,我们有几种方法来实现它。

1. 朴素的并行哈希连接
这种方法的思想很简单:将其中一个关系(如R)通过网络“洗牌”分配到所有机器上,每台机器为其收到的部分数据构建一个内存哈希表。然后,将另一个关系(S)也使用相同的哈希函数进行洗牌。当S的数据到达各台机器时,只需在本地已构建好的哈希表中进行查找匹配,并输出结果。这个过程大部分步骤可以并行执行,但需要等待哈希表构建完成才能开始处理S的数据。

2. 并行Grace哈希连接
当数据量太大,无法在内存中构建完整的哈希表时,我们可以使用Grace哈希连接的并行版本。其步骤与单机版类似:

  • 首先,使用一个哈希函数将两个关系R和S的数据洗牌分配到所有机器上。
  • 然后,在每个节点上,对本地数据进行多趟处理,将哈希桶写入磁盘,直到每个桶小到可以放入内存。
  • 最后,从两个关系中读取对应的桶,在内存中执行连接操作。
    这个方案也实现了近乎完美的并行加速。

3. 其他连接变体

  • 单边洗牌连接:如果一个关系已经按照连接键分区好了,那么我们只需要对另一个关系进行洗牌即可。
  • 广播连接:如果一个关系非常小,可以完全放入单台机器的内存,那么可以将其复制(广播)到所有机器上,然后在每台机器上执行本地连接,避免了数据洗牌的开销。
  • 对称流水线哈希连接:这是一种流式处理算法,适用于无限数据流。它在每台机器上为两个关系各维护一个哈希表。当任一关系的数据到达时,既将其存入自己的哈希表,也用它去探测另一个关系的哈希表以产生连接结果。这种方法无需等待任何阶段完成,完全流水线化,但需要更多内存。

并行排序操作

并行排序的基本思路是:先根据某个属性范围将数据洗牌分配到各台机器,然后每台机器在本地对自己负责的数据范围进行排序。

  • 挑战:数据倾斜:如果数据分布不均匀,可能导致某些机器负载过重。为了解决这个问题,可以尝试先对数据采样,构建一个直方图来了解数据分布,然后根据直方图进行范围划分,以确保各机器负载均衡。
  • 排序合并连接:一旦两个关系都排序完毕,就可以并行执行排序合并连接。每台机器独立处理本地已排序的数据流,进行合并操作。

并行聚合操作

对于求和、计数、平均值等聚合操作,可以采用分层方法:

  1. 局部聚合:将数据分区后,每台机器先计算自己那部分数据的局部聚合结果(如局部和、局部计数)。
  2. 全局聚合:协调者收集所有机器的局部结果,进行合并计算,得到最终的全局聚合结果(如全局和、全局平均值)。

对于分组聚合,过程类似:先在各机器上进行本地分组聚合,然后根据分组键将中间结果洗牌到负责该键最终聚合的机器上,进行最终计算。


迈向分布式事务

到目前为止,我们学习了如何并行执行查询。然而,在分布式数据库中,我们还需要处理事务。如何保证分布在多台机器上的事务仍然满足ACID属性,是一个巨大的挑战。

分布式环境引入了新的复杂性:

  • 网络问题:延迟、消息丢失、乱序、网络分区。
  • 节点故障:参与事务的机器可能崩溃。
  • 协调困难:需要所有参与节点对事务的提交或中止达成一致。

分布式锁与死锁检测

在共享无架构中,锁通常与数据存放在一起,即每个节点管理自己数据上的锁。对于跨节点的“粗粒度”锁(如表锁),可以指定一个主节点来管理,或者在不同节点间分区管理。

分布式死锁检测比单机更复杂,因为等待图可能分布在多个节点上。一个常见的解决方案是:每个节点定期将自己的局部等待图发送给一个指定的协调者。协调者汇总所有局部图,构建全局等待图,并检测其中是否存在环(即死锁)。如果检测到死锁,协调者将决定中止哪个事务,并通知所有相关节点。

提交协议面临的挑战

每个事务通常有一个协调节点负责最终决定事务提交或中止。一个简单的“强人”策略是让协调者独自做决定,但这忽略了其他参与节点的意见。例如,某个节点可能因为违反完整性约束(如主键冲突)而希望中止事务。

因此,我们需要一个协议,让所有参与节点都能对事务结果进行“投票”,并最终达成一致。这就是著名的两阶段提交协议的基础思想。此外,我们还需要处理节点故障和消息延迟带来的不确定性,例如区分一个节点是崩溃了还是仅仅响应很慢。


总结

本节课中,我们一起学习了并行数据库的核心操作。我们回顾了数据分区策略,并深入探讨了如何在多台机器上并行执行连接、排序和聚合等关系操作。最后,我们将视角转向更复杂的分布式事务领域,初步了解了在分布式环境下管理锁、检测死锁以及达成事务提交共识所面临的基本挑战。在下一讲中,我们将深入探讨解决这些挑战的关键协议——两阶段提交。

课程 P23:分布式事务与两阶段提交协议 🧩

在本节课中,我们将学习分布式事务的核心概念,特别是两阶段提交协议。我们将探讨在分布式系统中如何协调多个节点以达成事务提交或中止的一致决定,并了解该协议如何与日志记录和恢复机制协同工作。


分布式提交的挑战

上一节我们介绍了分布式事务的基本概念。本节中我们来看看在多个节点间协调提交时面临的具体挑战。

在分布式系统中,一个事务可能涉及多个节点上的数据操作。协调器单方面决定提交事务是不可靠的,原因如下:

  • 节点故障:某个节点可能已经崩溃,无法响应。
  • 节点意愿:即使协调器想提交,某个节点可能因本地约束(如外键冲突)而希望中止事务。
  • 消息问题:消息可能丢失、延迟或乱序到达。

因此,我们需要一个分布式投票协议,确保所有参与节点都同意提交,事务才能最终提交。


两阶段提交协议介绍

为了解决上述挑战,我们引入了两阶段提交协议。该协议分为两个阶段,类似于婚礼仪式。

第一阶段:准备阶段

协调器询问所有参与者:“你是否准备好提交此事务?”

以下是该阶段的步骤:

  1. 协调器向所有参与者发送 PREPARE 消息。
  2. 每个参与者在本地评估事务。如果可以提交,则将 PREPARE 记录持久化到日志,然后向协调器回复 YES;否则回复 NO
  3. 协调器收集所有投票。

第二阶段:提交/中止阶段

协调器根据投票结果宣布最终决定。

以下是该阶段的步骤:

  1. 如果所有参与者都回复 YES,协调器则决定提交。它将 COMMIT 记录写入自己的日志并持久化,然后向所有参与者发送 COMMIT 消息。
  2. 如果有任何参与者回复 NO,或超时未回复,协调器则决定中止。它将 ABORT 记录写入日志,然后向所有参与者发送 ABORT 消息。
  3. 参与者收到决定后,执行相应操作(提交或中止),将结果(COMMIT/ABORT)记录到自己的日志并持久化,最后向协调器发送 ACK 确认。
  4. 协调器收到所有参与者的 ACK 后,写入 END 记录,事务完成。

核心流程公式化表示

协调器 -> 参与者: PREPARE
参与者 -> 协调器: YES/NO (投票)
if 所有投票 == YES:
    协调器 -> 参与者: COMMIT
    参与者 -> 协调器: ACK
else:
    协调器 -> 参与者: ABORT
    参与者 -> 协调器: ACK

与日志恢复的协同工作

两阶段提交协议必须与日志恢复机制(如 ARIES)结合,才能处理节点故障。关键点在于,在发送投票或确认之前,必须先将关键记录(PREPARE, COMMIT, ABORT刷新到磁盘,确保故障后能正确恢复。

恢复场景处理

每个节点(协调器或参与者)恢复时,会检查自己的日志:

以下是节点恢复时可能遇到的情况及处理逻辑:

  • 协调器日志有 COMMIT 记录:重新执行第二阶段,向参与者发送 COMMIT 消息。
  • 协调器日志有 PREPARE 记录,但无决定:单方面决定 ABORT
  • 参与者日志有 PREPARE 记录,但无 COMMIT/ABORT:向协调器查询事务状态,并根据协调器的回复执行相应操作。
  • 参与者日志无 PREPARE 记录:忽略此事务(视为从未发生)。

这种查询机制确保了即使协调器崩溃后恢复,整个系统也能达成一致状态。


与两阶段锁定的交互

在实际系统中,两阶段提交协议常与两阶段锁定协议一起使用,以确保可串行化。

为了安全地释放锁,我们需要确保节点在提交前已收到所有必要的消息。这通常通过为消息分配密集有序的ID来实现。节点维护一个接收消息的缓冲区,如果发现ID序列中有空缺,则知道有消息尚未到达,从而不能提交或释放锁。

核心交互原则

在确认收到所有必要消息之前,参与者不能提交事务或释放锁。这防止了级联中止,并保证了严格的两阶段锁定语义。


协议的限制与优化

基本的两阶段提交协议存在一个著名问题:协调器单点故障。如果协调器在发送决定后、记录END前永久崩溃,部分参与者可能永远处于不确定状态,持有锁而阻塞其他事务。

虽然存在如三阶段提交等协议尝试解决此问题,但更可靠的解决方案是使用基于法定人数的共识算法(如 Paxos、Raft)。这些算法允许多个节点共同扮演协调器的角色,即使少数节点故障,系统仍能进展。

此外,协议还有许多优化空间,例如:

  • 协调器可以不等待所有 ACK 就通知应用服务器(冒一定风险)。
  • 减少必须同步刷新的日志记录数量。

本章总结

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

  1. 分布式提交的挑战:节点故障、消息延迟、本地约束等使得单点决策不可靠。
  2. 两阶段提交协议:通过“准备”和“提交”两个阶段,让所有参与者投票达成一致。
  3. 故障恢复:协议与日志系统(如ARIES)紧密结合,通过持久化日志和状态查询来处理各种崩溃场景。
  4. 并发控制:协议需与两阶段锁定等并发控制机制协作,通过消息有序化来安全管理锁的获取与释放。
  5. 协议局限:认识到基本2PC存在协调器单点故障问题,并了解了更高级的共识算法是解决之道。

通过两阶段提交协议,我们实现了在分布式数据库环境中运行事务的能力,这是构建可靠、可扩展数据系统的关键基石。

课程 P24:第24讲 NoSQL I - 动机与数据模型 🗄️

在本节课中,我们将要学习 NoSQL 数据库的诞生动机及其核心数据模型。我们将探讨为什么传统的关系型数据库在扩展时面临挑战,以及 NoSQL 如何通过简化数据模型和功能来应对这些挑战。课程将从回顾关系型数据库的扩展策略开始,逐步过渡到 NoSQL 的基本概念和不同类型的数据存储模型。

回顾:关系型数据库的扩展挑战

上一节我们介绍了关系型数据库的两种主要扩展策略:分区和复制。本节中我们来看看这些策略在保证一致性方面面临的困难。

关系型数据库系统通常服务于两类应用:在线事务处理(OLTP)和在线分析处理(OLAP)。NoSQL 的出现,主要是为了应对需要大规模扩展的 OLTP 工作负载。

扩展关系型数据库服务器的主要挑战在于保持数据的一致性。这涉及到我们之前学过的两阶段锁(2PL)和两阶段提交(2PC)等复杂协议。

扩展策略及其权衡

以下是两种主要的扩展策略及其优缺点:

  • 分区(分片)

    • 做法:将数据分割到多个数据库服务器上。
    • 优点:可以存储海量数据,在理想情况下查询只需访问单台机器,能提高吞吐量。
    • 缺点:跨分区的操作(如连接)非常复杂且昂贵,需要类似 2PC 的协议来保证一致性。若某台机器故障,其上的数据将暂时不可用。
  • 复制

    • 做法:将完整的数据集复制到多台服务器上。
    • 优点:易于分摊只读查询负载,提高吞吐量;容错性好,一台服务器宕机不影响服务。
    • 缺点:写入操作变得昂贵,因为需要更新所有副本。同时,要保证所有副本的强一致性也非常困难。

这两种策略在关系型模型下都难以轻松实现扩展,这正是 NoSQL 试图解决的问题。

NoSQL 的核心理念

上一节我们看到了关系型数据库扩展的复杂性,本节中我们来看看 NoSQL 是如何通过“做减法”来应对的。

NoSQL 的基本思路是:通过简化数据模型和减少数据库系统提供的功能(如强一致性、复杂查询),来换取系统易于扩展到大规模工作负载的能力。

这意味着,如果应用程序需要执行类似连接的操作,或者必须读取最新的数据,那么这些功能需要由应用程序开发者自己来实现。数据库系统不再原生提供这些保障。

BASE 原则 vs. ACID 特性

NoSQL 的理念通常用 BASE 原则来概括,这与关系型数据库的 ACID 特性形成对比。

  • 基本可用性(Basic Availability):系统保证基本可用,但并非时刻可用。如果一台服务器宕机,应用程序需要自行处理如何从其他副本获取数据。
  • 软状态(Soft State):数据库的状态可能在没有输入的情况下也会改变(例如,由于更新在副本间的传播延迟)。
  • 最终一致性(Eventual Consistency):系统不保证强一致性或可串行化,但保证数据最终(在一段未定义的时间后)会达到一致状态。

这种模型适用于许多现代应用场景(如社交网络)。例如,用户发布一条状态后,其好友是否立即看到或稍后看到,通常是可以接受的。这与银行转账等需要强一致性的场景截然不同。

NoSQL 的数据模型

理解了 NoSQL 的动机和理念后,本节中我们来看看它具体如何组织数据。NoSQL 提供了多种数据模型,我们将重点介绍前两种。

1. 键值存储

键值存储是最简单的 NoSQL 数据模型。它将所有数据视为一系列键值对。

  • :可以是任何数据类型(如字符串、整数),但必须在整个数据集中唯一。类似于关系型数据库中的主键。
  • :可以是任何东西——一个数字、一个字符串、一个列表、一张图片,甚至是一段二进制数据。没有固定的模式(Schema)约束。
  • 操作:通常只支持两个基本操作:
    • GET(key):根据键检索对应的值。
    • PUT(key, value):插入或更新一个键值对。

系统不支持连接、分组、聚合等复杂查询。从本质上讲,它可以看作一个分布式的巨型哈希映射。

示例:存储航班信息。
在关系型数据库中,我们可能设计 Flights(id, date, carrier, from, to)Carriers(id, name) 两个表。在键值存储中,则有多种设计方式:

  • 键设为航班ID,值为包含所有航班信息的JSON对象。
  • 键设为“出发地-目的地”对(如“SFO-NYC”),值为该航线上所有航班的列表。

选择哪种方式,完全取决于应用程序最常进行的查询类型。这种灵活性是把双刃剑:它简化了数据库实现,但将数据建模和复杂查询的负担转移给了应用开发者。

2. 宽列存储 / 可扩展记录存储

宽列存储是键值存储的一个演进,它重新引入了一些类似关系型数据库的结构。

  • 核心思想:数据模型仍然是基于键的,但键的结构更复杂。通常,键由行键(Row Key)和列族/列限定符(Column Family/Qualifier)组合而成。
  • 特点:在同一张“表”中,所有记录共享一个基本的模式框架(即列族的定义),但不同的行可以拥有不同的列(稀疏性)。这使得它比纯键值存储更有规律,又比严格的关系型表更灵活。
  • 起源:该模型源自 Google 的 Bigtable 论文,并有如 Apache HBase 等开源实现。

这种模型可以看作是键值存储和关系型表格之间的一个折中方案。


本节课总结

在本节课中,我们一起学习了 NoSQL 数据库的起源和基本概念。我们首先回顾了关系型数据库在扩展性上面临的挑战,特别是分区和复制策略在保证一致性方面的复杂性。接着,我们探讨了 NoSQL 的核心理念,即通过放弃关系型数据库的 ACID 强保证,转而采用 BASE 原则,以换取系统的简单性和可扩展性。最后,我们介绍了 NoSQL 的两种基础数据模型:极度灵活的键值存储和具有一定结构性的宽列存储。理解这些基本模型是深入学习具体 NoSQL 系统(如 Redis、Cassandra、MongoDB 等)的重要基础。在接下来的课程中,我们将深入探讨另一种流行的 NoSQL 模型——文档存储。

课程 P25:NoSQL II - MongoDB 查询语言 🗄️

在本节课中,我们将学习 MongoDB 的核心查询语言(MQL)。我们将从数据模型回顾开始,然后深入探讨检索查询和聚合管道,理解其语法、设计决策以及与关系型数据库(如 SQL)的对比。

数据模型回顾 📝

上一节我们介绍了 NoSQL 数据库的背景,本节中我们来看看 MongoDB 的具体数据模型。MongoDB 的数据模型基于文档,其结构与 JSON 类似。

  • 数据库:在 MongoDB 中,一个数据库包含多个集合。
  • 集合:相当于关系型数据库中的“表”。
  • 文档:相当于关系型数据库中的“行”或“记录”。文档是字段-值对的集合。
  • 字段:相当于关系型数据库中的“列”或“属性”。

文档中的值可以是以下几种类型:

  • 原子值:例如字符串、整数、布尔值。
  • 嵌套文档:一个文档可以包含另一个文档。
  • 数组:可以是原子值数组或文档数组。

每个文档都有一个特殊的主键字段 _id,它在默认情况下会被索引。如果插入文档时未提供,MongoDB 会自动生成。

查询语言基础 🔍

MongoDB 查询语言(MQL)主要围绕单个集合进行操作。与 SQL 可以同时操作多张表不同,MQL 的查询通常从一个集合开始,然后通过一系列操作对其进行转换。

MQL 有两个核心语法特征:

  1. 点表示法:用于访问嵌套文档或数组中的字段。例如,inventory.qty 表示访问 inventory 字段(可能是一个嵌套文档或文档数组)中的 qty 字段。当使用点表示法时,表达式需要用引号括起来。
    // 示例:查询嵌套字段
    db.collection.find({ "size.uom": "cm" })
    
  2. 美元符号$ 后跟的字符串是特殊操作符关键字,例如 $gt (大于)、$lte (小于等于)。它们用于构建查询条件。
    // 示例:使用操作符
    db.collection.find({ qty: { $gte: 50 } })
    

检索查询 📥

检索查询用于从集合中查找并返回文档,类似于 SQL 中的 SELECT ... WHERE ... 语句。

一个基本的检索查询模板如下:

db.collection.find(<谓词>, <投影>)
  • 谓词:指定文档必须满足的条件,相当于 SQL 的 WHERE 子句。它本身是一个文档。
  • 投影:指定返回结果中应包含或排除哪些字段,相当于 SQL 的 SELECT 子句。它也是一个文档。

以下是检索查询的一些示例:

基本查询:

// 返回所有文档
db.inventory.find({})
// 返回 status 字段为 "D" 的文档
db.inventory.find({ status: "D" })

使用操作符:

// 返回 qty 大于等于 50 的文档
db.inventory.find({ qty: { $gte: 50 } })
// 返回 status 为 "D" 且 qty 大于等于 50 的文档(AND)
db.inventory.find({ status: "D", qty: { $gte: 50 } })
// 返回 status 为 "D" 或 qty 小于 30 的文档(OR)
db.inventory.find({ $or: [ { status: "D" }, { qty: { $lt: 30 } } ] })

查询嵌套文档和数组:

// 精确匹配嵌套文档
db.inventory.find({ size: { h: 14, w: 21, uom: "cm" } })
// 使用点表示法查询嵌套字段
db.inventory.find({ "size.uom": "cm", "size.h": { $gt: 14 } })
// 查询数组是否包含特定元素
db.inventory.find({ tags: "red" })
// 使用 $elemMatch 查询数组中的元素满足多个条件
db.inventory.find({ dim_cm: { $elemMatch: { $gt: 15, $lt: 20 } } })

投影:

// 只返回 item 和 _id 字段
db.inventory.find({}, { item: 1 })
// 返回 item 字段,并明确排除 _id 字段
db.inventory.find({}, { item: 1, _id: 0 })
// 返回 item 和 status 字段,排除 _id
db.inventory.find({}, { item: 1, status: 1, _id: 0 })

注意:在投影文档中,通常不能混合使用 1(包含)和 0(排除),但 _id 字段是一个例外。

排序和限制:

// 返回最多5个文档
db.inventory.find().limit(5)
// 按 qty 降序,然后按 item 升序排序
db.inventory.find().sort({ qty: -1, item: 1 })

聚合管道 🛠️

聚合管道提供了一种更强大、更灵活的数据处理方式,它由多个按顺序执行的“阶段”组成。每个阶段对输入文档进行处理,并将结果传递给下一个阶段。

聚合管道的基本语法如下:

db.collection.aggregate([
    { <阶段1> },
    { <阶段2> },
    ...
])

以下是聚合管道中常用的阶段:

$match:过滤文档,只将符合条件的文档传递到下一阶段。相当于 find() 中的谓词。

{ $match: { status: "A" } }

$project:重塑文档结构,可以包含、排除字段,重命名字段,创建计算字段或嵌套文档。功能比检索查询中的投影更强大。

{ $project: { item: 1, total: { $multiply: ["$price", "$qty"] } } }

$group:按指定表达式对文档进行分组,并对每个组进行聚合计算(如求和、求平均)。

{
  $group: {
    _id: "$state", // 按 state 字段分组
    totalPop: { $sum: "$pop" } // 计算每州总人口
  }
}

$sort:对文档进行排序。

{ $sort: { totalPop: -1 } } // 按总人口降序排序

$unwind:将数组字段拆分为多个文档,每个文档包含数组中的一个元素。

{ $unwind: "$tags" } // 为 tags 数组中的每个元素生成一个文档

$lookup:执行左外连接,从另一个集合中查询匹配的文档。

{
  $lookup: {
    from: "otherCollection",
    localField: "localField",
    foreignField: "foreignField",
    as: "outputArray"
  }
}

聚合管道示例:
假设我们有一个 zipcodes 集合,包含 city, state, pop(人口)等字段。

  1. 找出总人口超过1500万的州,并按人口降序排列:

    db.zipcodes.aggregate([
      {
        $group: {
          _id: "$state",
          totalPop: { $sum: "$pop" }
        }
      },
      {
        $match: { totalPop: { $gte: 15000000 } }
      },
      {
        $sort: { totalPop: -1 }
      }
    ])
    

    对应的 SQL 大致为:

    SELECT state AS _id, SUM(pop) AS totalPop
    FROM zipcodes
    GROUP BY state
    HAVING SUM(pop) >= 15000000
    ORDER BY totalPop DESC;
    
  2. 计算每个州最大城市的人口:

    db.zipcodes.aggregate([
      {
        $group: {
          _id: { state: "$state", city: "$city" },
          cityPop: { $sum: "$pop" }
        }
      },
      {
        $sort: { "_id.state": 1, cityPop: -1 }
      },
      {
        $group: {
          _id: "$_id.state",
          biggestCity: { $first: "$_id.city" },
          biggestPop: { $first: "$cityPop" }
        }
      },
      {
        $sort: { biggestPop: -1 }
      }
    ])
    

经验法则与总结 📚

本节课中我们一起学习了 MongoDB 查询语言的核心概念。以下是使用 MQL 时的一些经验法则:

  • $project:当你需要构造或解构嵌套文档、重命名字段、创建计算字段时非常有用。
  • $group:除了用于标准聚合(如求和),还可以利用 $push$addToSet 来为每组创建数组。
  • $unwind:是处理数组、将其“展开”为独立文档的关键操作,这在关系模型中不常见。
  • $lookup:是实现集合间连接的主要方式。请注意,连接结果通常是一个数组,后续可能需要使用 $project 进行清理。

MongoDB 的文档模型和 MQL 提供了高度的灵活性,特别适合处理非结构化或半结构化数据。其设计选择(如围绕单个集合操作、使用文档表示查询参数)与关系型数据库形成了鲜明对比,理解这些差异对于有效使用 MongoDB 至关重要。

课程26:MapReduce与Spark 🚀

在本节课中,我们将学习NoSQL数据库MongoDB的更新操作,并深入探讨分布式数据处理框架MapReduce的核心概念、工作原理及其与关系数据库的对比。


概述

上一节我们介绍了MongoDB的基本查询操作。本节中,我们来看看MongoDB的更新操作,并转向一个全新的主题:大规模分布式数据处理框架MapReduce。我们将学习MapReduce的编程模型、执行流程,并了解它如何用于处理海量数据。


MongoDB更新操作

在MongoDB中,更新数据库的方法包括插入、删除或更新文档。这些操作有“一个”或“多个”的后缀变体。我们将重点讨论插入多个、删除多个和更新多个。

插入多个文档

以下是插入多个文档的方法。insertMany 操作接受一个文档数组。

db.collectionName.insertMany([
    { item: "journal", qty: 25 },
    { item: "notebook", qty: 50 }
])

执行此操作时,系统会采取几个步骤:

  1. 如果集合不存在,则创建它。无需单独的模式定义语句。
  2. 为每个文档自动添加一个 _id 属性作为主键(如果文档本身未提供)。
  3. 默认情况下,_id 是文档的第一个字段。

更新多个文档

updateMany 操作类似于SQL中的UPDATE语句。它需要一个条件(谓词)来匹配文档,以及一个修改语句来指定更改内容。

db.inventory.updateMany(
    { "dim.0": { $lt: 15 } }, // 条件:数组dim的第一个元素小于15
    {
        $set: { "dim.0": 15, status: "Invalid width" } // 修改:将dim[0]设为15,并添加status字段
    }
)

在这个例子中,所有 dim 数组第一个元素小于15的文档都会被更新:该元素被设置为15,并添加一个新的 status 字段。

除了直接设置值,还可以对数值数据进行运算,例如递增:

db.inventory.updateMany(
    { "dim.0": { $lt: 15 } },
    {
        $inc: { "dim.0": 5 }, // 将dim[0]增加5
        $set: { status: "Invalid width" }
    }
)

这与SQL的类比非常直接:UPDATE table SET ... WHERE ...


MongoDB架构与查询优化

MongoDB是一个分布式NoSQL数据库。集合(包含JSON文档)会被分片(Sharding),即基于特定字段进行分区,这个过程是自动的。每个分区会被复制以实现容错。复制通常是异步的,这提供了最终一致性

在查询优化方面,MongoDB主要使用启发式方法:

  1. 索引利用:如果管道早期有 $match 阶段且字段有索引,会尝试使用索引。
  2. 谓词合并:如果管道中有多个连续的 $match 阶段,系统会尝试合并它们的谓词。
  3. 谓词下推:尝试将 $match 阶段尽可能早地推送到管道靠近数据源的位置,类似于关系数据库中的选择下推。

然而,MongoDB没有基于成本的优化器,也不进行跨操作符的流水线执行。每个聚合阶段都是独立评估,中间结果会被物化。


从关系数据库到MapReduce

之前我们讨论了单节点和并行关系数据库系统。NoSQL数据库是另一种反应。现在,我们关注在分布式环境中简化编程的模型——MapReduce。

关系查询处理有三种并行模式:

  1. 查询间并行:在不同机器上并发运行不同的查询。
  2. 操作符间并行:将单个查询树的不同操作符分布到多台机器上运行。
  3. 操作符内并行:在多台机器上运行同一个操作符(例如,并行哈希连接)。这是我们讨论的重点。

MapReduce的诞生源于谷歌等公司需要处理海量非关系型数据(如网页)。它提供了一个高级编程模型,让用户无需使用SQL也能轻松编写并行处理管道。


MapReduce 核心概念

MapReduce程序运行在分布式文件系统(如HDFS)之上,数据被存储为大文件并分割成块(如64MB)。

一个MapReduce作业必须由用户定义的 MapReduce 两个函数组成。

  • 输入和输出 都是键值对(Key-Value Pairs)。
  • Map阶段:并行处理输入数据块,每个Map任务读入键值对,输出中间键值对。
  • Shuffle阶段:系统自动将Map输出的中间结果中,键相同的所有值聚集到一起。
  • Reduce阶段:每个Reduce任务接收一个键及其对应的值集合,进行处理后输出最终结果。

示例:词频统计

这是一个经典的MapReduce示例,用于统计大量文档中每个单词的出现次数。

Map函数:输入是(文档ID, 文档内容)。它遍历文档中的每个单词,为每个单词输出一个中间键值对 (word, 1)

# 伪代码
def map(doc_id, content):
    for word in content.split():
        emit(word, 1)

Reduce函数:输入是 (word, [1, 1, 1, ...])。它对所有值(都是1)进行求和,输出该单词的总数 (word, count)

# 伪代码
def reduce(word, list_of_counts):
    total = 0
    for count in list_of_counts:
        total += count
    emit(word, total)

执行流程

  1. 多个Map任务并行处理不同文档,生成 (word, 1) 对。
  2. Shuffle阶段将所有相同的 word 对应的 1 聚集到一起。
  3. 多个Reduce任务并行处理不同的 word,分别求和并输出最终词频。


MapReduce 内部机制与容错

在Hadoop等实现中,有一个主节点(Master)负责协调。它将输入数据分片,并调度工作节点(Worker)执行Map和Reduce任务。

容错性

  • Map任务和Reduce任务会将中间输出写入磁盘。
  • 如果一个Worker节点失败,Master会将该节点上的任务重新调度到其他健康节点上执行。由于中间结果已持久化,新节点可以读取这些结果继续计算,无需从头开始。

应对慢节点(Straggler)

  • 如果某个任务执行速度远慢于其他同类任务,系统会启动一个相同的备份任务
  • 原始任务和备份任务同时运行,哪个先完成就采用哪个的结果,并终止另一个。这被称为“推测执行”。

在MapReduce中实现关系操作

我们可以用MapReduce模型来实现关系代数操作,这有助于理解两者的联系。

选择(Selection)

假设要根据属性 a 的值进行选择。

Map函数:检查每个元组 t,如果满足谓词(如 t.a == 123),则输出 (t.a, t)
Reduce函数:实际上不需要做任何计算,只需将Map输出的值原样传递出去即可。在早期MapReduce中,Reduce函数不能省略,因此可以设计一个“空”Reduce。

分组聚合(Group By with Aggregation)

假设要按属性 a 分组,并计算属性 b 的和。

Map函数:对于每个输入元组 t,输出 (t.a, t.b)。这里键是分组属性 a,值是需要聚合的 b
Reduce函数:接收 (a, [b1, b2, ...]),对列表中的所有 b 值求和,然后输出 (a, sum)

可以看到,MapReduce的Shuffle阶段天然完成了按键分组的工作,Reduce阶段则执行具体的聚合逻辑。


总结

本节课我们一起学习了以下内容:

  1. MongoDB的更新操作:包括 insertManyupdateMany,以及其背后的简单原理。
  2. MongoDB的架构:包括分片、复制和基于启发式的查询优化。
  3. MapReduce的动机:作为处理海量非关系型数据的编程模型,它将控制权交还给程序员。
  4. MapReduce核心模型:基于Map和Reduce两个函数,通过Shuffle阶段连接,非常适合批处理任务。
  5. MapReduce的执行与容错:通过主节点协调、中间结果落盘和推测执行来实现可靠且高效的计算。
  6. MapReduce与关系代数的关系:可以通过MapReduce来实现选择、分组聚合等关系操作,二者在功能上可以相互表达。

MapReduce的核心思想在于通过一个简单的编程接口,隐藏了分布式计算的复杂性,使得大规模数据处理变得更容易。在下一节课中,我们将了解MapReduce的演进——Spark框架。

课程 P27:OLAP 与列存储 📊

在本节课中,我们将学习在线分析处理(OLAP)的基本概念,并与在线事务处理(OLTP)进行对比。我们还将探讨数据仓库、星型/雪花模式、数据立方体以及列存储的核心思想。课程内容旨在让初学者能够理解这些概念。


概述

我们将首先回顾 MapReduce 和 Spark 的基本概念,然后深入探讨 OLAP 系统。OLAP 专注于读取和汇总大量数据以发现趋势,这与频繁进行小规模读写的 OLTP 系统形成鲜明对比。我们将学习数据仓库的架构、典型的数据模型(如星型模式),以及用于高效分析查询的数据立方体概念。


1. MapReduce 与 Spark 回顾

上一节我们介绍了并行数据处理的基础。本节中,我们来看看 MapReduce 和 Spark 这两种流行的并行计算框架。

MapReduce 提供了一个简单的编程模型,将计算分为 Map(映射)Reduce(归约) 两个阶段。其核心思想是将中间结果写入磁盘以确保容错性。

示例:单词计数

# Map 函数
def map(doc):
    for word in doc.split():
        emit(word, 1)

# Reduce 函数
def reduce(word, counts):
    total = sum(counts)
    emit(word, total)

Spark 是 MapReduce 的演进,它引入了弹性分布式数据集(RDD)的概念。RDD 是一个不可变的分布式数据集合,Spark 通过记录其“血统”(Lineage)来实现容错,并允许程序员控制数据的持久化。

示例:Spark 过滤日志

val lines = spark.read.textFile("log.txt")
val errors = lines.filter(_.startsWith("ERROR"))
val sqliteErrors = errors.filter(_.contains("SQLite"))
sqliteErrors.collect()

Spark 的操作分为 转换(Transformations,惰性执行)行动(Actions,立即执行),这为优化和恢复提供了灵活性。


2. OLAP 与 OLTP 对比

理解了并行计算框架后,我们转向数据库的不同工作负载。本节我们将区分 OLAP 和 OLTP。

  • OLTP(在线事务处理):处理大量的、并发的、短小的读写操作(如银行交易、订单录入)。特点是高吞吐、低延迟、强一致性,通常涉及少量数据。
  • OLAP(在线分析处理):处理复杂的、只读的查询,用于数据分析、报告和决策支持(如销售趋势分析)。特点是读取大量数据、进行聚合和分组,对实时性要求较低。

典型的组织架构会分离这两种负载:多个 OLTP 数据库处理实时业务,其数据通过 ETL(提取、转换、加载) 过程定期同步到一个集中的 数据仓库 中,供 OLAP 查询使用。这导致了“数据陈旧性”的权衡,但保证了事务性能。


3. 数据仓库与星型/雪花模式

数据仓库有其专门的数据模型。以下是其核心组件:

事实表(Fact Table):存储业务过程度量(事实)的中心表,包含大量的数值型数据(如销售额、数量)。
维度表(Dimension Table):存储描述性属性(维度),为事实提供上下文(如产品、商店、客户、时间)。

星型模式(Star Schema):由一个事实表和多个直接连接到它的维度表组成,形似星星。
雪花模式(Snowflake Schema):是星型模式的规范化形式,维度表本身可以进一步连接到其他维度表,形似雪花。

示例星型模式

  • 事实表:Sales(Fact) - product_id, store_id, customer_id, date_id, quantity, price
  • 维度表:Product(Dim), Store(Dim), Customer(Dim), Date(Dim)

典型的 OLAP 查询涉及对事实表和维度表进行连接,然后按维度属性分组并聚合度量值。

示例 OLAP 查询

SELECT p.category, s.country, SUM(f.quantity)
FROM Sales f
JOIN Product p ON f.product_id = p.id
JOIN Store s ON f.store_id = s.id
GROUP BY p.category, s.country;

4. 数据立方体(Data Cube)

为了支持多维度分析,我们引入数据立方体的概念。数据立方体是一种多维数据模型,允许从不同角度和粒度查看聚合数据。

给定维度(如产品、地区、时间)和度量(如销售额),数据立方体预先计算了所有可能的维度组合的聚合结果。

核心操作

  • 上卷(Roll-up):沿维度层次结构向上聚合,例如从“日”汇总到“月”。
  • 下钻(Drill-down):沿维度层次结构向下查看更详细的数据,例如从“年”下钻到“季度”。
  • 切片(Slice):固定一个维度的值,例如只看“2023年”的数据。
  • 切块(Dice):选择多个维度的值范围,例如看“2023年北美地区”的数据。

SQL 提供了 GROUP BY CUBEGROUP BY ROLLUP 等扩展来支持数据立方体查询。

示例:使用 ROLLUP

SELECT product, color, size, SUM(quantity)
FROM inventory
GROUP BY ROLLUP(product, (color, size));

此查询会生成不同粒度级别的汇总:(产品, 颜色, 尺寸)(产品, 颜色)(产品) 以及总计。


5. 列式存储简介

最后,我们简要介绍一种优化 OLAP 工作负载的存储格式。传统的数据库使用 行式存储,将一行的所有列值连续存储。这对于 OLTP 的点查询很高效。

列式存储 则将每一列的数据分别存储在一起。这对于 OLAP 查询有巨大优势:

  1. 高压缩率:同一列的数据类型相同,更容易压缩。
  2. 减少 I/O:查询通常只涉及少数列,列存只需读取相关列的数据。
  3. 利于向量化处理:CPU 可以高效地对连续存储的列数据进行批处理操作。

公式:对于分析查询 SELECT SUM(column_a) FROM table WHERE column_b > 100,列存只需读取 column_b(用于过滤)和 column_a(用于聚合)的数据块,而无需读取整行。


总结

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

  1. MapReduce 与 Spark:回顾了并行计算模型,Spark 通过 RDD 和惰性求值提供了更灵活、高效的框架。
  2. OLAP vs OLTP:理解了事务处理与分析处理在目标、查询特性和系统架构上的根本区别。
  3. 数据仓库建模:掌握了用于分析的数据仓库典型模型——星型模式和雪花模式,以及事实表与维度表的概念。
  4. 数据立方体:学习了用于多维分析的数据立方体模型及其核心操作(上卷、下钻、切片、切块)。
  5. 列式存储:了解了为何列式存储特别适合 OLAP 场景,它能通过减少 I/O 和利用压缩来大幅提升分析查询性能。

这些概念构成了现代大数据分析系统的基础。

课程 P3:SQL II (续) + 磁盘、缓冲区、文件 I 📚

在本节课中,我们将继续深入学习 SQL 连接的高级概念,并初步探讨数据库系统的底层存储结构,包括磁盘、缓冲区和文件组织。我们将从 SQL 的自然连接、外连接和空值处理开始,然后过渡到查询的组合与嵌套,最后概述数据库管理系统的体系结构。


连接扩展 🔗

上一节我们介绍了基本的连接操作。本节中,我们来看看更高级的连接类型:自然连接和外连接。

自然连接

自然连接允许我们省略连接条件。它隐式地假设两个表中所有具有相同名称的属性值应该相等。

示例
假设有两个表:Sailors(sid, sname, rating)Reserves(sid, bid, day)。它们共有的属性是 sid

SELECT * FROM Sailors NATURAL JOIN Reserves;

此查询等价于:

SELECT * FROM Sailors, Reserves WHERE Sailors.sid = Reserves.sid;

关于自然连接需要注意的一点是,它有时会产生意想不到的结果,尤其是当两个关系中有许多名称相同的属性,但你不希望它们在连接中被视为相等时。

外连接

外连接可以捕获内连接或自然连接中不匹配的行。它分为左外连接、右外连接和全外连接。

左外连接:返回左表的所有行,以及右表中匹配的行。如果右表没有匹配项,则结果中右表的部分填充为 NULL

SELECT * FROM Sailors LEFT OUTER JOIN Reserves ON Sailors.sid = Reserves.sid;

右外连接:与左外连接相反,返回右表的所有行。
全外连接:返回两个表中所有的行,不匹配的部分用 NULL 填充。

需要注意的是,某些 SQL 实现(如 SQLite)可能不支持右外连接和全外连接,但可以通过调整表顺序和使用集合运算符来模拟。


空值处理 ❓

空值(NULL)用于表示未知或不适用的值。它的存在使得查询逻辑变得更加复杂。

空值与选择谓词

WHERE 子句中,涉及 NULL 的比较结果通常是 NULL(即“未知”),这会导致该行不被包含在输出中。

示例

SELECT * FROM Sailors WHERE rating > 8;

如果某个水手的 ratingNULL,则该水手不会出现在结果中,因为我们无法确定其评级是否大于 8。

为了显式处理 NULL,可以使用 IS NULLIS NOT NULL 谓词。

SELECT * FROM Sailors WHERE rating > 8 OR rating <= 8 OR rating IS NULL;

三值逻辑

为了组合涉及空值的谓词,SQL 使用了三值逻辑(True, False, Unknown/NULL)。其真值表扩展了传统的布尔逻辑。

AND 真值表示例

AND True False NULL
True True False NULL
False False False False
NULL NULL False NULL

OR 真值表示例

OR True False NULL
True True True True
False True False NULL
NULL True NULL NULL

规则是:如果 WHERE 子句的计算结果为 NULL(未知),则不输出该元组。

空值与聚合函数

聚合函数(如 COUNT, SUM, AVG)会忽略 NULL 值。

  • COUNT(*) 计算所有行。
  • COUNT(rating) 只计算 rating 非空的行。
  • SUM(rating)AVG(rating) 也只对非空值进行计算。

查询组合与嵌套 🧩

SQL 允许通过集合运算符和子查询来组合查询结果。

集合与包语义

  • 集合:不允许重复元素。
  • :允许重复元素。SQL 默认使用包语义。

以下是面向集合和面向包的运算符:

  • UNION / UNION ALL:并集。
  • INTERSECT / INTERSECT ALL:交集。
  • EXCEPT / EXCEPT ALL:差集。

带有 ALL 的运算符执行面向包的操作,会考虑元组的重复次数。

嵌套查询

可以将一个查询(子查询)嵌套在另一个查询中。

使用 IN 检查成员资格

SELECT sname FROM Sailors WHERE sid IN (SELECT sid FROM Reserves WHERE bid = 102);

相关子查询:子查询可以引用外层查询的变量。

SELECT sname FROM Sailors S WHERE EXISTS (SELECT * FROM Reserves R WHERE R.sid = S.sid AND R.bid = 102);

概念上,外层查询的每一行都会执行一次子查询。

关系除法示例:查找预订了所有船只的水手。

SELECT sname FROM Sailors S WHERE NOT EXISTS (
    SELECT B.bid FROM Boats B WHERE NOT EXISTS (
        SELECT * FROM Reserves R WHERE R.sid = S.sid AND R.bid = B.bid
    )
);

视图与公共表表达式 👓

视图和公共表表达式是命名查询的机制,用于简化复杂查询并提高安全性。

视图

视图是存储的查询定义,使用时会被重写展开。

CREATE VIEW RedCount AS
SELECT B.bid, COUNT(*) AS scount
FROM Boats B, Reserves R
WHERE B.bid = R.bid AND B.color = 'red'
GROUP BY B.bid;

使用视图:

SELECT * FROM RedCount WHERE scount < 10;

公共表表达式

使用 WITH 子句在查询中临时定义视图。

WITH RedCount(bid, scount) AS (
    SELECT B.bid, COUNT(*)
    FROM Boats B, Reserves R
    WHERE B.bid = R.bid AND B.color = 'red'
    GROUP BY B.bid
)
SELECT * FROM RedCount WHERE scount < 10;

数据库系统体系结构概述 🏗️

编写 SQL 查询是一种声明式体验。接下来,我们将深入探讨数据库系统如何执行这些查询。

数据库管理系统通常采用分层架构:

  1. 查询处理层:接收 SQL 查询,进行解析、优化,生成查询计划。
  2. 执行引擎层:执行查询计划中的操作符(如连接、排序)。
  3. 缓冲区管理层:管理数据在内存(缓冲区)和磁盘之间的移动。
  4. 存储管理层:在磁盘上组织和管理数据(文件、页、记录)。

两个横切关注点贯穿所有层次:

  • 并发控制:管理多个用户同时访问数据。
  • 恢复:在系统故障后确保数据一致性。

存储介质 💾

数据最终存储在物理设备上。不同的存储介质在速度、容量和成本上差异巨大。

存储层次结构

从快到慢,从贵到便宜:

  1. CPU 寄存器
  2. CPU 缓存
  3. 主内存
  4. 固态硬盘
  5. 机械硬盘
  6. 磁带

访问时间可能从纳秒级(寄存器)到天级(磁带)。我们通常将频繁访问的数据放在更快的存储中。

机械硬盘

机械硬盘由旋转的盘片和移动的磁头组成。

  • 数据存储在盘片的磁道扇区上。
  • 读取数据涉及寻道(移动磁头到正确磁道)和旋转延迟(等待所需扇区转到磁头下)。
  • 因此,顺序读写远快于随机读写。

数据库系统以(通常由多个扇区组成)为单位与磁盘交换数据。


总结 📝

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

  1. SQL 连接扩展:包括自然连接的隐式等值连接,以及左/右/全外连接对不匹配行的处理。
  2. 空值处理:理解了 NULL 表示未知值,以及在三值逻辑下如何影响 WHERE 子句和聚合函数。
  3. 查询组合:使用集合运算符和嵌套子查询来构建更复杂的查询,并了解了关系除法的概念。
  4. 视图与 CTE:使用视图和公共表表达式来模块化查询,提高代码可读性和安全性。
  5. 系统概述:初步了解了数据库系统的分层架构和核心组件。
  6. 存储基础:认识了不同的存储介质及其特性,特别是机械硬盘的工作原理,为理解数据如何存储和访问打下基础。

掌握 SQL 需要大量练习。请务必通过作业和项目来巩固这些概念。在接下来的课程中,我们将自底向上地探索数据库系统的实现细节。

数据库系统原理 P4:磁盘、缓冲区与文件 I(续)& II 🗂️

在本节课中,我们将继续学习数据库管理系统(DBMS)的底层存储组件。我们将从磁盘技术的基本原理开始,逐步深入到数据在磁盘和内存中的组织方式,包括文件结构、页面布局和记录格式。通过本节课,你将理解DBMS如何高效地管理数据存储与访问。


公告与回顾 📢

首先是一些课程公告。第二次作业将在本次讲座结束后发布,截止时间为下周四。第一个项目将在本周四截止。项目答疑与交流活动将在今晚太平洋时间7点到9点进行。这是一个鼓励合作与寻找项目伙伴的新尝试,欢迎大家参与并提供反馈。

每周初会发布公告,请大家留意截止日期和重要事项。

现在,回到课程内容。上周我们讨论了DBMS的分层架构,从解析查询开始,一直到管理存储数据的磁盘缓冲区。我们是从最底层的组件——磁盘开始讲起的。


存储层次结构与磁盘基础 💽

上一讲我们介绍了你可能已经熟悉的存储层次结构。数据检索速度最快的是CPU寄存器,最慢的则是传统的机械磁盘写入。检索时间的差异巨大,从5纳秒到20毫秒不等。不同层次之间的时间差非常显著。

磁盘由盘片和磁臂组件构成。盘片旋转,磁臂上的磁头负责读写数据。磁盘表面被划分为同心圆,称为磁道;磁道进一步划分为扇区,是读写的基本单位。

读取数据的过程可以类比黑胶唱片:

  1. 寻道时间:移动磁臂到目标磁道。
  2. 旋转延迟:等待盘片旋转,使目标扇区到达磁头下方。
  3. 传输时间:实际读取或写入数据。

寻道时间和旋转延迟是主要的性能开销,我们需要尽量减少它们。


固态硬盘(SSD)简介 ⚡

固态硬盘(SSD)使用闪存技术,其组织方式与机械硬盘(HDD)不同。数据存储在单元格中,可以进行随机读写,无需移动机械部件,因此没有寻道时间和旋转延迟。

然而,SSD的写入有其特点:

  • 每个存储单元的擦写次数有限(通常约3000次)。
  • 写入前需要先擦除单元格。
  • 为了均衡磨损、延长寿命,SSD控制器会动态地将数据写入不同的物理单元(磨损均衡)。这使得写入延迟不像读取那样完全可预测。

对于本课程,你只需记住:SSD的随机读写很快,但写入延迟可能因磨损均衡机制而变得不那么可预测。


存储空间管理策略 🧠

鉴于顺序读写通常比随机读写更快(对HDD和SSD均适用),DBMS采用以下策略优化性能:

  • 预取:预测并提前加载可能被访问的数据。
  • 缓存:将频繁访问的数据保留在内存中。
  • 缓冲写入:将多个写入操作收集起来,然后一次性顺序写入磁盘。

为了统一讨论不同的存储技术,我们引入两个通用术语:

  • :磁盘与内存之间数据传输的基本单位。大小因技术而异(例如4KB或64KB)。
  • 页面:在本课程中,页面是同义词,都指代这个基本传输单位。需要注意,有些资料中“页面”特指内存单位。

磁盘空间管理器 🗃️

磁盘空间管理器负责将数据库页面映射到磁盘上的物理位置。它向上层提供简单的页面读写接口。

一个典型的API使用模式如下,它假设顺序访问页面是高效的:

page = get_first_page(“sailors”) # 获取表的第一个页面
while page is not None:
    process(page)                # 处理页面数据
    page = get_next_page()       # 获取下一个页面

实现磁盘空间管理器有两种主要思路:

  1. 直接管理设备:为特定存储设备编写专属驱动程序。性能可能最优,但缺乏通用性,难以维护和扩展。
  2. 利用操作系统文件:向操作系统申请一个大的、连续的文件空间,然后在此文件内部自行管理页面分配和读写。这样可以从设备细节中抽象出来,是更常见的做法。

数据库文件与堆文件结构 📄

从数据库的角度看,数据以关系(表)的形式组织。一个关系可能存储在一个或多个操作系统文件中。我们首先关注单个文件。

一个数据库文件包含一组页面,每个页面包含多条记录(即元组)。页面是DBMS各层之间交换数据的“通用货币”。

有多种组织文件的方式,我们首先介绍最简单的无序堆文件。它不强制记录在页面中或页面之间的任何顺序,只是记录的集合。

为了管理堆文件,我们需要跟踪文件中的所有页面以及每个页面中的空闲空间。一种实现方式是使用页面目录。目录本身是一个或多个特殊的页面,其中存储了指向各个数据页面的指针以及该页面的空闲空间大小。这样能快速定位有空间插入新记录的页面。


页面布局 📑

页面是存储记录的基本单元。页面通常包含一个页头(存储元数据,如记录数量、空闲空间信息)和实际的记录数据区

页面布局的设计需考虑:

  1. 记录长度:固定长度还是可变长度。
  2. 打包方式:记录紧密排列(打包)还是允许空闲槽位(解包)。

固定长度记录的布局

  • 密集打包:记录连续存放。通过 记录起始位置 = 页头大小 + 记录大小 * (记录号-1) 快速定位。但删除记录时需要移动后续记录以保持紧凑,会导致大量记录ID变更,影响外部引用。
  • 槽式页面(解包):为每条记录分配一个固定的“槽”(位置),并使用一个位图来标记槽是否被占用。插入时找到第一个空闲槽;删除时只需清除位图中的位。记录ID(页面号+槽号)稳定,无需重组。

可变长度记录的布局

对于可变长度记录,槽式页面方案同样有效且更优。页面布局如下:

  • 记录区:从页面头部开始向后增长,存放实际记录数据。
  • 槽目录:从页面尾部开始向前增长,每个槽项存储对应记录的起始位置长度
  • 页头:包含空闲空间起始指针、槽目录计数器等。

操作示例

  • 插入:在记录区末尾的空闲空间存放新记录,在槽目录中找一个空闲槽项,填入记录的起始位置和长度,并更新空闲空间指针。
  • 删除:只需清除对应槽项的内容(如将指针置空)。记录区空间不会立即回收。
  • 碎片整理:当页面内碎片过多时,可以重新紧凑排列记录,并更新对应槽项的指针。由于外部只通过槽号引用记录,此操作是安全的。

这种方案平衡了灵活性(处理变长记录)、效率(稳定记录ID)和空间利用率。


记录格式 📝

记录由一组固定类型的字段构成。DBMS通常使磁盘和内存中的记录格式保持一致,以避免序列化/反序列化的开销。字段的类型信息存储在系统目录(另一组特殊的表)中,而非每条记录中。

记录格式的设计目标是:

  1. 快速访问字段:支持只读取部分字段的查询。
  2. 紧凑存储:节省空间,使每页能存放更多记录。

固定长度记录很简单,字段连续存放,通过偏移量直接访问。

可变长度记录(或允许为空的固定长度字段)采用记录头方案。在记录起始处有一个小的头,其中包含指向各个可变长度字段结束位置的指针。结合固定长度字段的已知长度,可以快速计算出任何字段的起始和结束位置。这是实现紧凑存储和快速字段访问的常用方法。


总结 🎯

本节课我们一起深入探讨了数据库的存储管理层:

  1. 存储硬件:回顾了HDD和SSD的基本原理与性能特点。
  2. 管理策略:了解了利用顺序I/O优势的预取、缓存和缓冲写入策略。
  3. 抽象与接口:引入了“块/页面”作为通用单位,并介绍了磁盘空间管理器的API。
  4. 文件组织:学习了以堆文件形式组织关系,并使用页面目录来高效管理页面。
  5. 页面布局:详细分析了槽式页面结构,它如何优雅地处理固定长度和可变长度记录的插入、删除与碎片整理。
  6. 记录格式:掌握了使用记录头来高效编码可变长度字段,实现快速访问和紧凑存储。

从磁盘到文件,再到页面和记录,我们看到了DBMS如何通过层层抽象和精心设计的数据结构,在复杂的硬件基础上构建出高效、可靠的数据存储服务。下一讲,我们将继续探讨缓冲区管理和索引结构。

📚 课程 P5:第五讲 成本模型和索引 + B+ 树

在本节课中,我们将学习如何估算不同文件组织方式下数据库操作的成本,并介绍一种名为 B+ 树的高效索引结构。我们将通过简单的数学公式和代码示例来理解核心概念,并比较不同方案的优劣。


📊 成本模型概述

上一节我们介绍了不同的文件组织方式,如堆文件和排序文件。本节中,我们来看看如何估算在这些文件上执行操作的成本。我们关心成本,是为了在给定查询工作负载下,选择最优的数据存储方式。

成本模型旨在对数据操作(如插入、删除、查找、扫描)的成本进行粗略估算。我们使用以下参数:

  • B:文件中数据块(页)的数量。
  • R:每个块中记录的数量。
  • D:读取或写入一个磁盘块的平均时间。

我们做出以下简化假设:

  1. 忽略顺序IO和随机IO的差异。
  2. 忽略缓存和预取机制。
  3. 忽略CPU处理记录本身的成本。
  4. 所有数据操作必须先加载到内存,修改后必须写回磁盘。
  5. 每次操作(插入/删除)只涉及一条记录。
  6. 查找操作假设只有一条完全匹配的记录。
  7. 堆文件中,记录总是追加到文件末尾。
  8. 排序文件根据搜索键排序。

⚖️ 堆文件与排序文件的成本分析

接下来,我们分别分析在堆文件和排序文件上执行各种操作的平均成本。

扫描所有记录

无论是堆文件还是排序文件,都需要读取所有数据块。

成本公式Cost = B * D

等值查找

查找一个具有特定键值的记录。

堆文件:由于无序,平均需要扫描一半的块才能找到记录。
平均成本公式Cost ≈ (B/2) * D

排序文件:可以利用二分查找。
平均成本公式Cost ≈ (log₂ B) * D

范围查找

查找键值在某个范围内的所有记录。

堆文件:必须扫描所有块。
成本公式Cost = B * D

排序文件:先通过二分查找找到范围起点,然后顺序扫描直到终点。
成本公式Cost ≈ (log₂ B) * D + (扫描页数) * D。平均情况下,扫描页数可近似为 B/2

插入记录

堆文件:假设总是追加到文件末尾。需要读取最后一页,插入记录,再写回该页。
成本公式Cost = 2 * D (一次读,一次写)

排序文件:需要找到正确位置(二分查找),移动后续记录以腾出空间,并将受影响的所有页写回。
成本公式Cost ≈ (log₂ B) * D + B * D (查找 + 读写移位页)

删除记录

堆文件:需要找到记录(平均扫描一半的块),删除后将该页写回。
平均成本公式Cost ≈ (B/2) * D + D (查找 + 写回)

排序文件:需要找到记录(二分查找),删除后移动后续记录以填补空隙,并将受影响的所有页写回。
成本公式Cost ≈ (log₂ B) * D + B * D (查找 + 读写移位页)

从以上分析可以看出,没有一种文件组织在所有操作上都最优。堆文件在插入上表现好,而排序文件在查找上表现好。


🔍 索引简介

为了获得比单纯排序文件更好的性能,我们引入索引。索引是一种数据结构,用于根据搜索键快速查找或修改记录。常见的例子包括电话簿(按姓名找号码)和教科书索引(按关键词找页码)。

在数据库中,索引需要高效且持久化到磁盘。我们讨论的索引存储 <键, 记录指针> 对,其中记录指针通常是 (页ID, 槽ID),指向记录的实际位置。


🌳 B+ 树索引详解

B+ 树是一种动态、平衡的树状索引结构,广泛应用于数据库系统。它解决了之前简单索引方案(如排序的键查找页)的痛点:维护成本高、树深度大。

B+ 树的核心特性

  • 保持平衡:插入和删除时自动调整,保证树平衡。
  • 高扇出:每个节点有多个子节点,树宽而浅,减少IO次数。
  • 数据存储在叶节点:内部节点仅存储导航用的键和指针,所有数据记录(或指向记录的指针)都存储在叶节点。这有利于范围查询。
  • 叶节点链表:所有叶节点通过指针串联起来,支持高效的范围扫描。

B+ 树的结构

一个 B+ 树包含:

  • 根节点:树的顶端。
  • 内部节点:存储键和指向子节点的指针。
  • 叶节点:存储键和指向实际数据记录的指针,并包含指向相邻叶节点的指针。

节点容量规则(阶为 d 的 B+ 树):

  • 内部节点(除根):指针数在 d2d 之间,键数比指针数少1。
  • 叶节点:键(和记录指针)数在 d2d 之间。
  • 根节点:可以少于 d 个键。

B+ 树的效率

由于扇出很高,B+ 树通常非常浅。例如,假设一个页大小为 128KB,每个键-指针对占 40 字节,则每页可存储约 3200 个指针对。即使保守估计平均填充率为 2/3,扇出也超过 2000。

存储容量估算

  • 高度为 1(仅根节点和叶节点):可支持约 2000 * 2000 = 4 百万 条记录。
  • 高度为 2(根、一层内部节点、叶节点):可支持约 2000 * 2000 * 2000 = 80 亿 条记录。

这意味着即使对于海量数据,B+ 树也能在很少的磁盘访问(通常 3-4 次)内找到任何记录。

为什么使用 B+ 树而非 B 树?

B 树在内部节点也存储数据记录。B+ 树将所有数据置于叶节点,这样做的好处是:

  1. 内部节点更紧凑,扇出更高,树更浅。
  2. 范围查询时,一旦定位到起始叶节点,即可通过链表顺序扫描,无需回溯到树的上层。


📝 总结

本节课我们一起学习了数据库操作的成本模型,并深入探讨了 B+ 树索引。

  • 我们首先建立了成本模型,用于分析堆文件和排序文件在扫描、查找、插入、删除操作上的性能差异。
  • 我们了解到,为了获得更高效的数据访问,特别是对于点查询和范围查询,需要引入索引结构。
  • 我们重点介绍了 B+ 树,它是一种平衡、高扇出的树形索引,能够支持高效的点查询、范围查询以及动态插入和删除,是现代数据库系统的基石。

通过理解成本权衡和 B+ 树的工作原理,我们可以为特定的应用场景选择或设计最合适的数据存储和索引方案。

课程P6:索引与B+树优化 🗂️

在本节课中,我们将学习B+树索引的核心概念、操作及其优化。我们将探讨B+树的结构、搜索、插入、删除以及批量加载等操作,并分析不同索引设计选择的优缺点。

B+树结构回顾 🌳

上一节我们介绍了索引的基本概念,本节中我们来看看B+树的具体结构。

B+树是一种平衡树数据结构,用于数据库和文件系统中的索引。它由内部节点和叶节点组成。

以下是B+树的关键组成部分:

  • 根节点:树的顶层节点,是一个特殊节点。
  • 内部节点:用于路由的节点,在图中通常用蓝色表示。它们包含键值和指向子节点的指针。
  • 叶节点:存储实际数据引用的节点,在图中通常用灰色表示。它们包含键值和指向数据记录(如记录ID)的指针。
  • 指针:叶节点之间通过指针(如前向和后向指针)连接,支持高效的范围扫描。

B+树中的页面(节点)在物理存储上不一定按逻辑顺序排列,但通过树形结构和叶节点间的指针,仍然能提供有序的数据访问路径。

B+树的性质与规模 📏

了解了结构后,我们来看看保证B+树高效性的核心性质及其能索引的数据规模。

B+树需要维护占用不变量,以确保操作成本有界。

  • 对于内部节点(除根节点外),其条目数 n 需满足:d <= n <= 2d。其中 d 是树的最小度数(通常为最大容量的一半)。
  • 叶节点也需要满足类似的半满约束。
  • 根节点可以例外,条目数可以少于 d

这个性质保证了树的平衡,使得查找、插入和删除操作的时间复杂度为 O(log_d N),其中 N 是记录总数。

基于此性质,我们可以估算B+树的容量。假设:

  • 页面大小为8KB。
  • 每个索引条目(键值+指针)占40字节。
  • 则每页可存储约 8000 / 40 ≈ 200 个条目。
  • 假设平均填充因子为2/3,则平均扇出 f ≈ 133

那么:

  • 一棵高度为2(1层内部节点+叶节点)的B+树可索引记录数约为 f * f ≈ 17,689 条。
  • 一棵高度为3的B+树可索引记录数约为 f * f * f ≈ 2.3 百万 条。

实践中,由于扇出很大(通常可达数百甚至上千),B+树的高度很少超过4层,却能索引数十亿条记录,效率非常高。

B+树的操作:搜索 🔍

现在我们已经了解了B+树的结构和性质,本节中我们来看看如何在B+树中进行搜索操作。

搜索过程从根节点开始,逐层向下:

  1. 查找键值:在当前节点中查找第一个大于或等于目标键值的条目。
  2. 跟随指针:根据比较结果,沿相应的指针进入下一层子节点。
  3. 到达叶节点:重复上述步骤,直到到达包含目标键值(或应该插入的位置)的叶节点。
  4. 获取数据:在叶节点中找到目标键值及其对应的记录ID(如 (page_id, slot_id)),然后使用该ID从堆文件中检索实际数据记录。

范围扫描是搜索的一种扩展。例如,查找所有键值 >=27 的记录:

  1. 首先使用上述方法找到键值27所在的叶节点。
  2. 然后,利用叶节点之间的前后指针,按顺序遍历后续的叶节点,获取所有满足条件的记录。
    这种方法避免了为区间内的每个键值都从根节点重新遍历树,极大地提高了效率。

B+树的操作:插入 ➕

上一节我们介绍了搜索,本节中我们来看看如何向B+树中插入新的数据。

插入操作的目标是在维护B+树性质(特别是占用不变量)的前提下,添加新的键值对。基本步骤如下:

  1. 查找叶节点:使用搜索算法找到应插入新键值的叶节点 L

  2. 情况一:叶节点未满:如果 L 中有空闲槽位,则直接将新条目插入 L 的合适位置(保持键值有序),操作结束。

  3. 情况二:叶节点已满:如果 L 已满,则需要分裂 L

    • 创建一个新的叶节点 L2
    • L 中的条目(包括新条目)重新均匀分布到 LL2 中。
    • 更新 LL2 以及它们邻居节点之间的前后指针。
    • L2 的最小键值(即其中第一个键值)复制到一个新的索引条目 (key, pointer_to_L2) 中。
    • 尝试将这个新索引条目插入到 L 的父节点中。
  4. 处理父节点插入:向父节点插入新条目可能再次导致父节点过满,从而需要递归地进行内部节点分裂

    • 内部节点分裂与叶节点分裂类似,但有一个关键区别:被提升到父节点的中间键值是移动而非复制。这意味着该键值不再保留在分裂后的子节点中。
  5. 创建新根(必要时):如果分裂一直向上传播到根节点,并且根节点也需要分裂,那么就会创建一个新的根节点。树的高度随之增加。这种情况在实践中很少发生。

插入操作本质上是自底向上的,这有助于保持树的平衡。


B+树的操作:删除与批量加载 🗑️⚡

讨论了插入之后,我们来看看删除以及更高效的索引构建方法——批量加载。

删除操作在实践中的处理通常比插入简单。为了减少开销,许多系统在删除时并不严格强制占用不变量(即不立即合并未半满的节点)。它们只是简单地删除叶节点中的条目,并可能在页面完全清空时将其删除。这种“惰性”方法是可以接受的,因为后续的插入可能会填充这些空间,且树的对数级性能保证依然基本有效。教科书描述了更复杂的删除再平衡算法(如借用兄弟节点条目或合并节点),但本课程不作重点要求。

批量加载用于从头开始高效构建一个B+树索引,相比逐条插入有巨大优势。逐条插入的缺点是:需要多次从根节点搜索、修改随机页面导致缓存效率低、最终叶节点填充率可能不高。

批量加载算法步骤如下:

  1. 排序:首先按键值对要索引的所有数据记录进行排序。
  2. 填充叶节点:按顺序将排序后的记录填入叶节点页,直到达到预定的填充因子(例如75%),然后创建下一个叶节点。
  3. 构建内部节点:在填充叶节点的同时,自底向上构建内部节点。当一组叶节点填满后,就为它们创建父节点条目。如果父节点变满,则像插入时一样进行分裂。
  4. 生长树:这个过程持续进行,树的左侧部分一旦构建完成就不再被修改,从而实现了良好的局部性和缓存利用率。

批量加载的好处包括:

  • 更高的缓存效率:顺序构建,局部性好。
  • 更优的叶节点填充率:可控制初始填充度,得到更浅的树。
  • 数据物理有序:产生的索引通常是聚集的(见下文),有利于范围扫描。

索引设计选择 🤔

我们已经掌握了B+树的基本操作,现在来探讨一些重要的索引设计选择,这些选择影响着索引的性能和特性。

主要设计选择集中在叶节点内容数据组织方式上。

1. 叶节点数据条目格式
以下是三种常见的备选方案:

  • 方案1:存储完整记录值:叶节点直接存储数据记录本身。优点:查找时无需额外访问堆文件。缺点:若一个表有多个索引,会导致数据冗余,增加更新开销和存储成本。
  • 方案2:存储记录引用(键值,记录ID):叶节点存储键值和对应的记录ID(如 (page_id, slot_id))。这是我们之前一直假设的方案。优点:索引与数据解耦,支持多索引且无冗余。
  • 方案3:存储记录引用列表(键值,记录ID列表):对于有大量重复键值的情况,将一个键值对应的所有记录ID存储在一个列表中。优点:比方案2更紧凑。缺点:当列表跨越多页时需要额外的簿记管理。

方案2和3因其解耦特性而被广泛使用。

2. 堆文件组织:聚集 vs 非聚集
这与索引本身无关,而是与索引所指向的堆文件的组织方式有关。

  • 聚集索引:堆文件中的数据记录大致按照索引的键值顺序物理存储。优点:范围扫描效率极高,因为连续访问磁盘页,可以利用预取和顺序I/O优势。缺点:维护聚集属性的成本较高(插入新记录时需要找到合适位置或定期重组)。
  • 非聚集索引:堆文件中数据的物理存储顺序与索引键值无关。优点:插入简单(通常追加到文件末尾)。缺点:范围扫描可能导致大量随机I/O,性能可能很差。

聚集索引通常通过批量加载构建,并通过在页面内预留空间或定期重组来维护其聚集性。

操作成本分析 💰

最后,我们来量化分析使用B+树索引执行各种操作的成本。我们基于以下假设进行分析:

  • 使用方案2(存储记录引用)
  • 堆文件页面的平均填充因子为2/3
  • 索引是聚集的。
  • 我们只考虑I/O成本(即读写页面数),忽略CPU成本。
  • 定义:
    • B:堆文件中的页面数。
    • R:总记录数。
    • D:平均I/O延迟。
    • f:索引内部节点的平均扇出。
    • e:索引叶节点平均条目数。

以下是五种常见操作的成本估算:

  1. 扫描所有记录

    • 描述:读取整个关系。
    • 成本1.5 * B * D
    • 说明:由于填充因子为2/3,需要读取的页面数比完全填满时多50%。无需使用索引。
  2. 等值搜索(返回一条记录)

    • 描述:查找具有特定键值的记录。
    • 成本(log_f(R/e) + 1) * D
    • 说明log_f(R/e) 是遍历索引内部节点到达叶节点的I/O次数(即树高-1),+1 是读取叶节点本身。找到记录ID后,还需要一次I/O来读取堆文件页面。
  3. 范围搜索(返回多条记录)

    • 描述:查找键值在某个范围内的所有记录。
    • 成本(log_f(R/e) + 1.5*#匹配页数 - 1 + 1.5*#匹配页数) * D
    • 说明:第一项是找到范围起点的成本。第二项是扫描索引叶节点以获取所有匹配记录ID的成本(估算为1.5倍匹配页数,并减去已计数的第一页)。第三项是读取堆文件中所有匹配页面的成本(同样考虑填充因子)。由于索引是聚集的,堆文件的访问相对顺序。
  4. 插入

    • 描述:插入一条新记录。
    • 成本:搜索成本 + 分裂成本(若发生)+ 写入成本。最坏情况下可能涉及多次页面写入(修改叶节点、父节点等)。
  5. 删除

    • 描述:删除一条记录。
    • 成本:搜索成本 + 修改页面成本。若采用惰性删除,成本较低。

这些分析表明,B+树索引在点查询和范围查询上通常优于简单的堆文件或全排序文件,尤其是在处理大型数据集时。其对数级的访问复杂度、对范围扫描的良好支持以及动态平衡的能力,使其成为数据库系统中事实上的标准索引结构。




总结 🎯

在本节课中,我们一起深入学习了B+树索引。我们从B+树的结构和性质出发,详细探讨了搜索、插入、删除以及批量加载等核心操作。我们还分析了不同的索引设计选择,包括叶节点数据格式和聚集与非聚集索引,并对比了它们的优缺点。最后,我们对各种操作进行了成本分析,理解了B+树为何能成为数据库系统中高效、可靠的索引基石。掌握这些知识,对于设计和优化数据库系统至关重要。

📚 课程 P7:第七讲 缓冲管理与关系代数入门

在本节课中,我们将要学习数据库系统中的两个核心组件:缓冲管理关系代数。首先,我们将回顾并总结B+树索引的成本模型,然后深入探讨缓冲管理器的工作原理及其页面替换策略。最后,我们将初步了解关系代数,这是数据库系统内部表示和操作查询的基础。


🔄 回顾:B+树索引的成本模型

上一节我们介绍了B+树索引的结构。本节中,我们来看看如何计算使用索引进行不同操作(如范围查询和插入)的I/O开销。

我们关注平均情况下的开销,并假设使用聚集索引。关键参数如下:

  • B:数据页数量。
  • R:每个页面能存放的记录数。
  • D:读取或写入一个磁盘页面的时间。
  • F:B+树内部节点的扇出(分支因子)。

范围查询的成本计算

进行范围查询(例如,查找键值在3到7之间的所有记录)通常包含三个步骤:

  1. 使用索引找到起始叶子页。
  2. 扫描索引的叶子页,找出所有包含目标记录的堆文件页。
  3. 从堆文件中读取对应的页面。

总I/O成本公式近似为:
Cost = (树的高度 + 1) + (目标记录页数 * 2/3) + (目标记录页数 * 2/3) - 1
公式中减1是为了避免重复计算第一步中已读取的第一个叶子页。

插入操作的成本计算

插入一条新记录(例如,键值4.5)也涉及三个步骤:

  1. 通过索引找到应插入的堆文件页。
  2. 读取并修改该堆文件页。
  3. 更新索引的叶子页,并写回修改过的堆文件页。

总I/O成本为:
Cost = (树的高度 + 1) + 1 + 2

索引选择的考量

虽然可以使用大O符号抽象比较(例如,索引的等值查找成本为 O(log_F(B)),远优于堆文件的 O(B)),但常数因子同样重要。因为随机I/O的成本可能远高于顺序I/O(在机械硬盘上可能相差百倍)。因此,使用索引时,应尽量减少需要随机访问的页面数量。有两种常见策略:

  1. 使用聚集索引,使得在找到第一页后能进行顺序读取。
  2. 使用SSD(固态硬盘),其随机读取与顺序读取成本相近。

🧠 缓冲管理

上一节我们讨论了数据在磁盘上的组织。本节中我们来看看如何高效地将数据页调入主内存,这就是缓冲管理的核心任务。

缓冲管理器在主内存中维护一系列,用于存放从磁盘读入的页面。其API与磁盘管理器类似,提供pin(固定)和unpin(取消固定)页面等操作。

缓冲管理器的内部状态

缓冲管理器需要为每个帧维护一些元数据:

  • 页面标识:帧中存放的是哪个磁盘页。
  • 脏位:标记页面内容是否被修改过。
  • 固定计数:记录当前有多少个查询操作正在使用该页面。只有当固定计数为0时,页面才可能被替换。

页面替换策略

当缓冲池已满且需要加载新页面时,必须选择一个现有页面进行替换(驱逐)。以下是两种常见策略:

1. 最近最少使用策略

LRU策略会驱逐最近最少使用的页面。它需要记录每个页面最后一次被访问的时间。

  • 优点:对具有时间局部性的访问模式效果好。
  • 缺点:维护精确的访问时间开销较大;在顺序洪泛(反复顺序扫描大量数据)的场景下表现极差,可能导致缓存命中率为0。

2. 时钟策略

时钟策略是LRU的一种近似实现,成本更低。它为每个页面维护一个引用位,并有一个时钟指针循环遍历所有帧。

  • 工作流程:当需要替换页面时,检查指针指向的帧。若其页面未被固定且引用位为0,则选择它进行替换;若引用位为1,则将其置0并移动指针检查下一帧。
  • 优点:实现简单,开销小。
  • 缺点:是LRU的近似,可能无法选出真正最久未用的页面。

3. 最近最常使用策略

MRU策略会驱逐最近最常使用的页面。

  • 适用场景:在顺序洪泛的场景下,MRU表现优于LRU,因为它倾向于保留扫描序列中较早的页面,从而获得更高的缓存命中率。

核心结论:没有一种替换策略适合所有访问模式。数据库系统需要根据工作负载特征进行选择或采用自适应混合策略。


➕ 关系代数入门

前面我们了解了数据存储与内存管理。现在,我们开始探讨数据库系统如何表示和处理查询本身,这就要用到关系代数

关系代数是数据库系统内部操作数据的形式化语言,它定义了一系列在关系(表)上进行操作的运算符。系统会将用户提交的SQL查询转换为关系代数表达式,进而优化并执行。

关系代数的特点

  1. 封闭性:运算符的输入和输出都是关系。
  2. 组合性:简单的表达式可以组合成更复杂的表达式。
  3. 集合语义:纯关系代数基于集合理论,结果中无重复元组(但SQL实际使用多重集/包语义)。

基本运算符

关系代数运算符主要分为一元运算符和二元运算符。

一元运算符

作用于单个关系。

  • 投影:符号 π。用于选择关系中的特定列(垂直过滤)。对应于SQL中的 SELECT 子句(列选择部分)。
    • 示例π_{sname, age}(Sailors) 返回仅包含船员姓名和年龄的关系。
  • 选择:符号 σ。用于选择满足条件的元组(水平过滤)。对应于SQL中的 WHERE 子句。
    • 示例σ_{rating > 7}(Sailors) 返回评分大于7的船员。
  • 重命名:符号 ρ。用于重命名关系或其属性。

二元运算符

作用于两个关系。

  • 并集:符号 。返回出现在任一关系中的元组集合。
  • 差集:符号 -。返回出现在第一个关系但不在第二个关系中的元组集合。
  • 笛卡尔积:符号 ×。返回两个关系所有元组的组合。

派生运算符

这些运算符可由基本运算符定义,是常用的快捷方式。

  • 交集:符号 。可通过并集和差集实现。
  • 连接:符号 。最常见的是在满足某条件下组合两个关系的元组,是笛卡尔积后加选择操作的组合。有多种变体,如等值连接、自然连接、外连接等。

一个重要区别:在SQL术语中,SELECT 关键字对应关系代数的投影,而 WHERE 关键字对应选择。这个命名差异是历史原因造成的,需要注意。


📝 总结

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

  1. B+树索引的成本模型:分析了范围查询和插入操作的I/O开销,并讨论了索引选择时需要权衡随机I/O与顺序I/O的成本差异。
  2. 缓冲管理:了解了缓冲管理器如何作为磁盘和计算之间的桥梁,管理内存中的页帧,并深入探讨了LRU、时钟和MRU等页面替换策略及其适用场景。
  3. 关系代数入门:认识了关系代数作为数据库内部查询表示语言的角色,学习了投影、选择、并集、差集、笛卡尔积和连接等基本运算符,为后续学习查询优化与执行打下了基础。

理解这些底层机制,有助于我们更好地设计数据库结构、编写高效查询,并理解数据库系统的工作原理。

课程 P8:关系代数 🧮

在本节课中,我们将学习数据库查询语言的核心——关系代数。我们将了解其基本操作符、如何组合它们以构建复杂查询,以及它在数据库系统优化中的重要性。


概述

关系代数是一组对关系(即数据库表)进行操作的运算符。它类似于算术或线性代数,允许我们通过组合基本操作来构建复杂的查询表达式。理解关系代数是理解 SQL 查询如何被数据库系统解析、优化和执行的基础。


基本操作

上一节我们介绍了关系代数的概念,本节中我们来看看其核心的五个基本操作。

1. 投影 (π)

投影操作符 π 用于从关系中选择特定的列(属性),相当于 SQL 中的 SELECT 子句(仅列名部分)。它执行的是关系的“垂直切片”。

公式
π_{属性列表}(关系R)

示例
假设有一个水手关系 Sailors(SID, Sname, Age, Rating)
π_{Sname, Age}(Sailors) 将返回一个只包含 SnameAge 两列的新关系。

注意:在关系代数中,结果遵循集合语义,会自动去除重复行。但在实际 SQL 中,需要使用 SELECT DISTINCT 来显式去重。

2. 选择 (σ)

选择操作符 σ 用于根据指定条件筛选关系中的行,相当于 SQL 中的 WHERE 子句。它执行的是关系的“水平切片”。

公式
σ_{条件}(关系R)

示例
σ_{Rating > 8}(Sailors) 将返回所有评分大于 8 的水手记录。

注意:选择操作不会产生重复行,因此无需额外去重。

3. 并集 (∪)

并集操作符 用于合并两个具有相同模式(相同数量和类型的属性)的关系中的所有元组,相当于 SQL 中的 UNION

公式
关系R ∪ 关系S

注意:在关系代数中,并集操作会自动去除重复的元组。SQL 中的 UNION ALL 则保留重复项。

4. 差集 (-)

差集操作符 - 用于找出存在于第一个关系但不存在于第二个关系中的元组,两个关系必须模式兼容。相当于 SQL 中的 EXCEPT

公式
关系R - 关系S

5. 笛卡尔积 (×)

笛卡尔积操作符 × 将两个关系中的每一个元组进行两两配对,生成一个新的关系。如果关系 R 有 m 行,关系 S 有 n 行,结果将有 m×n 行。

公式
关系R × 关系S

注意:这通常是一个代价高昂的操作,在实际查询中应尽量避免,或通过连接条件进行限制。

6. 重命名 (ρ)

重命名操作符 ρ 用于更改关系或其属性的名称,这在处理自连接或避免属性名冲突时非常有用。

公式
ρ_{新关系名(新属性名1, ...)}(关系R)ρ_{新属性名/旧属性名}(关系R)

示例
ρ_{Sailor2(SID2, Name2, Rating2, Age2)}(Sailors) 创建了 Sailors 表的一个副本并重命名了所有属性。


复合操作

以上是基本操作,接下来我们看看如何用它们定义更高级的复合操作。

交集 (∩)

交集操作符 用于找出同时存在于两个关系中的元组。两个输入关系必须模式兼容。

公式
关系R ∩ 关系S

实现:交集可以通过差集来表达:R ∩ S = R - (R - S)

连接 (⨝)

连接是关系代数中最重要的操作之一,用于根据相关列组合两个表中的行。它有多种变体。

θ-连接 (⨝_θ)

θ-连接是笛卡尔积后接选择操作。
公式
R ⨝_{θ条件} S = σ_{θ}(R × S)
其中 θ 是任意条件(如 R.A > S.B)。

等值连接

等值连接是 θ-连接的一种特例,其中条件 θ 只包含相等谓词(如 R.A = S.A)。

自然连接 (⨝)

自然连接是一种特殊的等值连接,它会自动对所有同名的属性进行等值比较,并且在结果中同名属性只保留一份。
概念流程π_{去重属性集}(σ_{同名属性相等}(R × S))

示例
Reserves ⨝ Sailors 会在 SID 属性上自动进行等值连接,并将两个表中的信息合并。


组合复杂表达式

关系代数表达式可以通过多种方式组合,就像数学表达式一样。

以下是构建复杂表达式的几种方法:

  1. 赋值序列:创建中间关系变量,使表达式更易读。
    Temp1 = σ_{Rating>8}(Sailors)
    Result = π_{Sname}(Temp1)
    
  2. 带括号的表达式:使用括号明确运算顺序。建议总是使用括号,避免依赖默认优先级。
    π_{Sname}(σ_{Rating>8}(Sailors))
  3. 表达式树:以树形结构可视化表达式,其中叶子节点是关系,内部节点是操作符。这在查询优化中非常有用。

默认优先级(如不使用括号):一元操作(σ, π, ρ) > 笛卡尔积和连接 (×, ⨝) > 集合操作 (∪, ∩, -)。


查询优化简介

数据库系统会重写关系代数表达式以提高执行效率,这个过程称为查询优化。

以下是两个关键的优化规则:

  1. 选择操作下推:尽可能早地执行选择操作,以减少中间结果的大小。

    • 未优化:π_{...}(σ_{条件}(R ⨝ S))
    • 已优化:π_{...}((σ_{条件}(R)) ⨝ S)π_{...}(R ⨝ (σ_{条件}(S)))
    • 优势:尽早过滤掉不满足条件的行,减少后续连接操作需要处理的数据量。
  2. 连接顺序调整:连接操作的顺序会影响性能。优化器会估算不同连接顺序的成本。
    (R ⨝ S) ⨝ T 的成本可能与 R ⨝ (S ⨝ T) 不同。


扩展操作

除了基本和复合操作,关系代数还可以扩展以支持 SQL 中的其他功能。

分组与聚合 (γ)

分组操作符 γ 对应于 SQL 中的 GROUP BY 和聚合函数(如 AVG, COUNT)。

公式
γ_{分组属性, 聚合函数}(关系R)

示例
γ_{Age, AVG(Rating)}(Sailors) 按年龄分组,并计算每个年龄组的平均评分。
添加 HAVING 子句相当于在分组结果上再进行一次选择操作。


总结

本节课中我们一起学习了关系代数的核心内容:

  • 关系代数是一组将关系映射为关系的封闭运算符,是声明式查询语言(如 SQL)的操作性基础。
  • 我们掌握了六个基本操作:投影 (π)、选择 (σ)、并集 (∪)、差集 (-)、笛卡尔积 (×) 和重命名 (ρ)。
  • 我们了解了复合操作,如交集 (∩) 和各种连接 (⨝),它们都可以用基本操作来定义。
  • 我们看到了如何通过赋值序列、表达式和表达式树来组合这些操作,构建复杂的查询。
  • 我们初步探讨了查询优化的重要性,例如通过“选择下推”来显著提升查询性能。
  • 最后,我们简要介绍了扩展操作,如分组聚合 (γ),它们涵盖了 SQL 的更多功能。

理解关系代数不仅有助于编写高效的 SQL 查询,更是深入理解数据库系统如何工作的关键一步。

课程9:排序与哈希 🗂️🔍

在本节课中,我们将学习关系型数据库中两个核心的算法思想:排序哈希。我们将探讨它们如何被用于高效地实现关系代数操作,例如去重和分组。课程将涵盖外部排序、外部哈希的基本算法,分析其I/O成本,并讨论如何利用并行性来提升性能。


概述与背景 📋

上一节我们介绍了关系代数。本节中,我们来看看实现这些代数操作背后的基础算法:排序与哈希。

我们之所以讨论排序和哈希,是因为它们是实现不同关系代数运算符(如连接、去重、分组)的常见底层操作。理解这些算法有助于我们在后续的查询处理和优化中做出明智的选择。

本课程将反复出现两个核心主题:

  1. 流式处理:从磁盘流式读取记录到主存,处理后再写回磁盘。
  2. 分而治之:作为一种基础算法范式,将在多种场景中应用。

双缓冲技术 ⚙️

在深入算法之前,我们先介绍一个通用的性能优化框架:双缓冲

其核心思想是避免I/O(输入/输出)操作成为处理流程的瓶颈。基本流程如下:

  1. 从磁盘加载数据到主存的输入缓冲区
  2. 调用处理函数 f(x) 处理缓冲区中的数据。
  3. 将结果写入输出缓冲区
  4. 将输出缓冲区的数据写回磁盘。

双缓冲的优化在于使用多个缓冲区和多线程:

  • 一个线程专门处理已在内存中的元组(执行 f(x))。
  • 另一个线程专门负责从磁盘读取新元组到空闲缓冲区,或将已处理的元组从输出缓冲区写回磁盘。
  • 当一个输入缓冲区处理完毕,两个线程的角色可以“交换”,从而使得I/O操作和处理操作可以重叠进行,减少等待时间。

这本质上是一种多线程技术,一个线程/进程负责计算,另一个负责I/O。在本课后续讨论的所有算法实现中,都可以在幕后应用这种双缓冲思想以提高效率。


排序算法详解 🔢

现在,让我们正式探讨排序算法。我们的目标是对一个存储在磁盘上的巨大文件进行排序,该文件包含 N 个数据页(block)。我们只有有限的主存,假设有 B 个缓冲页可供使用。

如果内存足够容纳整个文件,那么直接使用快排等内存排序算法即可。但通常 B << N,因此我们需要外部排序算法。

简单的两路归并排序

一个直观的想法是进行多轮(Pass)处理:

第0轮(初始轮)

  • 从磁盘逐页读取数据。
  • 对每一页在内存中进行排序。
  • 将排序后的每一页写回磁盘。此时,我们得到了 N 个已排序的“段”(Run),每个段长度为1页。

后续归并轮次

  • 每次从磁盘读取两个已排序的段(每段一页)到内存的输入缓冲区。
  • 在内存中执行归并排序,合并这两个段。
  • 将合并后的有序结果写入输出缓冲区,写满一页后就写回磁盘,形成一个新的、更长的有序段(例如,长度变为2页)。
  • 重复此过程,直到所有段合并为一个完整的有序文件。

这个过程类似于自底向上的归并排序。每一轮合并,段的数量减半,段的长度翻倍。总共需要的轮次数约为 log₂N

I/O成本分析

  • 每一轮都需要读取和写入所有 N 个数据页,因此每轮成本为 2N 次I/O。
  • 总轮次为 1 + ⌈log₂N⌉(第0轮 + 归并轮次)。
  • 总I/O成本约为 2N * (1 + ⌈log₂N⌉)

以下是该过程的示意图:

通用多路归并排序

上一节我们介绍了基于两路归并的简单算法。本节中我们来看看如何利用更多内存缓冲页(B)来大幅减少归并轮次。

算法流程

  1. 第0轮(创建初始段)
    • 使用所有 B 个缓冲页。一次性读入 B 页数据,在内存中排序,然后作为一个有序段写回磁盘。
    • 初始段的数量为 ⌈N / B⌉,每个段的长度为 B 页(最后一个段可能不满)。
  2. 后续归并轮次
    • 我们拥有 B 个缓冲页。其中 1 个用作输出缓冲区,其余 B-1 个用作输入缓冲区。
    • 因此,我们可以同时归并 B-1 个有序段。
    • 每一轮归并,段的数量减少为原来的约 1/(B-1),段的长度相应增加。

I/O成本公式
总I/O次数 = 2N * (⌈log_{B-1}⌈N/B⌉⌉ + 1)

示例
假设 B = 5(5个缓冲页),N = 108(108页数据)。

  • 第0轮:产生 ⌈108/5⌉ = 22 个初始段(前21个段为5页,最后1个段为3页)。
  • 第1轮:使用4个输入缓冲区,归并段,段数减少。
  • 总共大约需要4轮即可完成排序。
  • 代入公式计算总I/O次数。

优势:通过增加 B(即使用更多内存),可以指数级减少归并轮次,从而显著降低总I/O开销。这类似于B+树的高扇出特性,可以用很浅的树结构管理大量数据。

两轮排序的可行性

一个有趣的问题是:能否只用两轮完成排序?(即第0轮创建初始段,第1轮完成最终归并)

分析

  • 第0轮后,我们创建了长度约为 B 页的段。
  • 第1轮归并时,我们最多能同时合并 B-1 个这样的段。
  • 因此,两轮算法能处理的最大数据量约为 B * (B-1) ≈ B² 页。

结论:若想用两轮排序 X 页数据,所需内存缓冲页数 B 至少需要约为 √X。这说明即使内存相对较小,也能通过两轮高效处理相当大量的数据。


哈希算法详解 #️⃣

排序并非总是必须。对于去重(DISTINCT)分组(GROUP BY)等操作,哈希是更高效的选择。其核心目标是将输入元组根据哈希函数分散到不同的“桶”(分区)中,并确保具有相同哈希值的记录在磁盘上连续存放。

基本的两阶段哈希算法

假设每个分区最终都能放入内存处理。算法分为两个阶段:

阶段1:分区(Partitioning Phase)

  • 流式读取输入文件的每一页。
  • 对每个元组应用哈希函数 h1,决定它属于 B-1 个输出分区中的哪一个(需要1个缓冲页用于输入)。
  • 将元组写入对应分区的内存输出缓冲区。当某个分区的缓冲区满时,将其写回磁盘。
  • 此阶段结束后,每个磁盘分区包含哈希值相同的所有元组,但同一分区内可能混合了多个不同的哈希值。

阶段2:去重(ReHashing/Duplicate Elimination Phase)

  • 逐个读入第一阶段生成的每个分区(假设其大小 ≤ B 页,可完全放入内存)。
  • 在内存中,对分区内的所有元组使用另一个哈希函数 h2(或直接构建内存哈希表)进行哈希。
  • 此阶段可以轻松检测并消除重复项,然后将唯一元组写回磁盘。

关键点:此算法成立的前提是,阶段1产生的每个分区都能被阶段2的内存(B 页)所容纳。

I/O成本:每个阶段都需要读取和写入所有数据一次,因此总成本为 2N 次I/O。

两轮哈希的数据量:与排序类似,两阶段哈希能处理的最大数据量也约为 页。要哈希 X 页数据,约需要 √X 页内存。

递归分区哈希

如果阶段1产生的某个分区太大(超过 B 页),无法在阶段2放入内存怎么办?

解决方案:递归应用分区!

  • 对这个过大的分区,再次应用一个新的哈希函数 h2',将其进一步细分为更小的子分区,并写回磁盘。
  • 重复此过程,直到每个子分区的大小都能被内存容纳,然后对其运行阶段2的去重操作。

极端情况——数据倾斜:如果某个键值(如gender=‘Male’)出现频率极高,导致无论用什么哈希函数,其所有记录始终在同一个分区。此时递归分区无效。

  • 处理办法:算法需要检测这种情况。当发现一个分区内所有记录的哈希值(或键值)都相同时,递归可以停止,因为去重工作已经可以在该分区内轻松完成。

排序与哈希的类比

排序和哈希算法呈现出有趣的对称性(二象性):

  • 排序:先征服(创建初始有序段),后融合(归并段)。
  • 哈希:先分割(分区),后征服(在内存中处理每个分区)。
  • I/O成本:在理想的两轮情况下,两者成本相同,均为 2N
  • 选择依据:查询是否需要有序结果(排序胜出),还是仅需分组或去重(哈希更优)。

并行化处理 ⚡

我们可以利用多台机器或磁盘进一步加速排序和哈希。

并行哈希

  1. 数据分发(Map):使用一个哈希函数 h,将原始数据分布到多台机器上。具有相同键值的记录会被发送到同一台机器。
  2. 本地哈希:每台机器独立地对自己收到的数据运行上述的两阶段外部哈希算法。
  3. 结果合并:如果需要,可以将各机器处理后的结果合并。

这本质上是一种 Map-Reduce 模式,其中分发阶段是 Map,本地哈希可以看作是另一个 Map 或本地的 Reduce。

并行排序

并行排序面临一个挑战:如何分发数据才能保证最终全局有序?

  • 范围分区(Range Partitioning):预先定义键值范围,将不同范围的数据发送到不同的机器,然后每台机器对自己范围内的数据排序。
  • 问题——数据倾斜:如果数据分布不均匀,某些机器负载过重,成为瓶颈。
  • 解决方案:可以先扫描数据构建直方图,了解数据分布,然后根据直方图动态调整范围分区的边界,以实现负载均衡。

总结 🎯

本节课中我们一起学习了数据库系统中两个根本性的算法:外部排序外部哈希

  • 核心思想:我们深入探讨了如何利用有限的内存(B 页)对远超内存容量的大数据集(N 页)进行排序和哈希。
  • 关键算法
    • 排序基于归并排序思想,通过多轮归并实现。
    • 哈希基于分区思想,通过递归分区处理大数据集。
  • 性能分析:我们推导了算法的I/O成本公式,并发现两者在仅用两轮处理时,能处理的数据量都与 成正比,所需内存约为数据量的平方根。
  • 对称性:排序(融合-征服)与哈希(分割-征服)在流程和成本上存在优美的类比。
  • 并行化:我们探讨了如何通过数据分发(哈希分发或范围分发)到多台机器,实现排序和哈希的并行计算,并讨论了负载均衡的重要性。

这些算法之所以至今仍然至关重要,是因为数据增长的速度往往快于内存容量的提升,且存储系统本身具有层次结构(如内存、SSD、HDD)。理解这些核心算法是设计高效查询执行器和优化器的基础。


课程内容结束

posted @ 2026-02-04 18:21  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报