CMU-15-445-数据库导论笔记-全-

CMU 15-445 数据库导论笔记(全)

1:课程介绍与关系模型

概述

在本节课中,我们将要学习数据库系统导论课程的基本信息,并深入探讨关系模型的核心概念。课程将涵盖数据库管理系统的设计与实现,而本节课的重点是理解关系模型的基础知识,包括关系、元组、属性以及关系代数。


课程信息

我是这门课程的讲师 Andy。由于行程安排,本周的课程将通过录制视频进行。从下周开始,我们将恢复课堂授课。

Oracle 公司是本学期课程的重要合作伙伴。作为 20 世纪 70 年代最早出现的关系数据库管理系统之一,Oracle 至今仍被广泛使用和销售。它是全球部署最广泛的商业数据库系统,并且仍在积极开发中,不断加入现代化的新功能。本课程将涵盖传统或经典设计的数据库管理系统。Oracle 的专家将在学期中做客座讲座,分享他们在 Oracle 中实现的、超越本课程基础内容的更高级主题。

本讲座首先介绍课程的整体大纲和对学生的期望,然后用半节课的时间讲解关系模型和关系代数。这些知识将为整个学期讨论的各种主题提供必要的背景。

选课与候补名单

由于教室容量限制,本课程无法接收所有报名的学生。目前候补名单人数众多,如果你现在尚未正式选上课,最终被录取的可能性很低。欢迎旁听课程,但请注意我们无法为旁听生提供官方支持。学生退课后,我们将根据学生在选课系统 S3 上的候补位置顺序进行补录。

课程核心与相关课程

15-445/645 课程的核心是数据库管理系统的设计与实现。这不是一门关于如何使用数据库构建应用程序(如网站)或如何部署、管理数据库的课程。我们真正关注的是如何构建和设计作为数据库供应商的软件本身。

如果你对如何构建更好的数据库管理系统不感兴趣,卡内基梅隆大学还有其他相关课程可供考虑,例如 Heinz 学院的 95-703(信息系统管理),该课程侧重于如何设置和管理数据库管理系统,但不会深入探讨如何构建软件。本学期计算机科学系内唯一可用的数据库相关课程就是本课程。

课程大纲

我们将讨论如何构建一个面向磁盘的数据库管理系统。“面向磁盘”意味着我们假设数据存储在磁盘上。课程大纲按系统层次组织:

  1. 首先在高层面上讨论什么是关系数据库。
  2. 然后讨论如何存储数据、在其上执行操作、运行事务。
  3. 接着讨论在系统崩溃或需要重启时如何进行恢复。

掌握到恢复为止的知识,是理解数据库管理系统工作原理的核心。在此基础之上,我们将讨论更高级的主题,如分布式数据库或其他类型的数据库,以及关系数据库的扩展。简而言之,我们将遍历构建系统的每一个层次,并在讲解完恢复机制后,完成对数据库系统基础工作原理的学习,随后探讨如何扩展系统以提升规模或在云环境中运行。

课程网站和教学大纲已在线发布。基本安排是每周两次讲座,每次讲座配有延伸阅读材料。请始终参考课程网页以获取最新信息。

学术诚信

这是一门高级课程,所有学生都应意识到,不得从互联网上随意复制代码,也不得相互抄袭。如果你对某项行为是否构成抄袭有疑问,请随时联系我进行讨论。我们会对作业进行检查。

所有课程相关的讨论和通知将在 Piazza 上进行。评分使用 Gradescope,最终成绩将发布在 Canvas 上。Piazza 的链接可在课程网页找到。

教材

本课程指定教材为《Database Systems Concepts》(数据库系统概念)。这是今年新出的版本。在我看来,这是目前最新、最好的数据库系统教材之一。对于教材未涵盖的主题,我会提供讲义。新版与旧版(第六版)在章节编号上可能有所不同,但核心内容差异不大。作业不会直接取自教材。

评分构成

课程评分构成如下:

  • 作业:占 15%。
  • 课程项目:占 45%。正是因为项目占比较高,本课程才符合计算机科学本科系统软件选修课的要求。
  • 期中考试与期末考试:各占 20%。
  • 附加学分:占 10%(可选)。几周后会公布具体内容。

本学期共有五次作业。第一次是 SQL 作业,之后的所有作业都是笔头作业,旨在练习课程中讨论的一些理论内容。所有作业都应独立完成。

课程项目

我对本学期项目感到非常兴奋。在整个学期中,你将从头开始构建自己的数据库存储管理器。你将逐步添加功能,构建出一个功能齐全的数据库存储管理器。关键词是“存储管理器”而非完整的“数据库系统”,因为你不会实现 SQL 解析器,但能够运行我们提供的、手动编码的查询。它比简单的键值存储复杂,但并非功能齐全的系统。

项目的关键在于必须跟上进度,因为每个项目都建立在前一个项目的基础上。你需要正确完成第一个项目,第二个、第三个项目才能正常工作。

本项目使用 C++17 编写。这是一门高级课程,助教不会教授如何编写或调试 C++。如果你对 C++ 不熟悉,现在就应该开始学习。我们期望在答疑时间讨论更高层次的数据库概念,而非基础的编程问题。

所有项目将在我们开发的新学术系统 Bustub 上实现。所有源代码将发布在 GitHub 上。Bustub 是一个基于磁盘的数据管理系统,支持火山模型查询处理。系统的不同部分具有可插拔的 API,允许我们插入不同的算法、索引数据结构、日志方案或并发控制方案。这样设计是为了每年可以完全更换项目内容,并逐年为系统添加新的特性和功能。因此,我们可以将其开源。

Bustub 目前是一个存储管理器,不支持 SQL,但你可以以物理操作符的形式编写查询。项目名称 Bustub 的含义我会在之后解释。我们为它设计了一个 Logo,并将在 Piazza 上发布 GitHub 链接。

迟交政策

每位学生有 4 个“宽限日”。在任何作业或项目提交时,你可以声明使用了多少个宽限日。用完宽限日后,每迟交 24 小时,总分将扣除 25%。如有医疗或其他紧急情况,请联系我,我们可以另行安排。

深入研究机会

如果你想深入了解数据库,卡内基梅隆大学数据库小组每周一下午 4:30 在 Gates 大楼有例会。此外,我们还在每周二中午 12 点在 Gates 大楼举行团队会议,开发一个功能更齐全的数据库系统。如果你想参与这类工作,欢迎参加。春季学期的高级课程 15-721 的项目就是基于这个新系统。


数据库与关系模型

上一节我们介绍了课程的基本信息,本节中我们来看看数据库为何如此重要,并深入探讨关系模型这一核心概念。

数据库的重要性

数据库在现实生活中至关重要,因为它们无处不在。任何复杂的计算机应用程序,无论是手机应用、桌面软件、网站还是复杂的计算机模拟,其底层几乎都离不开数据库。许多问题最终都可以归结为数据库问题。

我喜欢的数据库定义是:数据库是一个以某种方式相互关联的数据集合,旨在模拟现实世界的某个方面。它不仅仅是你笔记本电脑上随意存放的一堆文件。这些数据通常相互关联或有共同主题,试图模拟现实中发生的某些事情。

以数字音乐商店(如 Spotify 或 iTunes Store)为例。支撑这个应用程序的数据库将跟踪我们拥有的各种艺术家及其专辑。我们会将艺术家的基本信息以及他们发行的专辑信息存入数据库。

自建应用的挑战

假设我们不知道任何现成的数据库系统,需要在应用程序中自己编写代码来存储这些信息。最简单的实现方式可能是将数据存储在一堆逗号分隔值文件(CSV 文件)中,然后在应用程序代码中编写读取数据、提取信息以回答问题(即查询)的过程或方法。

例如,为“艺术家”和“专辑”这两个实体分别创建 artists.csvalbums.csv 文件,并编写代码来打开文件、解析每一行以提取属性。

以下是这种方法面临的一些问题,这也正是我们为何需要构建通用数据库管理系统的动机:

  1. 数据完整性:如何确保专辑文件中的“艺术家”字段与艺术家文件中的记录完全一致?如何避免拼写错误?如果艺术家改名,如何确保所有相关记录都被更新?
  2. 数据类型验证:如何确保存储的数据类型有效?例如,“专辑年份”应为四位数,但如果有人在此处输入了随机字符串怎么办?任何人都可以打开并修改文件,但我们的应用程序在解析时遇到非预期的字符串会抛出错误。
  3. 复杂关系:如果一张专辑有多个艺术家怎么办?CSV 文件的单字段设计难以处理这种情况,需要添加复杂的解析逻辑。
  4. 查询效率:如何查找记录?简单的例子是使用循环遍历解析每一行。如果文件有 30 行,这没问题。但如果我有 10 亿行呢?每次查询都打开文件、扫描解析每一行会非常慢。
  5. 多语言与共享访问:如果我想用另一种语言编写另一个应用程序来访问同一个数据库怎么办?例如,Web 服务器用 Python 编写,而手机应用用另一种语言编写,就需要重复所有解析逻辑。此外,如何让多个线程或进程同时安全地写入文件?如果没有特殊处理,数据可能会丢失或损坏。
  6. 安全性与容错:如何确保数据安全?如果我在更新记录时程序或机器崩溃了怎么办?更新应该存在吗?还是只更新了一半?如何判断正确状态?如果我想将数据库复制到不同机器以实现容错呢?

正是由于上述种种问题,我们才不希望在自己的应用程序中编写解析文件、读取数据的代码,而是希望将这些复杂的数据管理逻辑卸载给数据库管理系统。

数据库管理系统

数据库管理系统 是一种专用软件,它允许应用程序存储和分析数据库中的信息,而无需担心底层的实现细节。它是可以在不同应用程序之间复用的软件,避免了重复造轮子。

我们本学期讨论的通用 DBMS 旨在允许应用程序定义、创建、查询、更新和管理数据库。就我们的目的而言,我们假设数据库存储在磁盘上(当然也存在内存数据库、GPU 数据库等)。

数据库管理系统是一类非常特殊且重要的软件,其重要性足以支撑一门完整的课程来讲解如何构建它。几乎每个复杂应用程序的底层都运行着一个数据库。

关系模型的诞生

数据库系统并不新鲜。第一个系统大约在 1965 年出现在通用电气公司。人们很快意识到,需要专门的软件来管理大型数据集。

在 20 世纪 60 年代末,IBM 研究院的数学家 Ted Codd 注意到,从事数据库工作的人们花费大量时间反复重写数据库应用程序,因为数据库的逻辑层(存储什么)和物理层(如何存储)之间存在紧密耦合。

例如,如果你告诉数据库系统将数据存储为哈希表,它就会暴露哈希表的 API。但后来如果你想进行范围查询,就需要将数据结构改为树。这时,你必须转储所有数据,更改应用程序代码以调用树形 API 而非哈希表 API,然后重新加载所有数据。每次改变存储方式都需要重写代码。

Codd 意识到这很愚蠢,是在浪费人们的时间。当时人力比计算机便宜,但如今云计算廉价而人力昂贵,这个问题变得更加突出。

为此,Ted Codd 提出了 关系模型。他在 1970 年发表在《ACM 通讯》上的论文《大型共享数据库的关系数据模型》是开创性的,引发了关系数据模型的革命,并成为本课程的基础。

关系模型有三个关键原则:

  1. 简单数据结构:将数据库存储在称为“关系”的简单数据结构中。“关系”本质上是“表”的同义词。实体之间的关系可以通过表中的元组来体现。
  2. 高级语言访问:通过高级语言访问数据。我们只需写出想要数据库执行以检索数据的代码,而不必指定具体步骤。这个想法在当时是革命性的,因为之前人们都编写显式的过程式代码(如循环遍历表)。当时有人认为软件永远无法生成像人类手工编写那样高效的查询计划,这类似于 20 世纪 70 年代人们对编译器的争论。如今,编译器生成的代码通常比手写汇编更高效,数据库优化器也能生成非常高效的查询计划。
  3. 物理存储独立性:数据库的物理存储策略留给 DBMS 的实现。我们在高层将数据库定义为关系,但这些关系实际如何存储完全由 DBMS 决定。这就在逻辑层和物理层之间实现了清晰的分离,这正是我们想要的。

数据模型与模式

关系数据模型不是唯一的数据模型,但它是最广泛使用且在许多情况下是最佳的数据模型。不同的工作负载可能适合不同的数据模型,但十有八九你可能需要关系模型。

数据模型 是描述数据库中数据组织方式的高级概念。
模式 是针对给定数据集合或数据库的定义,即我们实际存储了什么。

以下是一些数据模型的示例:

  • 关系模型:MySQL, PostgreSQL, Oracle, DB2, SQL Server, SQLite。
  • 键值、图、文档(JSON)、列族模型:通常被称为“NoSQL”系统。
  • 数组/矩阵模型:用于机器学习,但不常见。
  • 层次和网状模型:20 世纪 60-70 年代原始的数据模型,现在几乎只存在于遗留应用中。

本课程将专注于关系数据模型。

关系模型的组成部分

关系模型由三部分组成:

  1. 结构:关系的结构,即模式,定义关系中有哪些属性及其类型。
  2. 完整性约束:指定在给定模式下,什么是数据库的有效实例。
  3. 数据操作:操作和访问数据库中数据的方法。

让我们回到音乐商店的例子,看看关系模型的具体实例。

一个 关系 是一个无序的元素或记录集合,这些记录具有表示关系中实体实例的 属性。关系中的一条记录在关系模型中称为 元组,它是该实体实例所有属性的集合。

在 Ted Codd 最初的关系模型中,所有值都必须是 原子的标量的,不能是数组或嵌套对象。但现代关系数据库已经放宽了这个限制。

关系中还可以有特殊的 NULL 值,表示值未知或不存在。

一个具有 n 列的关系称为 n 元关系。术语“关系”、“表”可以互换使用;“元组”、“记录”也可以互换使用。

  • 主键:一个或多个能唯一标识单个元组的属性。例如,可以为艺术家表引入一个唯一的 artist_id 字段作为主键。主键可以是自动递增的整数等。
  • 外键:一种指定某个关系中的属性必须在另一个关系的至少一个元组中存在的约束。这是维护不同关系之间完整性的方法,可以防止插入引用不存在实体的数据。例如,专辑-艺术家关联表可以通过 artist_idalbum_id 外键分别引用艺术家表和专辑表,从而支持多艺术家专辑,并确保引用的艺术家和专辑确实存在。

数据操作语言

DML 是操作和访问数据以产生所需结果的方法。有两种方式:

  1. 过程式:指定 DBMS 应如何找到结果的高级策略。
  2. 非过程式/声明式:只说明我们想要什么结果,而不指定如何产生。

我们将要讨论的 关系代数 是过程式语言的例子。而 关系演算 是非过程式语言的例子。本课程主要关注关系代数。

关系代数

Ted Codd 提出了关系代数的七个基本运算符。这种代数基于 集合(无序、无重复的集合)。每个关系运算符以一个或多个关系作为输入,并总是输出一个新的关系。通过将这些关系运算符链接在一起,我们可以构建复杂的查询。

以下是七个基本运算符:

  1. 选择

    • 符号:σ
    • 含义:选取满足选择谓词的元组子集。可以看作一个过滤器。
    • 示例σ_{a_id=2}(R) 表示从关系 R 中选择 a_id 等于 2 的元组。
    • SQL 对应WHERE 子句。
  2. 投影

    • 符号:π
    • 含义:生成一个只包含输入关系中指定属性的新关系。可以重新排序属性或对属性值进行运算。
    • 示例π_{b_id-100, a_id}(σ_{a_id=2}(R)) 表示先选择,然后输出 b_id-100a_id 列。
    • SQL 对应SELECT 子句中的列列表。
  3. 并集

    • 符号:∪
    • 含义:取两个关系,生成包含出现在第一个关系或第二个关系或两者中的所有元组的新关系。要求两个关系具有相同数量和类型的属性。
    • SQL 对应UNION ALL
  4. 交集

    • 符号:∩
    • 含义:生成同时出现在两个输入关系中的元组。
    • SQL 对应INTERSECT
  5. 差集

    • 符号:-
    • 含义:生成出现在第一个关系但不出现在第二个关系中的元组。
    • SQL 对应EXCEPT
  6. 笛卡尔积

    • 符号:×
    • 含义:生成两个关系所有元组的所有可能组合。
    • SQL 对应CROSS JOIN
  7. 自然连接

    • 符号:⋈
    • 含义:根据所有同名的属性进行等值连接。只输出匹配的元组,同名属性在结果中只出现一次。
    • SQL 对应NATURAL JOIN

除了这七个基本运算符,后来还扩展了其他运算符,如重命名、赋值、去重、聚合、分组、排序、除等。

声明式查询的优势

关系代数虽然比手写循环高级,但仍然指定了执行步骤的顺序。例如,查询“连接 R 和 S,然后过滤 b_id=102”可以有两种等价的代数表达式:

  1. σ_{b_id=102}(R ⋈ S):先连接,后过滤。
  2. (σ_{b_id=102}(S)) ⋈ R:先在 S 上过滤,后连接。

从逻辑结果上看,两者相同。但从执行效率看,可能天差地别。如果 S 表有 10 亿行,但只有一行满足 b_id=102,那么第二种方式(先过滤)的效率远高于第一种(先连接 10 亿行数据)。

我们真正想要的是 声明式 查询:只告诉数据库系统“我想要连接 R 和 S 后 b_id=102 的结果”,而不指定先做什么。这样,DBMS 的优化器可以根据数据量、索引等情况,自动选择最高效的执行计划。如果未来数据量发生变化,也无需修改应用程序代码。

SQL 就是这样的声明式查询语言。它成为了标准,尽管历史上存在其他竞争者。SQL 查询会被 DBMS 翻译成关系代数表达式,进而生成具体的执行计划。关系模型的魅力在于,我们可以编写高级查询,而 DBMS 能够随着数据变化、系统升级或工作负载改变而自我适应和优化。


总结

本节课中

2:高级SQL 📚

在本节课中,我们将要学习高级SQL的概念和操作。我们将超越基础的SQL知识,探讨更复杂和有趣的功能,包括聚合、分组、字符串与日期处理、输出控制、嵌套查询、公共表表达式以及窗口函数。这些知识对于高效地操作和分析数据库至关重要。


聚合与分组 📊

上一节我们介绍了SQL的基本概念,本节中我们来看看如何使用聚合函数和分组来汇总数据。聚合函数可以对一组行进行计算并返回单个值。

以下是SQL-92标准中定义的聚合函数:

  • AVG():计算平均值。
  • MIN():找出最小值。
  • MAX():找出最大值。
  • SUM():计算总和。
  • COUNT():计算行数。

例如,要计算登录名以“@cs”结尾的学生数量,可以使用以下查询:

SELECT COUNT(login) FROM student WHERE login LIKE '%@cs';

由于我们只是计数,COUNT(login) 可以简化为 COUNT(*) 甚至 COUNT(1),它们语义相同。

我们可以组合多个聚合函数。例如,同时获取学生数量和平均GPA:

SELECT COUNT(*), AVG(gpa) FROM student WHERE login LIKE '%@cs';

使用 DISTINCT 关键字可以只计算唯一值:

SELECT COUNT(DISTINCT login) FROM student WHERE login LIKE '%@cs';

当我们想要查看聚合结果的详细信息时,需要使用 GROUP BY 子句。例如,计算每门课程的平均学生GPA:

SELECT AVG(s.gpa), e.cid FROM enrolled AS e, student AS s WHERE e.sid = s.sid GROUP BY e.cid;

重要规则SELECT 输出列表中任何非聚合的列,都必须出现在 GROUP BY 子句中。

如果我们想对聚合结果进行过滤,不能使用 WHERE 子句,因为聚合计算在过滤之后。此时应使用 HAVING 子句。例如,只显示平均GPA大于3.9的课程:

SELECT AVG(s.gpa) AS avg_gpa, e.cid FROM enrolled AS e, student AS s WHERE e.sid = s.sid GROUP BY e.cid HAVING avg_gpa > 3.9;

字符串与日期/时间操作 ⏳

本节我们将探讨如何处理字符串和日期时间数据。不同数据库系统在这些操作上的实现差异较大。

SQL标准规定字符串使用单引号,并且是大小写敏感的。LIKE 操作符用于模式匹配:

  • % 匹配任意长度的任意字符序列。
  • _ 匹配单个任意字符。

例如,查找课程ID以“15-445”开头的记录:

SELECT * FROM enrolled WHERE cid LIKE '15-445-%';

查找登录名以“@cs”结尾,且前面只有一个字符的学生:

SELECT * FROM student WHERE login LIKE '%@c_';

字符串函数(如 SUBSTRING, UPPER, LOWER)可以出现在查询的多个位置。例如,获取学生姓名的前五个字符:

SELECT SUBSTRING(name, 1, 5) AS abbrv_name FROM student WHERE uid = 123;

使用 UPPER 函数进行不区分大小写的匹配:

SELECT * FROM student WHERE UPPER(name) LIKE 'KAN%';

字符串拼接在不同数据库中有不同语法:

  • SQL标准:使用双竖线 ||SELECT name || ‘!!!’ FROM student;
  • MySQL:使用 CONCAT 函数。SELECT CONCAT(name, ‘!!!’) FROM student;

日期和时间操作更为复杂。一个简单的任务——计算从年初到今天的天数——在不同数据库中的写法截然不同:

  • PostgreSQLSELECT DATE('2018-08-30') - DATE('2018-01-01') AS days;
  • MySQLSELECT DATEDIFF('2018-08-30', '2018-01-01') AS days;
  • SQLiteSELECT julianday('now') - julianday('2018-01-01') AS days;


输出控制与重定向 📤

现在,我们来看看如何控制查询结果的输出。默认情况下,SQL基于“包”代数,结果是无序的。使用 ORDER BY 可以对结果进行排序。

例如,按成绩降序排列选课记录:

SELECT sid, grade FROM enrolled WHERE cid = '15-721' ORDER BY grade DESC;

可以按多个列排序。例如,先按成绩降序,再按学生ID升序:

SELECT sid, grade FROM enrolled WHERE cid = '15-721' ORDER BY grade DESC, sid ASC;

ORDER BY 中的列不必出现在 SELECT 输出列表中。

使用 LIMITOFFSET 可以限制返回的行数并实现分页。例如,获取成绩最高的前10条记录:

SELECT sid, name FROM student ORDER BY gpa DESC LIMIT 10;

跳过前10条,获取接下来的10条:

SELECT sid, name FROM student ORDER BY gpa DESC LIMIT 10 OFFSET 10;

我们可以将查询结果重定向到一个新表或已存在的表中。使用 INTO 创建新表:

SELECT DISTINCT cid INTO CourseIds FROM enrolled;

使用 INSERT INTO ... SELECT 插入到已存在的表:

INSERT INTO CourseIds (cid) SELECT DISTINCT cid FROM enrolled;

目标表的列数和类型必须与查询输出匹配。


嵌套查询 🔄

嵌套查询允许我们将一个查询的结果作为另一个查询的输入,这提供了强大的表达能力。

一个简单的例子是查找至少选修了一门课程的学生姓名。我们可以用连接(JOIN)实现,也可以用嵌套查询:

SELECT name FROM student WHERE sid IN (SELECT sid FROM enrolled);

这里,内层查询返回所有选课学生的ID集合,外层查询检查哪些学生的ID在这个集合中。

嵌套查询可以出现在 WHERE 子句中,使用 IN, EXISTS, ANY, ALL 等操作符。例如,查找选修了“15-445”课程的学生:

SELECT name FROM student WHERE sid = ANY (SELECT sid FROM enrolled WHERE cid = '15-445');

也可以出现在 SELECT 输出列表中。例如,另一种方式查找选修了“15-445”课程的学生:

SELECT (SELECT S.name FROM student AS S WHERE S.sid = E.sid) AS sname FROM enrolled AS E WHERE cid = '15-445';

更复杂的例子:查找选课学生中ID最大的学生信息。不能直接使用 MAX 聚合函数和非聚合列,但可以通过嵌套查询实现:

SELECT sid, name FROM student WHERE sid >= ALL (SELECT sid FROM enrolled);

或者使用 IN 和子查询:

SELECT sid, name FROM student WHERE sid IN (SELECT MAX(sid) FROM enrolled);

查找没有学生选修的课程:

SELECT * FROM course WHERE NOT EXISTS (SELECT * FROM enrolled WHERE course.cid = enrolled.cid);

窗口函数 🪟

窗口函数允许我们在不将行分组到单一输出行的情况下,对一组相关的行进行计算。它像是聚合函数和 GROUP BY 的结合,但会保留所有原始行。

基本语法是 function_name OVER (...)OVER 子句定义了如何对行进行分区和排序。常见的窗口函数包括:

  • 标准聚合函数:AVG(), SUM(), COUNT()
  • 特殊窗口函数:ROW_NUMBER(), RANK()

例如,为 enrolled 表中的每一行添加一个行号:

SELECT *, ROW_NUMBER() OVER () AS row_num FROM enrolled;

使用 PARTITION BY 在每个分区内重新开始编号。例如,按课程ID分区编号:

SELECT cid, sid, ROW_NUMBER() OVER (PARTITION BY cid) AS row_num FROM enrolled ORDER BY cid;

使用 ORDER BY 在窗口内排序。例如,按成绩排序后编号:

SELECT cid, sid, grade, ROW_NUMBER() OVER (ORDER BY grade) AS row_num FROM enrolled;

一个强大的应用是查找每门课程中成绩最高的学生。这需要结合嵌套查询和窗口函数:

SELECT * FROM (
    SELECT *, RANK() OVER (PARTITION BY cid ORDER BY grade ASC) AS rank FROM enrolled
) AS ranking WHERE ranking.rank = 1;

内层查询使用 RANK() 函数为每门课程(PARTITION BY cid)内的学生按成绩升序排名。外层查询只选取排名第一的记录。


公共表表达式 (CTE) 🧩

公共表表达式(CTE)提供了一种更清晰的方式来编写嵌套查询,并且支持递归,这是普通嵌套查询无法做到的。

CTE 使用 WITH 子句定义,其结果可以在后续的主查询中像普通表一样被引用。例如:

WITH cteName (col1, col2) AS (SELECT 1, 2) SELECT col1 + col2 FROM cteName;

这个CTE创建了一个包含一列(值为1)的虚拟表 cteName,然后主查询从中选择数据。

CTE 可以简化复杂查询。例如,重写之前查找最大学生ID的查询:

WITH maxID (max_id) AS (SELECT MAX(sid) FROM enrolled) SELECT name FROM student, maxID WHERE student.sid = maxID.max_id;

CTE 最强大的特性是递归。使用 WITH RECURSIVE 可以执行迭代操作。例如,生成一个从1到10的数字序列:

WITH RECURSIVE cteSource (counter) AS (
    (SELECT 1)                 -- 初始查询(锚点成员)
    UNION ALL
    (SELECT counter + 1 FROM cteSource WHERE counter < 10) -- 递归成员
)
SELECT * FROM cteSource;

这个递归CTE首先产生数字1(锚点成员),然后反复执行递归成员(将计数器加1),直到条件 counter < 10 不再满足为止。


总结 ✨

本节课中我们一起学习了高级SQL的多个核心主题。我们探讨了如何使用聚合函数和 GROUP BY 对数据进行汇总分析,了解了不同数据库在字符串和日期操作上的差异。我们学习了如何通过 ORDER BYLIMITOFFSET 控制输出,以及如何将结果重定向到其他表。

我们还深入研究了强大的嵌套查询,它允许查询之间相互引用。接着,我们介绍了窗口函数,它能够在保留所有行的同时进行跨行的计算。最后,我们学习了公共表表达式(CTE),它提供了更清晰的结构化查询方式,并且通过递归支持了迭代计算。

掌握这些高级SQL技术将使你能够更高效、更灵活地从数据库中提取和处理信息,解决复杂的数据分析问题。SQL虽然诞生于上世纪70年代,但至今仍在不断演进,是数据领域不可或缺的核心技能。

3:数据库存储 1 💾

在本节课中,我们将要学习数据库系统如何存储数据。我们将从宏观层面了解数据库在磁盘上的组织形式,深入到页面内部的数据结构,并理解为什么数据库系统需要自己管理存储,而不是依赖操作系统。

上一节我们介绍了数据库的逻辑视图(关系表和SQL),本节中我们来看看数据库系统在物理层面是如何组织和存储数据的。

存储层次结构与数据库假设

数据库系统通常被设计为面向磁盘的。这意味着系统假设数据的主要驻留位置是磁盘,而内存(DRAM)容量有限。因此,系统设计的核心目标之一是高效管理数据在磁盘(非易失性存储)和内存(易失性存储)之间的移动。

计算机的存储层次结构如下(从上到下,速度递减、容量递增、成本递减):

  • CPU寄存器 / 缓存
  • DRAM(内存)
  • SSD / 机械硬盘
  • 网络存储

对于数据库系统,我们最关心的是易失性存储(如DRAM)和非易失性存储(如SSD/硬盘)之间的分界线。从磁盘读取数据比从内存读取要慢数个数量级(例如,从内存读取可能需要100纳秒,而从磁盘读取可能需要10毫秒)。因此,数据库系统的核心挑战之一就是最小化访问磁盘带来的性能影响。

为什么数据库系统要自己管理存储?

一个自然的想法是:为什么不直接使用操作系统的内存映射文件功能(如 mmap)来管理数据在磁盘和内存间的移动呢?这样数据库系统就不需要实现复杂的缓冲池了。

以下是数据库系统选择自己管理存储的主要原因:

  1. 放弃控制权:操作系统不了解数据库查询的高级语义(例如,哪些数据即将被访问,哪些数据是“热”数据)。它只能看到一堆低级的读写请求,因此其页面置换策略(如LRU)可能不是最优的。
  2. 正确性问题:对于写入操作,数据库有严格的顺序要求(例如,写日志必须在写数据页之前刷新到磁盘)。操作系统无法理解这些约束,可能导致数据损坏。
  3. 性能瓶颈:通用操作系统的优化策略无法针对数据库的工作负载进行定制。数据库系统可以利用自身知识(如下一个查询可能需要哪些数据)进行预取、更智能的缓存和I/O调度。

虽然少数数据库(如MongoDB的早期版本、LMDB)使用了mmap,但它们通常需要大量额外工作来规避上述问题。主流的生产级数据库系统(如PostgreSQL, MySQL, Oracle)都实现了自己的存储管理器和缓冲池。

核心结论:数据库系统总能比通用操作系统更好地管理自己的数据。

数据库文件的组织

数据库在磁盘上本质上就是一个或多个文件。有些系统(如SQLite)使用单个文件,而有些(如PostgreSQL)使用多个文件和目录。使用多个文件可以避免操作系统对单个文件大小的限制,并便于管理。

这些文件对操作系统来说只是二进制数据,其具体格式是数据库管理系统私有的。负责维护这些磁盘文件的组件称为存储管理器存储引擎

页面:存储的基本单元

数据库文件被组织成一系列固定大小的块,称为页面。页面是数据库在磁盘和内存之间传输数据的基本单位。

需要区分几种不同的“页面”概念:

  • 硬件页面:存储设备(如SSD)保证原子写入的最小单元,通常为4KB。
  • 操作系统页面:操作系统内存管理使用的单位,通常为4KB。
  • 数据库页面:数据库系统内部使用的单位,大小可配置(例如512字节到16KB不等)。这是我们在课程中主要关注的。

每个页面都有一个唯一的页面ID。系统通过一个页面目录来维护页面ID到其在文件中物理位置(文件+偏移量)的映射。这种间接层允许系统在磁盘上移动页面而无需更新所有引用该页面的上层结构。

堆文件组织

堆文件是一种简单的组织方式,它是页面的无序集合。元组可以以任何顺序插入到任何有空间的页面中。系统需要提供API来:1)读取/写入指定页面;2)遍历所有页面(用于全表扫描);3)找到有自由空间的页面(用于插入)。

以下是管理堆文件中页面的两种方法:

1. 链表式堆文件(低效,仅作理解)
在文件头部维护两个链表头指针:一个指向所有空闲页面的链表,另一个指向所有已包含数据的页面链表。要找到空闲页面需要遍历链表,效率低下。

2. 页面目录式堆文件(常用方法)
使用一个或多个特殊的目录页面。目录中的每个条目对应一个数据页面,存储了其页面ID、物理位置以及该页面中的空闲空间大小。当需要插入新数据时,系统可以快速扫描目录条目来找到一个有足够空间的页面,而无需遍历所有数据页面。

页面内的数据布局

现在我们深入到单个页面内部,看看数据(特别是元组)是如何存储的。

面向元组的页面布局:槽式页面

最常见的布局方式是槽式页面。页面被分为三个部分:

  • 页头:存储元数据,如页面大小、校验和、数据库版本、事务可见性信息等。
  • 槽数组:从页面开头尾部增长。每个槽是一个“指针”,存储了对应元组数据在页面内的起始偏移量。槽号(从0开始)是元组在页面内的逻辑标识。
  • 元组数据区:从页面尾部开头增长。新插入的元组数据放在这块区域。

当需要插入新元组时,系统在元组数据区的尾部分配空间,并在槽数组的头部添加一个新槽指向它。删除元组时,只需标记对应的槽为“无效”,元组数据区可能产生“空洞”。系统可以定期运行压缩(Vacuum)进程来重整页面,回收空间。

这种设计的优势

  • 支持变长元组
  • 通过槽数组的间接层,可以在页面内移动元组数据(例如压缩时)而无需更新外部对该元组的引用。外部引用只需知道(页面ID, 槽号)

元组标识符

系统通过记录ID来唯一标识一个元组,通常由页面ID槽号组成。例如:

  • PostgreSQL: ctid (例如 (0,1) 表示第0页,第1槽)
  • MySQL InnoDB: 类似的概念
  • Oracle: ROWID

上层组件(如索引)存储的是记录ID。当需要访问元组时:

  1. 使用页面ID查询页面目录,找到该页面的物理位置并读入内存。
  2. 在页面内,使用槽号查找槽数组,获得元组数据的实际偏移量。
  3. 访问该偏移量获取元组字节序列。

元组内的数据布局

一个元组本身是一个字节序列。其布局通常包括:

  1. 元组头:包含元数据,如空值位图、事务ID等。
  2. 属性数据:按照CREATE TABLE语句中定义的列顺序依次存储各个属性的值。

数据库系统根据系统目录中存储的表模式信息(各列的数据类型、长度等)来解释这个字节序列。

反规范化存储

通常,一个页面只存储单个表的元组。但在某些为了优化查询性能的场景下,可能会进行反规范化存储。例如,如果Bar表通过外键关联Foo表,且两者经常被一起查询,系统可以选择将Bar表的关联数据内联Foo表的元组中存储。这实质上是物理层面的“预连接”,避免了运行时连接操作的开销。一些现代数据库(如文档数据库)常采用这种思想。


本节课中我们一起学习了数据库存储的基础。我们理解了为什么数据库要自己管理存储而非依赖操作系统,知道了数据库文件如何被组织成页面,并通过页面目录进行管理。我们还深入了解了最常见的槽式页面布局,以及如何通过(页面ID, 槽号)来定位元组。这些概念是构建数据库存储引擎的基石。下一节课,我们将继续探讨如何管理这些页面在内存中的缓存,即缓冲池的设计与实现。

4:数据库存储 2

概述

在本节课中,我们将继续深入学习数据库存储的内部机制。我们将探讨如何在元组内部表示数据、如何管理元数据(系统目录),并深入分析两种核心的存储模型:行存储与列存储。理解这些底层设计对于构建高效、适应不同工作负载的数据库系统至关重要。

数据表示

上一节我们讨论了如何在页面和文件中组织数据。本节中,我们来看看如何表示元组内部的具体数据。

一个元组本质上是一个字节序列(字节数组)。数据库管理系统需要解释这个字节数组,识别出整数、浮点数、字符串等不同属性的数据。系统目录中的元数据(如表模式)将指导系统如何解析这些字节。

以下是不同类型数据的常见表示方法:

  • 固定长度类型:如整数、浮点数,通常遵循硬件/编程语言(如C/C++)的标准表示方式(例如IEEE 754标准)。
  • 可变长度类型:如字符串(VARCHAR)、二进制大对象(BLOB)。通常会在数据前添加一个头部,用于存储数据的长度信息,然后是实际的字节序列。这与C语言中以空字符结尾的字符串不同。
  • 日期与时间戳:不同系统实现差异很大。常见做法是存储自某个纪元时间(如UNIX纪元:1970年1月1日)以来的秒数或微秒数。
  • 定点小数:为了精确表示十进制数(如金融数据),避免浮点数的舍入误差,数据库系统需要自行实现定点数的存储和运算逻辑。这比使用CPU原生浮点指令要慢,但能保证精度。

浮点数与定点数的性能对比

以下是一个简单的演示,说明在PostgreSQL中,对REAL(浮点数)和DECIMAL(定点数)类型进行相同聚合查询的性能差异:

-- 使用REAL(浮点数)类型
EXPLAIN ANALYZE SELECT SUM(a + b) FROM real_table;
-- 执行时间约 0.8 秒

-- 使用DECIMAL(定点数)类型
EXPLAIN ANALYZE SELECT SUM(a + b) FROM decimal_table;
-- 执行时间约 1.6 秒

定点数运算更慢,是因为数据库系统需要在软件层面实现复杂的算术逻辑,而不是依赖CPU的单一浮点运算指令。

大值存储

当某个属性的值太大,无法放入单个页面时,有两种主要的处理策略。

以下是两种大值存储策略:

  1. 溢出页:在元组中存储一个指针(如页号+偏移量),指向一个或多个专门的“溢出页”,这些页面链式地存储大值数据。这样,大值数据仍然受数据库系统的事务和恢复机制保护。PostgreSQL的TOAST、MySQL/ SQL Server的溢出页都采用此方式。
  2. 外部存储:不在数据库内部存储数据,而是存储一个外部文件路径(如本地文件系统、HDFS、S3的路径)。当查询需要该数据时,再读取外部文件。这种方式节省了数据库的存储空间,但数据不受数据库系统的事务控制,可能被外部修改。

选择哪种方式取决于数据大小、访问模式以及对性能、一致性和成本的综合考量。

系统目录

系统目录是数据库的“元数据”,它记录了数据库自身的信息,例如有哪些表、表的结构(列名、类型)、索引以及内部统计信息等。

几乎所有数据库系统都将系统目录存储在普通的表里,即“自食其果”。为了便于应用程序移植,SQL标准定义了INFORMATION_SCHEMA视图,提供了一种统一的方式来查询元数据。当然,各数据库也提供了自己的快捷命令(如PostgreSQL的\d,MySQL的SHOW TABLES)。

在实现上,数据库内部通过低级代码直接访问这些目录表,以获取解释元组字节数组所需的模式信息。

存储模型

关系模型本身并未规定数据的物理存储方式。根据不同的工作负载,主要衍生出两种存储模型。

工作负载类型

首先,我们需要了解两种典型的工作负载:

  • 联机事务处理:这类应用频繁地插入、更新或删除少量数据,查询通常只访问少量记录(例如,用户登录、下订单)。其特点是写操作多,查询简单且涉及数据量小。
  • 联机分析处理:这类应用主要用于分析海量历史数据,查询复杂(常涉及多表连接和聚合),需要扫描大量数据,但通常是只读的(例如,生成月度销售报告、用户行为分析)。其特点是读操作多,查询复杂且涉及数据量大。

行存储

行存储(或N-ary存储模型)是将一个元组的所有属性值连续地存储在一起。这是最常见的存储方式,非常适合OLTP工作负载。

优点

  • 插入、更新、删除整个元组的效率高。
  • 需要访问单个实体全部属性的点查询效率极高(一次磁盘I/O即可获取所有数据)。

缺点

  • 对于OLAP查询,如果只需要表中少数几列,也必须将整个元组(包含所有列)读入内存,造成了大量的I/O浪费。

列存储

列存储(或分解存储模型)是将表中每一列的所有值连续地存储在一起。即,每个数据页只存储一个列的数据。这种模型非常适合OLAP工作负载。

优点

  • 减少I/O浪费:查询只需读取涉及的列,无需读取无关列的数据。
  • 高效压缩:同一列的数据类型相同,值域相似,更容易获得高压缩比,进一步减少I/O和内存占用。
  • 利于向量化处理:可以对整列数据进行批量操作,充分利用现代CPU的SIMD指令集,提高处理速度。

缺点

  • 需要重建整行数据的点查询或更新操作性能较差,因为需要从多个列文件中收集数据。
  • 插入单条记录开销大,需要分散写入多个列文件。

列存储并非新技术,但在2000年代后随着数据分析需求的爆发而广泛应用。Vertica、Amazon Redshift、Google BigQuery等都是著名的列存储系统。

总结

本节课我们一起深入探讨了数据库存储的多个关键层面。我们学习了如何在元组内部表示各种数据类型,特别是定点小数与浮点数的取舍。我们了解了当数据过大时的溢出页和外部存储策略。我们还认识了系统目录的作用,它是数据库自我描述的元数据仓库。最后,我们重点对比了行存储和列存储两种核心模型,理解了它们分别最适合OLTP和OLAP两种不同的工作负载场景。掌握这些底层存储知识,是设计、优化和选用数据库系统的基础。从下节课开始,我们将研究如何将这些存储在磁盘上的数据高效地管理在内存中。

5:缓冲池与内存管理 🗄️

在本节课中,我们将要学习数据库系统如何管理内存,特别是如何通过缓冲池(Buffer Pool)高效地从磁盘读取数据页到内存中,以及如何制定策略来优化这一过程,以支持超过物理内存大小的数据库并最小化性能影响。

概述

我们已经讨论了如何在磁盘上表示数据库(如页面目录、槽式页面等)。现在,我们需要了解如何将这些磁盘上的页面有效地取到内存中进行操作。数据库系统无法直接在磁盘上操作数据,必须将数据页读入内存。缓冲池管理器(或称缓冲区缓存)就是负责这项工作的核心组件。它的目标是让系统表现得仿佛整个数据库都在内存中,同时最大限度地减少因数据不在内存而导致的查询延迟(Stall)。

缓冲池基础

上一节我们介绍了数据在磁盘上的组织方式,本节中我们来看看如何将它们带入内存。

缓冲池本质上是数据库系统在内存中分配的一大块区域。系统将这块内存划分为多个固定大小的块,称为(Frame),每个帧的大小与磁盘上的(Page)大小相同。

当数据库执行引擎请求一个特定页时(例如“读取第2页”),缓冲池管理器会执行以下操作:

  1. 检查该页是否已在缓冲池的某个帧中。
  2. 如果在(称为“缓存命中”),则直接返回指向该帧的指针。
  3. 如果不在(称为“缓存未命中”),则需要从磁盘读取该页,放入一个空闲帧中,然后返回指针。

为了追踪页在内存中的位置,系统需要维护一个页表(Page Table)。这是一个哈希表,它将页ID映射到帧ID。这样,系统就能快速找到内存中是否存在某个页以及它在哪里。

除了映射关系,页表还为每个缓冲中的页维护重要的元数据:

  • 脏位(Dirty Flag):一个标记位,指示该页自读入内存后是否被修改过。
  • 钉住计数(Pin/Reference Counter):一个计数器,记录当前有多少个正在运行的查询或线程正在使用该页。钉住的页不能被移出缓冲池,以确保使用它的操作不会出错。

在并发环境下,访问页表等数据结构需要使用闩锁(Latch)进行保护。闩锁是一种低级的、短期的同步原语(类似于互斥锁),用于保护数据库系统内部的关键数据结构和内存区域,与保护数据库逻辑内容(如表、元组)的(Lock)不同。

重要区分:页目录(Page Directory)是磁盘上的持久化结构,用于定位文件中的页。页表(Page Table)是内存中的临时映射,用于追踪页在缓冲池中的位置。页目录必须持久化,而页表不需要。

缓冲池优化策略

一个简单的缓冲池可以工作,但为了获得最佳性能,数据库系统可以采用多种优化策略,这些策略利用了数据库系统比操作系统更了解查询意图的优势。

以下是几种关键的优化技术:

1. 多缓冲池

与其使用单个全局缓冲池,系统可以配置多个缓冲池实例。

优势在于

  • 减少闩锁争用:不同线程访问不同缓冲池的页表时,不会相互竞争同一个闩锁,提高了多核环境下的可扩展性。
  • 定制化策略:可以为不同类型的数据(如表、索引)或不同访问模式的工作负载设置不同的缓冲池,并为每个池定制页面替换策略。

映射方式

  • 扩展记录ID:在记录ID中加入对象标识,通过查表确定对象属于哪个缓冲池。
  • 哈希法:直接对页ID或记录ID进行哈希,根据结果分配到不同的缓冲池。例如:buffer_pool_id = hash(page_id) % num_pools

2. 预取

预取(Prefetching)基于查询计划,提前将未来可能需要的页读入缓冲池,从而避免后续请求时的等待。

示例

  • 顺序扫描:当查询开始全表扫描时,系统可以预判并提前读取接下来的若干页。
  • 索引扫描:对于范围查询(如 WHERE value BETWEEN 150 AND 250),系统在遍历索引树时,可以预取即将访问的叶子节点页,即使这些页在磁盘上并不连续。这是操作系统难以做到的,因为数据库系统理解数据的语义和结构。

3. 扫描共享

扫描共享(Scan Sharing)允许多个查询共享同一个扫描光标(Cursor)。当一个查询正在顺序读取页时,另一个需要相同数据的查询可以“搭便车”,复用已读入缓冲池的页,而不是自己重新发起扫描。

工作方式

  1. 查询Q1开始扫描表。
  2. 查询Q2稍后开始扫描同一张表。
  3. 系统识别到这一点,让Q2附着在Q1的扫描光标上。
  4. Q2从附着点开始接收数据,并在Q1结束后继续扫描剩余部分。

这特别适用于关系模型,因为关系是无序的,从中间开始扫描得到的结果集仍然是有效的。

4. 缓冲池旁路

对于某些已知的、短暂的、不会重复使用的大量数据扫描(如大型排序的中间结果),可以绕过缓冲池。

做法:查询线程分配一小块本地内存,直接从磁盘读取数据到这块内存中进行处理,处理完毕后丢弃。这样做避免了污染全局缓冲池,也省去了查询页表和持有闩锁的开销。

操作系统页面缓存与数据库缓冲池

当数据库通过read()系统调用读取磁盘页时,数据通常会先经过操作系统的页面缓存(Page Cache)。这意味着磁盘上的一个页,可能在操作系统缓存和数据库缓冲池中各有一份副本。

大多数数据库系统(如Oracle, MySQL)倾向于使用直接I/O(Direct I/O)标志打开文件,绕过OS页面缓存,完全由自己管理缓存。这样做的好处是:

  • 避免双重缓存,更高效地利用内存。
  • 完全控制写入顺序和时机,这对于保证崩溃恢复的正确性至关重要(例如,确保日志先于数据页写入磁盘)。

PostgreSQL是一个特例,它默认依赖操作系统的页面缓存。这简化了数据库自身的缓存管理代码,但可能带来轻微的性能开销和双重缓存的问题。

页面替换策略

当缓冲池已满且需要读入新页时,必须决定将哪个现有页移出(替换)。目标是替换掉未来最不可能被使用的页。

经典策略:LRU与时钟算法

  • 最近最少使用:跟踪每个页最后一次被访问的时间戳。替换时,选择时间戳最老的页。维护精确的时间戳开销较大。
  • 时钟算法:LRU的一种高效近似实现。它为每个页设置一个引用位。有一个“钟表指针”循环扫描所有页:
    1. 如果当前页的引用位为1(表示最近被访问过),则将其置为0,指针移向下一个。
    2. 如果引用位为0,则选择该页进行替换。
      这种方法避免了全局排序,开销很小。

替换策略的挑战与优化

  • 顺序扫描污染:一个全表扫描会读入所有页,并更新它们的引用位或时间戳,可能“挤掉”真正需要的热点数据页。
  • 优化方法
    • LRU-K:记录页的最后K次访问时间,根据访问间隔的频率来预测未来访问可能性,而不仅仅是最近一次。
    • 本地化策略:结合多缓冲池,为不同工作负载使用不同的替换策略。
    • 优先级提示:查询执行器可以向缓冲池管理器提供提示。例如,对于B+树索引,根节点几乎总是被访问,可以提示将其“钉”在内存中。

处理脏页

替换一个脏页(被修改过)比替换干净页代价更高,因为需要先将脏页写回磁盘,然后才能复用其帧。

优化手段

  • 后台写入:系统周期性地扫描缓冲池,将脏页批量写回磁盘,使其变“干净”。这样在需要替换时,有更多干净的候选页可供选择,减少替换延迟。
  • 替换策略权衡:策略需要在“替换一个很快能丢弃的干净页(但它可能很快又被用到)”和“替换一个需要额外I/O写回的脏页(但它可能不再需要)”之间做出权衡。

总结

本节课中我们一起学习了数据库内存管理的核心——缓冲池。我们了解了缓冲池的基本架构,包括帧、页表和元数据(脏位、钉住计数)。我们探讨了如何通过多缓冲池、预取、扫描共享和缓冲池旁路等技术来优化性能。我们还分析了数据库缓冲池与操作系统页面缓存的交互,以及关键的页面替换策略(如时钟算法)及其在面对像顺序扫描这样的挑战时的优化思路。

有效的缓冲池管理是数据库高性能的基石,它使系统能够智能地利用有限的内存资源,让基于磁盘的数据库系统获得接近内存数据库的响应速度。

6:哈希表 🗂️

在本节课中,我们将要学习数据库系统中一个核心的数据结构:哈希表。哈希表是一种提供快速查找功能的数据结构,它通过哈希函数将任意键映射到特定的存储位置。我们将探讨其工作原理、不同类型的哈希方案以及在实际数据库系统中的应用。

概述

哈希表是一种无序的关联数组实现,用于将任意键映射到任意值。其核心思想是使用哈希函数计算键的存储位置,从而实现平均情况下的常数时间复杂度查找。在数据库系统中,哈希表被广泛用于内部元数据管理、核心数据存储、临时数据处理以及表索引等场景。

哈希函数 🔑

哈希函数的作用是将任意键(如字符串、整数)转换为一个固定大小的整数值(如32位或64位)。这个整数值用于确定键在哈希表中的起始查找位置。

一个理想的哈希函数需要在速度和碰撞率之间取得平衡。碰撞是指两个不同的键被哈希到同一个表位置的情况。

核心公式slot_index = hash(key) % table_size

现代数据库系统通常使用高性能的哈希函数,例如 Facebook 的 xxHash。它因其出色的速度和低碰撞率而被认为是目前最佳选择之一。其他历史选项包括 MurmurHash、CityHash 和 FarmHash,但它们在性能或碰撞率上通常不及 xxHash。

静态哈希方案

静态哈希方案要求预先知道或估算要存储的键的大致数量,以便分配固定大小的存储空间。如果表变得过满,则需要重建整个表(即重新哈希所有键到一个更大的表中),这是一个昂贵的操作。

线性探测哈希

线性探测是最简单的哈希表实现之一。其解决碰撞的方法是:如果目标位置已被占用,则顺序扫描下一个位置,直到找到空槽为止。

操作流程

  1. 插入:计算哈希位置。如果该位置为空,则插入;否则,顺序向下查找第一个空槽并插入。
  2. 查找:计算哈希位置。从该位置开始顺序扫描,直到找到目标键或遇到空槽(表示键不存在)。
  3. 删除:直接删除会导致查找链断裂。常用方法是使用“墓碑”标记该位置已被逻辑删除但物理上仍视为占用,或者执行复杂的数据移动来填补空缺。

以下是线性探测的简单示例:

# 伪代码示例:线性探测插入
def linear_probe_insert(table, key, value):
    index = hash(key) % len(table)
    while table[index] is not None and table[index].key != key:
        index = (index + 1) % len(table) # 循环回到开头
    table[index] = Entry(key, value)

罗宾汉哈希

罗宾汉哈希旨在通过平衡键的“贫富”程度来优化线性探测。每个键记录其当前位置与原始哈希位置的偏移量(即“距离”)。

核心思想:在插入时,如果新键的偏移量小于当前位置上已有键的偏移量(即新键更“穷”),则新键会“窃取”这个槽位,迫使原有键继续向下探测。目标是使所有键的探测距离尽可能平均,避免个别键的查找路径过长。

布谷鸟哈希

布谷鸟哈希使用两个(或更多)独立的哈希表和哈希函数。每个键可以放在两个表对应的任一位置。

操作流程

  1. 插入:检查两个候选位置。如果任一为空,则插入。如果均被占用,则随机选择一个位置,踢出原有的键,并尝试将该旧键插入到它的另一个候选位置(可能引发连锁反应)。
  2. 查找与删除:只需检查两个固定位置,时间复杂度为严格的 O(1)。

布谷鸟哈希的插入可能触发无限循环,此时需要触发哈希表扩容。

动态哈希方案

动态哈希方案允许哈希表根据需要动态增长,而无需一次性重建整个数据结构。

链式哈希表

这是最直观的动态哈希方案。哈希表的每个槽位不再直接存储数据,而是存储一个桶(如链表)的指针。所有哈希到同一位置的键值对都存储在对应的链表桶中。

  • 优点:实现简单,易于并发控制。
  • 缺点:如果某个链表变得非常长,性能会退化为线性查找。

可扩展哈希

可扩展哈希使用一个指向桶的指针数组(目录)。目录的大小基于一个全局深度(位数)。每个桶有一个本地深度。

工作流程

  1. 插入时,根据键哈希值的前全局深度位找到目录项及对应的桶。
  2. 如果桶已满,则增加全局深度(目录大小翻倍),并分裂溢出的桶。分裂时,根据新的位数将原桶中的条目重新分布到两个新桶中。
  3. 目录翻倍成本较低,因为它只包含指针。

线性哈希

线性哈希旨在避免像可扩展哈希那样一次性翻倍目录所带来的全局锁竞争。它引入一个“分裂指针”,指向下一个待分裂的桶。

工作流程

  1. 当任何桶溢出时,并不立即分裂该桶,而是分裂由“分裂指针”指向的桶(即使它未满)。
  2. 分裂时,添加一个新的桶,并使用一个新的哈希函数(通常为 hash(key) mod 2N)来重新分布被分裂桶中的部分数据。分裂指针随后向前移动。
  3. 查找时,需要根据键的哈希值和当前分裂指针的位置,判断应该使用哪个哈希函数来确定桶的位置。

这种方法将扩容开销分摊到多次插入操作中,避免了集中式的重组。

总结

本节课我们一起深入探讨了哈希表这一数据库系统中的关键数据结构。我们从哈希函数的选择讲起,比较了不同函数的性能。接着,详细介绍了多种哈希方案:从简单的线性探测,到优化的罗宾汉哈希和布谷鸟哈希,再到支持动态扩容的链式哈希、可扩展哈希和线性哈希。

每种方案都有其权衡:静态哈希简单快速但需预知数据量;动态哈希灵活但逻辑更复杂。在数据库的实际应用中,哈希表因其平均 O(1) 的查找速度,被广泛用于内部元数据管理、连接操作等场景。然而,由于其无法支持范围查询,对于通用的表索引,数据库更常使用下一讲将要介绍的 B+ 树。理解这些权衡对于设计和实现高效的数据库系统至关重要。

7:树索引 1 🌳

在本节课中,我们将要学习数据库系统中的一种核心数据结构——树索引。我们将从回顾哈希表的局限性开始,引出为什么需要树索引,并深入探讨B+树的基本概念、结构、操作以及在实际系统中的优化。

上一节我们介绍了哈希表,它适用于点查询,但无法高效支持范围查询。本节中我们来看看如何通过树索引来解决这个问题。

概述

表索引是表中某些属性子集的副本,以更有效的方式存储,允许我们进行高效的查找,避免对整个表进行顺序扫描。数据库系统负责维护索引与底层表的同步,这对应用程序程序员是透明的。虽然索引能加速查询,但也会带来额外的存储和维护成本。

B+树简介

B+树是一种自平衡的树数据结构,它保持数据有序,支持高效的搜索、插入、删除和顺序扫描操作,时间复杂度为 O(log n)。与哈希表不同,B+树能很好地支持范围查询。它最初设计用于磁盘I/O效率,至今仍在各种数据库系统中广泛使用。

B+树与B树的区别

在实践中,“B树”一词常被用来指代B+树,但两者有本质区别:

  • B树:值(记录ID或元组)可以出现在树的任何节点(内部节点或叶节点)。每个键只出现一次。
  • B+树:值出现在叶节点中。内部节点仅存储用于导航的“路标”键,这些键可能会重复(例如,一个键可能出现在内部节点中,但实际数据已在叶节点中被删除)。

现代数据库系统(如PostgreSQL、MySQL)实际使用的是B+树或其变体。

B+树的结构与性质

B+树具有以下关键性质:

  • M路搜索树:每个节点最多有 M 条到子节点的路径。
  • 完美平衡:从根节点到任何叶节点的距离总是相同的。
  • 半满保证:除了根节点,每个节点必须至少半满(即至少包含 ceil(M/2) - 1 个键)。
  • 内部节点:存储键和指向子节点的指针。如果有 K 个键,则有 K+1 个非空子指针。
  • 叶节点:存储键值对,并通过兄弟指针相互连接,支持高效的范围扫描。

以下是B+树结构的简化图示:

         [ 内部节点 / 根 ]
         /      |       \
        /       |        \
[叶节点] <-> [叶节点] <-> [叶节点]
(键值对)    (键值对)    (键值对)

在内部节点中,键用于决定搜索路径(例如,小于键X的走左边,大于等于的走右边)。叶节点中的“值”可以是记录ID(指向堆文件中元组的位置),也可以是元组数据本身(聚集索引)。

B+树的操作

理解了B+树的结构后,我们来看看如何对其进行插入和删除操作,并保持其平衡性。

插入操作

插入新键时,我们需要:

  1. 遍历树,找到应插入的叶节点。
  2. 如果叶节点有空间,则直接插入并保持键有序。
  3. 如果叶节点已满,则需要进行分裂
    • 将原节点一分为二。
    • 将中间的键提升到父节点。
    • 更新父节点的指针以指向新节点。
    • 如果父节点也因此变满,则分裂操作可能递归向上传播,直至根节点。根节点的分裂是树长高的唯一途径。

删除操作

删除键时,我们需要:

  1. 遍历树,找到包含该键的叶节点并删除它。
  2. 如果删除后,叶节点仍至少半满,则操作完成。
  3. 如果删除后,叶节点少于半满,则需要重新平衡:
    • 借用:首先尝试从相邻的兄弟节点“借”一个键(及其对应的值),前提是兄弟节点在借用后仍能保持半满。这通常不需要修改父节点。
    • 合并:如果无法借用,则将该节点与一个兄弟节点合并。合并后,需要从父节点中删除指向被合并子节点的指针和对应的分隔键。这个删除操作可能导致父节点不满足半满条件,从而可能触发父节点的合并操作,并递归向上传播。

注意:在实际系统中,为了性能,可能不会立即执行合并,而是允许节点暂时低于半满,通过后台任务或重建来最终整理树的结构。

B+树的优化与实践

在真实的数据库系统中,B+树的实现包含了许多优化,以适应不同的工作负载和硬件特性。

以下是实践中常用的一些优化技术:

  • 节点/页面大小调整:节点大小通常与磁盘页面大小对齐。对于慢速磁盘(如HDD),使用较大的页面(如1MB)有利于顺序I/O;对于SSD,页面可以更小(如10KB);对于内存数据库,页面可以非常小(如512字节)。
  • 延迟合并:不立即执行删除后的合并操作,以避免频繁的分裂-合并振荡,提升整体吞吐量。
  • 变长键处理:有几种方法处理长度不固定的键(如字符串):
    1. 指针法:在节点中只存储指向实际键的指针。节省空间但增加一次查找开销。
    2. 填充法:用空字符填充所有键至固定长度。简单但可能浪费空间。
    3. 键映射/间接法(最常用):在节点头部存储一个有序的“键指针”数组,指向节点尾部存储的实际变长键值。这样可以在指针数组中进行高效的二分查找。
  • 非唯一键处理:对于允许重复值的列建立索引,有两种主要方法:
    1. 重复键:在叶节点中简单地多次存储相同的键,每个键对应其自己的值(记录ID)。
    2. 值列表:每个键只存储一次,并附加一个列表,包含所有拥有该键的记录ID。
  • 节点内搜索优化
    • 线性搜索:简单,无需保持键在节点内严格有序,适用于小节点或插入频繁的场景。
    • 二分查找:在有序的键数组中查找,效率更高(O(log k)),是常见选择。
    • 插值搜索:如果知道键的分布(如整数范围),可以估算键的大致位置,作为搜索起点。
  • 压缩技术
    • 前缀压缩:在叶节点中,由于键是有序的,相邻键通常有共同前缀。可以提取并只存储一次公共前缀,然后存储每个键独有的后缀部分。
    • 后缀截断:在内部节点中,用于导航的键可能不需要存储完整的值,只需存储能区分左右子树的最小前缀即可。
  • 批量加载:当需要为一个已有大量数据的表创建索引时,自下而上批量构建比逐条插入高效得多。方法是先对所有键排序,然后直接构建满的叶节点层,再向上构建内部节点层。
  • 指针切换:对于确信会常驻内存的页面(如B+树的上层节点),可以直接在父节点中存储子节点的内存指针,而不是页面ID。这样可以避免每次遍历时都查询缓冲池管理器进行页ID到指针的转换,从而显著提升遍历速度。当页面被换出时,需要将指针恢复为页面ID。

总结

本节课中我们一起学习了数据库索引的核心——B+树。我们首先了解了为什么需要树索引来支持范围查询,然后深入探讨了B+树的结构、性质以及与B树的区别。我们详细分析了B+树的插入、删除操作以及保持平衡的机制。最后,我们介绍了一系列使B+树在现代数据库系统中高效运行的优化技术,包括节点大小调整、变长键处理、压缩和批量加载等。B+树因其出色的磁盘I/O友好性和强大的操作性能,至今仍是关系数据库中最主要的索引数据结构。

8:树索引 2

在本节课中,我们将继续深入学习树索引。我们将探讨如何处理B+树中的重复键,了解表聚簇的概念,并学习几种高级索引技术,如部分索引、覆盖索引和函数索引。最后,我们将简要介绍基数树和倒排索引,作为B+树和哈希索引的替代方案。

处理重复键

上一节我们介绍了B+树的基本结构,本节中我们来看看如何处理B+树索引中的重复键。有两种主要方法。

第一种方法是自动使每个键变得唯一。具体做法是在插入索引的键后附加该元组的记录ID(Record ID)。记录ID通常是页面ID和槽偏移量的组合,用于唯一标识元组的物理位置。这样,即使属性值相同,组合键(属性值+记录ID)也是唯一的。在B+树中,我们仍然可以进行部分键查找(例如,仅根据属性值查找),然后沿着叶节点扫描找到所有匹配项。

第二种方法是使用溢出叶节点。当叶节点已满且需要插入重复键时,不进行节点分裂,而是添加一个溢出页面来存放新条目,类似于链式哈希表。这样,一个逻辑叶节点可能由多个物理页面组成。

以下是两种方法的对比:

  • 附加记录ID:优点是不需要修改数据结构逻辑;缺点是增加了索引的存储大小。
  • 溢出页:优点是不存储冗余信息;缺点是增加了管理的复杂性,例如在反向扫描时需要额外逻辑。

表聚簇

表聚簇是指使用某个索引来强制表中元组本身的物理存储顺序。在默认情况下,关系数据库中的表是无序的。

一些数据库系统(如SQL Server、Oracle)支持创建聚簇索引,这意味着表数据将按照索引定义的顺序存储。对于这类表,顺序扫描可能非常高效,甚至可以直接在表上进行二分查找。

在PostgreSQL中,可以使用 CLUSTER 命令基于某个索引对表进行一次性的重新排序。但这并非自动维护,在后续插入后,顺序可能会被打乱,需要再次执行 CLUSTER

高级索引技术

除了标准的B+树索引,还有几种高级索引技术可以优化特定类型的查询。

部分索引

部分索引只对表中满足特定条件(WHERE子句)的元组子集创建索引。这可以减小索引的大小,提高查询效率,并减少缓冲池的污染。

例如,可以为每个月的数据创建一个部分索引,以快速查询该月内的订单。

覆盖索引

当一个查询所需的所有列都包含在某个索引中时,数据库可以仅通过扫描索引来获取结果,而无需回表查找数据页。这被称为覆盖索引。覆盖索引不是一种特殊的索引类型,而是数据库查询优化器可以识别并利用的一种特性。

例如,如果索引包含列(a, b),查询 SELECT b FROM table WHERE a = 10 就可以使用覆盖索引。

包含列的索引

某些数据库系统(如SQL Server, PostgreSQL 11+)支持INCLUDE子句。这允许在索引的叶节点中包含额外的列,但这些列不作为索引键的一部分用于查找。这结合了覆盖索引的优点,同时避免了将这些列作为搜索键可能带来的开销。

例如:CREATE INDEX idx ON table (a) INCLUDE (b, c);。查找a=10时,可以直接从叶节点获取bc的值。

函数/表达式索引

索引不仅可以建立在列的直接值上,还可以建立在列的函数或表达式上。这对于查询条件中包含函数计算的场景非常有用。

例如,有一个login_time字段,想要查找所有在星期二登录的用户。可以创建一个表达式索引:CREATE INDEX idx_day ON users (EXTRACT(dow FROM login_time));。之后,查询 SELECT * FROM users WHERE EXTRACT(dow FROM login_time) = 2; 就可以利用这个索引。

注意:表达式在创建索引时被计算并固定,因此如果表达式依赖于如CURRENT_TIMESTAMP这样的动态值,可能无法达到预期效果。

基数树简介

基数树是字典树的一种压缩形式。与B+树在节点中存储完整键的副本不同,基数树将键分解为数字(如字节),并沿着树的不同层级存储这些数字。

它的特点包括:

  • 形状确定:树的形状只取决于键的分布和长度,与插入顺序无关。
  • 无需再平衡:不像B+树需要复杂的分裂与合并操作。
  • 操作复杂度为O(k):查找、插入和删除的复杂度取决于键的长度k,而不是数据量N
  • 隐式存储键:键由从根到叶的路径隐式表示,节省了空间。
  • 点查询快,范围扫描慢:对于点查询,可能在到达叶节点前即可确定键不存在;但进行顺序扫描比B+树更复杂。

基数树通过水平压缩(不存储数字值本身,仅通过指针偏移隐含)和垂直压缩(合并只有一个子节点的路径)来进一步优化。虽然基数树在学术和特定系统(如HyPer)中很有趣,但目前B+树仍然是商用数据库系统中主流的索引结构。

倒排索引简介

B+树和哈希索引擅长处理点查询和范围查询,但不适用于关键字搜索(例如,在文本中查找包含某个单词的记录)。

倒排索引正是为此设计。它将文档中的单词(或词条)映射到包含该单词的文档列表(记录ID)。这有时被称为全文搜索索引。

使用倒排索引,可以高效执行:

  • 关键字搜索:查找包含特定单词的所有记录。
  • 短语搜索:查找包含特定单词序列的记录。
  • 邻近搜索:查找两个单词在特定距离内出现的记录。

倒排索引可以在数据库内部实现(如PostgreSQL的全文搜索),也可以由外部专用系统提供(如Elasticsearch)。其内部通常使用B+树或哈希表来存储从词条到发布列表的映射,并在发布列表中存储额外的上下文信息(如位置、频率)以支持高级查询。

总结

本节课中我们一起深入探讨了树索引的多个高级主题。我们学习了处理B+树中重复键的两种策略,了解了表聚簇如何优化数据物理布局。接着,我们介绍了几种强大的索引技术:部分索引、覆盖索引、包含列的索引和函数索引,它们能显著提升特定查询模式的性能。最后,我们简要了解了作为B+树替代方案的基数树,以及用于全文搜索的倒排索引的基本概念。掌握这些知识,将帮助你更好地理解和设计高效的数据库索引策略。

9:多线程索引并发控制 🔒

在本节课中,我们将要学习如何让数据库系统中的索引数据结构(如哈希表和B+树)在多线程环境下安全地工作。我们将重点讨论锁存器的使用,这是一种保护数据结构内部物理完整性的机制,确保多个线程同时读写时不会导致数据损坏或程序崩溃。


概述

到目前为止,我们在讨论哈希表和B+树等数据结构时,都假设只有一个线程在访问它们。但在真实的数据库系统中,为了充分利用多核CPU并减少I/O等待带来的延迟,我们需要允许多个线程并发地访问这些数据结构。本节课的核心就是介绍如何通过锁存器协议来实现这一点,确保并发访问时的物理正确性


锁存器与锁的区别

在深入细节之前,我们需要明确数据库领域中两个关键概念的区别:锁存器

上一节我们介绍了并发控制的基本目标,本节中我们来看看实现这一目标的两类不同机制。

  • :这是一种高级逻辑概念,用于保护数据库的逻辑内容,例如元组、表或整个数据库。锁在事务的整个持续时间内持有,并且系统需要能够回滚锁保护对象上的更改。锁主要用于隔离不同事务之间的操作。
  • 锁存器:这是一种低级保护机制,类似于操作系统中的互斥锁。它用于保护数据库系统内部数据结构的临界区,防止多个线程同时读写导致物理结构损坏(如指针失效)。锁存器只在一个操作的短暂临界区内持有,操作完成后立即释放,通常不需要支持回滚。

以下是两者的核心区别总结:

特性 锁存器
保护对象 数据库逻辑内容(如元组、表) 内部数据结构(如索引页、哈希桶)
持有时间 整个事务持续时间 操作临界区持续时间
模式 多种(共享、独占、意向锁等) 两种(读、写)
死锁处理 通过事务管理器检测和解决(如超时、回滚) 通过编码规范避免(如按固定顺序获取)
回滚 支持,需记录日志 通常不支持,操作被视为原子

我们本节课的重点是锁存器,它确保数据结构的物理完整性。关于锁和事务的更多内容,将在后续课程中讨论。


锁存器的实现

锁存器是如何实现的呢?主要有两种方式:

  1. 操作系统阻塞式互斥锁:例如C++标准库中的 std::mutex。使用简单,但获取失败时线程会被操作系统挂起,上下文切换开销较大,在高竞争场景下性能不佳。
  2. 自旋锁存器:在用户空间实现,通常基于CPU的原子指令(如compare-and-swap)实现。线程会通过循环不断尝试获取锁存器(“自旋”),避免了陷入内核的开销,非常高效。

自旋锁存器的一个简单代码示例如下:

std::atomic_flag latch = ATOMIC_FLAG_INIT; // 原子标志,表示锁存器状态

// 尝试获取锁存器
while (latch.test_and_set(std::memory_order_acquire)) {
    // 获取失败,可以在此处选择自旋、让出CPU或放弃
}
// 成功进入临界区...
// 执行操作...
latch.clear(std::memory_order_release); // 释放锁存器

在实际数据库系统中,我们通常需要支持读写锁存器,即在基本自旋锁或互斥锁之上,管理读线程和写线程的队列,以支持共享读和独占写。


哈希表的并发控制

对于哈希表(特别是线性探测哈希表)的并发控制相对简单,因为所有线程的探测方向都是一致的(从上到下,循环扫描),这从根本上避免了死锁的可能性。

实现时主要考虑锁存器的粒度

  • 页级锁存器:每个哈希桶(或页)有一个读写锁存器。优点是存储开销小,实现简单;缺点是可能降低并行度,因为访问同一页不同槽位的线程也会被串行化。
  • 槽级锁存器:每个槽位都有一个锁存器。优点是并行度高;缺点是存储和管理开销大,遍历时需要获取和释放多个锁存器。

以下是两种粒度下线程操作的简要对比:

  • 使用页级锁存器时,线程在访问一个页之前获取该页的锁存器,在跳转到下一个页之前释放当前页的锁存器。
  • 使用槽级锁存器时,线程可以以更细的粒度交错执行,例如一个线程在槽A上等待时,另一个线程可以处理槽C。

由于所有操作方向一致,线程在跳转到下一个桶/页时,可以安全地释放当前持有的锁存器,因为目标位置由静态的哈希函数决定,不会被其他线程改变。


B+树的并发控制:锁存器耦合

B+树的并发控制更为复杂,因为线程不仅可能修改同一个节点,而且在遍历过程中,下方的线程可能进行分裂或合并操作,改变树的结构,导致上方线程的指针失效。

解决这个问题的标准技术称为锁存器耦合(或锁存器爬行)。其核心规则是:当从父节点移动到子节点时,必须在持有父节点锁存器的情况下,尝试获取子节点的锁存器。获取子节点锁存器后,如果判断该子节点是“安全”的,则可以释放父节点的锁存器。

“安全”节点的定义:对于一个修改(插入/删除)操作,如果当前节点不可能因为后续操作而发生分裂或合并,则该节点是安全的。

  • 插入安全:节点未满(有空间容纳新键)。
  • 删除安全:节点超过半满(删除一个键后不会触发合并)。

让我们看看不同操作下锁存器耦合的流程:

  • 查找操作:全程获取读锁存器。每到达一个子节点并确认其位置后,即可释放其父节点的读锁存器。
  • 插入/删除操作(悲观路径):从根节点开始,根据操作类型获取写锁存器。向下遍历时,只有遇到“安全”节点时,才能释放其所有祖先节点的写锁存器。这保证了在可能修改路径上持有必要的锁存器。

乐观锁存器耦合

悲观路径中,每次修改操作都从根节点开始获取写锁存器,这会使根节点成为性能瓶颈。为了提升并发度,我们可以采用乐观锁存器耦合

其基本思想是:假设大多数修改操作不会导致叶子节点的分裂或合并。因此,首先尝试快速路径

  1. 像查找操作一样,使用读锁存器进行锁存器耦合,遍历到目标叶子节点。
  2. 在叶子节点上获取写锁存器。
  3. 检查操作是否真的会导致分裂/合并。
    • 如果不会,则直接完成操作,成功返回。这避免了从根节点开始的写锁存器竞争。
    • 如果,则放弃当前所有锁存器,回退并重启整个操作,这次使用悲观的写锁存器路径。

在真实的数据库系统中,由于节点(页)通常较大(如8KB),能容纳很多键,因此大多数插入/删除操作不会触发分裂/合并,乐观假设通常是成立的,能显著提升性能。


叶子节点扫描与死锁处理

当B+树支持通过兄弟指针进行叶子节点范围扫描时,线程的移动方向就不仅是从上到下,还包括了在叶子层的水平移动。这引入了死锁的可能性。

例如:

  • 线程1持有节点A的写锁存器,希望获取节点B的读锁存器以继续向右扫描。
  • 线程2持有节点B的写锁存器,希望获取节点A的读锁存器以继续向左扫描。
  • 双方互相等待,形成死锁。

在事务级别的锁管理中,有死锁检测器来处理这种情况。但在低级别的锁存器世界中,没有全局协调器。最实用和简单的解决方案是:当线程无法立即获取所需的锁存器时,立即中止当前操作,释放所有已持有的锁存器,然后重试

这虽然可能导致重试开销,但实现简单,且能有效避免死锁。线程可以加入短暂的随机退避时间再重试,以减少冲突。


B-link树优化:延迟父节点更新

为了进一步优化高并发插入场景,B-link树提出了一种改进:在叶子节点发生分裂时,不立即更新父节点

具体步骤是:

  1. 线程乐观地遍历到叶子节点,发现需要分裂。
  2. 它仍然完成叶子节点的分裂操作,创建新节点,并设置好兄弟指针。
  3. 但是,它不直接更新父节点来加入对新节点的引用,而是将一个“提示”(如待插入的键值及新页面ID)记录在一个与父节点关联的“临时区域”。
  4. 该线程随后释放锁存器并返回。父节点的更新被延迟。
  5. 后续任何一个线程在遍历到该父节点并需要获取其写锁存器时(无论是为了插入、删除还是其他分裂),会检查这个“临时区域”。如果发现有待处理的更新,则该线程会“好心”地帮助完成父节点的更新操作。

这种延迟更新技术减少了持有父节点写锁存器的时间,降低了竞争,提升了并发性。


总结

本节课中我们一起学习了数据库索引在多线程环境下的并发控制机制。

我们首先区分了保护逻辑内容的和保护物理结构的锁存器。然后,我们探讨了锁存器的两种实现方式:操作系统互斥锁和更高效的自旋锁。

接着,我们分析了如何为哈希表这种相对简单的结构添加并发控制,其关键在于所有操作方向一致,避免了死锁。

课程的重点是B+树的并发控制。我们深入学习了锁存器耦合协议,它通过按顺序获取和释放锁存器,并定义“安全”节点,来保证遍历过程中的结构完整性。为了提升性能,我们引入了乐观锁存器耦合,先尝试快速读路径,失败时再回退到保守的写路径。

最后,我们讨论了叶子节点扫描可能引发的死锁问题,并给出了“中止-重试”的解决方案。此外,还简要介绍了B-link树通过延迟父节点更新来优化并发插入性能的技术。

掌握这些底层并发控制技术,对于构建高性能、高可靠的数据库系统至关重要。这些思想不仅适用于数据库索引,也广泛存在于其他对性能要求苛刻的并发系统中。

10:排序和聚合

在本节课中,我们将要学习数据库系统中两个核心的物理操作:排序和聚合。我们将探讨当数据无法完全装入内存时,如何设计高效的算法来处理这些操作。课程将重点介绍外部归并排序算法和基于哈希的聚合算法,并比较排序与哈希两种策略的优劣。

课程安排与背景

在深入今天的内容之前,我们先快速回顾一下接下来两周的课程安排。作业三今天发布,将于下周三(9号)截止。项目二也将在今天发布,期中考试定于16号(周三)正常上课时间进行,时长为80分钟,涵盖截至下周三(9号)课程的所有内容。

目前,我们已经学习了数据库系统的架构层次,包括如何在磁盘上存储数据页、如何通过缓冲池管理器将数据页调入内存,以及如何通过索引或顺序扫描访问数据。现在,我们将进入查询执行阶段,学习如何为SQL查询生成查询计划,并利用访问方法获取所需数据。

在接下来的两周,我们将首先讨论如何实现各种查询操作符的算法,然后探讨查询处理的不同方式(例如数据在操作符间的传递),最后讨论系统的运行时架构(如线程或进程的组织,以实现并行查询执行)。

查询计划简介

在深入算法细节之前,我们先简要了解一下查询计划。查询计划本质上是数据库系统执行给定查询的高级指令集,通常组织成树形结构或有向无环图。

例如,对于一个包含连接和过滤的SQL查询,其逻辑查询计划可能如下所示:叶子节点执行表扫描,然后将元组向上传递给连接操作符,连接操作符的结果再传递给投影操作符。逻辑计划只描述要做什么(如连接、过滤),而不指定具体如何做(如使用哪种连接算法、哪种扫描方式)。

本节课和下节课的重点,就是探讨实现这些物理操作符的具体算法。之后,在讨论查询优化时,我们会将这些算法结合起来,为查询计划选择最佳的执行策略。

设计这些算法时,我们必须考虑一个核心约束:数据(包括中间结果)可能无法完全装入主内存。因此,我们需要设计能够处理磁盘I/O的算法,并利用我们在第一个项目中构建的缓冲池管理器来管理超出内存容量的数据。与普通算法课程不同,我们需要特别关注数据的访问模式,尽可能最大化顺序I/O,因为顺序I/O比随机I/O高效得多。

我们将首先讨论外部归并排序算法,从中可以看到适用于其他操作符的高级分治策略。然后,我们将讨论聚合操作,它既可以依赖排序,也可以使用哈希,这自然过渡到下周要讲的哈希连接。排序和哈希是数据库系统中执行算法的两种主要方法,各有优劣。

为什么需要排序?📊

在关系模型中,关系中的元组本质上是无序的。我们不能假设读取数据时它们会按某种特定顺序排列。虽然聚簇索引可以基于某个键强制排序,但并非所有表都有聚簇索引,并且即使有,我们可能需要按另一个不同的键排序。

除了直接支持ORDER BY子句外,排序还能优化其他操作:

  • 去重:如果数据已排序,只需扫描一次即可消除重复项。
  • 分组聚合:如果数据已按分组键排序,可以一次扫描计算运行总计。
  • 批量加载B+树:预排序数据后自底向上构建索引,效率更高。

因此,排序是数据库系统中一项非常有用的工具性操作。

如果所有待排序数据都能装入内存,我们可以直接使用任何经典的内存排序算法(如快速排序、堆排序)。问题在于数据无法装入内存时,像快速排序这样进行大量随机内存访问的算法会引发大量随机磁盘I/O,性能极差。因此,我们需要一种对磁盘I/O成本敏感、能最大化顺序I/O的算法。

外部归并排序 🔄

几乎所有支持超内存排序的数据库系统都使用外部归并排序算法。这是一种分治策略,基本思想是将待排序数据集分割成较小的块(称为“归并段”或“run”),先分别排序这些块,然后逐步合并它们,最终得到完全排序的结果。

该算法分为两个阶段:

  1. 阶段一:将数据分成若干“归并段”,每个归并段的大小等于内存可容纳的页数(B)。将每个归并段读入内存进行排序,然后写回磁盘。
  2. 阶段二:递归地合并已排序的归并段,生成越来越大的有序归并段,直到合并成一个包含所有数据的完整有序归并段。

这个过程可能需要对数据集进行多轮(pass)处理。

二路归并排序

我们先看一个简单的例子:二路归并排序。“二路”指的是在每一轮合并中,每次合并两个归并段。

假设数据集包含N个数据页,我们有B个缓冲页可用。在第0轮,我们每次读入B页数据到内存排序,然后写回磁盘,生成若干个大小为B页的已排序归并段。

后续轮次,我们合并归并段。要进行二路合并,我们至少需要3个缓冲页:两个用于存放待合并的输入归并段的当前页,一个用于存放输出页。我们使用两个“游标”分别扫描两个输入归并段,比较当前键值,将较小的输出到输出页,并移动相应游标。当输出页写满时,将其写回磁盘。

轮次数与I/O成本

  • 总轮次数 = 1 + ⌈log₂ N⌉。其中1代表第0轮的排序阶段。
  • 总I/O成本 = 2N * 轮次数。因为每一轮都需要读入和写出所有数据一次。

多路归并排序 (K-Way)

二路归并排序只使用了3个缓冲页,即使有更多可用内存也无法提升性能。我们可以进行推广,使用多路归并排序(K路),在一次合并中合并多个(K个)归并段。

假设有B个缓冲页可用。我们使用B-1个缓冲页来存放输入归并段(因为至少需要1个缓冲页用于输出)。因此,K = B-1。

计算过程

  • 第0轮:生成归并段。归并段数量 = ⌈N / B⌉。每个归并段大小为B页(最后一个可能小于B页)。
  • 后续轮次:每一轮合并,我们将上一轮的归并段数量除以 (B-1)(向上取整),直到结果为1,即得到最终排序结果。
  • 总I/O成本仍然是 2N * 轮次数。

优化技巧:双缓冲
一个简单的优化是双缓冲,即使用异步I/O预取下一批需要处理的数据页。这样,当CPU处理当前数据时,磁盘可以并行地读取下一批数据,减少I/O等待时间。

使用B+树加速排序

在某些情况下,我们可以利用现有的B+树索引来加速排序操作,避免昂贵的外部归并排序。

  • 聚簇索引:如果表上存在聚簇索引,且排序键与索引键相同,那么数据在物理上已经按照该键排序。此时,只需遍历索引的叶节点即可按序获取所有数据,无需额外排序。
  • 非聚簇索引:如果使用非聚簇索引来获取排序顺序,情况会很糟糕。因为索引键是排序的,但对应的数据记录可能分散在磁盘各处。每获取一条记录都可能引发一次随机磁盘I/O,效率极低。

因此,当查询需要按某个键排序,且恰好存在该键的聚簇索引时,查询优化器应选择使用索引,而不是执行排序算法。

聚合操作 🧮

接下来,我们看看聚合操作。聚合是另一个可以体现排序与哈希算法权衡的典型操作。通常,在磁盘I/O成为瓶颈的场景下,哈希方法往往表现更优,尤其是当我们可以设计哈希聚合算法以进行更多顺序I/O时。

使用排序实现聚合

如果数据已经排序,实现聚合(如去重DISTINCT或分组GROUP BY)非常高效。因为排序后,相同的键会相邻排列。我们只需对数据扫描一次,在扫描过程中即可完成去重或计算分组聚合值。

例如,一个查询需要获取成绩为B或C的不重复课程ID,并按课程ID排序。一个可能的查询计划是:

  1. 过滤:首先过滤出成绩为B或C的元组。
  2. 投影:丢弃不需要的列(如学生ID、成绩),只保留课程ID。
  3. 排序:按课程ID排序。
  4. 去重:扫描排序后的数据,移除连续的重复课程ID。

这个计划中,排序既满足了ORDER BY的要求,又为去重创造了条件,一举两得。但如果不要求输出有序,排序本身的开销可能就显得不必要了。

使用哈希实现聚合

哈希是另一种实现聚合的方法,特别适用于不需要有序输出的场景。

内存哈希聚合
如果所有不同的键可以装入内存,方法很简单:构建一个临时哈希表。扫描输入数据时,对每个元组的聚合键计算哈希值:

  • 对于DISTINCT:如果键不在哈希表中,则插入;如果已在,则忽略(重复)。
  • 对于GROUP BY聚合(如SUM, AVG):如果键不在哈希表中,则插入并初始化聚合值(如计数和总和);如果已在,则更新聚合值。

这种方法具有O(1)的查找/更新复杂度,非常高效。但问题在于,如果键的数量太多,哈希表无法完全装入内存,性能会因大量随机磁盘I/O而急剧下降。

外部哈希聚合
为了解决内存不足的问题,我们可以采用与外部归并排序类似的分治策略,即外部哈希聚合。它也分为两个阶段:

  1. 分区阶段

    • 使用一个哈希函数h1将元组分散到多个分区中。确保相同的键一定会进入同一个分区。
    • 我们使用B-1个缓冲页来存储这些分区的输出(每个分区在内存中有一个页缓冲区)。当某个分区的缓冲区满时,就将其写回磁盘。
    • 此阶段是顺序的:读入一页数据,对其中每个元组用h1计算其应属的分区,并写入对应的缓冲区。
  2. 探测阶段

    • 逐个处理每个分区。将整个分区读入内存(如果分区太大,则分批处理)。
    • 在内存中为当前分区构建一个新的(第二个)哈希表,使用另一个哈希函数h2
    • 扫描该分区内的所有元组,对每个元组执行与内存哈希聚合相同的操作(插入/更新临时哈希表)。
    • 处理完一个分区后,输出该分区的聚合结果,清空内存中的哈希表,然后处理下一个分区。

这种方法的关键在于,分区阶段通过哈希将所有相同键的元组聚集到相同的磁盘分区中。因此,在探测阶段,我们可以在内存中完全处理一个分区的所有重复键,而不用担心其他分区的数据。这最大限度地减少了随机I/O,因为每个分区内的处理是顺序的,且分区之间无需交叉访问。

对于GROUP BY聚合,内存中的哈希表需要存储更复杂的状态。例如,计算平均成绩AVG(GPA),哈希表的值部分需要存储两个值:该组的GPA总和以及元组计数。最终输出时,用总和除以计数得到平均值。

总结 📝

本节课我们一起学习了数据库系统中两个基本操作的实现:排序和聚合。

  • 我们深入探讨了外部归并排序算法,这是一种用于超内存数据排序的分治算法。我们学习了二路和多路归并,并分析了其I/O成本。
  • 我们了解到,如果存在与排序键匹配的聚簇索引,可以避免排序操作。
  • 对于聚合操作,我们比较了基于排序和基于哈希的两种实现方法。
    • 排序方法在数据有序时效率很高,尤其当查询本身就需要有序输出时。
    • 哈希方法通常更快,尤其是对于不需要有序输出的聚合。我们重点介绍了外部哈希聚合算法,它通过分区和重哈希两个阶段,有效地处理了内存无法容纳所有键的情况。
  • 排序和哈希背后的分治与分区思想,是数据库系统算法设计中反复出现的核心主题,我们将在后续课程(如连接操作)中再次看到它们。

在下节课中,我们将把注意力转向数据库系统中另一个至关重要且耗时的操作:连接(Join),并详细探讨排序合并连接和哈希连接等算法。


项目二说明

项目二是构建一个线程安全的线性探测哈希表。这个哈希表将基于你在项目一中实现的缓冲池管理器,这意味着它不是纯粹的内存哈希表,而是由磁盘页支持的。

核心任务

  1. 设计页布局:你需要设计哈希表头页和块数据页的布局,理解如何将缓冲池中的普通页解释为存储特定数据结构的页。
  2. 实现哈希表:实现基本的插入、删除、查找操作,并保证线程安全(使用提供的读写锁存器)。
  3. 支持动态扩容:当哈希表变满时,需要支持调整大小(扩容)。这需要锁住整个表,创建两倍大小的新表,然后重新哈希所有现有元素。

实施建议

  • 首先确保页布局正确无误,这是基础。
  • 先实现单线程版本并保证正确性,再添加并发控制。
  • 最后进行性能优化。项目会有一个排行榜。

请关注Piazza上的官方通知,获取最新的代码仓库和测试用例。请勿抄袭。


下节课我们将学习连接算法,特别是哈希连接。

11:连接算法 🧩

在本节课中,我们将要学习数据库系统中一个至关重要的操作:连接(Join)。连接是关系数据库的核心,用于合并多个表中的数据。我们将专注于一次连接两个表的内部等值连接,并探讨几种主要的连接算法,分析它们的成本与适用场景。本节课结束时,你将理解嵌套循环连接、排序合并连接和哈希连接的基本原理与性能差异。

为什么要进行连接?🔗

连接是关系数据库系统规范化的副产品。为了减少数据冗余,我们将数据拆分到不同的表中。当需要查询跨表信息时,就必须通过连接操作来重建完整的元组。最常见的场景是通过外键关联表,例如订单表和订单明细表。连接操作允许我们高效地收集所有相关信息。

在分析型查询中,表通常非常庞大,因此选择高效的连接算法至关重要,这可能意味着分钟、小时甚至天级的性能差异。

连接算法的设计考量 ⚙️

在深入算法之前,我们需要明确两个设计决策:

  1. 连接算子的输出是什么? 是发送完整的拼接后元组,还是只发送必要的属性或记录ID(后期物化)?这取决于系统的存储模型(行存或列存)和查询的具体需求。
  2. 如何评估算法的优劣? 我们主要依据I/O成本来衡量。我们使用以下变量:
    • 表 R:共 M 页, m 个元组。
    • 表 S:共 N 页, n 个元组。
      我们关注的是计算连接本身的成本,而非产生最终输出的成本,因为对于同一数据集,不同算法产生的输出结果是相同的。

我们主要讨论三类连接算法:嵌套循环连接、排序合并连接和哈希连接。


嵌套循环连接 🔄

嵌套循环连接是最基础、最直观的连接算法。其思想是:对于外表(左表)中的每一个元组,遍历内表(右表)中的每一个元组,检查连接条件是否满足。

基础版本(元组嵌套循环)

算法伪代码如下:

for each tuple r in R:
    for each tuple s in S:
        if condition(r, s) is true:
            emit output tuple

其 I/O 成本为:M + (m * N)。这非常昂贵,因为它为外表的每个元组都需要扫描整个内表。

优化版本(块嵌套循环)

我们可以利用数据以页为单位存储的特性进行优化。一次读取一个块(多行)到内存,基于块进行嵌套循环。

算法伪代码如下:

for each block Br in R:
    for each block Bs in S:
        for each tuple r in Br:
            for each tuple s in Bs:
                if condition(r, s) is true:
                    emit output tuple

其 I/O 成本为:M + (M * N)。性能有所提升。

进一步优化(缓冲块嵌套循环)

如果内存中有 B 个缓冲页,我们可以分配 (B-2) 个页给外表,1个页给内表,1个页用于输出。这样,我们可以一次将外表的 (B-2) 个块读入内存,然后只扫描内表一次。其成本约为:M + ceiling(M/(B-2)) * N

关键优化点

  • 选择较小的表作为外表
  • 尽可能利用内存缓冲外表
  • 如果内表在连接键上有索引,可用索引查找替代内表全扫描,将成本从 N 降低到索引查找的常数成本。

尽管经过优化,嵌套循环连接本质上仍是一种暴力搜索,适用于小表或内表有高效索引的场景。


排序合并连接 📊

排序合并连接利用数据有序的特性来避免不必要的扫描。它分为两个阶段:排序阶段和合并阶段。

算法步骤

  1. 排序阶段:使用外部归并排序等算法,根据连接键对两个输入表 R 和 S 进行排序。
  2. 合并阶段:使用两个指针,分别指向已排序的 R 和 S 的开头,然后协同遍历。
    • 如果 R 的当前键 < S 的当前键,移动 R 的指针。
    • 如果 R 的当前键 > S 的当前键,移动 S 的指针。
    • 如果相等,则输出匹配的元组,并处理可能存在的重复键(可能需要回溯 S 的指针)。

性能分析

  • 成本:排序成本(2 * 对每个表排序的成本) + 合并成本(约 M + N)。
  • 优点:当输入数据已排序(例如,存在聚集索引)或查询本身需要有序输出时,效率很高。合并阶段通常只需对每个表扫描一次。
  • 缺点:如果数据严重倾斜(例如,连接键只有一个值),排序带来的收益很小,可能退化为类似嵌套循环连接的行为。

排序合并连接在数据已预排序或查询包含 ORDER BY 子句(且排序键与连接键相同)时是理想选择。


哈希连接 ⚡

哈希连接是现代数据库系统中最常用且通常性能最好的连接算法,尤其适合大型数据集。其核心思想是使用哈希函数将数据分区,使得连接操作只需在对应的分区内进行。

基础哈希连接(内存可容纳)

假设哈希表可完全放入内存。

  1. 构建阶段:扫描外表 R,对每个元组的连接键应用哈希函数 h1,将其插入内存中的哈希表(如线性探测哈希表)。
  2. 探测阶段:扫描内表 S,对每个元组的连接键应用相同的哈希函数 h1,到哈希表中查找匹配项。如果找到,则输出连接结果。

其成本约为扫描两表的成本:M + N

优化:布隆过滤器

在构建哈希表的同时,可以构建一个小的布隆过滤器。在探测阶段,先查询布隆过滤器。如果它说“键不存在”,则可以立即跳过该元组,避免昂贵的哈希表查找(尤其是当哈希表在磁盘上时)。布隆过滤器可能产生假阳性,但绝不会产生假阴性。

分区哈希连接(Grace Hash Join,用于内存不足时)

当哈希表太大无法放入内存时,需要使用分区哈希连接。

  1. 分区阶段
    • 使用哈希函数 h1 将两个表 R 和 S 分别分区成 k 个分区,并写回磁盘。目标是使每个分区都能被内存容纳。
    • 此阶段需要读写磁盘,成本约为 2*(M + N)
  2. 连接阶段
    • 逐个处理对应的分区对 (Ri, Si)。将分区 Ri 读入内存并构建哈希表,然后扫描分区 Si 进行探测。
    • 此阶段成本约为 M + N
  3. 递归分区:如果某个分区仍然太大,可以对该分区使用另一个哈希函数 h2 进行递归分区,直到每个子分区都能放入内存。

哈希连接的总成本通常优于排序合并连接,因为它将随机比较转换为了顺序分区和分区内的高效查找。


算法对比与总结 🏁

以下是主要连接算法的简单对比:

算法 核心思想 最佳适用场景 关键成本因素
嵌套循环连接 双重循环暴力匹配 小表驱动,或内表有索引 M + (m * N) 或优化后形式
排序合并连接 先排序,后合并指针 输入已排序,或需要有序输出 排序成本 + (M + N)
哈希连接 哈希分区,分区内匹配 大型数据集,等值连接 约 3*(M + N) (分区哈希连接)

核心要点

  • 哈希连接 在绝大多数等值连接场景下性能最优,是数据库系统的默认选择。
  • 排序合并连接 在数据已排序或查询需要有序结果时更有优势。
  • 嵌套循环连接 是最简单的后备方案,在内表有索引或表非常小时可用。

这些算法的存在体现了关系数据库和 SQL 的声明式优势:数据库优化器可以根据数据统计信息和系统资源,自动为查询选择最合适的连接算法,而无需修改应用程序代码。


本节课中,我们一起学习了三种基础的连接算法:嵌套循环连接、排序合并连接和哈希连接。我们了解了它们的工作原理、I/O成本模型以及各自的适用场景。掌握这些知识是理解数据库查询执行和性能调优的基础。下一节课,我们将探讨如何将这些算子组合起来,以管道或物化的方式执行完整的查询计划。

12:查询执行 1

概述

在本节课中,我们将学习如何将查询计划中的各个操作符组合起来,执行一个端到端的查询并生成最终结果。我们将重点讨论查询处理模型、访问方法以及谓词和表达式的求值。


处理模型

上一节我们介绍了查询计划中的各个操作符。本节中,我们来看看数据库系统的处理模型,它定义了如何执行查询计划,本质上规定了数据在操作符之间的流动方向与方式。主要有三种主要方法,它们在不同的工作负载和运行环境下有不同的权衡和性能影响。

以下是三种主要的处理模型:

  1. 迭代器模型:这是最常见的模型,几乎每个数据库系统都使用它。每个操作符都实现一个 next 函数,父节点调用子节点的 next 函数来获取下一个需要处理的元组。
  2. 物化模型:这是迭代器模型的一个特化版本,主要用于内存数据库系统。每个操作符一次性输出其所有结果元组。
  3. 向量化模型:基于迭代器模型,但每次 next 调用返回一批元组(向量),而不是单个元组,这对分析型工作负载更有利。

迭代器模型详解

迭代器模型有时也被称为火山模型或流水线模型。其核心思想是,每个操作符都维护一个状态,并实现一个 next 方法。当父操作符调用子操作符的 next 时,子操作符会返回下一个元组。这种方式允许单个元组在查询计划中尽可能向上“流动”,在获取下一个元组之前完成尽可能多的工作。这对于基于磁盘的系统很重要,因为我们可以尽量利用已读入内存的数据。

考虑以下查询计划的伪代码示例:

SELECT * FROM R JOIN S ON R.id = S.id WHERE S.value > 100

对应的操作符 next 函数可能如下所示(伪代码):

# 投影操作符 (根节点)
def next(self):
    for tuple in self.child.next():
        yield self.project(tuple)

# 哈希连接操作符
def next(self):
    if not self.hash_table_built:
        # 构建阶段:从左子节点读取所有元组构建哈希表
        for tuple in self.left_child.next():
            self.build_hash_table(tuple)
        self.hash_table_built = True
    # 探测阶段:从右子节点读取元组进行探测
    for tuple in self.right_child.next():
        if self.probe_hash_table(tuple):
            yield self.join(tuple)

# 扫描操作符 (叶节点)
def next(self):
    for page in self.table.pages:
        for tuple in page.tuples:
            if self.predicate(tuple):  # 例如 S.value > 100
                yield tuple

这种模型的优点是易于理解和实现,并且能很好地支持输出控制(如 LIMIT 子句)和并行查询。

然而,某些操作符会中断这种流水线,被称为流水线中断器。例如,哈希连接在构建阶段必须读取所有左侧元组后才能开始向上传递结果;排序操作符(ORDER BY)也需要看到所有元组才能确定全局顺序。这些操作符无法避免。

物化模型

在物化模型中,每个操作符不是通过 next 函数逐个返回元组,而是通过一个 output 函数一次性输出所有结果元组。这对于OLTP(联机事务处理)工作负载非常有效,因为通常只涉及少量记录,避免了频繁函数调用的开销。然而,对于OLAP(联机分析处理)工作负载,如果谓词选择性不高,可能会向上传递大量不需要的数据。

向量化模型

向量化模型是迭代器模型的扩展。每次调用 next 函数时,返回的是一批元组(一个向量),而不是单个元组。操作符的内部循环(内核)被设计为能高效处理这批数据。这对于现代CPU的SIMD(单指令多数据)指令集非常有利,可以显著提升分析型查询的性能,因为这类查询通常需要扫描大量数据。近年来构建的主要数据仓库系统都采用了向量化模型。


访问方法

上一节我们讨论了如何组织查询执行流程。本节中,我们来看看查询计划中叶节点的访问方法,即如何从数据库表中实际检索数据。

访问数据主要有两种方式:通过索引进行索引扫描,或直接对表进行顺序扫描。通常,索引扫描可能更优,但最终的回退方案总是顺序扫描。

顺序扫描优化

顺序扫描是当没有合适索引时的默认方法。虽然它受限于磁盘I/O速度,但我们可以通过一些优化来减少不必要的工作:

以下是几种优化顺序扫描的方法:

  1. 预取:提前将后续可能需要的页面读入缓冲区。
  2. 缓冲区旁路:为特定查询线程使用单独的缓冲区,避免污染全局缓冲池。
  3. 区域映射:为每个数据页预计算并存储一些元数据(如某列的最小值、最大值、平均值)。在执行查询时,先检查区域映射,如果确定该页没有符合谓词条件的元组,则直接跳过该页,避免不必要的I/O和元组检查。
  4. 延迟物化:在列存储数据库中,可以延迟组装完整的元组。操作符间只传递偏移量或记录ID,直到查询计划中确实需要某列数据时,才去读取该列。这减少了早期阶段的数据移动量。
  5. 堆聚类:如果表上有聚簇索引,数据页的物理顺序与索引键顺序一致,那么顺序扫描可以按照索引顺序高效读取数据。

索引扫描

索引扫描的目标是利用索引快速定位所需数据,减少处理无关数据的工作量。选择哪个索引取决于多个因素:查询引用的属性、谓词类型(等值、范围等)、数据的实际分布(选择性)以及索引类型(唯一或非唯一)。

例如,对于查询:

SELECT * FROM students WHERE age < 30 AND dept = ‘CS‘ AND country = ‘US‘

假设在 agedept 上分别有索引。如果计算机科学系(dept=‘CS‘)的学生非常少,那么使用 dept 上的索引就更具选择性,效果更好。反之,如果年龄小于30的学生非常少,则使用 age 上的索引更好。数据库的查询优化器会基于统计信息来做出这个选择。

多索引扫描

如果查询的 WHERE 子句包含多个条件,并且有多个相关索引,数据库系统可以执行多索引扫描。它分别从每个索引中获取匹配的记录ID集合,然后根据逻辑操作符(ANDOR)对这些集合进行交集并集操作,最后根据合并后的结果去获取实际元组。

例如,对于上面的查询,如果 age < 30dept = ‘CS‘ 都有索引且都很有选择性,系统可以:

  1. 使用 age 索引找出所有 age < 30 的记录ID集合 A。
  2. 使用 dept 索引找出所有 dept = ‘CS‘ 的记录ID集合 B。
  3. 计算 A 和 B 的交集 C(因为条件是 AND)。
  4. 根据 C 中的记录ID去获取元组,并应用剩余的谓词 country = ‘US‘ 进行过滤。

PostgreSQL 等系统将此称为位图扫描,它们使用位图来高效地表示和组合这些记录ID集合。

索引扫描与排序

对于非聚簇索引,索引扫描可能引发随机I/O。如果一个查询不需要按索引键排序的结果,系统可以先通过索引扫描收集所有需要的记录ID,然后按照这些记录ID对应的页面ID进行排序。这样,在读取实际数据时,对每个页面只需一次I/O,就能处理该页上所有需要的元组,将随机I/O转换为顺序I/O。关系模型的无序性允许我们进行这种优化。


表达式求值

上一节我们讨论了如何获取数据。本节中,我们来看看如何对查询中的谓词和表达式(如 WHERE 子句)进行求值。

我们将 WHERE 子句表示为一棵表达式树。树的节点代表各种表达式类型:比较操作(=, >)、逻辑连接(AND, OR)、算术运算符、函数调用、属性查找、常量值等。

例如,对于谓词:R.id = S.id AND S.value > 100,其表达式树如下:

        AND
       /   \
      =     >
     / \   / \
 R.id S.id S.value 100

为了对表达式求值,我们需要一些上下文信息:当前正在处理的元组、查询的输入参数(如预编译语句中的占位符值)、以及关系的模式信息。

求值过程从根节点开始,以深度优先的方式遍历树。到达叶节点(如属性、常量、参数)后,获取它们的值,然后将这些值向上传递,在父节点应用相应的操作符(如加法、比较),最终在根节点得到布尔结果(truefalse)。

虽然表达式树易于理解和实现,但逐元组遍历树进行求值的开销很大。高性能的数据库系统会采用即时编译技术。它们不是解释执行表达式树,而是将整个谓词(甚至整个查询计划)编译成一段高效的、直接的机器代码。这样就去除了遍历树和类型判断的开销,可以像手写代码一样快速执行。


总结

本节课中,我们一起学习了查询执行的核心组成部分。我们了解到,同一个查询计划在不同的系统环境中可以通过多种方式执行,这取决于它是行存储还是列存储,以及是OLTP还是OLAP工作负载。迭代器模型(自上而下)是最常见的处理模型。在可能的情况下,系统总是倾向于使用索引扫描而非顺序扫描。表达式树为谓词求值提供了清晰的逻辑表示,但在生产系统中,为了追求极致性能,通常会将其编译为高效的本地代码。下一节课,我们将继续探讨查询执行,重点关注如何利用并行性来加速查询处理。

13:查询执行 2

在本节课中,我们将要学习数据库系统如何实现查询的并行执行。我们将探讨不同的进程模型、查询并行性的类型以及如何通过I/O并行性来提升性能。本节课的核心目标是理解如何利用现代多核硬件来加速查询处理。

上一节我们介绍了查询执行的基本模型和迭代器模式,本节中我们来看看如何将这些操作并行化。

进程模型

数据库系统需要组织多个工作线程或进程来处理并发请求。以下是三种主要的进程模型:

  • 每个工作者一个进程:每个客户端连接由一个独立的操作系统进程处理。优点是进程间隔离性好,一个进程崩溃不会影响整个系统。缺点是需要通过共享内存来协调对共享数据结构(如缓冲池)的访问,开销较大。许多传统数据库系统(如Oracle, DB2的早期版本)采用此模型。
  • 进程池:系统预先创建一组固定的工作进程。当新请求到达时,调度器从池中分配一个空闲进程来处理。这避免了为每个连接频繁创建和销毁进程的开销,并且池中的进程可以协作处理一个查询,实现查询内并行性。
  • 多线程模型:整个数据库系统运行在单个进程中,但内部使用多个线程作为工作者。这是现代数据库系统最常见的方法。它允许数据库系统完全控制任务的调度,线程间切换开销低,并且所有线程可以自然地共享内存地址空间,便于管理全局状态(如缓冲池)。

查询内并行性

查询内并行性是指将一个查询分解为多个子任务,并利用多个工作者同时执行这些任务。主要有三种类型:

  • 算子内并行(水平并行):将一个操作符(如扫描、连接)的工作拆分成多个独立的片段,由不同的工作者并行执行。这通常需要一个特殊的交换(Exchange)操作符来合并或重新分发来自不同工作者的数据流。
    • 聚集交换:将来自多个工作者的输出流合并成一个单一的流。
    • 重分区交换:根据某种规则(如哈希值)将一个或多个输入流重新分发到多个输出流。
    • 分发交换:将一个输入流分发到多个输出流。
  • 算子间并行(垂直并行/流水线并行):查询计划树中不同层级的操作符由不同的工作者同时执行,一个操作符的输出作为流水线直接喂给下一个操作符。这要求操作符之间能够以流水线方式传递数据。
  • Bushy并行:这是算子间并行的一种扩展形式,允许同时执行查询计划树中不同分支上的操作符。它结合了水平与垂直并行的思想。

在实际系统中,这些并行方式可以组合使用,以最大化资源利用率和查询性能。

I/O并行性

如果计算可以并行,但数据存储在慢速磁盘上,所有线程仍可能因等待I/O而阻塞。I/O并行性旨在通过跨多个存储设备分布数据来缓解这个问题。

  • 数据库分区:数据库系统主动将数据分割并存储在不同的磁盘设备上。
    • 垂直分区:将表的不同列存储在不同的位置,类似于列存储的简化形式。
    • 水平分区:将表的行分割成不相交的子集(分区),存储在不同的设备上。这是实现分片(Sharding)的基础。
  • RAID(独立磁盘冗余阵列):使用多个物理磁盘组合成一个逻辑磁盘,对数据库系统透明。常见级别包括:
    • RAID 0(条带化):数据交替存储在多个磁盘上,提高读写吞吐量,但无冗余。
    • RAID 1(镜像):数据完全复制到多个磁盘上,提高可靠性和读性能,但存储效率低。

通过I/O并行性,多个工作者可以同时从不同的磁盘设备读取数据,从而减少I/O瓶颈。

总结

本节课中我们一起学习了数据库查询的并行执行。我们首先探讨了组织并行工作者的三种进程模型,并指出多线程模型是现代系统的主流选择。接着,我们深入分析了查询内并行性的三种主要类型:算子内并行、算子间并行和Bushy并行,并认识了关键的交换操作符。最后,我们讨论了通过数据库分区和RAID技术实现I/O并行性,以打破存储瓶颈。理解这些并行执行技术对于设计和优化高性能数据库系统至关重要。

14:查询规划与优化 1

概述

在本节课中,我们将要学习数据库查询规划与优化的核心概念。查询优化器是数据库系统的关键组件,它负责将用户声明的SQL查询转换为最高效的执行计划。我们将探讨查询优化的两种主要方法:基于静态规则的启发式优化和基于代价的搜索优化,并理解其背后的基本原理。

查询优化的挑战与重要性

上一节我们介绍了查询执行的基本操作。本节中我们来看看如何为查询选择最佳的执行计划。SQL是一种声明式语言,它只告诉数据库需要什么结果,而不指定如何计算。因此,数据库系统需要自行决定最高效的执行方式。不同的执行计划(例如,选择嵌套循环连接还是哈希连接)在性能上可能存在巨大差异。查询优化器的质量是区分高端商业数据库系统(如Oracle、DB2)与开源系统的重要因素。

查询优化流程

查询优化通常遵循一个标准化的处理流程。以下是其核心步骤:

  1. SQL重写器:这是一个可选步骤,主要用于分布式数据库或视图,对原始SQL查询进行初步转换或注解。
  2. SQL解析器:将SQL字符串转换为内部的抽象语法树。
  3. 绑定器:将查询中引用的对象名称(如表名、列名)转换为系统内部的标识符,并生成初始的逻辑计划。逻辑计划描述了要执行的高级操作(如扫描、连接),但不指定具体算法。
  4. 查询重写器:基于静态规则和启发式方法,对逻辑计划进行等价变换以提升效率,例如谓词下推。此阶段通常只参考系统目录(元数据),而不查看实际数据。
  5. 优化器:这是核心环节,进行基于代价的搜索。它枚举多个可能的执行计划,使用代价模型估算每个计划的执行成本,并选择成本最低的计划作为最终的物理计划。代价模型产生的数字是用于内部比较的相对值。
  6. 执行引擎:执行优化器生成的物理计划,产生查询结果。

逻辑计划与物理计划的关键区别在于:逻辑计划描述“做什么”,而物理计划则明确指定“如何做”,例如使用索引扫描还是顺序扫描,使用哈希连接还是排序合并连接。

关系代数等价性与查询重写

查询优化的核心理论基础是关系代数的等价性。如果两个关系代数表达式(或查询计划)能产生相同的元组集合,则它们是等价的。利用这种等价性,我们可以对查询计划进行转换,以期获得更高效的执行方式。

以下是几种常见的基于规则的优化:

谓词下推

尽可能早地应用过滤条件,以减少后续操作需要处理的数据量。

原始计划

π (σ (Student ⋈ Enrolled))

优化后计划

π (Student ⋈ σ (Enrolled))

通过将选择操作 σ(过滤成绩为A的记录)下推到连接操作 之前,可以显著减少连接操作需要处理的元组数量。

投影下推

尽早移除查询中不需要的列,减少在操作符间传递的数据量,这对行存储或分布式数据库尤其重要。

无用谓词消除

数据库可以识别并移除永远为真或永远为假的谓词,避免不必要的计算或扫描。

示例

SELECT * FROM table WHERE 1 = 0; -- 优化器可能直接返回空结果集
SELECT * FROM table WHERE 1 = 1; -- 优化器可能移除WHERE子句

合并谓词

将重叠的过滤条件合并,简化计算。

示例

-- 原始查询
SELECT * FROM a WHERE val BETWEEN 1 AND 100 AND val BETWEEN 50 AND 150;
-- 可重写为
SELECT * FROM a WHERE val BETWEEN 1 AND 150;

基于代价的优化简介

当基于规则的优化无法做出最佳决策时(例如,决定多表连接的顺序),就需要进行基于代价的优化。其核心思想是:

  1. 枚举:生成多个可能的查询执行计划。
  2. 估算:使用代价模型估算每个计划的执行成本。
  3. 选择:选择估算成本最低的计划。

代价模型依赖于数据库维护的统计信息,例如表的元组数量、列中不同值的数量、数据分布直方图等。这些信息通过 ANALYZE 等命令收集并存储在系统目录中。

然而,基于代价的优化面临巨大挑战:对于涉及N个表的连接,可能的连接顺序数量是阶乘级的(O(N!)),穷举所有可能性的搜索空间巨大。因此,实际的优化器必须使用智能策略来减少需要枚举的计划数量。

总结

本节课中我们一起学习了查询规划与优化的基本框架。我们了解到查询优化器通过将声明式的SQL查询转换为高效的物理执行计划来提升性能。我们探讨了基于静态规则的查询重写技术,如谓词下推和投影下推,并初步介绍了基于代价的优化所面临的挑战和核心思路。下一节课,我们将深入探讨代价估计的具体方法和查询计划的枚举策略。

15:查询规划与优化 2

概述

在本节课中,我们将继续深入学习查询优化。上一节我们介绍了基于规则和启发式的查询重写,本节中我们来看看查询优化的第二个核心部分:基于代价的查询规划。我们将学习如何使用代价模型来评估不同查询计划的执行成本,如何高效地枚举和选择最优的查询计划,以及如何处理嵌套查询。

代价模型与成本估算

上一节我们介绍了如何在不检查数据的情况下应用规则和启发式来改变查询计划。本节我们将聚焦于更复杂的部分:使用代价模型来评估查询计划在执行前需要完成的工作量。其核心思想是,我们希望能够枚举尽可能多的查询计划,然后选择我们认为最优的一个。代价模型越精确,我们对最优查询计划的选择就越准确。然而,这是一个非常困难的问题,目前仍未完全解决。

代价模型的构成

代价模型本质上是一种估算查询执行所需工作量或时间的方法。我们通常希望选择成本最低的计划。成本可以是多种底层硬件指标的组合:

  • CPU使用量:在基于内存的系统中很重要,但在基于磁盘的系统中,磁盘通常是主要瓶颈。
  • 磁盘I/O次数:这是我们主要关注的指标,与连接算法、排序算法等密切相关。
  • 内存使用量:某些算法可能使用大量内存以获得更快性能,但系统可能没有足够内存。
  • 网络消息数:在分布式数据库中,机器间通过网络传输数据通常非常昂贵。

通常,我们会以将要访问的元组数量作为所有这些指标的代理,用它来估算需要在操作符之间传递的数据量,从而推导出我们认为的最佳计划。

数据库统计信息

为了进行估算,数据库系统依赖其内部的系统目录来收集关于表的信息。这些统计信息可以通过多种方式更新:

  • 手动命令:如 ANALYZE TABLEUPDATE STATISTICSRUN STATS
  • 定时任务:系统定期自动更新。
  • 查询附带:在执行查询时顺便更新。
  • 触发器:当表中一定比例的数据发生变化时触发更新。

一个常见的做法是在OLTP系统的白天交易时段禁用统计更新,在夜间进行全表扫描来更新,因为更新统计信息本身(涉及全表顺序扫描)开销很大。

核心统计信息

对于每个表,我们主要维护两类信息:

  1. 表的元组总数 (num_tuples)。
  2. 表中每个属性的不同值数量 (num_distinct)。

基于这些信息,我们可以推导出一个新的统计量:选择基数

选择基数 (SC) 定义为:SC(A, R) = num_tuples(R) / num_distinct(A, R)
它表示给定属性 (A) 上每个不同值平均出现的次数。

这里我们做了一个关键假设:数据是均匀分布的。即假设每个不同的值出现的次数相同。现实世界的数据往往是有偏的,但为了简化计算,我们暂时使用这个假设。

选择率估算

选择率 是指给定表上谓词匹配的元组比例。我们可以根据选择基数来计算不同谓词的选择率。

以下是计算选择率的公式示例(假设数据均匀分布):

  • 等值谓词(唯一键):选择率 = 1 / num_tuples
  • 等值谓词(非唯一属性):选择率 = SC(A, R) / num_tuples(R) = 1 / num_distinct(A, R)
  • 范围谓词(如 age >= 2:选择率 = (high_key - constant) / (high_key - low_key)
  • 否定谓词:选择率 = 1 - selectivity(predicate)

一个重要的观察是,选择率估计本质上是一个概率问题。它表示一个元组匹配给定谓词的概率。基于这个认识,我们可以使用基本的概率论知识来组合更复杂的谓词。

组合谓词的选择率

假设谓词之间是独立的(这是另一个关键假设),我们可以用以下方式组合:

  • 合取(AND)selectivity(p1 AND p2) = selectivity(p1) * selectivity(p2)
  • 析取(OR)selectivity(p1 OR p2) = sel(p1) + sel(p2) - sel(p1) * sel(p2)

代价估算的挑战与假设

我们目前讨论的方法基于三个可能不成立的简化假设:

  1. 均匀数据假设:假设所有值均匀分布。
  2. 谓词独立假设:假设不同谓词之间没有关联。
  3. 连接包含原则:假设连接时,内表的每个元组都能在外表中找到匹配项。

这些假设会使估算产生误差,尤其是在处理复杂查询时,误差会不断累积。例如,考虑一个查询:WHERE make = ‘Honda’ AND model = ‘Accord’。如果假设“品牌”和“型号”独立,估算的选择率会是 (1/10) * (1/100) = 0.001。但实际上,这两个属性高度相关(只有本田生产雅阁),真实选择率是 1/100。估算值误差达一个数量级。

应对策略

  • 应对数据倾斜:为高频出现的“热点”值维护单独的直方图或列表,对其他值仍使用均匀假设。
  • 应对属性相关:高级商业数据库系统支持声明列之间的相关性,优化器可以据此使用特殊的公式。
  • 应对连接包含:这个问题在基础场景中不那么突出。

统计信息的存储:直方图与采样

为了获取属性值的分布信息(如每个值出现的次数),数据库系统内部使用直方图

直方图的类型

  1. 等高直方图:为每个不同的值存储一个计数项。最精确,但如果不同值很多,存储开销巨大。
  2. 等宽直方图:将值域划分为若干个宽度相等的桶,每个桶存储该桶内所有值的总计数。节省空间,但会引入误差。
  3. 等高直方图:调整桶的宽度,使得每个桶内值的总计数大致相等。通常能提供比等宽直方图更准确的估计。

替代方案:数据采样

另一种方法是直接维护表的一个样本(例如,定期随机抽取一部分元组)。当需要估算选择率时,直接在样本上运行谓词,并假设样本的分布代表了全表的分布。一些高端系统(如SQL Server)会结合使用直方图和采样技术。

采样 vs 直方图:采样可能更准确,但在优化器枚举计划时需要即时对样本进行扫描,可能比查询直方图更慢。因此,对于简单查询可能用直方图,对于复杂、耗时的查询,花费额外时间进行采样以获取更精确的估算可能是值得的。

基于代价的查询规划

在完成基于规则的查询重写后,我们进入基于代价的搜索阶段,目标是将逻辑计划转化为实际的物理执行计划。

单表查询规划

对于只涉及单个关系的查询,规划相对简单,核心是选择访问路径

  • 顺序扫描:总是可用,但通常最慢。
  • 二分查找:如果存在聚集索引。
  • 索引扫描:使用一个或多个索引。

此外,还需要考虑谓词求值顺序。将选择率更高的谓词放在前面求值,可以尽早过滤掉更多数据。对于OLTP查询,优化器通常使用启发式方法,例如直接选择选择性最强的索引或谓词,而不需要进行复杂的代价搜索。

多表连接查询规划

这是查询优化中最困难的部分。难点不仅在于选择连接算法(嵌套循环、哈希连接、排序合并连接),还在于确定连接的顺序。对于n个表的连接,可能的连接顺序数量是n的阶乘级别,再加上每种顺序可用的不同连接算法,搜索空间会爆炸式增长。

由于这是一个NP难问题,我们无法进行穷举搜索,必须使用策略来减少搜索空间。

动态规划

System R 开创性地使用动态规划来解决这个问题。其核心思想是将问题分解为子问题:

  1. 首先,枚举所有可能的第一个连接(两个表的连接),计算每种连接方式(使用不同算法)的代价,并为每对表保留代价最低的方案。
  2. 然后,基于上一步的结果,枚举所有可能的第二个连接(将已连接的结果与第三个表连接),同样计算并保留最低代价方案。
  3. 如此递归,直到所有表都连接完毕。最后回溯即可得到全局最优(在搜索空间内)的连接顺序和算法。

为了进一步缩减搜索空间,System R 引入了一个关键假设:只考虑左深连接树

  • 左深连接树:所有连接节点的右孩子都是基表,左孩子是另一个连接节点或基表。这种树形结构有利于流水线执行,避免中间结果的物化。
  • 其他树形:如右深连接树、浓密连接树,则不被考虑。

现代数据库系统的优化器可能支持更多树形,但动态规划的基本框架仍然被广泛使用。

遗传算法

对于涉及大量表(例如超过12个)的连接,动态规划的搜索空间仍然过大。PostgreSQL 在这种情况下会启用一种称为遗传查询优化器 的备选方案:

  1. 初始化:随机生成一组查询计划(包含连接顺序、算法等)作为“第一代”。
  2. 评估:计算每个计划的代价。
  3. 选择:保留代价最低的计划作为当前最优解。
  4. 进化:淘汰代价高的计划,对保留的计划进行“交叉”和“变异”(随机交换部分连接顺序或算法),产生“下一代”计划。
  5. 迭代:重复评估和进化过程,直到达到时间限制或连续多代没有改进。
    这种方法是一种随机搜索,不能保证找到最优解,但能在可接受的时间内找到一个较好的解。

嵌套查询优化

嵌套查询(子查询)需要特殊的处理方式,目标是将它们转化为更高效的连接或其他形式。

优化策略

主要有两种优化策略,这些通常可以在查询重写阶段完成,无需进入代价搜索:

  1. 重写为连接:如果子查询引用了外层查询的列(相关子查询),可以尝试将其重写为连接。例如:
    SELECT s.name FROM Sailors s WHERE EXISTS (SELECT * FROM Reserves r WHERE r.sid = s.sid AND r.date = ‘2023-10-27’)
    可以重写为:
    SELECT s.name FROM Sailors s JOIN Reserves r ON s.sid = r.sid WHERE r.date = ‘2023-10-27’
  2. 解嵌套/物化:对于非相关子查询,可以将其单独执行一次,将结果物化(存入临时表或变量),然后供外层查询使用。这避免了对外层查询的每一行都执行一次子查询。

总结

本节课我们一起深入学习了基于代价的查询规划与优化。我们首先探讨了如何使用代价模型和统计信息(如选择基数、选择率)来估算查询计划的成本,并指出了其中基于均匀分布、谓词独立等假设带来的挑战。接着,我们了解了数据库如何通过直方图和采样来维护统计信息。然后,我们重点学习了多表连接查询的规划,介绍了使用动态规划算法在左深连接树空间中搜索最优计划的方法,以及PostgreSQL应对复杂连接的遗传算法。最后,我们简要讨论了嵌套查询的优化策略,包括重写为连接和解嵌套。查询优化是数据库系统中极其复杂但至关重要的环节,它使得用户能够以声明式语言提出查询,而系统能自动找到高效的执行方式。

16:并发控制理论 🧠

在本节课中,我们将要学习数据库系统中一个至关重要的概念:并发控制理论。我们将探讨当多个事务同时运行时,如何保证数据的正确性和一致性。我们将从交易的基本概念开始,逐步深入到并发控制的核心原理,包括原子性、一致性、隔离性和持久性(ACID属性)。通过本课,你将理解为什么并发控制是数据库系统中最具挑战性的部分之一,以及如何通过理论模型来确保系统的正确性。


事务基础 📝

上一节我们介绍了课程的整体安排,本节中我们来看看什么是数据库事务。

事务是数据库系统中执行一系列操作的基本单元,这些操作共同完成一个更高级别的功能(例如,转账)。事务的关键特性是原子性:事务中的所有操作要么全部成功执行,要么全部不执行,不存在部分执行的情况。

例如,从账户A转账100美元到账户B的事务包含两个步骤:

  1. 从账户A扣除100美元。
  2. 向账户B存入100美元。

数据库系统必须保证这两个步骤作为一个不可分割的整体完成。如果在步骤1完成后系统崩溃,那么恢复后,步骤1的修改必须被撤销,账户A的余额应恢复到事务开始前的状态。


并发执行的动机与挑战 ⚡

上一节我们介绍了事务的原子性,本节中我们来看看为什么需要让多个事务并发执行,以及这会带来哪些挑战。

如果数据库系统一次只允许运行一个事务(串行执行),虽然能保证正确性,但效率极低。现代硬件拥有多核CPU和大量内存,为了充分利用硬件资源、提高系统吞吐量和响应速度,我们必须允许多个事务并发执行。

然而,并发执行会引入竞争条件数据不一致的风险。例如,两个事务同时读取并更新同一个账户的余额,可能会导致更新丢失。

以下是并发执行可能引发的三种主要问题(异常):

  • 脏读:一个事务读取了另一个未提交事务写入的数据。
  • 不可重复读:一个事务两次读取同一数据项,中间被另一个事务修改,导致两次读取结果不一致。
  • 幻读/写覆盖:一个事务的写入操作覆盖了另一个未提交事务的写入结果。

为了解决这些问题,我们需要一个正式的标准来判断并发执行是否正确。


可序列化性:正确性的黄金标准 🥇

上一节我们看到了并发可能带来的问题,本节中我们来看看如何定义“正确”的并发执行。

并发执行的正确性标准是可序列化性。其核心思想是:一个并发执行调度(操作的交错顺序)是正确的,当且仅当它的执行效果等价于某个事务串行执行(一个接一个执行)的效果。

注意,它只要求等价于某个串行顺序,而不一定是事务实际开始的顺序。这给了调度器更大的灵活性来优化性能。

判断可序列化性的一种实用方法是冲突可序列化性。我们首先定义冲突操作:两个来自不同事务的操作访问同一个数据对象,并且至少有一个是写操作,则它们冲突。

通过分析调度中所有冲突操作的顺序,我们可以构建一个优先图(或称依赖图)。如果图中存在环,则该调度不是冲突可序列化的;如果无环,则是冲突可序列化的,因此也是正确的。

公式/代码描述冲突判断

IF (Op1.Transaction != Op2.Transaction) AND
   (Op1.Object == Op2.Object) AND
   (Op1.Type == WRITE OR Op2.Type == WRITE) THEN
    // Op1 和 Op2 是冲突操作
    // 它们在调度中的先后顺序决定了事务间的依赖关系

ACID属性详解 🛡️

上一节我们定义了正确性的标准,本节中我们将其与数据库事务的经典保障——ACID属性——联系起来。

ACID是衡量数据库事务处理可靠性的四个关键属性:

  • 原子性:事务是一个不可分割的工作单元。事务中的所有操作要么全部完成,要么全部不完成。这通常通过日志记录(记录修改前的值用于回滚)或影子分页等技术实现。
  • 一致性:事务必须使数据库从一个一致的状态转换到另一个一致的状态。这里的一致性包括预定义的完整性约束(如外键、唯一性)。需要注意的是,数据库系统只能强制执行其已知的约束,更高层次的业务逻辑一致性需要由应用层保证。
  • 隔离性:并发执行的事务彼此隔离,每个事务都感觉不到其他事务在并发执行。理想情况下,隔离性提供可序列化的保证。实践中,数据库会提供多种隔离级别(如读已提交、可重复读),在性能和正确性之间进行权衡。
  • 持久性:一旦事务提交,它对数据库所做的修改就是永久性的,即使系统发生故障也不会丢失。这主要通过将日志记录和数据更改持久化到非易失性存储(如磁盘)来实现。

本节课我们重点讨论了原子性和隔离性。一致性的概念在单节点数据库中相对直接,在分布式系统中更为复杂。持久性则与恢复机制紧密相关,我们将在后续课程中深入探讨。


实现概述:悲观与乐观协议 🤖

上一节我们了解了ACID的理论目标,本节中我们简要看看实现这些目标的两类主要技术途径。

数据库系统通过并发控制协议来保证隔离性(进而实现可序列化)。主要分为两类:

  1. 悲观并发控制:假定冲突很可能发生,因此在事务访问数据前先获取锁,防止其他事务访问。如果获取不到锁,则必须等待。两阶段锁定是这类协议的经典代表。
  2. 乐观并发控制:假定冲突很少发生,允许事务在不加锁的情况下执行所有操作。在事务提交时,检查执行过程中是否发生了冲突。如果发生冲突,则中止并回滚该事务。时间戳排序是这类协议的典型例子。

选择哪种协议取决于应用的工作负载(冲突的频率)。悲观协议在冲突频繁时表现更好,而乐观协议在冲突稀少时能减少锁的开销,提高并发度。


总结 📚

本节课中我们一起学习了数据库并发控制的理论基础。我们从事务的概念出发,理解了为什么需要并发执行以及由此带来的挑战。我们定义了可序列化性作为并发执行正确性的黄金标准,并介绍了通过冲突操作优先图来判断调度是否正确的实用方法。最后,我们将这些概念与著名的ACID属性(原子性、一致性、隔离性、持久性)联系起来,并概述了实现隔离性的两类基本协议:悲观控制乐观控制

并发控制是数据库系统的核心与难点,它确保了在多用户、高并发的环境下,数据依然能保持正确和一致。理解这些理论是后续学习具体并发控制算法(如两阶段锁定)和恢复机制的基础。

17:两阶段锁定并发控制 🔒

在本节课中,我们将要学习一种重要的并发控制协议——两阶段锁定。这是一种悲观的方法,通过锁来协调多个事务对数据的访问,以确保事务的隔离性,从而产生可串行化的调度。

上一节我们介绍了事务的ACID属性,并重点讨论了隔离性,以及如何判断一个调度是否是可串行化的。本节中我们来看看如何在实时运行的数据库系统中实现这种保证。


锁的基本类型

为了控制并发访问,我们首先需要定义两种基本的锁类型:

  • 共享锁:用于读操作。允许多个事务同时持有同一个数据对象的共享锁。
  • 排他锁:用于写操作。一个数据对象在任意时刻只能被一个事务持有排他锁,且持有排他锁时不能再授予任何其他锁。

这两种锁的兼容性可以用一个矩阵来描述:

当前持有的锁 请求共享锁 请求排他锁
共享锁 ✅ 兼容 ❌ 冲突
排他锁 ❌ 冲突 ❌ 冲突

事务在执行任何读写操作前,都必须向一个中央的锁管理器申请相应的锁。锁管理器根据其内部维护的元数据(如谁持有什么锁)来决定是授予锁还是让请求事务等待。


两阶段锁定协议

仅使用基本的锁机制可能会导致不可重复读等异常。为了解决这个问题,我们需要引入一个协议来规范锁的获取和释放时机。

两阶段锁定 协议要求每个事务必须分两个阶段处理锁:

  1. 增长阶段:事务可以不断获取新锁,但不能释放任何锁。
  2. 缩减阶段:事务可以释放锁,但不能再获取任何新锁。

这个协议保证了所有遵循它的事务调度都是冲突可串行化的。然而,它存在一个缺点:级联中止。即一个事务的 abort 可能导致读取了它未提交数据的其他事务也必须 abort。


严格两阶段锁定

为了解决级联中止问题,我们对两阶段锁定进行加强,得到 严格两阶段锁定

严格两阶段锁定规定:事务持有的所有锁都必须在事务提交(或中止)时才统一释放。这意味着事务的“缩减阶段”被压缩到了提交那一刻。

以下是严格两阶段锁定的优势:

  • 防止脏读:其他事务只能读取已提交的数据。
  • 避免级联中止:一个事务的失败不会影响其他事务。
  • 简化回滚逻辑:系统只需回滚 abort 的事务本身。

死锁处理

使用锁的协议(包括两阶段锁定)都可能导致死锁,即两个或更多事务相互等待对方释放锁,导致所有事务都无法继续执行。

处理死锁主要有两种策略:

死锁检测与恢复

系统周期性地构建一个 等待图,其中节点是事务,边表示事务A正在等待事务B持有的锁。如果图中存在环,则检测到死锁。

以下是检测到死锁后的处理步骤:

  1. 选择牺牲者:系统需要选择一个事务进行中止以打破死锁。选择策略可能基于事务的年龄、已执行的工作量或已持有的锁数量等。
  2. 回滚牺牲者:中止选中的事务,释放其持有的所有锁,从而让其他事务可以继续执行。

死锁预防

另一种思路是设计协议,从根本上防止死锁发生。常见的方法是基于时间戳分配优先级:

  • 等待-死亡:如果请求锁的事务(T_req)比持有锁的事务(T_hold)更“老”(优先级更高),则 T_req 等待;否则 T_req 自行中止。
  • 伤害-等待:如果 T_req 比 T_hold 更“老”(优先级更高),则 T_req 可以“伤害” T_hold,导致 T_hold 中止并释放锁;否则 T_req 等待。

这两种协议都通过确保锁请求遵循一个全局的序(如时间戳序)来避免循环等待。


锁的粒度与意向锁

如果事务需要访问大量数据项(如百万行记录),为每个数据项都申请锁将带来巨大的开销。为此,数据库系统支持多粒度锁定,允许在数据库、表、页、行等不同层级上设置锁。

为了高效地实现多粒度锁定,我们引入了 意向锁。意向锁是放在较粗粒度对象(如表)上的锁,用以“暗示”后续将在其子节点(如行)上请求特定类型的锁。这允许系统快速判断在粗粒度对象上能否授予锁,而无需检查其所有子节点。

以下是新增的锁类型:

  • 意向共享锁:表示将在下层节点加共享锁。
  • 意向排他锁:表示将在下层节点加排他锁。
  • 共享意向排他锁:表示当前节点已加共享锁,且将在下层节点加排他锁。

通过使用意向锁和锁层级,事务可以根据需要选择最合适的锁粒度,从而减少锁管理器的调用次数,提升系统整体性能。


总结

本节课中我们一起学习了并发控制的核心协议——两阶段锁定。

  • 我们首先了解了基本的共享锁和排他锁。
  • 然后,我们学习了两阶段锁定协议,它通过划分锁的增长和缩减阶段来保证冲突可串行化。
  • 为了克服级联中止问题,我们引入了严格两阶段锁定,要求锁在事务结束时才释放。
  • 接着,我们探讨了锁协议带来的死锁问题,并学习了死锁检测与预防两种应对策略。
  • 最后,为了提升效率,我们介绍了多粒度锁定和意向锁的概念,允许系统在更粗的粒度上进行高效的并发控制。

两阶段锁定因其有效性和相对简单的实现,被广泛应用于 PostgreSQL、MySQL、Oracle 等主流数据库系统中。

18:时间戳排序并发控制 🕒

在本节课中,我们将学习一种不依赖于锁的并发控制协议——时间戳排序。我们将探讨其基本思想、两种主要实现方式(基本时间戳排序和乐观并发控制),并了解如何通过分区技术来优化性能。最后,我们会简要讨论幻读问题及其解决方案。


概述

上一节我们介绍了基于锁的两阶段锁定协议。本节中,我们将探讨一系列不依赖锁,而是基于时间戳来保证事务可串行化执行的并发控制协议。这些协议通常更为乐观,假设系统中事务冲突较少。


基本时间戳排序协议

基本时间戳排序协议的核心思想是为每个事务分配一个唯一且单调递增的时间戳。数据库系统通过比较事务时间戳与数据对象上的时间戳,来决定是否允许读写操作,从而在运行时生成可串行化的调度。

时间戳的生成

时间戳是一种独特的数值,需要满足两个条件:

  1. 单调递增:时间戳必须随时间推移而增加。
  2. 唯一性:任何两个事务不能拥有相同的时间戳。

生成时间戳有几种常见方法,各有优缺点:

以下是几种时间戳生成方法:

  • 系统时钟:使用当前物理时间。缺点是分布式系统中难以同步,且可能因(如夏令时)导致时间回退。
  • 逻辑计数器:使用一个CPU内的计数器原子递增。缺点是计数器可能溢出。
  • 混合方法:结合物理时钟和逻辑计数器,是大多数系统的选择。

协议规则

为了使协议工作,每个数据库元组需要维护两个额外的时间戳:

  • RT(x):最近成功读取该元组的事务的时间戳。
  • WT(x):最近成功写入该元组的事务的时间戳。

设事务 T_i 的时间戳为 TS(T_i)

读操作 read(x)
事务 T_i 请求读取对象 x

  1. 如果 TS(T_i) < WT(x),则拒绝读取,并中止 T_i,然后以新的时间戳重启 T_i
  2. 如果 TS(T_i) >= WT(x),则允许读取。将 RT(x) 更新为 max(RT(x), TS(T_i))。同时,事务必须将 x 的值复制到其私有工作区,以保证可重复读。

写操作 write(x)
事务 T_i 请求写入对象 x

  1. 如果 TS(T_i) < RT(x),则拒绝写入,并中止且重启 T_i
  2. 如果 TS(T_i) < WT(x),则拒绝写入,并中止且重启 T_i
  3. 如果以上条件均不满足,则允许写入。将 WT(x) 更新为 TS(T_i)。同样,写入操作在私有工作区中进行。

示例与托马斯写规则

考虑以下场景:一个旧事务试图写入一个已被新事务写入过的对象。根据基本规则,旧事务必须中止。但观察发现,旧事务的写入最终会被新事务的写入覆盖,从外部看,忽略这次写入是安全的。

因此可以引入 托马斯写规则 进行优化:

  • TS(T_i) < WT(x) 时,事务 T_i 可以忽略此次写操作(即不更新数据库中的 xWT(x)),仅在其私有工作区中记录,并继续执行。

这个优化允许某些原本会被中止的事务成功提交。

优缺点

优点

  • 不会产生死锁。
  • 在低冲突场景下性能较好。

缺点

  • 可能产生不可恢复的调度:一个事务可能读取了另一个未提交事务的数据,并在后者中止后提交,导致数据不一致。
  • 开销大:每个读写操作都需要复制数据到私有工作区。
  • 可能饿死长事务:短事务频繁更新数据可能导致长事务不断重启。

乐观并发控制 (OCC) 😊

乐观并发控制协议采取了更乐观的假设:认为事务间冲突很少。它将事务执行分为三个阶段,把冲突检查推迟到事务结束前。

三个阶段

  1. 读阶段(工作阶段)

    • 事务读取任何数据时,都将数据库中的值及其写时间戳复制到私有工作区
    • 所有的读写操作都在私有工作区中进行,不直接修改数据库。
  2. 验证阶段

    • 当事务提交时,进入验证阶段。此时,系统才为该事务分配一个最终时间戳
    • 系统检查该事务的读写集是否与其他并发执行时间戳更新的事务的读写集冲突。若无冲突,则通过验证。
  3. 写阶段

    • 通过验证后,事务将其私有工作区中的所有更新原子地写回全局数据库,并更新相关数据项的写时间戳。

验证机制

验证是关键,确保调度的可串行化。主要有两种策略:

  • 向后验证:检查当前事务是否与所有更早的(时间戳更小)并发事务冲突。
  • 向前验证:检查当前事务是否与所有更新的(时间戳更大)并发事务冲突。

系统必须统一采用一种验证方向。

优缺点

优点

  • 低冲突、只读或访问数据集不相交的工作负载下性能优异,因为避免了锁开销。
  • 无死锁。

缺点

  • 高冲突工作负载下性能差:事务做了大量工作后可能在验证阶段失败,导致浪费。
  • 验证阶段可能成为瓶颈,且需要闩锁来保护读写集的检查过程。
  • 仍需要维护私有工作区副本,内存开销大。

基于分区的时间戳排序

为了进一步减少锁和副本开销,可以将数据库水平分区,并确保每个分区在某一时刻最多只有一个事务在执行。

工作原理

  1. 数据库被划分为多个逻辑分区(例如,按客户ID范围划分)。
  2. 每个分区有一个单独的执行线程和一个事务队列。
  3. 事务到达时,根据其访问的数据被路由到特定分区队列,并分配时间戳。
  4. 每个分区串行地执行其队列中的事务(按时间戳顺序)。由于单线程执行,分区内无需任何锁或闩锁
  5. 如果一个事务需要访问多个分区,它可能需要先获取所有相关分区的锁,这可能导致中止和重试。

优缺点

优点

  • 对于单分区事务,性能极高(裸机速度),无锁无副本开销。
  • 通过多个分区实现并行性。

缺点

  • 多分区事务处理复杂,可能需要中止重试以获取所有锁,性能下降。
  • 需要工作负载易于分区,否则可能产生数据倾斜(热分区)。

幻读问题与隔离级别

我们之前的讨论假设事务只读写现有数据。但当事务可以插入新数据时,会引入幻读问题:一个事务两次执行相同的范围查询,由于中间有另一个事务插入了满足条件的新数据,导致两次结果不同。

解决方案

以下是解决幻读的几种常见方法:

  • 谓词锁:直接在查询条件(如 status = ‘lit’)上加锁。概念强大但实现复杂昂贵,很少使用。
  • 索引锁:在相关的索引项上加锁。如果插入的新数据要进入某个索引范围,会因无法获得锁而被阻塞。
  • 间隙锁:锁住索引记录之间的“间隙”,防止在范围内插入。
  • 表级或页级锁:通过粗粒度锁(锁住整个表或页)来阻止插入,简单但并发度低。

这些解决方案引出了数据库不同的隔离级别概念(如可重复读、可串行化),它们定义了事务在并发执行时可能遇到的各种现象(脏读、不可重复读、幻读)的允许程度。我们将在后续课程中详细讨论。


总结

本节课我们一起学习了基于时间戳的并发控制协议。

  • 我们从基本时间戳排序开始,了解了其通过比较时间戳来即时决定操作是否有效的悲观但无死锁的特性。
  • 接着,我们探讨了乐观并发控制,它通过将冲突检测推迟到事务结束前,在低冲突场景下实现了高性能。
  • 然后,我们介绍了基于分区的时间戳排序,它通过将数据分区和串行执行,极大地减少了锁和内存复制的开销,尤其适合单分区事务。
  • 最后,我们指出了当存在数据插入时产生的幻读问题,并简要介绍了通过索引锁、间隙锁等高级锁机制来解决该问题,这为理解事务隔离级别打下了基础。

这些协议各有适用场景,数据库系统会根据不同的工作负载特点选择合适的并发控制策略。

19:多版本并发控制 (MVCC) 🧬

在本节课中,我们将学习多版本并发控制(MVCC)的核心概念。MVCC是一种通过维护数据的多个版本来实现高并发访问的数据库技术。它允许读写操作互不阻塞,从而显著提升系统性能,特别是在只读事务较多的场景下。接下来,我们将详细探讨MVCC的工作原理、设计决策及其实现方式。


概述

多版本并发控制(MVCC)并非一个独立的并发控制协议,而是一种构建数据库系统的方法。它通过为数据项维护多个版本,为每个事务提供一个一致的数据库快照。这种方法使得读者不会阻塞写者,写者也不会阻塞读者,只有在两个事务同时写入同一对象时,才需要依赖传统的并发控制协议(如两阶段锁)来解决冲突。


MVCC的基本思想

上一节我们介绍了MVCC的核心理念。本节中,我们来看看其具体的工作方式。

高层工作方式是,系统为每个到达的事务分配一个时间戳,并为该事务提供数据库在该时间戳下的一致性快照。这意味着事务只能看到在其开始之前已提交的更改,而看不到其他未提交或之后开始的事务的修改。这个快照是逻辑上的,并非物理复制整个数据库。

MVCC对只读事务特别有效。如果SQL方言允许声明事务为只读,数据库系统就无需获取任何锁或维护读写集,因为事务基于其开始时的快照进行操作,这使得只读事务非常高效。

另一个优点是能够支持“时间旅行查询”,即查询数据库在过去某个时间点的状态。然而,由于需要永久保留所有旧版本,这可能导致存储空间迅速耗尽,因此并非所有系统都默认支持此功能。


MVCC运行示例

为了理解MVCC如何管理版本和可见性,我们将通过两个例子来演示。请注意,MVCC独立于底层的并发控制协议,这些例子主要展示版本信息的更新和可见性判断逻辑。

示例一:无写冲突

假设数据库表中有一个对象A,其初始版本为A0start_timestamp=0end_timestamp=INF

  1. 事务T1到达,被分配时间戳TS=1。它读取对象A。系统检查发现TS=1A0的起止时间戳[0, INF)范围内,因此T1读取版本A0
  2. 事务T2到达,被分配时间戳TS=2。它要写入对象A。
    • 系统创建新版本A1,其start_timestamp=2end_timestamp=INF
    • 接着,更新旧版本A0end_timestamp2
  3. 此时,如果另一个时间戳为TS=1.5的事务读取A,由于1.5仍在A0[0, 2)范围内,它仍会读取A0
  4. 事务T2提交后,其状态被更新。

示例二:存在写冲突

初始状态同上。

  1. 事务T1TS=1)读取A0,然后要写入A。它创建新版本A1start_timestamp=1, end_timestamp=INF),并更新A0end_timestamp=1
  2. 事务T2TS=2)开始,尝试读取A。在可串行化隔离级别下,由于A1的创建者T1尚未提交,T2必须读取已提交的版本A0TS=2不在A0[0,1)范围内?这里需要修正:对于T2,TS=2,它需要找到一个版本V,满足V.start_timestamp <= 2 < V.end_timestamp。此时A0end_timestamp已被T1更新为1,而A1start_timestamp=1。因此T2可以读取A1吗?这取决于T1是否已提交。如果T1未提交,A1对T2不可见,T2可能被阻塞或回滚。这正体现了MVCC与并发控制协议的结合:版本链由MVCC维护,但写-写冲突由底层协议(如锁或时间戳排序)处理)。
  3. 假设T1提交。T2现在可以安全地创建新版本A2start_timestamp=2),并更新A1end_timestamp=2

这些示例说明了系统如何通过时间戳和版本链来决定数据对事务的可见性。


MVCC的关键设计决策

实现一个MVCC系统需要做出一系列设计决策。以下是四个核心方面:

1. 并发控制协议

MVCC本身处理读-写冲突,但写-写冲突仍需底层并发控制协议处理。您需要选择并结合使用如两阶段锁(2PL)乐观并发控制(OCC)时间戳排序(T/O) 等协议,并设定隔离级别。

2. 版本存储

版本存储决定了如何物理地保存数据的多个版本。以下是三种主要方法:

  • 仅追加存储:每次更新都将新版本作为完整的元组追加到主表中。版本通过指针形成链表。

    • 优点:实现简单,读取旧版本快。
    • 缺点:写操作开销大,需要复制整个元组;主表容易膨胀。
    • 代码/指针示例tuple_v1.next -> tuple_v2
  • 时间旅行存储:主表只保存最新版本。旧版本被移动到单独的“时间旅行表”中。主表元组包含指向时间旅行表中对应旧版本的指针。

    • 优点:主表保持紧凑。
    • 缺点:查询可能需要访问两个表。
  • 增量存储:主表保存最新版本。更新时,只将更改的字段(增量)写入“增量存储区”。每个版本通过指针链接到其前一个版本的增量。

    • 优点:写操作高效,尤其当只更新少数字段时。
    • 缺点:读取旧版本需要“重放”增量以重构数据,开销较大。
    • 公式/概念Version_N = Apply(Delta_N, Version_{N-1})

3. 垃圾回收

旧版本在不再对任何活动事务可见后,需要被回收以释放空间。主要有两种方法:

  • 元组级垃圾回收:由后台线程(“真空”进程)定期扫描表,根据所有活动事务的时间戳范围,识别并回收过期的版本。
  • 事务级垃圾回收:每个事务在提交时,就知道自己修改了哪些数据。系统可以立即回收那些只对该事务可见且事务已提交的旧版本。

4. 索引管理

索引必须指向正确的版本(通常是版本链的头部)。这里有两种策略:

  • 物理指针:索引直接存储元组的物理地址(如页面ID和偏移量)。每次版本链头部更新,所有相关索引都必须更新,开销大。

    • 代码示例INDEX -> (PageID: 123, Offset: 45)
  • 逻辑指针:索引存储一个不变的逻辑标识符(如主键或人造的元组ID)。通过一个中间映射层(如主键索引或哈希表)将逻辑ID转换为当前版本的物理地址。

    • 优点:版本链头部更新时,只需更新中间映射层,无需更新所有二级索引。
    • 代码示例SECONDARY_INDEX -> TupleID: 1001 -> Mapping_Table[1001] -> (PageID: 456, Offset: 78)

总结

本节课中,我们一起学习了多版本并发控制(MVCC)。我们了解到MVCC通过维护数据的历史版本,为事务提供一致性快照,从而实现了读写操作的非阻塞并发。要实现MVCC,需要做出几个关键设计决策:选择底层并发控制协议来处理写-写冲突;决定版本数据的物理存储方式(仅追加、时间旅行或增量存储);设计有效的垃圾回收机制来清理旧版本;以及管理索引以确保其指向正确的数据版本。现代数据库系统根据其目标工作负载,在这些设计点上做出了不同的选择,形成了各自的性能特性。理解这些原理有助于我们更好地使用和调优数据库系统。

20:数据库日志记录方案 📝

在本节课中,我们将学习数据库系统中一个至关重要的概念:日志记录与恢复。我们将探讨为什么需要日志、系统可能面临的各种故障类型,以及如何通过不同的日志记录方案来确保数据的原子性、一致性和持久性。本节课的重点是理解在系统正常运行时需要做什么来为可能的故障恢复做好准备。


故障类型 🛑

上一节我们概述了日志记录的目的,本节中我们来看看数据库系统可能遇到的故障类型。理解这些故障是设计有效恢复机制的基础。

数据库系统的故障主要分为三类:

  • 事务故障:指单个事务执行过程中出现的错误。这包括:
    • 逻辑错误:例如,事务试图违反数据库的完整性约束(如外键约束)。
    • 内部状态错误:例如,在并发控制中因死锁或时间戳冲突而导致事务需要中止。
  • 系统故障:指导致整个数据库系统停止运行的软硬件问题。
    • 软件故障:数据库系统自身的Bug导致崩溃。
    • 硬件故障:运行数据库的机器断电或操作系统崩溃。我们假设硬件故障是“可停止的”,即故障不会造成存储介质的物理损坏,系统重启后可以恢复。
  • 存储介质故障:指磁盘等存储设备发生不可修复的物理损坏(如磁头划伤盘片)。数据库系统本身的恢复协议无法处理此类故障,通常需要通过数据冗余(如备份、分布式复制)来应对。

我们的日志恢复协议主要需要处理事务故障系统故障


缓冲区管理策略:Steal与Force 🔄

在深入日志方案之前,我们需要理解缓冲区管理的两个关键策略,它们决定了脏页(已被修改但未写回磁盘的页)何时可以写回磁盘。

以下是两个核心策略的定义:

  • Steal策略:允许将未提交事务修改的脏页写回磁盘。
  • No-Steal策略禁止将未提交事务修改的脏页写回磁盘。
  • Force策略:要求事务在提交前,必须将其所有修改的脏页强制写回磁盘。
  • No-Force策略不要求事务在提交前将脏页写回磁盘。

不同的策略组合在运行时性能和恢复复杂度上各有优劣。我们将分析其中一种组合。


No-Steal / Force 策略分析

这种组合意味着:不允许写出来提交的更改,但提交时必须强制写出所有更改。

优点

  • 恢复简单。因为提交后所有数据都已持久化,崩溃后无需重做;未提交的数据从未写入磁盘,也无需撤销。

缺点

  • 事务工作集受内存限制:如果事务要修改的数据量超过缓冲区容量,则无法运行。
  • 提交延迟高:提交时需要等待所有脏页写回磁盘,这是一个慢速的I/O操作。
  • 写放大:如果多个事务修改同一页,该页会被反复写回磁盘,增加I/O负担,影响SSD寿命。

由于其性能限制,现代数据库系统通常不采用这种策略。


影子分页方案 👥

为了解决No-Steal/Force的一些问题,历史上曾出现过影子分页方案。它的核心思想类似于写时复制。

工作原理如下:

  1. 维护一个主页表,指向当前已提交的所有数据页。
  2. 当事务开始时,创建影子页表,初始内容复制自主页表。
  3. 事务修改数据时,并不直接覆盖原数据页,而是将数据页复制到磁盘的新位置,并在影子页表中更新指向。
  4. 事务提交时,只需原子性地更新数据库根指针,使其指向影子页表。这个操作通常通过持久化一个包含新根指针的页来实现。
  5. 提交后,影子页表变为主页表,旧的主页表及其指向的旧数据页成为可回收的“垃圾”。

优点

  • 恢复极快,崩溃后只需读取最后的根指针即可获得一致状态。

缺点

  • 提交开销大(需写回多个页表页和根指针)。
  • 导致磁盘碎片,需要后台垃圾回收。
  • 通常需要批量提交或串行化写事务以实现原子性切换。

由于其局限性,现代主流数据库系统已不再使用纯影子分页。


预写日志方案(Write-Ahead Logging, WAL)🚀

上一节我们看到了影子分页的不足,本节中我们来看看目前数据库系统事实上的标准方案:预写日志。WAL是Steal/No-Force策略的典范,在运行时性能和恢复能力之间取得了最佳平衡。

核心原则

WAL的核心原则非常简单却至关重要:

任何数据页在写回磁盘之前,其对应的所有日志记录必须已经持久化在日志中。

这意味着,日志的写入顺序优先于实际数据的写入。

如何工作

  1. 日志记录:事务对数据所做的每一个修改,都会先被转化为一条日志记录,并追加到内存中的日志缓冲区。一条基本的日志记录包含:事务ID修改的对象ID旧值(用于UNDO)新值(用于REDO)
  2. 修改数据:日志记录存入缓冲区后,事务才被允许在缓冲区中修改实际的数据页。
  3. 提交事务:事务提交时,系统会生成一条COMMIT日志记录并放入日志缓冲区。在通知应用程序提交成功之前,系统必须确保该事务产生的所有日志记录(包括COMMIT记录)都已持久化到磁盘的日志文件中。 此时,数据页本身可以仍然留在内存中。
  4. 刷脏页:脏页由缓冲区管理器在后台择机写回磁盘,不受事务提交的即时约束。

优势

  • 高效的提交:提交只需等待一次顺序的日志文件追加写(fsync),远比随机写回多个数据页快。
  • 支持大事务:事务工作集可以远超内存容量,因为未提交的脏页可以被“偷”(Steal)出缓冲区以腾出空间,只要其日志已持久化即可。
  • 组提交优化:可以将多个事务的日志一次性刷盘,分摊fsync的开销,极大提升吞吐量。

日志记录的类型

  • 物理日志:记录数据页上具体字节的变化。恢复精确,但日志量大。
  • 逻辑日志:记录高级操作(如SQL语句)。日志量小,但恢复时可能需要重新执行整个操作,且UNDO复杂。
  • 生理日志(Physiological Logging)大多数系统采用。它是物理和逻辑的折衷,记录类似“在页面P的槽位S更新元组T为以下新值”的信息。它提供了足够的灵活性,且恢复效率较高。

检查点机制 ⏱️

如果日志无限增长,恢复时将需要重放整个日志历史,耗时极长。检查点机制用于截断日志,限制恢复时需要回看的日志范围。

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

  1. 暂停所有新事务的开始。
  2. 等待所有当前活跃事务完成。
  3. 将当前所有脏页(包括已提交和未提交事务的)强制写回磁盘。
  4. 在日志中写入一条CHECKPOINT记录。
  5. 恢复事务处理。

检查点的作用:系统崩溃后恢复时,只需从最近一个检查点开始扫描日志,而不需要处理检查点之前的日志。因为检查点保证了在那一刻,所有已提交事务的修改都已持久化在数据页中。

检查点的频率需要在恢复时间(检查点越频繁,恢复越快)和运行时性能(做检查点会消耗I/O资源)之间进行权衡。一种常见的策略是基于日志大小触发检查点。


总结 📚

本节课我们一起学习了数据库日志记录与恢复的基础知识。

  • 我们首先了解了数据库系统可能面临的故障类型,明确了恢复协议需要处理的范围。
  • 接着,我们探讨了缓冲区管理的Steal/No-StealForce/No-Force策略,这些策略决定了数据持久化的时机。
  • 然后,我们分析了影子分页这一历史方案,理解了其原理和优缺点。
  • 之后,我们深入学习了现代数据库系统的核心方案——预写日志。我们掌握了其“日志先行”的核心原则、工作流程以及带来的性能优势。
  • 最后,我们介绍了检查点机制,它通过定期将脏页刷盘并记录日志位置,来限制恢复范围并提升恢复速度。

预写日志方案通过UNDO(撤销未提交事务)和REDO(重做已提交事务)这两个基本操作,并结合检查点,共同确保了数据库的原子性和持久性。在下节课中,我们将学习在系统崩溃后,如何利用WAL日志和检查点信息来执行具体的恢复算法。

21:ARIES数据库恢复算法 🛠️

在本节课中,我们将学习数据库恢复算法的核心部分——ARIES。我们将探讨在系统崩溃后,如何利用日志将数据库恢复到一致状态。课程将涵盖运行时日志记录策略、检查点技术以及详细的三阶段恢复过程。


运行时操作与日志序列号 📝

上一节我们介绍了恢复的基本概念和写前日志。本节中,我们来看看在常规事务处理过程中,系统需要记录哪些额外信息来支持恢复。

ARIES协议基于IBM开发的技术,是现代数据库恢复的基石。其核心思想是:在事务正常运行时记录足够信息,以便在崩溃后能“重演”和“撤销”历史,从而恢复数据。

为了跟踪日志记录的顺序,系统引入了日志序列号。LSN是一个单调递增的计数器,系统在生成每条日志记录时为其分配一个唯一的LSN。

系统各个部分都会使用LSN来追踪数据状态:

  • FlushLSN:一个内存计数器,记录已持久化到磁盘的最后一条日志记录的LSN。
  • PageLSN:存储在每一数据页中,记录最后一次修改该页的日志记录的LSN。
  • RecLSN:存储在每一数据页中,记录自该页上次从磁盘读入后,第一次使其变脏(被修改)的日志记录的LSN。

每个事务也会记录其最后生成的日志记录的LSN(LastLSN)。此外,还有一个主记录指向日志中最后一个成功完成的检查点的位置。

关键保证:在将脏页写回磁盘前,必须确保修改该页的所有日志记录(即LSN小于等于该页PageLSN的记录)都已持久化(即其LSN ≤ FlushLSN)。这通过写前日志和Steal/No-Force策略实现。

以下是系统各部分LSN的汇总:

LSN 类型 存储位置 描述
LogRecord.LSN 日志记录中 该日志记录的唯一序列号
PageLSN 数据页中 最后一次修改此页的日志记录的LSN
RecLSN 数据页中 此页在内存中首次被修改时的日志记录LSN
FlushLSN 内存中 已刷新到磁盘的最后一条日志记录的LSN
LastLSN 事务元数据中 该事务生成的最后一条日志记录的LSN
MasterRecord 磁盘固定位置 最后一个完整检查点开始位置的LSN

事务提交、中止与补偿日志记录 🔄

本节我们来看看事务在正常执行时,提交和中止过程与之前有何不同,并引入一个关键概念——补偿日志记录。

当事务提交时,系统会写入一条COMMIT日志记录。COMMIT记录本身及其之前该事务产生的所有日志记录都持久化到磁盘后,系统才能通知客户端事务已提交。此后,系统会在内部稍后时间写入一条TXN-END记录,表示该事务的所有相关工作(如释放锁)已完成,可以从活跃事务表中移除。

当事务中止时,情况更为复杂。系统需要撤销该事务已做的所有修改。关键点在于:每一个撤销操作本身也必须被记录到日志中。这种记录称为补偿日志记录

CLR描述了为撤销某个更新操作所执行的动作。它看起来像一条普通的更新日志记录,但“前像”和“后像”的值是相反的。此外,CLR还包含一个UndoNxtLSN字段,指向同一事务中下一个需要被撤销的日志记录的LSN,这形成了一个链表,方便按逆序进行撤销。

事务中止的步骤如下:

  1. 立即向客户端返回中止结果。
  2. 从最后一条日志记录开始,沿LastLSNPrevLSN形成的链表逆向扫描。
  3. 为每个需要撤销的更新生成一条CLR,并写入日志。
  4. 完成所有撤销后,写入该事务的TXN-END记录。

注意:与提交不同,中止操作无需等待任何日志记录刷新到磁盘就可以响应客户端。CLR会像普通日志记录一样被异步写出。


模糊检查点 📌

上一节我们提到了检查点的必要性。本节我们介绍一种高性能的检查点技术——模糊检查点,它允许在创建检查点期间事务继续执行。

简单的检查点方案需要停止所有新事务,甚至暂停所有写操作,这会导致系统不可用。模糊检查点解决了这个问题。

模糊检查点过程如下:

  1. 在日志中写入一条CHECKPOINT-BEGIN记录。
  2. 允许事务继续正常执行和修改数据
  3. 将当前内存中的活跃事务表脏页表的快照写入日志。ATT记录了检查点开始时所有未完成事务的ID、状态及其LastLSN。DPT记录了所有脏页的ID及其RecLSN
  4. 将缓冲池中所有脏页(包括在检查点过程中新变脏的页)刷新到磁盘。
  5. 在日志中写入一条CHECKPOINT-END记录,其中包含第3步中记录的ATT和DPT信息。
  6. 更新主记录,指向这个CHECKPOINT-BEGIN记录的位置。

由于检查点过程中事务仍在运行,此时写入磁盘的数据可能包含未提交事务的修改,因此是不一致的。但通过记录ATT和DPT,恢复算法可以知道在检查点“瞬间”有哪些事务和脏页是活跃的,从而能够从日志中正确重放或撤销后续修改。


三阶段恢复算法 🔁

现在,我们拥有了崩溃后恢复所需的所有组件。ARIES的恢复过程分为三个顺序阶段:分析、重做和撤销。

阶段一:分析

分析阶段的目标是确定崩溃发生时系统的状态:哪些事务未完成,以及哪些数据页可能包含未持久化的更改。

  1. MasterRecord找到最后一个完整检查点的CHECKPOINT-BEGIN位置。
  2. 从该点开始,正向扫描日志直到末尾。
  3. 重建崩溃时的活跃事务表脏页表
    • 遇到TXN-BEGIN,将事务加入ATT,状态为UNDO
    • 遇到COMMIT,将ATT中对应事务状态改为COMMIT
    • 遇到TXN-END,从事务表中移除该事务。
    • 遇到UPDATECLR,如果被修改的页不在DPT中,则将其加入,并设置其RecLSN为当前日志记录的LSN。

分析阶段结束后,ATT包含了所有需要处理的事务(状态为UNDO),DPT包含了所有在崩溃时可能脏的页。

阶段二:重做

重做阶段的目标是重复历史,将数据库恢复到崩溃发生时的物理状态,即使这个状态包含未提交事务的修改。

  1. 从DPT中找到最小的RecLSN。这是重做扫描的起点。
  2. 从该LSN开始,正向扫描日志。
  3. 对每一条UPDATECLR记录:
    • 如果其修改的页不在DPT中,说明该页的所有修改已持久化,跳过。
    • 如果其修改的页在DPT中,但当前记录的LSN < 该页的PageLSN,说明这个特定修改已持久化,跳过。
    • 否则,重新应用该修改(重做操作),并更新该页的PageLSN为当前记录的LSN。
  4. 重做所有操作,包括已中止事务产生的CLR。

阶段三:撤销

撤销阶段的目标是回滚所有在崩溃时未提交的事务(即ATT中状态为UNDO的事务),使数据库达到逻辑一致的状态。

  1. 从ATT中找出具有最大LastLSN的事务,从其LastLSN指向的日志记录开始处理。
  2. 按LSN逆序处理每个需要撤销的事务:
    • 遇到普通UPDATE记录,则执行撤销操作,并生成一条对应的CLR写入日志。CLRUndoNxtLSN指向该事务中前一条需要处理的日志记录的LSN(即PrevLSN)。
    • 遇到CLR记录,则跳过(因为它是对一个已撤销操作的记录)。
  3. 当一个事务的所有操作都被撤销后,为其写入一条TXN-END记录,并将其从ATT中移除。
  4. 重复步骤1-3,直到ATT为空。

关键点:在撤销阶段生成CLR是必须的,这保证了即使在恢复过程中再次发生崩溃,系统也能在下次重启时正确恢复,不会丢失撤销操作的进度。


总结 📚

本节课我们一起学习了ARIES数据库恢复算法的完整流程。我们首先介绍了日志序列号这一核心概念,它用于在整个系统中追踪更改的顺序和持久化状态。接着,我们探讨了事务提交中止时的详细步骤,特别是补偿日志记录的引入,它确保撤销操作本身也是可恢复的。

然后,我们学习了模糊检查点技术,它通过记录活跃事务和脏页的快照,允许在检查点期间系统继续运行,从而大大减少了性能开销。最后,我们深入分析了恢复过程的三个关键阶段:分析阶段用于确定崩溃时的系统状态;重做阶段通过重复历史将数据库恢复到崩溃前的物理状态;撤销阶段则回滚所有未提交的事务,最终达到逻辑一致的状态。

ARIES算法通过写前日志、Steal/No-Force策略、模糊检查点以及严谨的三阶段恢复,为数据库系统提供了强大且高效的容错能力,确保了事务的原子性和持久性。理解这些机制是构建可靠数据库系统的基石。

22:分布式数据库简介 🚀

在本节课中,我们将要学习分布式数据库系统的基本概念。我们将从理解分布式数据库的架构开始,探讨数据分区的不同方法,并初步了解在分布式环境中进行并发控制的挑战。本节内容为后续深入学习分布式事务和一致性模型打下基础。

系统架构概述

上一节我们介绍了单节点数据库的工作原理。本节中我们来看看当数据库扩展到多台机器时,系统架构会发生哪些变化。分布式数据库的架构主要分为三类:共享内存、共享磁盘和共享无。

共享内存架构

在共享内存架构中,多个CPU资源运行在不同的机器上,但通过一个高速互连层(如InfiniBand)共享一个统一的内存视图。数据库实例运行在这些CPU上,它们知道彼此的存在,可以通过写入全局数据结构或发送进程间通信消息来协调。然而,这种架构在数据库领域并不常见。

共享磁盘架构

共享磁盘架构是当今云环境中的主流架构。计算节点拥有自己的本地内存(可用于缓存),但数据库的持久化状态存储在一个共享的后端存储设备中(如Amazon S3或EBS)。这种架构的优势在于计算资源和存储资源可以独立扩展,因为计算节点是无状态的。但缺点是数据访问的局部性较差,且节点间需要额外的协调机制来通知数据变更。

共享无架构

共享无架构是大多数人想到分布式数据库时的模型。每个节点都是一个独立的“孤岛”,拥有自己的本地内存和本地磁盘。节点之间协调的唯一方式是通过顶层的网络消息传递。数据被分区(或分片)存储在不同的节点上。这种架构能提供更好的性能和效率,因为可以充分利用数据的局部性,但扩展容量和确保一致性也最为困难。

数据分区

理解了架构之后,我们需要知道如何将数据分布到不同的节点上。这个过程称为分区或分片。

以下是两种主要的分区策略:

  • 垂直分区:将不同的表存储在不同的节点上。例如,节点1存储表A,节点2存储表B。这种方式简单,但只适用于查询主要访问单一表的情况。
  • 水平分区:将同一个表按行拆分到不同节点。这是更常见的做法,通常基于一个或多个分区键的值来决定行的归属。

水平分区有两种主要方法:

  • 哈希分区:对分区键的值进行哈希运算,然后根据哈希值(通常取模分区数)决定数据归属。适用于等值查询,但不适合范围查询。
  • 范围分区:根据分区键值的连续范围进行分区。例如,ID 1-100在分区1,101-200在分区2。适合范围查询。

一致性哈希

当需要动态增加或减少分区数量时,传统的哈希分区需要重新哈希并移动大量数据。一致性哈希技术解决了这个问题。它将哈希空间视为一个环(0到1),节点和数据键都通过哈希映射到环上。数据归属于从该数据键位置沿环顺时针方向找到的第一个节点。

当新增节点D时,只需要将原本属于节点C的部分数据(环上C和D之间的部分)迁移到D,其他数据位置不变。这大大减少了数据迁移量。Memcached、Cassandra和DynamoDB等系统都使用了这项技术。

分布式并发控制简介

在单节点数据库中处理事务已经颇具挑战。在分布式环境中,当事务需要跨多个节点更新数据时,保证ACID属性(特别是原子性和一致性)变得异常复杂和昂贵。

我们需要一种机制来协调跨节点的事务提交。这通常通过事务协调器来实现。

集中式协调

在集中式方法中,有一个中心化的协调器(如TP监视器)或中间件。所有事务请求都通过它,它维护全局状态(如锁表),负责路由查询,并在提交阶段与所有相关分区通信,确保它们都同意提交后,才向应用返回提交成功。Facebook的MySQL集群就使用了这种中间件方法。

去中心化协调

在去中心化方法中,没有单一的协调器。应用可能直接与分区通信。当事务需要提交时,会指定一个主节点(可能是参与事务的节点之一),由该主节点负责与其他节点通信并达成提交共识。

无论采用哪种方式,分布式环境下的死锁检测和解决都更加困难,因为没有一个节点拥有完整的全局等待图视图。

本节课中我们一起学习了分布式数据库的基本架构(共享内存、共享磁盘、共享无),探讨了数据分区的核心策略(垂直、水平、哈希、范围)以及用于动态扩展的一致性哈希技术。最后,我们初步了解了在分布式环境中进行事务并发控制所面临的巨大挑战,这为下一节课深入探讨分布式事务、复制和CAP定理等内容做好了准备。

23:分布式OLTP数据库

在本节课中,我们将要学习分布式在线事务处理数据库的核心概念。我们将重点探讨如何让多个节点就事务提交达成一致,如何处理数据复制以确保高可用性,以及理解分布式系统中的一致性权衡。

上一节课我们介绍了分布式数据库的基本概念和架构。本节中,我们来看看专门针对OLTP工作负载的分布式数据库所面临的特定挑战和解决方案。

原子提交协议

在分布式数据库中,当一个事务涉及多个节点时,我们需要一个机制让所有参与者就“提交”还是“中止”达成一致。这就是原子提交协议的作用。

两阶段提交

两阶段提交是最经典的原子提交协议,它分为两个阶段:准备阶段和提交阶段。

以下是两阶段提交的基本流程:

  1. 准备阶段:协调者向所有参与者发送“准备”消息,询问是否可以提交事务。
  2. 投票阶段:每个参与者执行本地检查(如写日志),然后回复“同意”或“中止”。
  3. 决策阶段:如果所有参与者都回复“同意”,协调者进入提交阶段,向所有参与者发送“提交”命令;否则,发送“中止”命令。
  4. 完成阶段:参与者执行命令(提交或中止)并回复确认。

两阶段提交的关键在于所有参与者都必须同意,事务才能提交。只要有一个节点不同意或失效,整个事务就会中止。

axos协议

Paxos是另一种共识协议,它不要求所有节点同意,而是要求多数节点同意即可提交。

Paxos协议的核心思想是:

  • 一个提案(例如,提交事务T)需要被大多数接受者接受才能通过。
  • 每个提案都有一个唯一的、递增的编号,这确保了即使有多个提案者同时提案,系统也能最终就一个值达成一致。

与两阶段提交相比,Paxos能更好地处理节点故障,因为只要多数节点存活,协议就能继续推进,而不会像两阶段提交那样被单个故障节点阻塞。

数据复制

复制是分布式数据库中实现高可用性和容错的关键技术。它通过在多个节点上保存数据副本来确保即使某些节点失效,数据依然可访问。

以下是复制配置的主要模式:

  • 主从复制:所有写操作都发送到主节点,主节点负责将更改传播到从节点。读操作可以发送到主节点或从节点。当主节点故障时,会进行选举产生新的主节点。
  • 多主复制:写操作可以发送到任意副本节点。这些节点需要协调解决潜在的写入冲突(例如,对同一数据的并发更新)。

接下来,我们看看数据同步的两种策略:

  • 同步复制:主节点等待从节点确认已将更改持久化后,才向客户端确认写操作成功。这保证了强一致性,但增加了延迟。
  • 异步复制:主节点在本地提交更改后立即向客户端确认,随后异步地将更改传播到从节点。这提供了更低延迟,但存在数据丢失的风险(如果主节点在传播前故障)。

CAP定理

CAP定理是理解分布式系统设计权衡的重要框架。它指出,在一个分布式系统中,一致性、可用性和分区容错性三者不可兼得,最多只能同时满足其中两项。

  • 一致性:每次读取都能获得最新写入的数据。
  • 可用性:每个请求都能得到响应(不保证是最新数据)。
  • 分区容错性:系统在遇到网络分区(节点间无法通信)时仍能继续运行。

以下是常见的取舍:

  • CP系统:优先保证一致性和分区容错性。当发生网络分区时,系统可能拒绝请求,牺牲可用性以确保数据一致性。许多传统关系数据库的分布式版本属于此类。
  • AP系统:优先保证可用性和分区容错性。即使发生网络分区,系统也继续提供服务,但可能返回过时数据,牺牲强一致性。许多NoSQL数据库属于此类。
  • CA系统:优先保证一致性和可用性。这通常意味着系统不能很好地处理网络分区,因此这类系统通常不是严格意义上的分布式系统。

联邦数据库

联邦数据库旨在将多个异构的、自治的数据库系统整合成一个逻辑上统一的数据库视图。

其基本架构是:

  • 一个中间件层接收用户查询。
  • 中间件将查询分解,重写为适合底层不同数据库(如MySQL, MongoDB, Redis)的查询。
  • 中间件从各数据库收集结果,进行整合后返回给用户。

然而,联邦数据库的挑战在于其功能受限于所有底层数据库的“最小公分母”,难以实现跨所有数据源的高级功能(如分布式事务)。

本节课中我们一起学习了分布式OLTP数据库的核心机制。我们探讨了如何通过两阶段提交和Paxos协议实现分布式事务的原子性,了解了主从和多主复制策略以提升可用性,并通过CAP定理理解了分布式系统在一致性、可用性和分区容忍性之间的根本权衡。最后,我们简要介绍了联邦数据库的概念。这些知识是理解和设计可靠、高效的分布式事务处理系统的基础。

24:分布式OLAP数据库 🚀

在本节课中,我们将要学习分布式在线分析处理(OLAP)数据库的核心概念。我们将探讨其架构、执行模型、查询规划,以及如何高效地执行分布式连接。课程内容将帮助初学者理解在分析型工作负载下,如何管理和处理大规模分布式数据。


概述 📋

上一节我们介绍了分布式数据库的事务处理(OLTP),重点在于保证ACID属性。本节中,我们将离开事务处理的世界,转向分析处理(OLAP)。在OLAP中,我们主要执行只读查询,但处理的数据量通常非常庞大,远超单机处理能力。我们将学习如何在这种环境下高效地组织数据、规划查询和执行连接。


典型架构与数据建模 🏗️

一个典型的分析系统架构包含前端OLTP数据库和后端分析数据库(数据仓库)。数据通过ETL(提取、转换、加载)过程从前端流入后端,并在后端进行清理和整合,形成统一的模式。

在数据仓库中,有两种常见的数据建模方法:星型模式和雪花模式。

以下是两种模式的核心区别:

  • 星型模式:包含一个中心事实表和多个一级维度表。事实表存储核心业务事件(如销售记录),维度表存储描述性属性(如产品信息)。这种模式连接简单,查询性能通常更好,但可能导致数据冗余。
  • 雪花模式:是星型模式的规范化扩展。维度表可以进一步连接到其他查找表。这减少了数据冗余,保证了更好的数据完整性,但增加了查询的复杂性,因为需要更多的连接操作。

选择哪种模式需要在查询性能和数据管理复杂度之间进行权衡。


分布式查询执行模型 ⚙️

在分布式环境中执行查询,核心问题在于决定将计算推向数据,还是将数据拉向计算。

上一节我们讨论了分布式事务的协调问题。本节中,我们来看看在只读的分析查询场景下,如何执行查询。主要策略如下:

  • 推送查询:将查询计划片段发送到存储数据的节点,在本地进行过滤和计算,然后只将结果返回。这在无共享架构中很常见,能最大限度地减少网络传输数据量。
  • 拉取数据:协调节点从存储节点获取所需的数据页,在本地进行计算。这在共享磁盘架构中更常见,但现代云存储(如Amazon S3)也开始支持谓词下推,模糊了界限。

对于长时间运行的OLAP查询,节点故障是一个需要考虑的问题。大多数传统分布式OLAP系统不支持查询容错。如果节点在查询中途崩溃,查询通常会失败并需要重启。这是因为为中间结果创建检查点的开销很大。而像Google这样运行在廉价硬件上的系统,则更倾向于实现容错机制。


分布式查询规划 🗺️

分布式查询规划比单机规划更加复杂。优化器不仅需要考虑连接顺序、谓词下推等,还必须考虑数据的物理位置、网络传输成本以及节点间的计算负载。

生成执行计划后,有两种方式将其分发到各节点:

  1. 发送物理计划片段:主节点生成全局物理查询计划,将其切分成片段发送到对应节点执行。这是大多数系统的做法。
  2. 发送重写的SQL查询:主节点将原始SQL查询根据数据分区进行重写,然后将子查询发送到各节点。各节点拥有自己的优化器,可以基于本地数据统计信息生成最优的本地执行计划。MemSQL等系统采用类似方法。

分布式连接算法 🔗

连接是分析查询中最昂贵、最常见的操作。分布式连接算法本身与单机算法(如哈希连接、排序合并连接)并无不同,核心挑战在于如何高效地将需要连接的数据汇集到同一节点

以下是四种常见的场景及处理策略:

  • 场景一:最佳情况

    • 描述:一张表在连接键上分区,另一张表完全复制到所有节点。
    • 策略:每个节点可独立进行本地连接,无需跨节点协调。最后合并结果即可。
    • 公式本地连接(分区数据, 全量复制数据) -> 合并结果
  • 场景二:次佳情况

    • 描述:两个表都在相同的连接键上,并且按相同范围分区。
    • 策略:具有相同键范围的分区可以直接进行本地连接(如分区1的数据只与分区1的数据连接)。这被称为分区连接
    • 代码逻辑for each partition i: 本地连接(Partition_R[i], Partition_S[i])
  • 场景三:广播连接

    • 描述:一张表未在连接键上分区,且该表尺寸较小
    • 策略:将小表广播到所有拥有大表分区的节点,然后在每个节点上进行本地连接。
    • 公式广播(小表) -> 各节点本地连接(本地大表分区, 小表副本)
  • 场景四:最坏情况 - 重分区连接

    • 描述:两个大表都未在连接键上分区,或分区方式不匹配。
    • 策略:必须根据连接键对两个表的数据进行重分区(洗牌),使具有相同键的数据落到同一节点,然后再进行本地连接。这是开销最大的方式。
    • 代码逻辑重分区(R, join_key) -> 重分区(S, join_key) -> 本地连接(新分区_R, 新分区_S)

为了最小化网络传输,优化器会尝试使用半连接等技术,即只传输判断连接是否存在所需的最小信息(如连接键本身),而非整个元组。


云数据库现状 ☁️

云数据库正在改变分布式系统的构建方式。它们主要分为两类:

  • 托管数据库:将现有的数据库系统(如MySQL, PostgreSQL)运行在云虚拟机上,由云提供商负责运维。用户无需管理底层硬件。
  • 云原生数据库:专为云环境设计,深度集成云服务(如对象存储S3)。它们通常是共享磁盘架构,计算层与存储层分离。无服务器数据库是云原生数据库的一种演进,其计算资源可以按需伸缩,在空闲时甚至可以降为零,从而优化成本。

此外,为了实现不同系统间的数据共享,出现了开放的列式存储格式,如 ParquetORCArrow。这些格式不绑定于任何特定数据库,提高了云上数据生态的互操作性。


总结 🎯

本节课中我们一起学习了分布式OLAP数据库的关键知识。我们了解了用于分析的星型与雪花数据模型,探讨了在分布式环境下推送查询与拉取数据的执行策略,并认识到查询容错在OLAP中并非默认提供。我们深入研究了分布式查询规划的复杂性,以及针对不同数据分布情况下的四种连接策略:本地复制连接、分区连接、广播连接和重分区连接。最后,我们概述了云数据库(托管与云原生)的现状及开放数据格式的重要性。掌握这些概念,有助于理解如何在大规模数据分析场景下,有效利用分布式数据库系统。

25:Oracle内存数据库创新

在本节课中,我们将学习Oracle内存数据库的核心创新。我们将探讨其双格式架构、矢量化分析、存储层扩展、智能自动化以及持久内存等关键技术,了解现代企业级数据库如何应对实时数据处理的需求。

双格式架构:支持混合工作负载与更快分析

上一节我们介绍了内存数据库的背景,本节中我们来看看其核心架构。Oracle内存数据库采用双格式架构,同时维护行存储和列存储,以支持混合工作负载。

  • 行存储:适用于事务处理(OLTP),如通过键快速查找并更新特定行。例如,ATM取款交易需要快速定位并修改特定账户记录。
  • 列存储:适用于分析查询(OLAP),如扫描全表但只针对少数列进行聚合或筛选。数据以连续内存块按列存储,访问特定列时效率极高。

优化器会根据查询类型自动选择访问路径。对于点查询,使用行存储和传统索引;对于分析查询,则使用列存储。

数据存储与压缩

我们了解了架构,现在深入看看数据在列存储中是如何组织和压缩的。数据被组织在内存压缩单元(IMCU)中,每个IMCU包含约50万到100万行数据的所有列。

以下是主要的压缩技术:

  • 字典编码:识别列中的不同值,排序并分配数字代码,然后用代码替换原始值。例如,值 [cat, cat, fish, fish, horse] 可能被编码为 [0, 0, 1, 1, 2]
  • 游程编码:识别连续重复的代码,并将其替换为(值,重复次数)对。例如,编码流 [0, 0, 1, 1] 可压缩为 [(0,2), (1,2)]
  • 前缀编码:对已排序的字典值,移除相邻符号间的公共前缀,仅存储差异部分,以进一步节省空间。

压缩数据在扫描时无需完全解压,执行引擎可直接在压缩格式上操作,结合SIMD指令提升效率。

矢量化分析处理

上一节提到压缩数据可直接处理,本节中我们来看看如何利用现代CPU硬件进行高速矢量化分析。SIMD(单指令多数据)指令集允许在一个CPU周期内对一组数据(如64个值)执行相同操作。

以下是一个谓词求值的矢量化过程示例:

// 假设 state 列已字典编码并加载到SIMD寄存器
simd_vector states = load_from_memory(state_column_chunk);
simd_vector california = broadcast_to_vector(“CA”);
// 单条指令完成64次比较
bitmask result = compare_vector_equal(states, california);

通过矢量化,扫描、连接、聚合等操作均可大幅提速,实现每秒数十亿行的处理能力。例如,利用布隆过滤器和字典编码,可将等值连接简化为快速的代码匹配操作。

存储层扩展:内存+闪存

当数据量超出内存容量时,需要扩展到存储层。Oracle Exadata数据库机将架构分为计算节点和存储节点。

  • 计算节点:执行复杂的SQL操作(连接、聚合)。
  • 存储节点:数据持久化存储在SSD,热点数据缓存在闪存中,并以列格式存储。

系统自动实现存储分层:最热数据驻留内存,温数据缓存在闪存,冷数据留在持久存储。查询时,存储节点可利用矢量化技术快速过滤,仅将必要数据传回计算节点。

智能自动化与容错

管理内存和列存储内容可能很复杂,因此需要智能自动化。系统通过监控数据访问模式(频率、类型)自动对数据进行冷热分类。

以下是自动化策略:

  • 将热数据和频繁用于谓词判断或聚合的列保留在内存中。
  • 对很少访问的列进行压缩或移出列存储。
  • 动态调整内存分配,平衡行缓存与列存储的需求。

在容错方面,数据在内存压缩单元级别跨集群节点复制。若某个节点故障,查询可自动重定向到拥有数据副本的其他节点,确保高可用性。

持久内存的应用

持久内存是一种新型硬件,其容量和价格介于DRAM和闪存之间,断电后数据不丢失。它能为内存数据库带来变革。

  • 内存模式:将持久内存与DRAM结合,DRAM作为持久内存的高速缓存。最热数据缓存在DRAM以实现最快访问,大部分数据驻留在容量更大的持久内存中。
  • 优势:允许在“内存”中容纳TB级数据集,避免访问磁盘带来的性能断崖,显著提升处理超大规模数据分析的性能。

总结

本节课中我们一起学习了Oracle内存数据库的五项核心创新。我们了解了双格式架构如何兼顾事务与分析;看到了矢量化处理如何利用硬件实现极致性能;探讨了通过内存与闪存分层扩展存储能力;认识了智能自动化如何简化管理;最后展望了持久内存技术带来的未来潜力。这些技术共同构成了支撑现代实时企业数据处理的基石。

26:更多数据库系统杂烩(Facebook Scuba、MongoDB、CockroachDB) 🗄️

在本节课中,我们将学习三个现代数据库系统的核心概念:Facebook Scuba、MongoDB和CockroachDB。我们将了解它们各自的设计目标、系统架构以及它们如何应用我们本学期所学的数据库原理。

课程概述与期末安排 📅

在深入探讨新系统之前,我们先回顾一下本课程的期末安排。期末考试定于12月9日(星期一)下午5:30在波斯纳大厅举行。考试内容涵盖整个学期的知识,特别是事务、并发控制、崩溃恢复和分布式数据库等核心主题。考试时允许携带一页手写的双面笔记。

关于最终项目和额外学分,提交截止日期是12月10日。请注意学术诚信,抄袭将导致严重后果。请务必填写课程评估,你们的反馈对改进课程至关重要。

Facebook Scuba:实时指标分析系统 📊

上一节我们回顾了课程安排,本节中我们来看看第一个系统:Facebook Scuba。Scuba是Facebook内部开发的数据库系统,专为低延迟查询和摄取海量指标数据而设计。

核心设计目标

Scuba的核心目标是快速处理从Facebook各种服务生成的性能指标日志。它不运行核心业务应用,而是用于内部调试和性能分析,例如追踪某个函数调用为何变慢。

系统架构

Scuba采用分布式共享无状态架构,并利用分层存储(内存、闪存、磁盘)。其架构是异构的,包含不同类型的节点。

以下是Scuba数据处理管道的核心组件:

  • 应用服务器:生成结构化的调试日志(如JSON格式)。
  • Scribe:一个类似于Kafka的内部日志聚合工具,负责收集和分类日志。
  • Tailer服务:将日志记录批量转换为列式存储文件(如Parquet或ORC格式)。
  • 叶节点:存储列式数据文件,并执行查询中的扫描和基础计算。它们不维护元数据或索引。
  • 聚合器节点:接收来自“根”的查询计划片段,分发给叶节点,并聚合返回的结果。
  • 根节点:接收SQL查询(仅支持单表查询,含WHERE过滤和聚合,不支持连接和全局排序),将其分解并协调整个查询过程。

容错与数据一致性

Scuba的一个独特之处在于其对数据丢失的容忍度。由于指标数据的价值并非绝对关键,系统允许最终的数据丢失。它通过在不同区域部署多个冗余的Scuba集群来实现容错。查询会同时发送到所有集群,系统通过一个“验证服务”来了解每个集群读取了多少数据,最终选择数据丢失最少、结果最准确的查询结果。

MongoDB:分布式文档数据库 📄

了解了面向实时分析的Scuba后,我们转向一个更通用的系统:MongoDB。MongoDB是一个开源的分布式文档模型数据库管理系统。

文档数据模型

MongoDB的核心是文档数据模型,使用JSON-like的BSON格式存储数据。它与关系模型的区别在于反规范化。例如,在关系数据库中,客户、订单、订单项会分三张表存储并通过外键连接;而在MongoDB中,这些信息可以嵌入到一个客户文档中,形成嵌套结构,旨在减少查询时的连接操作。

查询与系统演进

MongoDB提供自己的JSON查询API,而非SQL。在查询优化方面,早期版本采用了一种“试错”方法:并行尝试多种查询计划,选择最先返回结果的计划并缓存以供后续相同查询使用。
系统架构属于无共享架构,包含查询路由器、配置服务器和分片节点。它早期因支持自动分片而受欢迎,但初版存储引擎使用mmap,存在全局写锁等问题。后来MongoDB收购了WiredTiger存储引擎,这是一个高性能的键值存储引擎,提供了健全的事务、并发控制和崩溃恢复机制,使MongoDB变得稳定可靠。

重要特性

MongoDB现在已支持多文档事务和连接操作,但其核心优势仍在于灵活的文档模型和对分布式扩展的支持。

CockroachDB:分布式SQL数据库 🪳

最后,我们探讨CockroachDB,这是一个受Google Spanner启发、但架构不同的分布式SQL数据库。

核心架构

CockroachDB是一个分布式共享无状态同构系统。它使用范围分区,底层存储引擎是RocksDB(一个嵌入式键值存储)。CockroachDB支持PostgreSQL有线协议,这意味着许多为PostgreSQL编写的应用可以相对容易地迁移到CockroachDB。

事务与一致性

在事务管理上,CockroachDB默认使用乐观并发控制 并提供可序列化的快照隔离级别。它使用Raft共识协议来跨多台机器协调数据复制和事务提交,确保强一致性。为了在分布式环境下生成全局有序的事务时间戳,它采用了混合逻辑时钟,结合物理时钟和逻辑计数器来减少时钟偏差带来的影响。

查询路由

与Scuba不同,CockroachDB维护一个分布式的分区表,用于跟踪哪个节点是特定数据范围的“领导者”。查询首先查询此元数据,然后将请求路由到正确的领导者节点执行,这提高了查询效率。

总结与课程寄语 🎓

本节课中,我们一起学习了三个现代数据库系统:Facebook Scuba、MongoDB和CockroachDB。

  • Scuba 展示了为特定场景(实时指标分析)定制设计的重要性,其容忍数据丢失的架构与传统数据库形成鲜明对比。
  • MongoDB 的演进说明了工程实践的重要性,从快速原型(使用mmap)到构建稳健产品(集成WiredTiger)的路径。
  • CockroachDB 体现了将经典数据库理论(事务、并发控制)与分布式系统技术(共识协议、混合时钟)结合,以构建全局分布式SQL数据库的努力。

希望本课程为你提供了理解数据库系统内部运作的基础。无论未来从事何种工作,数据库知识都至关重要。记住,在构建应用时,应避免过早优化,通常从像PostgreSQL或MySQL这样成熟的关系数据库开始是一个好选择,随着业务增长再考虑更复杂的分布式方案。

祝大家在期末考试中取得好成绩!

posted @ 2026-03-28 12:22  布客飞龙V  阅读(1)  评论(0)    收藏  举报