第一章:DBMS系统概述
1
数据库管理系统将满足:
- 用户使用专门的数据定义语言来创建新的数据库并指定其模式(数据的逻辑结构)
- 用户使用适当的语言查询数据
- 大量的数据长期地进行存储
- 数据具有持久性,从故障、多种类型的错误或者故意滥用中进行恢复。
- 多个用户同时对数据进行访问,孤立性,原子性
关系数据库系统:关系数据库的程序员并不需要关心存储结构。查询可以用很高级的语言来表达,这样可以极大地提高数据库程序员的效率。
数据操纵语言(DML)
查询响应
- 查询编译器对查询进行分析和优化,得到的查询计划传给执行引擎。
- 执行引擎向资源管理器发出一系列对小的数据单元(通常是记录或关系的元组(tuple))的请求。
- 查找数据的请求被传送给缓冲区管理器。从持久地存储数据的辅助存储器(磁盘)中将数据的适当部分取到主存的缓冲区中。
- 缓冲区管理器和存储管理器进行通信,以从磁盘获取数据,存储管理器的任务是控制数据在磁盘上的放置和在磁盘与主存之间的移动。
事务处理:
孤立地执行的单位,一个或多个数据库操作组成一组,称作事务
1. 并发控制管理器或调度器,它负责保证事务的原子性和孤立性。
2. 日志和恢复管理器,它负责事务的待久性。
第二章:辅助存储管理
加速数据库操作的关键技术是安排好数据, 使得当某一个磁盘块中有数 据被访问时, 大约在同时很有可能该块上的其他数据也需要被访问。
数据库中的任何修改都不能认为是最终有效的,直到该修改被存储到非易失性的辅助存储器中。
倘若一部分磁化层被以某种方式损坏,那么那些包含这个部分的整个扇 区也不能再使用。
加速对辅助存储器的访问
1/0开销的主导地位:块访问(磁盘1/0)次数就是算法所需要的时间的近似值, 而且应该被最小化。
- 将要一起访问的块放在同一柱面上,这样我们可以经常避免寻道时间,也可能避免旋转延迟。
- 将数据分隔存储在几个相对较小的磁盘上而不是放在一个大磁盘上。让更多的磁头组设备分别去访问磁盘块可增加在单位时间内的磁盘块访问数量。
- "镜像“磁盘一把两个或者更多的数据副本放在不同的磁盘上。该策略除了可以保存数 据以备某个磁盘可能坏掉,如同将数据分隔存储几个磁盘上,可以让我们一次访问多个磁盘块。(加快读取速度,不能加快存储速度)
- 在操作系统、DBMS或磁盘控制器中,使用磁盘调度算法选择读写所请求的块的顺序。
- 预先将预期被访问的磁盘块取到主存储器中。
磁盘故障
分为间断性故障和介质损坏以及磁盘崩溃
间断性故障
磁盘块的正确内容没有被传送到磁盘控制器中。
判断:(校验和)。 每个扇区有若干个附加位,称为校验和 (checksum)
奇偶校验:单位/多位
关千大规模的错误,任何一个奇偶位将 检测出错误的可能性是50%, 8位都检测不 出错误的机会仅仅是1/2^8
如果我们用n个独立位作为校验码,漏掉一个错误的机会仅为1/2^n
稳定存储的策略:
稳定存储策略是一种数据冗余技术,用于提高数据存储的可靠性和容错能力。比如在写时发生故障,写的数据丢失,同时原数据也遭到破坏。
- 成对的扇区:数据被存储在成对的扇区中,每对扇区包含相同的数据内容X。这些扇区被称为“左”拷贝XL和“右”拷贝XR。
- 奇偶校验:每个拷贝使用额外的奇偶校验位来增强数据的完整性。
- 写策略:
- 步骤1:首先将数据X写入XL。如果写入过程中奇偶校验位正确,认为写入成功;否则,需要重新写入,直到成功或确定存在介质故障。
- 步骤2:如果XL写入成功,对XR重复步骤1的操作。
- 介质故障处理:如果在多次尝试后,数据仍然无法写入某个扇区,可以认为该扇区存在介质故障。此时,应使用备用扇区来代替故障扇区。
- 读策略:
- 交替尝试读取XL和XR,直到获得一个“好”的值,即奇偶校验位正确的值。
- 如果在预设的尝试次数内无法获得好值,可以认为数据X是不可读的。
- 容错能力:通过成对存储和奇偶校验,该策略能够容忍单个扇区的故障,同时保持数据的完整性。
稳定存储的错误处理
- 写故障:
- 在写入数据X的过程中,如果发生系统故障(如电源断电),可能导致X在主存中丢失,同时正在写入的X的拷贝也可能被破坏。
- 系统恢复后,我们可以通过测试XL和XR的状态来确定X的值。可能的情况包括:
a) 写XL时发生故障:
- 如果故障发生在写入XL的过程中,我们将发现XL的状态是“坏”。
- 由于我们从未写入XR,它的状态将是“好”(除非XR同时发生介质故障,这种情况可能性很小)。
- 这样我们可以从XR读取X的旧值,并可以将XR复制到XL,以修复XL的故障。
b) 写XR后发生故障:
- 如果故障发生在写入XR之后,我们预计XR将有状态“好”,并且我们可以从XR读取X的新值。
- 由于XL可能包含部分新值和部分旧值,我们需要将XR复制到XL中,以确保两个扇区的数据一致性。
冗余技术
将多个物理磁盘驱动器组合成一个或多个逻辑单元,以提高数据存储性能和提供容错能力的技术。
RAID1
一个数据盘,一个冗余盘。
RAID4(奇偶块)
多个数据盘,一个冗余盘
读操作:
- 从数据盘读取数据块与从冗余盘读取数据块在操作上没有区别。
- 读操作可以直接从任何一个数据盘进行,因为所有数据盘上的数据块内容是一致的。
写操作:
- 当向数据盘写入一个新的数据块时,不仅需要更改该数据盘上的块,还需要更新冗余盘上相应的奇偶校验块,以保持数据的奇偶校验一致性。
朴素的写方法:
- 读取所有n个数据盘上相应的数据块。
- 将这些数据块进行模2加法以计算新的奇偶校验值。
- 重写冗余盘上的块以反映新的奇偶校验信息。
- 这种方法涉及n-1次数据盘的读操作,1次数据盘的写操作,以及1次冗余盘的写操作,总共是n+1次磁盘操作。
改进的写方法:
- 只关注正在被重写的数据块i的旧版本和新版本。
- 通过取旧版本和新版本的模2加法,确定哪些位发生了变化(从0变为1或从1变为0)。
- 由于变化总是使1的总数从一个偶数变为一个奇数,只需更改冗余块中相应位置的位,即可恢复奇偶校验的一致性。
- 这种方法减少了磁盘操作次数,提高了写操作的效率。
写操作的步骤:
- 读取要被改变的数据盘上的旧数据块。
- 读取冗余盘上相应的奇偶校验块。
- 将新数据块写入数据盘。
- 根据新旧数据块的模2加法结果,重新计算并写入冗余盘的相应块。
RAID 4级别能够在单个数据盘发生故障时保护数据不丢失,并且通过奇偶校验机制实现数据的恢复。然而,每次写操作都需要更新奇偶校验信息。
RAID5
- 旨在解决RAID 4中的写入瓶颈问题。
- RAID 5是为了提高写入性能而设计的,它通过分布式奇偶校验来平衡各个磁盘的读写负载。
- 在RAID 4中,所有的写操作都需要访问一个专用的冗余磁盘来更新奇偶校验信息,这成为系统的瓶颈。
- RAID 5使用分布式奇偶校验,即奇偶校验信息不是存储在一个专用的冗余磁盘上,而是分散存储在所有磁盘上。
- 在RAID 5中,写操作不需要总是访问同一个磁盘,而是根据数据块的位置动态选择奇偶校验磁盘,从而提高了写入性能。
多个盘崩溃时的处理(RAID6)
海明码:于检测和纠正单个位的错误(在某些情况下,可以检测两个错误并纠正一个)
【官方双语】汉明码Pa■t1,如何克服噪■_哔哩哔哩_bilibili
【官方双语】汉明码part2,优雅的全貌_哔哩哔哩_bilibili
1.盘5的位是盘1、2、3 相应位的模2和。
2.盘6的位是盘1、2、4相应位的模2和。
3.盘7的位是盘1、3、4相应位的模2和。
读
从任何一个数据盘中正常地读数据。冗余盘可以不予理睬。
写
为了写某个数据盘的一个块,我们要求那个块的新版与旧版的模2和。得出的结果与对应的冗余块进行模2和更新。
2.6 指针混写:
处理数据项在不同存储层次(如主存储器和二级存储器)之间的移动时的指针地址转换问题
指针混写是块从第二级存储器移到内存中时,将数据库地址空间转换为虚拟地址空间。
自动混写
优点:
高效性:一旦块被加载到内存,所有可识别的指针立即被混写,减少了后续操作中查找和混写指针的延迟。
简化编程:开发者无需担心指针的混写和解混写,DBMS自动处理。
缺点:
资源浪费:如果某些混写的指针从未被访问,则混写这些指针的时间和资源就被浪费了。
复杂性:需要复杂的机制来识别和定位块内的所有指针,可能涉及模式识别或额外的数据结构。
按需混写
优点:
资源节约:只在指针被实际使用时才进行混写,避免了不必要的混写操作。
灵活性:可以根据访问模式动态调整混写策略。
缺点:
延迟:首次访问未混写的指针时会有额外的混写开销。
编程复杂性:开发者需要更仔细地管理指针的混写状态,确保在需要时能够正确混写。
不混写
优点:
简单性:无需实现复杂的混写和解混写逻辑。
灵活性:指针始终以数据库地址形式存在,便于处理和跟踪。
缺点:
性能下降:每次访问数据库地址时都需要通过转换表查找对应的内存地址,增加了访问延迟。
内存管理限制:数据块无法像混写时那样被有效地固定在内存中。
混写的程序控制
优点:
灵活性:开发者可以根据具体的应用场景和需求来控制混写行为。
性能优化:对于频繁访问的数据块,可以显式地进行混写以提高性能。
缺点:
编程复杂性:需要开发者对应用的访问模式有深入的了解,并编写额外的代码来控制混写行为。
错误风险:不恰当的混写控制可能导致性能下降或资源浪费。
块返回磁盘
转换表(Translation Table)存储了内存地址(memAddr)和数据库地址(dbAddr)之间的映射关系。这种映射允许系统在需要时将内存中的地址转换回它们在数据库(即磁盘)上的原始地址,或者在数据从磁盘加载到内存时执行相反的转换。
为了加速查询过程,通常在转换表上建立索引。
索引
常见的索引结构包括散列表(Hash Table)、B树(B-Tree)或平衡树(如AVL树、红黑树)等。
_散列表_在提供快速查找方面具有优势,特别是对于等长键的查找。然而,散列表在处理键的删除和重新插入时可能会遇到性能问题,尤其是当发生哈希冲突时。
_B树和类似的平衡树结构_则更适合于需要支持范围查询和频繁更新的场景。它们通过保持树的平衡来确保查询、插入和删除操作的时间复杂度保持在对数级别。
被钉住的记录和块
如果内存中一个块当前不能安全地被写回磁盘,称它为被钉住的。
内存中B1有一个混写指针,指向B2。B2移回磁盘时,B1的指针成为悬挂指针,我们称B2是被钉住的。
我们需要解混写指向B2的所有指针。因此,对每一个有数据项在内存中的数据库地址,转换表必须记录指向内存中存在那个数据项的混写指针的位置。
为了解决悬挂指针的问题,系统需要维护一个转换表。这个表记录了每个在内存中有数据项的数据库地址,以及指向这些数据项的混写指针在内存中的位置。当某个内存块(如B2)需要被写回到磁盘时,系统会使用这个转换表来找到所有指向该内存块的混写指针,并将它们更新为新的有效值(如果B2的数据在磁盘上有了新的位置)或删除它们(如果B2的数据不再需要被引用)。
在转换表中使用附加链表
在这种方法中,转换表不仅仅存储数据库地址到内存地址的映射,还附加了一个链表来跟踪所有引用该内存地址的混写指针。这个链表实际上是附加在转换表中与特定内存地址相关联的表项上的。
当需要将一个内存块(如B2)写回磁盘时,系统会查找转换表中B2的内存地址(memAddr)对应的条目。然后,它会遍历附加在该条目上的链表,找到并更新或删除所有引用B2的混写指针。
在指针自身空间中创建链表
这种方法假设内存地址(或指针大小)比数据库地址短得多,因此有足够的空间在指针本身内部来存储额外的信息。
每个指针不再仅仅是一个内存地址,而是被扩展为一个结构体,包含至少两个部分:
a)被混写的指针(或数据库地址):指向实际数据的原始数据库地址。
b)链表指针:指向一个链表节点,该节点是包含所有引用该指针的混写指针出现的链表的一部分。
链表内容:每个链表节点包含对另一个混写指针(实际上是另一个扩展的指针结构体)的引用,这些混写指针都指向同一个内存地址。
2.7 变长数据和记录
2.7.1 具有变长字段的记录
将所有定长字段放在变长字段之前,然后我们在记录首部写人以下信息:
1.记录长度。
2.指向所有除第一个之外的变长字段起始处(即偏移量)的指针(我们知道第一个变长字段就紧跟在定长字段之后)。
2.7.2 具有重复字段的记录
方法一:
将字段F的每次出现放在一起,在记录首部放一个指针,让它指向字段F出现的第一个位置。
我们可用以下方法找到字段F出现的所有位置:令字段F的一次出现占用的字节数为L,然后在字段F的偏移量上加上L的所有整数倍数,从0开始而后L、2L、3L,依此类推。最后,我们到达F后面的字段的偏移量或记录末尾,至此停止。
方法二:
另一种表示方法是保持记录定长,而将变长部分(无论它是变长字段,还是重复次数不确定的字段)放在另一个块上。在记录本身中我们存储:
1.指向每一个重复字段开始处的指针。
2.重复次数或者重复结束处。
优点:
保持记录定长。可以更有效地对记录进行搜索,使块首部的开销最少,记录能很容易地在块内或块间移动。
缺点:
另一方面,将变长部分存储在另一个块中增加了为检査一条记录的所有部分而进行的磁盘 //0 数目。
2.7.4 不能装入一个块中的记录
如果记录能跨块,则每一条记录和记录片段需要一些额外的首部信息:
1.每一条记录或片段首部必须包含一个二进制位,指明它是否为一个片段。
2.如果它是一个片段,则它需要几个二进制位,指明它是否为它所属的记录的第一个或最后一个片段。
3.如果对同一条记录有下一个和/或前一个片段,则片段需要指向这样一些其他片段的指针。
记录片段2-a的首部包含一个指明它是片段的标记,一个指明它是记录的第一个片段的标记和一个指向下一个片段2-b的指针。
同样,2-b首部指明它是记录的最后一个片段,且有一个指向前一个片段2a的反向指针。
2.7.5 BLOB(大数据量存储)
连续存储: 理想情况下,BLOB数据应该连续存储在磁盘上,以减少磁头移动的次数,从而提高数据读取效率。然而,随着BLOB数据量的增加,找到足够大的连续空间可能会变得困难。
链表存储: 当无法找到足够的连续空间时,BLOB数据可能被分割并存储在磁盘的多个不连续块中,这些块通过链表连接起来。这种方式虽然解决了空间分配的问题,但可能会增加数据检索时的磁盘I/O操作次数,降低性能。
多磁盘存储: 对于非常大的BLOB数据,可以考虑将其分布在多个磁盘上。这样,当进行数据检索时,可以并行地从多个磁盘读取数据块,显著提高读取速度。这种方法特别适用于需要实时处理大量数据的场景,如视频流服务。
按需传输: 数据库系统可以根据客户端的播放速度实时地传输BLOB数据块,而无需等待整个文件完全加载。这种方式极大地减少了延迟,提高了用户体验。
2.7.6列存储
列存储(Column-Oriented Storage)是一种数据库存储技术,与传统的行存储(Row-Oriented Storage)方式相对。在列存储中,数据库表中的数据不是按行存储,而是按列存储。
列存储的优势:
数据压缩: 由于同一列中的数据通常具有相似的数据类型和值域,因此可以实现高效的数据压缩。例如,在性别(gender)列中,如果只有“男”和“女”两种值,则可以使用非常少的位来表示每个值,从而大大减少存储需求。
更快的查询速度: 当查询只涉及表中的少数几列时,列存储数据库可以只读取这些列的数据,而无需读取整行数据。这减少了I/O操作的数量,从而提高了查询速度。
更好的缓存利用率: 由于相同列的数据在物理上连续存储,因此它们更有可能被缓存在一起。这提高了缓存的利用率,因为一旦某个列的数据被读入缓存,后续对该列的查询就可以直接从缓存中获取数据。
更高效的聚合操作: 列存储特别适合于执行聚合操作(如SUM、AVG、COUNT等),因为相同列的数据已经聚集在一起,可以直接进行计算而无需跨行访问。
2.8 记录的修改
2.8.1 插入
定位块->检查空间->空间足够->滑动记录->更新偏移量表->插入新记录->添加新指针(可以通过偏移量表来访问它了)
块空间不够插入新数据
- 在“邻近块”中找空间
步骤:
找到逻辑上或物理上紧随块B的下一个块(如块B’)。
检查块B’是否有足够的空间。
如果有,将块B中最后一个记录(或几个记录)移动到块B’的开始位置,为新记录腾出空间。
更新块B和块B’的偏移量表(如果使用)和任何必要的索引。
将新记录插入到块B的适当位置。
这种方法的好处是保持了记录的物理顺序,减少了数据碎片,但可能需要移动大量数据以腾出空间。
- 创建一个溢出块
当在邻近块中找不到足够的空间时,或者为了优化性能而减少数据移动,可以创建一个溢出块来存储那些在当前块中无法容纳的额外记录。
步骤:
当块B没有足够的空间时,创建一个新的溢出块。
在块B的首部(或某个预定义的位置)添加一个指针,指向这个溢出块。
将原本应该插入到块B但由于空间不足而无法存储的记录放入溢出块中。
优点:减少了因插入操作而需要移动的数据量
缺点:可能会增加查询操作的复杂性,因为查询可能需要遍历多个溢出块来找到所需的记录。此外,过多的溢出块还可能导致性能下降,因为需要读取更多的磁盘块来完成查询。
2.8.2 删除
删除记录并回收空间
如果记录可以滑动,那么在删除一条记录后,可以将块中的其他记录向前滑动以填补被删除记录留下的空白。这样可以使块内的空间更加紧凑,减少碎片。在使用偏移量表的情况下,删除记录后需要更新偏移量表中的指针。
如果记录不能滑动,在块首部维护一个可用空间列表。这个列表记录了块中所有可用的空间区域及其大小。当删除一条记录时,将该记录所占用的空间区域添加到可用空间列表中。当需要插入新记录时,可以从这个列表中查找合适的空间区域。
第三章:索引结构
3.1 索引基础结构
索引:它以一个或多个字段的值为输人并能快速地找出具有该值的记录。
索引使我们只需查看所有可能记录中的一小部分就能找到所需记录。建立索引的字段(组合)称为查找键。
稠密索引:为数据文件中的每个记录都创建一个索引项。这种索引方式提供了最精确的位置信息,但会占用更多的存储空间。
稀疏索引:不是为数据文件中的每个记录都创建索引项,而是选择性地为某些记录(如每个数据块的首个记录)创建索引项。稀疏索引减少了索引文件的大小,但可能需要在检索时执行额外的步骤来定位具体的数据记录。
主索引:也称为聚集索引,它决定了数据文件中记录的物理存储顺序。主索引能够直接定位到数据文件中的记录位置,因此查询效率很高。在数据库中,通常会在主键上建立主索引。
辅助索引(非聚集索引):不决定数据记录的物理存储顺序,仅提供查找键与数据记录之间的逻辑关联。辅助索引通常用于非主键列,以提高基于这些列的查询效率。
顺序文件:顺序文件是对关系中的元组按主键进行排序而生成的文件。关系中的元组按照这个次序分布在多个数据块中。
3.1.2 稠密索引
如果记录是排好序的,我们就可以在记录上建立稠密索引
它是这样一系列存储块:块中只存放记录的键以及指向记录本身的指针
存储索引文件比存储数据文件所需存储块要少得多。通过使用索引文件,我们每次査询只用一次 I/0操作就能找到给定键值的记录。
查找索引更快
1.索引块数量通常比数据块数量少。
索引块数量通常比数据块数量少的原因主要基于以下几点:
键与记录大小的差异:在数据库中,一个记录(record)通常包含多个字段,如姓名、地址、电话号码等,这些字段合起来可能占用相当多的存储空间。相比之下,索引中存储的仅仅是键(key)以及指向记录的指针(或地址)。键通常是记录中的一个或几个字段,因此其大小远小于整个记录。由于索引块中只存储键和指针,所以每个索引块能够存储的索引项(即键和指针的组合)数量相对较多。
压缩性:由于索引中的键通常是按顺序存储的,这允许数据在物理存储上更加紧凑。此外,如果键的类型是固定的(如整数或固定长度的字符串),那么索引块中的空间可以得到更有效的利用,因为不需要为每个键动态分配空间。
索引的稀疏性:索引不需要为数据集中的每个记录都创建一个索引项。特别是在面对大量数据时,完全索引(即每个记录都有一个索引项)可能会非常庞大且效率低下。相反,索引可以设计为稀疏的,即只在关键位置(如数据块的起始处或特定间隔)创建索引项。这样,索引文件就会比完全索引要小得多。
多层索引结构:对于非常大的数据集,通常会采用多层索引结构(如B树、B+树等)。在这些结构中,最底层的索引块(叶子节点)直接指向数据记录,而更高层的索引块则指向下一层的索引块。这种多层结构进一步减少了顶层索引块的数量,因为每个上层索引块能够覆盖多个下层索引块或数据块。
2.由于键被排序,我们可以使用二分查找法来查找K。若有几个索引块,我们只需查找log2^n 个块。
3.索引文件可能足够小,以至可以永久地存放在主存缓冲区中。要是这样的话,查找键K时就只涉及主存访问而不需执行 I/0 操作。
3.1.3 稀疏索引
稀疏索引只为数据文件的每个存储块设一个键-指针对,它比稠密索引节省了更多的存储空间,但查找给定值的记录需更多的时间。
只有当数据文件是按照某个查找键排序时,在该查找键上建立的稀疏索引才能被使用,而稠密索引则可以应用在任何的査找键。
3.1.4 多级索引
3.1.5 辅助索引
有一个书架,上面放了很多书。这些书是按照某种顺序(比如书名的字母顺序)排列的,这个顺序就是书的主索引,类似于数据库中的聚簇索引。你按照这个顺序找书会很快,因为书是按照这个顺序摆放的。
但是,有时候你可能不想按照书名来找书,而是想按照作者名、出版年份或者书的主题来找。这时候,如果书架上没有额外的标记或指示来帮助你快速找到这些书,你就会很费劲。
在数据库中,这就是辅助索引的作用。辅助索引就像是书架上的标签或指示牌,它们告诉你:“如果你想找作者名是XXX的书,可以去看这里;如果你想找出版年份是YYYY的书,可以去看那里。”
具体来说,辅助索引在数据库中是一个额外的数据结构,它存储了表中某些列的值(比如作者名、出版年份等)以及这些值对应的行在表中的位置(通常是一个指向主键的指针,因为主键能够唯一标识表中的每一行)。
当你根据非主键列(比如作者名)来查询数据时,数据库会使用辅助索引来快速定位到这些列的值对应的行。首先,数据库在辅助索引中查找你指定的值(比如作者名),找到后,它会获取到对应的行位置(比如主键值),然后再根据这个位置去聚簇索引中找到完整的行数据。
需要注意的是,虽然辅助索引可以提高查询效率,但它们也会占用额外的存储空间,并且在数据发生变化(如插入、删除、更新)时,数据库需要同时更新辅助索引,这可能会增加额外的维护成本。
**辅助索引总是稠密索引:**辅助索引的索引项与字段值的数量是相对应的,呈现出一种“稠密”的状态。如果辅助索引是稀疏的,即只为字段的某些值建立索引项,那么就无法保证快速定位到所有相关的记录。
3.1.7 辅助索引中的间接
每个查找键K有一个键-指针对,指针指向一个桶文件,该文件中存放K的桶。从这个位置开始,直到索引指向的下一个位置,其间指针指向索引键值为K的所有记录。
辅助索引上使用间接层也有一个重要的好处:我们通常可以在不访问数据文件记录的前提下利用桶的指针来帮助回答一些查询。特别是,当查询有多个条件,而每个条件都有一个可用的辅助索引时,我们可以通过在主存中将指针集合求交来找到满足所有条件的指针,然后只需要检索交集中指针指向的记录。这样我们就节省了检索满足部分条件而非所有条件的记录所需的 I/0开销。
辅助索引求交集:
3.1.8 文档检索和倒排索引
传统的数据库索引:该索引的键是文档ID,而值是文档中出现的词(或词频、位置等信息)。然
**倒排索引:**索引的键是文档中出现的词(或称为“词条”),而值则是包含该词的文档ID列表(或称为“文档集合”)。这样,当我们想要查找包含某个特定词的文档时,就可以直接通过该词作为键来查找对应的文档ID列表,而无需遍历所有文档的索引项。
文档检索:
-
一个文档可被看成是关系 Doc的元组。这个关系有很多的属性,每个属性对应于文档可能出现的一个词。每个属性都是布尔型的–表明该词在该文档出现还是没有出现。因此,这一关系模式可以被看作:
Doc(hasCat,hasDog,...)<br /> 其中 hasCat 取值为真当且仅当该文档中至少出现一次“cat"这个词。
-
关系 Doc 的每个属性上都建有辅助索引。不过,我们不必费心为属性值为FALSE的元组建索引项;相反,索引只会将我们带到出现该词的那些文档。也就是说,索引中只有查找键值为 TRUE的索引项。
-
我们不是给每个属性(即每个词)建立一个单独的索引,而是把所有的索引合成一个,称为倒排索引。这个索引使用间接桶来提高空间利用率,正如3.1.7节中讨论的那样。
桶文件中指针可以是:
1.指向文档本身的指针。
2.指向词的一个出现的指针。在这种情况下,指针可以是由文档的第一个块和一个表示该词在文档中出现次数的整数构成的对。
当我们使用指针“桶”指向每个词的多次出现的时候,我们可能就会想扩展这个想法,使桶数组包含更多有关词的出现的信息。这样,桶文件本身就成了有重要结构的记录集合。
3.2 B-树
B-树能自动地保持与数据文件大小相适应的索引层次。
对所使用的存储块空间进行管理,使每个块的充满程度在半满与全满之间。
3.2.1 B-树的结构
一颗m阶的B树定义如下:
1)每个结点最多有m-1个关键字。
2)根结点最少可以只有1个关键字。
3)非根结点至少有Math.ceil(m/2)-1个关键字。((m/2)向上取整减去1)
4)每个结点中的关键字都按照从小到大的顺序排列,每个关键字的左子树中的所有关键字都小于它,而右子树中的所有关键字都大于它。
5)所有叶子结点都位于同一层,或者说根结点到每个叶子结点的长度都相同。
3.2.2 B-树的应用
B-树是用来建立索引的一种强有力的工具。它的叶结点上指向记录的一系列指针可以起到任何一种索引文件中指针序列的作用。
3.2.5 B-树的插入
1、插入一个元素时,首先在B树中是否存在,如果不存在,即比较大小寻找插入位置,在叶子结点处结束,然后在叶子结点中插入该新的元素
2、如果叶子结点空间足够,这里需要向右移动该叶子结点中大于新插入关键字的元素,如果空间满了以致没有足够的空间去添加新的元素,则将该结点进行“分裂”,将一半数量的关键字元素分裂到新的其相邻右结点中,中间关键字元素上移到父结点中(当然,如果父结点空间满了,也同样需要“分裂”操作)
3、当结点中关键元素向右移动了,相关的指针也需要向右移。如果在根结点插入新元素,空间满了,则进行分裂操作,这样原来的根结点中的中间关键字元素向上移动到新的根结点中,因此导致树的高度增加一层。
3.2.6 B-树的删除
1)如果当前需要删除的key位于非叶子结点上,则用后继key(这里的后继key均指后继记录的意思)覆盖要删除的key,然后在后继key所在的子支中删除该后继key。此时后继key一定位于叶子结点上,这个过程和二叉搜索树删除结点的方式类似。删除这个记录后执行第2步
2)该结点key个数大于等于Math.ceil(m/2)-1,结束删除操作,否则执行第3步。
3)如果兄弟结点key个数小于Math.ceil(m/2)-1,则父结点中的key下移到该结点,兄弟结点中的一个key上移,删除操作结束。
否则,将父结点中的key下移与当前结点及它的兄弟结点中的key合并,形成一个新的结点。原父结点中的key的两个孩子指针就变成了一个孩子指针,指向这个新结点。然后当前结点的指针指向父结点,重复上第2步。
有些结点它可能即有左兄弟,又有右兄弟,那么我们任意选择一个兄弟结点进行操作即可。
删除27。从上图可知27位于非叶子结点中,所以用27的后继替换它。从图中可以看出,27的后继为28,我们用28替换27,然后在28(原27)的右孩子结点中删除28。删除后的结果如下图所示。
删除后发现,当前叶子结点的记录的个数小于2,而它的兄弟结点中有3个记录(当前结点还有一个右兄弟,选择右兄弟就会出现合并结点的情况,不论选哪一个都行,只是最后B树的形态会不一样而已),我们可以从兄弟结点中借取一个key。所以父结点中的28下移,兄弟结点中的26上移,删除结束。结果如下图所示。
3.2.7 B-树的效率
B-树的效率:磁盘I/O次数、树的层数、节点的分裂与合并、以及针对查找、插入和删除操作的优化。
磁盘I/O次数
B-树的主要优势之一是它减少了磁盘I/O次数,这是因为它通过保持树的高度尽可能低来实现。在B-树中,每个节点可以包含多个键和子指针,这允许树在保持相同数量元素的情况下具有更少的层数。对于大多数数据库和文件系统应用,三层B-树足以满足需求,因为节点容量(如每块340个键-指针对)可以非常大,这取决于块的大小和键的大小。
树的层数
B-树的层数决定了查找记录所需的最大磁盘I/O次数。在三层B-树中,最多需要三次磁盘I/O(从根到叶节点,加上一次读取数据记录本身)。然而,通过缓存技术(如将根节点或某些中间节点常驻内存),这个次数可以进一步减少。例如,如果根节点被缓存,那么查找将只需要两次磁盘I/O。
节点的分裂与合并
在B-树中,节点的分裂和合并是罕见的,特别是当节点容量足够大时。当节点需要分裂时,大多数操作都局限于叶节点,这减少了对树结构整体的影响。此外,分裂和合并操作通常只涉及两个节点(一个要被分裂的节点和一个新的或已有的节点)以及它们的父节点,这进一步限制了操作的复杂性。
插入和删除
插入和删除操作可能需要额外的磁盘I/O来更新数据记录和索引本身。然而,通过适当地维护B-树的平衡(如通过旋转和重新分布节点中的键),可以最小化这些操作的影响。在某些实现中,如果删除操作导致节点中的键数低于最小要求,但预计该节点很快会再次填满,那么可能不对该节点进行合并或重新分配键,而是简单地留下该节点。
删除标记
在处理删除操作时,一种常见的策略是使用“删除标记”而不是实际从B-树中删除条目。这样做的好处是,即使记录已从数据库中删除,B-树索引仍然可以保留对该记录的引用(尽管它已被标记为删除)。这有助于维护索引的完整性,并允许通过B-树索引查询已删除记录的状态(例如,检查记录是否已被删除)。
3.3 散列表
散列函数:散列函数 ( h ) 接受一个查找键(Key)作为输入,并计算出一个介于 0 到 ( B-1 ) 之间的整数,其中 ( B ) 是桶(Bucket)的数目。
桶:桶是散列表中用于存储数据的单元。每个桶可以存储多个数据项,通常使用链表来实现。桶数组是一个序号从 0 到 ( B-1 ) 的数组,每个数组元素对应一个桶。
存储:当需要存储一个记录时,首先计算其查找键 ( K ) 的散列值 ( h(K) )。然后将该记录链接到桶号为 ( h(K) ) 的桶表中。
3.3.1 辅存散列表
有的散列表包含大量记录,记录如此之多,以至于它们主要存放在辅助存储器上,这样的散列表在一些细小而重要的方面与主存中的散列表存在区别
3.3.2 散列表的插入
查找键为K的新记录需要被插入时,计算h(K)。
如果桶号为h(K)的桶还有空间,我们就把该记录存放到此桶的存储块中。
其存储块没有空间时存储到块链上的某个溢出块中
如果桶的所有存储块都没有空间:我们就增加一个新的溢出块到该桶的链上。
3.3.5 可扩展散列表
它在简单的静态散列表结构上主要增加了:
1.为桶引人了一个间接层,即用一个指向块的指针数组来表示桶,而不是用数据块本身组
成的数组来表示桶。
2.指针数组能增长,它的长度总是2的幂,因而数组每增长一次,桶的数目就翻倍。
3.并非每个桶都有一个数据块;如果某些桶中的所有记录可以放在一个块中,那么
这些桶可能共享一个块。
4.散列函数h为每个键计算出一个K位二进制序列,该K足够大,比如32。但是,桶的数目总是使用从序列第一位或最后一位算起的若干位,此位数小于K,比如说是i位。也就是说,当i是使用的位数时,桶数组将有2^i个项。
3.3.6 可扩展散列表的插入
计算哈希值:首先,计算要插入的键的哈希值 h(K)。
定位桶数组项:使用哈希值 h(K) 的前 i 位来确定桶数组中的项。这里的 i 是当前用于索引桶数组的全局位数(global bit position)。
检查存储块空间:如果找到的存储块(B)有足够的空间来存放新记录,则直接插入新记录。
如果存储块已满(即没有空间),则需要进行分裂或桶数组扩展。
处理存储块满的情况:
如果 j < i:
将存储块 B 分裂成两个新的存储块。
根据新记录的哈希值的第 (j+1) 位来决定其归属的存储块(0 或 1)。
更新存储块的小方块中的位数为 (j+1),表示现在使用更多的位来确定存储块的成员资格。
调整桶数组中的指针,根据新记录的哈希值的第 (j+1) 位来指向正确的存储块。
如果分裂后仍然有存储块过满,则继续以更高的 j 值重复分裂过程。
如果 j = i:
桶数组的长度翻倍,并创建一个新的桶数组。
对于旧桶数组中的每个项 w,在新桶数组中创建两个项 u0 和 u1(分别通过在 w 后面添加 0 和 1 来获得)。这两个新项都指向原 w 项指向的存储块。
然后,像 j < i 的情况一样,分裂过满的存储块。
3.3.7 线性散列表
可扩展散列缺点:
1.当桶数组需要翻倍时,要花费很长的时间。
2.当桶数翻倍后,它在主存中可能就装不下了,或者把其他的一些我们需要保存在主存的数据挤出去。其结果是,一个运行良好的系统可能突然之间每个操作所需磁盘I/O开始大增。
3.如果每块的记录数很少,那么很有可能某一块的分裂比在逻辑上讲需要分裂的时间提前许多。
线性散列解决其缺点
- 桶数n的选择总是使存储块的平均记录数保持与存储块所能容纳的记录总数成一个固定的比例,如 80%。
- 由于存储块并不总是可以分裂,所以允许有溢出块,尽管每个桶的平均溢出块数远小于1。
- 用来作桶数组项序号的二进制位数是1og2^n向上取整,其中n是当前的桶数。这些位总是从散列函数得到的位序列的右(低位)端开始取。
- 假定散列函数值的i位正在用来给桶数组项编号,且有一个键值为K的记录想要插人到编号为 a,…a; 的桶中;即 a,…a 是h(K)的后i位。那么,把 a,a,…a;当作二进制整数,设它为 m。如果m<n,那么编号为m的桶存在并把记录存入该桶中。如果 n≤m<2i那么m桶还不存在,因此我们把记录存人桶m-2(i-1),也就是当我们把a1(它肯定是1)改为0时对应的桶。
3.3.8 线性散列表的插入
确定桶号:
使用哈希函数 h(K) 计算键 K 的哈希值。
取 h(K) 序列末尾的 i 位来表示桶号 m。这里,i 是一个与当前散列表容量相关的参数,可能基于 n(当前桶的数量)的某种属性(如 log2(n) 的整数部分,但具体取决于实现)。
如果 m < n,则直接将记录插入到桶 m 中。
如果 m ≥ n,则通过 (m - 2^(i-1)) % n来计算实际的桶号,并插入记录。
处理溢出:
如果桶已满,则创建一个新的溢出块(如链表节点),将新记录存入该节点,并将此节点链接到桶的尾部。
动态扩容和桶分裂:
每次插入后,检查当前记录总数与 n 的比例是否超过了某个阈值 r/n。
如果超过,则增加散列表的容量(即增加桶的数量),并可能需要重新组织或分裂某些桶。
如果新增加的桶号的二进制表示为 1aa…a,则分裂桶号为 0aa…a 的桶。这通常意味着根据记录的后 i 位值(特别是从右数起的第 i 位)来重新分配记录到两个桶中。
i 值的调整:
当 n 超过 2^i 时,i+1。
3.4 多维索引
3.4.2 利用传统索引执行范围查询
在处理二维范围查询时,尽管分别为x和y坐标构建一维B-树索引可以高效定位单维度范围内的记录,但将它们用于二维查询时效率较低。因为即使每维索引能减少候选记录数量,交集操作后仍需访问大量数据块,导致磁盘I/O成本高昂。因此,在处理多维数据时,考虑使用如R树等多维索引结构以更直接地定位满足多维查询条件的记录,从而提高查询性能。
3.4.1 多维索引应用
1.部分匹配查询。我们指定一维或多维上的值并查找在这些维上匹配这些值的所有点。
2.范国查询。我们给出一维或多维上的范围并查找在这些范围内点的集合,或者在所表示的是形状时,查找出部分或全部形状在该范围内的形状的集合。
3.最近邻查询。我们查找与给定点最近的点。
4.where-am-查询。已知一个点,我们想知道该点所处的形状,若存在这样的形状的话。一个熟悉的例子是当你点击鼠标时,系统决定你点击的是哪个显示元素。
3.4.2传统索引进行范围查询
首先我们通过x的B-树索引得到在x范围内的所有记录的指针。然后,我们通过y的B-树索引得到在y范围内的所有记录的指针。最后求出这些指针的交集。I/O次数多。
3.5 多维数据的散列结构
3.5.1 网格文件
网格文件(Grid File)是一种用于多维数据索引的高效数据结构,它通过在各维度上划分空间为网格状区域,显著提升了多维数据查询的性能。这种划分允许将数据集划分为多个子空间(或“条状”),其中每个子空间由网格线界定,且网格线的数量和间隔在不同维度上可灵活设置,甚至在同一维度内也可不同,从而实现了对多维数据的高效索引和查询处理。
3.5.2 网格文件的查找
空间划分成的每一个区域可以被看成是散列表的一个桶,落人该区域的每个点的记录都存放在属于该桶的块中。如有必要,溢出块可以用来增加桶的大小。
与传统散列表中使用的一维桶数组不同,网格文件使用的桶数组的维数与数据文件的维数一样。
3.5.3 网格文件的插入
我们遵循查找记录的过程并把新记录放到查找到的桶中。如果在该桶中的块有空间,那就不需要做更多的事。如果在桶中没有空间,通常有两种方法解决这个问题:
1.按需要给桶增加溢出块。
2.通过增加或移动网格线来重组结构。这种方法类似于3.3节讨论的动态散列技术,但这里还有别的问题,因为在一个维上桶的内容是相互关联的。也就是说,增加一条网格线将分裂沿该线的所有桶。因此,要选择一条对所有桶都最优的网格线也许是不可能的。例如,要是一个太大,我们也许不知是该选择对维分裂还是对点分裂,并且不会造成许多的空桶或使某些桶太满。
3.6 多维数据的树结构
1.多键索引。
2. kd-树。
3.四叉树。
4.R-树。
前三种用于点集。R-树通常用来表示区域的集合,它也可用来表示点集。
3.6.1 多键索引
假若我们有几个属性来表示我们的数据点的维,并且我们想在这些点上支持范围查询或最近邻查询。用来访问这些点的一个简单的树模式是索引的索引,或者用更一般的话来说,是一棵树,它的每一层的结点都是一个属性的索引。
这种想法如图 所示,这是两个属性的情况,“树根”是两个属性中第一个属性的索引,它可以是任何类型的常规索引,如B-树或散列表。该索引把每一个索引键值–即第一个属性的值–同指向另一个索引的指针相关联。如果V是第一个属性的一个值,那么通过键值V和它的指针找到的索引是一个指向这些点集的索引,这些点的第一个属性值是V而第二个属性为任意值。
3.6.3 kd-树
kd-树(k维搜索树)是把二叉搜索树推广到多维数据的一种主存数据结构。我们将先介绍这种思想,然后讨论怎样使这种思想适合存储的块模型。kd-树是一个二叉树,它的内部结点有一个相关联的属性a和一个值V,它将数据点集分成两个部分:a值小于V的部分和a值大于等于V的部分。由于所有维的属性在层间交替出现,所以树的不同层上的属性是不同的。在一般的 kd-树中,数据点被存放在结点内,就像在二叉搜索树中一样。不过我们在开始引人这个思想时做了两个修改,以便获得块模式的有限益处:
1.内部结点只有一个属性,该属性的一个划分值和指向左、右子树的指针。
2.叶结点是块,块空间中存放着尽可能多的记录。
在kd-树中查找一个所有维都给定值的元组类似于在二叉搜索树中查找一个值。从根节点开始,根据当前节点的划分维度和查询点的对应维度值决定是向左子树还是向右子树递归查找,直到达到叶节点。如果叶节点中包含查询点,则查找成功;否则,查找失败。
3.6.4 kd-树的操作
插入操作
1. 查找过程:首先,执行一个类似于查找的过程,以确定新数据点应该插入的位置。这将引导我们到一个叶节点。
2. 空间检查:如果叶节点的块中还有空间(即未达到最大容量限制),则直接将新数据点放入该块中。
3. 分裂操作:如果叶节点的块已满,则需要进行分裂操作。选择当前节点的划分维度作为分裂维度,并根据该维度上的中位数(或其他合适的值)将节点中的点分为两部分,创建两个新的子节点。
4. 创建新节点:然后,创建一个新的内部节点,其左右子节点指向这两个新创建的子节点,并设置该内部节点的划分值为分裂时使用的值。
范围查询
在范围查询中,我们可能需要根据多个维度上的范围来查找数据点。例如,查找年龄在35到55之间且薪水在8100K至200K之间的所有点。
1. 从根节点开始:根据根节点的划分维度(假设是薪水)和查询范围,确定是否进入左子树、右子树或两者都进入。
2. 递归查询:在每个子节点上重复上述过程,根据当前节点的划分维度和查询范围来决定搜索路径。
3. 收集结果:当达到叶节点时,检查该节点中的点是否满足查询条件,并将满足条件的点添加到结果集中。
4. 回溯:在回溯过程中,继续检查其他可能包含满足条件点的子树。
3.6.5 使 kd-树适合辅助存储器
为了提高kd-树在辅助存储器(如磁盘)上的效率,可以通过内部结点的多分支和聚集内部结点到块这两种方法来解决长路径和未用空间的问题。多分支使得内部结点能够管理更多的键和指针,减少树的高度;而聚集内部结点到块则通过减少磁盘I/O次数来优化性能,因为一次磁盘访问可以处理多个内部结点。这两种方法共同作用,能够显著提升kd-树在处理大规模数据集时的效率。
3.6.7 R-树
R-树(区域树)是一种利用B-树的某些本质特征来处理多维数据的数据结构。B-树的结点有一个键的集合,这些键把线分成片段,沿着那条线的每个点仅属于一个片段,B-树因此使我们很容易地找到点。如果我们把沿线各处的点表示成B-树结点我们就能够确定点所属的唯一子结点,在那里可以找到该点。
而R-树表示由二维或更高维区域组成的数据,我们把它称为数据区。R-树的一个内部结点对应于某个内部区域,或称“区域”,它不是普通的数据区。原则上,区域可以是任何形状虽然实际上它经常为矩形或其他简单形状。R-树的结点的键位置上含有子区域,它表示结点的子结点的内容。允许子区域有部分重叠,尽管我们希望重叠较小。
3.7 位图索引
二进制的位数代表总共有多少数据,哪个数据中包含25,对应位为1。
第2,8个数据是25,所以25的向量为:100000001000
第四章:查询执行
查询编译预览
查询分析(Analysis)
目的:将输入的查询语句(如SQL查询)转换成一种中间表示形式,即分析树(Parse Tree)或抽象语法树(AST)。这个树形结构表示了查询的语法结构,但尚未考虑查询的逻辑或物理执行方式。
过程:词法分析器(Lexer)将查询语句分解成一系列的词法单元(tokens),如关键字、标识符、常量等;然后语法分析器(Parser)根据这些词法单元构建出分析树。
查询重写(Query Rewriting)
目的:将分析树转换为初始查询计划,这个计划通常以查询的代数表达式(如关系代数表达式)的形式表示。之后,这个初始计划会被进一步优化,以产生一个预期执行时间更短的等价查询计划。
过程:
逻辑优化:包括规则如谓词下推(Pushdown Predicates)、视图重写(View Rewriting)、选择投影合并(Select-Project Merge)等,以减少计算量或避免不必要的数据访问。
等价变换:寻找查询的代数等价形式,这些形式可能在执行时更高效。
物理计划生成(Physical Plan Generation)
目的:将逻辑查询计划转换为物理查询计划,这包括为每个逻辑操作符选择具体的实现算法(如哈希连接、嵌套循环连接等),并决定这些操作符的执行顺序。
过程:
操作符选择:为每个逻辑操作符(如选择、投影、连接等)选择最合适的物理实现算法。
执行顺序确定:根据数据分布、索引可用性等因素,确定操作符的最佳执行顺序。
数据传输策略:决定数据如何在操作之间传输,包括是否使用流水线(Pipeline)、内存缓冲区或磁盘等。
4.1 物理查询计划操作符介绍
4.1.1 扫描表
1. 表扫描(Table Scan)
表扫描是最直接的读取数据方式,它遍历表中存储的所有数据块,读取并检查每个元组(行)以找出满足查询条件的那些。这种方式的效率通常较低,特别是在处理大型表或只有少数元组满足查询条件时。然而,在以下情况下,表扫描可能是必要的或最优的:
表中数据量很小。
没有合适的索引可以利用,或者索引的使用成本(如索引维护开销)超过了直接扫描表的成本。
查询条件涉及非索引列或复杂计算,使得索引无法有效减少搜索空间。
2. 索引扫描(Index Scan)
索引扫描利用索引来快速定位满足查询条件的元组。索引是数据库管理系统用于提高数据检索效率的数据结构,它存储了表中某些列(或列的组合)的值以及这些值对应的元组在表中的位置信息。通过索引,数据库系统可以快速缩小搜索范围,只检查那些可能包含所需数据的块或页。索引扫描可以分为几种类型:
全索引扫描:遍历索引中的所有条目,但这通常比表扫描要快,因为索引通常比表小得多。
范围索引扫描:在索引中查找一个范围内的条目。
唯一索引扫描:当索引是唯一的,并且查询条件精确匹配索引中的一个值时,这种扫描非常高效。
索引扫描的优势在于其速度,尤其是在处理大型表时。然而,索引并非没有成本。索引需要额外的存储空间,并且在数据插入、删除和更新时需要维护,这可能会降低这些操作的性能。
4.1.2 扫描表时的排序
在数据库查询处理中,排序是一个常见的操作,它可能由查询中的ORDER BY子句直接触发。
使用索引进行排序
如果排序所依据的属性(如属性a)上存在B-树索引,或者关系本身就是按照该属性的索引顺序存储的,那么可以直接通过索引扫描来获取已经排序好的数据。这种方法非常高效,因为它避免了额外的排序步骤,直接利用了索引的有序性。
内存排序
如果关系R的大小足够小,能够完全装入内存,那么可以先通过表扫描或索引扫描获取R的所有元组,然后在内存中使用排序算法(如快速排序、归并排序等)对它们进行排序。内存排序的优点是速度快,但缺点是当数据集很大时,可能无法全部装入内存,从而导致溢出到磁盘,影响性能。
多路归并排序
当关系R太大,无法全部装入内存时,就需要采用外部排序的方法。多路归并排序是外部排序的一种常用技术,它将关系R分成多个可以装入内存的部分(称为“块”或“段”),对每个部分在内存中进行排序,然后将排序后的部分写入到外部存储(如磁盘)上。最后,使用多路归并算法将这些排序后的部分合并成一个完整的有序关系。
4.1.4 衡量代价的参数
**B:**当描述一个关系R的大小时,绝大多数情况下,我们关心包含R的所有元组所需的块的数目。这个块的数目表示为B®。
**T:**有时候,我们也需要知道R中的元组的数目,我们将这个数量表示为T®。
**V:**最后,我们有时候希望参考出现在关系的一个列中的不同值的数目。如果R是一个关系,它的一个属性是。那么V(R,a)是R中a对应列上不同值的数目。
4.1.6 实现物理操作符的迭代器
**1.0pen()。**这个方法启动获得元组的过程,但并不获得元组。
**2.GetNext()。**这个方法返回结果中的下一个元组
3. Close()
/注意,如果S已消耗完,s.GetNext将会返回 NotFound,对 GetNext 来说这也是正确的动作
4.2 一趟算法
我们怎样执行逻辑查询计划中的每个单独的步骤(例如,连接或选择)?关于各种操作符已提出了很多算法,
我们可以将操作符算法按照难度和代价分成三种“等级”:
a)一些方法仅从磁盘读取一次数据,这就是一趟(one-pass)算法。它们通常要求操作的至少一个操作对象能完全装人内存.
b)一些方法处理的数据量太大以至于不能装入可利用的内存,但又不是可想象的最大的数据集合。这些两趟算法的特点是首先从磁盘读一遍数据,用某种方式处理,将全部或绝大部分写回磁盘,然后在第二趟中为了进一步处理,再读一遍数据。
c)某些方法对处理的数据量没有限制。这些方法用三趟或更多趟来完成工作,它们是对两趟算法的自然的递归的推广。
操作符分为三大类:
1.一次单个元组,一元操作。我们一次可以读一个块,使用内存缓冲区,并产生我们的输出。
2.整个关系,一元操作。这些单操作对象的操作需要一次从内存中看到所有或大部分元组因此,一趟算法局限于大小约为M(内存中可用缓冲区的数量)或更小的关系。
3.整个关系,二元操作。其他所有的操作可以归为这一类:并、交、差、连接和积的集合形式以及包形式。我们将发现如果要用一趟算法,那么这类操作中的每一个都要求至少一个操作对象的大小限制在 M 以内。
4.2.1一次单个元组操作的一趟算法
我们一次读取R的一块到输人缓冲区,对每一个元组进行操作,并将选出的元组或投影得到的元组移至输出缓冲区。
如果R是聚集的,代价就是B,如果R不是聚集的,代价就是T。
B:包含R的所有元组所需的块的数目。这个块的数目表示为B®。
T:R中的元组的数目,我们将这个数量表示为T®。
4.2.2 整个关系的一元操作的一趟算法
一元操作(消除重复,分组)
消除重复
一次一个地读取R的每一块,但是对每一个元组,我们需要判定:
1.这是我们第一次看到这个元组,这时将它复制到输出。
2.我们从前见过这个元组,这时不必输出它。
为支持这个判定,我们需要为见过的每一个元组在内存中保存一个备份。用一个内存缓冲区保存R的元组的一个块,其余的M-1个缓冲区可以用来保存目前为止我们见过的每个元组的一个副本。
分组
- 对 MIN(a)或MAX(a)聚集来说,分别记录组内迄今为止见到的任意元组在属性a上的最小或最大值。每当见到组中的一个元组时,如果合适,就改变这个最小值或最大值。
- 对于任意 COUNT聚集来说,为组中见到的每个元组加1。
- 对SUM(a)来说,如果a不为NULL的话,在它的组里扫描到的累加值上增加属性a的值。
- AVG(a)的情况复杂。我们必须保持两个累计值:组内元组个数以及这些元组在a上的值的和。二者的计算分别与我们为COUNT和SUM聚集所做的一样。当R中所有元组都被扫描后,我们计算总和和个数的商以得到平均值。
4.2.3 二元操作的一趟算法(并、交、差、积和连接)
B:包 S:集合
1. 集合并(Union)
操作:合并两个集合S和R中的所有不重复元素。
实现:将S读入内存并使用查找结构(如哈希表)存储,然后遍历R的每一块,对于R中的每个元素,如果它不在S中,则将其复制到输出。
2. 集合交(Intersection)
操作:找出同时存在于S和R中的元素。
实现:同样将S读入内存并使用查找结构,然后遍历R的每一块,对于R中的每个元素,如果它也在S中,则将其复制到输出。
3. 集合差(Difference)
操作:找出存在于S但不在R中的元素(S-R),或存在于R但不在S中的元素(R-S)。
实现:对于S-R,遍历R并删除S中与R相同的元素,最后输出S中剩余的元素。对于R-S,遍历R并仅输出不在S中的元素。
4. 包交(Bag Intersection)
操作:类似于集合交,但保留元素出现的次数。
实现:为S中的每个元素维护一个计数,遍历R时,如果元素在S中存在且计数大于0,则输出该元素并减少计数。
5. 包差(Bag Difference)
操作:类似于集合差,但保留元素出现的次数。
实现:对于S-R,遍历R并减少S中对应元素的计数,最后输出计数大于0的元素及其剩余次数。对于R-S,遍历R,如果元素不在S中或S中计数为0,则输出元素(可能带计数)。
6. 积(Cartesian Product)
操作:将S和R中的每个元素组合起来。
实现:将S读入内存,然后遍历R的每一块,将R中的每个元素与S中的每个元素组合后输出。
7. 自然连接(Natural Join)
操作:基于两个关系的公共属性连接它们。
实现:将S读入内存并使用公共属性作为查找键构建查找结构,然后遍历R的每一块,对于R中的每个元素,使用查找结构找到S中与之匹配的元素,并输出连接后的结果。
4.3 嵌套循环连接
4.3.1 基于元组的嵌套循环连接
如果我们不注意关系R和S的块的缓冲方法,那么这种算法需要的磁盘I/0可能多达T®T(S)。
当我们可以使用R的连接属性上的索引来查找与给定的8元组匹配的R元组时,这样的匹配不必读取整个关系R。
执行内层循环时,要尽可能多地使用内存,以减少磁盘0的数目。
4.3.3 基于块的嵌套循环连接算法
1.对作为操作对象的两个关系的访问均按块组织。
2.使用尽可能多的内存来存储属于关系S的元组,S是外层循环中的关系。
第1点确保了当在内层循环中处理关系R的元组时,我们可以用尽可能少的磁盘I/0来读取R。
第2点使我们不是将读到的R的每一个元组与S的一个元组连接,而是与能装入内存的尽可能多的S元组连接。
σ® - 选择(Selection)π® - 投影(Projection)
♾️:连接
4.4 基于排序的两趟算法
4.4.1 两阶段多路归并排序
两阶段多路归并排序(TPMMS)是一种针对大数据集进行排序的有效方法,特别是在处理无法一次性装入内存的数据集时。
第一阶段:分割和局部排序
分割:将大数据集R分割成多个较小的部分(块),每个部分大小适中,可以单独放入内存进行排序。
局部排序:使用内存中的排序算法(如快速排序、归并排序等)对每个块进行排序,并将排序后的结果写回到外存(如硬盘)。每个块排序后形成一个有序的子表。
第二阶段:全局归并
读取和归并:从外存中读取这些有序的子表到内存中,并使用多路归并算法将它们合并成一个大的有序表。
多路归并:同时处理多个有序子表,每次从所有子表中选出最小(或最大)的元素,并将其放入结果集中。这个过程中,可能需要多次从外存读取子表数据到内存。
4.4.2 利用排序去除重复
第一趟:分割和局部排序
与TPMMS的第一阶段相同,
第二趟:去重和归并
去重:对于每个输入块,算法读取第一个未考虑的元组t,并将其复制到输出块中。然后,它检查输入块中剩余的元素,如果发现有与t相同的元素,则将它们从输入块中删除(或标记为已处理,实际删除可能不是必需的,具体取决于实现方式)。这样,每个唯一的元组只会被复制到输出块一次。
归并:由于输入块已经是有序的,因此可以确保在复制过程中,输出块中的元素也是有序的。随着输入块的读取和去重,算法会不断地将唯一的元组添加到输出块中。
性能分析
磁盘I/O:与TPMMS相同,该算法的总磁盘I/O次数也是3B®,其中B®是数据集的块数。这是因为每个块在第一趟中需要被读入和写出一次,在第二趟中可能再次被读入(用于去重和归并),并且结果可能还需要被写出(尽管在某些情况下,这个结果可能直接用于后续处理而不需要写回磁盘)。
4.4.3 利用排序进行分组和聚集
第一趟:分割、排序和写入
读取和排序:将数据集R的元组按批次读取到内存中,每次读取M个块。使用分组属性作为排序关键字,对每个内存中的块进行排序。
写入磁盘:将每个排好序的子表(块)写回到磁盘上。这样,每个子表都是按分组属性有序的。
第二趟:归并、分组和聚集
初始化缓冲区:为每个子表分配一个主存缓冲区,并将每个子表的第一个块加载到其对应的缓冲区中。
查找最小分组:在所有缓冲区的可用元组中,反复查找排序关键字(即分组属性)的最小值。这个最小值确定了下一个要处理的分组。
分组和聚集:
准备聚集:为每个新的分组准备一个空白的聚集结果列表或数据结构。
遍历和累计:遍历所有缓冲区中的元组,检查每个元组的分组属性是否与当前处理的分组相匹配。如果匹配,则根据需要对元组中的其他属性进行聚集操作(如计数、求和等)。
更新聚集结果:将每个匹配的元组的贡献累加到当前分组的聚集结果中。
替换空缓冲区:如果一个缓冲区的所有匹配元组都已处理完毕(即缓冲区为空或剩余元组不属于当前分组),则用同一子表中的下一个块替换它(如果有的话)。
输出分组和聚集结果:当不再有排序关键字为当前分组的元组时,输出一个包含分组属性和对应聚集值的元组。
重复:重复步骤2-5,直到所有分组都被处理完毕。
4.4.4 基于排序的并算法
第一趟:创建排序子表
将关系R和S的元组分别读取到内存中,并使用排序关键字(通常是元组本身,因为并集操作不需要特定的排序属性)进行排序。
将排好序的元组块写回到磁盘上,形成排序子表。这些子表将用于第二趟的归并操作。
第二趟:归并和输出
为R和S的每个子表分配一个内存缓冲区,并用每个子表的第一块数据初始化这些缓冲区。
重复地在所有缓冲区中查找并比较剩余的第一个元组t。将t复制到输出缓冲区(或直接输出到最终结果中,如果输出缓冲区已满或接近满),并从所有包含t的缓冲区中删除t的副本(在集合的情况下,每个元素至多有两个副本,分别来自R和S)。
当输入缓冲区变空或输出缓冲区变满时,根据TPMMS算法的策略进行处理:从磁盘上读取新的块到输入缓冲区,或将输出缓冲区的内容写回磁盘,并清空输出缓冲区以继续接收新数据。
4.4.5 基于排序的交和差算法
集合与包交算法
集合交:当处理到所有缓冲区中最小的元组t时,如果t同时在关系R和S的缓冲区中都存在,则输出t。
由于是集合操作,不考虑t在R和S中出现的次数,只关心是否存在。
包交:对于包交,需要计算t在R和S中出现的最小次数,并输出该次数。
如果t在任一关系中未出现(即计数为0),则不输出t。
集合与包差算法
集合差 R-S:当处理到所有缓冲区中最小的元组t时,如果t在R的缓冲区中存在但在S的缓冲区中不存在,则输出t。由于是集合操作,不关心t在R中出现的具体次数。
包差 R-S:对于包差,需要计算t在R中出现的次数减去在S中出现的次数,并输出该差值。如果差值小于或等于0(即t在S中出现的次数至少等于在R中出现的次数),则不输出t。当处理一个块且该块中所有剩余的元组都是t时,需要继续读取下一个块来计算t的准确出现次数。
4.4.6 基于排序的一个简单的连接算法
给定两个要连接的关系R(X, Y)和S(Y, Z),其中Y是连接属性,且有一个大小为M的内存块限制用于缓冲区。算法的目标是计算这两个关系的连接结果。
1. 排序:
○ 使用Y作为排序关键字,对关系R和S分别应用2PMMS(两趟多路归并排序)算法进行排序。排序后,具有相同Y值的元组在各自的关系中会相邻。
2. 归并和连接:
○ 使用两个缓冲区,一个用于存储R的当前块,另一个用于存储S的当前块。
○ 重复执行以下步骤,直到R和S的所有元组都被处理完:
a. 在当前R和S的块的前端查找连接属性Y的最小值y。
b. 如果y在另一个关系的前部没有出现(即,当前R块的最小Y值大于当前S块的最大Y值,或反之),则删除当前块中具有排序关键字y的元组(或跳过它们,因为它们无法参与当前连接),并尝试从磁盘加载下一个块到相应关系的缓冲区中。
c. 如果y在两个关系中都出现,则:
■ 从R和S中读取尽可能多的块,直到确定每个关系中都不再有y的副本。在这个过程中,可以使用多达M个缓冲区来存储具有相同Y值的元组。
■ 输出通过连接R和S中具有共同的Y值y的元组所能形成的所有元组。这通常涉及嵌套循环遍历具有相同Y值的元组对。
d. 如果一个关系在内存中已没有未考虑的元组(即,其缓冲区为空),则重新从磁盘加载该关系的下一个块到缓冲区中。
4.4.8 一种更有效的基于排序的连接
排序阶段:
使用连接属性Y作为排序关键字,分别对关系R(X, Y)和S(Y, Z)进行排序。
将排序后的数据分割成多个子表(或块),每个子表的大小通常受限于内存中的可用缓冲区大小。这里假设总共有不超过M个子表,每个子表可以被完全加载到内存中。
初始化缓冲区:
为每个子表分配一个缓冲区(总共不超过M个缓冲区),并将每个子表的第一块加载到对应的缓冲区中。
归并和连接阶段:
重复执行以下步骤,直到所有子表都被完全处理:
a. 在所有子表的当前块中查找具有最小Y值的元组。
b. 一旦找到具有最小Y值y的元组,就识别出关系R和S中所有具有此Y值的元组。
c. 使用找到的元组进行连接操作,并输出所有可能的连接结果。这通常涉及到对两个关系中具有相同Y值的元组进行嵌套循环遍历。
d. 如果一个子表的当前块中的所有元组都已被处理,则将该子表的下一个块从磁盘加载到其对应的缓冲区中。如果所有块都已被处理,则可能需要重新排序或合并剩余的子表块。
4.5 基于散列的两趟算法
基于散列的算法在处理大数据集时提供了一种有效的方法来减少内存使用并提高操作效率。这些算法通过选择适当的散列关键字,将大量数据分散到有限数量的桶(或称为哈希桶)中,从而允许算法一次处理一个桶或具有相同散列值的一对桶。
4.5.1通过散列划分关系
初始化:
设定散列函数h,该函数将关系R中的整个元组t作为输入,并输出一个介于0到M-1之间的整数,表示桶的索引。
准备M个缓冲区(或磁盘上的块),每个缓冲区对应一个桶。注意,实际上只有M-1个桶用于存储数据,而第M个缓冲区用作临时存储,以便在最后一个桶填满时可以继续加载数据。
遍历关系R:
遍历关系R中的每个元组t。使用散列函数h计算元组t的桶索引h(t)。将元组t复制到对应桶的缓冲区中。
缓冲区管理:
如果某个桶的缓冲区已满,将该缓冲区的内容写入磁盘上的相应块中。
为该桶初始化一个新的缓冲区,以便继续存储后续的元组。
处理最后一个桶:
在遍历完关系R的所有元组后,最后一个桶(可能包括第M个缓冲区中暂存的元组)可能并未填满。如果最后一个桶的缓冲区不为空,将其内容写入磁盘上的相应块中。
4.5.2 基于散列的消除重复算法
**散列划分:**使用散列函数h将关系R的元组划分到M-1个桶中。每个桶与一个缓冲区或磁盘块相关联,用于存储散列到该桶的元组。如果某个桶的缓冲区满了,就将其内容写入磁盘上的一个新块,并为该桶初始化一个新的缓冲区。
**写入磁盘:**遍历完关系R后,将所有非空桶的缓冲区内容写入磁盘上的块中。
**重复消除:**对每个桶中的数据进行重复消除。由于每个桶的大小相对较小(假设每个桶都能装入内存),因此可以使用一趟算法(如4.2.2节中讨论的方法)来去除重复元组。
优点:
减少了内存使用,因为每个桶都可以独立地装入内存处理。
提高了处理速度,因为可以并行处理多个桶。
适用于大数据集,因为可以通过增加桶的数量来扩展处理能力。
4.5.3 基于散列的分组和聚集算法
**散列划分:**使用依赖于分组属性的散列函数h将关系R的元组划分到M-1个桶中。每个桶收集具有相同散列值的元组,这些元组在分组属性上可能相同或相似。
写入磁盘:
如果某个桶的缓冲区满了,就将其内容写入磁盘上的一个新块,并为该桶初始化一个新的缓冲区。最终,所有非空桶的内容都需要被写入磁盘,以便后续处理。
**分组和聚集:**遍历每个桶,对每个桶内的元组进行分组。由于桶的大小可能较大,但分组是基于散列值的,因此同一分组的所有元组理论上应该都在同一个桶内。
对每个分组应用聚集函数(如求和、平均值、最大值、最小值等)。
合并结果:
将来自不同桶的聚集结果合并成一个完整的结果关系。这通常不需要额外的磁盘I/O,除非结果关系本身太大而无法完全装入内存。
4.5.4 基于散列的并(∪)、交(∩)和差(-)算法
**散列划分:**使用相同的散列函数h将关系R和S的元组分别散列到M-1个桶中。这意味着对于R,我们有桶R₀, R₁, …, R_{M-1};对于S,我们有桶S₀, S₁, …, S_{M-1}。
**写入磁盘:**如果某个桶的缓冲区满了,就将其内容写入磁盘上的一个新块。最终,所有非空桶的内容都需要被写入磁盘,以便后续处理。
执行集合操作:
并(∪):对于每个i,计算桶R_i和S_i的并集,并将结果输出到结果关系的相应桶中。注意,由于使用了相同的散列函数,任何在两个关系中都出现的元组都会被散列到相同的桶中,从而避免了在结果中引入重复。
交(∩):对于每个i,计算桶R_i和S_i的交集,并将结果输出到结果关系的相应桶中。
差(-):对于每个i,计算桶R_i中但不在S_i中的元组(即R_i - S_i),并将结果输出到结果关系的相应桶中。注意,如果还需要计算S - R,则需要额外处理。
4.5.5 散列连接算法
为了计算关系R(X, Y)和S(Y, Z)的连接,我们可以使用基于散列的两趟算法。这个算法的核心思想是利用连接属性Y作为散列关键字,将R和S的元组分别散列到多个桶中。然后,对每个对应的桶对执行一趟连接操作,最终将所有桶的连接结果合并起来得到最终的结果关系。
**散列划分:**使用连接属性Y作为散列关键字,将R和S的元组分别散列到M个桶中。结果得到桶R_0, R_1, …, R_{M-1}和桶S_0, S_1, …, S_{M-1}。
**写入磁盘:**将每个桶的内容写入磁盘,以便后续处理。
**执行连接操作:**对每对对应的桶(R_i 和 S_i)执行一趟连接操作。由于连接属性Y被用作散列关键字,因此如果R中的元组t_R能与S中的元组t_S连接,则它们必然位于具有相同i值的桶中。在一趟连接过程中,算法会读取桶R_i和S_i的内容到内存中,执行连接操作,并将结果输出到结果关系的相应部分。
**合并结果:**将来自不同桶的连接结果合并成一个完整的结果关系。这通常涉及读取每个桶的连接结果,并将它们组合起来。
**磁盘I/O:**散列连接算法大致需要3(B® + B(S))次磁盘I/O操作,其中B®和B(S)分别是关系R和S的块数。这些操作包括读取R和S的块到桶中、将桶写入磁盘(如果必要)、以及读取桶对以执行连接操作。
4.5.6 节省一些磁盘 I/O(混合散列连接)
桶的分配与内存使用:
假设有两个关系R和S,其中S较小。我们创建k个桶,但k的数量远小于可用的内存M(以块为单位)。选择m个桶(m < k)完全保留在内存中,以存储S的元组。这些桶的选择基于它们预期的大小和内存的限制。对于剩下的k-m个桶,每个桶只保留一个块在内存中,其余部分写入磁盘。
第一趟处理:
当读取S的元组时,将它们分配到相应的桶中。m个桶完全保留在内存中,而k-m个桶的剩余部分写入磁盘。
当读取R的元组时,也进行类似的分配。但此时,对于R的每个桶,如果它对应的S桶在内存中,则直接进行连接;如果S桶在磁盘上,则将R桶的当前块写入内存中的临时空间,以便后续处理。
第二趟处理:
读取所有写入磁盘的桶(包括S和R的桶),并对它们进行连接。重要的是,对于那些在第一趟中已经在内存中完成连接的S桶和R桶对,不需要在第二趟中再次连接。
节省磁盘I/O的关键
减少寻道时间和旋转延迟:通过将多个块连续写入磁盘,可以减少磁盘的寻道次数和旋转延迟,从而提高I/O效率。
避免不必要的读写:通过在内存中直接连接部分桶,可以避免将这些桶的内容写入磁盘后再读取的额外I/O
要最大化节省的磁盘I/O,需要使m/k的比率尽可能大,同时满足内存限制。
基于排序和基于散列的区别
大小依赖性的差异:
基于散列的算法在处理二元操作时(如比较、合并等),其性能或空间需求通常依赖于两个操作对象中较小的一个的大小。这是因为散列通常通过映射较小的数据元素到固定的桶(或位置)中来工作,避免了直接处理整个数据集的大小。
相比之下,基于排序的算法通常需要处理整个数据集,或至少是数据集的一个显著部分,其性能往往与数据集的总大小直接相关。
有序输出的能力:
基于排序的算法天然能够产生有序的结果集,这对于需要排序输出的应用非常重要。排序后的数据还可以用于进一步的查询或分析,如二分查找、范围查询等。
基于散列的算法则不直接提供排序功能,其输出通常是无序的。如果需要排序,则必须额外进行排序操作。
桶大小的限制:
基于散列的算法依赖于固定大小的桶来存储数据。如果数据分布不均,可能导致某些桶过载而其他桶空闲,影响性能。此外,桶的数量通常受限于可用的存储空间或内存大小。
特别是在处理具有少量不同值的场景(如group-by操作中的少量分组)时,桶的利用效率可能较低。
磁盘I/O效率:
在基于排序的算法中,通过合理组织磁盘上的数据块,可以最小化磁盘I/O操作,提高性能。特别是当排序后的数据可以连续存储在磁盘上时,可以减少磁头移动和旋转延迟时间。
基于散列的算法在磁盘I/O方面可能不那么高效,除非能够精心组织桶的存储位置以减少磁盘访问时间。
批量读写优化:
当排序子表的数量远小于可用内存块(M)时,基于排序的算法可以通过批量读写磁盘块来减少I/O操作次数和延迟。
类似地,在基于散列的算法中,如果桶的数量可以精心选择以匹配磁盘块的布局,则可以实现类似的性能优化。
4.6 基于索引的算法
通过在关系的一个或多个属性上创建索引,可以极大地加速数据检索、选择和连接等操作。基于索引的算法利用这些索引来快速定位数据,从而显著提高查询效率。
4.6.1聚簇和非聚簇索引
聚簇(Clustering)
在数据库管理系统中,聚簇通常指的是将表中的数据物理地存储在一起,以便基于某个特定的顺序或属性来优化查询性能。当关系的元组被紧缩到能存储这些元组的尽可能少的块中时,我们称这个关系是被聚簇的。聚簇可以是基于整个表(即整个关系)的,也可以是基于表中某个或某些属性的。
聚簇索引(Clustered Index)
聚簇索引是一种特殊的索引,它不仅定义了表中数据的逻辑顺序,还决定了数据的物理存储顺序。在具有聚簇索引的表中,索引键的值决定了数据行在磁盘上的物理位置。这意味着,当你通过聚簇索引来查询数据时,数据库可以直接定位到数据的物理位置,并快速读取数据。
非聚簇索引(Non-Clustered Index)
与聚簇索引不同,非聚簇索引不定义表中数据的物理存储顺序。相反,它提供了一个索引结构,该结构包含索引键和指向表中相应数据行的指针。这意味着,当你通过非聚簇索引来查询数据时,数据库首先使用索引来快速定位到数据行的指针,然后通过该指针访问实际的数据行。
4.6.2 基于索引的选择
聚簇索引下的选择操作
当属性a上的索引是聚簇的时,数据本身就是按照索引键(即属性a的值)的顺序存储的。这意味着,如果我们想要选择所有a=v的元组,我们可以直接通过索引快速定位到这些元组在磁盘上的位置,并读取它们所在的块。
然而,实际的磁盘I/O次数可能会受到几个因素的影响:
索引不在内存中:索引本身可能不完全驻留在内存中,因此可能需要从磁盘读取索引块。
块分裂:即使所有满足条件的元组都可以放入少量的块中,这些元组也可能因为块分裂而分布在多个块中,特别是如果它们不是从块的开始位置开始的。
块填充和预留空间:关系R的块可能没有被完全填满,或者为了未来的插入操作而预留了空间。这会导致需要读取更多的块来检索所有相关的元组。
整数取整:如果B®/V(R,a)不是整数,我们需要向上取整以确保所有相关的块都被读取。
非聚簇索引下的选择操作
在非聚簇索引的情况下,索引仅仅提供了指向数据行指针的列表,而数据本身并不是按照索引键的顺序物理存储的。因此,当我们通过非聚簇索引查找满足a=v的元组时,每个匹配的元组都可能位于不同的块中。这导致我们需要读取更多的块来检索所有相关的元组,因为索引只能告诉我们每个元组的位置,而不能保证它们会聚集在一起。
4.6.3 使用索引的连接
自然连接 R(X, Y) ⨝ S(Y, Z)
在自然连接中,我们假设两个关系R和S分别具有属性集X和Y的交集,以及各自的额外属性集(Y对于R,Z对于S)。连接操作基于这些共同的属性(在这个例子中是Y)来合并元组。
索引的使用
假设S在属性Y上有一个索引。这个索引可以极大地加速连接过程,因为它允许我们快速定位S中所有与R中当前元组在Y属性上相匹配的元组。
磁盘I/O数量的分析
读取R的元组:
如果R是聚簇的,我们需要读取B®个块来获取R的所有元组。
如果R不是聚簇的,可能需要读取多达T®个块(即R中元组的总数)。
对于R中的每个元组,查找S中的匹配元组:
使用S在Y属性上的索引,我们可以快速定位到S中所有Y值匹配的元组。
如果索引是非聚簇的,那么对于R中的每个元组,我们平均需要读取T(S)/V(S,Y)个S的元组(这里V(S,Y)是S中Y属性不同值的数量)。因此,总的磁盘I/O次数大约为T®T(S)/V(S,Y)。
如果索引是聚簇的,那么我们可以直接通过索引访问到物理上连续存储的元组,从而减少磁盘I/O。在这种情况下,我们大约需要T®B(S)/V(S,Y)次磁盘I/O(这里假设每个块可以容纳多个具有相同Y值的元组)。注意,如果B(S)/V(S,Y)小于1,则至少需要一个磁盘I/O来读取包含至少一个匹配元组的块。
4.6.4 使用有序索引的连接
当索引以有序形式(如B-树索引)存在时,我们可以利用这些索引来优化连接操作。有序索引允许我们直接按照排序顺序遍历关系中的元组,而无需显式地对整个关系进行排序,从而节省了大量的计算资源和时间。
Zig-Zag 连接
当两个关系R(X, Y)和S(Y, Z)在共同的属性Y上都拥有有序的索引时,我们可以采用一种高效的连接方法,称为Zig-Zag连接。这种方法的核心思想是同时遍历R和S的索引,并比较当前元组的Y值,以找到所有匹配的元组对。
**初始化:**设置两个指针,一个指向R的索引的起始位置,另一个指向S的索引的起始位置。
**比较与遍历:**比较两个指针当前指向的元组的Y值。如果Y值相等,则这两个元组构成一个连接对,输出它们,并将两个指针都向前移动。如果R的Y值小于S的Y值,则将R的指针向前移动。如果R的Y值大于S的Y值,则将S的指针向前移动。重复上述步骤,直到至少一个索引被完全遍历。
4.7 缓冲区管理
4.7.1 缓冲区管理结构
1. 直接控制内存的缓冲区管理器
在这种模式下,DBMS的缓冲区管理器直接管理内存中的缓冲区,负责分配和回收内存空间给这些缓冲区。
2. 使用虚拟内存的缓冲区管理器
DBMS的缓冲区管理器在虚拟内存中分配缓冲区,而不是直接管理物理内存。操作系统负责将虚拟内存映射到物理内存,并根据需要在物理内存和磁盘的交换空间之间移动数据块。
4.7.2 缓冲区管理策略
最近最少使用(LRU)
先进先出(FIFO)
“时钟”算法(第二次机会)
4.8 使用超过两趟的算法
4.8.1 基于排序的多趟算法
当关系R的大小(以块数B®衡量)可以直接装入M个内存缓冲区时:
读取数据:将整个关系R读入内存。
排序:在内存中使用任何高效的排序算法(如快速排序、归并排序等)对R进行排序。
写回磁盘:将排序后的关系R写回到磁盘上。
当关系R的大小超过了M个内存缓冲区能够容纳的范围时:
分组:将R的块分成M个组,即R₁, R₂, …, Rₘ。
递归排序:对每个分组Rᵢ(i=1,…,M)递归地应用排序算法。这意味着每个分组都会被读入内存、排序,然后写回磁盘。
合并排序的子表:使用类似于外部归并排序的方法,将所有M个排序后的子表合并成一个大的有序表。这个步骤可能需要多趟读写操作,具体取决于内存大小和关系R的块数。
执行一元操作
如果需要在排序的同时执行一元操作(如去重δ或分组γ),则需要在合并步骤中适当修改算法:
去重(δ):在合并过程中,仅当遇到新的元组时才将其写入输出文件,忽略重复的元组。
分组(γ):首先按分组属性对关系进行排序,然后在合并过程中收集具有相同分组属性值的元组,并根据需要进行聚合操作(如求和、平均等)。
执行二元操作
对于二元操作(如交、连接等),算法也遵循类似的分而治之策略:
分组和排序:对两个关系R和S分别进行分组和排序,生成排序后的子表。
分配缓冲区:根据R和S的块数(B®和B(S))按比例分配M个缓冲区给R和S。
执行操作:使用适当的算法(如嵌套循环连接、归并连接等)来合并排序后的子表,并执行所需的二元操作。这个步骤可能需要多次从磁盘读取和写入数据,具体取决于操作类型和缓冲区大小。
4.8.3 基于散列的多趟算法
一元操作
如果关系R能够装入M个内存缓冲区中(即B® ≤ M),则直接将R读入内存,并在内存中执行所需的一元操作。如果关系R太大,无法装入M个内存缓冲区,则执行以下步骤:
散列到桶中:使用散列函数将关系R的元组散列到M-1个桶中。
递归处理:对每个桶递归地执行一元操作。
合并结果:将所有桶的处理结果合并成一个最终的结果集。
二元操作
如果有一个关系(比如R)能够装入M-1个内存缓冲区中,而另一个关系(比如S)可以逐个块地装入剩下的第M个缓冲区,则可以直接在内存中执行连接操作。具体地,将R读入内存,然后逐个块地将S读入第M个缓冲区,并在内存中执行连接。
如果两个关系都太大,无法同时装入内存,则执行以下步骤:
散列到桶中:将两个关系R和S分别散列到M-1个桶中。对于每个桶i(i=1,…,M-1),R和S都有一个对应的桶R[i]和S[i]。
递归处理:对每个相应的桶对(R[i], S[i])递归地执行连接操作。注意,这里的递归处理与一元操作类似,但操作的是桶对而不是单个关系。
合并结果:将所有桶对的连接结果合并成一个最终的结果集。这个合并过程可能需要额外的排序和归并步骤,以确保最终结果的顺序性(如果需要的话)。
第五章 查询编译器
5.1 语法分析和预处理
5.1.1 语法分析与语法分析树
语法分析树的构成
原子:语法分析树中的叶子节点,对应于源代码中的不可再分的基本元素,如关键字、标识符(如变量名、表名、列名)、常量、标点符号(如括号、逗号)、运算符等。这些元素是词法分析器(Lexer)的输出,词法分析器负责将源代码文本分割成这些基本的词法单元(Tokens)。
语法类:语法分析树中的非叶子节点,代表语法结构中的构造块或“短语”,这些短语由一组原子或更小的语法类通过特定的语法规则组合而成。这些节点用于表示语言中的语法结构,如表达式、语句、程序块等。在表示时,通常会使用尖括号(< >)来标识语法类的名称,以便于区分原子和语法类。
:整个查询的根节点,表示一个完整的SQL查询。
:表示一个SELECT语句,可能包括选择列表、FROM子句、WHERE子句等。
:包含要选择的列名或表达式的列表,如name, age。
:表示对表中某列的引用,如name和age(尽管在这里它们直接作为原子出现,但在更 复杂的表达式中,它们可能是的实例)。
:表示FROM子句,指定查询的数据来源,如FROM users。
:表示表名,如users。
:表示WHERE子句,包含用于过滤结果的条件,如WHERE age > 30。
:表示一个条件表达式,如age > 30。这里可以进一步细分,比如表示比较操作,表示值(尽管在这个例子中30被直接视为原子)。
SELECT name, age FROM users WHERE age > 30;
该查询的语法分析树可能包含以下节点:
<Query>:根节点,表示整个查询。
<SelectList>:包含name和age的列表。
name(原子)
age(原子)
<FromClause>:包含users。
users(原子)
<WhereClause>:包含条件表达式。
<Condition>:表示条件表达式age > 30。
age(原子)
>(原子)
30(原子)
5.1.2 SQL 的一个简单子集的语法
存在一些“基本”或“终端”语法类(有时也称为词法单元或标记),它们不是通过其他语法规则组合而成的,而是直接对应于源代码中的基本元素。这些元素在语法分析阶段之前就已经被词法分析器识别出来。
通常代表数据库表中的列名。在SQL查询中,这些列名用于指定要从表中检索哪些数据。在语法分析树中,的一个实例就是任何当前数据库模式中存在的有效列名。例如,在表employees中,name、age和salary都可能是的实例。
代表数据库中的一个表或视图。在SQL查询中,FROM子句后面通常会跟有一个或多个,以指定查询将要从哪些表中检索数据。同样,在语法分析树中,的一个实例就是任何当前数据库模式中存在的有效表名或视图名。例如,employees、departments和orders都可能是的实例。
在SQL中通常与字符串匹配和搜索相关,尽管在标准的SQL查询中直接使用的情况可能不如和那么普遍。但是,在像LIKE这样的SQL语句中,用于指定要匹配的字符串模式。的一个实例是任何被引号括起来的、符合SQL字符串匹配规则的字符串。例如,在LIKE语句中,'John%'可能是一个的实例,用于匹配以"John"开头的任何字符串。
5.1.3预处理器
视图引用的预处理
当查询语句中引用的关系实际上是一个视图时,预处理器需要将该视图的定义嵌入到原始查询中。
语义检查
确保查询语句在逻辑上是合理的。包括检查关系、属性、类型和运算符的使用是否满足数据库规则。
检查关系的使用:
验证FROM子句中指定的每个关系(表或视图)在当前数据库模式中是存在的。
检查属性的使用:
确保在SELECT子句、WHERE子句等中引用的每个属性都属于当前查询范围内的一个关系。
类型的检查:
验证每个属性的使用是否符合其数据类型的要求。例如,在WHERE子句中使用LIKE运算符时,比较的两边通常应该是字符串或可以隐式转换为字符串的类型。如果使用了不兼容的类型(如将日期类型直接与字符串进行比较),预处理器将需要执行类型转换。
5.2 用于改进查询计划的代数定律
本节列出一些代数定律,用于将一个表达式树转换成一个等价的表达式树,后者可能有更有效的物理查询计划。应用这些代数变换式的结果是逻辑查询计划,它是查询重写阶段的输出。
数据库系统之:关系代数详解-超详细-CSDN博客
选择(σ):选择运算用于从关系中选择满足特定条件的元组。选择运算的表达式通常表示为σp®,其中p是选择条件,R是关系名。
投影(π):投影运算用于从关系中选择指定的属性列,并删除重复的元组。投影运算的表达式通常表示为πA®,其中A是属性列表,R是关系名。
消除重复(δ):当δ运算符应用于一个关系时,它会检查关系中的每个元组,并移除所有重复的元组。这意味着在结果关系中,每个元组都是唯一的。
笛卡尔积:笛卡尔积是指两个集合(在数据库操作中,可以理解为两个表)的所有可能组合。如果表A有M行,表B有N行,那么A和B的笛卡尔积将有M*N行。每一行都是由表A中的一行与表B中的一行组合而成。不考虑表之间的实际关系,简单地将一个表中的每一行与另一个表中的每一行进行组合。
自然连接:自然连接是一种特殊的等值连接,它自动根据两个表中具有相同名称的列来连接这两个表。如果两个表中存在多个具有相同名称的列,则这些列都会被用作连接条件。连接后,结果表中只保留一个公共列,并去除不满足连接条件的行。根据两个表中具有相同名称的列来连接这两个表,只保留满足连接条件的行,并且结果表中只保留一个公共列。
等值连接(E Inner Join):
等值连接是基于两个或多个表中指定列的值相等进行连接的连接操作。与自然连接不同,等值连接需要显式指定连接条件,即哪些列的值应该相等。连接结果中包含满足连接条件的所有行和列,不会自动去除重复的列。
SELECT * FROM 表A INNER JOIN 表B ON 表A.ID = 表B.ID;
5.2.1 交换律与结合律
交换律
对于某些运算符,其操作数的顺序可以互换而不影响结果。并集(∪)、交集(∩)和笛卡尔积(×)等运算符满足交换律。
并集(∪):对于任意两个关系R和S,如果它们有相同的结构(相同的列名),则R ∪ S = S ∪ R。
交集(∩):对于任意两个结构相同的关系R和S,R ∩ S = S ∩ R。
笛卡尔积(×):虽然笛卡尔积通常用于不同结构的关系之间,但如果我们考虑两个关系R和S(不必有相同的结构),则R × S和S × R的结果在逻辑上是不同的(因为结果关系中的元组顺序和属性排列会不同)。然而,在关系代数的上下文中,我们通常只关注元组的内容而不关注其顺序,因此可以认为笛卡尔积在“无序”的意义上满足交换律。
结合律
并集(∪)、交集(∩)和笛卡尔积(×)等运算符也满足结合律。
并集(∪):对于任意三个结构相同的关系R、S和T,(R ∪ S) ∪ T = R ∪ (S ∪ T)。
交集(∩):(R ∩ S) ∩ T = R ∩ (S ∩ T)。
笛卡尔积(×):对于任意两个关系R和S,以及另一个关系T(T的结构可以与R和S不同),(R × S) × T 和 R × (S × T) 在逻辑上是不等价的(因为结果关系的结构和元组数会不同)。但是,如果我们考虑将笛卡尔积的结果视为一个单一的关系,并仅关注元组的内容(而不考虑其内部结构),则可以认为笛卡尔积满足结合律。
5.2.2 涉及选择的定律
“下推选择”(Pushing Selections Down)是一种重要的优化策略,旨在通过重新排列查询计划中的操作顺序来减少处理的数据量,从而提高查询效率。
下推选择是指在构建查询执行计划时,将选择(或称为过滤)操作尽可能地移动到查询计划的早期阶段,即靠近数据源(如表扫描或索引扫描)的位置。这样做的目的是减少后续操作需要处理的数据量,从而提高查询的整体性能。
1.对于并,选择必须下推到两个参数中。
2.对于差,选择必须下推到第一个参数,下推到第二个参数是可选的。
3.对于其他运算符,只要求选择下推到其中一个参数。对于连接和积,将选择下推到两个参数是没有意义的,因为参数可能有也可能没有选择所要求的属性。即使可以同时下推到两者该做法也不一定能改进计划。选择条件可能依赖于两个表之间的交互数据,而这些数据在连接或积操作之前是不可见的。
假设关系R具有C中要求的全部属性:
5.2.3 下推选择
当查询包含虚视图时,有时首先将选择尽可能往树的上部移是很必要的,然后再把选择下推到所有可能的分支。
**选择前置:**原始查询中的选择条件(即电影年份为1996年)实际上可以前置到连接操作之前。我们可以先对Movies表进行过滤,只选择1996年的电影,然后再与StarsIn表进行连接。
**选择下推:**在将选择前置后,我们可以将这个选择条件进一步下推到连接的子节点上。因为year是Movies表的属性,也是连接操作需要考虑的属性,所以我们可以将这个条件分别应用于Movies表和StarsIn表。连接操作只需要处理与1996年电影相关的StarsIn记录,从而显著减少了处理的数据量。
5.2.4 涉及投影的定律
投影的下推
当投影操作可以下推到其他操作(如连接、排序等)中时,可以优化查询的执行计划。投影操作只是减少了每个元组的长度,对总处理量的减少作用有限。因此,在选择和投影都能下推的情况下,优先选择下推选择操作往往更为有效。
扩展投影
扩展投影是投影操作的一个扩展,它允许在投影的过程中不仅选择原始关系中的属性,还可以计算新的属性或表达式作为输出。在扩展投影中,输出属性x可能是一个复杂的表达式,该表达式可以包含输入属性E中的属性、常量或函数运算等。
E->x
输入属性:在投影操作中,E中提到的属性称为输入属性,它们是原始关系中的属性,用于计算或选择输出属性。
输出属性:在投影或扩展投影中,x是输出属性,它可能是原始关系中的一个属性,也可能是通过输入属性计算得到的新值。
简单投影:如果投影列表中的属性只是简单地选择原始关系中的属性,没有包含复杂的表达式或更名操作,则称该投影为简单投影。
5.2.6 有关消除重复的定律
5.2.7 涉及分组与聚集的定律
运算符 γ (Gamma): γ 表示聚集运算符,它可以对一组数据进行某种形式的聚合操作,比如求和、计数、平均、最小值或最大值等。
运算符 δ (Delta): 表示去重操作,即从一组数据中删除重复的记录,只保留唯一的记录。
运算符 π (Pi): 是一个选择操作符,用于从一组数据中选择特定的列或属性。
L(属性列表):通常与聚集操作相关联。在聚集函数的表达式中,“L” 指的是聚集操作所应用的属性列表。
M(属性列表):通常用于投影操作。在关系代数中,投影操作(π)用来从关系中选择特定的列或属性。表示从关系 R 中选择属性列表 M 中的所有属性。“M” 在聚集操作中的作用是确保在进行聚集之前,关系中包含所有需要的属性。
聚集运算符的通用定律: δ(γL®) = γL®,意味着先进行聚集操作,然后去重,与先去重再进行聚集操作的结果是相同的。这是因为聚集操作本身不关心数据中的重复项。
投影去除无用属性: γL® = γL(πM®) 表示,在进行聚集操作之前,我们可以选择性地去除那些在聚集列表 L 中没有被使用的属性 M。这是因为这些属性对于最终的聚集结果没有影响。
不受重复影响的聚集: 当聚集列表 L 中只包含 MIN 或/和 MAX 函数时,我们称这个聚集操作是不受重复影响的。这是因为 MIN 和 MAX 函数的结果是不会因为数据中的重复项而改变的。因此,可以有 γL® = γL(δ®),即无论数据是否有重复,最终的聚集结果都是相同的。
受重复影响的聚集: 与不受重复影响的聚集相反,像 SUM、COUNT、AVG 这样的聚集函数,其结果会受到数据中重复项的影响。如果在计算这些聚集之前去除重复项,最终的值可能会不同。
5.3 从语法分析树到逻辑查询计划
第一步:使用关系代数运算符替换语法树
关系代数是一组抽象的操作,用于对关系(即表)进行查询和修改。这些操作包括选择(σ)、投影(π)、连接(⨝)、并(∪)、差(-)、笛卡尔积(×)等。
表引用:直接映射为关系代数中的关系(即表)。
选择条件:转换为选择(σ)操作,用于过滤满足条件的元组。
投影:将SELECT子句中的字段名转换为投影(π)操作,用于选择特定的属性。
连接:根据JOIN条件(如A.dept_id = B.dept_id),转换为连接(⨝)操作,合并两个或多个关系。
第二步:优化关系代数表达式
在得到逻辑查询计划(即关系代数表达式)后,下一步是优化它以生成更有效的物理查询计划。
查询重写:通过等价变换(如改变连接顺序、合并选择操作等)来减少计算量。
索引利用:识别并利用索引来加速查询执行。
并行处理:如果可能,将查询分解为可以并行执行的部分。
5.3.1 转换成关系代数
您“简单”SELECT-FROM-WHERE结构转换为关系代数表达式的规则。
处理FROM列表:
将FROM列表中提及的所有关系(即表)视为关系代数中的基本关系。如果FROM列表中包含了多个表,并且这些表之间需要通过某种方式(如JOIN条件)相关联,则首先计算这些表的笛卡尔积。
处理WHERE条件:
使用选择(σ)运算符来过滤满足WHERE子句中条件的元组。将WHERE子句中的条件表达式转换为关系代数中的选择条件。
处理SELECT列表:
使用投影(π)运算符来选择SELECT列表中指定的属性。投影运算符作用于经过选择和连接(如果有的话)处理后的关系上。
5.3.2 从条件中去除子查询
5.3.3 逻辑查询计划的改进
** 选择条件下推**
选择条件下推是一种将选择(过滤)操作尽可能地向查询计划树的底部(即数据源)移动的优化策略。当查询中包含多个连接的表时,将选择条件尽早应用于较小的数据集可以减少后续操作的数据量,从而提高查询效率。对于AND连接的多个条件,可以分别将每个条件下推到树的相应位置。
投影下推
投影下推是另一种优化策略,它将投影(即选择列)操作尽可能地向查询计划树的底部移动。这有助于减少在数据传输和处理过程中所需的数据量。
重复消除
重复消除是指移除查询结果中的重复行。在某些情况下,这个操作可以被移动到查询计划树中更方便的位置,以减少需要处理的数据量。
选择与积的结合
在某些情况下,可以将选择条件与下面的连接操作结合,将等值条件的选择操作转换为等值连接。这通常可以提高查询的效率,因为等值连接通常可以更有效地利用索引和连接算法。
5.4 运算代价的估计
在将逻辑查询计划转换为物理查询计划的过程中,有四个方面值得关注。
1. 满足结合律与分配律的运算的次序与分组
结合律:对于满足结合律的运算(如连接、并、交),其操作对象的次序可以改变而不影响最终结果。在物理计划中,我们可以重新排列这些运算的次序,以最小化数据移动、减少中间结果的大小或利用系统的并行处理能力。
分配律:在某些情况下,我们可以利用类似分配律的性质来优化查询。例如,将选择(过滤)操作“分配”到连接操作之前,以减少需要连接的数据量。
分组:将多个可结合的运算组合成一个更大的运算单元,可以减少运算符之间的数据交换次数,提高执行效率。例如,将多个连接操作组合成一个复合连接操作。
2. 逻辑计划中每个运算符的算法选择
在逻辑计划中,我们定义了要执行的运算,但在物理计划中,我们需要选择实现这些运算的具体算法。例如,对于连接操作,我们可以选择嵌套循环连接、散列连接或排序合并连接等算法。
3. 物理计划中的其他运算符
扫描:物理计划需要明确如何从存储介质(如磁盘)中读取数据。这通常涉及扫描操作,可以是全表扫描或索引扫描。
排序:在物理计划中,排序操作可能是显式的(如ORDER BY子句)或隐式的(如连接操作前的排序)。排序算法的选择和数据的物理布局都会影响排序操作的效率。
4. 参数和数据的传输方式
在物理计划中,我们需要考虑数据如何在不同的运算符之间传输。这包括确定中间结果的存储位置(如内存、磁盘)以及传输机制(如迭代器、批量传输)。使用磁盘上的中间结果可以减少内存的使用,但会增加I/O成本。使用迭代器可以逐条处理数据,减少内存占用,但可能增加CPU的开销。
5.4.1 中间关系大小的估计
主要关注查询执行过程中产生的临时结果(即中间关系)的元组数或数据量。这是评估查询性能的一个重要方面,因为中间关系的大小直接影响了查询所需的存储空间、I/O操作次数以及后续运算的复杂度。
5.4.2 投影运算大小的估计
对于关系 R:
每个元组大小:12(元组头)+ 4(a)+ 4(b)+ 100(c)= 120字节。
块中元组数:(1024−24)/120=8(向下取整)。
元组总数 T®=10000,因此块数 B®=10000/8=1250。
对于关系 S=π + ®(其中 + 表示扩展投影,用 a 与 b 的和替代 a 和 b):
新的元组结构包括:12(元组头)+ 4(a+b的和,假设结果仍为整数)+ 100(c)= 116字节。
尽管元组大小略有减少,但由于仍然可以存放8个元组在一个块中,所以块数和元组总数保持不变:T(S)=10000,B(S)=1250。
对于关系 U=π a,b ®(即传统的去除 c 的投影):
新的元组结构包括:12(元组头)+ 4(a)+ 4(b)= 20字节。
由于元组大小显著减小,现在每个块可以存放更多的元组:(1024−24)/20=50(向下取整)。
因此,虽然元组总数仍然是 T(U)=10000,但块数大大减少:B(U)=10000/50=200。
5.4.3 选择运算大小的估计
等值比较:
当查询条件为某个属性等于某个常量时(如 S = σA=c®),那么结果集的大小可以通过 T(S) = T® / V(R, A) 来估计,其中 T® 是表 R 的元组数,V(R, A) 是属性 A 的不同取值的数量。这个假设在属性值均匀分布时较为准确。
非等值比较:
对于非等值比较(如 S = σA<c® 或 S = σA>c®),由于条件的非确定性,结果集的估计更加复杂。一种假设是认为满足条件的元组大约占总元组数的一半,然而涉及非等值比较的查询倾向产生更小量的元组因此我们估计: T(S) = T® / 3。
特殊比较:
对于某些特殊的选择条件,如检查某个属性是否非空(A IS NOT NULL),如果大多数元组都满足这个条件(即空值较少),则可以假设所有元组都满足条件,即 T(S) = T®。或者,更精确地,可以通过 T(S) = T® * (V(R, A) - 1) / V(R, A) 来估计。
5.4.4 连接运算大小的估计
值集的包含:
如果一个属性Y同时出现在关系R和S中,且R的Y值集合是S的Y值集合的子集(即V(R,Y) ≤ V(S,Y)),那么R中的每个Y值都会在S中出现。这个假设在Y是S的键且是R的外键时成立,但在其他情况下可能不成立。
值集的保持:
在进行连接时,非连接属性(即只出现在一个关系中的属性)不会丢失其值集中的任何值。这意味着,如果A是R的属性但不是S的属性,那么V(R∞S, A) = V(R, A)。这个假设在大多数情况下是合理的,但也可能在某些特殊情况下不成立。
概率计算与结果集大小估计
如果V(R,Y) > V(S,Y),那么S中每个Y值都会出现在R中,因此s的Y值出现在R中的概率为1,但r和s具有相同Y值的概率为1/V(R,Y)(因为R中有V(R,Y)个可能的Y值)。
如果V(R,Y) < V(S,Y),则情况相反,r的Y值会出现在S中,r和s具有相同Y值的概率为1/V(S,Y)。
为了统一这两种情况,我们可以将概率估算为1/max(V(R,Y), V(S,Y))。
基于上述概率,我们可以估算连接结果集T(R∞S)的大小:T(R∞S)= T®×T(S)/max( V(R,Y),V(S,Y) )
5.4.5 多连接属性的自然连接
当连接R(X,Y)xS(Y,Z)中的属性集Y包含多于一个属性时
R⋈S的大小估计是通过T®乘以T(S),对于每一个R与S的公共属性y,除以V(R,y)与V(S,y)中较大者来计算。
假设我们有两个关系R(X, Y)和S(Y, Z),其中Y是两个关系的共同属性集,并且Y包含多个属性(y1, y2, …, yn)。我们可以尝试使用以下方法来近似计算自然连接结果集的大小:
5.4.6 多个关系的连接
在考虑多个关系(表)的自然连接时,特别是当某个属性A出现在多个关系中时,我们需要分析这些关系在连接后如何影响结果集的大小以及属性A上值的分布。以下是对您提出的问题的详细解答:
假设有 k 个关系 _R_1,_R_2,…,Rk,它们通过自然连接合并成一个新的关系 S,即 S=R_1⋈_R_2⋈…⋈_Rk。属性 A 出现在这 k 个关系中的至少两个关系中。
属性A上值的分布:
假设属性 A 在 k 个关系中的值集大小分别为 _u_1,_u_2,…,uk,且已按从小到大的顺序排列,即 u_1≤_u_2≤…≤_uk。
值集保持假设:在连接后的关系 S 中,属性 A 的值集大小将是这些 ui 中的最小值,即 _u_1。
所选元组在属性A上相同的概率:
假设首先从具有最小 _u_1 的关系 _R_1 中选择一个元组 _t_1,其属性 A 的值为 a。
对于其他关系 Ri(其中 i=2,3,…,k),所选元组 ti 在属性 A 上与 _t_1 相同的概率是 _ui_1(因为 Ri 中有 ui 个不同的 A 值)。
因此,所有 k 个元组在属性 A 上相同的概率是这些概率的乘积,即 1/_u_1_u_2…uk。
估计连接后的大小:
一个近似的估计方法是:对于每个这样的属性 A,从乘积中除以除了 V(Ri,A) 中的最小值(即 _u_1)之外的所有 V(Ri,A) 的较大值的乘积
5.4.7 其他运算大小的估计
并
包的并:结果的大小正好是参数关系大小之和。
集合的并:结果的大小介于两参数大小之和到两参数中较大者之间。建议取中间值,如较大者加上较小者的一半。
交
结果的大小可以从0个元组(无交集时)到两参数中较小者(完全重叠时)之间变化。建议取两极端的平均值,即较小值的一半。
差
当计算 R_−_S 时,结果中的元组数可以从 T(R)(S 为空时)到 T(R)−_T_(S)(S 完全包含在 R 中时)之间变化。建议估计值取其平均值:T(R)−_T_(S)/2。
消除重复
如果 R(_a_1,_a_2,…,an) 是一个关系,则去重后 δ(R) 的大小理论上等于 R 中不同元组的数量。由于这个统计值通常不可得,需要取近似值。极端情况下,去重后的大小可以是 T(R)(无重复元组时)或1(所有元组都相同时)。另一个上限是可能存在的不同元组的最大数,即所有 V(R,ai)(i=1,2,…,n)的乘积。然而,这个数可能远大于 T(R) 的实际值。一个合理的估计是取 T(R)/2 与所有 V(R,ai) 之积中的较小者。
分组(GROUP BY)与聚集
对于分组操作 γL(R)(其中 L 是分组属性列表),结果中的元组数与分组数相同。
分组数的范围可以从1(所有元组在分组属性上都相同)到 T(R)(每个元组在分组属性上都是唯一的)。
与去重类似,可以使用所有 V(R,A)(其中 A 是 L 中的属性)的乘积作为分组数的上界。建议的估计值是 T(R)/2 与这个乘积中的较小者。
5.5 基于代价的计划选择介绍
5.5.1 大小参数估计值的获取
元组数和不同值数目的获取
元组数(T®):通过对整个关系R进行一次扫描,可以简单地计算出关系中的元组数。
不同值数目(V(R,A)):通过扫描关系R并跟踪每个属性A的不同值,可以计算出该属性列的不同值数目。
块数(B®)的估计 : 如果关系R是聚簇存储的,那么它所占用的块数(B®)可以通过实际使用的块数来计数。如果关系不是聚簇存储的,或者需要更粗略的估计,则可以通过将元组数(T®)除以一个磁盘块可以容纳的R的元组个数来估算块数。
直方图的计算
等宽直方图:将属性值的范围划分为等宽的区间,并计算每个区间内的元组数。如果遇到新的更小的值,可能需要调整区间的边界。
等高直方图:基于百分比来划分区间,例如列出最小值、比最小值多p%的值、比最小值多2p%的值等,直到最大值。
最频值直方图:列出最常见的值及其出现次数,同时可能包含其他值的分组统计。
基于直方图的连接大小估计
使用直方图可以更准确地估计连接操作的结果大小。特别是当连接属性的值显式地出现在两个关系的直方图上时,可以精确地知道结果中有多少元组将具有该值。
5.5.2 统计量的计算
周期性更新的原因
稳定性:数据库中的统计量在短时间内通常不会发生剧烈变化,因此没有必要频繁更新。
一致性:即使统计量不是绝对精确的,只要它们被一致地应用于所有查询计划,优化器仍然能够进行有效的比较和选择。
性能考量:频繁更新统计量会将统计量本身变成数据库中的“热点”,因为它们会被频繁读取和写入,这会影响数据库的整体性能。
取样大小:取样的元组数量需要根据统计精度和计算成本之间的平衡来确定。例如,可以选择关系R的1%作为样本。
5.5.4 枚举物理计划的方法
选择最优的物理查询计划:
**穷尽法:**穷尽法是一种最直接但可能最耗时的方法,它尝试所有可能的物理计划组合,并对每个计划进行代价估计,最终选择代价最小的计划。这种方法适用于小型数据库或查询,但对于大型数据库和复杂查询来说,其计算量将变得不可接受。
自顶向下与自底向上方法
自顶向下(Top-Down):从逻辑查询计划树的根部开始,逐级向下考虑每个运算符的可能实现,并计算每种组合的代价。这种方法在每一步都需要评估所有可能的子计划,并可能导致大量的重复计算。
自底向上(Bottom-Up):从逻辑查询树的叶子节点(即基本关系)开始,逐级向上计算每个子表达式的最优计划,并在构建较大子表达式的计划时只考虑较小子表达式的最优计划。这种方法通常与动态规划相结合,以减少重复计算并提高效率。
**动态规划:**动态规划是自底向上方法的一个变种,它通过保存子问题的解来避免重复计算。对于每个子表达式,动态规划方法只保留其最小代价的计划,并在构建更大子表达式的计划时利用这些已保存的最优解。这种方法虽然不保证总是找到全局最优解,但在许多情况下都能获得很好的结果。
**Selinger 风格的优化:**是对动态规划方法的一种改进。它不仅记录了每个子表达式的最小代价计划,还记录了那些具有较高代价但结果顺序对上层运算符有用的计划。这种方法利用了查询计划中的某些特性(如排序、分组和连接属性),以期望从非最优的子表达式计划中构建出整体最优的查询计划。
启发式选择:基于一系列启发式规则来选择物理计划。这些规则通常基于经验或统计信息,旨在快速找到近似最优的查询计划。例如,如果连接的一个参数在连接属性上有索引,则采用索引连接;如果需要对多个关系进行并或交操作,则先对最小关系进行组合等。启发式选择方法通常比穷尽法更快,但可能无法找到全局最优解。
**分支界定计划枚举:**通过启发式方法找到一个好的初始物理计划,并以此为基准来剪枝搜索空间。在搜索过程中,任何代价高于当前已知最优计划的计划都将被放弃,因为它们不可能成为更优的完整查询计划的一部分。这种方法可以显著减少搜索空间的大小,并提高搜索效率。
爬山法:从一个初始的物理计划开始,通过不断地对计划进行小的修改(如替换运算符的实现方法、重新排序连接等)来寻找代价更低的邻近计划。当无法再通过小修改来降低计划的代价时,算法停止并返回当前的计划作为最优解。这种方法可能陷入局部最优解,但通常能够快速找到一个可接受的解决方案。
5.6连接顺序的选择
5.6.1 连接的左右参数的意义
**一趟连接:**通常选择较小的关系作为左参数(构造用关系),并将其加载到内存中,构建成一个哈希表。这个哈希表使得数据查找变得非常高效。然后,将较大的关系(右参数,探查用关系)分批读入内存,并将每个元组与哈希表中的元组进行匹配,从而执行连接操作。这种策略利用了较小的关系能够完全加载到内存中并构建高效索引的优势。
嵌套循环连接:左参数通常被选作外部循环关系。这意味着查询优化器会遍历左参数中的每一个元组,并将其与右参数中的所有元组进行比较,以查找匹配项。如果左参数较小,则外部循环的开销相对较小,这有助于提升整体性能。如果左参数很大,则可能需要考虑其他连接策略。
索引连接:右参数通常被认为是有索引的关系。这意味着在连接过程中,查询优化器会利用右参数的索引来快速定位匹配的元组,而不是遍历整个关系。因此,选择拥有有效索引的关系作为右参数可以显著提高连接操作的效率。索引连接特别适用于当右参数很大,但可以通过索引快速访问其特定部分时。
5.6.3 左深连接树
左深树
浓密树
右深树
左深树的数量与所有树的数量
对于给定数目的关系(即树叶),所有可能的树形结构(包括非左深树和左深树)的数量是巨大的,特别是随着关系数量的增加,这个数量呈指数级增长。相比之下,左深树作为这些所有可能树形结构的一个子集,其数量虽然也很大,但增长速度要慢得多。这是因为左深树的结构限制了树的形状,使得每个节点最多只能有一个右子节点(且该右子节点可以是另一个连接或关系),从而减少了可能的组合方式。
左深树与连接算法的交互
一趟连接:左深树使得优化器能够选择较小的关系作为构建哈希表的关系(即左子树),而将较大的关系或连接结果作为探查的关系(即右子树)。这种安排可以最大化哈希表的效率,因为哈希表可以一次性构建并用于多个连接操作。
嵌套循环连接:在嵌套循环连接中,左深树允许优化器将较小的关系放在外层循环(即左子树),而将较大的关系或连接结果放在内层循环(即右子树)。这样可以减少内层循环的迭代次数,从而提高整体连接的效率。
**左深树中的运算符:**左深树或右深树中的树叶(即基本关系)不仅可以是简单的关系,还可以是包含其他运算符(如选择、投影)的内部节点。这意味着在构建查询计划时,优化器可以灵活地将这些运算符应用于连接的输入或输出,以进一步减少处理的数据量并优化查询性能。
5.6.4 通过动态规划来选择连接顺序和分组
1. 定义状态和问题
在动态规划中,我们首先定义状态。在这个场景下,状态可以定义为包含一定数量表的集合,以及这些表之间可能的连接顺序和成本。目标是找到包含所有目标表的一个集合的最优连接顺序,使得总成本最小。
2. 构建代价表
代价表(或称为DP表)用于存储中间结果,即对于每个可能的表集合,其最优连接顺序及其对应的成本。这个表通常通过归纳法来填充。
基础情况:对于只包含一个表的集合,其最优连接顺序就是该表本身,成本为0(因为没有进行连接)。
归纳步骤:对于包含多个表的集合,我们考虑所有可能的子集划分,并计算每种划分下的连接成本。我们选择成本最小的划分作为该集合的最优连接顺序。
3. 考虑左深树与其他树形
左深树:在只考虑左深树的情况下,连接顺序的选择相对简单。对于集合R中的k个表,我们尝试将R划分为R-i和{i}(其中i是R中的一个表),并计算R-i与i连接的成本。我们选择成本最小的划分作为最优解。
所有可能的树形:如果考虑所有可能的树形,问题变得更加复杂。我们需要考虑所有可能的子集划分,并计算每种划分下将子集连接起来的成本。这通常涉及更复杂的成本函数和更多的计算。
4. 计算成本和表达式
对于每种划分,我们需要计算两个主要值:
连接成本:这是将两个子集连接起来的成本,通常基于它们的大小、索引使用情况、以及可能的公共属性等因素。
结果大小:这是连接操作后结果集的大小,用于后续连接的成本计算。
5. 表达式构建
在找到最优连接顺序的同时,我们还需要构建相应的表达式,这个表达式描述了表之间的连接顺序。在左深树的情况下,表达式就是表的顺序;在更一般的情况下,表达式可能包括嵌套的连接操作。
6. 应用到实际查询
最终,我们得到的代价表和相应的表达式可以被用来优化实际的数据库查询。通过选择成本最低的连接顺序,数据库管理系统可以减少查询的执行时间,提高查询效率。
5.6.5 带有更具体的代价函数的动态规划
仅基于关系大小来估计连接成本可能会忽略一些重要的实现细节,如索引的使用、磁盘I/O成本等。为了更准确地估计连接成本,我们可以对动态规划算法进行修改,以纳入更具体的代价函数,如磁盘I/O成本。在动态规划算法中,我们可以修改代价的计算方式,以考虑实际的连接成本。这通常涉及以下几个方面:
磁盘I/O成本:对于每个关系,我们需要知道其数据在磁盘上的存储方式(如是否索引),以及访问这些数据所需的磁盘I/O次数。
连接算法:不同的连接算法(如嵌套循环连接、排序合并连接、散列连接)具有不同的成本特性。我们需要根据关系的特性和可用索引来选择最合适的连接算法。
中间结果的大小:连接操作的结果大小也会影响后续操作的成本。较大的中间结果可能需要更多的磁盘空间,并增加后续操作的I/O成本。
**Selinger风格优化:**是一种更全面的查询优化方法,它不仅考虑连接操作的成本,还考虑查询结果的排序和存储顺序。这种方法对于生成高效的查询计划特别有用,特别是当查询结果需要按照特定顺序返回给用户时。在Selinger风格优化中,对于每个可能被连接的关系集合,我们不仅计算连接的最小成本,还考虑生成以几个“感兴趣”的顺序中的任意一个顺序存储的关系的最小成本。这些“感兴趣”的顺序可能包括:
对后续排序连接有利的顺序:如果查询计划中包含排序连接,那么生成已经排序的中间结果可能会减少后续操作的成本。
用户期望的输出顺序:如果查询结果需要按照特定顺序返回给用户,那么生成符合这一顺序的中间结果可能会减少最终排序的成本。
5.6.6 选择连接顺序的贪婪算法
在数据库查询优化中,特别是在处理多表连接(JOINs)时,选择最优的连接顺序对于提高查询效率至关重要。当连接的表数量增多时,可能的连接顺序组合呈指数级增长,这使得穷尽搜索所有可能的连接顺序变得不现实。因此,启发式算法如贪婪算法成为了一个实用的选择。
贪婪算法在连接顺序选择中的应用
贪婪算法在选择连接顺序时,遵循局部最优原则,即每一步都选择当前看来最好的选择,从而希望导致全局最优解。
**初始化:**选择一个或多个基础表(通常是较小的表或者索引良好的表)作为起始点。初始化一个空的连接树,这个树开始时只包含选定的基础表。
**迭代选择:**在所有尚未被添加到当前连接树中的表中,选择一个表与当前连接树的根(或某个节点)进行连接,这个选择基于某种估计的成本或大小(如基于统计信息的行数估计)。将选中的表添加到连接树中,形成新的连接树结构。
5.7 物理查询计划选择的完成
1. 算法选择
在逻辑查询计划转换为物理查询计划的过程中,需要为各个操作选择合适的算法。
2. 中间结果的物化和流水操作
物化:物化是指将查询的中间结果完全存储在磁盘上。这种方式在内存不足或需要重用中间结果时非常有用。然而,物化会增加磁盘IO操作,降低查询效率。
流水操作:流水操作是指在执行查询时,中间结果只在内存中临时存储,不保存到磁盘上。这种方式可以减少磁盘IO,但要求系统有足够的内存来支持所有中间结果的存储。
5.7.1 选取一个选择方法
分析不同算法的代价
1. 表扫描算法与过滤器步骤结合
代价估计:如果R被聚集(Clustered):B®,其中B®是关系R的数据块数量。聚集意味着数据在物理存储上按某个特定属性(通常是主键或索引键)的顺序排列,但这对于表扫描的代价估计通常不直接影响,因为表扫描仍需检查所有块。
如果R没有被聚集:T®,这里T®可能表示关系R中所有元组的总数,但在I/O代价的上下文中,更准确地应该是T®除以每块能容纳的元组数,但通常简化为B®,即数据块的数量,因为I/O操作以块为单位进行。
2. 利用等值索引扫描
代价估计:如果索引是聚集的:B®/V(R,a),其中V(R,a)是属性a在关系R中的唯一值数量(或近似值)。聚集索引意味着数据本身就是按照索引的顺序存储的,因此可以更快地定位到满足条件的元组。但代价估计中仍然考虑了整个关系的数据块数量,因为即使索引是聚集的,也可能需要遍历多个块来找到所有匹配的元组。
如果索引不是聚集的:T®/V(R,a)。非聚集索引不包含数据本身,只包含指向数据块的指针,因此需要额外的I/O来检索实际的数据行。但在这个简化的估计中,我们假设通过索引可以快速定位到包含所需数据的块。
3. 利用不等值索引扫描
如果索引是聚集的:B®/3.9。这个估计试图反映不等值查询可能需要检查比等值查询更多的块,因为不等值条件可能匹配多个连续的数据块。然而,这个数字3.9是一个高度简化的假设,实际中可能需要更复杂的模型来准确估计。
如果索引不是聚集的:T®/3。对于非聚集索引,不等值查询同样需要额外的I/O来检索数据行,但代价估计的具体数字取决于索引和数据的实际情况。
5.7.3 /4流水操作与物化
流水操作:流水操作是一种更有效的方法,它允许同时交错进行多个运算,减少了磁盘I/O的需求。在流水操作中,一个运算产生的结果元组直接传递给需要它的运算,而不需要将中间结果存储在磁盘上。这种方法通常由迭代器网络执行,迭代器网络中的迭代器在适当的时候互相调用,实现数据的流动。
**一元流水:**一元流水操作是指涉及单个输入源的操作,如选择(Selection)和投影(Projection)。在这种操作中,消费者(即需要结果的查询部分)通过调用迭代器的GetNext()
方法来请求下一个元组。
**二元流水:**二元运算涉及两个输入参数,如连接(Join)或合并(Union)操作。二元运算的结果也可以进行流水操作。我们使用一个缓冲区将结果传递给消费者,一次一块。然而,计算结果和消费结果所需的其他缓冲区数目是不同的,它们取决于结果的大小以及参数的大小。我们将使用一个扩展的例子来演示折中和机会。
5.7.7 物理运算的排序
物理查询计划树的分解与执行
树的分解:当查询计划以树的形式表示时,我们可以通过物化(即存储中间结果)来分解这棵树。物化意味着在树中的某些节点处,将中间结果存储在磁盘上,以便后续运算使用。这样做可以将复杂的查询计划分解成一系列较小的、更易于管理的子树。
子树的执行顺序:在物化策略下,子树的执行顺序通常是按照从下到上、从左到右的前序遍历顺序进行的。这种顺序确保了每个子树在其依赖的子树完成执行后才能开始执行,从而保证了数据的正确性和完整性。
迭代器网络与流水操作
对于采用流水操作(也称为流水线操作)的物理查询计划,迭代器网络是实现这一策略的关键。迭代器网络由一系列相互连接的迭代器组成,每个迭代器代表查询计划中的一个运算。迭代器之间通过调用GetNext等方法来传递数据,从而实现了数据的直接传递和连续处理,而无需将中间结果存储在磁盘上。
迭代器网络中的事件顺序
在迭代器网络中,事件的顺序是由各个迭代器之间的交互和调用关系决定的。具体来说,当一个迭代器需要数据时,它会调用其上游迭代器的GetNext方法。这个调用会触发上游迭代器执行必要的运算,并将结果返回给下游迭代器。通过这种方式,整个查询计划中的运算被同步地执行,而事件的确切顺序则是由这些调用关系决定的。
查询优化与执行代码生成
基于上述策略,查询优化器可以为给定的查询生成相应的执行代码。这些代码通常是一系列函数调用的序列,它们按照预定的顺序执行查询计划中的各个运算。通过这种方式,数据库系统能够高效地处理查询请求,并返回准确的结果。
第6章系统故障对策
日志:是支持数据可恢复性的基础技术。日志以安全的方式记录了数据库中所有变更的历史,包括数据的增、删、改操作。
日志类型
- Undo 日志:记录了如何将数据库从当前状态回滚到某个之前的状态。主要用于处理事务的撤销操作,确保在事务失败或需要回滚时,数据库能够恢复到事务开始前的状态。
- Redo 日志:记录了如何将数据库从某个旧状态重新应用更改以恢复到当前状态。在系统故障后,可以使用这些日志来重做所有未完成的更改,以恢复数据库的最新状态。
- Undo/Redo 日志:结合了上述两种日志的特性,既能够回滚也能够重做数据库的操作,提供了更灵活的恢复策略。
Undo日志和Redo日志在数据库系统中各有其独特的作用和应用场景。Undo日志主要用于事务的回滚操作,确保事务的原子性和一致性;而Redo日志则主要用于系统的恢复和故障处理过程,确保数据库在崩溃或故障后能够恢复到一致的状态。两者共同协作,为数据库系统的可靠性和稳定性提供了重要保障。
**检查点技术:**减少恢复过程中需要检查的日志量,提高恢复效率。
工作原理:定期在数据库系统中创建一个检查点,该点表示此时刻数据库的状态是一致的。在检查点时刻,系统会记录当前所有事务的状态(如已提交、未提交等)和日志信息的位置。如果系统发生故障,恢复过程只需要从最近的检查点开始,而不是从头开始检查日志,从而大大减少了恢复所需的时间和资源。
6.1 可恢复操作的问题和模型
6.1.1 故障模式
1. 错误数据输入
:::danger
错误数据输入可能由人为因素(如键盘输入错误)或系统错误引起。这些错误可能难以被自动检测,特别是当错误数据在格式上仍然符合规则时(如电话号码中的一位数字错误)。
:::
应对措施
编写约束:通过数据库管理系统提供的约束功能(如NOT NULL、UNIQUE、CHECK等),限制输入数据的类型和范围,确保数据在逻辑上的一致性。
触发器:使用触发器在数据被插入、更新或删除时自动执行特定的检查或操作,以识别并处理潜在的数据错误。
2. 介质故障
:::danger
介质故障包括磁盘的局部故障(如单个扇区损坏)和全局故障(如磁头损坏导致整个磁盘不可访问)。
:::
应对措施:
奇偶校验:利用与磁盘扇区相关联的奇偶校验来检测并纠正局部故障。
RAID技术:通过配置RAID(独立磁盘冗余阵列)来提高数据的可用性和容错性。RAID可以通过数据条带化、镜像、校验等方式来减少单个磁盘故障对数据完整性的影响。
备份:定期创建数据库的完整或增量备份,并将备份存储在远离主数据库的安全位置。这样,在发生介质故障时,可以通过恢复备份来恢复数据。
分布式冗余拷贝:在多个物理节点上保存数据库的冗余拷贝,以提高系统的可靠性和容错性。同时,需要维护这些拷贝之间的一致性,确保数据的准确性。
3. 灾难性故障
:::danger
灾难性故障包括数据库所在物理环境的完全毁坏,如火灾、爆炸或恶意破坏,导致所有数据介质同时失去作用。
:::
备份和冗余:与介质故障相似,但备份需要更加频繁和全面,以确保在灾难发生后能够恢复尽可能多的数据。
分布式冗余拷贝:将数据库的冗余拷贝分布在不同地理位置的多个节点上,以减少单一地点灾难对系统的影响。
- 系统故障
:::danger
系统故障通常指的是导致事务状态丢失的问题,如掉电或软件错误。由于内存是易失性的,掉电会导致主存中的数据丢失,包括事务的当前状态和修改结果。
:::
日志记录:使用非易失性的日志来记录所有对数据库的更新操作。这样,在系统故障后,可以通过日志来恢复事务的状态和数据库的一致性。
恢复机制:开发复杂的恢复机制,确保日志记录能够在不受故障干扰的情况下进行。这些机制通常包括日志的持久化、事务的原子性保证以及恢复算法等。
6.1.2 关于事务的进一步讨论
事务是执行数据库操作的基本单位,它确保了数据的一致性和完整性。
事务的四个基本特性(ACID特性):
原子性(Atomicity):事务中的所有操作要么全部完成,要么全部不执行,不能存在部分执行的情况。
一致性(Consistency):事务执行的结果必须使数据库从一个一致性状态转变到另一个一致性状态。
隔离性(Isolation):并发执行的事务之间互不干扰,每个事务都独立运行,如同没有其他事务在并发执行一样。
持久性(Durability):一旦事务被提交,它对数据库的修改就是永久性的,即使系统发生故障也不会丢失。
日志管理器负责维护日志,确保事务的日志记录能够正确地存储在磁盘上。由于日志记录最初是存储在内存中的,因此它们需要在适当的时候被拷贝到磁盘上,以确保数据的持久性。日志管理器与缓冲区管理器交互,以管理日志记录的存储和检索。
恢复管理器在数据库系统崩溃时被激活。它检查日志记录,并使用这些记录来恢复数据库到一致的状态。恢复管理器可能需要回滚未提交的事务,或者重做已提交但尚未写入磁盘的事务,以确保数据库的完整性和一致性。
6.1.3 事务的正确执行
数据库元素与状态
数据库元素(如关系、磁盘块/页、元组等)是数据库操作的基本单位。选择磁盘块或页作为数据库元素的原因在于,它们通常是数据库管理系统(DBMS)中数据读写操作的最小单位。这样做可以简化事务日志和恢复机制的设计,因为每次事务操作都可以针对完整的磁盘块进行记录,从而避免了部分数据写入导致的复杂问题。
数据库状态的一致性
数据库状态的一致性不仅要求满足数据库模式中的显式约束(如键约束、值约束等),还需要满足隐式约束。隐式约束可能来源于业务逻辑、数据完整性规则或用户界面的限制等。确保数据库状态的一致性对于维护数据的完整性和可靠性至关重要。
事务的正确性原则
如果事务在没有其他任何事务和系统错误的情况下执行,并且在它开始执行时数据库处于一致的状态,那么当事务结束时数据库仍然处于一致的状态。
6.1.4 事务的原语操作
1. INPUT(X)
INPUT(X) 操作负责将包含数据库元素 X 的磁盘块读取到主存中的一个缓冲区中。
2. READ(X, t)
READ(X, t) 操作首先检查包含数据库元素 X 的块是否在主存缓冲区中。如果不在,则执行 INPUT(X) 将其加载到缓冲区。然后,将缓冲区中 X 的值赋给事务的局部变量 t。
3. WRITE(X, t)
WRITE(X, t) 操作首先检查包含数据库元素 X 的块是否在主存缓冲区中。如果不在,则执行 INPUT(X)。然后,将事务局部变量 t 的值写入缓冲区中 X 的位置。
4. OUTPUT(X)
OUTPUT(X) 操作负责将包含数据库元素 X 的缓冲区中的块写回到磁盘上。这个操作是确保数据持久性的关键步骤。
6.2 undo 日志
Undo 日志:记录了如何将数据库从当前状态回滚到某个之前的状态。主要用于处理事务的撤销操作,确保在事务失败或需要回滚时,数据库能够恢复到事务开始前的状态。
6.2.1 日志记录
日志记录类型的详细解释
1. 记录
含义:这个记录表示事务T已经开始执行。它标记了事务在日志中的起始点,有助于在恢复过程中识别哪些事务可能尚未完成。
作用:在恢复过程中, 记录用于确认事务的存在和开始时间。虽然它本身不直接参与恢复操作,但它是事务日志完整性的重要组成部分。
2. 记录
含义:这个记录表示事务T已成功完成,并且其对数据库的所有修改都应该是永久的。然而由于缓冲区管理器的行为,这些修改可能尚未写入磁盘。
作用:在恢复过程中, 记录是确定哪些事务应该被视为成功完成的依据。然而,由于磁盘写入的不确定性,恢复管理器可能需要额外的步骤来确保所有已提交事务的修改都被正确地写入磁盘。
3. 记录
含义:这个记录表示事务T由于某种原因(如内部错误、死锁、超时等)不能成功完成。
作用:在恢复过程中, 记录用于标识需要回滚(撤销)的事务。恢复管理器将使用undo日志中的更新记录来回滚这些事务所做的所有修改,以确保数据库的一致性。
4. 更新记录 <T, X, v>
含义:这个三元组表示事务T修改了数据库元素X,而X的原始值是v。它记录了事务对数据库的具体修改。
作用:更新记录是undo日志的核心。在恢复过程中,如果事务T被中止(即出现记录),恢复管理器将使用这些更新记录来将数据库元素X的值恢复到它们在事务T开始之前的状态。这样,中止事务对数据库的影响就被消除了。
undo日志的局限性:undo日志仅记录旧值,不记录新值。这意味着它只能用于回滚事务,而不能用于重做已提交事务的修改。
6.2.2 undo 日志规则
规则U1:如果事务T修改了数据库元素X,那么表示这一修改的日志记录(形如<T, X, v>,其中v是X的旧值)必须在X的新值被写入磁盘之前写入磁盘。这条规则确保了即使在系统崩溃时,也有足够的信息来撤销事务T对X所做的修改,从而恢复数据库的一致性。
规则U2:如果事务提交,那么它的COMMIT日志记录()必须在事务改变的所有数据库元素都已被写入磁盘之后写入磁盘,但应尽快。这条规则确保了提交操作是持久的,即一旦事务被提交,其修改就不会因为系统故障而丢失。
6.2.3 使用 undo 日志的恢复
恢复过程
撤销(Undo)未提交的事务:对于那些在崩溃时尚未提交的事务,恢复管理器需要撤销它们所做的所有修改,以确保这些事务不会留下任何不一致的状态。
重做(Redo)可能丢失的已提交事务的修改:如果某些已提交事务的修改因为系统崩溃而未能完全写入磁盘,恢复管理器需要重做这些修改。
识别事务状态
已提交事务:这些事务的记录已经写入日志。它们的修改被认为是有效的,不需要撤销。
未提交事务:这些事务的记录存在,但记录不存在。它们的修改需要被撤销,以恢复数据库到一致状态。
撤销未提交事务的修改
恢复管理器从日志的末尾开始扫描,向前移动。
当遇到<T, X, v>记录时,如果尚未扫描到记录,则意味着事务T未提交,需要撤销对X的修改。恢复管理器会将X的值改回日志中记录的修改前的值。
如果已经扫描到记录,则不对该事务的修改进行任何操作。
处理事务结束
在完成所有必要的撤销操作后,恢复管理器会为每个未完成的事务T写入记录到日志中,并刷新日志,以确保这些记录被持久化。
6.2.4 检查点
检查点机制通过定期地记录数据库和日志文件的状态,来确保在系统崩溃后能够快速地恢复到最近的一个一致状态。
- 停止新事务。
- 等待所有活跃事务完成:这包括等待所有事务提交或中止,并在日志中记录COMMIT或ABORT。这一步确保了在检查点时刻,所有未完成的事务状态都是已知的。
- 刷新日志到磁盘:将内存中的日志记录强制写入磁盘,确保日志的持久性。
- 写入记录:在日志中写入一个特殊的检查点记录,表明此点之后的所有事务都是在此检查点之后开始的。
记录用于标记检查点(Checkpoint)的完成。在记录之后的所有事务都是在当前检查点之后开始的。记录包含了以下关键信息:
时间戳:记录了检查点完成的具体时间点,这对于后续的恢复操作至关重要。
状态信息:可能包含了数据库在检查点时刻的特定状态信息,如活跃事务列表、已提交事务的日志位置等,这些信息有助于恢复过程快速定位到正确的恢复起点。
日志位置:在某些实现中,记录还可能包含当前日志文件的写入位置或检查点相关的日志序列号(LSN),这有助于恢复过程确定从哪些日志记录开始应用或忽略。
通过记录,数据库系统能够在发生崩溃或需要恢复时,快速定位到最近的检查点,并仅从该检查点之后的日志记录中恢复未完成的事务或应用已提交事务的修改。这大大减少了恢复过程所需处理的数据量,提高了恢复效率。
- 重新开始接收事务:一旦检查点完成,系统可以继续接收新的事务。
6.2.5 非静止检查点
传统的静止检查点要求在检查点期间停止接收新的事务,等待所有当前活跃的事务完成,并将它们的修改写入磁盘。然而,这种方法会导致系统暂停服务,影响用户体验。
非静止检查点技术允许在系统进行检查点的同时,继续接收和处理新的事务
非静止检查点的步骤
- 开始检查点:
系统在日志中写入一个特殊的<STARTCKPT(T,…,T)>记录,其中T,…,T是所有当前活跃事务的标识符。
这个记录表明,从现在开始,系统将开始一个检查点过程,但会继续接收新的事务。
- 等待活跃事务完成:
系统继续运行,允许新事务进入并处理。
同时,系统等待记录中列出的所有活跃事务完成(提交或中止),并将它们的修改写入磁盘。
- 结束检查点:
当所有活跃事务都完成后,系统在日志中写入一个记录。
这个记录表明,检查点过程已完成,所有在之前开始的事务都已经处理完毕。
恢复过程
当系统从故障中恢复时,它会从日志的尾部开始向后扫描:
如果先遇到记录,那么恢复过程可以安全地忽略之前的所有日志记录,因为它们所代表的事务更新已经稳定地存储在数据库中。恢复过程将继续向后扫描,直到遇到下一个记录。
如果先遇到记录但还没有记录,那么系统崩溃发生在检查点过程中。恢复过程需要找到并撤销那些在和崩溃之间开始且未完成的事务,以及那些在中列出但尚未在崩溃前完成的事务。
6.3 redo 日志
- undo 日志在恢复时消除未完成事务的影响并忽略已提交事务,而redo日志忽略未完成的事务并重复已提交事务所做的改变。
- undo日志要求我们在COMMIT日志记录到达磁盘前将修改后的数据库元素写到磁盘,而redo日志要求COMMIT记录在任何修改后的值到达磁盘前出现在磁盘上。
- 对于undo日志,恢复时需要旧值(即事务开始前的值)来撤销更改。而对于redo日志,恢复时需要的是新值(即事务提交后应该存在于数据库中的值)来重做更改。
6.3.1 redo 日志规则
- 指出被修改元素的日志记录:
当事务T需要修改数据库元素X时,首先会生成一条形如<T, X, v>的日志记录,其中T是事务标识,X是被修改的数据库元素标识,v是新的值。这条记录会立即被写入到日志文件中,但此时数据库元素X本身还没有被修改。
- COMMIT 日志记录:
当事务T完成所有修改并准备提交时,会生成一条的日志记录,表示事务T已经成功完成。这条记录同样会被写入到日志文件中,并且它必须出现在所有与该事务相关的更新记录之后。
- 改变的数据库元素自身:
只有当上述所有与事务T相关的日志记录(包括更新记录和提交记录)都成功写入到磁盘上的日志文件中之后,事务T才会实际修改数据库元素X的值,并将这个新的值写入到磁盘上的数据库文件中。
这恢复过程会检查日志文件中的记录,并按照事务的提交顺序重新执行所有已提交的更新操作,从而恢复数据库到崩溃前的状态。
由于先写日志规则的存在,即使数据库元素本身还没有被写入磁盘,但是相关的日志记录已经存在,因此可以通过这些日志记录来恢复数据库的状态。这种机制大大提高了数据库的可靠性和恢复能力。
6.3.2 使用redo 日志的恢复
- 确定已提交的事务
在恢复过程开始时,首先需要确定哪些事务是已经提交的,哪些事务是未完成的(即已启动但尚未提交或已回滚的事务)。这通常通过检查日志文件中的记录来完成。如果日志中存在记录,则事务T被认为是已提交的;否则,事务T被认为是未完成的。
2. 从首部开始扫描日志
接下来,从日志文件的开始部分顺序扫描每一条记录。对于遇到的每一条<T, X, v>记录(其中T是事务标识,X是数据库元素标识,v是新值),需要执行以下操作:
- a) 如果T是未提交的事务
在这种情况下,由于事务T最终没有成功提交,因此它对数据库所做的任何修改都不应该被保留。因此,对于这样的记录,恢复过程将忽略它,不执行任何操作。
- b) 如果T是提交的事务
对于已提交的事务,其修改是有效的,并且需要被应用到数据库中。因此,恢复过程会将新值v写入到数据库元素X中,无论该元素当前的值是什么。这是通过redo日志实现的,即重新执行已提交事务的修改操作。
3. 对每个未完成的事务T,在日志中写入一个记录并刷新日志
在扫描完所有日志记录并应用了所有已提交事务的修改之后,恢复过程需要处理那些未完成的事务。虽然这些事务的修改不会被应用到数据库中,但出于一致性和审计的目的,通常会在日志中为每个未完成的事务写入一个记录。这个记录表明事务T由于某种原因(如系统崩溃)而未能成功完成,并且其修改应该被忽略。为了确保记录被正确地写入到日志文件中,通常需要执行一个“刷新”操作,以确保日志文件的更改被持久化到磁盘上。这样,即使系统再次崩溃,也能够从日志文件中准确地了解哪些事务是已提交的,哪些事务是未完成的。
6. 3. 3 redo 日志的检查点
- 写入日志记录<START CKPT(T1,…,Tn)>,其中T1,…,Tn是所有活跃(即未提交的)事务,并刷新日志
2. 将STARTCKPT记录写入日志时所有已提交事务已经写到缓冲区但还没有写到磁盘的数据库元素写到磁盘
在写入记录之后,系统需要遍历所有的缓冲区,找出那些已经被已提交事务修改过但尚未写入磁盘的数据库元素(即“脏页”)。然后,将这些脏页写入磁盘。这一步是检查点过程的核心,因为它确保了所有在检查点之前已经提交的事务的修改都被持久化到磁盘上。
3. 写入日志记录并刷新日志
在所有必要的脏页都被写入磁盘之后,系统在redo日志中写入一个记录,标志着检查点过程的结束。
6.3.4 使用带检查点 redo 日志的恢复
情况一:最后一个检查点记录是
<STARTCKPT(T,…,T)>记录之前提交的所有事务的修改都已经成功写入了磁盘。因此,恢复过程可以简化为:
忽略<STARTCKPT(T,…,T)>之前提交的事务,因为它们的修改已经持久化。
关注<STARTCKPT(T,…,T)>中列出的事务以及在该检查点之后开始的所有事务。
扫描日志,从日志的开头或上一个检查点之后开始,直到找到<STARTCKPT(T,…,T)>。
重做(redo)<STARTCKPT(T,…,T)>中列出的事务以及之后开始并提交的所有事务的修改。这些事务的修改可能还没有写入磁盘。
停止扫描日志,当遇到最早的记录(表示一个新事务的开始)且该事务不在<STARTCKPT(T,…,T)>中时,因为这意味着后续的事务与当前恢复过程无关。
情况二:最后一个检查点记录是<START CKPT (T,…,Tk)>
我们不能确定在<START CKPT (T,…,Tk)>之前提交的事务的修改是否已经被写入磁盘。因此,恢复过程需要更多的步骤:
回溯到日志中查找最近的记录。
找到与这个匹配的<STARTCKPT(S,…,Sn)>记录。
重做(redo)所有在<STARTCKPT(S,…,Sn)>和<START CKPT (T,…,Tk)>之间开始并成功提交的事务的修改,因为这些事务的修改可能还没有被写入磁盘。
检查<START CKPT (T,…,Tk)>之后的事务,并只重做那些已经提交的事务的修改。
6.4 undo/redo 日志
我们已经看到了两种不同的日志方式,它们的差别在于当数据库元素被修改时日志中保存旧值还是新值。它们各有其缺陷:
- undo日志要求数据在事务结束后立即写到磁盘,可能增加需要进行的磁盘I/0次数。
- 另一方面,redo日志要求我们在事务提交和日志记录刷新以前,将所有修改过的块保留在缓冲区中,这样可能增加事务需要的平均缓冲区数。
- 如果数据库元素不是完整的块或块集,在检查点过程中undo日志和redo日志在如何处理缓冲区方面都存在矛盾。例如,如果一个缓冲区中包含被提交的事务修改过的数据库元素A和同一缓冲区中被尚未将其COMMIT记录写到磁盘的事务修改过的数据库元案B。
6.4.1 undo/redo 规则
规则UR1:在事务T对数据库元素X的修改(即新值w写入磁盘上的X)之前,更新记录<T, X, v, w>必须已经写入磁盘。这里,v是修改前的旧值,w是修改后的新值。这个规则确保了即使在系统崩溃之后,我们也有足够的信息来恢复数据(通过重做已提交事务的修改)或撤销未提交事务的修改。这个日志记录为系统提供了足够的信息来执行undo(如果事务需要被回滚)或redo(如果事务已经提交但系统崩溃,需要重新应用其修改)操作。
6.4.2 使用 undo/redo 日志的恢复
1. 按照从前往后做顺序,重做所有已提交的事务
这一步是在系统恢复过程中首先执行的。它的目的是重新应用所有已经成功提交的事务的修改,以确保这些修改对数据库的影响是持久的。
2. 按照从后往前做顺序,撤销所有未提交的事务
在重做所有已提交事务之后,下一步是撤销所有未提交的事务。这是因为未提交的事务表示它们还没有被系统正式接受为数据库状态的一部分,因此它们的修改不应该在恢复后的数据库中反映出来。为了撤销这些事务,系统会按照日志中从后往前的顺序(即事务发生的逆序)来查找所有未包含日志记录的事务,并撤销它们所做的修改。
6.4.3 undo/redo 日志的检查点
- 写入日志记录 <START_CKPT(T,…,T)> 并刷新日志
2. 将所有脏缓冲区写到磁盘
3. 写入日志记录 <END_CKPT> 并刷新日志
6.5 针对介质故障的防护
6.5.1 备份
完全转储
完全转储涉及拷贝数据库在某一时刻的完整状态。这是恢复过程的基础,因为它提供了一个完整的、无遗漏的数据集。完全转储通常被标记为“0级”转储,因为它不依赖于任何先前的转储。
增量转储
增量转储仅拷贝自上次转储(无论是完全转储还是增量转储)以来发生变化的数据库元素。这种方法显著减少了每次备份所需的数据量,但恢复过程可能需要多个转储文件,因为它们是按顺序依赖的。增量转储可以进一步细分为多个级别,其中“i级”转储表示拷贝自最后一个小于或等于i级转储之后改变的所有内容。
起始点:恢复过程通常从一个完整的转储(0级转储)开始,因为这个转储包含了数据库在某个时间点的完整状态。
增量层叠:随后的增量转储(1级、2级、…、i级)都是基于之前的转储进行的。例如,1级增量转储包含了自0级转储以来发生变化的元素;而2级增量转储则包含了自最近一次1级(或更低级别)增量转储以来发生变化的元素,以此类推。
恢复顺序:在恢复时,必须首先恢复0级转储,因为这是所有后续增量转储的基础。然后,需要按照递增的顺序(1级、2级、…、i级)应用增量转储,以确保所有更改都被正确地应用到数据库中。
6.5.2 非静止转储
非静止转储:是一种在数据库系统不停止运行的情况下进行的备份策略。由于数据库在备份过程中仍然处于活动状态,新的事务可能会开始、修改或提交,因此备份的数据库状态可能会与转储开始时有所不同。为了解决这个问题,非静止转储结合了日志记录来确保数据库在恢复时能够达到一个一致的状态。
非静止转储的特点
并发性:在备份过程中,数据库系统继续处理新的事务,包括数据的插入、更新和删除。
日志依赖:由于备份是在数据库活动的状态下进行的,因此备份中可能包含不一致的数据。为了恢复到一个一致的状态,需要依赖于在备份过程中生成的日志记录。
顺序拷贝:非静止转储通常按照某种固定的顺序(如数据库表的顺序或数据页的顺序)来拷贝数据库元素。然而,由于并发性,这些元素在被拷贝的过程中可能会被其他事务修改。
恢复过程:恢复过程包括将备份数据恢复到数据库,并应用备份过程中生成的日志记录来纠正任何不一致。这通常涉及回滚未提交的事务并应用已提交事务的更改。
6.5.3 使用备份和日志的恢复
假设发生了介质故障,并且我们要通过此前已到达安全的远程结点、在崩溃中未丢失的日志和最近的备份重建数据库。我们执行下列步骤:
1.根据备份恢复数据库。
a)找到最近的完全转储,并根据它来恢复数据库(即将备份拷贝到数据库)。
b)如果有后续的增量转储,按照从前往后做的顺序,根据各个增量转储修改数据库。
2.用保留下来的日志修改数据库。使用对应所用日志方式的合适的恢复机制。
第7章 并发控制
调度器:不同事务各个步骤的执行顺序由调度器完成。
调度器所要实现的:可串行性,或者冲突可串行性。
调度器最重要的技术:封锁,时间戳,有效性确认。
7.1 串行调度和可串行化调度
7.1.1 调度
重要的读写动作发生在主存缓冲区中,而不是磁盘上。
调度是一个或多个事务的重要动作的一个序列。
7.1.2 串行调度
一个事务的所有动作完成之后才能进行下一个事务的所有动作。不同事务的调度顺序结果可能不一样。
7.1.3 可串行化调度
可串行化调度 :它允许事务并发执行,但产生的结果与某个串行调度产生的结果相同。
**可串行化:**如果一个并发调度(即多个事务可能同时执行)的执行结果与某个串行调度(即事务按某种顺序一个接一个执行)的执行结果对于所有可能的数据库初始状态都是相同的,那么这个并发调度就被认为是可串行化的。
7.1.5 事务和调度的一种记法
读操作 (rTi(X)): 表示事务读取数据库元素X的当前值。
写操作 (wTi(X)): 表示事务将数据库元素X的值更改为新值。
7.2 冲突可串行化
7.2.1 冲突
- 同一事务的两个动作总是冲突的。单个事务的动作顺序不能改变。
- 不同事务对同一数据库元素的写冲突。
- 不同事务对同一数据库元素的读和写也冲突。
总结
不同事务的任何两个动作可以交换,除以下情况外:
- 它们涉及同一数据库元素。
- 至少有一个是写。
冲突等价
如果通过一系列相邻动作的非冲突交换能将它们中的一个转换为另一个,我们说两个调度是冲突等价的。
冲突可串行化
如果一个调度 冲突等价 于一个串行调度,那么我们说该调度是冲突可串行化的。
7.2.2 优先图及冲突可串行化判断
**冲突可串行化:**它确保了一个调度虽然可能包含并行执行的事务,但这些事务的执行顺序可以重新排列成一个没有冲突的串行执行顺序,同时保持所有事务的原始读写操作。如果这样的串行顺序存在,那么该调度就被称为冲突可串行化。
优先图
优先图是一种有向图,用于表示事务之间的先后顺序关系。
根据调度序列,与r冲突的是w,与w冲突的是r,w。
例:
- 与r2(A)冲突的是w1.3(A),因此存在一条 2——>3
- 与r1(B)冲突的是w2.3(B),因此存在一条 1——>2
- 与w2(A)冲突的是w1.3(A),r1.3(A),因此存在一条2——>3
- 与r2(B)冲突的是w1.2(B),因此存在一条2——>1
以此类推…得到优先图:
7.3 使用锁的可串行化实现
事务获得在它所访问的数据库元素上的锁,以防止其他事务几乎在同一时间访问这些元素并因而引人非可串行化的可能。
7.3.1 锁
读写前的锁请求:
事务T在读取(r(X))或写入(w(X))数据库元素X之前,必须先请求(L(X))并获得该元素上的锁。这确保了当事务尝试访问数据库元素时,它对该元素具有独占访问权,从而避免了数据的不一致性问题。
锁请求(L(X))和读写操作(r(X)或w(X))之间不能有释放该元素锁的操作(u(X))。这是为了确保在访问元素期间,锁是持续有效的。
锁的释放:
事务T在完成对数据库元素X的读写操作后,必须释放(u(X))在该元素上的锁。这允许其他事务在需要时能够请求并获取锁,进而访问或修改该元素。
锁的互斥性:
任何两个事务(T1和T2)都不能在同一时间封锁同一个数据库元素X,除非其中一个事务(如T1)已经先释放了它在X上的锁。这意味着如果调度中存在两个连续的锁请求动作L(X)和L(X’)(假设它们是由不同事务发出的),那么在这两个动作之间必须有一个对应的解锁动作u(X)(由已经持有锁的事务执行)。确保了数据库元素在任何时候都只能被一个事务独占访问,从而维护了数据的一致性和完整性。
7.3.3 两阶段封锁
加锁阶段:
在此阶段,事务可以申请并获得它需要的所有锁。
一旦事务开始了加锁阶段,它就不能释放任何锁,直到进入解锁阶段。
解锁阶段:
在此阶段,事务可以释放它在加锁阶段获得的所有锁。
一旦事务进入了解锁阶段,它就不能再申请任何新锁。
7.4 有多种锁模式的封锁系统
在7.3节讨论的简单封锁模式中,每个事务在读写数据库元素时都需要获得锁,这在实际应用中可能过于严格且效率低下。
- 事务T即使只想读数据库元素X而不写它,也必须获得X上的锁。
7.4.1 共享锁与排他锁
共享锁(Shared Lock,S锁):
也称为读锁。
当事务需要读取数据库元素但不修改它时,可以请求共享锁。
多个事务可以同时持有同一个数据库元素上的共享锁,这意味着它们可以同时读取该元素。
如果某个事务已经持有数据库元素的共享锁,其他事务也可以请求该元素的共享锁,但不能请求排他锁。
排他锁(Exclusive Lock,X锁):
也称为写锁。
当事务需要修改数据库元素时,必须请求排他锁。
排他锁是独占的,即在同一时间只有一个事务可以持有某个数据库元素上的排他锁。
如果某个事务已经持有数据库元素的排他锁,其他事务既不能请求该元素的共享锁也不能请求排他锁
sli(X):事务Ti申请数据库元素X上的一个共享锁
xli(X):事务Ti申请数据库元素X上的一个排他锁
li(X):请求锁
ui(X):释放锁
7.4.2 相容性矩阵
行:表示数据库元素上当前已经被其他事务持有的锁类型。
列:表示新申请的锁类型。
示例
S 申请 | X 申请 | |
---|---|---|
S 持有 | 是 | 否 |
X 持有 | 否 | 否 |
7.4.3 锁的升级
在数据库事务管理中,锁机制是确保数据一致性和隔离性的重要手段。锁升级是指事务在开始时获取较低级别的锁(如共享锁),随着操作的进行,根据需要将其升级到更高级别的锁(如排他锁)。
优点:
提高并发性:在事务开始时使用共享锁可以允许多个事务同时读取数据,从而提高了系统的并发性能。
减少等待时间:事务在需要写入数据前可能已经完成了大部分读取操作,此时升级锁可以减少因等待排他锁而导致的延迟。
缺点:
死锁风险增加:多个事务可能同时尝试升级同一资源的锁,导致它们相互等待对方释放锁,从而引发死锁。
出现死锁:
当T和T’几乎同时开始时,它们都会首先获取A上的共享锁。
随后,它们都试图将A上的锁升级为排他锁,但由于对方持有A上的共享锁,因此都无法立即升级。
结果是,T和T’都会陷入无限等待状态,因为它们都在等待对方释放A上的锁。
死锁解决方案
使用超时机制:在尝试升级锁时设置超时时间,如果在规定时间内无法升级锁,则释放已持有的锁并回滚事务或采取其他恢复措施。
7.4.4 更新锁
更新锁介于共享锁(S锁)和排他锁(X锁)之间。更新锁允许事务在读取数据的同时,保留将来可能对该数据进行更新的权利。
更新锁的特点
读取权限:持有更新锁的事务可以读取数据,但不能写入数据。
升级权限:更新锁可以被升级为排他锁,但共享锁不能直接升级为排他锁(除非先释放共享锁)。
互斥性:一旦资源上有了更新锁,就不能再有其他事务在该资源上获得任何类型的锁(包括共享锁、更新锁和排他锁),直到更新锁被释放或升级为排他锁。
行:表示数据库元素上当前已经被其他事务持有的锁类型。
列:表示新申请的锁类型。
避免死锁
在例7.16中,由于事务T和T’都试图将共享锁升级为排他锁,导致它们互相等待对方释放锁,从而引发死锁。通过使用更新锁,我们可以避免这种情况。在例7.17中,事务T和T’都首先申请A上的更新锁。由于更新锁的互斥性,当T持有A上的更新锁时,T’尝试获取A上的更新锁将会被拒绝。这样,T可以继续执行并完成其操作,然后释放A上的锁。之后,T’可以获取A上的更新锁,进而升级为排他锁并完成其操作。
7.4.5 增量锁
增量锁是一种特殊的锁机制,适用于那些仅涉及对数据库元素进行增加或减少操作的事务。
多个事务可以同时对同一数据库元素进行增量操作,而这些操作的结果与它们执行的顺序无关。
增量锁的特性
增量操作的交换性:增量锁允许多个事务同时对同一数据库元素进行增量操作,且这些操作的顺序可以交换而不影响最终结果。
锁的兼容性:在增量锁被持有的情况下,其他事务不能对该元素加共享锁(S锁)或排他锁(X锁),但可以同时有多个事务持有该元素的增量锁(i锁)。
限制读写操作:增量锁不赋予事务对数据库元素进行读或写操作的权力,仅允许进行增量操作。
7.5 封锁调度器的一种体系结构
事务申请锁,释放锁都是调度器的干预。
7.5.1 插入锁动作的调度器
事务请求的动作通常通过调度器传送并在数据库上执行。但是在某些情况下,事务等待个锁而被推迟,其请求(暂时)不被传送到数据库。
第I部分:请求处理与锁插入
功能:负责接收来自事务的请求流,这些请求包括数据库访问操作(如读、写、增量、更新)和必要的锁请求。
操作:在数据库访问操作之前,插入适当的锁动作(如加锁、解锁等)。然后,将处理后的封锁和数据库访问动作序列传递给第Ⅱ部分。
第Ⅱ部分:动作执行与锁管理
功能:接收第I部分传来的封锁和数据库访问动作序列,并负责它们的执行。
操作
- 如果一个事务由于等待锁而被推迟,则将该动作推迟,并加入到一个待执行列表中。
- 如果事务的所有请求锁都已被授予,则执行该事务的数据库访问操作或封锁动作。
- 封锁动作的执行包括检查锁表,以确定锁是否可以被授予。如果可以,则更新锁表;如果不可以,则在锁表中标记该锁已被申请,并推迟事务直到锁可用。
锁释放与通知:当事务提交或中止时,通知第I部分释放锁。如果有事务等待这些锁,则通知第Ⅱ部分进行处理。
锁的获得与执行:当某个数据库元素上的锁变得可用时,第Ⅱ部分决定哪个(或哪些)事务可以获得这些锁,并允许它们执行被推迟的操作,直到它们完成或遇到新的锁等待。
7.5.2 锁表
锁表是将数据库元素与有关该元素的封锁信息联系起来的一个关系表。
表可以用一个散列表来实现,使用数据库元素(地址)作为散列码。任何未被封锁的元素在表中不出现,因此表的大小只与被封锁元素的数目成正比,而不是与整个数据库的大小成正比。
一个列表描述所有或者在A上当前持有锁,或者在等待A上的锁的那些事务。
组模式概括事务申请A上的一个新锁时所面临的最苛刻的条件。
在共享-排他-更新(SXU)封锁模式中,规则很简单:组模式:
a)S表示被持有的只有共享锁。
b)U表示有一个更新锁,而且可能有一个或多个共享锁。
c)X表示有一个排他锁,并且没有其他的锁。
封锁请求处理
检查锁表项:
- 如果A的锁表项不存在,说明A上当前没有锁,调度器将创建一个新的锁表项,并立即同意T的请求。
- 如果A的锁表项存在,调度器将检查当前的组模式(U-更新锁、X-排他锁、S-共享锁)。
根据组模式决定请求:
- 如果组模式是U(更新锁),则只有T自己持有的U锁或与其他请求相容的锁才能被授予。否则,请求被拒绝,并在等待列表中为T的请求添加一项,设置Wait?=Yes。
- 如果组模式是X(排他锁),则请求同样被拒绝,并添加等待项。
- 如果组模式是S(共享锁),则另一个共享锁或更新锁可以被授予。如果授予的是更新锁,则组模式改为U;如果是共享锁,则组模式保持S。
更新锁表:
- 无论锁是否被授予,新的列表项都会通过Tnext和Nex字段正确地链接到等待列表中。
- 调度器可以从锁表直接获取所需信息,无需检查锁的列表(但列表用于管理等待的事务)。
解锁处理
删除列表项:
从等待列表中删除T关于A的项。
检查并更新组模式:
如果T持有的锁与组模式不同(如T持有S锁,但组模式是U),则组模式保持不变,因为还有其他事务可能持有U锁。
如果T的锁与组模式相同,并且T是最后一个持有该锁的事务(即没有其他事务持有A上的锁了),则组模式可能需要更改:
如果组模式是U,并且没有其他锁存在,则组模式变为无(因为没有锁了)。
如果组模式是S,并且还有其他事务持有S锁,则组模式保持S;否则,也变为无。
授予等待的锁:
如果存在等待的锁(Waiting = ‘yes’),调度器需要决定如何授予这些锁。策略包括:
先来先服务:按照请求的先后顺序授予锁,确保公平性。
共享锁优先:首先授予所有等待的共享锁,然后是更新锁,最后是排他锁。这种策略可能导致更新锁或排他锁饿死。
升级优先:如果有一个持有U锁的事务在等待将其升级到X锁,则优先授予该锁。这可以确保重要的更新或写入操作尽快完成。
7.6 数据库元素的层次
7.6.1 多粒度的锁
元组级锁(最细粒度):
优点:支持高并发,因为每个事务只需要锁定它所修改或查询的元组。
缺点:管理开销大,因为锁的数量可能非常多,同时增加了死锁的风险。
适用场景:当大量事务主要修改或查询少量的数据时,如银行的存款和取款操作,每个账户视为一个元组。
页/块级锁:
优点:相对于元组级锁,减少了锁的数量和管理开销,同时仍然可以支持较高的并发。
缺点:可能会因为锁定整个页/块而导致一些不必要的等待,尤其是当页/块内只有少量数据被修改时。
适用场景:当事务倾向于修改或查询页/块内多个数据时,如银行数据库中同一页内的多个账户。
关系级锁(最粗粒度):
优点:管理开销最小,因为整个关系只需要一个锁。
缺点:并发性能低,因为任何对关系内数据的修改都需要先获得整个关系的锁。
适用场景:当大多数事务是读操作且很少写操作时,如文档数据库,其中文档经常被检索但很少被编辑。
7.6.2 警示锁
。警示锁与普通锁(如共享锁S和排他锁X)配合使用,以提供更灵活的锁控制机制,特别是在处理多层次数据结构时。
警示锁的基本概念
定义:警示锁通过在普通锁前加前缀(如“意向”)来表示,例如IS(Intention Shared)和IX(Intention Exclusive)。它们用于表明事务打算在其子元素上获取特定类型的锁。
作用:警示锁主要用于提高锁管理的效率,减少不必要的锁冲突,并帮助数据库系统快速判断某个事务是否可能与其他事务发生冲突。
警示锁的协议规则
从根开始:在尝试对任何元素加S或X锁之前,必须从层次结构的根(如关系)开始。
直接加锁:如果当前结点就是需要封锁的结点,则直接请求该结点上的S或X锁。
向下传递警示:如果需要封锁的结点位于层次结构中更靠下的位置,则在当前结点上添加一个警示锁(IS或IX)。然后,继续向包含目标结点的子结点行进,并重复此过程。
锁的相容性矩阵
7.6.3 幻象与插入的正确处理
幻象问题是指在一个事务执行过程中,另一个事务插入了新的行,这些新行满足第一个事务中某个查询的条件,但第一个事务在开始时并未看到这些行。这会导致第一个事务的查询结果不准确,违反了事务的隔离性。
解决方案
使用更严格的隔离级别:如可串行化(Serializable)隔离级别。在这个级别下,事务会完全串行执行,从而避免了幻象的发生。但这种方法可能会显著降低系统的并发性能。
使用锁:
表级锁:在事务开始时,对整个表加锁,直到事务结束。这可以防止其他事务插入新行。但这种方法会限制表的并发访问。
范围锁:对查询涉及的数据范围加锁,防止其他事务在该范围内插入新行。但这种方法需要数据库系统能够预测查询的范围,这在很多情况下是不可行的。
幻象锁(Phantom Locks):一种特殊的锁,用于防止在特定查询条件下插入新行。虽然大多数数据库系统不直接提供这种锁,但可以通过其他机制(如索引锁、谓词锁等)来实现类似的效果。
使用多版本并发控制(MVCC):MVCC 允许数据库为每个事务维护数据的一个或多个版本。当事务读取数据时,它看到的是事务开始时数据的一个快照。这样,即使其他事务插入了新行,也不会影响当前事务的查询结果。
7.7 树协议
7.7.1基于树的封锁的动机
在处理B-树索引的并发访问时,传统的两阶段封锁(2PL)协议可能会遇到严重的限制,导致并发性能不佳。这是因为B-树的结构特性使得根节点或内部节点的锁可能成为瓶颈,特别是在进行插入或删除操作时,理论上可能需要重写这些节点
基于树的封锁动机
减少锁粒度:直接在B-树的根节点或所有内部节点上加锁会极大地限制并发性,因为这会要求事务在继续深入树之前获得这些高级别节点的锁。通过基于树的结构特性来优化锁的使用,可以减少不必要的锁等待和锁冲突。
提高并发性:通过观察和预测B-树操作的局部性,可以设计一种机制,使得事务在确信不会修改树的高层结构时,能够提前释放高级别节点的锁。这种策略可以显著增加多个事务同时访问B-树的能力。
保持可串行性:虽然释放锁可能违反了两阶段封锁的原则,但可以通过设计专门的协议来确保事务的调度仍然是可串行化的。这些协议利用了B-树操作的特定顺序性和结构稳定性,通过跟踪事务对树的访问路径和所做的修改来确保数据的一致性。
7.7.2 访问树结构数据的规则
事务的第一个锁可以在树的任何结点上:
这个规则允许事务从树的任何位置开始其操作,而不仅仅是从根节点开始。这增加了灵活性,因为事务可以根据其需要直接定位到树中的相关部分。
只有事务当前在父结点上持有锁时才能获得后续的锁:
这个规则确保了事务在深入树结构时遵循一种“从上到下”的顺序。这有助于防止不同事务之间的锁冲突,因为它们在树中的路径不会交叉。此外,这个规则还隐含了一个要求,即事务在释放一个节点的锁之前,必须先释放其所有子节点的锁。这是因为如果没有这个要求,事务可能会陷入一个无法释放父节点锁的情况,因为它还持有子节点的锁。
结点可以在任何时候解锁:
这个规则允许事务在不再需要某个节点的锁时立即释放它。这有助于减少锁持有的时间,从而提高并发性。
事务不能对一个它已经解锁的结点重新上锁,即使它在该结点的父结点上仍持有锁:
这个规则防止了事务在释放了一个节点的锁之后重新获取它,除非它重新开始整个事务或重新获得从根节点到该节点的路径上所有节点的锁。这有助于避免死锁和复杂的状态管理问题。
7.8 使用时间戳的并发控制
7.8.1 时间戳
基于系统时间生成:
这种方法利用操作系统的当前时间来为事务分配时间戳。
使用计数器生成:
调度器维护一个递增的计数器,每当一个新事务开始时,计数器加1并将新值作为事务的时间戳。
RT(X):X的读时间戳。表示最后一次成功读取X的事务的时间戳。
当事务T读取X时,如果T的时间戳大于RT(X),则更新RT(X)为T的时间戳。
WT(X):X的写时间戳。表示最后一次成功写入X的事务的时间戳。
当事务T写入X时,无论T的时间戳与WT(X)的关系如何,都更新WT(X)为T的时间戳。
C(X):X的提交位。表示最后一次写入X的事务是否已经提交。
7.8.2 事实上不可实现的行为
过晚的读
事务T试图读取一个在其理论上执行之后才被写入的值。这违反了时间戳调度器的假设,即事务的执行顺序应该与其时间戳顺序一致。
我们只能中止事务T。
过晚的写
如果事务的时间戳处于一个“过晚”的位置,即它试图写入一个已经被其他事务读取(但尚未写入新值)的数据库元素,并且这个读取操作的时间戳比当前事务的时间戳还要晚,那么就发生了过晚的写。
7.8.3 脏数据的问题
脏读问题
脏读发生在一个事务读取了另一个未提交事务修改的数据。但事务最终可能由于某种原因而未能提交。
解决方法
检查提交位C(X),如果C(X)为假,表示X的当前值尚未被提交,因此事务T应该推迟读取X,直到事务U提交或中止。
另一潜在问题
事务T试图写入X,但发现有一个时间戳更晚的事务U已经写入了X,T继续写的话会造成数据错误。
解决方法:Thomas写法则
Thomas写法则允许一个事务在发现有更晚时间戳的写操作已经发生时,跳过自己的写操作。
Thomas写法则的潜在问题
如果事务U后来中止,其写入X的操作应该被撤销,但此时T的写操作已经被跳过,无法恢复X到T写入之前的状态。这可能导致数据丢失或不一致。
处理策略
尝试性写入:当事务T写入数据库元素X时,调度器并不立即将X的值更新为T写入的值,而是将这次写入视为“尝试性”的。调度器同时保存X的旧值和原有写时间戳WT(X)的拷贝。
提交或中止处理:
如果事务T提交,调度器将X的值更新为T写入的值,并将提交位C(X)设为真。
如果事务T中止,调度器将撤销T对X的写入,恢复X的旧值和原有写时间戳WT(X)。
7.8.4 基于时间戳调度的规则
每个事务分配一个唯一的时间戳(TS)。
1. 读请求的处理
当调度器收到来自事务T的对数据项X的读请求r(X)时:
- 如果TS(T) ≥ WT(X),即事务T的时间戳大于或等于X的最近写时间戳,说明T的读请求是事实上可实现的(即不会读到脏数据)。
- 如果C(X)为真(表示X的最近写已提交),则同意请求,并根据需要更新X的读时间戳RT(X)。
- 如果C(X)为假(表示X的最近写未提交),则推迟T直到C(X)为真或写X的事务中止。
- 如果TS(T) < WT(X),即事务T的时间戳小于X的最近写时间戳,说明T的读请求是事实上不可实现的(即会读到脏数据),因此必须回滚T并重启。
2. 写请求的处理
当调度器收到来自事务T的对数据项X的写请求w(X)时:
- 如果TS(T) ≥ PT(X) 且 TS(T) ≥ WT(X),即事务T的时间戳大于或等于X的最早提交时间戳和最近写时间戳,说明T的写请求是事实上可实现的,即T写X之后暂时不会被其他写覆盖,必须执行。
- 为X写入新值。
- 更新WT(X)为TS(T)。
- 将C(X)设为false,表示X的最新写尚未提交。
- 如果TS(T) > RT(X) 但 TS(T) < WT(T),即T的时间戳大于X的读时间戳但小于X的最近写时间戳,说明T的写是事实上可实现的但X已有更晚的写。
- 如果C(X)为真,则前一个X的写已提交,忽略T的写。
- 如果C(X)为假,则推迟T直到C(X)为真或写X的事务中止。
- 如果TS(T) < RT(X),即事务T的时间戳小于X的读时间戳,说明T的写是事实上不可实现的,必须回滚T。
3. 提交请求的处理
当调度器收到事务T的提交请求时:
- 将T所写的所有数据库元素X的C(X)设为true,表示这些写已提交。
- 允许任何等待X被提交的事务继续进行。
4. 中止或回滚请求的处理
当调度器收到事务T的中止请求或决定回滚T时:
- 任何等待T所写元素X的事务需要重新尝试其读或写请求,检查这些操作在T的写被中止后是否仍然合法。
7.8.5 多版本时间戳
每个数据库元素(如记录、块或页)都可能有多个版本,每个版本都与一个特定的时间戳相关联。这些版本记录了数据在不同时间点的状态。
多版本时间戳的主要目的是允许事务在读取数据时,即使其他事务正在修改这些数据,也能继续执行而不会导致中止。这是通过让事务读取适合其时间戳的数据版本来实现的。
7.8.6 时间戳与封锁
时间戳机制:
在高冲突场景下,由于多个事务尝试写入相同数据,可能导致大量事务需要回滚,增加系统延迟。
适用于大多数事务为只读或并发事务较少读写同一数据元素的情况。
封锁机制:
事务可能因等待锁而阻塞,导致性能下降,甚至可能出现死锁。
在高冲突场景下,即多个事务频繁读写相同数据元素时,封锁机制的性能较好。
7.9 使用有效性确认的并发控制
有效性确认与时间戳的主要区别在于调度器维护关于活跃事务正在做什么的一个记录,而不是为所有数据库元素保存读时间和写时间。事务开始为数据库元素写人值前的一刹那,它经过一个“有效性确认阶段”,这时用它已经读的和将写的元素集合与其他活跃事务的写集合做比较。如果存在事实上不可实现行为的风险该事务就被回滚。
7.9.1 基于有效性确认调度器的结构
告知调度器事务T的读集合RS(T)和写集合WS(T)
读阶段:
事务读取其读集合(RS(T))中的所有数据库元素。事务在其局部地址空间中计算将要写入的所有值,但此时不修改数据库。
有效性确认阶段:
调度器比较当前事务的读写集合(RS(T)和WS(T))与其他活跃事务(即在START或VAL集合中的事务)的读写集合。
如果发现存在冲突,即当前事务的写集合与其他事务的读集合或写集合有交集,且不满足可串行化的要求,则事务被回滚。
如果确认成功,事务进入下一个阶段。
写阶段:
事务将其写集合(WS(T))中的值写入数据库。
为了支持做出是否确认事务有效性的决定,调度器维护三个集合:
START集合:
包含已经开始但尚未完成有效性确认的事务。对于每个事务T,调度器记录其开始时间START(T)。
VAL集合:
包含已经通过有效性确认但尚未完成写阶段的事务。T确认时间VAL(T)。
FIN集合:
包含已经完成写阶段的事务。T完成时间FIN(T)。
7.9.2 有效性确认规则
规则一:检查读后写冲突
对于所有已经过有效性确认(即在VAL或FIN集合中)且在事务T开始前没有完成的事务U(即满足FIN(U) > START(T)),调度器需要检测是否存在读后写冲突。这种冲突发生在事务T读取了某个数据库元素X之后,而事务U可能(或已经)修改了X的值。
- 具体检查:检查RS(T) ∩ WS(U) 是否非空。如果非空,说明事务T读取了事务U将要修改的数据库元素,这可能导致事务T读到了旧值,而事务U最终会写入新值,从而破坏了串行顺序的假设。
- 处理:如果检测到这种冲突,调度器必须回滚事务T,以避免潜在的数据不一致。
规则二:检查写-写冲突
对于所有已经过有效性确认(即在VAL集合中)且在事务T进入其有效性确认阶段前没有完成的事务U(即满足FIN(U) > VAL(T)),调度器需要检测是否存在写-写冲突。这种冲突发生在两个事务都试图修改同一个数据库元素。
- 具体检查:检查WS(T) ∩ WS(U) 是否非空。如果非空,说明事务T和事务U都计划修改同一个数据库元素,而根据假设的串行顺序,事务U应该在事务T之前完成修改。
- 处理:如果检测到这种冲突,调度器同样需要回滚事务T,以确保事务的执行顺序与假设的串行顺序一致。