提交 af80c33e 编写于 作者: L Longda

fixed #29 add lecture to documents

上级 87226467
# 版权声明
1. 本教材刊载的所有内容,包括但不限于文字报道、图片、视频、图表、标志标识、商标、版面设计、专栏目录与名称、内容分类标准等,均受《中华人民共和国著作权法》、《中华人民共和国商标法》、《中华人民共和国专利法》及适用之国际公约中有关著作权、商标权、专利权以及或其它财产所有权法律的保护,相应的版权或许可使用权均属华中科技大学谢美意老师、左琼老师所有。
2. 凡未经华中科技大学谢美意老师、左琼老师授权,任何媒体、网站及个人不得转载、复制、重制、改动、展示或使用《数据库管理系统实现基础讲义》的局部或全部的内容。如果已转载,请自行删除。同时,我们保留进一步追究相关行为主体的法律责任的权利。
3. 本教材刊载的所有内容授权给北京奥星贝斯科技有限公司。
# 数据库管理系统实现基础讲义
作者 华中科技大学谢美意 左琼
[第1章 数据库管理系统概述](lecture-1.md)
[第2章 数据库的存储结构](lecture-2.md)
[第3章 索引结构](lecture-3.md)
[第4章 查询处理](lecture-4.md)
[第5章 查询优化](lecture-5.md)
[第6章 事务处理](lecture-6.md)
[参考资料](references.md)
[版权声明](copyright.md)
# 第1章 数据库管理系统概述
## 1.1 课程简介
随着信息时代的发展,数据的重要性日益凸显,它是各级政府机构、科研部门、企事业单位的宝贵财富和资源,因此数据库系统的建设对于这些组织的生存和发展至关重要。作为数据库系统的核心和基础,数据库管理系统(Data Base Management System,DBMS)得到了越来越广泛的应用。DBMS帮助用户实现对共享数据的高效组织、存储、管理和存取,经过数十年的研究发展,已经成为继操作系统之后最复杂的系统软件。
对于DBMS的学习一般可分为两个阶段:
第一个阶段是学习DBMS的使用,包括如何运用数据库语言创建、访问和管理数据库,如何利用DBMS设计开发数据库应用程序。在这个阶段,学习者只需掌握DBMS提供的功能,并不需要了解DBMS本身的工作原理。
第二个阶段是学习DBMS的内部结构和实现机制。通过学习DBMS的实现技术,学习者对数据库系统的工作原理会有更深入的理解,这有助于学习者分析数据库系统在复杂应用环境中可能出现的各种性能问题,设计开发出更高效的数据库应用程序,并为其从事数据库管理软件和工具的开发及改进工作打下基础。
在本教程中,我们从DBMS开发者的视角,讨论实现一个关系型DBMS需要考虑的一些关键问题,比如:数据库在存储介质上是如何组织和存储的?一条SQL语句是如何被正确地解析执行的?有哪些结构和方法可以用来快速定位数据库中的记录,提高存取效率?多用户共享数据库时,如何在避免并发错误的同时提高并发度?发生故障时,如何保证数据库能够恢复到正确的状态?在此基础上,学习者可以尝试自己从零开始开发一个简单的DBMS,并逐渐完善、增强它的功能,在这个过程中掌握各种计算机专业知识在DBMS这样的复杂系统软件设计中的应用,提高自己的系统综合能力。
作为学习本课程的前提条件,我们假设学习者已经具备了一定的计算机学科背景知识,包括关系代数、关系数据库语言SQL、数据结构、算法,以及操作系统及编译的相关知识。
## 1.2 数据库管理系统的组成
![图1-1 DBMS内部结构图](images/1-1.png)
<center>图1-1 DBMS内部结构图</center>
DBMS允许用户创建数据库并对数据库中的数据进行查询和修改,同时提供故障时的数据恢复功能和多用户同时访问时的并发控制功能。图1-1是一个DBMS的内部结构示意图。其中单线框表示系统模块,双线框表示内存中的数据结构,实线表示控制流+数据流,虚线表示数据流。该图反映了DBMS的几大主要功能的处理流程,即数据定义、数据操纵和事务管理,这些功能均依赖底层的存储管理及缓冲区管理组件提供对磁盘中数据的访问支持。以下我们分别对这几个功能进行简要说明。
### 1.2.1 存储及缓冲区管理
数据库中的数据通常驻留在磁盘中,当系统需要对数据进行操作时,要先将其从磁盘读入内存。
存储管理器的任务是控制数据在磁盘上的放置和数据在磁盘与内存之间的交换。很多DBMS依赖底层操作系统的文件系统来管理磁盘中的数据,也有一些DBMS为了提高效率,直接控制数据在磁盘设备中的存储和访问。存储管理器登记了数据在磁盘上所处的位置,将上层模块提出的逻辑层面的页面访问请求映射为物理层面的磁盘访问命令。
缓冲区管理器将内存空间划分为与页面同等大小的帧,来缓存从磁盘读入的页面,并保证这些页面在内存和磁盘上的副本的一致性。DBMS中所有需要从磁盘获取信息的上层模块都需要与缓冲区管理器交互,通过缓冲区读写数据。这些信息包括以下类型:
- 数据:数据库自身的内容。
- 元数据:描述数据库的结构及其约束的数据库模式。
- 日志记录:记录事务对数据库所做修改的信息,用于保证数据库的一致性和持久性。
- 统计信息:DBMS收集和存储的关于表、索引等数据库对象的大小、 取值分布等信息,用于查询优化。
- 索引:支持对数据进行高效存取的数据结构。
### 1.2.2 DDL命令的处理
DDL是指数据定义语言,这类命令一般由DBA等有特殊权限的用户执行,用于定义或修改数据库的模式,比如创建或者删除表、索引等。关于数据库模式的描述信息称为元数据。元数据与普通数据一样,也是以表(称为系统表)的形式存在的。DDL命令由DDL处理器解析其语义,然后调用记录管理器及索引管理器对相应的元数据进行修改。
### 1.2.3 DML命令的处理
DML是指数据操纵语言,这类命令一般由普通用户或应用程序执行。DML又可分为对数据库的修改操作(增、删、改)和对数据库的查询操作。
对DML命令的处理中最重要的部分是查询处理。查询处理的过程分为以下几步:
- 查询分析及检查:先对查询语句的文本进行语法分析,将其转换为语法树,然后进行查询检查(例如,检查查询中所提到的关系是否确实存在),并将语法树中的某些结构转换成内部形式,形成查询树。查询树表示了一个关系代数表达式,即要在关系上执行的一系列操作。
- 查询优化:查询优化器利用元数据和关于数据的统计信息来确定哪个操作序列可能是最快的,将最初的查询树等价转换为最高效的操作序列。
- 查询执行:执行引擎负责查询计划的执行,它通过完成查询计划中的各个操作,得到最终的执行结果。在执行过程中,它需要与DBMS中很多其他组件进行交互。例如,调用记录管理器和索引管理器获取需要的数据,调用并发控制组件对缓冲区中的某条记录加锁以避免并发错误,或者调用日志组件登记对数据库所做的修改。
### 1.2.4 事务处理
事务是一组数据库操作,这组操作要么都做,要么都不做,不可分割。一个事务中包含哪些操作是由用户定义的,可以包含多个数据库操作,也可以只包含单个数据库操作。对事务的处理由事务管理器负责,它包括并发控制组件和日志及恢复组件,目的是保证事务的ACID特性,即原子性、一致性、隔离性和持久性。
事务管理器接收来自用户或应用程序的事务命令,从而得知什么时候事务开始、什么时候事务结束、以及事务的参数设置(例如事务的隔离级),然后在事务运行过程中执行下列任务:
- 登记日志:为了保证一致性和持久性,事务对于数据库的每一个修改都在磁盘上记录日志,以保证不管在什么时候发生故障,日志及恢复组件都能根据日志将数据库恢复到某个一致的状态。日志一开始被写到缓冲区中,然后会在适当的时机从日志缓冲区写回到磁盘中。
- 并发控制:事务的执行从表面上看必须是孤立的,但是在大多数系统中,实际上有许多事务在同时执行。因此,并发控制组件必须保证多个事务的各个动作以一种适当的顺序执行,从而使得最终的结果与这些事务串行执行的结果相同。常见的并发控制方式是封锁机制,通过加锁来防止两个事务以可能造成不良后果的方式存取同一数据。
## 1.3 关系模型和SQL
本教程讨论的关系型DBMS是以关系模型为理论基础的。另一方面,SQL作为一种关系数据库标准语言,得到了几乎所有商用关系DBMS的广泛支持。要实现一个关系DBMS,我们需要考虑如何在系统中支持符合关系模型定义的数据结构、数据操作和数据约束,同时支持用户通过SQL命令来访问系统。本节将简单回顾关系模型和SQL中的一些重要概念,并讨论二者的关系。
### 1.3.1关系模型
1970年,E.F.Codd在他的论文《A Relation Model of Data for Large Shared Data Banks》中首次提出关系模型。关系模型相对于层次模型和网状模型的优势在于:它提供了一种只使用自然结构来描述数据的方法,而不需要为了方便机器表示而附加任何额外的结构。这样就为更高级的数据语言提供了基础,这种语言使得程序能够独立于数据的机器表示及组织方式,具有更好的数据独立性。
#### 1.3.1.1关系
关系模型采用的数据结构称为关系。在关系模型中,数据库中的全部数据及数据间的联系都用关系来表示。关系是一个无序的元组集合,每个元组由一组属性值构成,表示一个实体。一个有n个属性的关系称为n元关系。由于关系中的元组是无序的,因此DBMS可以采用任何它希望的方式存储它们,以便进行优化。
#### 1.3.1.2 主键和外键
主键和外键反映了关系模型的实体完整性约束和参照完整性约束。
主键可唯一地标识关系中的一个元组,以确保没有任何两个元组是完全一样的。如果用户没有定义主键,有些DBMS会自动创建内部主键。
外键指定一个关系中的属性在取值时必须与另一个关系中的某个元组相对应,不能随意取值。
#### 1.3.1.3 关系代数
关系代数是关系模型定义的一组运算符,用于检索和操作关系中的元组。每个运算符接受一个或多个关系作为输入,并输出一个新的关系。为了表示查询,可以将这些运算符连接在一起以创建更复杂的运算,称为关系代数表达式。
常见的关系代数运算符包括:
- **选择(selection)**:选择运算是从关系R中选取满足给定条件的元组构成结果关系,记作σF(R)。
- **投影(Projection)** :投影运算是从关系R中选取若干属性列A构成结果关系,记作 ΠA(R)。
- **并( Union )** :两个关系R和S的并是由属于R或属于S的元组构成的集合,记为 R∪S。
- **交( Intersection)** :两个关系R和S的交是由既属于R又属于S的元组构成的集合,记为 R ∩ S。
- **差(Difference )** :两个关系R和S的差是由属于R但不属于S的元组构成的集合,记为 R-S。
- **笛卡尔积( Cartesian Product)** :两个关系R和S的笛卡尔积是由这两个关系中元组拼接而成的所有可能的元组的集合,记为R×S。
- **自然连接(Natural Join)** :两个关系R和S的自然连接是由这两个关系中在共同属性上取值相等的元组拼接而成的所有可能的元组的集合,记为R⋈S。
关系代数可以被视为一种过程化语言,因为一个关系代数表达式指定了查询的具体计算步骤。例如, ![1.3.1.3-1](images/1.3.1.3-1.png) 指定的计算步骤是先计算关系S和SC的自然连接,然后选择,而 ![1.3.1.3-2](images/1.3.1.3-2.png) 指定的计算步骤则是先选择后连接。这两个表达式其实是等价的,它们的计算结果相同,但是计算速度却不同,后者明显更快。如果像这样由用户来指定查询的计算步骤,性能优化的压力就会落在用户身上,因为他们必须考虑如何写出更高效的查询表达式。所以更好的方法是DBMS提供一种非过程化语言,用户只指定需要什么数据,而不指定如何找到它。这正是SQL的成功之处。
### 1.3.2 SQL
SQL 是关系数据库的标准语言,它是1974 年由Boyce和Chamberlin提出的,最初叫 Seque(Structured English Query Language), 并在IBM公司研发的关系数据库管理系统原型System R上实现,后改名为SQL(Structured Query Language)。SQL是一种通用的、功能极强的关系数据库语言,其功能不仅仅是查询,而是包括数据库模式创建、数据库数据的插入与修改、数据库安全性完整性定义与控制等一系列功能。但是,数据查询仍然是SQL中最重要、也最具特色的功能。
关系模型中的关系在SQL中被映射为表或视图。其中,表是指数据实际存储在数据库中的关系,视图是指不实际存储数据,但是需要时可以由实际存储的关系构造出来的关系。
需要指出的是,关系模型中的关系和SQL中的表和视图在概念上存在一些差异。前者是基于集合(set)的,即关系中的元组是不允许重复的;而后者是基于包(bag)的,允许表、视图或结果集中出现重复的元组。
SQL的查询通过SELECT语句来表达,它的基本语法如下:
```sql
SELECT <列名或表达式序列>
FROM <表名或视图名序列>
[WHERE <行条件表达式>]
[GROUP BY <列名序列>
[HAVING <组条件表达式>] ]
[ORDER BY <排序列名>[ASC|DESC] [,...]]
```
以上语法成分中,只有SELECT和FROM子句是必不可少的。此外,SQL还提供了一个强大的特性,允许在WHERE、FROM或HAVING子句中嵌入子查询。子查询也是一个SELECT语句,在上述的WHERE、FROM或HAVING子句中可以使用子查询的返回结果来进行计算,这也是SQL之所以称为&quot;结构化&quot;查询语言的原因。
对于一条典型的查询语句,其结果可以这样计算:
1. 读取FROM子句中基本表及视图的数据,并执行笛卡尔积操作;
2. 选取其中满足WHERE子句中条件表达式的元组;
3. 按GROUP BY子句中指定列的值分组;
4. 提取满足HAVING子句中组条件表达式的那些分组;
5. 按SELECT子句投影出结果关系;
6. 按ORDER BY子句对结果关系进行排序。
以上计算过程可以被看作是对一系列关系代数运算的执行。实际上一个SELECT语句在DBMS中就是被解析为一个关系代数表达式,再由执行引擎来对其进行计算的。但是对于同一条SELECT语句,可能存在多个等价的关系代数表达式。例如,对于以下语句:
```sql
SELECT 姓名
FROM 学生, 选课
WHERE 学生.学号=选课.学号 AND 课号=2 ;
```
存在多个等价的关系代数表达式:
1. ​ Π<sub>姓名</sub><sub>学生.学号=选课.学号 ∧ 课号=2 </sub>(学生×选课))
2. ​ Π<sub>姓名</sub><sub>课号=2</sub> (学生⋈选课))
3. ​ Π<sub>姓名</sub>(学生⋈σ<sub>课号=2</sub> (选课)
这三个表达式的计算代价差异巨大,而DBMS的一个重要任务就是通过查询优化处理找到其中代价最小的那一个。SQL采用的这种非过程化语言形式,既简化了用户的表达,又为DBMS优化查询语句的执行性能提供了巨大的灵活性。
# 第2章 数据库的存储结构
## 2.1 存储设备概述
大多数计算机系统中都存在多种数据存储类型,根据不同存储介质的速度和成本,可以把它们按层次结构组织起来,如图2-1所示。位于顶部的存储设备是最接近CPU的,其存取速度最快,但是容量最小,价格也最昂贵。离CPU越远,存储设备的容量就越大,不过速度也越慢,每比特的价格也越便宜。
![图2-1 存储设备层次结构图](images/2-1.png)
<center>图2-1 存储设备层次结构图</center>
按其存储数据的持久性,可将存储设备分为易失性存储和非易失性存储两类。
- **易失性存储:** 易失性意味着当机器掉电时存储介质中的数据会丢失。易失性存储支持随机字节寻址方式,程序可以跳转到任意字节地址并获取数据。易失性存储通常指的是内存。
- **非易失性存储:** 非易失性是指存储设备不需要通过连续供电来保证其存储的数据不丢失。非易失性存储设备是块寻址的,这意味着为了读取该设备中特定偏移位置上的一个值,必须先将包含这个值的一个块的数据加载到内存中。非易失性存储设备虽然也支持随机存取,但通常在顺序访问时(即同时读取多个连续块时)性能表现更好。目前常见的非易失性存储有固态硬盘(SSD)和机械硬盘(HDD),在本教程中不刻意区分,统称为磁盘。
除了上述存储设备,目前还有一种称为持久内存(persistent memory)的新型存储设备。持久内存既有内存的高速性,又有磁盘的持久性,兼具双重优势,不过这类设备不在本教程的讨论范围内。
## 2.2 面向磁盘的DBMS概述
根据数据库的主存储介质的不同,DBMS可分为面向磁盘(disk-oriented)和面向内存(memory-oriented)两种体系结构,本教程重点介绍经典的面向磁盘的体系结构。这种体系结构的特点是,为了保证在系统发生故障时的数据持久化,数据库使用非易失的磁盘作为主存储介质,但是由于系统不能直接操作磁盘上的数据,因此还需使用易失的内存作为缓存。众所周知,相对于内存,磁盘的访问速度非常慢,因此在面向磁盘的DBMS中,需要重点考虑的一个问题就是,如何在磁盘和内存之间交换数据才能减少磁盘I/O带来的性能延迟。
![图2-2 面向磁盘的DBMS](images/2-2.png)
<center>图2-2 面向磁盘的DBMS</center>
面向磁盘的DBMS的存储架构如图2-2所示。DBMS将数据库映射到文件中,这些文件由底层操作系统维护,永久存储在磁盘上。因为文件存取是操作系统提供的基本功能,所以我们默认文件系统总是作为DBMS的基础而存在的。主流操作系统提供的通常为无结构的流文件,DBMS会将每个文件再划分为固定大小的数据块,称为页(page)。页是DBMS在磁盘和内存间交换数据的基本单元。
如果需要对数据库进行读写操作,DBMS需要先将数据从磁盘读取到内存中的缓冲池内,缓冲池管理器负责在磁盘和内存之间以页为单位进行数据交换。DBMS的执行引擎在语句处理过程中需要使用某个数据页时,会向缓冲池提出请求,缓冲池管理器负责将该页读入内存,并向执行引擎提供该页在内存中的指针。当执行引擎操作那部分内存时,缓冲池管理器必须确保该页面始终驻留在那片内存区域中。
## 2.3 文件的组织结构
### 2.3.1文件的分页
DBMS最常见的做法是将数据库以文件的形式存储在磁盘上。有些DBMS可能使用一组文件来存储数据库,有些DBMS可能只使用单个文件。
从操作系统的角度来看,一个文件就是一个字节流序列,操作系统并不关心和了解文件的内容以及文件之间的关联性。数据库文件的内容只有创建它的DBMS才知道如何解读,因为它是由DBMS以其特定的方式来组织的。
数据库文件的组织和管理由DBMS的存储管理器负责,它将文件划分为页面的集合,并且负责跟踪记录这些页面的使用情况,包括哪些页面存储了什么数据,哪些页面是空闲的等等。页面中可以存储不同类型的数据,比如记录、索引等,但是DBMS通常不会将不同类型的数据混合存储在同一个页面中。
### 2.3.2 页的标识
每个页面都有一个唯一的标识符。如果数据库是单个文件,那么页面ID可以直接映射为文件内的偏移量;如果数据库包含多个文件,则还需加上文件标识符来进行区分。大多数DBMS都有一个间接层,能够将页面ID映射为文件路径和偏移量。系统上层模块请求一个页面时,先给出页面ID,存储管理器将该页面ID转换为文件路径和偏移量,并由此定位到对应页面。
### 2.3.3 页的大小
大多数DBMS使用固定大小的页面,因为支持可变大小的页面会带来很多麻烦。例如,对于可变大小的页面,删除一个页面可能会在数据库文件中留下一个空缺,而由于页面的大小不等,这个空缺位置很难被一个新页填满,从而导致碎片问题。
大多数数据库默认使用4~8KB的页大小,但是许多数据库允许用户在创建数据库实例时自定义页的大小。
需要注意区分以下两个关于页的概念:
- **硬件页:** 即磁盘块,大小通常为4 KB,是磁盘I/O的基本单位。
- **数据库页:** 大小通常为磁盘块大小的整数倍,是DBMS在磁盘和缓冲池之间交换数据的基本单位。
二者的区别在于,对硬件页的写操作是原子的,但是对数据库页的写操作则不一定。换言之,如果硬件页的大小为4KB,那么当系统尝试向磁盘写入一个硬件页时,这4KB数据要么全部写入,要么全部不写入,这一点是由存储设备来保证的。但是,如果数据库页大于硬件页,那么DBMS对一个数据库页的写操作将被操作系统分解为对多个硬件页的写操作,此时DBMS必须采取额外措施来确保数据被安全地写入磁盘,因为系统可能会在将一个数据库页写入到磁盘的过程中发生崩溃,从而导致该数据库页的内容出现不一致性错误。
### 2.3.4 堆文件
关系是记录的集合,这些记录在数据库文件中可以有多种组织方式:
- **堆文件组织( heap file organization)** :堆文件是页的无序集合,记录在页中以随机的顺序存储。即,一条记录可以放在文件中的任何地方,只要那里有足够的空间存放这条记录,记录间不用考虑先后顺序的。 通常每个关系使用一个单独的堆文件。
- 顺序文件组织(sequential file organization):记录根据其&quot;查找键&quot;的值顺序存储。
- **散列文件组织( hash file organization)** :在每条记录的某个/些属性上计算一个散列函数,根据散列的结果来确定将记录放到文件的哪个页面中。
在本节种,我们重点介绍堆文件的组织方式。由于这种组织方式并不关心记录间的顺序,因此DBMS只需要登记堆文件中哪些页面中是存储了数据的(数据页),哪些页面是空闲的(空闲页)。具体可以采用以下两种表示形式:
- 链表:以链表的形式将文件中的空闲页和数据页分别勾连起来,并在文件的首页维护两个指针,分别指向空闲页链表和数据页链表的第一个页面,如图2-3所示。这种方式下,如果想要找到一个特定的数据页,需要从链首开始逐个扫描链表中的页面,直到找到为止,I/O开销较大。
- 页目录:维护一种特殊的页面(目录页),在该页中记录每个数据页的位置以及该数据页中剩余的空闲空间大小,如图2-4所示。页目录将页面的状态信息集中存放在一起,可以提高查找特定页面的速度。
![图2-3 链表表示法](images/2-3.png)
<center>图2-3 链表表示法</center>
![图2-4 页目录表示法](images/2-4.png)
<center>图2-4 页目录表示法</center>
## 2.4 页的组织结构
一个页面的内部结构可以粗略的划分为两部分:
- **页头** :页头登记了关于页面内容的元数据,如页面大小、校验和、DBMS版本、事务可见性、压缩信息等。有些系统(如Oracle)要求页面是自包含的,即关于该页的所有描述信息都可以在该页面中找到。
- **数据区** :存放数据的区域。这里我们只讨论如何在数据区中存放记录。目前DBMS中最常用的方法是采用槽式页面。这种方法将数据区划分为一个个插槽(slot),每个插槽中放置一条记录。
注意,本节的讨论基于以下限制条件:(1)不存在整个数据区放不下单条记录的情况;(2)一条记录必须包含在单个页面中,换言之,没有哪条记录是一部分包含在一个页面中、一部分包含在另一个页面中的(第5节讨论的溢出页除外),这个限制可以简化并加速数据访问。
### 2.4.1 槽式页面
在槽式页面结构中,为了登记当前页面中有多少条记录以及每条记录的位置,必须在页头中维护以下信息:
1. 本页中已使用的槽的数量;
2. 最后一个已使用的槽的起始位置;
3. 一个槽数组,登记本页中每个记录的起始位置。
如果允许记录是变长的,我们一开始并不能确定一个页面中能存放多少条记录,因此也就无法确定槽数组的最大长度,也就是说页头所占的区域大小是不确定的。因此比较合理的做法是,向页中插入记录时,槽数组从前向后增长,而被插入的记录数据则是从页尾向前增长。当槽数组和记录数据相遇时,则认为该页面是满页。槽式页面的布局示意图如图2-5所示。
![图2-5 槽式页面的布局](images/2-5.png)
<center>图2-5 槽式页面的布局</center>
### 2.4.2 插入记录
向关系中插入一条记录时,对于堆文件,只需要找到一个有足够空闲空间能放得下这条记录的页面,或当所有已分配页面中都没有足够空闲空间时,就申请一个新的空闲页,然后将记录放置在那里。
### 2.4.3 删除记录
从页中删除记录时,需要考虑如何回收该记录的空间。
一种方法是在页内滑动记录,使得记录间没有空隙,从而保证页面中未使用的区域一定位于槽数组和已使用区域之间,图2-5表示的就是这种方式。
如果不滑动记录,则需要在页头维护一个空闲区列表,以保证当向页中插入一条新记录时,我们能知道该页中的空闲区在哪里,有多大。当然,页头通常不必存储全部空闲区列表,只存列表的链头就够了,然后可以使用空闲区自身的空间存储下一个空闲区的信息。
### 2.4.4 修改记录
如果修改的是定长记录,对页面存储没有影响,因为修改后记录占用的空间与修改前完全相同。但是如果修改的是变长记录,就会碰到与插入和删除类似的问题。
如果修改后的记录比其旧版本长,则我们需要在当前页面中获得更多的空间,这个过程可能涉及记录的滑动。如果当前页面中的空闲区域不够,还需要将记录移动到其他页面。反之,如果记录由于修改而变短,我们可以像删除记录时那样回收其释放的空间。
## 2.5 记录的组织结构
记录本质上就是一个字节序列,如何将这些字节解释为属性类型和值是DBMS的工作。与页面结构类似,记录内部结构也可以分为两部分:
- **记录头** :存放关于记录的元数据,例如DBMS并发控制协议的可见性信息(即哪个事务创建/修改了此记录的信息)、NULL值的位映射等。注意,关于数据库模式的元数据没有必要存储在记录头里。
- **记录数据** :包含记录中各个属性的实际数值。如前所述,大多数DBMS不允许记录的长度超过页面的大小,且一个页面中一般只存放同一个关系的记录。
### 2.5.1 定长记录
定长记录全部由定长字段组成,是最简单的记录组织形式。定长记录的插入和删除是比较容易实现的,因为被删除的记录留出的可用空间恰好是插入新的记录所需要的空间。
定长记录在组织时需要注意的一个问题是内存对齐问题。很多处理器需要在数据的开始地址为4或8的倍数时才能实现更高效的内存读写,所以DBMS在组织记录数据时通常会根据情况使所有字段的起始地址是4或8的倍数。采用这种做法时,一个字段前可能会存在一些没有被上一个字段使用的空间,这些空间其实是被浪费掉了。但尽管如此,这样做还是有必要的。因为记录虽然是存放在磁盘而不是内存中,但是对记录的操作仍需在内存中进行,所以在组织记录时需要考虑如何让它在内存能够被高效访问。
### 2.5.2 变长记录
变长记录允许记录中存在一个或多个变长字段。由于变长字段在记录中的偏移位置是不确定的,因此记录中必须包含足够多的信息,让我们能够方便地提取记录的任何字段。变长记录的实现可以采用以下两种方法。
一种简单有效的实现方法,是将所有定长字段放在变长字段之前,然后在记录头写入以下信息:(1)记录长度;(2)除第一个变长字段之外的所有变长字段的偏移位置。之所以不需要存第一个变长字段的偏移位置,是因为我们知道第一个变长字段就紧跟在定长字段之后。一个变长记录的例子如图2-6所示,该记录共包含四个字段,其中有两个变长字段:name和address。
![图2-6 变长记录表示方法一示例](images/2-6.png)
<center>图2-6 变长记录表示方法一示例</center>
变长记录的另一种表示方法是保持记录定长,将变长部分放在另一个溢出页中,而在记录本身存储指向每一个变长字段开始位置的指针,如图2-7所示。
![图2-7 用溢出页存放变长字段](images/2-7.png)
<center>图2-7 用溢出页存放变长字段</center>
这种方法的好处是可以保持记录定长,能够更有效地对记录进行搜索,记录也很容易在页内或页间移动。但是另一方面,将变长部分存储在另一个页中,增加了为检索一条记录的全部数据而需要进行的磁盘I/O次数。
溢出页不仅可以存储变长字段,还可以用于存储大值数据类型的字段,比如TEXT和BLOB字段,这些数据往往需要使用多个页面来存储。
## 2.6 缓冲池管理
面向磁盘的DBMS的一个主要目标就是尽量减少磁盘和内存之间传输的页面数量。减少磁盘访问次数的一种方法是在内存中保留尽可能多的页面,理想情况下,要访问的页面正好都已经在内存中了,这样就不再需要访问磁盘了。
但是在内存中保留所有的页面是不可能的,所以就需要有效地管理内存中用于缓存页面的空间,尽可能提高页面在内存中的命中率。用于缓存页面的那部分内存空间称为缓冲池,负责缓冲池空间分配的子系统称为缓冲池管理器。
### 2.6.1 缓冲池结构
缓冲池本质上是在DBMS内部分配的一大片内存区域,用于存储从磁盘获取的页面。这片内存空间被组织为一个数组,其中每个数组项被称为一个帧(frame),一个帧正好能放置一个页面。当一个页面被请求时,DBMS首先搜索缓冲池,如果在缓冲池中没有找到该页,就从磁盘获取该页的副本,并放置到缓冲池的一个帧中。缓冲池的组织结构如图2-8所示。
![图2-8 缓冲池组织结构](images/2-8.png)
<center>图2-8 缓冲池组织结构</center>
为了有效和正确地使用缓冲池,缓冲池管理器必须维护一些元数据。
页表是一个内存哈希表,用于登记当前已经在内存中的页面的信息。页表将页面ID映射到缓冲池中一个帧的位置。因为缓冲池中页面的顺序不一定反映磁盘上的顺序,所以需要通过这个额外的数据结构来定位页面在缓冲池中的位置。
除了保存页面的内存地址,页表还为每个页面维护一个脏标志和一个引用计数器。
- 脏标志:脏标志由线程在修改页面时设置。如果一个页面被设置了脏标志,就意味着缓冲池管理器必须将该页写回磁盘,以保证磁盘上的页面副本包含最新的数据。
- 引用计数:引用计数表示当前访问该页(读取或修改该页)的线程数。线程在访问该页之前必须增加引用计数。如果页的引用计数大于零,说明该页面正在被使用,此时不允许缓冲池管理器从内存中淘汰该页。
关于缓冲池中的内存空间如何分配的问题,缓冲池管理器可采取两种策略:
- 全局策略:有利于当前整体工作负载的策略。全局策略综合考虑所有活动事务,以找到分配内存的最佳方案。
- 本地策略:以保证单个查询或事务运行得更快为目标的策略。本地策略将一个帧分配给特定事务时,不考虑其他并发事务的行为,即使这样可能对整体工作负载不利。
### 2.6.2 缓冲池替换算法
与其他应用程序一样,DBMS对数据库文件的读写操作都需要通过调用操作系统的接口来实现。通常,为了优化I/O性能,操作系统自身也维护了一个缓冲区来缓存从磁盘读入的数据块。这个缓冲区和DBMS的缓冲池在功能上显然是重复的,会导致同一个数据库页面的数据在内存中的冗余存储,而且操作系统缓冲区的管理策略还使得DBMS难以控制内存与磁盘之间的页面交互。因此,大多数DBMS都使用直接I/O绕过操作系统的缓存。
当DBMS需要释放一个帧来为新的页面腾出空间时,它必须决定从缓冲池中淘汰哪个页面,这取决于DBMS采用的缓冲池替换算法。替换算法的目标是提高正确性、准确性、速度和元数据开销。需要注意的是,引用计数大于零的页面是不能淘汰的。
常用的替换算法有最近最少使用(LRU)算法和时钟(CLOCK)算法。
- LRU算法:LRU算法为每个页面维护其最后一次被访问的时间戳,这些时间戳可以存储在一个单独的数据结构(如队列)中,以便对其进行排序来提高效率。需要淘汰页面时,DBMS总是选择淘汰时间戳最早的页面。
- CLOCK算法:CLOCK算法是一种近似LRU算法,它不需要每个页面都有单独的时间戳,而是为每个页面维护一个引用位。当某个页面被访问时,就将它的引用位的值置为1。想象页面被组织在循环缓冲区中,需要选择淘汰页面时,有一个&quot;时钟指针&quot;在循环缓冲区中扫描,检查页面的引用位是否为1。如果是,则将引用位重新置0并移动指针到下一个页面;否则,淘汰当前页面。
LRU算法和CLOCK算法应用于DBMS的缓冲池管理时存在许多问题。比如顺序扫描时,LRU和CLOCK容易使缓冲池的内容出现顺序溢出问题。因为顺序扫描会依次读取每个页面,所以读取页面的时间戳并不能反映我们实际想要哪些页面。换句话说,最近使用的页面实际上是最不需要的页面。
有三种解决方案可以解决LRU和CLOCK算法的缺点。
第一种解决方案是LRU-K,它会以时间戳的形式登记最后K次引用的历史,并计算连续引用之间的时间间隔,将此历史记录用于预测页面下一次被访问的时间。
第二种解决方案是对每个查询进行局部化,DBMS在每个查询的局部范围内选择要淘汰的页面,这样可以最小化每个查询对缓冲池的污染。
最后一种解决方案是优先级提示,它允许事务在查询执行期间根据每个页面的上下文,告诉缓冲池管理器该页面是否重要。
在淘汰页面时,对于脏页可以有两种处理方法:(1)总是优先淘汰缓冲池中的非脏页面;(2)先将脏页写回磁盘以确保其更改被持久化,然后再将其淘汰。后者会降低替换页面的速度;而前者虽然速度快,但是有可能将未来不会被再次访问的脏页留在缓冲池。
避免在淘汰页面时执行页面写出操作的一种方法是后台写。采用这种方法的DBMS会定期遍历页表并将脏页写入磁盘。当脏页被安全写入磁盘后,将该页面的脏标志重新置零。
### 2.6.3 缓冲池的优化
有许多方法来优化缓冲池,使其适合应用程序的工作负载。
(1)多缓冲池
DBMS可以维护多个用于不同目的的缓冲池,比如每个数据库使用一个缓冲池,每种页面类型使用一个缓冲池。然后针对其中存储的数据的特点,每个缓冲池可以采用量身定制的管理策略。
将所需页面映射到缓冲池有两种方法:对象ID和散列。对象ID这种方法需要扩展元数据,使其包含关于每个缓冲池正在管理哪些数据库对象的信息,然后通过对象ID,就可以实现从对象到特定缓冲池的映射。另一种方法是散列,DBMS散列页面ID以选择访问哪个缓冲池。
(2)预取
DBMS还可以根据查询计划通过预取页面来进行优化。然后,在处理第一组页面时,系统可以将第二组页面预取到缓冲池中。这种方法通常在顺序访问多个页面时使用。
(3)扫描共享
查询游标可以重用从磁盘读入的数据或操作符的计算结果。这种方法允许将多个查询附加到扫描表的单个游标上。当一个查询开始扫描时,如果已经有另一个查询在扫描,DBMS会将第一个查询附加到第二个查询的游标上。DBMS登记第二个查询加入时的位置,以便在到达数据结构末尾时结束扫描。
(4)缓冲池旁路
为了避免开销,顺序扫描操作符不会将获取的页存储在缓冲池中,而是使用正在运行的查询的本地内存。如果操作符需要读取磁盘上连续的大量页序列,那么这种方法可以很好地工作。缓冲池旁路也可以用于临时数据,如排序、连接。
### 2.6.4 其他内存池
除了元组和索引,DBMS还需要内存来存放其他东西。这些内存池中的内容可能并不总是来自磁盘或者需要写入磁盘,具体取决于实现。
- 排序+连接缓冲区
- 查询缓存
- 维护缓冲区
- 日志缓冲区
- 字典缓存
# 第3章 索引结构
## 3.1 索引结构概述
许多查询只涉及表中的少量记录。例如&quot;查找学号为&#39;U2021001&#39;的学生的专业&quot;,这个查询最多只涉及学生表中的一条记录。如果系统为了找到学号为&quot;U2021001&quot;的记录而读取整个学生表,这样的操作方式显然是低效的。理想情况下,系统应该能够直接定位到这条记录。为了支持这种访问方式,需要额外设计一些与表相关联的附加结构,我们称之为索引。
索引是这样的数据结构:它以一个或多个属性的值为输入,并能快速地定位具有该值的记录的位置。建立索引的属性(组)称为查找键(search key)。与表一样,索引结构同样存储在数据库文件中。例如,我们可以用一个数据文件来存储一个表,用一个索引文件来存储一个索引。一个数据文件可能拥有一个或多个索引文件。
由于索引是表的附加结构,当表的内容发生变化时,DBMS必须同步更新该表的索引,以确保索引的内容与表的内容一致。由此可见,索引虽然有助于提高查询性能,但是索引本身也会带来存储和维护开销,因此在一个数据库应用中,具体创建什么索引、以及创建多少索引,用户是需要权衡的。不过在查询的执行过程中,是否需要使用索引、以及使用哪些索引,则是由DBMS来决定的,用户并不能干涉。如何恰当地利用索引来提高查询的执行效率,是DBMS的重要工作。
数据库系统中存在不同类型的索引结构,这些索引结构之间没有绝对的优劣之分,只能说某种索引结构在某种特定的场景下是最合适的。评价一种索引结构一般参考以下指标:
- 查找类型:该索引结构能有效支持的查找类型,比如等值查找、范围查找等。
- 查找时间:使用该索引结构找到一个特定索引项(集)所需的时间。
- 插入时间:插入一个新的索引项所需的时间,包括找到插入这个新索引项的正确位置,以及更新索引结构所需的时间。
- 删除时间:删除一个索引项所需的时间,包括找到待删除项所需的时间, 以及更新索引结构所需的时间。
- 空间开销:索引结构所占用的存储空间。
在本教程中,我们将介绍数据库系统中最常用的索引结构: B+树和散列表。
## 3.2 B+树
### 3.2.1 B+树的结构
B+树是一种平衡排序树,树中根结点到叶结点的每条路径的长度相同,并且保持键的有序排列。在B+树中进行搜索、顺序访问、插入和删除的时间复杂度均为O(log(n)),它是在数据插入和删除的情况下仍能保持其执行效率的几种使用最广泛的索引结构之一,几乎所有现代DBMS都使用B+树。
B+树可以定义为具有以下性质的m路搜索树:
- 除非整棵树只有一个结点,否则根结点至少有两个子结点;
- 除根结点外的所有内结点至少是半满的,即有⌈m/2⌉到m个子结点;
- 所有叶结点的深度相等;
- 叶结点中键的数量必须大于等于 ⌈(m-1)/2⌉ 且小于等于 m-1 ;
- 每个有k个键的内结点都有k+1个非空子结点;
- 叶结点中包含所有查找键值。
![图3-1 B+树示意图](images/3-1.png)
<center>图3-1 B+树示意图</center>
B+树的示意图如图3-1所示。树中的每个结点中都包含一个键/值对数组,这个数组是按键排序的。键/值对中的键来自索引的查找键,值则根据结点类型而有不同含义。如果结点是内结点,则值是指向子结点的指针。如果结点是叶结点,则结点中的值可能是记录ID,比如对于数据库中的非聚集索引,B+树中存放的就是指向记录位置的指针;叶结点中的值也可能是记录数据,比如对于聚集索引, B+树中存放的就是记录的实际数据。
在树的最底层,叶结点间通过兄弟指针链接起来,形成一个按所有键值大小排序的链表,以便更高效地支持范围查找等顺序处理。
图3-1中的B+树,其m的取值为4。在具体实现中,将B+树索引存储到磁盘文件中时,通常用一个页面来存储一个结点,在页面能够容纳的前提下,应该把m的值取得尽可能大,从而使得树的高度尽可能小。
### 3.2.2 B+树的查找
1. 等值查找
假设有一棵B+树,如果想找出键值为K的记录,则需要执行从根结点到叶结点的递归查找,查找过程为:
1. 若当前结点为内结点,且结点中的键为**K<sub>1</sub>,K<sub>2</sub>,…,K<sub>n</sub>**,则根据以下规则来决定下一步对此结点的哪一个子结点进行查找:
1. 如果**K<K<sub>1</sub>**,则下一个结点为第1个子结点;
2. 如果**K<sub>i</sub>≤K<K<sub>i</sub>+1**,则下一个结点为第i+1个子结点;
3. 如果**K≥K<sub>n</sub>**,则下一个结点为第n+1个子结点。
递归执行此查找过程,直到查找到叶结点;
1. 若当前结点为叶结点,在该结点的键值中查找,若第i个键值为K,则根据第i个值即可找到所需记录;否则查找失败。
1. 范围查找
如果想在B+树中找出在范围[a, b]之间的所有键值,先通过等值查找来查找键a,不论键a在B+树中是否存在,都会到达可能出现a的叶结点,然后在该叶结点中查找等于或大于a的那些键。只要在当前叶结点中不存在比b大的键,就根据兄弟指针找到下一个叶结点,继续查找[a, b]之间的所有键值。
上面的查找算法在查找范围只有上界或者只有下界时也有效:
1. 当查找范围为[a,+∞)时,先找到键a可能出现的叶结点,然后从该结点中第一个等于或大于a的键开始,一直到最后一个叶结点的最后一个键。
2. 当查找范围为(‐∞, b]时,则从B+树的第一个叶结点开始向后查找,直到遇到第一个超过b的键时停止查找。
### 3.2.3 B+树的插入
要向B+树中插入一个新索引项,必须遍历该树并使用内部结点来确定将键插入到哪个叶结点。在插入过程中,当结点太满时需要对其进行拆分,过程如下:
1. 找到正确的叶结点L;
2. 将新索引项按顺序插入到L中:
1. 如果L有足够的空间,则执行插入操作,算法结束;
2. 否则,将L平均拆分为L和L2两个结点,并复制L2的第一个键,将其插入到L的父结点中。
1. 如果父结点中有足够的空间,则执行插入操作,算法结束;否则拆分父结点,将该结点的中间键上移插入到其父结点,然后将剩余的索引项平均拆分为两个结点。递归执行此步骤直到算法结束。
![图3-2 B+树的插入过程示意图-a](images/3-2-a.png)
<center>(a) 插入10后</center>
![图3-2 B+树的插入过程示意图-b](images/3-2-b.png)
<center>(b) 插入10后</center>
![图3-2 B+树的插入过程示意图-c](images/3-2-c.png)
<center>(c) 插入2后</center>
<center>图3-2 B+树的插入过程示意图</center>
图3-2是向一棵4路B+树分别插入键值10和2的过程。可以看到,插入键值10后,原B+树中最右的叶结点发生了分裂,新增叶结点的第一个键值10被复制并插入到父结点中。插入键值2后,最左的叶结点发生了分裂,新增叶结点的第一个键值3被复制并插入到父结点中,而且还进一步导致了父结点的分裂,其中间键值7被上移并插入到新增的根结点中。
### 3.2.4 B+树的删除
在删除过程中,如果因删除索引项导致结点小于半满状态,则必须合并结点。过程如下:
1. 找到待删除的索引项所在的叶结点L;
2. 从L中删除该索引项,删除后:
1. 如果L不低于半满状态,则算法结束;
2. 否则,通过向兄弟结点借索引项来满足约束条件,如果能成功借到,则算法结束;
3. 如果兄弟结点也没有多余的索引项可借,则合并L和兄弟结点,删除父结点中指向被合并子结点的索引项。递归执行以上删除操作,直至算法结束。
![图3-3 B+树的删除过程示意图-a](images/3-3-a.png)
<center>(a) 删除前</center>
![图3-3 B+树的删除过程示意图-b](images/3-3-b.png)
<center>(b) 删除6后</center>
![图3-3 B+树的删除过程示意图-c](images/3-3-c.png)
<center>(c) 删除1后</center>
<center>图3-3 B+树的删除过程示意图</center>
图3-3是从一棵5路B+树中先后删除键值6和1的过程。可以看到,删除键值6时,原B+树中第二个叶结点中的项数已经无法满足最低要求,因此向左边的兄弟结点借了1项来达到约束条件。删除键值1时,最左的叶结点中项数无法满足最低要求,而且兄弟结点也没有多余的项可借,因此只能对最左的两个结点进行合并。
### 3.2.5 非唯一查找键
基于某个查找键来构建索引时,假如表中存在两条或者多条记录在查找键属性上拥有相同的值,那么该查找键称为非唯一查找键。
非唯一查找键的一个问题在于影响记录删除的效率。假设某个查找键值出现了很多次,当表中拥有该查找键值的某条记录被删除时,为了维护索引与表数据的一致性,删除操作需要在B+树中查看很多个索引项,才能从中找出和被删除记录相对应的那个索引项并删除它,这个过程可能需要遍历多个叶结点。
解决以上问题的方法有两种:
一种简单的解决方法是创建包含原始查找键和其他额外属性的复合查找键,确保该复合查找键对于所有记录是唯一的,这种方法通常被大多数数据库系统使用。这个额外属性也叫唯一化属性,它可以是记录ID,或者是在拥有相同查找键值的所有记录中取值唯一的任何其他属性。删除一条记录时,先计算该记录的复合查找键值,然后再用这个复合键值到索引中查找。因为复合查找键值是唯一的,所以不会影响记录删除的效率。在这种方法中,一个查找键值在记录中出现多少次,它在索引中就会被重复存储多少次。
另一种方法是,每个查找键值在B+树中只存储一次,并且为该查找键值维护一个记录指针的桶(或者列表)来解决非唯一问题。这种方法虽然没有存储冗余信息,但是索引维护和修改起来更加复杂。
## 3.3 散列表
散列表也叫哈希表,是一种常见的数据结构,它通过把键值映射到桶数组中的某个位置来加快查找记录的速度。散列表中包含两个关键元素:
- **散列函数** :散列函数h以查找键(散列键)为参数并计算出一个介于0到B-1之间的整数。
- **桶数组** :桶数组是一个编号从0到B-1、长度为B的数组,其中包含B个链表头,每个链表头对应一个桶,用于存储记录。
构造散列表时,如果一条记录的查找键为K,则将该记录链接到桶号为h(K)的桶中存储。
散列表在DBMS中被广泛运用,例如基于散列表来组织数据文件、基于散列表来构造索引文件、或者基于散列表进行连接运算等。当散列表的规模大到内存难以容纳时,或者出于数据持久化的目的,就需要将散列表存储在磁盘中。本教程主要讨论散列表在磁盘上的实现。
磁盘中的散列表与内存中的散列表存在一些区别。首先,桶数组是由页面组成,而不是由指向链表的指针组成;其次,散列到某个桶中的记录是存储在磁盘上的页面而非内存中。因此,磁盘上的散列表在设计时需要考虑访问磁盘的I/O代价以及表规模的扩展问题。
### 3.3.1 静态散列表
对于一个散列表,如果其桶数组的规模B(即桶的数量)一旦确定下来就不再允许改变,则称其为静态散列表。
#### 3.3.1.1散列函数
由于在设计时无法事先准确知道文件中将存储哪些搜索键值,因此我们希望选择一个具有下列特性的散列函数:
- 函数的输出是确定的。相同的搜索键值应该总是生成相同的散列值。
- 输出值的分布是随机且均匀的。散列函数应该表现为随机的,即散列值不应与搜索键的任何外部可见的排序相关,且不管搜索键值实际怎样分布,每个桶应分配到的记录数应该几乎相同。
- 易于计算。散列函数的执行时间不能太长,因为它需要执行很多次。
理想的散列函数是能将搜索键值均匀地分布到所有桶中,使每个桶含有相同数目的记录,但是这样的函数往往需要非常长的时间来进行计算。因此,散列函数需要在冲突率和快速执行之间进行权衡。目前最先进的散列函数是Facebook XXHash3。
#### 3.3.1.2散列表的插入
当一个查找键为K的新记录需要被插入时,先计算h(K),找到桶号为h(K)的桶。如果桶内还有空间,我们就把该记录存放到此桶对应的页面中。如果该桶的页面中已经没有空间了,就增加一个新的溢出页,链接到该桶之后,并把新记录存入该页面。这种处理桶溢出问题的方式称为溢出链,如图3-4所示。
![图3-4 散列表的溢出链](images/3-4.png)
<center>图3-4 散列表的溢出链</center>
#### 3.3.1.3散列表的删除
删除查找键值为K的记录与插入操作的方式类似。先找到桶号为h(K)的桶,由于不同的查找键值可能被映射到同一个桶中,因此还需要在桶内搜索,查找键值为K的记录,继而将找到的记录删除。删除记录后,如果允许记录在页面中移动,还可以选择合并同一桶链上的页面来减少链的长度。但是合并页面也有一定的风险,如果交替地往一个桶中插入和删除记录,可能导致页面被反复地创建和删除。
#### 3.3.1.4散列表的效率
如果希望达到最好的查找效率,理想情况是散列表中有足够的桶,每个桶只由单个页面组成。如果是这样,那么查询一条记录就只需一次磁盘I/O,且记录的插入和删除也只需两次磁盘I/O。
为了减少桶溢出的可能性,桶的数量B可选为 (_n_/_f_)\*(1+_d_),其中n是要存储的记录总数,f是一个桶中能存放的记录数,d表示避让因子,一般取值为0.2。这种做法会导致一定的浪费,平均每个桶有20%的空间是空的,好处则是减少了溢出的可能性。
但是,如果记录不断增长,而桶的数量固定不变,那么最终还是会出现很多桶都包含多个页面的情况。这种情况下,我们就需要在由多个页面构成的桶链中查找记录,每访问一个新的页面就增加一次磁盘I/O,这显然会严重影响散列表的查找效率。
### 3.3.2 动态散列表
静态散列表由于其桶的数量不能改变,因此当无法预知记录总数时,难以解决由于记录数不断增长而带来的性能问题。本节我们将讨论两种动态散列表,它们能够以不同的方式动态调整散列表的大小,既不需要重新构建整个表,又能保证每个桶大多只有一个页面,从而最大化读写效率。
#### 3.3.2.1 可扩展散列表
与静态散列表相比,可扩展散列表在结构上做了以下改变:
- 增加了一个间接层,用一个指向页面的指针数组(桶地址表)而非页面数组来表示桶数组。
- 指针数组能动态增长,且数组长度总是2的幂,因此数组每增长一次,桶的数量就翻倍。
- 并非每个桶都单独拥有一个页面。如果多个桶的记录只需一个页面就能放下,那么这些桶可能共享一个页面,即多个桶指针指向同一个页面。
- 散列函数h为每个键计算出一个长度为N的二进制序列,N的值足够大(比如32),但是在某一时刻,这个序列中只有前i位(i≤N)被使用,此时桶的数量为 2i个。
可扩展散列表的一般形式如图3-5所示。
![图3-5 可扩展散列表结构示意图](images/3-5.png)
<center>图3-5 可扩展散列表结构示意图</center>
向可扩展散列表中插入键值为K的记录的方法如下:
1. 计算h(K),取出该二进制序列的前i位,并找到桶数组中编号与之相等的项,定位到该项对应的页面,假设该页面的编号为j;
2. 如果页面j中还有剩余空间,则将该记录插入该页面,操作结束;
3. 如果页面j已满,则需要分裂该页面:
a) 如果i=i<sub>j</sub>,说明在桶地址表中只有一个表项指向页面j,此时分裂该页,需要增加桶地址表的 大小,以容纳由于分裂而产生的两个桶指针。令i=i+1,使桶地址表的大小翻倍。桶地址表扩 展后,原表中的每个表项都被两个表项替代,且这两个表项都包含和原始表项一样的指针, 所以也应该有两个表项指向页面j。此时,分配一个新的页面n,并让第二个表项指向页面n。 将i<sub>j</sub>和i<sub>n</sub>的值均置为当前的i值,并将原页面j中的各条记录重新散列,根据前i位来确定该记录 是放在页面j中还是页面n中,然后再次尝试插入新记录。极端情况下,新纪录要插入的页面 可能仍然是满的,说明原页面j中的所有记录在分裂后仍然被散列到了同一个页面中,此时需 要继续上述分裂过程,直至为新纪录找到可存放的空间。
b) 如果i> i<sub>j</sub>,说明在桶地址表中有多个表项指向页面j,此时不需要扩大桶地址表就能分裂页面 j。分配一个新的页面n,将i<sub>j</sub>和i<sub>n</sub>置为原i<sub>j</sub>加1后的值;调整桶地址表中原来指向页面j的表项, 其中一半仍指向页面j,另一半则指向新创建的页面n;重新散列页面j中的各条记录,将其分 配到页面j或页面n中,并再次尝试插入新记录。与上一种情况一样,插入仍有可能失败,此 时需继续进行页面分裂的处理。
以下是一个可扩展散列表的例子。图3-6(a)所示为一个小型的可扩展散列表,假设其散列函数h能产生4位二进制序列,即N=4。散列表只使用了1位,即i=1。此时桶数组只有2项,一个编号为0,一个编号为1,分别指向两个页面。第一页存放所有散列值以0开头的记录,第二页存放所有散列值以1开头的记录。每个页面上都标注了一个数字,表示由散列函数得到的二进制序列中的前几位用于判定记录在该页面中的成员资格。目前两个页面都只用了1位。
接下来向表中插人一个散列值为1010序列的记录。因为第一位是1,所以该记录应放入第二个页面,但第二页已经满了,因此需要分裂该页。而此时i<sub>2</sub>=i=l,因此先要将桶数组翻倍,令i=2,将数组的长度扩展为4。
扩展桶数组后,以0开头的两个项都指向存放散列值以0开头的记录的第一页,且该页上标注数字仍然为1, 说明该页中记录的成员资格只由其散列值的第一位判定。而原本存放散列值以1开头的记录的页面则需要分裂,把这个页面中以10开头和11开头的记录分别存放到两个页面中。在这两个页面上方标注的数字是2,表示该页面中记录的成员资格需要使用散列值的前两位来判定。改变后的散列表如图3-6(b)所示。
![图3-6 可扩展散列表举例-a](images/3-6-a.png)
<center>(a) 插入前</center>
![图3-6 可扩展散列表举例-b](images/3-6-b.png)
<center>(b) 插入散列值为1010的记录后</center>
<center>图3-6 可扩展散列表举例</center>
可扩展散列表的优点在于每个桶只有一个页面,所以如果桶地址表小到可以驻留在内存的话,查找一个记录最多只需要一次磁盘I/O。但是由于它是以桶数组翻倍的形式扩展的,所以也存在以下缺点:
- 随着i的增大,每次桶数组翻倍时需要做的工作将越来越多,而且这些工作还会阻塞对散列表的并发访问,影响插入和并发操作的效率。
- 随着i的增大,桶地址表会越来越大,可能无法全部驻留在内存,或者会挤占其他数据在内存中的空间,导致系统中的磁盘I/O操作增多。
#### 3.3.2.2 线性散列表
针对可扩展散列表存在的问题,下面介绍另一种动态散列表,称为线性散列表。相对于可扩展散列表,线性散列表中桶的增长较为缓慢,它有以下特点:
- 桶数n的大小,要能使所有桶中的实际记录总数与其能容纳的记录总数之间的比值保持在一个指定的阈值之下(如80%),如果超过该阈值,则增加一个新桶。
- 允许桶有溢出页,但是所有桶的平均溢出页数远小于1。
- 若当前的桶数为n,则桶数组项编号的二进制位数i=⌈ log<sub>2</sub>n⌉。
令一个线性散列表当前桶数为n,桶数组项编号的二进制位数为i,向线性散列表中插入键值为K的记录的方法如下:
1. 计算h(K),取出该二进制序列右端的i位,假设为a<sub>1</sub>a<sub>2</sub>…a<sub>i</sub>,令a<sub>1</sub>a<sub>2</sub>…a<sub>i</sub>对应的二进制整数为m。如果m<n说明编号为m的桶存在将记录存入桶m中如果nm<2<sup>i</sup>,说明编号为m的桶还不存在,则将记录存入编号为(m-2<sup>i</sup>-1)的桶中,即将a<sub>1</sub>a<sub>2</sub>…a<sub>i</sub>中的a<sub>1</sub>改为0时对应的桶。
2. 如果要插入的桶中没有空间,则创建一个溢出页,将其链到该桶上,并将记录就存入该溢出块中。
3. 插入记录后,计算 (当前实际记录总数r) / (n个桶能容纳的记录总数) 的值,并跟阈值相比,若超过阈值,则增加一个新桶到线性散列表中。注意,新增加的桶和之前发生插入的桶之间没有任何联系。如果新桶编号的二进制表示为la<sub>2</sub>a<sub>3</sub>…a<sub>i</sub>,则分裂桶号为0a<sub>2</sub>a<sub>3</sub>…a<sub>i</sub>的桶中的记录,根据这些记录的散列值的后i-1位分别散列到这两个桶中。
当n的值超过2<sup>i</sup>时,需要将i的值加1。理论上,对于现有的桶编号,要在它们的位序列前面增加一个0,来保证跟新的桶编号的位数一致,但是由于桶编号被解释成二进制整数,因此实际上它们只需要保持原样即可。
以下是一个线性散列表的例子。
图3-7(a)所示为一个桶数n=2 的线性散列表,桶编号所需要的二进制位数i = ⌈ log<sub>2</sub>2⌉ = 1,表中的记录数r=3。图中两个桶的编号分别为0和1,每个桶包含一个页面,每个页面能存放两个记录。假设散列函数产生4位二进制序列,用记录散列值的末位来确定该记录所属的桶,所有散列值以0结尾的记录放入第一个桶,以1结尾的记录放入第二个桶。
在确定桶数n时,本例使用的阈值是85%,即桶的平均充满率不超过总容量的85%。
下面先插入散列值为0101的记录。因为0101以1结尾,所以记录应放入第二个桶。插入该记录后,两个桶中存放了四个记录,平均充满率为100%,超过了85%,因此需要增加一个新桶,即桶数n=3。i = ⌈log<sub>2</sub>3⌉ = 2,即桶编号需要2位。新增的桶的编号为10。接着,分裂桶00(即原来的桶0),将散列值为0000 (末两位为00)的记录保留在桶00中,散列值为1010(末两位为10)的记录存入桶10中,改变后的散列表如图3-7(b)所示。
接下来再插入散列值为0001的记录。因为0001的末两位为01,所以应将该记录存入桶01中。不巧的是,该桶的页面已经装满,所以需要增加一个溢出页来提供存储空间。插入后,3个桶中有5条记录,平均充满率约83%,未超过85%,所以不需要创建新桶。改变后的散列表如图3-7(c)所示。
![图3-7 线性散列表举例-a](images/3-7-a.png)
<center>(a) 插入前</center>
![图3-7 线性散列表举例-b](images/3-7-b.png)
<center>(b) 插入散列值为0101的记录后</center>
![图3-7 线性散列表举例-c](images/3-7-c.png)
<center>(c) 插入散列值为0001的记录后</center>
<center>图3-7 线性散列表举例</center>
# 第4章 查询处理
## 4.1查询处理概述
![图 4-1 关系数据库查询处理流程](images/4-1.png)
<center>图 4-1 关系数据库查询处理流程</center>
关系数据库管理系统查询处理可以分为4个阶段:查询分析、查询检查、查询优化和查询执行。
1. **查询分析** :对用户提交的查询语句进行扫描、词法分析和语法分析,判断是否符合SQL语法规则,若没有语法错误,就会生成一棵语法树。
2. **查询检查** :对语法树进行查询检查,首先根据数据字典中的模式信息检查语句中的数据对象,如关系名、属性名是否存在和有效;还要根据数据字典中的用户权限和完整性约束信息对用户的存取权限进行检查。若通过检查,则将数据库对象的外部名称转换成内部表示。这个过程实际上是对语法树进行语义解析的过程,最后语法树被解析为一个具有特定语义的关系代数表达式,其表示形式仍然是一棵树,称为查询树。
3. **查询优化** :每个查询都会有多种可供选择的执行策略和操作算法,查询优化就是选择一个能高效执行的查询处理策略。一般将查询优化分为代数优化和物理优化。代数优化指对关系代数表达式进行等价变换,改变代数表达式中操作的次序和组合,使查询执行更高效;物理优化则是指存取路径和底层操作算法的选择,选择依据可以是基于规则、代价、语义的。查询优化之后,形成查询计划。
4. **查询执行** :查询计划由一系列操作符构成,每一个操作符实现计划中的一步。查询执行阶段,系统将按照查询计划逐步执行相应的操作序列,得到最终的查询结果。
## 4.2 选择运算
选择操作的典型实现方法有全表扫描法和索引扫描法。
### 4.2.1 全表扫描法
对查询的基本表顺序扫描,逐一检查每个元组是否满足选择条件,把满足条件的元组作为结果输出。
假设可以使用的内存为M块,全表扫描的算法思想如下:
1. 按物理次序读表T的M块到内存;
2. 检查内存的每个元组t,如果t满足选择条件,则输出t;
3. 如果表T还有其他块未被处理,重复(1)和(2)。
这种方法适合小表,对规模大的表要进行顺序扫描,当选择率(即满足条件的元组数占全表比例)较低时,此算法效率很低。
### 4.2.2 索引扫描法
当选择条件中的属性上有索引(例如B+树索引或Hash索引)时,通过索引先找到满足条件的元组指针,再通过元组指针直接在要查询的表中找到元组。
**[例1 ]** 等值查询:`select * from t1 where col=常量`,并且col上有索引(B+树索引或Hash索引均可) ,则使用索引得到col为该常量元组的指针,通过元组指针在表t1中检索到结果。
**[例2 ]** 范围查询: `select * from t1 where col > 常量`,并且col上有B+树索引,使用B+树索引找到col=常量的索引项,以此为入口点在B+树的顺序集上得到col \&gt; 常量的所有元组指针, 通过这些元组指针到t1表中检索满足条件的元组。
**[例 3 ]** 合取条件查询:`select * from t1 where col1=常量a AND col2 >常量b`,如果 col1和 col1上有组合索引(col1,col2),则利用此组合索引进行查询筛选;否则,如果 col1和 col2上分别有索引,则:
方法一:分别利用各自索引查找到满足部分条件的一组元组指针,求这2组指针的交集,再到t1表中检索得到结果。
方法二:只利用索引查找到满足该部分条件的一组元组指针,通过这些元组指针到t1表中检索,对得到的元组检查另一些选择条件是否满足,把满足条件的元组作为结果输出。
一般情况下,当选择率较低时,基于索引的选择算法要优于全表扫描。但在某些情况下,如选择率较高、或者要查找的元组均匀分散在表中,这时索引扫描法的性能可能还不如全表扫描法,因为还需要考虑扫描索引带来的额外开销。
## 4.3 排序运算
排序是数据库中的一个基本功能,用户通过Order by子句即能达到将指定的结果集排序的目的,而且不仅仅是Order by子句,Group by、Distinct等子句都会隐含使用排序操作。
### 4.3.1 利用索引避免排序
为了优化查询语句的排序性能,最好的情况是避免排序,合理利用索引是一个不错的方法。因为一些索引本身也是有序的,如B+树,如果在需要排序的字段上面建立了合适的索引,那么就可以跳过排序过程,提高查询速度。
例如:假设t1表存在B+树索引key1(key\_part1, key\_part2),则以下查询可以利用索引来避免排序:
```sql
SELECT * FROM t1 ORDER BY key_part1, key_part2;
SELECT * FROM t1 WHERE key_part1 = constant ORDER BY key_part2;
SELECT * FROM t1 WHERE key_part1 > constant ORDER BY key_part1;
SELECT * FROM t1 WHERE key_part1 = constant1 AND key_part2 > constant2 ORDER BY key_part2;
```
如果排序字段不在索引中,或者分别存在于多个索引中,或者排序键的字段顺序与组合索引中的字段顺序不一致,则无法利用索引来避免排序。
### 4.3.2 数据库内部排序方法
对于不能利用索引来避免排序的查询,DBMS必须自己实现排序功能以满足用户需求。实现排序的算法可以是文件排序,也可以是内存排序,具体要由排序缓冲区(sort buffer)的大小和结果集的大小来确定。
数据库内部排序的实现主要涉及3种经典排序算法:快速排序、归并排序和堆排序。对于不能全部放在内存中的关系,需要引入外排序,最常用的就是外部归并排序。外部归并排序分为两个阶段:Phase1 – Sorting,对主存中的数据块进行排序,然后将排序后的数据块写回磁盘;Phase2 – Merging,将已排序的子文件合并成一个较大的文件。
#### 4.3.2.1 常规排序法
一般情况下通用的常规排序方法如下:
(1) 从表t中获取满足WHERE条件的记录;
(2) 对于每条记录,将记录的主键+排序键(id,colp)取出放入sort buffer;
(3) 如果sort buffer可以存放所有满足条件的(id,colp)对,则进行排序;否则sort buffer满后,进行排序并固化到临时文件中。(排序算法采用快速排序);
(4) 若排序中产生了临时文件,需要利用归并排序算法,保证临时文件中记录是有序的;
(5) 循环执行上述过程,直到所有满足条件的记录全部参与排序;
(6) 扫描排好序的(id,colp)对,并利用id去取SELECT需要返回的目标列;
(7) 将获取的结果集返回给用户。
从上述流程来看,是否使用文件排序主要看sort buffer是否能容下需要排序的(id,colp)对。此外一次排序涉及两次I/O:第一次是取(id,colp),第二次是取目标列。由于第一次返回的结果集是按colp排序,因此id是乱序的。通过乱序的id去取目标列时,会产生大量的随机I/O。因此,可以考虑对第二次I/O进行优化,即在取数据之前首先将id排序并放入缓冲区,然后按id顺序去取记录,从而将随机I/O转为顺序I/O。
为了避免第二次I/O,还可以考虑一次性取出(id,colp,目标列),当然这样对缓冲区的需求会更大。
#### 4.3.2.2 堆排序法
堆排序法适用于形如&quot;order by limit m,n&quot;的这类排序问题,即跳过m条数据,提取n条数据。这种情况下,虽然仍然需要所有元组参与排序,但是只需要m+n个元组的sort buffer空间即可,对于m和n很小的场景,基本不会出现因sort buffer不够而需要使用临时文件进行归并排序的问题。对于升序,采用大顶堆,最终堆中的元素组成了最小的n个元素;对于降序,则采用小顶堆,最终堆中的元素组成了最大的n的元素。
## 4.4 连接运算
连接操作是查询处理中最常用最耗时的操作之一。主要有4种实现方法:嵌套循环、排序-合并、索引连接和散列连接。
首先引入2个术语:外关系(outer relation)和内关系(inner relation)。外关系是左侧数据集,内关系是右侧数据集。例如:对于A JOIN B,A为外关系,B为内关系。多数情况下,A JOIN B 的成本跟 B JOIN A 的成本是不同的。假定外关系有n个元组,内关系有m个元组。
### 4.4.1 嵌套循环连接
嵌套循环连接是最简单且通用的连接算法,其执行步骤为:针对外关系的每一行,查看内关系里的所有行来寻找匹配的行。这是一个双重循环,时间复杂度为O(n\*m)。
![图 4-2 嵌套循环连接示意图](images/4-2.png)
<center>图 4-2 嵌套循环连接示意图</center>
在磁盘 I/O 方面, 针对外关系的每一行,内部循环需要从内关系读取m行。这个算法需要从磁盘读取 n+ n\*m 行。但是,如果外关系足够小,我们可以把它先读入内存,那么就只需要读取 n+m 行。按照这个思路,外关系就应该选更小的那个关系,因为它有更大的机会装入内存。
当然,内关系如果可以由索引代替,对磁盘 I/O 将更有利。
当外关系太大无法装入内存时,采用块嵌套循环连接方式,对磁盘 I/O 更加有利。其基本思路是将逐行读取数据,改为以页(块)为单位读取数据。算法如下:
(1) 从磁盘读取外关系的一个数据页到内存;
(2) 从磁盘依次读取内关系的所有数据页到内存,与内存中外关系的数据进行比较,保留匹配的结果;
(3) 从磁盘读取外关系的下一个数据页,并继续执行(2),直至外关系的最后一个页面。
与嵌套循环连接算法相比,块嵌套循环连接算法的时间复杂度没有变化,但降低了磁盘访问开销,变为M+M\*N。其中,M为外关系的页数,N为内关系的页数。
### 4.4.2 索引嵌套循环连接
在嵌套循环连接中,若在内关系的连接属性上有索引,则可以用索引查找替代文件扫描。对于外关系的每一个元组,可以利用索引查找内关系中与该元组满足连接条件的元组。这种连接方法称为索引嵌套循环连接,它可以在已有索引或者为了计算该连接而专门建立临时索引的情况下使用。
索引嵌套循环连接的代价可以如下计算。对于外关系的每一个元组,需要先在内关系的索引上进行查找,再检索相关元组。在最坏的情况下,缓冲区只能容纳外关系的一页和索引的一页。此时,读取外关系需M次I/O操作,这里的M指外关系的数据页数;对于外关系中的每个元组,在内关系上进行索引查找,假设索引查找带来的I/O开销为C,则总的I/O开销为:M+(m×C),其中m为外关系的元组数。
这个代价计算公式表明,如果两个关系上均有索引时, 一般把元组较少的关系作外关系时效果较好。
![图4-3 索引连接示意图](images/4-3.png)
<center>图4-3 索引连接示意图</center>
### 4.4.3 排序-合并连接
排序-合并连接算法常用于等值连接,尤其适合参与连接的表已经排好序的情况。其方法如下:
第一步:如果参与连接的表没有排好序,则根据连接属性排序;
第二步:sorted\_merge:
(1) 初始化两个指针,分别指向两个关系的第一个元组;
(2) 比较两个关系的当前元组(当前元组=指针指向的元组);
(3) 如果匹配,保留匹配的结果,两个指针均后移一个位置;
(4) 如果不匹配,就将指向较小元组的那个指针后移一个位置;
(5) 重复步骤(2)、(3)、(4),直到其中一个关系的指针移动到末尾。
![图4-4 排序-合并连接示意图](images/4-4.png)
<center>图4-4 排序-合并连接示意图</center>
因为两个关系都是已排序的,不需要&quot;回头去找&quot;,所以此方法的时间复杂度为O(n+m)。如果两个关系还需要排序,则还要考虑排序的成本:O(n\*Log(n) + m\*Log(m))。
很多情况下,参与连接的数据集已经排好序了,比如:表内部就是有序的,或者参与连接的是查询中已经排好序的中间结果,那么选用排序-合并算法是比较合适的。
### 4.4.4 散列连接
散列连接算法也是适用于等值连接的算法。
散列连接分成两个阶段:第一步,划分阶段,为较小的关系建立hash表,将连接属性作为hash码;第二步,试探阶段,对另一张表的连接属性用同样的hash函数进行散列,将其与相应桶中匹配的元组连接起来。
本算法要求内存足够大,小表的hash表如果能全部放进内存,则效果较好。
![图 4-5 散列连接示意图](images/4-5.png)
<center>图 4-5 散列连接示意图</center>
在时间复杂度方面需要做些假设来简化问题:
(1) 内关系被划分成 X 个散列桶。散列函数几乎均匀地分布每个关系内数据的散列值,即散列桶大小一致。
(2) 外关系的元素与散列桶内所有元素的匹配,成本是散列桶内元素的数量。
算法的开销包括创建散列表的成本(m) +散列函数的计算开销\*n + (m/X) \* n。如果散列函数创建的散列桶的规模足够小,则算法复杂度为O(m+n)。
### 4.4.5 连接算法的选择
具体情况下,应该选择以上哪种连接算法,有许多因素要考量:
(1) 空闲内存:没有足够的内存就无法使用内存中的散列连接。
(2) 两个数据集的大小。比如,如果一个大表连接一个很小的表,那么嵌套循环连接就比散列连接快,因为后者有创建散列表的高昂成本;如果两个表都非常大,那么嵌套循环连接的CPU成本就很高。
(3) 是否有索引:如果连接属性上有两个B+树索引的话,合并连接会是很好的选择。
(4) 关系是否已经排序:这时候合并连接是最好的选择。
(5) 结果是否需要排序:即使参与连接的是未排序的数据集,也可以考虑使用成本较高的合并连接(带排序的),比如得到排序的结果后,我们还可以将它用于另一个合并联接,或者查询中存在ORDER BY/GROUP BY/DISTINCT等操作符,它们隐式或显式地要求一个排序结果。
(6) 连接的类型:是等值连接?还是内连接?外连接?笛卡尔积?或者自连接?有些连接算法在某些情况下是不适用的。
(7) 数据的分布:如果连接条件的数据是倾斜的,用散列连接不是好的选择,因为散列函数将产生分布极不均匀的散列桶。
(8) 多表连接:连接顺序的选择很重要。
另外,还可能考虑实现方式问题,比如连接操作使用多线程或多进程的代价考量。因此,DBMS需要通过查询优化器来选择恰当的执行计划。
## 4.5 表达式计算
如何计算包含多个运算步骤的关系代数表达式?有两种方法:物化计算和流水线计算。
### 4.5.1 物化计算
物化计算以适当的顺序每次执行一次操作;每次计算的结果被物化到一个临时关系以备后用。其缺点为:需要构造临时关系,而且这些临时关系必须写到磁盘上(除非很小)。
表达式的执行顺序可以依据表达式在查询树中的层次而定,从树的底部开始。
![图4-6 一棵查询树](images/4-6.png)
<center>图4-6 一棵查询树</center>
如图4-6所示,此例中只有一个底层运算:department上的选择运算,底层运算的输入是数据库中的关系department。用前面提到的算法执行树中的运算,并将结果存储在临时关系中。在树的高一层中,使用这个临时关系来进行计算,这时输入的要么是临时关系,要么是一个数据库关系。通过重复这一过程,最终可以计算位于树的根节点的运算,从而得到表达式的最终结果。
由于运算的每个中间结果会被物化用于下一层的运算,此方法称为物化计算。物化计算的代价不仅是那些所涉及的运算代价的总和,还可能包括将中间结果写到磁盘的代价。
### 4.5.2 流水线计算
流水线计算可同时计算多个运算,运算的结果传递给下一个,而不必保存临时关系。这种方法通过减少查询执行中产生的临时文件的数量,来提高查询执行的效率。
如图4-6中,可以将选择、连接操作和投影操作组合起来,放入一条流水线,选择得到一个结果传给连接、连接产生一个结果元组马上传送给投影操作去做处理,避免中间结果的创建,从而直接产生最终结果。
创建一个操作的流水线可以带来的好处是:
(1) 消除读和写临时关系的代价,从而减少查询计算代价。
(2) 流水线产生查询结果,边生成边输出给用户,提高响应时间。
流水线可按两种方式来执行:
方式一:需求驱动方式,在操作树的顶端的将数据往上拉。
方式二:生产者驱动方式,将数据从操作树的底层往上推。
需求驱动的流水线方法比生产者驱动的流水线方法使用更广泛,因为它更容易实现。但流水线技术限制了能实现操作的可用算法。例如,若连接运算的左端输入来自流水线,则不能使用排序-合并连接,但可以用索引连接算法。由于这些限制,并非所有情况下流水线方法的代价都小于物化方法。
# 第5章 查询优化
## 5.1 查询优化概述
查询优化即求解给定查询语句的高效执行计划的过程。它既是关系数据库管理系统实现的关键技术,又是关系系统的优点所在。由DBMS进行查询优化的好处在于:查询优化的优点不仅在于用户不必考虑如何最好的表达查询以获得较高的效率,而且在于系统可以比用户程序的&quot;优化&quot;做得更好。
查询计划,从形式上看是一颗二叉树,树叶是每个单表对象,两个树叶的父节点是一个连接操作符连接后的中间结果(另外还有一些其他节点如排序等也可以作为中间结果),这个结果是一个临时关系,这样直至根节点。
从一个查询计划看,涉及的主要&quot;关系节点&quot;包括:
- 单表节点:考虑单表的获取方式(全表扫描,或索引获取,或索引定位再I/O到数据块获取数据)。这是一个物理存储到内存解析成逻辑字段的过程。
- 两表节点:考虑两表以何种方式连接,代价有多大,连接路径有哪些等。表示内存中的元组如何进行元组间的连接。此时,元组通常已经存在于内存中。这是一个完整用户语义的逻辑操作,但只是局部操作,只涉及两个具体的关系。完成用户全部语义,需要配合多表的连接顺序的操作。
- 多表中间节点:考虑多表连接顺序如何构成代价最少的&quot;执行计划&quot;。决定连接执行的顺序。
查询优化的总目标是选择有效的策略,求得给定关系表达式的值,使得查询代价较小。因为查询优化的搜索空间有时非常大,实际系统选择的策略不一定是最优的,而是较优的。
查询优化主要包括逻辑优化和物理优化。其中,逻辑优化又可包含语法级查询优化、基于规则的优化等;而物理优化主要指基于代价的优化。语法级优化是基于语法的等价转换;基于规则的优化(如依据关系代数的规则或依据经验的规则等)具有操作简单且能快速确定执行方式的优点,但这种方法只是排除了一部分不好的可能;基于代价的优化是在查询计划生成过程中,计算每条存取路径进行量化比较,从而得到开销最小的情况,但如果组合情况多则开销的判断时间就很多。查询优化器的实现,多是这两种优化策略的组合使用。
## 5.2 逻辑优化
查询优化器在逻辑优化阶段主要解决的问题是:如何找出SQL语句的等价变换形式,使SQL执行更高效。
### 5.2.1代数优化
代数优化是基于关系代数等价变换规则的优化方法。
代数优化策略是通过对关系代数表达式的等价变换来提高查询效率。所谓关系代数表达式的等价是指用相同的关系代替两个表达式中相应的关系所得到的结果是相同的。两个关系表达式E1和E2是等价的。
#### 5.2.1.1 关系代数表达式等价变换规则
常用的关系代数等价变换规则如下:
1. **连接、笛卡尔积的交换律**
设E1和E2为关系代数表达式,F为连接运算条件,则有:
​ E1×E2 ≡ E2×E1
​ E1⋈E2 ≡ E2⋈E1
​ ![5.2.1.1-1](images/5.2.1.1-1.png) ≡ ![5.2.1.1-2](images/5.2.1.1-2.png)
对于连接和笛卡尔积运算,可以交换前后位置,其结果不变。例如,两表连接算法中有嵌套循环连接算法,对外表和内表有要求,外表尽可能小则有利于做&quot;基于块的嵌套循环连接&quot;,所以通过交换律可以将元组少的表作为外表。
1. **连接、笛卡尔积结合律**
设E1、E2、E3为关系代数表达式,F1、F2为连接运算条件。则有:
​ (E1×E2)×E3 ≡ E1×(E2×E3)
​ (E1⋈E2)⋈E3 ≡ E1⋈(E2⋈E3)
​ ![5.2.1.1-3](images/5.2.1.1-3.png) ≡ ![5.2.1.1-4](images/5.2.1.1-4.png)
对于连接、笛卡尔积运算,如果新的结合有利于减少中间关系的大小,则可以优先处理。
1. **投影的串接定律**
设E为关系代数表达式,A<sub>i</sub>(i=1,2,3,…,n),B<sub>j</sub>(j=1,2,3,…,m)是属性名,且{A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>}为{B<sub>1</sub>,B<sub>2</sub>,…,B<sub>m</sub>}的子集。则有:
​ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub>(∏<sub>B<sub>1</sub>,B<sub>2</sub>,…,B<sub>m</sub></sub>(E)) ≡ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub> (E)
在同一个关系上,只需做一次投影运算,且一次投影时选择多列同时完成。所以许多数据库优化引擎会为一个关系收集齐该关系上的所有列,即目标列和WHERE、GROUP BY等子句中涉及到的所有该关系的列。
1. **选择的串接律**
设E为关系代数表达式,F<sub>1</sub>、F<sub>2</sub>为选择条件。则有:
​ σ<sub>F<sub>1</sub></sub><sub>F<sub>2</sub></sub>(E)) ≡ σ<sub>F<sub>1</sub></sub><sub>F<sub>2</sub></sub>(E)
此变换规则对于优化的意义在于:选择条件可以合并,使得一次选择运算就可检查全部条件,而不必多次过滤元组,所以可以把同层的合取条件收集在一起,统一进行判断。
1. **选择和投影的交换律**
设E为关系代数表达式,F为选择条件,A<sub>i</sub>(i=1,2,3,…,n)是属性名。选择条件F只涉及属性A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>。则有:
​ σ<sub>F</sub>(∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub> (E)) ≡∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub><sub>F</sub>(E))
此变换规则对于优化的意义在于:先投影后选择可以改为先选择后投影,这对于以行为单位来存储关系的主流数据库而言,很有优化意义。按照这种存储方式,系统总是先获取元组,然后才能解析得到其中的列。
设E为关系代数表达式,F为选择条件,Ai(i=1,2,3…,n)是属性名,选择条件F中有不属于A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>的属性B<sub>1</sub>,B<sub>2</sub>,…,B<sub>n</sub>。则有:
​ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub><sub>F</sub>(E)) ≡ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub><sub>F</sub>(∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub>,B<sub>1</sub>,B<sub>2</sub>,…,B<sub>m</sub>(E)))
此变换规则对于优化的意义在于:先选择后投影可以改为先做带有选择条件中的列的投影,然后选择,最后再完成最外层的投影。这样内层的选择和投影可以同时进行,不会增加过多的计算开销,但能减小中间结果集的规模。
1. **选择与笛卡尔积的交换律**
设E<sub>1</sub>、E<sub>2</sub>为关系代数表达式,F为选择条件,F中涉及的属性都是E<sub>1</sub>中的属性,则有:
​ σ<sub>F</sub>(E<sub>1</sub>×E<sub>2</sub>) ≡ <sub>σF</sub>(E<sub>1</sub>)×E<sub>2</sub>
如果F=F<sub>1</sub>∧F<sub>2</sub>,且F<sub>1</sub>只涉及E<sub>1</sub>中的属性,F<sub>2</sub>只涉及E<sub>2</sub>中的属性,则有:
​ σ<sub>F</sub>(E<sub>1</sub>×E<sub>2</sub>) ≡ σ<sub>F<sub>1</sub></sub>(E<sub>1</sub>)×σ<sub>F<sub>2</sub></sub>(E<sub>2</sub>)
此变换规则对于优化的意义在于:条件下推到相关的关系上,先做选择后做笛卡尔积运算,这样可以减小中间结果的大小。
1. **选择与并的分配律**
如果E<sub>1</sub>和E<sub>2</sub>有相同的属性名,且E= E<sub>1</sub>∪E<sub>2</sub>,则有:
​ σ<sub>F</sub>(E<sub>1</sub>∪E<sub>2</sub>) ≡ σ<sub>F</sub>(E<sub>1</sub>) ∪σ<sub>F</sub> (E<sub>2</sub>)
此变换规则对于优化的意义在于:条件下推到相关的关系上,先选择后做并运算,可以减小每个关系输出结果的大小。
1. **选择与差的分配律**
如果E<sub></sub>和E<sub>2</sub>有相同的属性名,则:
​ σ<sub>F</sub>(E<sub>1</sub>-E<sub>2</sub>) ≡ σ<sub>F</sub>(E<sub>1</sub>)-σ<sub>F</sub>(E<sub>2</sub>)
此变换规则对于优化的意义在于:条件下推到相关的关系上,先选择后做差运算,可以减小每个关系输出结果的大小。
1. **投影与笛卡尔积的交换律**
<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub>是E<sub>1</sub>的属性,<sub>B<sub>1</sub>,B<sub>2</sub>,…,B<sub>m</sub></sub>是E<sub>2</sub>的属性,则有:
​ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub>,<sub>B<sub>1</sub>,B<sub>2</sub>,…,B<sub>m</sub></sub>(E<sub>1</sub>×E<sub>2</sub>) ≡ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub>(E<sub>1</sub>)×∏<sub>B<sub>1</sub>,B<sub>2</sub>,…,B<sub>m</sub></sub>(E<sub>2</sub>)
此变换规则对于优化的意义在于:先投影后做笛卡尔积,可减少做笛卡尔积前每个元组的长度,使得计算后得到的新元组的长度也变短。
1. **投影与并的交换律**
如果E<sub>1</sub>和E<sub>2</sub>有相同的属性名,则有:
​ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub> (E<sub>1</sub>∪E<sub>2</sub>) ≡ ∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub> (E<sub>1</sub>)∪∏<sub>A<sub>1</sub></sub>,<sub>A<sub>2</sub></sub><sub>,…,A<sub>n</sub></sub> (E<sub>2</sub>)
此变换规则对于优化的意义在于:先投影后做并运算,可减少做并运算前每个元组的长度。
#### 5.2.1.2 针对不同运算符的优化规则
针对不同运算符的优化规则如表5-1~5-3所示。
<center>表5-1 运算符主导的优化</center>
<table>
<tr>
<th width="10%">运算符</th>
<th width="15%">子类型</th>
<th width="35%">根据特点可得到的优化规则</th>
<th width="50%">可优化的原因</th>
</tr >
<tr >
<td rowspan="7">选择</td>
<td>对同一个表的同样选择条件,作一次即可。</td>
<td>单行文本输入框</td>
<td>幂等性:多次应用同一个选择有同样效果;
交换性:应用选择的次序在最终结果中没有影响
选择可有效减少在它的操作数中的元组数的运算(元组个数减少)。</td>
</tr>
<tr>
<td rowspan="2">分解有复杂条件的选择</td>
<td>合取,合并多个选择为更少的需要求值的选择,多个等式则可以合并①。</td>
<td>合取的选择等价于针对这些单独条件的一系列选择。</td>
</tr>
<tr>
<td>析取,分解它们使得其成员选择可以被移动或单独优化②。</td>
<td>析取的选择等价于选择的并集。</td>
</tr>
<tr>
<td rowspan="2">选择和笛卡尔积</td>
<td>尽可能先做选择。</td>
<td>运算关系分别有N和M行,先做积运算将包含N×M行。先做选择运算减少N和M,则可避免不满足条件的元组参与积运算,节约时间同时减少结果集的大小。</td>
</tr>
<tr><td>尽可能下推选择。</td>
<td>如果积运算后面没有跟随选择运算,可以尝试使用其它规则从表达式树更高层下推选择。</td>
</tr>
<tr>
<td>选择和集合运算</td>
<td>选择下推到的集合运算中,如表5-2中的3种情况。</td>
<td>选择在差集、交集和并集算子上满足分配律。</td>
</tr>
<tr>
<td>选择和投影</td>
<td>在投影之前进行选择。</td>
<td>如果选择条件中引用的列是投影中的列的子集,则选择与投影满足交换性。</td>
</tr>
<tr>
<td rowspan="2">投影</td>
<td>基本投影性质</td>
<td>尽可能先做投影</td>
<td>投影是幂等的;投影可以减少元组大小。</td>
</tr>
<tr>
<td >投影和集合运算</td>
<td>投影下推到集合的运算中,如表5-3中的情况。</td>
<td>投影在差集、交集和并集算子上满足分配律。</td>
</tr>
</table>
1. 如WHERE A.a=B.b AND B.b=C.c可以合并为={A.a,B.b,C.c}而不是两个等式={A.a,B.b}和={B.b,C.c}。
1. 如WHERE A.a=3 OR A.b\&gt;8,如果A.a、A.b列上分别有索引,也许SELECT \* FROM A WHERE A.a=3 UNION SELECT \* FROM A WHERE A.b>8可以分别利用各自的索引提高查询效率。
表5-2 选择下推到集合的运算
<table>
<tr>
<th width="25%" rowspan="2">初始式</th>
<th width="75%" colspan="3"><center>优化后的等价表达式</center></th>
</tr >
<tr>
<th width="25%">等价表达式一</th>
<th width="25%">等价表达式二</th>
<th width="25%">等价表达式三</th>
</tr>
<tr >
<td>σ<sub>A</sub>(R-S)</td>
<td>σ<sub>A</sub>(R)-σ<sub>A</sub>(S)</td>
<td>σ<sub>A</sub>(R)-S</td>
<td></td>
</tr>
<tr >
<td>σ<sub>A</sub>(R∪S)</td>
<td>σ<sub>A</sub>(R)∪σ<sub>A</sub>(S)</td>
<td></td>
<td></td>
</tr>
<tr >
<td>σ<sub>A</sub>(R∩S)</td>
<td>σ<sub>A</sub>(R)∩σ<sub>A</sub> (S)</td>
<td>σ<sub>A</sub>(R)∩S</td>
<td>R∩σ<sub>A</sub>(S)</td>
</tr>
</table>
表5-3 投影下推到集合的运算
| **初始式** | **优化后的等价表达式** |
| --- | --- |
| ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(R-S) | ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(R)- ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(S) |
| ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(R∪S) | ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(R) ∪∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(S) |
| ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(R∩S) | ∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(R) ∩∏A<sub>1</sub>,A<sub>2</sub>,…,A<sub>n</sub>(S) |
#### 5.2.1.3 查询树启发式规则
包括:
1. 选择运算应尽可能先做。
2. 把投影运算和选择运算同时进行。如有若干投影和选择运算,并且它们都对同一个关系操作,则可以在扫描次关系的同时完成所有这些运算以避免重复扫描关系。
3. 把投影同其前或后的双目运算结合起来,没有必要为了去掉某些字段而扫描一遍关系。
4. 把某些选择同在它前面要执行的笛卡尔积结合起来称为一个连接运算。连接(特别是等值连接)运算比笛卡尔积性能高很多。
5. 找出公共子表达式,将其计算结果缓存起来,避免重复计算。
### 5.2.2 语法级查询优化
语法级优化要解决的主要问题是找出SQL语句的等价变换形式,使得SQL执行更高效,包括:
- 子句局部优化。如等价谓词重写、where和having条件简化等。
- 关联优化。如子查询优化、连接消除、视图重写等。
- 形式变化优化。如嵌套连接消除等。
以下介绍几种常见的优化方法。
#### 5.2.2.1 子查询优化
早期的查询优化器对子查询都采用嵌套执行的方式,即对父查询中的每一行都执行一次子查询,这样效率很低,因此对其进行优化很有必要。例如,将子查询转为连接操作之后,有如下好处:
- 子查询不用多次执行;
- 优化器可以根据统计信息来选择不同的连接方法和不同的连接顺序;
- 子查询中的连接条件、过滤条件分别变成了父查询的连接条件和过滤条件,优化器可以对这些条件进行下推,以提高执行效率。
1. **常见子查询优化技术**
**(1)** **子查询合并**
在语义等价条件下,多个子查询可以合并成一个子查询,这样多次表扫描,多次连接减少为单次表扫描和单次连接。例如:
```sql
SELECT *
FROM t1
WHERE a1<10 AND (
EXISTS (SELECT a2 FROM t2 WHERE t2.a2<5 AND t2.b2=1) OR
EXISTS (SELECT a2 FROM t2 WHERE t2.a2<5 AND t2.b2=2)
);
```
可优化为:
```sql
SELECT *
FROM t1
WHERE a1<10 AND (
EXISTS (SELECT a2 FROM t2 WHERE t2.a2<5 AND (t2.b2=1 OR t2.b2=2)
);
```
此例中,两个EXISTS子查询合并为一个子查询,查询条件也进行了合并。
**(2)** **子查询展开**
子查询展开又称子查询反嵌套,子查询上拉。实质是把某些子查询重写为等价的多表连接操作。带来好处是,有关的访问路径、连接方法和连接顺序可能被有效使用,使得查询语句的层次尽可能地减少。常见的IN / ANY / SOME / ALL / EXISTS依据情况转为半连接(SEMI JOIN)。例如:
```sql
SELECT *
FROM t1, (SELECT * FROM t2 WHERE t2.a2>10) v_t2
WHERE t1.a1<10 AND v_t2.a2<20;
```
可优化为:
```sql
SELECT *
FROM t1, t2
WHERE t1.a1<10 AND t2.a2<20 AND t2.a2>10;
```
此例中,原本的子查询变为了t1、t2表的连接操作,相当于把t2表从子查询中上拉了一层。
子查询展开是一种最常用的子查询优化技术,如果子查询是只包含选择、投影、连接操作的简单语句,没有聚集函数或者group子句,则可以上拉,前提是上拉后的结果不能带来多余元组,需遵循以下规则:
- 如果上层查询结果没有重复(select包含主键),则可以展开子查询,并且展开后的查询的select子句前应加上distinct标志;
- 如果上层查询的select语句中有distinct标志,则可以直接子查询展开;
- 如果内层查询结果没有重复元组,则可以展开。
子查询展开的具体步骤如下:
1. 将子查询和上层查询的from子句连接为同一个from子句,并且修改相应的运行参数;
2. 将子查询的谓词符号进行相应修改(如IN修改为=ANY);
3. 将子查询的where条件作为一个整体与上层查询的where条件进行合并,并用and连接,从而保证新生成的谓词与原谓词的语义相同,成为一个整体。
**(3)** **聚集子查询消除**
这种方法将聚集子查询的计算上推,使得子查询只需计算一次,并与父查询的部分或全表做左外连接。例如:
```sql
SELECT *
FROM t1
WHERE t1.a1 > (SELECT avg(t2.a2) FROM t2);
```
可优化为:
```sql
SELECT t1.*
FROM t1, (SELECT avg(t2.a2) FROM t2) as tm(avg_a2) )
WHERE t1.a1 ? tm.avg_a2;
```
**(4)** **其他**
此外还有利用窗口函数消除子查询、子查询推进等技术,本文不再细述。
1. **针对不同类型子查询的优化方法**
**(1) IN类型子查询**
IN类型有3种格式:
格式一:
```sql
outer_expr [not] in (select inner_expr from ... where subquery_where)
```
格式二:
```sql
outer_expr = any (select inner_expr from ... where subquery_where)
```
格式三:
```sql
(oe_1, ..., oe_N) [not] in (select ie_1, ..., ie_N from ... where subquery_where)
```
对于in类型子查询的优化,如表5-4所示。
<center>表5-4 IN类型子查询优化的几种情况</center>
![5.2.2.1-1](images/5.2.2.1-1.png)
情况一:outer\_expr和inner\_expr均为非NULL值。
优化后的表达式为:
```sql
exists (select 1 from ... where subquery_where and outer_expr=inner_expr)
```
子查询优化需要满足2个条件:
- outer\_expr和inner\_expr不能为NULL;
- 不需要从结果为FALSE的子查询中区分NULL。
情况二:outer\_expr是非空值。
优化后的表达式为:
```sql
exists (select 1 from ... where subquery_where and
(outer_expr=inner_expr or inner_expr IS NULL);
```
情况三:outer\_expr为空值。
则原表达式等价为:
```sql
NULL in (select inner_expr FROM ... where subquery_where)
```
当outer\_expr为空时,如果子查询结果为:
- NULL,select语句产生任意行数据;
- FALSE,select语句不产生数据。
对上面的等价形式,还有2点需说明:
- 谓词IN等价于=ANY。如:以下2条SQL语句是等价的。
```sql
select col1 from t1 where col1 =ANY (select col1 from t2);
select col1 from t1 where col1 IN (select col1 from t2);
```
- 带有IN谓词的子查询,如果满足上述3种情况,可做等价变换,把外层条件下推到子查询中,变形为EXISTS类型的逻辑表达式判断。而EXISTS子查询可以被半连接算法实现优化。
**(2) ALL/ANY/SOME类型子查询**
ALL/ANY/SOME子查询格式如下:
```sql
outer_expr operator ALL (subquery)
outer_expr operator ANY (subquery)
outer_expr operator SOME (subquery)
```
其中,operator是操作符,可以是>、>=、=、<、<=中任何一个。其中,
- =ANY与IN含义相同,可采用IN子查询优化方法;
- SOME与ANY含义相同;
- NOT IN 与 <>ALL含义相同;
如果子查询中没有group by子句,也没有聚集函数,则以下表达式可以使用聚集函数MAX/MIN做等价转换:
- `val>=ALL (select ...)` 等价变换为:`val>= (select MAX...)`
- `val<=ALL (select ...)` 等价变换为:`val<= (select MAX...)`
- `val>=ANY (select ...)` 等价变换为:`val>= (select MIN...)`
- `val>=ANY (select ...)` 等价变换为:`val>= (select MAX...)`
**(3) EXISTS类型子查询**
存在谓词子查询格式为:[NOT] EXISTS (subquery)
需要注意几点:
- EXISTS(subquery)值为TRUE/FALSE,不关心subquery返回的内容。
- EXISTS(subquery)自身有&quot;半连接&quot;的语义,部分DBMS用半连接来实现它;NOT EXISTS通常会被标识为&quot;反半连接&quot;处理。
- IN(subquery)等子查询可以被转换为EXISTS(subquery)格式。
所谓半连接(Semi Join),是一种特殊的连接类型。如果用&quot;t1.x semi= t2.y&quot;来表示表T1和表T2做半连接,则其含义是:只要在表T2中找到一条记录满足t1.x=t2.y,则马上停止搜索表T2,并直接返回表T1中满足条件t1.x=t2.y的记录,因此半连接的执行效率高于普通的内连接。
#### 5.2.2.2 等价谓词重写
等价谓词重写包括:LIKE规则、BETWEEN-AND规则、IN转换OR规则、IN转换ANY规则、OR转换ANY规则、ALL/ANY转换集函数规则、NOT规则等,相关原理比较简单,有兴趣的同学可以自行查找相关查询重写规则。
#### 5.2.2.3 条件化简
WHERE、HAVING和ON条件由许多表达式组成,而这些表达式在某些时候彼此间存在一定的联系。利用等式和不等式性质,可将WHERE、HAVING和ON条件简化,但不同数据库的实现可能不完全相同。
将WHERE、HAVING和ON条件简化的方式通常包括如下几个:
1. 去除表达式中冗余的括号:以减少语法分析时产生的AND和OR树的层次;
2. 常量传递:对不同关系可使用条件分离后有效实施&quot;选择下推&quot;,从而减小中间关系的规模。如:
`col1=col2 AND col2=3` 可化简为:`col1=3 AND col2=3`
操作符=、<><=、>=、<>、LIKE中的任何一个,在`col1<操作符>col2`条件中都会发生常量传递
3. 消除死码。化简条件,将不必要的条件去除。如:
`WHERE (0>1 AND s1=5)`, `0>1`使得`AND`为恒假,去除即可。
4. 表达式变换。化简条件(如反转关系操作符的操作数顺序),从而改变某些表的访问路径。如:-a=3可化简为a=-3,若a上有索引,则可利用。
5. 不等式变换。化简条件,将不必要的重复条件去除。如:
`a>10 AND b=6 AND a>2` 可化简为:`a>10 AND b=6`
6. 布尔表达式变换。包括:
- 谓词传递闭包。如:`a>b AND b>2`可推导出`a>2`,减少a、b比较元组数。
- 任何一个布尔表达式都能被转换为一个等价的合取范式。一个合取项为假,则整个表达式为假。
## 5.3 物理优化
代数优化改变查询语句中操作的次序和组合,但不涉及底层的存取路径。物理优化就是要选择高效合理的操作算法或存取路径,求得优化的查询计划,达到查询优化的目标。
查询优化器在物理优化阶段,主要解决的问题是:
- 从可选的单表扫描方式中,挑选什么样的单表扫描方式最优?
- 对于两表连接,如何连接最优?
- 对于多表连接,哪种连接顺序最优?
- 对于多表连接,是否需要对每种连接顺序都探索?如果不全部探索,如何找到一种最优组合?
选择的方法可以是:
1. 基于规则的启发式优化。
2. 基于代价估算的优化。
3. 两者结合的优化方法。常常先使用启发式规则选取若干个较优的候选方案,减少代价估算的工作量,然后分别计算这些候选方案的执行代价,较快地选出最终的优化方法。
启发式规则优化是定性的选择,比较粗糙,但是实现简单而且优化本身的代价较小,适合解释执行的系统。因为解释执行的系统,其优开销包含在查询总开销之中,在编译执行的系统中,一次编译优化,多次执行,查询优化和查询执行是分开的,因此,可以用精细复杂一些的基于代价的优化方法。
### 5.3.1 基于代价的优化
#### 5.3.1.1 查询代价估算
查询代价估算基于CPU代价和I/O代价,计算公式如下:
```
总代价 = I/O代价 + CPU代价
COST = P * a_page_cpu_time + W * T
```
其中:
P是计划运行时访问的页面数,a\_page\_cpu\_time是每个页面读取的时间开销,其乘积反映了I/O开销。
T为访问的元组数,如果是索引扫描,还要考虑索引读取的开销,反映了数据读取到内存的CPU开销。
W为权重因子,表明I/O到CPU的相关性,又称选择率(selectivity),用于表示在关系R中,满足条件“A <op> a”的元组数与R的所有元组数N的比值。
选择率在代价估算模型中占有重要地位,其精确程度直接影响最优计划的选取。选择率计算常用方法如下:
1. 无参数方法:使用ad hoc(点对点)数据结构或直方图维护属性值的分布,直方图最常用;
2. 参数法:使用具有一些自由统计参数(参数是预先估计出来的)的数学分布函数逼近真实分布;
3. 曲线拟合法:为克服参数法的不灵活性,用一般多项式来标准最小方差来逼近属性值的分布;
4. 抽样法:从数据库中抽取部分样本元组,针对这些样本进行查询,然后收集统计数据;
5. 综合法:将以上几种方法结合起来,如抽样法和直方图法结合。
由于其中I/O代价占比最大,通常以I/O代价为主来进行代价估算。
1. 全表扫描算法的代价估算公式
- 如果基本表大小为 B 块,全表扫描算法的代价 cost = B;
- 如果选择条件是&quot;码=值&quot;,则平均搜索代价 cost = B/2。
​ 2. 索引扫描算法的代价估算公式
- 如果选择条件为&quot;码=值&quot;,则采用该表的主索引,若为B+树,设索引层数为L,需要存取B+树中从根节点到叶节点L块,再加上基本表中该元组所在的那一块,cost=L+1。
- 如果选择条件涉及非码属性,若为B+树索引,选择条件是相等比较,S为索引选择基数(有S个元组满足条件),假设满足条件的元组保存在不同块上,则最坏情况下cost=L+S。
- l 若比较条件为>,>=,<,<=,假设有一半元组满足条件,则需要存取一半的叶节点,并通过索引访问一半的表存储块,cost=L+Y/2+B/2。若可以获得更准确的选择基数,可进一步修正Y/2与B/2。
​ 3.嵌套循环连接算法的代价估算公式
- 嵌套循环连接算法的代价为:cost=B<sub>r</sub>+B<sub>r</sub>B<sub>s</sub>/(K-1), 且K<B(R)<B(S),其中K表示缓冲区大小为K块;
- 若需要把中间结果写回磁盘,则代价为:cost=B<sub>r</sub>+B<sub>r</sub>B<sub>s</sub>/(K-1) + (F<sub>rs</sub>\*N<sub>r</sub>\*N<sub>s</sub>)/M<sub>rs</sub>。F<sub>rs</sub>为连接选择率,表示连接结果数的比例,Mrs为块因子,表示每块中可以存放的结果元组数目。
​ 4.排序合并连接算法的代价估算公式
- 如 果 连 接 表 已 经 按 照 连 接 属 性 排 好 序 , 则 cost =B<sub>r</sub>+B<sub>s</sub>+(F<sub>rs</sub>\*N<sub>r</sub>\*N<sub>s</sub>)/M<sub>rs</sub>
- 如果必须对文件排序,需要在代价函数中加上排序的代价对 于 包 含 B 个 块 的 文 件 排 序 的 代 价 大 约 是:cost =(2\*B)+(2\*B\*log2B)。
#### 5.3.1.2 基于代价的连接顺序选择
多表连接算法实现的是在查询路径生成的过程中,根据代价估算,从各种可能的候选路径中找出最优的路径。它需要解决两个问题:
- 多表连接的顺序
- 多表连接的搜索空间:N个表的连接可能有N!种连接组合,这可能构成一个巨大的搜索空间。如何将搜索空间限制在一个可接受的范围内,并高效生成查询执行计划将成为一个难点。
多表间的连接顺序表示了查询计划树的基本形态。在1990年,Schneder等人在研究查询树模型时提出了左深树,右深树和紧密树3种形态,如图5-1所示。
<img src="images/5-1.png" alt="图5-1 三种树的形态" style="zoom: 100%;" />
<center>图5-1 三种树的形态</center>
即使是同一种树的生成方式,也有细节需要考虑。如图5-1-a中{A,B}和{B,A}两种连接方式开销可能不同。比如最终连接结果{A,B,C}则需要验证比较6种连接方式,找出最优的一种作为下次和其他表连接的依据。
多表连接搜索最优查询树,有很多算法,如启发式、分枝界定计划枚举、贪心、动态规划、爬山法、System R优化方法等。其中,常用算法如下。
1. **动态规划**
在数据库领域,动态规划算法主要解决多表连接的问题。它是自底向上进行的,即从叶子开始做第一层,然后开始对每层的关系做两两连接(如果满足内连接进行两两连接,不满足则不可对全部表进行两两连接),构造出上层,逐次递推到树根。以下介绍具体步骤:
初始状态:构造第一层关系,即叶子结点,每个叶子对应一个单表,为每一个待连接的关系计算最优路径(单表的最优路径就是单表的最佳访问方式,通过评估不同的单表的数据扫描方式代价,找出代价最小的作为每个单表的局部最优路径)
归纳:当第1层到第n-1层的关系已经生成,那么求解第n层的关系方法为:将第n-1层的关系与第一层中的每个关系连接,生成新的关系(对新关系的大小进行估算),放于第n层,且每一个新关系,均求解最优路径。每层路径的生成都是基于下层生成的最优路径,这满足最优化原理的要求。
还有的改进算法,在生成第n层的时候,除了通过第n-1层和第一层连接外,还可以通过第n-2层和第二层连接...。
PostgreSQL查询优化器求解多表连接时,采用了这种算法。
2. 启发式算法
启发式算法是相对最优化算法提出的,是一个基于直观或者经验构造的算法,不能保证找到最好的查询计划。在数据库的查询优化器中,启发式一直贯穿于整个查询优化阶段,在逻辑查询优化阶段和物理查询优化阶段,都有一些启发式规则可用。PostgreSQL,MySQL,Oracle等数据库在实现查询优化器时,采用了启发式和其他方式相结合的方式。
物理查询优化阶段常用启发式规则如下:
- 关系R在列X上建立索引,且对R的选择操作发生在列X上,则采用索引扫描方式;
- R连接S,其中一个关系上的连接列存在索引,则采用索引连接且此关系作为内表;
- R连接S,其中一个关系上的连接列是排序的,则采用排序连接比hash连接好。
3. 贪心算法
贪心算法最后得到的是局部最优解,不一定全局最优,其实现步骤如下:
(1) 初始,算法选出的候选对象集合为空;
(2) 根据选择函数,从剩余候选对象中选出最有可能构成解的对象;
(3) 如果集合中加上该对象后不可行,那么该对象就被丢弃并不再考虑;
(4) 如果集合中加上该对象后可行,就加到集合里;
(5) 扩充集合,检查该集合是否构成解;
(6) 如果贪心算法正确工作,那么找到的第一个解通常都是最优的,可以终止算法;
(7) 继续执行第二步。
MySQL查询优化器求解多表连接时采用了这种算法。
4. **System-R算法**
对自底向上的动态规划算法进行了改进,主要思想是把子树的查询计划的最优查询计划和次优查询计划保留,用于上层的查询计划生成,以便使得查询计划总体上最优。
<center>表5-5 多表连接常用算法比较</center>
| **算法名称** | **特点与适用范围** | **缺点** |
| ------------ | ------------------------------------------------------------ | ---------------------------------- |
| 启发式算法 | 适用于任何范围,与其它算法结合,能有效提高整体效率 | 不知道得到的解是否最优 |
| 贪婪算法 | 非穷举类型的算法。适合解决较多关系的搜索 | 得到局部最优解 |
| 爬山法 | 适合查询中包含较多关系的搜索,基于贪婪算法 | 随机性强,得到局部最优解 |
| 遗传算法 | 非穷举类型的算法。适合解决较多关系的搜索 | 得到局部最优解 |
| 动态规划算法 | 穷举类型的算法。适合查询中包含较少关系的搜索,可得到全局最优解 | 搜索空间随关系个数增长呈指数增长 |
| System R优化 | 基于自底向上的动态规划算法,为上层提供更多可能的备选路径,可得到全局最优解 | 搜索空间可能比动态规划算法更大一些 |
### 5.3.2 基于规则的优化
基于代价优化的一个缺点是优化本身的代价。因此,查询优化器使用启发式方法来减少优化代价。
- 选择操作的启发式规则:
1) 对于小关系,全表扫描;
2) 对于大关系:
(1) 若选择条件是主码,则可以选择主码索引,因为主码索引一般是被自动建立的;
(2) 若选择条件是非主属性的等职查询,并且选择列上有索引,如果选择比例较小(10%)可以使用索引扫描,否则全表扫描;
(3) 若选择条件是属性上的非等值查询或者范围查询,同上;
(4) 对于用and连接的合取选择条件,若有组合索引,优先用组合索引方法;如果某些属性上有一般索引,则用索引扫描,否则全表扫描;
(5) 对于用OR连接的析取选择条件,全表扫描。
- 连接操作的启发式规则
1) 若两个表都已经按连接属性排序,则选用排序-合并算法;
2) 若一个表在连接属性上有索引,则使用索引连接方法;
3) 若其中一个表较小,则选用hash join;
4) 最后可以使用嵌套循环,小表作为外表。
还有嵌套子查询优化、物化视图等多种优化手段,这里不再展开。
# 第6章 事务处理
## 6.1 事务概念
在数据库系统中,事务是指由一系列数据库操作组成的一个完整的逻辑过程。数据库提供了增、删、改、查等几种基础操作,用户可以灵活地组合这几种操作来实现复杂的语义。在很多场景下,用户希望一组操作可以做为一个整体一起生效,这就是事务的产生背景。
例如,一个银行转帐业务,在数据库中需要通过两个修改操作来实现:1. 从账户A扣除指定金额;2. 向账户B添加指定金额。这两个操作构成了一个完整的逻辑过程,不可拆分。如果第一个操作成功而第二个操作失败,说明转账没有成功。在这种情况下,对于银行来说,数据库中的账户数据是处于一种不正确的状态的,必须撤销掉第一个操作对数据库的修改,让账户数据恢复到转账前的状态。由此例可见,事务是数据库状态变更的基本单元,在事务将数据库从一个正确状态变更到另一个正确状态的过程中,数据库的那些中间状态,既不应该被其他事务看到或干扰,也不应该在事务结束后依然保留。
根据以上描述的事务概念,事务应具有四个特性,称为事务的ACID特性。它们分别是:
- **原子性** (Atomicity):一个事务中的所有操作,要么全做,要么全不做。事务如果在执行过程中发生错误,该事务修改过的数据应该被恢复到事务开始前的状态,就像这个事务从来没有执行过一样。
- **一致性** (Consistency):当数据库只包含成功事务提交的结果时,称数据库处于一致性状态。事务执行的结果必须使数据库从一个一致性状态变到另一个一致性状态。由此可见,一致性与原子性是密切相关的。
- **隔离性** (Isolation):一个事务的执行不能被其他事务干扰。DBMS允许多个并发事务同时执行,隔离性可以防止多个事务并发执行时由于相互干扰而导致数据的不一致。
- **持久性** (Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
在SQL中,开始和结束事务的语句如下:
- BEGIN TRANSACTION:开始一个事务。除了用该语句显式地开始一个事务,DBMS也允许隐式的开始一个事务。隐式开始事务时无需执行任何语句,每当用户连接成功,即开始一个事务,前一个事务结束时,即自动开始下一个事务。
- COMMIT:提交一个事务。此语句表示事务正常结束,DBMS应永久保存该事务对数据库的修改。
- ROLLBACK:回滚一个事务。此语句表示事务异常结束,DBMS应撤销该事务对数据库的所有修改。需要注意的是,当事务发生故障时,即使用户没有显式执行ROLLBACK语句,DBMS也应自动回滚事务。
一个支持事务的DBMS必须能保证事务的ACID特性,这部分工作是由事务处理机制来负责的。事务处理机制又分为并发控制机制和故障恢复机制两部分,以下分别介绍。
## 6.2 并发控制
所谓并发操作,是指在多用户共享的数据库中,多个事务可能同时对同一数据进行操作。如果对这些操作不加控制,则可能导致数据的不一致问题。因此,为了保证事务的一致性和隔离性,DBMS需要对并发操作进行正确调度。这就是并发控制机制的任务。
### 6.2.1 并发错误
并发操作带来的数据不一致性包括丢失修改、读脏和不可重复读。
1. 丢失修改
两个以上事务从数据库中读入同一数据并修改,其中一个事务(后提交的事务)的提交结果破坏了另一事务(先提交的事务)的提交结果,导致先提交的事务对数据库的修改被丢失。
2. 读脏
事务读取了被其他事务修改且未提交的数据,即从数据库中读到了临时性数据。
3. 不可重复读
一个事务读取数据后,该数据又被另一事务修改,导致前一事务无法再现前一次的读取结果。
不可重复读又可分为两种情况:一种情况是第一次读到的数据的值在第二次读取时发生了变化;还有一种情况是事务第二次按相同条件读取数据时,返回结果中多了或者少了一些记录。后者又被称为幻读。
### 6.2.2 并发控制的正确性标准
并发控制机制的任务就是对并发事务进行正确的调度,但是什么样的调度才是正确的呢?我们需要一个正确性的判断标准。
#### 6.2.2.1 可串行化
串行调度是指多个事务依序串行执行,仅当一个事务的所有操作执行完后才执行另一个事务。这种调度方式下,不可能出现多个事务同时访问同一数据的问题,自然也就不可能出现并发错误。串行调度显然是正确的,但是串行调度无法充分利用系统资源,因此其效率显然也是用户难以接受的。
并发调度是指在数据库系统中同时执行多个事务。DBMS对多个并发事务进行调度时,可能产生多个不同的调度序列,从而得到不同的执行结果。如何判断某个调度是不是正确呢?如果这些并发事务的执行结果与它们按某一次序串行执行的结果相同,则认为该并发调度是正确的,我们称之为可串行化调度。
#### 6.2.2.2 冲突可串行化
可串行化是并发控制的正确性准则。但是按照可串行化的定义,如果想要判断一个并发调度是不是可串行化调度,需要知道这批事务所有可能的串行调度的结果,然后将该并发调度的结果与这些结果进行比较,这显然是难以实施的。因此,我们需要一种可操作的判断标准,即冲突可串行化。
冲突可串行化是可串行化的充分条件。如果一个并发调度是冲突可串行化的,那么它一定是可串行化的。在定义冲突可串行化之前,需要先了解什么是冲突操作。
冲突操作是指不同的事务对同一个数据的读写操作或写写操作。例如,事务1对数据A的读操作&quot;r<sub>1</sub>(A)&quot;与事务2对数据A的写操作&quot;w<sub>2</sub>(A)&quot;就是一对冲突操作。
我们规定,不同事务的冲突操作和同一事务的两个操作是不能交换的。因为如果改变冲突操作的次序,则最后的数据库状态会发生变化。按照这个规定,在保证一个并发调度中的冲突操作次序不变的情况下,如果通过交换两个事务的非冲突操作,能够得到一个串行调度,则称该并发调度是冲突可串行化的。
例如,对于以下两个并发调度序列:
SC1:r<sub>1</sub>(A) w<sub>1</sub>(B) r<sub>2</sub>(B) w<sub>1</sub>(C) w<sub>2</sub>(B)
SC2:r<sub>1</sub>(B) r<sub>2</sub>(A) w<sub>1</sub>(A) w<sub>2</sub>(B)
SC1就是冲突可串行化的,因为可以通过交换非冲突操作3和4得到一个串行调度序列。而SC2则是非冲突可串行化的,因为操作2和3是冲突操作,无法交换。
### 6.2.3 事务隔离级别
可串行化是一个很严格的正确性标准。在实际应用中,有时候可能会希望降低这个标准,通过牺牲一定的正确性,达到提高并发度的目的。为此,SQL标准将事务的隔离程度划分为四个等级,允许用户根据需要自己指定事务的隔离级。这四种隔离级包括读未提交(Read Uncommitted)、读提交(Read Committed)、可重复读(Repeatable Read)和可串行化(Serializable)。
1. 读未提交:在该隔离级别,事务可以看到其他未提交事务的执行结果,即允许读脏数据。
2. 读提交:这是大多数DBMS的默认隔离级别,它要求事务只能看见已提交事务所做的修改,因此可以避免读脏数据。但是由于在某个事务的执行期间,同一个数据可能被另一个事务修改并提交,所以该事务对该数据的两次读取可能会返回不同的值,即出现不可重复读错误。
3. 可重复读:在该隔离级别,同一事务多次读取同一数据时,总是会读到同样的值。不过理论上,该隔离级不能避免幻读,即使用相同条件多次读取时,满足读取条件的数据的数量可能有变化,比如多出一些满足条件的数据。
4. 可串行化:这是最高的隔离级别,能够避免所有并发错误。可串行化的概念前面已经介绍过,此处不再赘述。
## 6.3 封锁机制
### 6.3.1什么是封锁
封锁机制是一种常用的并发控制手段,它包括三个环节:第一个环节是申请加锁,即事务在操作前对它要使用的数据提出加锁请求;第二个环节是获得锁,即当条件满足时,系统允许事务对数据加锁,使事务获得数据的控制权;第三个环节是释放锁,即完成操作后事务放弃数据的控制权。为了达到并发控制的目的,在使用时事务应选择合适的锁,并遵从一定的封锁协议。
基本的封锁类型有两种:排它锁(Exclusive Locks,简称X锁)和共享锁(Share Locks,简称S锁)。
1. 排它锁
排它锁也称为独占锁或写锁。一旦事务T对数据对象A加上了排它锁(X锁),则其他任何事务不能再对A加任何类型的锁,直到T释放A上的锁为止。
2. 共享锁
共享锁又称读锁。如果事务T对数据对象A加上了共享锁(S锁),其他事务对A就只能加S锁而不能加X锁,直到事务T释放A上的S锁为止。
### 6.3.2 封锁协议
简单地对数据加X锁和S锁并不能保证数据库的一致性。在对数据对象加锁时,还需要约定一些规则,包括何时申请锁、申请什么类型的锁、何时释放锁等,这些规则称为封锁协议。不同的规则形成了各种不同的封锁协议。封锁协议分三级,它们对并发操作带来的丢失修改、读脏和不可重复读等并发错误,可以在不同程度上予以解决。
1. 一级封锁协议
一级封锁协议是指事务T在修改数据之前必须先对其加X锁,直到事务结束才释放。
一级封锁协议可有效地防止丢失修改,并能够保证事务T的可恢复性。但是,由于一级封锁没有要求对读数据进行加锁,所以不能防止读脏和不可重复读。遵循一级封锁协议的事务可以达到读未提交的事务隔离级。
2. 二级封锁协议
二级封锁协议是指事务T在修改数据之前必须先加X锁,直到事务结束才释放X锁;在读取数据之前必须先加S锁,读完后即可释放S锁。
二级封锁协议不但能够防止丢失修改,还可进一步防止读脏。遵循二级封锁协议的事务可以达到读提交的事务隔离级。
3. 三级封锁协议
三级封锁协议是事务T在读取数据之前必须先对其加S锁,在修改数据之前必须先对其加X锁,直到事务结束后才释放所有锁。
由于三级封锁协议强调即使事务读完数据A之后也不释放S锁,从而使得别的事务无法更改数据A,所以三级封锁协议不但能够防止丢失修改和读脏,而且能够防止不可重复读。遵循三级封锁协议的事务至少可以达到可重复读的事务隔离级,至于是否能到达可串行化级别,则取决于S锁的粒度。比如,如果只对要读取的记录加锁,则无法避免幻读问题;但如果是对整个表加锁,则幻读问题可以避免,代价是并发度的下降。
### 6.3.3 封锁的实现
锁管理器可以实现为一个进程或线程,它从事务接受请求消息并反馈结果消息。对于事务的加锁请求消息,锁管理器返回授予锁消息,或者要求事务回滚的消息(发生死锁时);对于事务的解锁请求消息,只需返回一个确认消息,但可能触发锁管理器向正在等待该事务解锁的其他事务发送授予锁消息。
锁管理器使用以下数据结构:
- 为目前已加锁的每个数据对象维护一个链表,链表中的每个结点代表一个加锁请求,按请求到达的顺序排序。一个加锁请求包含的信息有:提出请求的事务ID,请求的锁的类型,以及该请求是否已被授予锁。
- 使用一个以数据对象ID为索引的散列表来查找数据对象(如果有的话),这个散列表叫做锁表。
图6-1是一个锁表的示例图,该表包含5个不同的数据对象14、17、123、144和1912的锁。锁表采用溢出链表示法,因此对于锁表的每一个表项都有一个数据对象的链表。每一个数据对象都有一个已授予锁或等待授予锁的事务请求列表,已授予锁的请求用深色阴影方块表示,等待授予锁的请求则用浅色阴影方块表示。 例如,事务T23在数据对象17和1912上已被授予锁,并且正在等待对数据对象14加锁。
![图6-1 一个锁表的示例图](images/6-1.png)
<center>图6-1 一个锁表的示例图</center>
虽然图6-1没有标示出来,但对锁表还应当维护一个基于事务标识符的索引,这样它可以快速确定一个给定事务持有的锁的集合。
锁管理器这样处理请求:
- 当一条加锁请求消息到达时,如果锁表中存在相应数据对象的链表,则在该链表末尾增加一个请求;否则,新建一个仅包含该请求的链表。对于当前没有加锁的数据对象,总是满足事务对其的第一次加锁请求,但当事务向已被加锁的数据对象申请加锁时,只有当该请求与当前持有的锁相容、并且所有之前的请求都已授予锁的条件下,锁管理器才为该请求授予锁,否则,该请求只能等待。
- 当锁管理器收到一个事务的解锁消息时,它先找到对应的数据对象链表,删除其中该事务的请求,然后检查其后的请求,如果有,则看该请求能否被满足,如果能,锁管理器授权该请求,再按相同的方式处理后续的请求。
- 如果一个事务被中止,锁管理器首先删除该事务产生的正在等待加锁的所有请求;当系统采取适当动作撤销了该事务后,该中止事务持有的所有锁也将被释放。
这个算法保证了锁请求无饿死现象,因为在先接收到的请求正在等待加锁时,后来的请求不可能获得授权。
为了避免消息传递的开销,在许多DBMS中,事务通过直接更新锁表来实现封锁,而不是向锁管理器发送请求消息。事务加锁和解锁的操作逻辑与上述锁管理器的处理方法类似,但是有两个明显的区别:
- 由于多个事务可以同时访问锁表,因此必须确保对锁表的互斥访问。
- 如果因为锁冲突而不能立刻获得锁,加锁事务需要知道自己何时可以被授予锁,解锁事务需要标记出那些可以被授予锁的事务并通知它们。这个功能可以通过操作系统的信号量机制来实现。
### 6.3.4 死锁处理
封锁机制有可能导致死锁,DBMS必须妥善地解决死锁问题,才能保障系统的正常运行。
如果事务T1和T2都需要修改数据Rl和R2,并发执行时Tl封锁了数据R1,T2封锁了数据R2;然后T1又请求封锁R2,T2又请求封锁Rl;因T2已封锁了R2,故T1等待T2释放R2上的锁。同理,因T1已封锁了R1,故T2等待T1释放R1上的锁。由于Tl和T2都没有获得全部需要的数据,所以它们不会结束,只能继续等待。这种多事务交错等待的僵持局面称为死锁。
一般来讲,死锁是不可避免的。DBMS的并发控制子系统一旦检测到系统中存在死锁,就要设法解除。通常采用的方法是选择一个处理死锁代价最小的事务,将其中止,释放此事务持有的所有的锁,使其他事务得以继续运行下去。当然,被中止的事务已经执行的所有数据修改操作都必须被撤销。
数据库中解决死锁问题主要有两类方法:一类方法是允许发生死锁,然后采用一定手段定期诊断系统中有无死锁,若有则解除之,称为死锁检测;另一类方法是采用一定措施来预防死锁的发生,称为死锁预防。
#### 6.3.4.1 死锁检测
锁管理器通过waits-for图记录事务的等待关系,如图6-2所示。其中结点代表事务,有向边代表事务在等待另一个事务解锁。当waits-for图出现环路时,就说明出现了死锁。锁管理器会定时检测waits-for图,如果发现环路,则需要选择一个合适的事务中止它。
![图6-2 waits-for图示例图](images/6-2.png)
<center>图6-2 waits-for图示例图</center>
#### 6.3.4.2 死锁避免
当事务请求的锁与其他事务出现锁冲突时,系统为防止死锁,杀死其中一个事务。选择要杀死的事务时,一般持续越久的事务,保留的优先级越高。这种防患于未然的方法不需要waits-for图,但提高了事务被杀死的比率。
### 6.3.7 封锁粒度
封锁粒度是指封锁对象的大小。封锁对象可以是逻辑单元,也可以是物理单元。以关系数据库为例,封锁对象可以是属性值、属性值的集合、记录、表、直至整个数据库;也可以是一些物理单元,例如页(数据页或索引页)、块等。封锁粒度与系统的并发度及并发控制的开销密切相关。封锁的粒度越小,并发度越高,系统开销也越大;封锁的粒度越大,并发度越低,系统开销也越小。
如果一个DBMS能够同时支持多种封锁粒度供不同的事务选择,这种封锁方法称为多粒度封锁。选择封锁粒度时应该综合考虑封锁开销和并发度两个因素,选择适当的封锁粒度以求得最优的效果。通常,需要处理一个表中大量记录的事务可以以表为封锁粒度;需要处理多个表中大量记录的事务可以以数据库为封锁粒度;而对于只处理少量记录的事务,则以记录为封锁粒度比较合适。
## 6.4 故障恢复
故障恢复机制是在数据库发生故障时确保数据库一致性、事务原子性和持久性的技术。当崩溃发生时,内存中未提交到磁盘的所有数据都有丢失的风险。故障恢复的作用是防止崩溃后的信息丢失。
故障恢复机制包含两个部分:
- 为了确保DBMS能从故障中恢复,在正常事务处理过程中需要执行的操作,如登记日志、备份数据等。
- 发生故障后,将数据库恢复到原子性、一致性和持久性状态的操作。
### 6.4.1 故障分类
由于DBMS根据底层存储设备被划分为不同的组件,因此DBMS需要处理许多不同类型的故障。
1. 事务故障
一个事务出现错误且必须中止,称其为事务故障。可能导致事务失败的两种错误是逻辑错误和内部状态错误。逻辑错误是指事务由于某些内部条件无法继续正常执行,如非法输入、找不到数据、溢出等;内部状态错误是指系统进入一种不良状态,使当前事务无法继续正常执行,如死锁。
2. 系统故障
系统故障是指导致系统停止运转、需要重新启动的事件。系统故障可能由软件或硬件的问题引起。软件问题是指由于DBMS的实现问题(如未捕获的除零异常)导致系统不得不停止;硬件问题是指DBMS所在的计算机出现崩溃,如系统突然掉电、CPU故障等。发生系统故障时,内存中的数据会丢失,但外存数据不受影响。
3. 介质故障
介质故障是指当物理存储损坏时发生的不可修复的故障,如磁盘损坏、磁头碰撞、强磁场干扰等。当存储介质失效时,DBMS必须通过备份版本进行恢复。
### 6.4.2 缓冲池管理策略
缓冲池管理策略是指,对于已提交和未提交的事务,它们在内存缓冲池中修改的数据页被写出到磁盘的时机。
对于已提交事务,存在两种策略:
- FORCE:事务提交时必须强制将其修改的数据页写盘;
- NOFORCE:允许在事务提交后延迟执行写盘操作。
对于未提交事务,也存在两种策略:
- STEAL:允许在事务提交前就将其修改的数据页写盘;
- NOSTEAL:不允许在事务提交前执行写盘操作。
对于恢复来说,FORCE+ NOSTEAL是最简单的策略,但是这种策略的一个缺点是要求内存能放下事务需要修改的所有数据,否则该事务将无法执行,因为DBMS不允许在事务提交之前将脏页写入磁盘。
从高效利用内存和降低磁盘I/O开销的角度出发,NOFORCE+ STEAL策略是最灵活的,这也是很多DBMS采用的策略。在这种策略下,一旦发生故障,恢复机制可能需要执行以下操作:
- UNDO:发生故障时,尚未完成的事务的结果可能已写入磁盘,为保证数据一致性,需要清除这些事务对数据库的修改。
- REDO:发生故障时,已完成事务提交的结果可能尚未写回到磁盘,故障使得这些事务对数据库的修改丢失,这也会使数据库处于不一致状态,因此应将这些事务已提交的结果重新写入磁盘。
为了保证在恢复时能够得到足够的信息进行UNDO和REDO,DBMS在事务正常执行期间需要登记事务对数据库所做的修改,这就是日志机制。
### 6.4.3 日志
#### 6.4.3.1 日志的原理
日志是由日志记录构成的文件,几乎所有DBMS都采用基于日志的恢复机制。它的基本思路是:DBMS在对磁盘页面进行修改之前,先将其对数据库所做的所有更改记录到磁盘上的日志文件中,日志文件包含足够的信息来执行必要的UNDO和REDO操作,以便在故障后恢复数据库。DBMS必须先将对数据库对象所做修改的日志记录写入日志文件,然后才能将该对象刷新到磁盘,这一过程称为WAL(Write Ahead Log)。WAL的执行过程如图6-3所示。事务开始后,所有对数据库的修改在发送到缓冲池之前都被记录在内存中的WAL缓冲区中。事务提交时,必须把WAL缓冲区刷新到磁盘。一旦WAL缓冲区被安全地写进磁盘,事务的修改结果就也可以写盘了。
![图6-3 WAL过程示意图](images/6-3.png)
<center>图6-3 WAL过程示意图</center>
日志文件中应该记录以下信息:
- l 事务开始时,向日志中写入一条该事务的开始记录<START T>
- l 事务结束时,向日志中写入一条该事务的结束记录,结束记录包括两类:正常结束记录<COMMIT T>,和异常结束记录<ABORT T>
- 事务对每个数据对象的修改操作对应一条日志记录,其中包含以下信息:
- 事务ID
- 对象ID
- 修改前的值(用于UNDO)
- 修改后的值(用于REDO)
将日志记录从日志缓冲区写入磁盘的时机有这样几个:
- 接收到提交事务的命令后,在返回提交成功的消息之前,DBMS必须将该事务的所有日志记录写入磁盘。系统可以使用&quot;组提交&quot;的方式来批处理多个事务的提交,以降低I/O开销。
- 日志缓冲区空间不足的时候,需要将缓冲区中的日子记录写入磁盘。
- 在将一个脏数据页写入磁盘之前,与更新该页有关的所有日志记录都必须先被写入磁盘。
需要注意的是,登记日志时必须严格按事务的操作顺序记录,并且写到磁盘中的日志记录顺序必须与写入日志缓冲区的顺序完全一致。
#### 6.4.3.2 日志的类型
根据实现时采用的恢复方法的不同,日志中记录的内容也不一样,分为以下几类。
1. 物理日志:物理日志中记录的是事务对数据库中特定位置的字节级更改。例如,日志中记录的是事务对指定数据页中从指定位置开始的若干字节的修改。
2. 逻辑日志:逻辑日志中记录的是事务执行的逻辑操作。例如,日志中记录的是事务执行的UPDATE、DELETE和INSERT语句。与物理日志相比,逻辑日志需要写的数据更少,因为每条日志记录可以在多个页面上更新多个元组。然而,当系统中存在并发事务时,通过逻辑日志实现恢复很困难。
3. 混合日志:日志中记录的是事务对指定页面中指定槽号内元组的更改,而不是对页中指定偏移位置的更改。
### 6.4.4 恢复算法
#### 6.4.4.1 事务故障的恢复
事务故障是指事务在运行至正常终止点前被终止,这时恢复子系统应利用日志文件UNDO此事务己对数据库进行的修改。事务故障的恢复应由DBMS自动完成,对用户完全透明。恢复步骤如下:
1. 反向扫描日志文件,查找该事务的更新日志记录。
2. 对该事务的更新操作执行逆操作, 即将日志记录中 &quot;更新前的值&quot; 写入数据库。如果记录中是插入操作,则逆操作相当于做删除操作:若记录中是删除操作,则逆操作相当于做插入操作;若是修改操作,则逆操作相当于用修改前的值代替修改后的值。
3. 继续反向扫描日志文件,查找该事务的其他更新日志记录并做相同处理,直至读到此事务的开始标记。
#### 6.4.4.2 系统故障的恢复
系统故障导致数据库处于不一致状态的原因,一方面是未提交事务对数据库的更新已经被写入数据库,另一方面则是已提交事务对数据库的更新没有被完全写入数据库。因此对于系统故障的恢复操作,就是要UNDO故障发生时未提交的事务,REDO已提交的事务。系统故障也是由DBMS在重启时自动完成,对用户完全透明。恢复步骤如下:
1. 正向扫描日志文件,通过事务开始记录和COMMIT记录找出在故障发生前已提交的事务集合和未提交的事务集合。已提交的事务既有开始记录也有COMMIT记录,未提交的事务则只有开始记录,没有相应的COMMIT记录。将已提交的事务加入重做队列(REDO-LIST),未提交的事务加入撤销队列(UNDO-LIST)。
2. 反向扫描日志文件,对UNDO-LIST中的各个事务进行UNDO处理。
3. 正向扫描日志文件,对REDO-LIST中的各个事务进行REDO处理。
#### 6.4.4.3 介质故障的恢复
发生介质故障后,磁盘上的物理数据和日志文件被破坏,这是最严重的一种故障,恢复方法是重装数据库,然后重做已完成的事务。介质故障的恢复需要用户人工介入,由DBA装入最新的数据库备份及日志文件备份,然后执行系统提供的恢复命令。
DBA装入相关备份文件后,系统执行的恢复过程与系统故障的恢复过程类似,也是通过扫描日志文件构造REDO-LIST和UNDO-LIST,然后对REDO-LIST和UNDO-LIST中的事务分别进行REDO和UNDO处理,这样就可以将数据库恢复到最近一次备份时的一致性状态。
### 6.4.5 检查点
以上讨论的基于日志的恢复算法存在两个问题:1. 构造REDO-LIST和UNDO-LIST需要搜索整个日志文件,耗费大量的时间;2.处理REDO-LIST时,很多事务的修改实际上已经写入了磁盘,但是仍然不得不进行REDO处理,浪费大量时间。为了解决上述问题,提高恢复效率,很多DBMS都采用了检查点技术,通过周期性地对日志做检查点来避免故障恢复时检查整个日志。
检查点技术的基本思路是:在日志文件中增加一类记录——检查点记录,并增加一个文件——重新开始文件。恢复子系统周期性地执行以下操作:
1. 将日志缓冲区中的日志记录全部写入磁盘中的日志文件;
2. 在日志文件中写入一个检查点记录;
3. 将数据缓冲区中的数据写入磁盘;
4. 将检查点记录在日志文件中的地址写入重新开始文件。
其中,检查点记录中包含以下信息:
- 检查点时刻,当前所有正在执行的事务清单
- 清单中每个事务最近一个日志记录的地址
![图6-4 带检查点的日志文件和重新开始文件](images/6-4.png)
<center>图6-4 带检查点的日志文件和重新开始文件</center>
由检查点时刻系统执行的操作可知,如果一个事务在一个检查点之前已经提交了,那么它对数据库所做的修改一定都被写入了磁盘,因此在进行恢复处理时,就没有必要再对该事务执行REDO操作了。
增加了检查点之后,基于日志的恢复步骤如下:
1. 从重新开始文件中找到最后一个检查点记录在日志文件中的地址,根据该地址在日志文件中找到最后一个检查点记录。
2. 由该检查点记录得到检查点时刻正在执行的事务清单ACTIVE-LIST。初始化两个事务队列UNDO-LIST和REDO-LIST,令UNDO-LIST = ACTIVE-LIST,令REDO队列为空。
3. 从检查点开始正向扫描日志文件直到日志文件结束,如有新开始的事务,则将其放入UNDO-LIST,如有提交的事务,则将其从UNDO-LIST队列移到REDO-LIST队列。
4. 对UNDO-LIST和REDO-LIST中的每个事务,分别执行UNDO和REDO操作。
# 参考资料
1. 王珊, 萨师煊. 数据库系统概论(第5版). 北京: 高等教育出版社, 2014
2. Hector Garcia-Mlina, Jeffrey D. Ullman, Jennifer Widom. 杨冬青 等译. 数据库系统实现. 北京: 机械工业出版社, 2010
3. [Abraham](http://search.dangdang.com/?key2=Abraham&amp;medium=01&amp;category_path=01.00.00.00.00.00) [Silberschatz](http://search.dangdang.com/?key2=Silberschatz&amp;medium=01&amp;category_path=01.00.00.00.00.00), [Henry](http://search.dangdang.com/?key2=Henry&amp;medium=01&amp;category_path=01.00.00.00.00.00) [F.Korth](http://search.dangdang.com/?key2=F.Korth&amp;medium=01&amp;category_path=01.00.00.00.00.00), S. Sudarshan. 杨冬青 等译. 数据库系统概念(第6版). 北京: 机械工业出版社, 2012
4. 李海翔. 数据库查询优化器的艺术原理解析与SQL性能优化. 北京: 机械工业出版社, 2014
5. [https://15445.courses.cs.cmu.edu/fall2020/schedule.html](https://15445.courses.cs.cmu.edu/fall2020/schedule.html)
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册