JHU-大规模数据库笔记-全-
JHU 大规模数据库笔记(全)
001:什么是分布式数据库系统? 🗄️
在本节课中,我们将要学习分布式数据库系统的基本概念。我们将首先明确它“不是什么”,通过对比其他系统来清晰地定义它,最后阐述其核心特征与设计考量。


分布式数据库系统“不是什么”
为了准确定义分布式数据库系统,我们首先需要排除一些容易混淆的概念。
不是分布式文件集合

分布式数据库系统不是一个分布式文件集合。虽然存在整合文件的系统(例如搜索引擎),但为了本课程的目的,我们不将常规文件的分布视为分布式数据库系统的一部分。
要被视为分布式数据库系统,文件必须:
- 相互关联:文件之间需要有意义的联系。
- 结构良好:良好的结构能实现快速的数据集成与关联。
不是多处理系统
分布式数据库系统也不是多处理系统。多处理系统是指多个处理器访问非常相似数据的系统。虽然听起来类似,但两者有本质区别。

多处理系统主要分为以下三种架构:
1. 共享内存系统
在这种系统中,多个处理器共享高速缓存和数据库(即主存和辅存)。
- 工作原理:当一个处理器(如处理器1)从数据库请求数据(如变量A、B、C)时,系统会将这些数据从较慢的磁盘移至高速缓存。当处理器1再次需要这些数据时,可以直接从高速缓存快速获取。
- 优点:如果另一个处理器(处理器2)也需要相同的数据,它也能直接从高速缓存获取,无需访问数据库,效率很高。
- 缺点:如果两个处理器频繁访问不同的数据集,会导致数据在高速缓存和数据库之间被频繁换入换出,反而降低效率。
2. 共享磁盘系统
在这种系统中,每个处理器拥有自己的高速缓存,但共享一个公共数据库。
- 工作原理:处理器1和处理器2从同一个数据库中读取各自需要的数据块到本地缓存中进行处理。
- 优点:适合处理器们需要访问数据库中不同部分数据的场景。
- 缺点:如果它们需要频繁访问磁盘上的同一数据,可能造成瓶颈;或者如果它们总访问相同数据,则各自维护缓存会造成资源浪费。
3. 无共享系统
这是目前非常流行的架构,尤其适用于大型数据仓库系统。在这种系统中,每个处理器都拥有自己独立的高速缓存和数据库。

- 工作原理:处理器主要访问自己本地数据库中的数据。只有当需要对方数据库中的数据时,才会通过网络发送消息来获取。
- 优点:当处理器需要的数据差异很大时,这种架构非常高效。
- 缺点:如果它们需要频繁共享或访问相同的数据,效率会降低。
重要提示:以上所有多处理系统架构,都不是我们所说的分布式数据库系统。

什么才是分布式数据库系统?
上一节我们排除了容易混淆的概念,本节中我们来看看分布式数据库系统的正确定义。
一个真正的分布式数据库系统,是一个拥有大型通信网络的系统,其中处理器地理上分布,并且每个处理器都挂载有自己的数据库。
请看以下示意图:


如图所示,我们有四个地理上分布的处理器(巴尔的摩、纽约、芝加哥等),每个地点都运行着自己的数据库。
分布式数据库系统的核心特征包括:
- 物理分布与数据冗余:数据表被分布在不同地点的数据库中。例如,
马里兰客户数据既存在于巴尔的摩的数据库,也可能存在于纽约的数据库中。这种冗余非常重要,它能确保当一个节点故障时,其他节点可以继续处理请求并保存信息,保证系统的高可用性。 - 引用局部性:这是分布式数据库设计的一个关键考量。例如,纽约的程序可能经常需要访问
马里兰客户数据。如果这些数据在纽约本地有副本,那么访问速度将远快于每次都通过互联网向巴尔的摩的数据库发送请求。因此,在设计分布式数据库时,我们需要在那些最可能需要该数据的地方复制信息,这既是为了冗余备份,也是为了提供快速的本地访问。

这种地理上的分布带来了许多重要的技术挑战,例如如何从多个站点访问和聚合数据、如何处理分布式事务、以及如何应对网络和带宽问题等,这些都将是我们课程后续讨论的重点。
总结

本节课中,我们一起学习了分布式数据库系统的基础定义。我们首先明确了它不是简单的分布式文件集合,也不是共享内存、共享磁盘或无共享的多处理系统。真正的分布式数据库系统的核心在于地理分布的处理器各自拥有独立的数据库,并通过网络连接。其设计强调数据冗余以保证高可用性,并利用引用局部性原则来优化数据布局,从而提升访问性能。理解这些基本概念是深入学习大规模数据库系统管理、查询执行和数据集成等高级主题的基础。
002:透明性问题 🧩
在本节课中,我们将要学习分布式数据库系统的核心特性之一:透明性。透明性意味着用户无需了解数据在系统中的具体分布、存储和访问细节,就能像使用单一数据库一样进行操作。我们将详细探讨透明性的多个方面,包括数据分布、复制、独立性、网络、服务、复制和分片透明性。
上一节我们介绍了分布式数据库系统的基本概念,本节中我们来看看透明性如何让用户更轻松地使用这些系统。
透明管理分布式与复制数据
假设我们有一个数据库定义,它包含四个表。这个定义将在整个课程中使用。


以下是数据库中的表结构:
- 客户表 (Customer Table): 包含客户编号、客户姓名和客户地址。
- 零件表 (Part Table): 包含零件编号、零件名称、制造商和成本。
- 订单表 (Order Table): 包含客户编号、零件编号和数量。
- 制造商表 (Manufacturer Table): 包含制造商和所在州。
在关系图中,客户与订单表之间存在一对多关系,一个客户可以下多个订单。同样,一个制造商可以生产多种零件,而一个零件可以出现在多个订单中。实际上,客户和零件的组合在订单表中构成了唯一的订单条目。
有多种方式可以在分布式数据库系统中分布这些表的信息。我们在上一单元讨论过这一点。在本例中,我们有四个不同的节点:巴尔的摩节点、纽约节点、芝加哥节点和洛杉矶节点。表的各个部分被地理分布在全国各地的不同处理器上,共同构成了这个数据库的信息。图中仅展示了客户和制造商表在这些位置上的分布,但实际上,订单表和零件表同样会被分布。

当用户在分布式数据库系统上执行查询时,系统会提供一定程度的透明性。用户无需理解所有这些数据部分具体存放在哪里。
因此,用户可以执行一个单一查询。例如,一个查询可能要求从四个不同的表中获取客户姓名、零件名称和数量,并在这些表之间进行连接操作,同时附加一个关于制造商所在州的约束条件。
SELECT Customer.Name, Part.Name, Order.Quantity
FROM Customer, Part, Order, Manufacturer
WHERE Customer.CustomerNo = Order.CustomerNo
AND Part.PartNo = Order.PartNo
AND Part.Manufacturer = Manufacturer.Manufacturer
AND Manufacturer.State = 'Maryland';
如果用户必须了解并知道数据库所有不同部分的位置,并且必须理解如何将它们整合在一起来满足查询,那么编写这样的查询将非常困难。分布式数据库系统提供的透明性使得用户在构建查询时,无需了解数据库各部分的地理位置。

用户也无需担心数据在大型分布式数据库系统中的复制问题。这是分布式数据库系统提供的一个重要透明性方面。
数据独立性
分布式数据库系统提供的另一个透明性部分是数据独立性。这意味着用户应用程序无需理解数据的变更。
- 逻辑独立性: 如果逻辑模式发生变更,例如增加了新列,或者数据分布方式发生了变化,用户无需知晓。用户只需按照原有方式构建查询,分布式数据库系统会自动将查询重定向到数据库中新的或已变更的部分,而用户无需了解数据库系统的任何变更。
- 物理独立性: 它同样对数据库的物理部分提供透明性。如果模式的物理部分、存储结构的细节等发生变更,用户也无需了解。所有这些变更都由分布式数据库系统管理。
此外,索引方案(如随机索引方案等)也是独立的。用户无需理解具体的方案是什么,他们只需要知道有可用的索引,分布式数据库管理系统会处理其余工作。
更进一步,用户无需理解存储介质,也无需理解如何提升查询性能。分布式数据库管理系统会理解底层拓扑结构、不同表的位置、不同表的结构,并基于数据的分布情况,知道如何构建最优查询。
网络透明性
如果用户在没有分布式数据库系统管理表访问的情况下自行处理表,他们将必须理解网络配置。优化查询的制定和执行需要了解网络的哪些部分负载较重,哪些部分负载较轻,从而引导查询和通信通过负载较轻的网络部分。要求用户管理这些是非常困难的。

分布式数据库管理系统可以掌握网络负载信息,并利用这些信息来制定针对分布在整个数据库拓扑中的数据库的最优查询。
服务与命名透明性
用户无需知道服务位于何处。服务以统一的方式被访问。用户无需知道数据的具体位置,也无需知道数据在单个数据库系统上存储时使用的特定名称。他们只需要使用整个分布式数据库系统所知晓的数据通用名称。
复制透明性
数据复制是为了实现访问的局部性,这将是贯穿本课程的一个反复出现的主题。访问局部性基于这样一个事实:位于某一地理位置的用户会希望他们的数据离他们很近,即使其他站点也需要这些数据。访问局部性使得分布式数据库系统能够将数据放置在最需要它们的用户附近。对于地理上分布的用户,系统将复制信息和数据,以便用户能够快速访问这些数据。数据复制也出于可靠性和备份的原因,我们将在学期后期讨论这一点。

那么,复制是否应该对用户透明?当然,从用户的角度来看,没有人愿意管理那种关于数据库拓扑的深层知识。从系统角度来看,如果由用户控制,事务管理会更简单吗?答案是,如果不是由单一的分布式数据库管理系统管理,事务管理几乎是不可能的。我们将在课程后期详细讨论这一点。
分片透明性
为了获得访问局部性的好处,通常明智的做法是将表分片,并将这些片段移动到数据库系统的不同部分。如图所示,橙色圆圈代表垂直分片,蓝色圆圈代表水平分片。水平分片数据和垂直分片数据都有其好处,我们将在后面详细讨论。
但最终目的,是为查询数据库的用户提供最佳的访问局部性。

本节课总结

本节课中,我们一起学习了分布式数据库系统的核心透明性特性。我们探讨了用户如何无需关心数据的物理分布、复制、网络细节和存储结构,就能执行查询。这些透明性包括:分布式与复制数据的管理、逻辑与物理数据独立性、网络与负载的透明性、服务与命名的统一访问、数据复制的隐藏以及表分片的细节抽象。正是这些特性使得分布式数据库对用户而言如同一个单一、集成的系统,极大地简化了复杂环境下的数据操作。
003:本课程将解决的核心问题 🎯
在本节课中,我们将概述《大规模数据库系统》课程将要探讨和解决的一系列核心挑战。理解这些问题,是学习后续分布式数据库设计、优化和管理技术的基础。
上一节我们介绍了课程的整体框架,本节中我们来看看课程具体要解决哪些关键问题。
1. 分布式数据库系统设计 🏗️


作为设计者,如何设计一个分布式数据库系统是本课程要解决的第一个问题。分布式数据库系统有多种设计方案,这些设计将直接影响查询性能和系统效率。我们将探讨如何为数据库系统选择不同的设计,并分析跨多个系统复制数据所带来的影响。
以下是设计中的两个核心问题:
- 数据表分片:分片意味着在某些情况下,你需要将数据表拆分。这包括水平分片(将不同的行放置在不同的地理位置)和垂直分片(将不同的列放置在不同的地理位置)。分片的最终目的是优化分布式查询。
- 设计复杂性:将表拆分成片段并在地理上分布,是一个计算上非常困难的问题。如果遍历所有选项,这将是一个NP难问题,意味着计算复杂度是指数级的,计算机需要很长时间才能计算出最优的分布方案。
因此,我们通常采用启发式算法。启发式算法能在多项式时间内找到一个非常好的解决方案(不一定是最优的),从而保证在合理的时间内完成数据分布。
我们将在接下来的课程中详细探讨分片与数据分布。
2. 分布式查询处理 🔍
一旦我们有了分布式设计,下一步就是确定如何向分布式数据库制定查询,这是本课程的主要部分。在分布式环境中制定查询可能很困难,这同样是一个NP难问题。

为分布式表或表片段找到最优的连接查询计划非常困难且耗时,因为存在大量可能的查询计划。使用指数级算法来计算最优计划,用户将无法接受其等待时间。
我们再次使用启发式方法。启发式方法能在更短的时间内找到一个良好的解决方案(不一定是最佳),使用户能在合理的时间内获得查询结果。
此外,在分布式查询处理中,我们还将利用并行化的优势,并必须考虑数据分布的位置以及制定正确查询计划时的通信成本。
3. 目录管理分布 📂

目录管理涉及三个层次:概念目录(应用查询所针对的全局概念模式)、逻辑目录(存在于每个数据库节点的模式)和物理目录(表在磁盘上的实际布局)。这三者相互关联,几乎任何数据库操作都需要查询这些目录。
这里的关键问题是:应该分布式管理还是集中式管理这些目录?我们将讨论分布式与集中式目录管理的权衡。
4. 分布式并发控制 ⚙️
当多个用户同时向数据库提交查询时,这些查询可能会相互干扰,导致数据不一致。例如,一个用户正在读取数据,而另一个用户修改了该数据,第一个用户仍基于旧值进行操作。

我们将探索分布式并发控制算法,以维护分布式数据库系统的完整性。这些算法主要分为两大类:
- 悲观并发控制:假设冲突经常发生,因此预先采取所有预防措施(如加锁),确保用户不会相互干扰。
- 乐观并发控制:假设冲突不常发生,因此允许操作进行,并在最后提交时检查是否发生了冲突。
课程后续将详细讨论这些方法,例如大家熟悉的加锁机制,以及可能不太熟悉的时间戳等方法。
5. 分布式死锁管理 🔗
在分布式数据库系统中,不同地理位置的资源可能会相互等待,从而导致死锁。数据库系统中的死锁管理与操作系统中的死锁管理非常相似。
当多个用户想要访问数据库中的不同数据,但彼此等待对方释放锁定的资源时,就可能发生死锁。我们将讨论几种著名的解决方案:
- 死锁预防
- 死锁避免
- 死锁检测与恢复
这些方法将在未来的课程中详细探讨。

6. 系统可靠性 💾
可靠性问题涉及当系统站点发生故障时会发生什么。例如,如果你向数据库写入数据,系统告诉你已写入,你便期望该数据始终存在。但如果数据库系统在数据从缓存写入磁盘前崩溃,数据就会丢失。
因此,当数据库系统向用户保证数据已持久化,并在系统从崩溃中恢复时,它必须确保这些数据确实存在。我们将探索一些算法来保证这一点,即使在系统故障导致数据丢失的情况下。
我们将首先探讨适用于单数据库系统的 Aries算法,然后将其扩展到多数据库系统,并讨论两阶段提交协议和三阶段提交协议。

7. 异构数据库系统集成 🔄
有时也称为多数据库系统。本课程的前半部分主要关注从零开始构建一个分布式数据库管理系统,即你对表、分布和数据库管理系统拥有完全控制权。
然而,在许多实际应用中,你并没有这种控制权。你可能需要集成由他人控制、已存在的数据库系统。你无法访问这些系统的统计信息或使用模式,但必须尽最大努力集成它们。

本课程中学到的许多技术将帮助我们集成那些已存在且不受我们控制的分布式数据库。我们将看到这些技术如何应用于异构数据库,以及如何在无法完全控制系统的情况下实现集成。
本节课中我们一起学习了《大规模数据库系统》课程将要解决的七大核心问题:分布式系统设计、分布式查询处理、目录管理、并发控制、死锁管理、系统可靠性以及异构系统集成。理解这些问题是掌握后续具体技术和算法的基础。在接下来的课程中,我们将对每个问题进行深入剖析。
004:规范化解决数据库系统异常 📊

在本节课中,我们将学习数据库规范化(Normalization)的基本动机,以及如果不进行规范化,数据库设计会遇到哪些问题。我们将通过一个具体的例子,分析重复异常、更新异常、插入异常和删除异常,并理解如何通过规范化来避免这些问题。
为什么需要规范化? 🤔
上一节我们介绍了数据库表通常由多个相互关联的表组成。本节中我们来看看,如果不进行规范化,将所有数据都放在一个“通用关系”(Universal Relation)大表中,会产生哪些问题。
假设我们有一个包含零件和制造商信息的单一表,其结构如下:
| 零件号 | 零件名称 | 成本 | 制造商 | 制造商所在州 |
|---|
以下是如果使用这种单一表结构会遇到的两个主要问题。


重复异常与更新异常 🔄
重复异常(Repetition Anomaly) 指的是相同的信息在表中被不必要地重复存储多次。
更新异常(Update Anomaly) 指的是当某个信息需要更新时,必须在所有重复出现的行中进行修改,否则会导致数据不一致。
请看示例数据:
| 零件号 | 零件名称 | 成本 | 制造商 | 制造商所在州 |
|---|---|---|---|---|
| P1 | 螺母 | 0.05 | Acme公司 | 马里兰州 |
| P2 | 螺栓 | 0.10 | Acme公司 | 马里兰州 |
| P3 | 面板 | 1.00 | XYZ企业 | 加利福尼亚州 |
在这个表中,“Acme公司”及其所在地“马里兰州”的信息被重复存储了两次。在实际系统中,制造商信息可能还包括地址、电话、总裁姓名等更多字段,每次新增一个Acme公司的零件时,所有这些信息都必须被重复输入一遍,这就是重复异常。
现在,假设Acme公司从马里兰州搬到了特拉华州。为了更新这个信息,我们必须找到并修改表中所有“制造商”为“Acme公司”的行。如果Acme公司生产了成千上万个零件,这个更新操作将非常繁琐且容易出错,这就是更新异常。
通过将表规范化为两个独立的表,可以解决这些问题:
- 零件表(Parts Table):包含
零件号,零件名称,成本,制造商ID。 - 制造商表(Manufacturers Table):包含
制造商ID,制造商名称,所在州,地址,电话等。
这样,制造商信息只需存储一次。零件表通过制造商ID外键引用制造商表。添加新零件时,只需引用已有的制造商ID,无需重复输入其详细信息。更新制造商信息时,也只需在制造商表中修改一次。
插入异常与删除异常 ⚠️
上一节我们讨论了重复数据带来的问题。本节我们来看看单一表结构在数据增删时引发的另外两种异常。
插入异常(Insertion Anomaly) 指的是无法单独插入某些必要信息。
删除异常(Deletion Anomaly) 指的是在删除某些数据时,会不必要地丢失其他信息。
以下是这两种异常的具体表现。
插入异常示例:假设一家新的制造商“Beta有限公司”成为了我们的供应商,但我们还没有从他们那里采购任何零件。在单一表结构中,由于“零件号”是主键(或不能为空),我们无法插入一条没有零件信息的记录。因此,在获得第一个Beta公司生产的零件之前,我们无法在系统中记录这家新制造商的信息。
删除异常示例:假设零件“面板”(P3)是数据库中唯一由“XYZ企业”生产的零件。如果我们决定删除“面板”这个零件,那么与之相关的整条记录都会被删除。这意味着“XYZ企业”的所有信息(名称、所在州等)也会从数据库中消失。我们可能希望保留制造商信息以备未来之需,但单一表结构迫使我们丢失了这些信息。

通过规范化,将数据拆分到零件表和制造商表,可以轻松解决这些异常:
- 解决插入异常:我们可以随时在制造商表中添加新的制造商记录,无需依赖是否有零件。
- 解决删除异常:当我们删除某个零件时,只会影响零件表。只要该制造商还有其他零件存在,或者即使暂时没有,其信息也会安全地保留在制造商表中。
总结 📝
本节课中,我们一起学习了数据库规范化的核心动机。我们通过一个具体的例子,分析了非规范化设计(使用单一通用关系表)会导致的四种主要异常:
- 重复异常:相同信息被不必要地重复存储。
- 更新异常:修改信息时需要在多行进行,易出错且低效。
- 插入异常:无法独立插入某些实体(如制造商)的信息。
- 删除异常:删除某个实体(如零件)时,会连带删除其他实体(如制造商)的信息。

规范化通过将数据分解到多个逻辑关联的表中,有效地消除了这些异常,从而保证了数据的一致性、完整性和操作效率。这是设计健壮、可维护的数据库系统的基石。
005:ANSI SPARC数据库架构 🗂️
概述
在本节课中,我们将要学习由ANSI SPARC委员会定义的数据库管理系统参考架构。这是一个非常高层级的架构,它描述了数据库系统如何组织、编目和呈现数据。我们将通过三个核心视图——外部视图、概念视图和内部视图——来理解数据在不同角色和层级间的映射关系。

ANSI SPARC三层架构
上一节我们介绍了课程背景,本节中我们来看看ANSI SPARC架构的核心模型。
该架构的中心是一个代表概念视图的矩形。这个概念视图可能是整个系统的实体关系模型或表关系图,它由数据库管理员整合,旨在统一地理解系统中所有数据的存储需求与关联方式。

从顶层看,由计算机终端代表的各个外部应用程序或用户,并不都拥有相同的系统概念视图。
以下是不同视图的核心描述:
- 外部视图:每个应用程序或用户根据自身需求与数据交互的视角。他们只关心对自己重要的数据部分。
- 概念视图:由数据库管理员定义的、统一的、全局的数据逻辑结构。它是所有外部视图映射的基础。
- 内部视图:数据库管理系统实际在磁盘上存储和组织数据的方式。在分布式系统中,可能存在多个内部视图,但它们都映射到单一的概念视图。
ANSI SPARC委员会定义了这三个层级的视图,并且每一层都可以映射到另一层。
视图示例
理解了架构的层次后,我们通过一个具体例子来看看这些视图是如何工作的。
从外部视图的角度,某个用户应用程序可能只关心“优质客户”这个概念。

以下是各视图在该场景下的具体体现:
- 外部视图(用户视角):用户通过一个名为
GOOD_CUSTOMER的视图与数据库交互。这个视图可能通过一个SQL查询来定义,例如:
用户只与这个视图交互,而不直接接触底层表。CREATE VIEW GOOD_CUSTOMER AS SELECT customer_id, customer_name FROM CUSTOMER WHERE customer_id IN ( SELECT customer_id FROM ORDERS GROUP BY customer_id HAVING COUNT(*) > 10 ); - 概念视图(DBA视角):数据库管理员定义整个系统的逻辑结构。例如,他需要创建
CUSTOMER表和ORDERS表。CUSTOMER表可能包含customer_id、customer_name、customer_address等列。最终用户并不直接访问这些基础表。 - 内部视图(系统视角):数据库系统决定如何在物理磁盘上存储这些数据。它可能为
customer_id列建立索引,使用不同的内部列名,并确定数据块的具体存储格式。
架构中的角色与流程
上一节我们通过例子理解了视图,本节我们来剖析架构图中各个组件代表的角色与流程。这张示意图描述了ANSI SPARC架构中各个实体如何协作。
图中,阴影六边形代表角色,矩形代表处理过程,而三角形代表模式集成点。
以下是关键角色与流程的解析:
- 企业管理员:负责定义数据库的概念模式。他需要理解系统中的所有数据需求与关系。
- 数据库管理员:负责将概念模式转化为内部模式。他需要关心数据在磁盘上的实际布局。
- 应用系统管理员:负责为外部用户定义外部模式。
- 模式集成:所有定义好的模式(概念、内部、外部)都汇集于此,并在此完成相互间的映射与转换。
- 应用程序员:编写应用程序,通过外部模式与数据库交互。其查询请求会经由外部模式、概念模式、内部模式层层转换,最终抵达数据库;返回的数据也沿原路径反向转换,以外部视图的格式呈现给程序。
- 应用系统程序员:编写更底层的应用程序,主要与数据库的内部结构直接交互。
整个数据请求的流程可以概括为:应用程序 -> 外部模式 -> 概念模式 -> 内部模式 -> 物理数据库。返回的路径则相反。
总结

本节课中我们一起学习了ANSI SPARC数据库三层架构。我们了解了外部视图如何满足不同用户的特定需求,概念视图如何提供统一的数据逻辑模型,以及内部视图如何管理数据的物理存储。我们还通过示意图分析了数据库管理员、应用程序员等不同角色在此架构中的职责与交互流程。该架构的核心价值在于实现了数据的逻辑独立性与物理独立性,使得用户访问、逻辑设计和物理存储可以相对独立地变化。
006:数据库架构分类 🗂️
概述
在本节课中,我们将学习分布式数据库系统的架构分类。我们将探讨三个独立的衡量维度:自治性、分布性和异构性,并了解如何通过这些维度来定义和设计不同的分布式数据库系统。

自治性的不同定义

上一节我们介绍了分布式数据库系统的三个衡量维度。本节中,我们来看看第一个维度——自治性。自治性指的是数据库系统在参与全局分布式系统时,其本地操作不受影响的程度。
以下是几种不同的自治性定义:
- Gligor 和 Luck 的定义:本地操作不受参与全局多数据库系统的影响。这意味着连接到分布式数据库的任何数据库都可以独立运行,无需了解全局模式。全局系统的查询处理和优化也不受全局查询访问的影响。
- Garcia-Molina 的定义:他们从不同方面衡量自治性。
- 设计自治性:数据库是否必须使用共同的数据库模型和事务管理方案。
- 通信自治性:数据库是否能自主决定向分布式系统提供哪些数据。
- 执行自治性:数据库是否能按自己的方式执行查询,而不是由分布式系统强制规定执行方式。
我们的教材作者 Özsu 和 Valduriez 则从集成程度的角度定义了三种自治性类别:
- 紧密集成:呈现单一数据库映像。
- 半自治:数据库自行决定共享、交换和修改哪些数据。
- 完全隔离:数据库是独立的,参与分布式系统但不关心其他数据库的数据表示。
从紧密集成到完全隔离,构成了自治性的一个连续谱系。完全隔离意味着完全自治。这是分布式数据库系统的一个关键设计特性,设计者可以选择谱系上的任意一点。
分布性:从集中到对等
理解了自治性后,我们转向第二个维度——分布性。它描述了数据库功能在物理或逻辑上的分散程度。


以下是分布性的主要类别:
- 非分布式:所有功能集中在一处。
- 客户端-服务器架构:数据库功能分布在客户端和服务器之间。
- 对等架构:完全分布式的系统,每个节点平等协作。
分布性维度与自治性维度是正交的。在设计分布式数据库时,你可以独立选择这两个维度的属性,并以任意方式组合它们。
异构性:同质与异质的系统
最后,我们探讨第三个维度——异构性。它衡量被集成的各个数据库系统之间的差异程度。

异构性可能体现在以下几个方面:
- 不同的硬件平台。
- 不同的数据模型。
- 不同的查询语言。
- 不同的事务模型。
系统间的差异越大,异构性就越高。在设计架构时,你需要评估并决定系统需要处理多大程度的异构性。
架构组合示例

现在,我们将三个维度结合起来看。你可以选择这三个维度的不同组合来定义你的分布式数据库系统。
例如:
- 组合一:
自治性=0(紧密集成),分布性=2(对等),异构性=1(同质平台)。这定义了一个紧密集成、完全分布但在同质平台上的系统。 - 组合二:
自治性=1(半自治),分布性=0(非分布),异构性=1(异构访问)。这类似于一个位于单一机器上但能处理异构数据源的联邦数据库系统。 - 组合三:
自治性=2(完全自治),分布性=1(分布),异构性=1(异构)。这描述了一个由散布在不同机器上、完全自治且彼此异构的数据库组成的系统。
你可以通过选择自治性、分布性和异构性的级别,来构建任何形式的分布式数据库架构。

客户端-服务器架构详解
客户端-服务器架构是分布性维度中的一种常见模式。让我们更详细地了解它。
在分布性为 D1 的客户端-服务器模型中,通常有几种配置:
- 多客户端对单服务器:多个客户端访问一个中央数据库服务器。
- 多客户端对多服务器:这又衍生出两种子模式:
- 客户端管理集成:每个客户端应用程序自己负责协调访问多个服务器。这增加了客户端的复杂性,因为每个应用都需要了解如何集成不同服务器的数据。
- 服务器管理集成:客户端只访问一个“中间”服务器,由该服务器负责将请求分发和管理到其他后端服务器。这实现了“瘦客户端”,将数据集成的工作负担放在了服务器端。
从用户视角看,客户端-服务器架构的典型分层如下:

[应用程序/用户界面] -> [客户端DBMS] -> [通信软件]
↓
[服务器通信层] -> [查询处理器] -> [语义数据控制器/查询优化器/事务管理器等]
所有数据库核心功能(如查询优化、事务管理、恢复)都集中在服务器端。
对等架构详解
接下来,我们看看完全分布式的对等架构。

对等架构通常意味着较低的自治性(A0),因为所有节点都需要意识到彼此是更大系统的一部分,并协同工作。但它提供了数据位置上的独立性。
对等架构的关键特点是具有一个全局概念模式。其架构层次如下:
[外部模式1] [外部模式2] ... [外部模式N]
↓ (映射)
[全局概念模式]
↓ (映射)
[本地概念模式1] [本地概念模式2] ...
↓ ↓
[本地内部模式1] [本地内部模式2] ...
在对等架构中,一个核心的分布式数据库管理系统层负责处理整个查询。其工作流程是:
- 用户通过用户界面处理器提交查询。
- 分布式DBMS层(包含语义数据控制器)接收查询。
- 该层利用外部模式、全局概念模式和全局数据字典来理解查询。
- 随后进行全局查询优化,并决定如何将查询分解。
- 将子查询分发给各个本地数据库服务器。
- 本地服务器使用自己的本地模式处理子查询。
- 结果返回给分布式DBMS层进行整合,最终返回给用户。


这种架构将许多集成与优化工作提升到了中间的分布式管理层,而本地服务器专注于执行。
总结

本节课中,我们一起学习了分布式数据库系统的架构分类方法。我们掌握了三个核心的衡量维度:自治性(从紧密集成到完全隔离)、分布性(从集中式、客户端-服务器到对等式)以及异构性(系统间的差异程度)。通过选择这三个维度的不同级别,可以组合出多种多样的分布式数据库架构。我们还深入分析了常见的客户端-服务器架构和对等架构在结构和工作流程上的特点与区别。理解这些分类是设计和选用合适分布式数据库解决方案的基础。
007:多数据库管理系统 🗃️
概述
在本节课中,我们将要学习多数据库管理系统的基本概念、架构及其与分布式数据库管理系统的关键区别。我们将探讨多数据库系统的设计挑战、集成方式以及不同用户群体如何与之交互。
多数据库管理系统介绍


欢迎来到第5单元,这是分布式数据库系统课程的一个模块。在本单元中,我将讨论多数据库管理系统。
实际上,在整个课程中我不会过多讨论这个话题。但为了完整描述不同的分布式数据库系统架构,有必要在此提及。我们注意到,这里讨论的是第二级自治性。这意味着您要集成的所有数据库系统都具有相当高的自治性。这些是已经存在的数据库系统,它们拥有自己的用户群体。现在有人来集成它们,形成一个分布式数据库系统的联邦。
要求是它们仍然必须自主运行。集成它们很困难。集成多数据库管理系统和分布式数据库管理系统之间的差异如下。
多数据库管理系统 vs. 分布式数据库管理系统
在多数据库管理系统中,您处理的是自底向上的设计。您已经拥有可访问的数据库,您的任务是集成它们。您对这些数据库的内部了解较少,对它们的设计方式、存放位置、使用方式以及如何集成的控制也较少。

您通常只有有限的能力来集成数据。通常会有一个全局概念模式,用于描述联邦中呈现的数据,并映射到各个数据库系统包含哪些数据。并非所有单个数据库系统都愿意共享其全部数据,它们可能只展示部分数据。因此,全局概念模式只描述它们共享的部分以及这些数据库之间的共性和关系。
分布式数据库管理系统是我们课程大部分时间将要讨论的内容,它是一种自顶向下的设计。您可以控制单个数据库的存放位置、这些数据库上存放哪些数据片段或表、如何控制全局概念模式。实际上,全局概念模式描述了所有数据库或这些数据库的并集,因为您设计了它们,您知道设计应该是什么样子,也知道如何布局。
因此,多数据库系统集成起来稍微困难一些。
多数据库系统架构示意图
在这个示意图中,请注意我们有两个本地数据库系统。它们的用户群体已经非常成熟。因此,您有本地内部模式1、本地内部模式2、本地概念模式1、本地概念模式2。这些都是两个独立的数据库系统及其概念模式。用户群体拥有它们的本地外部模式1.1、1.2、1.3,与本地概念模式1通信。这意味着已经存在一个预先建立的用户群体,通过外部模式访问数据库1。数据库2也有一个用户群体。
现在,您来定义一个全局概念模式,通过该模式以某种方式集成所有本地数据库。这个全局概念模式支持的是全局外部模式。因此,您现在有了一个新的用户群体,他们通过联邦与分布式数据库系统交互。实际上,您集成的是一个多数据库系统,它由两个预先存在的、拥有自己用户群体的数据库系统组成。
请注意,与这些数据库交互的用户是那些使用本地外部模式的人。他们不希望改变他们的接口,他们希望系统像往常一样运行。挑战在于,您作为多数据库架构师,必须将它们全部整合在一起,确保所有这些本地外部模式仍然能够以相同的方式访问它们原有的数据库,但同时您现在还必须支持全局外部模式。

多数据库系统的交互风格
与这些多数据库系统交互有不同的风格:单语言和多语言数据库。
- 单语言 意味着数据库系统只理解一种查询语言,具有一种数据库模型,这是最常见的情况。但这给用户带来了负担。用户自己可能已经考虑了不同的模型,用户群体有自己的外部模式,有自己的语言需求。因此,现在用户需要承担用多数据库系统的新单语言来制定新查询的负担,这种语言可能与他们原本使用的查询语言不同。
- 多语言多数据库系统 中,全局概念模式必须理解许多不同的查询语言。因此,每个用户实际上可以用自己的语言向该系统制定查询。现在,这真正成为了多数据库系统的负担,它需要解释每种查询语言,并向底层企业制定查询。这要困难得多,在多数据库系统中并不常见。首先,单语言多数据库管理系统要常见得多。
无全局概念模式的模型
有些模型根本没有全局概念模式。事实上,许多集成分布式数据库系统或多数据库系统的商业产品实际上并没有全局概念模式。因此,需要您在没有本地模式的情况下管理许多不同的数据库系统。
您基本上集成了数据库系统,现在您看到的不是单个表,而是所有表都呈现在您面前。由您来制定查询,由您来制定连接操作,将这些数据整合在一起是您的责任。每次制定查询时,您都必须理解外部模式(您的视图)和本地模式之间的映射。这需要您对所集成的所有数据库系统有更多的了解,您必须成为理解它们的语义、关系以及如何制定查询的专家。
这是一个没有全局概念模式的多数据库系统示意图。您会注意到,多个外部模式与许多不同的本地模式交互,实际上是由它们直接与这些本地模式交互。现在,集成系统可能提供了在本地模式之间执行连接操作的能力,这确实使外部用户更容易操作,但外部用户仍然需要了解所有可用的数据,并且必须了解如何将数据连接在一起以获得所需的结果。
总结

本节课中,我们一起学习了多数据库管理系统。我们了解了它与分布式数据库管理系统在设计和控制上的关键区别,即自底向上集成现有自治系统与自顶向下设计可控系统。我们探讨了多数据库系统的架构,包括支持新旧用户群体的全局和本地模式。我们还分析了单语言与多语言系统的不同交互风格及其对用户的负担。最后,我们认识到,即使在没有全局概念模式的模型中,集成工作依然存在,并且对用户的专业知识要求更高。理解这些概念有助于我们认识在现实世界中集成异构数据库系统所面临的挑战。
008:关系代数表示法 📚
在本节课中,我们将要学习关系代数的基本概念和核心操作。关系代数是数据库查询的理论基础,它定义了一系列对表(关系)进行操作的符号和方法。理解这些操作对于掌握SQL查询和数据库设计至关重要。
概述
关系代数包含一系列对表(关系)进行操作的运算。这些表可以视为具有行和列的集合。核心操作包括选择、投影、并集、差集和笛卡尔积。此外,还有一些派生操作,如交集、θ连接和自然连接,它们可以看作是基本操作的组合。
基本操作


上一节我们介绍了关系代数的概述,本节中我们来看看其基本操作。这些是构成更复杂查询的基础。
选择 (Selection)
选择操作用于根据特定条件筛选表中的行。其符号表示为 σ。
公式: σ<条件>(R)
其中,R 是一个关系(表),<条件> 是一个逻辑表达式(例如,cost = 3)。该操作会返回 R 中所有满足条件的行。
以下是选择操作的示例:
- 假设有一个零件表
Part,包含零件号、零件名、成本和制造商。 - 执行
σ<cost=3>(Part)会选择出成本等于3的所有行。 - 在SQL中,这等价于:
SELECT * FROM Part WHERE cost = 3;



投影 (Projection)
投影操作用于选择表中的特定列。其符号表示为 π。
公式: π<属性列表>(R)
其中,<属性列表> 是你希望从关系 R 中提取的列名。
以下是投影操作的示例:
- 从
Part表中,执行π<part_name, cost>(Part)会创建一个只包含part_name和cost两列的新表。 - 在SQL中,这等价于:
SELECT part_name, cost FROM Part;

并集 (Union)


并集操作用于合并两个具有相同列结构(即相同模式)的表。其符号表示为 ∪。
公式: R ∪ S
该操作返回一个包含 R 和 S 中所有不重复行的新关系。
以下是并集操作的示例:
- 假设
R和S是两个结构相同的零件表。 R ∪ S的结果是包含两个表中所有零件信息的一个表。- 在SQL中,这等价于:
SELECT * FROM R UNION SELECT * FROM S;(注意:UNION会自动去重)。
差集 (Set Difference)


差集操作用于从一个表中移除也存在于另一个表中的行。其符号表示为 -。
公式: R - S
该操作返回所有属于 R 但不属于 S 的行。
以下是差集操作的示例:
R - S的结果是仅存在于R表中的行。- 在SQL中,可以使用
EXCEPT或NOT IN子查询来实现。
笛卡尔积 (Cartesian Product)
笛卡尔积将两个表中的每一行进行两两组合。其符号表示为 ×。


公式: R × S
如果 R 有 m 行,S 有 n 行,那么结果将有 m × n 行。
以下是笛卡尔积的示例:
- 结果表的每一行都是
R的一行与S的一行的拼接。 - 在SQL中,这可以通过没有连接条件的
FROM子句实现:SELECT * FROM R, S;
派生操作
上一节我们介绍了五种基本操作,本节中我们来看看由它们组合而成的派生操作。这些操作提供了更便捷的表达方式。

交集 (Intersection)

交集操作用于找出两个表中共同存在的行。其符号表示为 ∩。
公式: R ∩ S
该操作返回同时属于 R 和 S 的所有行。值得注意的是,交集可以通过差集来定义:R ∩ S = R - (R - S)。
以下是交集操作的示例:
- 在SQL中,这等价于:
SELECT * FROM R INTERSECT SELECT * FROM S;
θ连接 (Theta Join)


θ连接是笛卡尔积后接选择操作的组合。它根据两个表之间属性的某种条件(θ,如 =, <, >)进行连接。其符号表示为 ⋈<条件>。
公式: R ⋈<R.A θ S.B> S
这等价于:σ<R.A θ S.B>(R × S)。
以下是θ连接操作的示例:
- 例如,可以根据“R.制造商编号 < S.制造商编号”这样的条件来连接两个表。
- 在SQL中,这等价于:
SELECT * FROM R JOIN S ON R.manufacturer_id < S.manufacturer_id;
自然连接 (Natural Join)

自然连接是一种特殊的等值连接,它自动根据两个表中所有同名的属性进行等值匹配,并在结果中合并这些同名列。其符号表示为 ⋈。
公式: R ⋈ S
以下是自然连接操作的示例:
- 如果
R表和S表都有一个名为manufacturer_id的列,那么R ⋈ S会基于manufacturer_id相等的条件连接两表,并且结果中只有一个manufacturer_id列。 - 在SQL中,可以近似表示为:
SELECT * FROM R NATURAL JOIN S;或者SELECT * FROM R JOIN S USING (manufacturer_id);
总结


本节课中我们一起学习了关系代数的核心表示法。我们从选择、投影、并集、差集和笛卡尔积这五个基本操作开始,理解了它们如何对表进行筛选、列选择、合并和组合。接着,我们探讨了由基本操作派生出的交集、θ连接和自然连接,这些操作使得表达常见的表连接查询变得更加简洁和直观。掌握这些关系代数操作是深入理解SQL查询语言和数据库系统内部工作原理的关键一步。
009:自顶向下的设计流程 🗺️


在本节课中,我们将学习数据库系统设计中的“自顶向下流程”。这是一种从宏观需求出发,逐步细化到具体物理实现的设计方法。我们将了解其核心步骤,以及每个步骤如何为构建一个高效、可扩展的分布式数据库系统奠定基础。
上一节我们介绍了课程背景,本节中我们来看看自顶向下设计流程的具体步骤。
该流程始于需求分析。需求分析涉及分析需要存储哪些数据、数据之间的关系、涉及哪些类型的表、表中将包含哪些类型的字段、谁将访问数据库以及这些用户将从何处访问数据库。所有这些需求都在需求分析过程中被收集起来。
在需求收集完毕后,工作实际上会分为两个不同的活动。
- 概念设计:这是从完美数据库和完美理解字段、实体及关系的角度出发,对整个数据库进行设计。我们为数据库提出一个完美的概念设计。这是将数据库作为一个整体来看待,就好像所有用户都在查看这个包含了所有人所需信息的数据库,并理解数据之间是如何关联的。这个过程实际上产生了一个良好的全局概念模式,它描述了整个系统如何表示以及这些表示之间的关系,最终生成一个全局ER图。
- 视图设计:这特别源自系统的用户。这些是系统的外部模式视图,描述了单个应用程序或单个用户希望如何使用数据。并非每个用户都需要使用概念设计的每个方面。一些用户可能对概念设计的某些部分有非常复杂的视图。这些因素在设计数据库系统的外部视图时都会被考虑进去。
因此,从全局设计的角度来看,全局概念模式是分布式设计的输入。同时,关于单个用户如何通过视图访问信息的信息,以及来自外部用户的外部模式定义,都会进入分布式设计的定义过程,并参与到在整个企业范围内布局分布式设计的过程中。
一旦我们根据概念设计的所有实体和关系,结合理解谁需要什么数据以及基于需求和访问信息将数据放置在何处,完成了在整个企业范围内的分布式设计布局,分布式设计就完成了。此时,所有的表都分布在数据库系统中。
在数据库系统完成分布式布局后,每个独立的数据库将根据其本地概念模式进行设计。每个作为这个更大分布式数据库系统成员的数据库上,都会有一个物理设计。这就是分布式数据库系统的初始布局。
在使用之后,我们将观察到使用模式,了解哪些是有效的、哪些是高效的、哪些是低效的。基于这些反馈,我们将能够将这些信息反馈给新的需求分析。这显然不是从头开始,但可能会修改概念设计、视图设计,并最终可能修改分布式设计和物理设计。这种反馈、需求分析和设计的循环过程将持续进行,直到我们拥有一个稳定的数据库系统。

本节课中,我们一起学习了自顶向下的数据库设计流程。我们了解到,该流程始于全面的需求分析,随后并行开展概念设计(定义全局数据模型)和视图设计(定义用户视角)。这两者的输出共同指导分布式设计,决定数据如何在系统中分布。最后,每个分布节点会进行本地概念模式和物理设计。整个过程是一个包含反馈循环的迭代过程,通过实际使用反馈不断优化设计,直至系统稳定。
010:水平分区 🗂️

概述

在本节课中,我们将要学习数据库系统中的分片概念。分片是一种将大型数据库表拆分成更小、更易管理的部分的技术,这对于构建大规模、高性能的分布式系统至关重要。我们将重点探讨两种主要的分片方式:水平分片和垂直分片。
水平分片介绍
上一节我们介绍了分片的基本概念,本节中我们来看看第一种分片方式:水平分片。

给定一个简单的项目表,它包含四个列:项目编号、项目名称、预算和地点。这个表只有四个条目:项目1、2、3和4。水平分片是指将这张表按行拆分成不同的集合,这些行集合会被不同的应用程序以不同的方式访问。
例如,我们可以将项目1定义为低预算项目,项目2定义为高预算项目。这样,我们就将包含所有列的同一张数据库表,拆分成了两个不同的行集合。对低预算数据感兴趣的用户会访问一个行集合,而对高预算数据感兴趣的用户会访问另一个行集合。
这是一种逻辑上的分片。其假设是,不同的用户对不同类型的数据有不同的需求。例如,可能芝加哥的用户需要访问低预算项目,而西雅图的用户需要访问高预算项目。或者,可能访问低预算数据的人更多,而访问高预算数据的人较少。
当人们对不同类型的数据有不同需求时,在分布式数据库系统中进行水平分片通常是合理的。我们可以将一个分片放在一个位置,另一个分片放在另一个位置,使需要低预算信息的用户能就近访问他们的数据,需要高预算信息的用户也能就近访问他们的数据。
以下是水平分片的一个简单示例:
-- 原始表
CREATE TABLE projects (
p_number INT,
p_name VARCHAR(255),
budget DECIMAL,
location VARCHAR(255)
);
-- 水平分片:低预算项目
CREATE TABLE projects_low_budget AS
SELECT * FROM projects WHERE budget < 100000;
-- 水平分片:高预算项目
CREATE TABLE projects_high_budget AS
SELECT * FROM projects WHERE budget >= 100000;
在水平分片中,我们总是可以通过并集操作来重建原始表:
公式:原始表 = 分片1 ∪ 分片2 ∪ ... ∪ 分片N
垂直分片介绍
了解了水平分片后,我们来看看另一种分片方式:垂直分片。
垂直分片是指将同一张表按列拆分开。我们假设有些用户或应用程序只对财务信息感兴趣,而另一些只对项目信息感兴趣。这些用户或应用程序可能位于不同的地理位置,或者他们对数据的访问模式不同。例如,财务信息可能比项目信息被访问得更频繁。

由于对这些垂直分片的访问模式存在差异,将表拆分成垂直分片是有意义的。需要注意的是,在进行垂直分片时,总需要有一个特殊的列用于重建原始表。在这个例子中,项目编号列就是这样一个列。因为最终我们需要重建原始表,而这是通过在项目编号列上进行连接操作来实现的。
以下是垂直分片的一个简单示例:
-- 垂直分片1:项目财务信息
CREATE TABLE projects_financial AS
SELECT p_number, budget FROM projects;
-- 垂直分片2:项目基本信息
CREATE TABLE projects_info AS
SELECT p_number, p_name, location FROM projects;
在垂直分片中,我们需要通过连接操作来重建原始表:
公式:原始表 = 分片1 ⨝ 分片2 ⨝ ... ⨝ 分片N (在公共键上连接,如 p_number)
与水平分片使用并集不同,垂直分片需要使用连接,并且依赖于一个公共标识符列来确保数据能正确关联。
总结
本节课中我们一起学习了数据库分片的两种核心策略。
- 水平分片:按行拆分数据。适用于不同用户群体访问数据不同子集的情况。重建方式为并集。
- 垂直分片:按列拆分数据。适用于不同应用关注表中不同属性集的情况。重建方式为在公共键上的连接。

理解这两种分片方法及其适用场景,是设计高效、可扩展的分布式数据库系统的基础。在后续课程中,我们将深入探讨如何具体实施和优化这些分片策略。
011:实现正确分片 📊

在本节课中,我们将学习如何确保对数据库表进行分片操作的正确性。分片是一种将大型数据库表拆分成更小、更易管理的部分的技术。为了确保分片后的数据依然完整、可重构且无冗余,我们需要遵循三个核心规则。

分片正确性三规则
上一节我们介绍了分片的基本概念,本节中我们来看看确保分片正确性的三个具体规则。
规则一:完整性 ✅
完整性规则要求,当一个关系(表)R被分片成n个关系(片段)时,必须确保R中的所有数据项都存在于这些片段中。这不仅包括当前已存在的数据,也包括未来可能插入的任何数据项,它们最终都会被分配到某个片段中。
核心概念:确保所有数据都被包含,且未来数据也能正确归位。

规则二:可重构性 🔄
在将关系拆分成多个片段后,我们必须确保存在某种关系运算符(记作∇),能够从这些片段中重构出原始关系。这个运算符的具体形式取决于分片的类型。
以下是不同分片类型对应的重构运算符:
- 对于水平分片,重构运算符是并集(Union)。
- 对于垂直分片,重构运算符是连接(Join)。
核心概念:重构关系 R = 片段1 ∇ 片段2 ∇ ... ∇ 片段n

规则三:不相交性 🚫
不相交性规则要求确保所有数据记录只存在于一个片段中,避免数据重复。
以下是针对不同分片类型的解释:
- 在水平分片中,每一行数据只能出现在一个片段里。
- 在垂直分片中,每一列(除了用于重构关系的关键列)也只能出现在一个片段里。
核心概念:确保在整个片段集合中没有重复的数据行或数据列(关键列除外)。
总结

本节课我们一起学习了实现正确分片必须遵循的三个核心规则:完整性、可重构性和不相交性。遵循这些规则能确保分片后的数据依然保持逻辑上的完整与一致,为后续的高效数据管理打下坚实基础。
012:水平分片实践 🧩


概述
在本单元中,我们将学习水平分片的具体实践方法。我们将探讨如何通过算法确定合适的分片规则,以确保每个分片内的数据访问概率均匀,而不同分片间的访问概率存在差异,从而实现高效的数据分布。
水平分片的目标
上一节我们介绍了水平分片的基本概念,本节中我们来看看其核心目标。
水平分片的最终目标是创建不同的数据片段。每个片段应包含一组记录(行),并且对于访问该片段的每个应用程序(或至少一个应用程序)而言,该片段内的每个元组都具有相等的访问概率。
这意味着,当一个应用程序查看某个片段时,该片段内可能有20行不同的数据,但该应用程序访问其中任何一行的可能性应该是相同的。如果某些行的访问概率明显高于其他行,这就提示我们可能需要进一步细分该表,或者当前的分片方案不正确,需要重新调整。
反之,我们希望确保如果定义了两个不同的片段,那么每个片段中行的访问概率应该是不同的。也就是说,如果行A在一个片段中,行B在另一个片段中,那么不同应用程序(甚至是同一个应用程序)访问行A与行B的概率应有显著差异。
我们可以走向一个极端:将每一行都分到自己的片段中。虽然这在理论上可能正确,但在分布式数据库系统中不会带来任何效率提升。我们希望尽可能多地将行放在一起,同时又不违反上述关于不同访问概率的规则。因此,关键在于找到正确的行集合和分片规则。
此外,我们还需要保证完整性,即确保所有行都恰好归属于一个片段,且仅归属于一个片段。
分片的形式化定义与核心算法
理解了目标后,我们需要一个系统的方法来实现它。以下是水平分片的核心算法步骤。
算法概述
该算法旨在找到一组“谓词”,用于将原始关系划分成多个水平片段,使得不同片段具有不同的访问概率特征。以下是五个关键步骤:
- 寻找完整且最小的谓词集
首先,我们需要从所有可能的谓词中,筛选出那些能对数据访问模式产生显著影响的谓词。例如,对于一个包含budget列的表,谓词可以是budget > 200000和budget <= 200000,这可能区分了高预算和低预算项目,从而影响访问频率。算法会评估每个谓词,保留那些能导致不同访问概率的谓词,剔除无关紧要的谓词。

-
定义完整的极小项集
获得相关谓词集后,我们需要生成所有可能的谓词组合,即“极小项”。如果初始有n个谓词,那么极小项的数量是2^n - 1(排除空组合)。例如,有5个谓词(P1, P2, P3, P4, P5),极小项包括单个谓词(如M1=P1)、两个谓词的合取(如M6=P1∧P2)、三个谓词的合取等,直到所有五个谓词的合取。 -
推导并消除矛盾
某些极小项可能是矛盾的,无法适用于任何数据行。例如,如果P1是location = 'Montreal',P2是location = 'New York City',那么极小项P1 ∧ P2就是矛盾的,因为一行数据不能同时位于两个城市。这一步需要识别并排除所有矛盾的极小项。 -
消除被包含的极小项
如果某个极小项的逻辑条件包含了另一个极小项的所有条件,则称前者“包含”后者。例如,如果极小项C是P1 ∧ P2,而极小项A是P1,那么C就包含了A。在这种情况下,更具体的极小项C会使得A变得冗余。因此,我们需要消除那些被其他极小项包含的冗余项,保留最具体、最明确的极小项来定义分片。 -
确定最终分片方案
经过以上步骤,我们得到了一组无矛盾、无冗余的极小项。每个极小项定义了一个逻辑条件,满足该条件的所有数据行将构成一个独立的水平片段。这些片段共同覆盖了原始关系的所有数据,且彼此互斥。
实践中的挑战与技巧

理论算法需要结合实际判断。在实践中,确定初始相关谓词集可能比想象中困难。
通常,分析应用程序的初始查询集是识别关键谓词的有效方法。但需要注意的是,一个最初看起来相关的谓词(如location),在深入分析后,可能会发现其影响实际上被另一个更根本的谓词(如budget)所掩盖(例如,高预算项目恰好都位于某个特定地点)。因此,算法过程是一个迭代和精炼的过程,需要经验和判断力。

总结
本节课中,我们一起学习了水平分片的实践方法。我们明确了分片的目标是创建访问概率均匀的片段,并介绍了实现这一目标的五步算法:1) 寻找最小完整谓词集,2) 生成极小项,3) 消除矛盾项,4) 消除冗余项,5) 确定最终分片。这个过程结合了形式化算法与对实际数据访问模式的理解,是设计高效分布式数据库的关键步骤。

在接下来的课程和配套网站示例中,你将看到一个具体的、简单的实例,来演示这个算法是如何实际运作的。

第3模块第4单元结束。
013:派生水平分片 📊


概述
在本节课中,我们将要学习派生水平分片的概念。这是一种在分布式数据库系统中,基于一个表的分片方式,自动推导并创建与其相关联的另一个表的分片策略。这种方法旨在优化查询性能,特别是减少跨数据库的联接操作。
1. 课程背景与示例模式

上一节我们介绍了水平分片的基本概念,本节中我们来看看一种特殊的水平分片策略——派生分片。
我们使用一个贯穿课程的“三元”模式作为示例。该模式包含以下关系:
- Pay 表:包含
title(职位)和salary(薪水)列。 - Employee 表:包含
employee number(员工号)、employee name(员工姓名)和title(职位)列。 - Assignment 表:记录员工与项目的分配关系。
- Project 表:项目信息。
它们之间的关系是:
Pay和Employee之间存在一对多关系(一个薪酬等级对应多名员工)。Employee和Assignment之间存在一对多关系(一名员工可参与多个项目分配)。Project和Assignment之间也存在一对多关系(一个项目可有多个分配记录)。Assignment表的主键由employee和project组合构成,确保每个员工对特定项目的分配是唯一的。
2. 派生分片的基本原理
假设我们已经根据薪水对 Pay 表进行了水平分片,创建了两个片段:
- Fragment Pay1:
σ salary ≤ 100,000 (Pay) - Fragment Pay2:
σ salary > 100,000 (Pay)
现在,我们将这两个分片分布到分布式数据库的不同节点上。
由于 Employee 表通过 title 列与 Pay 表关联(一对多关系),我们可以根据 Pay 表的分片来“派生”出 Employee 表的分片。具体方法是:将原始的 Employee 表分别与每个 Pay 片段进行联接(JOIN),联接结果就构成了 Employee 表对应的派生片段。
核心操作公式:
Employee_i = Employee ⋈ Pay_i (其中 i 为 1 或 2,代表 Pay 表的片段)

这样做的优势在于:当查询需要联接 Pay 和 Employee 表时(例如,查询某个薪水段的所有员工),数据库只需在同一个节点上对共存的 Pay_i 和 Employee_i 片段进行本地联接,无需进行代价高昂的跨节点(跨数据库)联接操作,从而极大地提升了查询效率。
因此,派生分片的策略是:让派生表(如 Employee)的分片方式与主表(如 Pay)保持一致,并将对应的片段放置在同一数据库节点上。
3. 处理多对多关系的派生分片
现在我们来处理一个更复杂的情况。请注意,Assignment 表同时位于两个一对多关系的“多”方:它既依赖于 Employee,也依赖于 Project。
假设 Employee 表和 Project 表都已经被分片。那么,Assignment 表应该根据哪一个来派生分片呢?这里有两个主要的考量因素:
以下是选择依据:
- 更好的联接特性:评估哪种分片方式能带来更高效的联接。但这取决于许多动态因素,如片段大小、分布式联接算法、并行查询能力、所需的合并操作量等,难以预先精确计算。
- 更频繁的应用使用:分析数据库应用程序的访问模式。如果大多数应用更频繁地联接
Employee和Assignment表,而较少联接Project和Assignment表,那么根据Employee的分片来派生Assignment的分片就是更合理的选择,因为这能优化最常用的查询路径。

通常,选择依据2(更频繁的应用使用) 是更实际和可操作的策略。
4. 派生分片示例与分布
让我们看一个具体例子。假设 Employee 表被分成了三个水平片段(E1, E2, E3)。

如果我们决定根据 Employee 的分片来派生 Assignment 表的分片,那么:
Employee的第一个片段(E1)会通过联接派生出Assignment的第一个片段(A1)。Employee的第二个片段(E2)派生出Assignment的第二个片段(A2)。Employee的第三个片段(E3)派生出Assignment的第三个片段(A3)。
在分布式部署时,Employee 的每个片段及其派生出的 Assignment 片段,将被放置到同一个数据库节点上。
5. 为何不进行双向派生?
你可能会问,为什么不同时根据 Employee 和 Project 两者来对 Assignment 进行派生分片呢?
这会导致问题迅速复杂化。假设 Employee 有3个片段,Project 也有3个片段。如果试图同时满足两者的派生关系,理论上 Assignment 表需要被分成 3 × 3 = 9 个片段。

这9个片段在分布式环境中很难有效放置。无论怎样分布,当进行 Employee-Assignment 或 Project-Assignment 联接时,都几乎不可避免地需要跨节点操作,这完全违背了我们通过派生分片来减少跨节点联接、提升性能的初衷。因此,双向派生通常不是个好主意。
6. 派生分片的正确性检验

最后,我们需要确保派生分片满足分布式数据库分片的三个正确性条件:
- 完备性:如果被派生的“主”表(如
Pay)的分片是完备的(即所有数据都在某个片段中),并且成员表(如Employee)依赖于主表,那么派生出的分片也是完备的。形式化地说,对于Employee中的每一个元组(行),在Pay表中都存在一个具有相同联接属性值的对应元组。 - 可重构性:将所有派生片段(如
Employee1,Employee2, ...)进行并集操作,必须能够重构出完整的原始Employee表。即:Employee = Employee1 ∪ Employee2 ∪ ... - 不相交性:对于主表进行的初级分片,很容易通过分片谓词(如
salary ≤ 100,000和salary > 100,000)证明其片段互不重叠。对于派生分片,如果派生过程是基于简单的联接(如我们上面的例子),不相交性通常也能得到保证。但如果分片策略非常复杂或非派生,则需要仔细检查每个元组,确保它只出现在唯一的一个片段中,这通常比较困难。
总结

本节课中我们一起学习了派生水平分片。我们了解到,这是一种通过一个表(主表)的分片策略,自动确定与之关联的另一个表(成员表)分片方式的技术。其核心目标是将需要频繁联接的数据保持在同一个数据库节点上,从而最大限度地减少分布式查询中昂贵的跨节点联接操作。在选择派生依据时,应优先考虑应用程序最常使用的联接路径。同时,我们需要确保派生分片满足完备性、可重构性和不相交性的要求。
014:垂直分片的复杂性 🔍


在本节课中,我们将要学习垂直分片(Vertical Partitioning)的复杂性,特别是与水平分片(Horizontal Partitioning)相比,其可能的分片方案数量是如何急剧增长的。
水平分片与垂直分片的对比
上一节我们介绍了数据库分片的基本概念。本节中我们来看看两种主要分片方式的复杂性差异。
我们有两种分片选项:水平分片和垂直分片。水平分片很复杂,但垂直分片更为复杂。

让我们简要讨论一下水平分片。如果谓词(predicates)的数量等于 n,那么基于最小项谓词(minterm predicates),分片的数量可能是 2^n。当每个谓词都被考虑在一个最小项中时,就会发生这种情况。虽然通常许多最小项会因为与某些谓词含义矛盾而被消除,但 2^n 仍然是一个很大的数字。这就是为什么我们需要启发式方法,以便在合理的时间内将可能的分区数量减少到可管理的范围。
但是,如果水平分片的选项数量是 2^n,那么垂直分片的选项数量就是 M^M。如果关键列的数量是 M,那么可能的分片数量就是贝尔数(Bell number),我们称之为 B(M)。对于较大的 M,它约等于 M^M。这是一个非常庞大的数字。M^M 大于 2^n。
贝尔数示例
让我通过一个例子来说明为什么这个数字如此之大。
让我们检查两列的贝尔数。对于 B(2),分片数量是 2。我们有两个选项:要么将列 B 和 A 放在一起形成一个分片,要么定义两个分片,一个包含列 B,另一个包含列 A。这非常简单,很容易检查,也很容易确定哪个更优。

对于三列来说就稍微复杂一些。观察这里的图示,你可以让 C 与 B 或 A 在一起,或者 B 和 A 在一起,或者 C 与 B 和 A 分开。这是第一行的图示。注意右边的符号,我的意思是取列 C,将其乘以 B(2)(即 C 单独存在时的情况)。这基本上就是 C 与 B 和 A 在一起,或者 C 与 B 和 A 分开。但这只是第一个选项。
我们还有将 C 与其他列组合的选项。例如,我们可以将 C 和 B 组合在一起,让 A 单独;或者将 C 和 A 组合在一起,让 B 单独;或者将 C、B 和 A 都放在一起。所以总共有五种不同的选项。这还不算太糟。
对于四列,情况变得更加复杂。我们想添加一列 D。首先,我们有 D 单独存在的五种选项(即 B(3) 的图示)。如果我们保持 D 单独,我们可以将其与 A、B、C 的三种组合中的每一种结合。我们可以有 D 和 C 在一起,列 B 和 A 在一起;D 和 C 在一起,列 B 和 A 分开;或者 D 和 B 在一起,C 和 A 在一起;D 和 B 在一起,C 和 A 分开;或者 D 和 A 在一起,C 和 B 在一起;D 和 A 在一起,C 和 B 分开。你注意到所有不同的组合了吗?因此,四列的贝尔数 B(4) 是 15。这里有15种不同的组合。

对于五列,情况就更复杂了。所以,包含两个元素的分片的贝尔数是 2,三个元素的是 5,四个元素的是 15,那么五个元素的就是 52。你可以观察我是如何将各列配对的。让我们看看五列的分片。我们说 E 与所有不同的四列分片组合在一起;或者 E 和 D 在一起,与 C、B、A 的所有不同三列组合;或者 E 和 C 在一起,与 D、B、A 的所有不同组合;或者 E 和 B 在一起,与 D、C、A 的所有组合,等等,直到我们开始映射所有三列在一起的情况:E、D、C 在一起,与 A 和 B 的所有组合;或者 E、D、A 在一起,与 C 和 B 的所有组合,等等。然后我们得到 E 作为四列之一参与的所有组合,或者将所有列放在一起。如果你数一下,加起来就是 52。

贝尔数的快速增长
贝尔数序列增长迅速。
以下是前10个贝尔数的值:
B(1) = 1B(2) = 2B(3) = 5B(4) = 15B(5) = 52B(6) = 203B(7) = 877B(8) = 4140B(9) = 21147B(10) = 115975
超过这个数字,它会变得更大。因此,给定一个简单的10列表,有 115975 种不同的垂直分片组合可供选择。这需要检查的组合数量非常庞大。

显然,当列数超过10时,我们需要一些启发式方法,来帮助我们遍历所有选项,以确定理想的垂直分片方案。我们需要启发式方法。这将是本模块其余部分的讨论内容。
总结

本节课中我们一起学习了垂直分片的复杂性。我们了解到,对于一个包含 M 列的表,可能的垂直分片方案数量是贝尔数 B(M),其值约等于 M^M,增长速度远快于水平分片的 2^n。例如,一个仅有10列的表就有超过11万种可能的分片方式。面对如此庞大的搜索空间,我们必须依赖启发式算法来在合理的时间内找到近似最优的分片方案,这将是后续课程的重点。
015:自动化聚类亲缘矩阵计算 🧮



在本节课中,我们将学习如何自动化地计算聚类亲缘矩阵。我们将深入理解全局亲缘度量的概念,并掌握邦德能量算法的具体步骤,该算法能帮助我们找到使矩阵整体亲缘度量最大化的列排列方式。
全局亲缘度量
上一节我们介绍了亲缘矩阵的基本概念,本节中我们来看看如何量化整个矩阵的“亲缘度”。
计算整个矩阵的邦德能量,实际上是在计算一个全局的亲缘度量,我们简称为 AM。
其计算公式如下:
AM = Σ_i Σ_j [ aff(i, j) * (aff(i-1, j) + aff(i+1, j) + aff(i, j-1) + aff(i, j+1)) ]
我们需要对矩阵中的每一个单元格(位置 (i, j))进行计算。具体做法是:取该单元格的亲缘值 aff(i, j),乘以它北、南、东、西四个邻居亲缘值的总和。

在计算时,需要注意边界条件:矩阵顶部、底部、左侧和右侧边界之外的亲缘值均视为 0。这意味着,如果一个单元格没有某个方向的邻居,则该邻居的亲缘值贡献为0。
计算的简化
由于矩阵具有对称性,我们不需要同时计算四个方向。我们可以选择只计算南北方向或东西方向的邻居,因为将矩阵沿对角线翻转后,这两种计算方式得到的全局亲缘度量值是相同的。
因此,公式可以简化为:

AM = Σ_i Σ_j [ aff(i, j) * (aff(i-1, j) + aff(i+1, j)) ]
或者等价地:
AM = Σ_i Σ_j [ aff(i, j) * (aff(i, j-1) + aff(i, j+1)) ]

选择其中一种方式计算即可。


邦德能量算法

理解了如何评估矩阵后,我们来看看如何通过算法自动找到最优的列排列。邦德能量算法的目标是找到能使全局亲缘度量 AM 最大化的列顺序。
以下是算法的核心步骤:
- 初始化:从属性亲缘矩阵中,任意选择两列作为起始,放入聚类亲缘矩阵的前两列位置。
- 迭代放置剩余列:对于尚未放置的每一列,尝试所有可能的插入位置。
- 评估每个位置:对于每一个可能的插入位置(例如,放在最前、插入某两列之间、放在最后),计算此时整个矩阵的邦德能量(即全局亲缘度量 AM)。
- 选择最佳位置:比较所有尝试位置计算出的邦德能量值,选择使能量值最大的那个位置,将该列插入。
- 重复迭代:对剩余的每一列重复步骤2-4,直到所有列都被放置到矩阵中。
- 重排行:最后,根据确定的列顺序,相应地重新排列矩阵的行,最终得到聚类亲缘矩阵。
总结

本节课中我们一起学习了自动化构建聚类亲缘矩阵的方法。我们首先定义了全局亲缘度量(AM) 的公式,它通过评估每个单元格与其邻居的关系来量化矩阵的整体结构。接着,我们详细介绍了邦德能量算法,该算法通过迭代地为每一列寻找最佳插入位置,最终得到一个邦德能量最大化的列排列,从而自动化地生成聚类亲缘矩阵。掌握这个算法是进行高效垂直分区的关键步骤。
016:简化势能绑定算法 🧮


在本节课中,我们将学习如何简化势能绑定算法。我们将从回顾亲和力度量开始,逐步推导出计算势能变化的简化公式,这将大大降低算法的计算复杂度。
回顾亲和力度量
上一节我们介绍了整个矩阵的亲和力度量,即水平亲和力度量。现在,我们来具体看看这个度量是如何计算的。
亲和力度量公式如下:
公式:
亲和力(A_i, A_j) = Σ (亲和力(A_i, A_j) * (亲和力(A_{i-1}, A_j) + 亲和力(A_{i+1}, A_j)))
其中,我们对矩阵的所有行和列进行双重求和。
推导列间势能

为了简化计算,我们需要将上述对整个矩阵的求和,转化为对列之间关系的度量。以下是推导步骤:
- 首先,我们将亲和力度量公式中的项进行重组,将每个单元格的亲和力与其西邻和东邻的亲和力相乘。
- 接着,我们可以进一步操作这些求和。我们可以将对行
i的求和,转化为计算两列之间所有行上亲和力值的乘积之和。

基于此,我们定义两列 A_x 和 A_y 之间的势能如下:

公式:
势能(A_x, A_y) = Σ_{z=1}^{n} [亲和力(A_z, A_x) * 亲和力(A_z, A_y)]
这个公式计算的是当两列相邻放置时,它们之间产生的“能量”总和。
利用这个新定义,原始的整个矩阵的亲和力度量可以重写为:
公式:
总亲和力 = Σ [势能(A_i, A_{i-1}) + 势能(A_i, A_{i+1})]

计算插入新列的影响


现在,考虑在一个由多列组成的矩阵中,在列 A_i 和 A_j 之间插入一个新列 A_k。我们的目标是计算插入操作前后,矩阵总势能的变化。
我们断言,插入操作不影响矩阵左侧部分(A_1 到 A_{i-1})和右侧部分(A_{j+1} 到 A_n)的内部势能。因此,我们只需要关注插入点附近的势能变化。
以下是计算势能变化 Δ势能 的步骤:
- 计算旧势能:在插入
A_k之前,A_i和A_j相邻。此时,插入点附近的势能贡献为2 * 势能(A_i, A_j)。 - 计算新势能:插入
A_k后,A_i和A_j不再相邻。新的相邻关系是A_i与A_k,以及A_k与A_j。此时,插入点附近的势能贡献变为2 * 势能(A_i, A_k) + 2 * 势能(A_k, A_j)。 - 计算势能变化:用新势能减去旧势能,得到插入操作导致的净势能变化。
公式:
Δ势能 = 2 * 势能(A_i, A_k) + 2 * 势能(A_k, A_j) - 2 * 势能(A_i, A_j)
这个公式的意义非常直观:势能的变化量等于新创建的两个相邻关系(A_i-A_k 和 A_k-A_j)的势能之和,减去被拆开的原有相邻关系(A_i-A_j)的势能。
简化算法的优势

通过上述推导,我们得到了计算势能变化的简化公式 Δ势能。与重新计算整个庞大矩阵的总亲和力相比,使用这个公式具有巨大优势:
- 计算简单:只需计算三个列对之间的势能,并进行一次加法和减法。
- 效率极高:在尝试将一个新列插入到矩阵的不同位置时,我们可以快速评估每个可能位置带来的势能增益,从而选择最优位置。
- 易于理解:算法核心变为寻找使
Δ势能最大化的插入位置,这直接对应着最大化矩阵的全局聚类亲和力。
总结

本节课中,我们一起学习了如何简化势能绑定算法。我们从完整的矩阵亲和力度量公式出发,通过定义列间势能,最终推导出计算插入新列所引起势能变化的简化公式 Δ势能。这个公式使得算法无需在每次迭代中计算整个矩阵的势能,只需进行少量列对间的计算即可做出决策,极大地提升了算法的效率和实用性。
017:垂直分区算法 🧩

在本节课中,我们将学习如何通过算法自动确定垂直分区的最佳方案。当属性数量众多时,仅凭观察亲和度矩阵来划分列变得非常困难。因此,我们需要一个系统化的方法来找到最优分区点。
上一节我们介绍了集群亲和度矩阵的概念,本节中我们来看看如何利用查询访问频率等数据,通过算法找到最佳分区点。
算法概述
垂直分区算法的目标是找到一种列的分组方式,使得经常在查询中共同使用的属性被分到同一个片段(表)中。其核心思想是评估在亲和度矩阵的每一列之间进行“切割”的收益。
我们通过一个称为 Z值 的度量来评估每个潜在切割点的优劣。Z值的计算公式如下:

Z = CTQ × CBQ - COQ²
其中:
- CTQ:只访问“顶部”片段(切割点左侧列)中属性的所有查询的总访问频率。
- CBQ:只访问“底部”片段(切割点右侧列)中属性的所有查询的总访问频率。
- COQ:必须同时访问顶部和底部片段中属性的所有查询的总访问频率。
我们的目标是最大化Z值。Z值越高,意味着该切割点能更好地将高频共同访问的属性聚集在一起,同时减少需要跨片段访问的查询。
算法步骤演示
我们通过一个具体的例子来演示算法的计算过程。假设我们有属性A1, A2, A3, A4,以及四个查询及其每小时的总访问频率:

- Q1 使用属性 {A1, A3}, 访问频率 = 45
- Q2 使用属性 {A2, A3}, 访问频率 = 5
- Q3 使用属性 {A2, A4}, 访问频率 = 75
- Q4 使用属性 {A3, A4}, 访问频率 = 3

我们已获得其集群亲和度矩阵,列顺序为 A1, A3, A2, A4。现在,我们尝试在不同的列之间进行切割。
尝试1:在A1和A3之间切割(Z1)
假设切割点在A1列之后。这意味着:
- 顶部片段(T) 包含列:A1
- 底部片段(B) 包含列:A3, A2, A4

以下是计算过程:
- CTQ:查找只访问A1(即仅属于顶部片段)的查询。没有这样的查询。因此 CTQ = 0。
- CBQ:查找只访问{A3, A2, A4}中任意子集(即仅属于底部片段)的查询。符合的查询有Q2、Q3、Q4。CBQ = 5 + 75 + 3 = 83。
- COQ:查找必须同时访问A1和{A3, A2, A4}中属性的查询。符合的查询是Q1(使用了A1和A3)。COQ = 45。
计算Z值:
Z1 = 0 × 83 - 45² = -2025
这个负值很大,表明在A1和A3之间切割是一个糟糕的选择。
尝试2:在A3和A2之间切割(Z2)
现在,将切割点移动到A3列之后。这意味着:
- 顶部片段(T) 包含列:A1, A3
- 底部片段(B) 包含列:A2, A4

以下是计算过程:
- CTQ:只访问{A1, A3}的查询。符合的查询是Q1。CTQ = 45。
- CBQ:只访问{A2, A4}的查询。符合的查询是Q3。CBQ = 75。
- COQ:必须同时访问{A1, A3}和{A2, A4}的查询。符合的查询是Q2(使用A2,A3)和Q4(使用A3,A4)。COQ = 5 + 3 = 8。
计算Z值:
Z2 = 45 × 75 - 8² = 3375 - 64 = 3311
这个Z值非常高,是一个非常好的候选切割点。

尝试3:在A2和A4之间切割(Z3)
最后,将切割点移动到A2列之后。这意味着:
- 顶部片段(T) 包含列:A1, A3, A2
- 底部片段(B) 包含列:A4
以下是计算过程:
- CTQ:只访问{A1, A3, A2}的查询。符合的查询是Q1和Q2。CTQ = 45 + 5 = 50。
- CBQ:只访问{A4}的查询。没有这样的查询。CBQ = 0。
- COQ:必须跨片段访问的查询。符合的查询是Q3(使用A2,A4)和Q4(使用A3,A4)。COQ = 75 + 3 = 78。
计算Z值:
Z3 = 50 × 0 - 78² = -6084
这个值也非常差。
算法优化与挑战
通过比较三次尝试,Z2(在A3和A2之间切割)的值3311远高于其他选项。因此,最佳垂直分区方案是创建两个片段:{A1, A3} 和 {A2, A4}。

然而,基本算法存在两个主要问题:
-
初始列排序的影响:算法的结果严重依赖于集群亲和度矩阵中列的初始顺序。一个不佳的初始排序可能隐藏了更好的分区方案。
- 解决方案:需要通过“行/列移位”来系统地尝试不同的列排列。即,将每一行及其对应的列依次移动到矩阵的末尾,对每一种新的排列重新计算所有可能的Z值,直到回到原始排列。这确保了所有可能的分组顺序都被考虑到。
-
多个切割点:上述算法只寻找一个最佳切割点,将表分成两个片段。但实际上,一个表可能被最优地分割成两个以上的片段。
- 解决方案:这需要递归地应用该算法。即在第一次切割得到的每个片段上,再次运行分区算法,看是否值得进一步分割。但挑战在于确定何时停止递归。可能的切割方案数量是属性数n的指数级(O(2ⁿ)),这使得寻找全局最优解成为一个计算复杂的问题,也是当前的研究课题。
总结
本节课中我们一起学习了垂直分区算法。该算法通过计算不同切割点的Z值(Z = CTQ × CBQ - COQ²),来量化分区方案的优劣,目标是最大化Z值。我们通过实例演示了计算过程,并找到了最佳分区点。最后,我们讨论了算法的局限性:依赖于初始列排序(可通过行/列移位优化)以及如何处理多个分区(需递归应用并面临组合爆炸的挑战)。理解这个算法是设计高效垂直分区的关键一步。
018:实施语义完整性 🛡️


在本节课中,我们将学习如何实施语义完整性控制,以确保数据库在执行更新操作后仍能保持数据的一致性和正确性。我们将探讨两种主要方法:后置测试和前置测试,并重点介绍如何使用差分关系来高效地实施约束。
欢迎来到语义数据控制
语义完整性控制是数据库管理中的一个核心概念,它确保所有数据操作都符合预定义的业务规则和约束。

实施语义完整性的挑战
实施语义完整性可能非常困难。给定一个更新数据库的操作,例如函数 U,它基本上将数据库从原始状态 D 转移到更新后的状态 DU。这意味着数据库可能增加新行、更改记录值或删除行。总之,在更新操作完成后,数据库的状态与操作开始前不同。
后置测试方法
一种处理方法是后置测试。具体流程是:先执行操作,将数据库状态从 D 更改为 DU。然后,在事务完成后测试约束条件。如果某个约束变为假,则拒绝该事务。
此时,我们需要修改更新操作,改变其更新的内容,或者撤销其操作效果。这通常需要大量工作,因为一旦数据库状态改变,就必须检查 DU 以确保其符合所有断言。此外,撤销操作本身也可能非常耗时。
前置测试方法
因此,我们倾向于选择另一种更高效的选项:前置测试。这种方法确保只有在操作执行后数据库能保持一致性状态时,才允许执行该操作。
前置测试更高效,因为它无需撤销任何操作。其原理是:在执行更新操作前,先检查如果执行此操作,数据库将进入的状态。如果该状态会违反某些断言,则根本不执行该更新操作。

以下是一个示例。假设我们有一个简单的查询,想要将某个项目的预算增加10%:

UPDATE project SET budget = budget * 1.1 WHERE project_name = 'CADCAM';

如果数据库的约束是预算必须在50万到100万之间,我们需要确保更新后的预算不会超过上限。
一种解决方法是,在执行查询前,将约束条件附加到更新语句中:
UPDATE project
SET budget = budget * 1.1
WHERE project_name = 'CADCAM'
AND NEW.budget BETWEEN 500000 AND 1000000;
这里使用了 NEW.budget 运算符,它代表预算操作生效后的新值。数据库可以提前检查此条件。如果预算确实会超过100万美元,它将拒绝该操作,并且不会更改数据库中的任何值。这样,数据库将保持其原有的一致状态。
一致性预防的局限性
然而,一致性预防方法仅适用于全称量化的元组演算公式。
这类公式的形式通常是:对于所有 x,F(x) 生效。这意味着当 F(x) 对数据库中的所有或部分数据生效时,它会更新关系的特定部分,并且该更新将仅应用于设计要更新的那些行。
我们可以将约束条件(例如之前的 Q(x))附加到查询的 WHERE 子句中,在数据进入数据库之前很久就检查其值。如果检查通过,则允许更新;否则拒绝操作,数据库保持不变。
但是,这种方法不适用于存在量化的断言,例如键约束。



请看页面中间的公式:
∀ a ∈ Assignment, ∃ p ∈ Project (a.project_number = p.project_number)
这个断言确保不会添加没有对应项目的任务行。也就是说,每次你想添加一个任务行时,必须确保该行中指定的项目编号等于项目表中的某个项目编号。
注意,这里有一个 ∃ p ∈ Project 子句。它并不适用于项目表中的所有行,因此不能像之前那样轻松地将此断言附加到更新操作中。
对于这个问题,有一些不太理想的解决方案:可以在编译时构建前置测试(但并非总是容易实现),也可以在运行时进行测试(但这需要在更新操作中构建一些复杂的逻辑)。这些方法通常只适用于对单个关系的单次更新,限制性较强。

差分关系方法
因此,我们引入了另一种方法:差分关系。这种方法非常有效,因为对某个关系 R 的每次更新都会产生一个三元组:(R, R+, R-)。
- R 是原始关系。
- R+ 是差分关系,代表更新操作要添加到 R 的新元组集合。例如,如果是纯插入操作,则 R- 为空。
- R- 也是差分关系,代表更新操作要从 R 中移除的元组集合。例如,如果是纯删除操作,则 R+ 为空。
对于更新操作,某些内容会被添加到关系中,某些内容会被移除。操作流程是:首先移除计划要删除的元素(R-),然后将新行(R+)添加到关系中。因此,关系的新版本如幻灯片最后一行所述:
R' = (R - R-) ∪ R+
编译断言的示例
编译后的断言以三元组形式表达:(R, T, C)。
- R 是目标关系。
- T 是更新操作的类型(插入、删除、修改)。
- C 是断言条件,仅在 R 和 T 匹配时才进行测试。
这意味着,只有当类型为 T 的更新作用于表 R 时,我们才测试该特定断言,并且它仅适用于 R+ 和 R-。
操作流程如下:我们启动操作,确定如果操作继续执行,将添加到数据库的内容(R+)和将从数据库减去的内容(R-)。此时 R 保持不变。我们测试 R+ 和 R- 中的值。如果它们通过断言测试,我们就更新数据库。如果断言测试失败,我们只需丢弃 R+ 和 R-,R 从未改变,数据库保持原状。
以下是任务表(Assignment)的一些断言示例:
-
插入断言 (C1):
- 作用:确保所有新插入的任务行都有一个对应的项目存在于项目表中。
- 逻辑:∀ new_a ∈ Assignment+, ∃ p ∈ Project (new_a.project_number = p.project_number)
- 目的:防止添加没有对应项目的“孤儿”任务。
-
删除断言 (C2):
- 作用:当从项目表中删除一行时,确保没有任务行仍然关联到要删除的这个项目。
- 逻辑:∀ a ∈ Assignment, ∀ old_p ∈ Project- (a.project_number ≠ old_p.project_number)
- 目的:防止在删除项目后留下“孤儿”任务。
-
修改断言 (C3):
- 作用:当更新项目表中的一行时进行处理。更新操作会产生旧行版本(old_p)和新行版本(new_p)。
- 逻辑:检查所有任务行,确保要么旧项目编号在任务表中没有对应项(即该项目没有任务),要么新行的项目编号与旧行相同(即项目编号未改变)。
- 目的:防止通过更改项目编号来破坏任务与项目之间的引用完整性,确保关联的任务行仍然有效。
总结

在本节课中,我们一起学习了实施语义完整性的核心方法。我们了解到,后置测试虽然直接但效率较低,而前置测试通过提前检查避免了撤销操作的开销。对于存在量化的复杂约束,我们引入了强大的差分关系方法,它通过检查更新操作产生的增量集合(R+ 和 R-)来高效地实施断言,从而在保持数据库一致性的同时,提升了操作的效率。
019:大规模数据库系统 - 实施分布式语义完整性 🔗
在本节课中,我们将要学习如何在分布式数据库系统中实施语义完整性约束。我们将探讨不同类型的约束,了解它们如何与数据分片交互,并学习在分布式环境下检查和强制执行这些约束的方法。
概述
语义完整性约束是确保数据库数据正确性和一致性的关键规则。在分布式系统中,由于数据被分割并存储在不同位置,实施这些约束变得更加复杂。本节将介绍三种主要的完整性约束类型,并解释如何在分布式环境中处理它们。
三种完整性约束类型
根据约束的范围和复杂性,我们可以将其分为三类。
1. 个体断言
上一节我们介绍了完整性约束的基本概念,本节中我们来看看第一种类型:个体断言。这种断言与一个表或与该表关联的分片一同被识别,并随分片被发送到每个站点。
以下是其实施要点:
- 检查是针对谓词和数据进行的。
- 这意味着需要检查这些分片是如何创建的,以及数据是否与这些断言一致。
- 此处假设我们已有一个现有数据库,现在正针对这些数据库(特别是此表)定义约束断言。
- 如果谓词在一个站点为假,则全局拒绝该谓词。
- 如果数据在一个站点不匹配,也全局拒绝该谓词。
- 即使尝试定义断言,我们也必须拒绝此断言,并声明该约束不再存在。
具体示例:
假设我们有一个员工表,按以下方式定义了三个分片:
- P1:员工号在 0 到 300 之间的员工。
- P2:员工号在 300 到 600 之间的员工。
- P3:员工号大于 600 的员工。
如果尝试定义一个约束,规定 员工号 < 400(即永远不会出现员工号大于等于400的员工),并且已有现有数据,我们将检查每个分片。
- 对于分片1,此约束与数据及分片定义一致,定义此断言没有问题。
- 对于分片2,虽然与分片定义(员工号在300到600之间)一致,但如果该分片中已存在员工号大于等于400的数据,我们将拒绝此谓词和断言。
- 对于分片3,其定义就是为了包含员工号大于600的员工,这与约束直接矛盾,因此我们将全局拒绝此断言。
2. 面向集合的断言
个体断言主要关注单表内的约束,而面向集合的断言则稍微复杂一些。它们是多关系的,涉及至少一个连接谓词,并且这些断言与单个关系存储在一起。
以下是其实施要点:
- 这些断言存储在所有存储该关系分片的站点。
- 它们无法仅通过谓词测试,只能对数据进行测试。
- 实际上,在许多情况下,必须执行连接操作以测试其是否为真。
具体示例:
给定一个断言:只能插入新的Assignment(任务分配)行,如果存在对应的Project(项目)行。
在这种情况下,我们将根据半连接分片策略进行检查。我们稍后将更详细地讨论半连接算法,但此时假设我们已按以下方式定义Assignment:
根据Project的分片来定义Assignment的分片。如果Project表被水平分片成多个片段,那么我们将以相应方式对Assignment表进行分片,使得每个Assignment分片与其对应的Project分片位于同一站点。
因此,当想要向数据库添加此插入时,我们只需检查一个站点,确保任何新的Assignment行在对应的Project分片中有相应的项目。此检查期间不需要通信,因为我们假设Assignment的分片范围与Project的分片范围相同。这是一种派生分片。
另一个示例:
如果Project基于两个谓词进行水平分片:
- 第一个分片:项目号小于300的项目。
- 第二个分片:项目号大于等于300的项目。
那么,每个新的Assignment元组将与第一个或第二个Project分片进行比较。因此,每个新元组最多只会产生一次通信开销。
如果Project是基于与连接条件无关的项目名称进行水平分片的,而Assignment表并非按此方式分片,那么每个新的Assignment都需要与每个Project分片的每一行进行比较,因为我们最终不知道那些项目存在于何处。这实际上是一项相当复杂的检查。
3. 多关系约束与聚合
面向集合的断言涉及多个关系,而多关系约束则更进一步,它涉及多个关系并且通常包含一些聚合操作。
以下是其实施要点:
- 每个约束将检索每个站点上所有可能失败的记录。
- 如果有一个失败,我们将拒绝它。
具体示例:
假设我们定义了这个复杂约束,由 SELECT ... FROM new_assignments, projects ... 组成。我们想确保新项目有对应的项目存在。COUNT 是一个聚合操作,我们需要确保遵守此约束。
不幸的是,所有可能最终进入数据库的新元组都必须发送到每个包含Project分片的站点,并返回到中央管理器。如果有一个站点拒绝,或者它们全部拒绝,则约束被拒绝,插入被拒绝。否则,它将被接受。
聚合操作更为复杂。在某种意义上,这里的 COUNT 操作是一种聚合,非常复杂。因此,检查完整性约束的复杂性可以从简单到复杂不等,这取决于聚合约束的复杂性,以及一次测试中涉及的不同分片之间关系的复杂性。
如何强制执行完整性约束
了解了不同类型的约束后,我们来看看如何在实际操作中强制执行它们。
- 通过个体断言:这些断言与关系一起存储在站点。当我们进行更新时,会引发对站点上所有断言的差异更新(
R+和R-)。站点将接受更新,如果它被任何一个断言拒绝,则将被拒绝。 - 面向集合的断言:这些稍微复杂一些。它们是同一表上的单关系约束,但是面向集合的,使用元组变量。例如,任何两个新员工必须遵守这样的约束:如果他们的员工号相同,那么他们的员工姓名最好也相同;或者,如果有一个员工号对应于表中已有的某个员工号,那么其姓名必须与表中该员工号对应的姓名一致。
系统将创建差异关系R+和R-并将其发送到每个站点,每个站点验证其是否为真。一旦有站点拒绝,全部或部分将被拒绝。 - 多关系约束:如前所述,这涉及多个关系的复杂约束,通常还包括一些聚合。每个约束会检索每个站点上所有会失败的记录。如果有一个失败,我们将拒绝它。
总结


本节课中我们一起学习了在分布式数据库系统中实施语义完整性约束的核心方法。我们探讨了三种主要约束类型:个体断言、面向集合的断言以及多关系与聚合约束,并分析了它们如何与数据分片交互以及在分布式环境下进行检查和强制执行的策略。关键在于理解,在数据分散存储的情况下,维护全局一致性需要协调各个站点,并根据约束的复杂性和数据分布模式,采用从本地检查到全局协调的不同技术手段。
020:级联撤销 🔄

概述

在本节课中,我们将要学习数据库权限管理中的一个重要概念:级联撤销。我们将通过几个逐步深入的例子,来理解当数据库管理员或用户撤销(REVOKE)某个用户的权限时,这个操作如何像多米诺骨牌一样,影响其他通过该用户间接获得权限的用户。
级联撤销的基本概念
级联撤销的核心思想是:当一个用户(授权者)撤销授予另一个用户(被授权者)的权限时,系统必须确保,不仅被授权者直接失去该权限,而且所有仅通过该被授权者获得此权限的用户也将失去该权限。
上一节我们介绍了语义数据控制,本节中我们来看看级联撤销的具体运作机制。
例一:简单的线性授权链
第一个例子非常简单。假设有三个用户:A、B 和 C。
- A 将某个数据库对象(如表或视图)的权限授予 B。
- B 随后将同一对象的权限授予 C。
此时,权限的流向形成一个简单的链:A -> B -> C。
当 A 决定撤销 B 的权限时,情况会如何发展?
A 撤销 B 的权限,意味着 A 不再信任 B 拥有该权限。由于 C 的权限完全来源于 B(且仅来源于 B),因此 B 也必须撤销授予 C 的权限。
最终结果是:B 和 C 都失去了对该对象的访问权限。
以下是这个过程的图示说明:


例二:用户从多路径获得权限
第二个例子稍复杂一些。假设有四个用户:A、B、C、D。
- A 将权限授予 B。
- B 将权限授予 C。
- A 也将权限授予 D。
- D 也将权限授予 C。
此时,用户 C 通过两条独立的路径获得了权限:A -> B -> C 和 A -> D -> C。
现在,当 A 撤销授予 B 的权限时,会发生什么?
- A 与 B 之间的授权关系被切断。
- B 必须级联撤销其授予 C 的权限。
- 然而,C 仍然保留着从 D 那里获得的权限,因为 A 并未撤销 D 的权限。
最终,C 的权限得以保留,因为它仍有另一条有效的权限来源路径(A -> D -> C)。只有 B 完全失去了权限。
以下是权限变化的对比:

例三:循环授权与检测
第三个例子引入了循环授权,情况变得更有趣。假设有三个用户:A、B、C。
- A 将权限授予 B。
- B 将权限授予 C。
- C 又将权限授予 B。

这就形成了一个循环:A -> B -> C -> B。
当 A 撤销 B 的权限时,问题出现了:B 似乎还拥有从 C 那里获得的权限,那么 B 的权限是否应该保留?
答案是否定的。A 的意图是彻底撤销 B 的权限。系统需要通过循环检测算法来识别出 B 和 C 之间存在的权限循环依赖。这个循环本质上是由最初的授权者 A 所启动的。
因此,级联撤销的结果是打破这个循环。最终,B 和 C 的权限都会被撤销,因为他们的权限根源(A)已经收回了授权。
以下是循环授权被检测并解除的示意图:

一个重要的补充:如果用户 C 除了从 B 获得权限外,还从其他独立来源(例如数据库管理员)直接获得了权限,那么当 A 撤销 B 的权限时,C 的权限将得以保留,因为它有另一个有效的权限来源。
例四:授权者自身在循环中
最后一个例子最为复杂。假设有三个用户:A、B、C。
- A 将权限授予 B。
- B 将权限授予 C。
- C 又将权限授予 A。
这就形成了一个包含初始授权者 A 的循环:A -> B -> C -> A。
现在,如果 B 撤销授予 C 的权限,会发生什么?
你可能会认为,通过循环检测,整个循环(A->B, B->C, C->A)都应该被移除。但这里有一个关键点:A 是初始的权限拥有者(例如,可能是对象的创建者)。A 的权限并非完全依赖于这个循环。
因此,在这种情况下,级联撤销算法必须足够智能,以确保初始授权者 A 不会因为撤销操作而意外地失去自己的权限。通常,A 的权限会得以保留,而 B 和 C 之间的授权关系被切断。
以下是此场景的说明:


总结
本节课中我们一起学习了级联撤销机制。我们通过四个由浅入深的例子,理解了数据库系统如何处理权限撤销的连锁反应:
- 线性链的完全撤销:权限沿单一链条传递时,撤销源头会导致后续所有用户权限丢失。
- 多路径授权的保留:只要用户仍有其他有效的权限来源路径,其权限就可以保留。
- 循环授权的检测与解除:系统需要检测并打破因循环授权产生的虚假权限依赖,确保撤销意图被彻底执行。
- 保护初始授权者:算法需确保初始权限拥有者不会因级联撤销而失去自身权限。

级联撤销是数据库安全模型中确保权限一致性和意图正确执行的关键机制。理解它有助于我们更好地设计和管理数据库的访问控制策略。
021:查询处理示例 📊



在本节课中,我们将通过一个具体的例子来学习查询处理。我们将分析一个简单的查询,并探讨如何通过不同的关系代数表达式来优化查询计划,以及如何在分布式数据库系统中评估不同策略的成本。
概述


查询处理是数据库系统的核心功能之一。它涉及将用户的高级查询(如SQL语句)转换为一系列可以在数据库上执行的低级操作。优化查询处理可以显著提高数据库的性能。本节课我们将通过一个简单的连接查询示例,来理解查询优化的基本概念和方法。
查询示例
我们首先来看一个简单的查询。这个查询涉及两个表:employee(员工表)和assignment(任务分配表)。我们的目标是找出所有职责为“经理”的员工姓名。
以下是查询的SQL表示:
SELECT employee.name
FROM employee, assignment
WHERE employee.emp_no = assignment.emp_no
AND assignment.responsibility = 'manager';



关系代数表达式
将SQL查询转换为关系代数表达式是查询处理的第一步。对于同一个查询,我们可以有多种等价的关系代数表达式,但它们的执行效率可能大不相同。
上一节我们介绍了查询示例,本节中我们来看看几种不同的关系代数表达式。
以下是三种等价但执行策略不同的关系代数表达式:

- 基础表达式:首先计算
employee和assignment表的笛卡尔积,然后应用选择条件(员工号相等且职责为经理),最后投影出员工姓名。- 公式:
π_name(σ_responsibility='manager' ∧ employee.emp_no=assignment.emp_no(employee × assignment))
- 公式:



-
优化表达式A:将选择条件
σ_responsibility='manager'下推到assignment表上,先过滤出经理任务,再进行连接和投影。这减少了连接操作的数据量。- 公式:
π_name(σ_employee.emp_no=assignment.emp_no(employee × σ_responsibility='manager'(assignment)))
- 公式:
-
优化表达式B:进一步优化,使用自然连接代替笛卡尔积加选择条件。先过滤
assignment表,然后与employee表进行自然连接,最后投影。- 公式:
π_name(employee ⋈ σ_responsibility='manager'(assignment))
- 公式:
直观上,表达式3的效率高于表达式2,表达式2的效率高于表达式1。这是因为我们尽可能早地减少了参与运算的数据量,并使用了更高效的自然连接操作。
集中式与分布式系统的挑战


在集中式数据库系统中,从众多可能的查询计划中找到最优解本身就是一个非常复杂(NP难)的问题。随着查询涉及的表和条件增多,可能的查询计划数量会呈指数级增长。
在分布式数据库系统中,问题变得更加复杂。我们不仅需要考虑本地操作的代价,还必须考虑数据在不同站点间传输的通信代价。此外,还需要处理数据分片、副本选择以及利用并行处理潜力等问题。虽然解空间更大,但通过合理的启发式方法和成本估算,我们仍然可以找到高效的查询计划。
分布式查询计划成本估算示例



为了量化不同查询计划的优劣,我们需要进行成本估算。成本通常包括I/O代价(读写元组)和通信代价(传输数据)。
假设我们有一个分布式数据库,数据分片存储在不同站点:
employee表(共400条记录)被水平分片为employee1(站点3)和employee2(站点4)。assignment表(共1000条记录)被水平分片为assignment1(站点1)和assignment2(站点2)。- 已知
assignment表中只有20条记录的responsibility为'manager',且均匀分布在两个分片中。 - 最终结果需要传送到站点5。
- 代价单位:一次本地元组访问为 1 单位,一次元组传输为 10 单位。
策略一:先过滤后连接(优化策略)
这个策略对应之前的“优化表达式B”,并在分布式环境下执行:
- 在站点1和2分别从
assignment分片中筛选出manager记录(各约10条)。 - 将这些过滤后的记录(各约10条)分别发送到对应的
employee分片所在站点(站点3和4)。 - 在站点3和4分别执行本地自然连接(
employee1⋈assignment1_filtered,employee2⋈assignment2_filtered)。 - 将连接结果(各约10条)发送到站点5。
- 在站点5合并结果。


以下是详细的成本计算:
总资源代价计算:
- 读取
assignment分片:500 + 500 = 1000 - 传输
manager记录到员工站点:(10 + 10) * 10 = 200 - 在员工站点写入接收到的记录:
10 + 10 = 20 - 执行本地连接(假设有索引,每次连接需2次读):
20 * 2 = 40 - 传输连接结果到站点5:
(10 + 10) * 10 = 200 - 在站点5写入结果:
10 + 10 = 20 - 在站点5合并结果:
10 + 10 = 20 - 总资源代价:
1000 + 200 + 20 + 40 + 200 + 20 + 20 = 1500

响应时间估算(考虑并行操作):
- 并行读取
assignment分片:max(500, 500) * 1 = 500 - 并行传输
manager记录:max(10, 10) * 10 = 100 - 并行在员工站点写入:
max(10, 10) * 1 = 10 - 并行执行本地连接:
max(20, 20) * 1 = 20(以较慢的站点为准) - 并行传输连接结果:
max(10, 10) * 10 = 100 - 在站点5写入结果:
(10 + 10) * 1 = 20 - 合并结果:
(10 + 10) * 1 = 20 - 总响应时间:
500 + 100 + 10 + 20 + 100 + 20 + 20 = 770(与视频中760略有出入,为计算细节差异)
策略二:全部集中后处理(朴素策略)
这个策略将所有数据都发送到站点5进行集中处理:
- 将
employee1、employee2、assignment1、assignment2所有数据发送到站点5。 - 在站点5重组完整的
employee表和assignment表。 - 在站点5执行完整的查询:先过滤
assignment,再与employee连接,最后投影。



以下是详细的成本计算:
总资源代价计算:
- 读取所有分片:
200 + 200 + 500 + 500 = 1400 - 传输所有数据到站点5:
(400 + 1000) * 10 = 14000 - 在站点5写入所有数据:
1400 * 1 = 1400 - 在站点5执行查询(无索引,需嵌套循环连接):
- 读取全部
assignment进行过滤:1000 - 连接操作:对20条
manager记录,每条需扫描400条employee记录:20 * 400 = 8000
- 读取全部
- 总资源代价:
1400 + 14000 + 1400 + 1000 + 8000 = 25800
响应时间估算(考虑并行传输):
- 并行读取分片:
max(200, 200, 500, 500) * 1 = 500 - 并行传输数据(以最大分片计):
500 * 10 = 5000 - 在站点5写入数据:
max(200, 500) * 1 = 500(以较大分片计) - 执行查询(无法并行):
- 过滤
assignment:1000 - 连接操作:
8000
- 过滤
- 总响应时间:
500 + 5000 + 500 + 1000 + 8000 = 15000




对比与总结
通过对比两种策略,我们可以清晰地看到:
| 评估指标 | 策略一(先过滤后连接) | 策略二(全部集中处理) | 结论 |
|---|---|---|---|
| 总资源代价 | 1500 单位 | 25800 单位 | 策略一节省约94%的资源 |
| 响应时间 | ~770 单位 | 15000 单位 | 策略一快约95% |



本节课中我们一起学习了:
- 一个查询可以对应多个等价的关系代数表达式,形成不同的查询执行计划。
- 通过将选择操作下推、使用连接代替笛卡尔积等代数变换,可以优化查询计划。
- 在分布式数据库系统中,评估查询计划成本时,必须同时考虑本地处理代价(I/O)和网络通信代价。
- 利用统计数据(如表大小、选择率)进行成本估算是查询优化的基础。准确的估算有助于系统选择高效的查询计划,避免低效计划。
- 本例证明,通过在数据源附近提前过滤减少数据传输量,通常能极大提升分布式查询的性能。



这个示例虽然简单,但揭示了查询优化,尤其是分布式查询优化的核心思想。在接下来的课程中,我们将深入探讨更复杂的代价模型和优化算法。
022:查询转换规则 📚


在本节课中,我们将学习查询处理中的一个核心环节:查询转换规则。我们将了解如何通过应用一系列规则,将一个查询的执行计划(通常表示为关系代数表达式树)转换为一个语义等价但执行效率更高的计划。这对于优化大规模数据库系统的查询性能至关重要。

从示例查询开始
首先,我们来看一个示例查询。这个查询涉及三个表:employee、assignment 和 project。它的目标是选择那些头衔为“程序员”、项目预算超过100,000且项目持续时间少于12个月的员工姓名。
对应的SQL语句如下:
SELECT e.name
FROM employee e, assignment a, project p
WHERE e.emp_no = a.emp_no
AND a.proj_no = p.proj_no
AND e.title = ‘programmer’
AND p.budget > 100000
AND p.duration < 12;
构建查询树 🌲
查询处理的第一步是将关系代数表达式转换为一个查询树。我们按照投影(Projection)、选择(Selection)和连接(Join)的顺序来构建这棵树。
构建出的查询树结构如下:
- 根节点是投影操作
π(e.name)。 - 其子节点是一个复合的选择操作
σ(title=‘programmer’ ∧ duration<12 ∧ budget>100000)。 - 这个选择操作作用于一个连接操作
⋈的结果。 - 该连接操作将
project表与另一个连接结果(assignment表与employee表的连接)进行连接。
这个树形结构本身就是发送给查询执行模块的指令集。执行模块会自底向上地处理这棵树:
- 首先连接
assignment和employee表。 - 将结果与
project表连接。 - 依次应用关于预算、持续时间和头衔的选择条件。
- 最后投影出员工姓名。
这个初始的执行计划可能需要较长的执行时间。因此,我们的目标是通过应用转换规则,生成一个语义相同但执行更快的查询树。

查询树转换规则 🔧
为了优化查询树,我们可以应用一系列转换规则。以下是一些核心规则,我们假设有两个表 R(A1, A2, ..., An) 和 S(B1, B2, ..., Bn)。
1. 交换律 (Commutative Law)
连接和笛卡尔积操作满足交换律:
- 公式:
R × S ≡ S × R - 公式:
R ⋈ S ≡ S ⋈ R
虽然这两个表达式在数学上等价,但对查询处理器而言意义不同。执行顺序决定了哪个是外表(驱动表),哪个是内表。如果内表 S 上有索引,那么 R ⋈ S(以 R 为外表)的效率是 O(n log n)。而如果顺序反过来 S ⋈ R(以 S 为外表),但 R 没有索引,效率就会降为 O(n²)。一个好的查询优化器会根据索引情况选择更优的连接顺序。


2. 结合律 (Associative Law)
连接和笛卡尔积操作也满足结合律:
- 公式:
(R × S) × T ≡ R × (S × T) - 公式:
(R ⋈ S) ⋈ T ≡ R ⋈ (S ⋈ T)
与交换律类似,不同的结合顺序也可能因为索引等因素而产生不同的执行效率。
上一节我们介绍了二元操作的交换律和结合律,本节中我们来看看针对一元操作(选择和投影)的转换规则。
3. 投影的级联消除

如果连续进行多次投影,且每次投影的属性集是前一次的子集,则可以合并或消除中间的投影层。
- 公式:
π_A‘ (π_A‘‘ (R)) ≡ π_A‘ (R),其中A‘ ⊆ A‘‘
4. 选择的级联合并


如果对同一个关系应用多个选择条件,可以将它们合并为一个包含所有条件的“与”操作。
- 公式:
σ_c1 (σ_c2 (R)) ≡ σ_(c1 ∧ c2) (R)
5. 选择与投影的交换/下推
如果在一个选择操作之后进行投影,并且选择条件 c 中涉及的属性 Ap 包含在投影属性集 {A1, ..., An} 中,则可以将投影下推到选择之前,仅保留选择所需的列,从而减少处理的数据量。
- 公式:
π_{A1,...,An} (σ_c (R)) ≡ π_{A1,...,An} (σ_c (π_{A1,...,An, Ap} (R)))
6. 选择对二元操作的下推
如果选择条件只涉及连接或笛卡尔积中某一个表的属性,可以提前将该选择操作应用到对应的表上,生成一个更小的中间结果,再进行连接,这能显著提升效率。
- 公式:
σ_c (R × S) ≡ σ_c (R) × S,当条件c只涉及R的属性时。 - 公式:
σ_c (R ⋈ S) ≡ σ_c (R) ⋈ S,当条件c只涉及R的属性时。

这个规则同样适用于并集操作。
- 公式:
σ_c (R ∪ S) ≡ σ_c (R) ∪ σ_c (S)
7. 投影对二元操作的下推
对于连接或笛卡尔积后的投影,如果投影的属性集 C 可以明确地划分为来自 R 的属性子集 A‘ 和来自 S 的属性子集 B‘,那么可以将投影分别下推到两个操作数上,先减少列数再进行连接。
- 公式:
π_C (R × S) ≡ π_A‘ (R) × π_B‘ (S),其中C = A‘ ∪ B‘,且A‘属于R,B‘属于S。 - 该规则对自然连接
⋈同样适用。


应用规则优化示例查询 🚀
现在,让我们将上述规则(特别是规则4、5、6、7)应用到最初的示例查询树上。核心思想是:尽可能地将选择和投影操作沿着查询树下推。
以下是优化步骤:
- 下推选择:
budget > 100000和duration < 12只涉及project表,可以下推到该表。title = ‘programmer’只涉及employee表,可以下推到该表。
- 下推投影:
- 最终只需要
e.name。分析连接条件(e.emp_no = a.emp_no和a.proj_no = p.proj_no),我们发现还需要project表的proj_no、assignment表的emp_no和proj_no以及employee表的emp_no和name。 - 因此,我们可以将投影下推到每个基表,只读取这些必需的列。
- 最终只需要
优化后的查询树变为:
- 从
project表中选择budget>100000 ∧ duration<12的行,并只投影proj_no。 - 从
assignment表投影emp_no, proj_no。 - 从
employee表中选择title=‘programmer’的行,并投影emp_no, name。 - 先将优化后的
assignment与employee片段连接,再与优化后的project片段连接。 - 最后从结果中投影
name。
这个新计划在处理早期就过滤了大量无关的数据行和列,从而大大提升了执行效率。



应用于分片数据库 🌐
在分布式或分片数据库中,这些转换规则能发挥更大作用,帮助我们消除对无关数据分片的访问。
水平分片削减
如果表被水平分片(按行分割),且分片定义带有谓词(例如,employee1: emp_no < 100, employee2: 100 <= emp_no < 200, employee3: emp_no >= 200)。
当查询自身带有谓词(例如,emp_no < 150)时:
- 我们可以分析查询谓词与分片定义谓词。
- 如果某个分片的定义与查询谓词矛盾(如
employee3的emp_no >= 200与emp_no < 150矛盾),则该分片不可能包含任何结果,可以从查询计划中完全消除。 - 原本需要合并所有分片再选择,现在只需访问相关的分片(
employee1和employee2)。

连接削减

当两个水平分片的表进行连接时,我们可以只连接那些定义谓词可能产生交集的分片对,避免不必要的笛卡尔积。
- 例如,
employee的分片1、2只与assignment的分片1连接;employee的分片3只与assignment的分片2连接。 - 最后将各个连接结果进行合并。
垂直分片削减



如果表被垂直分片(按列分割),例如 employee1 包含 (emp_no, name),employee2 包含 (emp_no, title, salary)。
当查询只请求某些列时(例如,只请求 name):
- 我们可以识别出只有
employee1分片包含所需列。 - 因此,查询计划可以忽略
employee2分片,直接对employee1分片进行操作,避免了不必要的列重组开销。
总结 📝


本节课中我们一起学习了查询转换规则,这是数据库查询优化的核心基础。我们了解到:
- 通过将关系代数表达式转换为查询树,可以形成具体的执行指令。
- 应用交换律、结合律、选择/投影下推等转换规则,可以生成语义等价但更高效的执行计划。
- 优化的核心原则是尽早过滤:尽早减少需要处理的行数(通过下推选择)和列数(通过下推投影)。
- 在分片数据库环境中,这些规则可以进一步用于分片削减,直接消除对不包含相关数据分片的访问,极大地提升了分布式查询的性能。

掌握这些规则,有助于我们理解数据库优化器的工作原理,并能更好地编写出本身就更易于优化的查询语句。
023:查询操作成本 🧮


在本节课中,我们将学习关系数据库中各种查询操作的计算成本。我们将探讨不同操作(如选择、投影、连接)在没有索引、有索引或已排序情况下的性能差异,并使用大O符号来描述其时间复杂度。
无索引表的选择操作
上一节我们介绍了课程概述,本节中我们来看看从没有索引的单个表中选择行的情况。
如果查看右侧的图表,它代表一个有N行的表。为了找到匹配的行,我们必须检查表中的每一行。在最坏的情况下,我们需要读取所有N行。即使我们知道表中只有一个匹配项,平均也需要读取大约一半的行(N/2)。


在计算复杂度理论中,我们使用大O符号来描述操作成本的数量级。对于这种情况,读取表行的成本是 O(N)。这意味着成本与表中的行数成线性关系。虽然平均读取次数是N/2,但在大O表示法中,我们忽略常数因子,因此它仍然是线性时间复杂度操作。
带索引的选择操作

上一节我们讨论了无索引的线性搜索,本节中我们来看看使用索引进行选择的情况。
大多数关系数据库使用B+树进行索引。B+树是一种平衡树,它根据某个键(如ID或姓氏)来组织表中的所有行。当插入一行时,系统会根据索引键值将其放置在树中的适当位置。
图中展示了一个二叉树(每个节点有两个分支),但实际的B+树每个节点可以有多个分支。B+树的关键优势在于它能保持平衡,这意味着树的最底层深度始终是 log N。因此,要查找一行,我们只需要进行大约 log N 次读取,而不是N次。这比直接扫描整个表要快得多。
使用B+树索引搜索一行的时间复杂度是 O(log N)。此外,B+树叶节点本身是排序的,并且通过指针相互连接。这意味着一旦找到第一个匹配项(例如“Harry”),我们就可以快速遍历相邻的叶节点来找到所有匹配项,后续每次读取的成本接近 O(1)。


因此,使用索引搜索的总时间是 O(log N),这比无索引的 O(N) 要高效得多。
哈希索引
上一节我们了解了B+树索引,本节中我们来看看另一种快速索引:哈希索引。
哈希索引速度非常快。其原理是:对搜索条件(键值)应用哈希函数,生成一个代表该键值的数字(哈希值)。当插入一行时(例如“Harry”),系统计算其哈希值,并在哈希索引中创建一个条目,该条目指向表中相应的行。

查找时,只需对键值(如“Harry”)应用相同的哈希函数,在哈希索引中找到对应的条目,然后跟随指针找到表中的行。这是一个 O(1) 的操作,理论上非常快。

那么,为什么我们不总是使用哈希索引呢?问题在于,当表中的行数非常多时,哈希索引会变得复杂。不同的键值可能会哈希到同一个值(哈希冲突)。为了解决冲突,哈希索引条目可能指向一个“桶链”,其中包含所有哈希到同一值的行。当数据量很大而哈希表相对较小时,会导致很长的桶链,搜索性能可能退化到接近 O(N)。
在本课程中,当我们讨论哈希索引时,将假设哈希索引有充足的空间,其操作的时间复杂度为 O(1)。
带去重的投影操作

上一节我们讨论了索引选择,本节中我们来看看投影操作及其去重成本。
投影操作是从结果集中获取特定的列。但获取这些列后,结果中可能存在重复行,因此需要去重。


以下是检索和排序这些记录的成本:
- 搜索这些记录的成本可能是 O(N)。
- 然而,排序本质上是一个 N log N 的过程。对于N个元素,目前已知最好的排序算法时间复杂度是 O(N log N)。
因此,总时间是 O(N log N + N)。根据大O表示法的规则,我们取增长最快的项,所以带去重的投影操作的时间复杂度是 O(N log N)。
类似地,如果要将记录分组,本质上也需要排序,因此分组操作的时间复杂度也是 O(N log N)。如果初始表已经排序,则排序步骤可以省略,此时搜索成本仅为 O(N)。
笛卡尔积与连接操作
上一节我们分析了投影和分组,本节中我们来看看更复杂的连接操作,特别是成本高昂的笛卡尔积。


笛卡尔积的计算复杂度非常高,是 O(N²) 的操作。我们假设有两个没有索引的表A和表B,每个表都有N行。

执行连接的算法是:遍历表A的每一行。对于表A中的每一行,我们都必须遍历表B的所有行以寻找潜在的匹配。因此,这本质上是 N * N 或 N² 次操作,非常昂贵。我们应尽可能避免笛卡尔积。
带索引的连接
带索引的连接则快得多。假设表A有N行,表B有N行且已建立索引。

当我们遍历表A的每一行时,可以通过表B的索引(一个 log N 深度的搜索)快速找到表B中对应的匹配行。因此,总搜索成本是 N * log N,即 O(N log N),这比笛卡尔积的 O(N²) 要快得多。


在已排序表上的连接
在已排序的表上进行连接可能是最好的连接方式。我们假设两个表都已根据连接键排序。
算法维护两个指针,分别指向表A和表B的当前位置。我们从顶部开始,比较两个指针指向的键值。根据比较结果,我们相应地移动其中一个或两个指针。由于表是排序的,我们永远不需要回溯指针,每个表的指针都只向下移动一次。

因此,这本质上是一个 2N 次读取的操作。根据大O表示法,我们忽略常数因子,所以其时间复杂度是 O(N)。
总结 🎯
本节课中我们一起学习了关系数据库中各种查询操作的时间复杂度:

- 无索引选择:成本为 O(N),需要线性扫描整个表。
- 带B+树索引选择:成本为 O(log N),利用平衡树结构快速定位。
- 带哈希索引选择:理想情况下成本为 O(1),但可能因哈希冲突退化。
- 带去重的投影/分组:成本为 O(N log N),主要由排序步骤决定。
- 笛卡尔积:成本为 O(N²),应尽量避免。
- 带索引的连接:成本为 O(N log N),比笛卡尔积高效。
- 在已排序表上的连接:成本为 O(N),是最优的连接方式之一。

理解这些操作的成本有助于我们设计更高效的数据库查询和索引策略。
024:大规模数据库系统 - 查询优化步骤 🧩


在本节课中,我们将学习查询优化的核心步骤。我们将了解数据库系统如何从一个SQL查询出发,通过一系列系统化的过程,最终找到最高效的执行计划。
概述
查询优化是数据库管理系统的核心组件,其目标是为给定的SQL查询找到成本最低的执行方案。这个过程主要包含三个关键步骤:生成搜索空间、应用成本模型和执行搜索策略。接下来,我们将逐一详细探讨这些步骤。
查询优化的三个核心过程
给定一个关系型查询,我们通过以下三个过程来寻找最优的查询执行计划(Query Execution Plan, QEP)。
-
生成搜索空间:此过程旨在找出所有可能用于执行该查询的潜在计划。正如我们在上一模块所见,我们可以为同一个查询创建多种语义等价但执行成本不同的关系代数树(即操作符树)。生成搜索空间就是系统地产生这些不同的计划。在实际中,由于计划数量可能极其庞大,我们通常应用一些规则来生成一部分有潜力的初始计划,从而得到一个候选的QEP集合。
-
应用成本模型:接下来,我们对上一步得到的QEP集合中的每一个计划应用成本模型进行估算。成本模型会评估每个计划的执行代价(如CPU、I/O、网络通信开销)。估算结果将揭示哪些计划成本高昂,哪些计划成本相对合理。
-
执行搜索策略:基于成本估算的结果,我们会剪枝掉那些成本高昂、没有潜力的计划。而那些表现出合理成本的计划,则会被送回“生成搜索空间”过程进行进一步的精炼。在这个反馈循环中,系统会对这些有潜力的计划应用更多优化规则,从而衍生出新的、可能更优的QEP集合。然后,再次应用成本模型和剪枝策略。如此循环迭代,最终基于成本估算选出最佳的查询执行计划。


深入搜索空间生成
上一节我们介绍了查询优化的整体流程,本节中我们来看看如何具体生成搜索空间。上一模块我们讨论了如何将关系查询转换为关系代数表达式,进而生成操作符树。对于一个查询,实际上可以生成许多不同的操作符树。
在本模块,我们将重点关注操作符树中的连接(Join)操作,因为连接通常是查询中最耗时、最昂贵的部分。在集中式数据库中,连接操作代价很高;在分布式数据库中,由于涉及不同处理器间的数据传输,其代价更为显著。
以下是一个示例查询,它从三个表中选择员工姓名和职责:
SELECT Employee.Name, Assignment.Responsibility
FROM Employee, Assignment, Project
WHERE Employee.EmpID = Assignment.EmpID
AND Assignment.ProjID = Project.ProjID;

对于这个三表连接查询,我们可以构想出不同的连接计划。以下是三种可能的连接树:
- 首先连接
Employee和Assignment表(基于EmpID),然后将结果与Project表连接(基于ProjID)。 - 首先连接
Assignment和Project表(基于ProjID),然后将结果与Employee表连接(基于EmpID)。 - 先计算
Employee和Project表的笛卡尔积,然后将结果与Assignment表连接(同时基于EmpID和ProjID)。
当涉及的表或片段(在分布式数据库中,每个片段被视为一个表)数量(设为 n)很大时,可能的连接顺序组合数量会变得极其庞大。
连接计划的数量级约为 O(n!),这大致相当于 O(nⁿ)。为每一个可能的计划估算成本是不切实际的,因此我们必须借助启发式规则来将搜索空间缩小到合理范围。

应用启发式规则

正如上一模块所讨论的,一些常见的启发式规则包括:将选择(Selection)和投影(Projection)操作尽可能下推到树的最底层,以及避免不必要的笛卡尔积。然而,即使应用了这些规则,我们仍然需要评估不同的连接顺序。

在评估连接时,主要有两类启发式方法,对应两种连接树形态:
- 线性树(Linear Trees)
- 丛生树(Bushy Trees)
请看右侧示意图。顶部的线性树是指,每个连接操作的结果只与另一个单一的表进行下一次连接,从而形成一条线性的链。例如,先连接 R1 和 R2,其结果再与 R3 连接,以此类推。虽然搜索空间仍然是指数级的,但线性树将可能性减少到了 O(2ⁿ) 左右。
右下方的丛生树则允许更灵活的结构,可以在同一层连接两个中间结果,然后再将它们的产物在更高层进行连接。这种结构有利于并行执行,但其可能的计划数量级仍然是 O(n!) 或 O(nⁿ),并且其成本有时可能高于等效的线性树计划。
为了高效处理这两种情况,我们将探索不同的优化器系统:
- 对于线性树,我们将研究 INGRES 系统采用的启发式方法。
- 对于丛生树,我们将探讨 System R 优化器所使用的技术。
我们将在后续的课程单元中对它们进行详细讲解。
总结

本节课中,我们一起学习了查询优化的核心步骤。我们了解到,优化器通过“生成搜索空间-成本估算-搜索剪枝”的循环迭代,从海量的潜在执行计划中筛选出最优解。面对连接顺序组合爆炸的问题(O(n!)),我们引入了启发式规则,并区分了线性树和丛生树两种优化路径,为后续深入理解具体优化算法奠定了基础。
025:查询优化统计信息 📊


在本节课中,我们将学习查询优化中至关重要的统计信息。我们将了解数据库系统收集哪些统计信息,以及如何利用这些信息来估算查询中间结果的规模,从而选择最优的查询执行计划。
统计信息的重要性


上一节我们介绍了查询处理的基本概念。本节中,我们来看看优化器做出明智决策所依赖的统计信息。没有关于数据库的统计信息,我们就无法计算最佳的执行计划。
以下是数据库系统通常会收集和维护的关键统计信息:
- 属性长度:对于关系R的每个属性a_i,其长度
length(a_i)表示该列在每个数据片段中占用的字节数。 - 投影值基数:对于某个数据片段中的属性a_i,其投影值基数
card(∏_ai (Rj))表示该片段中a_i的实际不同值的数量。例如,一个存储美国各州的表可能有大量行,但不同值只有50个。 - 最小值和最大值:了解属性a_i的最小值
min(ai)和最大值max(ai)对于预测满足范围约束(如age > 50)的行数至关重要。 - 域基数:属性a_i的域基数
card(dom(ai))表示该属性在理论上可能拥有的不同值的数量,而不仅仅是数据库中当前存在的值。 - 片段基数:数据片段Rj的基数
card(Rj)就是该片段中包含的元组(行)数量。


此外,连接选择因子也是一个重要概念。它衡量连接操作能多大程度地减少结果集的大小。其通用公式为:
SF_join = card(R ⋈ S) / (card(R) * card(S))
最后,我们可以估算中间关系的大小,其公式为:
size(Relation) = card(Relation) * length(tuple)
这可以理解为行数乘以每行的字节数。

估算中间结果基数的假设

为了简化估算过程,我们通常基于两个核心假设进行推导。请注意,这些假设在现实中并非总是成立,但它们是行之有效的经验法则。
- 属性值均匀分布:假设属性值在其取值范围内是均匀分布的。例如,如果年龄范围是20到60岁,那么每个年龄段(如20-30,30-40等)的行数大致相同。
- 属性间相互独立:假设不同属性的值之间没有关联。例如,年龄和邮政编码是独立的。这简化了涉及多个属性的谓词估算。
基于这些假设,我们可以推导出各种操作结果基数的估算公式。


选择操作的基数估算
给定一个关系R和一个选择公式F,应用选择后结果集的基数估算公式为:
card(σ_F (R)) = SF_F * card(R)
其中,SF_F是选择公式F的选择因子。


以下是针对不同选择条件的估算公式:

- 等值选择:
SF_(A=value) = 1 / card(dom(A)) - 范围选择(大于):
SF_(A>value) = (max(A) - value) / (max(A) - min(A)) - 范围选择(小于):
SF_(A<value) = (value - min(A)) / (max(A) - min(A)) - 合取选择:
SF_(F1 AND F2) = SF_(F1) * SF_(F2)(基于属性独立性假设) - 析取选择:
SF_(F1 OR F2) = SF_(F1) + SF_(F2) - SF_(F1) * SF_(F2) - 集合成员选择:
SF_(A IN {v1, ..., vn}) = n * SF_(A=value)



其他操作的基数估算


了解选择操作的估算后,我们来看看投影、连接等操作的基数估算。
- 投影:
- 若不消除重复值:
card(∏_A (R)) = card(R) - 若消除重复值:
card(∏_A (R))近似等于属性A的不同值数量,但难以精确预知。对于主键投影,其基数等于card(R)。
- 若不消除重复值:
- 笛卡尔积:
card(R × S) = card(R) * card(S) - 连接:
- 一般连接的上限是笛卡尔积的大小。
- 对于等值连接,若关系R和S在连接属性上是一对多关系,则
card(R ⋈_A S) ≈ card(S)。 - 对于非等值连接:
card(R ⋈_F S) = SF_join * card(R) * card(S)。但SF_join本身依赖于连接结果,因此难以精确估算。
- 半连接:半连接是一种优化技术,用于减少分布式连接中的网络传输量。其选择因子可估算为:
SF_semijoin = card(∏_A (S)) / card(dom(A))。 - 并集:
- 上限:
card(R ∪ S) ≤ card(R) + card(S) - 下限:
card(R ∪ S) ≥ max(card(R), card(S))
- 上限:
- 差集:
- 上限:
card(R - S) ≤ card(R) - 下限:
card(R - S) ≥ 0
- 上限:

总结

本节课中,我们一起学习了查询优化中使用的核心统计信息,包括属性长度、基数、最值等。我们引入了两个关键的简化假设(均匀分布和属性独立)来估算中间结果的基数,并推导了选择、投影、连接、并集、差集等多种关系代数操作的基数估算公式。这些估算是查询优化器评估不同执行计划成本、并最终选择最优计划的基础。理解这些统计信息和估算原理,对于深入理解数据库系统的性能调优至关重要。
026:INGRES 算法 🧩


在本节课中,我们将要学习查询处理中的一个重要算法——INGRES算法。该算法通过“分离”和“替换”两个核心步骤,将复杂的多表连接查询分解为一系列更简单的单表或双表查询,从而高效地处理大规模数据库查询。
分离与替换
INGRES 算法使用线性树结构来处理查询。观察右侧的图表,你会发现我们首先将两个由查询1和查询2产生的表连接起来,然后将结果与查询3产生的新表进行连接,依此类推。这意味着,当你拆解一个查询时,本质上是在将查询1的结果传递给查询2,再将结果传递给查询3,直到最后一个查询。每个查询步骤的评估都依赖于前一个查询的结果。

INGRES 算法主要包含两个步骤:一是分离,二是替换。
分离步骤
以下是评估分离步骤的方法。假设我们有一个查询,需要从一系列关系(表)中选择大量属性和关系,并且存在多个谓词(条件)。其中,谓词1应用于表R1,其余谓词应用于其他表。
第一步是从其余查询中分离出一个查询。具体来说,我们将分离出Q1,它本质上是选择某些属性,并通过将谓词1应用于R1中的某个属性来从R1创建一个临时表。这样就得到了查询1的连接结果。接着,我们将这个结果输入到其余查询中。其余查询是所有其他关系及其关联谓词的连接。
为了让概念更具体,我们来看一个例子。我们将从一个初始查询开始,这个查询需要从三个不同的表中选择员工姓名,并在这些表之间进行连接:从员工表到任务表,再从任务表到项目表,并且对项目名称有一个约束条件。

分离示例
INGRES 算法将基于项目表分离出第一个查询。它会应用约束条件:从项目表中选择项目编号到一个新表 P_new 中,条件是项目名称等于“CADCm”。这就是我们的第一个查询。
然后,我们取这个结果并将其输入到 P_new 中,再将 P_new 输入到第二个查询。第二个查询从任务表中选择员工编号,并在 P_new 和任务表之间进行连接,连接条件是项目编号。这将创建一个临时结果 A_new。
接着,A_new 被输入到第三个查询,该查询在 A_new 和员工表之间进行连接,最终找出该查询产生的所有员工姓名。
这个查询的模型显示在右侧:我们取项目表,生成一个临时表 P_new,将其输入到与任务表的连接中,创建出 A_new,最后将 A_new 输入到与员工表的连接中。

替换步骤
我们持续进行分离,直到查询变得不可约简,然后应用替换。查询不可约简意味着我们现在需要连接两个或更多的表。
回顾上一张幻灯片,第一个查询代表了一次分离,我们可以从单个项目表创建一个查询。将其结果输入到中间结果 P_new 后,之后的每个查询都变成了需要连接两个表的查询。我们将通过替换来处理这些需要连接的表。
在某些情况下,R1 将与 R2 连接。这是查询不可约简且必须应用替换的典型情况。还存在更复杂的查询,其中 R1、R2、R3 之间存在非常复杂的连接模式,甚至可能回连到 R1。这种情况虽不常见,但在通过替换简化查询时仍需考虑。
那么,当面对不可约简的查询,需要连接两个或更多表时,替换如何工作呢?



元组替换原理
假设我们有一个包含 n 行的表,每一行都可以从该表中抽象出来。给定一个查询和关系,我们实际上可以取出第一行(或第一个关系),并对其中的每个元组(即行),将其替换到查询的其余部分。
具体来说,我们取 T1_i(R1 中的每一行),并稍微修改查询。我们不再是在 R1 和其余关系之间进行连接查询,而是在 R1 中的一个特定元组和其余关系之间进行连接,并且递归地重新评估这个过程。

替换示例
我们来看一个具体的例子,假设我们需要连接员工表和 A_new 表。
这里存在一个连接。我们将查看 A_new 表,并从中取出员工编号 E_num。在一个非常简化的案例中,我们假设其中只有两个元组:一个员工编号是 E1,另一个是 E2。
接着,我们检查员工表,取出所有元组,并为每个元组创建独立的查询。我们不再是将员工表的 E_num 与 A_new 的 E_num 连接(A_new 只有 E1 和 E2 两个值),而是将 A_new 表中的 E1 值代入查询。这样我们就得到了一个查询:选择员工编号为 E1 的员工姓名。
然后,我们取 A_new 中的下一个元组(E2),再次将其代入查询,选择员工编号为 E2 的员工姓名。我们通过将每个单独的元组代入查询来评估结果。
这就是元组替换,这些查询由一个单变量查询处理器进行评估。
算法总结
因此,INGRES 算法基本上执行一个两步过程:
- 分离:评估单表上的查询。
- 替换:将结果输入到其他查询段中。这些查询段通常涉及两个表的连接(一个表是前一个连接的结果)。对于这种情况,我们将应用元组替换,即取出一行并将其值代入查询,使得查询不再仅仅是两个不同表之间的连接,而是变成了对第二个表的非常简单的查询。

本节课中,我们一起学习了 INGRES 算法的核心思想。它通过“分离”将复杂查询拆解,再通过“替换”(特别是元组替换)处理不可约简的连接,从而将复杂的多表连接转化为一系列高效的单表或简单查询,是大规模数据库系统中一种重要的查询优化策略。
027:System R 查询优化算法 🧠

概述
在本节课中,我们将学习 System R 查询优化算法。该算法用于在多表连接查询中,高效地寻找成本最低的执行计划。我们将通过一个具体的例子,理解算法如何通过逐步构建和剪枝执行计划树来工作。
连接操作的成本分析
上一节我们介绍了查询优化的重要性,本节中我们来看看如何估算不同连接策略的成本。连接操作的效率很大程度上取决于表的访问方式和是否有索引可用。
假设我们需要连接两个表 R 和 S。在连接表示法 R ⨝ S 中,R 被视为外表,S 被视为内表。
以下是几种常见的连接策略及其成本估算:

- 嵌套循环连接(无索引):这是效率最低的方法。对于外表 R 的每一行,都需要完整扫描内表 S 来寻找匹配行。
- 成本公式:
Cost = |R| * |S|(即 O(n²) 操作)
- 成本公式:

-
归并连接:这是最理想的情况,要求两个表在连接列上都是有序的。
- 成本公式:
Cost = |R| + |S|(即 O(n) 操作) - 如果表无序,则需要先排序,成本会增加。
- 成本公式:
-
索引嵌套循环连接:这是最常见的高效场景。假设内表 S 在连接列上有索引,而外表 R 没有。
- 对于 R 的每一行,我们使用 S 的索引(深度为 log|S|)来快速定位匹配行。
- 成本公式:
Cost = |R| + |R| * log|S|(即 O(n log n) 操作)

示例查询与统计信息
现在,让我们通过一个具体的查询来应用 System R 算法。考虑以下查询:从三个表(Employee, Assignment, Project)中,找出项目名为 “CAD/CAM” 的员工姓名。
其连接图如下:
Employee ⨝_{E#} Assignment ⨝_{P#} Project, 并且带有条件 Project.Pname = ‘CAD/CAM’。
假设我们拥有以下数据库统计信息和索引情况:
Employee表在E#(员工号)列上有索引。Assignment表在P#(项目号)列上有索引。Project表在Pname(项目名)列上有索引。
System R 算法步骤详解
基于以上信息,System R 算法通过动态规划来构建和选择最优计划。其核心思想是自底向上地构建所有可能的连接树,并利用启发式规则尽早剪枝掉低效的计划。
以下是算法执行过程的关键步骤:
-
初始化单表访问计划:算法首先为查询中涉及的每个基表生成一个初始的、单表的执行计划片段。此时,我们无法进行剪枝。
-
逐步扩展与连接:算法迭代地将已生成的计划(或基表)与另一个未连接的表进行连接,生成新的、更大的计划。
-
应用剪枝启发式规则:在扩展过程中,算法会应用规则来淘汰明显低效的计划,大幅减少搜索空间。主要规则有:
- 避免笛卡尔积:立即剪枝掉那些不基于连接条件、而是产生笛卡尔积的计划,因为其成本是 O(n²)。
- 利用索引优化连接方向:优先保留那些能让内表在连接列上使用索引的连接顺序。因为
外表 ⨝_{有索引列} 内表的成本通常是 O(n log n),而反向连接可能成本更高。
算法推演过程
让我们跟随算法的步骤,看看它是如何为我们的示例查询找到最优计划的。
首先,算法从三个基表计划开始:[Emp], [Asgn], [Proj]。
第一轮扩展:尝试将每个基表与另一个表连接。
-
从
[Emp]开始扩展:- 连接
Emp ⨝ Asgn(Emp为外表):由于内表Asgn在E#上无索引,这是一个低效的 O(n²) 连接,被剪枝。 - 连接
Emp ⨝ Proj:两者无直接连接条件,是笛卡尔积,被剪枝。 - 结果:从
[Emp]出发没有产生有希望的计划。
- 连接
-
从
[Asgn]开始扩展:- 连接
Asgn ⨝ Emp(Emp为内表):内表Emp在连接列E#上有索引,这是一个高效的 O(n log n) 连接,保留。 - 连接
Asgn ⨝ Proj(Proj为内表):内表Proj在连接列P#上无索引,是低效连接,被剪枝。
- 连接
-
从
[Proj]开始扩展:- 连接
Proj ⨝ Asgn(Asgn为内表):内表Asgn在连接列P#上有索引,是高效连接,保留。 - 连接
Proj ⨝ Emp:是笛卡尔积,被剪枝。
- 连接
第二轮扩展:对上一轮保留的计划进行最后连接。
- 计划 A:
(Asgn ⨝ Emp) ⨝ Proj- 现在需要将中间结果与
Proj连接,连接列是P#。此时Proj作为内表,但在P#上无索引,因此这个最终连接成本很高,被剪枝。
- 现在需要将中间结果与
- 计划 B:
(Proj ⨝ Asgn) ⨝ Emp- 将中间结果与
Emp连接,连接列是E#。内表Emp在E#上有索引,这是一个高效的 O(n log n) 连接。
- 将中间结果与
最终选择:经过层层剪枝,算法确定最优执行计划是 (Proj ⨝ Asgn) ⨝ Emp。即先利用 Asgn 表上的 P# 索引连接 Proj 和 Asgn,然后再利用 Emp 表上的 E# 索引将结果与 Emp 表连接。
总结

本节课中我们一起学习了 System R 查询优化算法。该算法通过动态规划系统地构建所有可能的连接顺序,并运用避免笛卡尔积和优先使用内表索引两大启发式规则进行剪枝,从而在庞大的搜索空间(n! 种可能)中高效地找到近似最优的查询执行计划。我们通过一个三表连接的实例,一步步演示了算法如何初始化、扩展和剪枝计划,最终选出成本最低的 (Proj ⨝ Asgn) ⨝ Emp 计划。理解这个算法对于掌握数据库查询优化的核心思想至关重要。
028:分布式INGRES 🗄️


概述
在本节课中,我们将要学习分布式INGRES数据库系统中的查询优化。我们将重点探讨在数据被分片存储的分布式环境下,如何为查询的每一步选择执行策略,并理解网络架构对策略选择的关键影响。
分布式查询优化简介
上一节我们介绍了分布式数据库的基本概念。本节中我们来看看在分布式INGRES系统中,面对一个分片数据库时,如何为查询制定执行策略。
考虑一个分布式数据库的例子。我们需要为每个查询步骤确定执行策略,以处理一个涉及分片的查询。
以下是一个分布示例:
- 在站点1,存储着项目表(
project)和分配表(assignment)的一个分片。 - 在站点2,存储着员工表(
employee)和分配表(assignment)的第二个分片。
回忆我们进行“分离与替换”的过程。第一步,我们将项目表化简为分离查询Q1,并将其结果发送到一个名为Pnew的临时表中。
第二步的策略选择
在第二步,我们面临多个选择。特别是在分布式数据库中,这说明了选项远比传统的集中式数据库复杂。

以下是第二步的一些可选策略(实际上可能更多):
- 选项一:将
Pnew移动到站点2。这样,站点1和站点2都有一份Pnew的副本。然后,在站点1执行Pnew与assignment1分片的连接操作,在站点2执行Pnew与assignment2分片的连接操作。最后,将站点1的连接结果发送到站点2,并与站点2的连接结果进行合并。 - 选项二:将
assignment2分片移回站点1。在站点1合并assignment1和assignment2分片,然后执行Pnew与合并后分配表的连接操作,再将最终结果发送到站点2。 - 选项三:将
Pnew和assignment1分片都移动到站点2。在站点2合并两个分配表分片,然后执行Pnew与完整分配表的连接操作。
请注意,在上述每个选项中,都需要在不同站点间移动各种分片和表。由于在分布式数据库系统中,通信时间是一项非常重要的成本,我们希望在可能的情况下尽量减少它。
网络架构对成本的影响
上一节我们看到了策略的多样性。本节中我们来看看网络架构如何影响不同策略的成本估算。
假设我们有一个包含四个站点的系统。项目表(project)被分片存储在全部四个站点上,而分配表(assignment)仅作为一个完整的表存在于站点3。
这涉及到我们早期模块中讨论过的不同网络架构:
- 点对点架构:每个站点通过独立的通信路径与其他站点相连。
- 广播架构:所有站点相互连接,数据可以一次性发送给所有站点。
回到点对点架构的例子。每次我们需要将数据从一个站点发送到另一个或多个站点时,都必须单独发送到每个目标站点。因此我们面临一个选择:是将项目表的分片发送到分配表所在的站点3,还是将分配表发送到每个存在项目表分片的站点。
在点对点架构下:
- 将不在站点3的所有项目表分片(即站点1、2、4的分片)发送到站点3的成本,大约是发送单个元组成本的3000倍(因为有3000行需要传输)。
- 或者,将分配表发送到其他三个站点。由于是点对点架构,我们需要分别向每个站点发送2000行,总成本将是
2000 * 3 = 6000倍的单行发送成本。
因此,在点对点架构中,策略似乎是将项目表分片发送到分配表所在的站点3,并在那里执行连接操作。
然而,在广播架构下,选择则不同:
- 将分配表广播到所有其他站点。由于是广播,我们只需一次性发出2000个元组,它们会到达所有目标站点,因此传输成本仅为2000。
- 将项目表所有分片发送到站点3的成本仍然是3000。
所以,在广播架构中,最佳策略是将分配表广播到每个存在项目表分片的站点。
由此可见,网络架构对于决定在站点间移动表和分片的最佳策略至关重要,而这直接影响了查询的最终成本估算。
总结

本节课中我们一起学习了分布式INGRES系统的查询优化。我们了解到,在分片数据库上执行查询时,需要为每一步选择策略,这涉及到在不同站点间移动数据。我们通过示例分析了多种可能的策略选项。更重要的是,我们认识到网络架构(点对点 vs 广播) 是影响策略选择和数据移动成本估算的关键因素,不同的架构会导致完全不同的最优执行计划。这凸显了分布式查询优化相比集中式系统的复杂性。
029:System R*算法 🧠


在本节课中,我们将学习分布式数据库系统System R*中用于执行表连接操作的优化算法。我们将重点探讨两种基本的数据传输策略,并学习如何通过成本模型计算和比较不同策略的开销,以选择最优的执行计划。
分布式System R*概述
上一节我们介绍了查询优化的基本概念,本节中我们来看看System R在分布式环境下的具体实现。System R主要采用两种策略来在不同站点间传输表数据以执行连接操作。
以下是两种核心的数据传输策略:
- 整体表传输:将一个完整的表从一个站点传输到另一个站点。
- 按需行获取:根据需要获取单行数据,并将该行传输到另一站点进行处理。
策略一:整体表传输
在整体表传输策略中,整个关系(表)被传输到执行连接的站点,并存储为一个临时关系。
如果参与连接的两个表都已排序,系统可以执行高效的归并连接。当被传输表的行数据到达时,可以与目标站点上已有的表进行流水线式的匹配处理,算法效率很高。
其成本计算公式如下:
总成本 = (读取R表所有行的CPU成本) + (传输整个R表到S站点的网络成本) + (在S站点读取匹配行的CPU成本)

具体公式为:
Cost₁ = (card(R) * T_cpu) + (size(R) * T_net) + (card(R) * s * T_cpu)
其中,card(R) 是表R的行数,size(R) 是表R的数据大小,T_cpu 是单次CPU读操作的成本,T_net 是单位数据的网络传输成本,s 是一个选择因子。
策略二:内部表回传
此策略将内部表S传输回外部表R所在的站点。在这种情况下,通常无法进行归并连接,因为表S需要在R的站点上完全存储后再进行处理。
其成本计算公式如下:
总成本 = (读取S表所有行的CPU成本) + (传输整个S表到R站点的网络成本) + (在R站点读取并连接匹配行的CPU成本)
具体公式为:
Cost₂ = (card(S) * T_cpu) + (size(S) * T_net) + (card(R) * s * T_cpu)
策略比较与选择因子
整体表传输可能涉及更大量的数据传输,但所需的消息数量可能更少(只需传输一次表)。而按需获取策略虽然每次传输的数据量小,但可能需要发送大量消息。
当关系表较小时,整体传输可能是好选择;当表很大且连接的选择性很好(即匹配的行很少)时,按需获取策略可能更优。系统依赖统计信息来帮助我们确定哪种策略更佳。
这里引入一个关键的选择因子 s,其定义为:
s = (通过半连接从R选出的行数) / card(R)
这个因子在计算最低成本策略时非常有用。
策略三:按需获取
现在,让我们看看“按需获取”策略的成本计算。该策略为表R的每一行,去表S所在的站点获取匹配的行。
以下是该策略的成本构成步骤:
- 读取表R的每一行。
- 将每行中用于连接的列值传输到表S所在的站点。
- 在S站点检索匹配的元组。
- 将结果行值传回初始的R站点。
其总成本计算公式为:
Cost₃ = (card(R) * T_cpu) + (card(R) * length(a) * T_net) + (card(R) * s * T_cpu) + (card(R) * s * length(S) * T_net)
其中,length(a) 是连接列a的长度,length(S) 是表S中一行的长度。

策略四:传输至第三方站点
第四种策略是将两个关系都传输到第三个站点,并在那里计算连接。
以下是该策略的成本计算:
- 检索并传输整个表S到第三站点。
- 检索并传输整个表R到第三站点。
- 在第三站点执行连接操作。
其总成本计算公式为:
Cost₄ = (读取和传输S表的成本) + (读取和传输R表的成本) + (在第三站点执行连接的成本)
具体为:
Cost₄ = [(card(S) * T_cpu) + (size(S) * T_net)] + [(card(R) * T_cpu) + (size(R) * T_net)] + (card(R) * s * T_cpu)
总结

本节课中我们一起学习了System R算法的核心。System R在执行连接时,会综合考虑上述四种策略。它利用数据库的统计信息(如表大小、行数、选择因子s)和既定的成本模型,为每一种策略计算出一个预估的总成本。最终,系统会选择成本最低的策略作为该连接操作的执行计划。这种基于成本的优化方法,是System R*能够在分布式环境下高效处理大规模数据连接的关键。
030:Java事务管理示例 🧑💻

在本节课中,我们将学习如何在Java程序中使用JDBC进行事务管理。我们将通过一个具体的订单处理示例,理解如何将多个数据库操作捆绑成一个原子性的事务,确保数据的一致性。
概述
事务管理的核心目标是确保一系列数据库操作要么全部成功,要么全部失败,从而维护数据的完整性。Java通过JDBC API提供了实现这一目标的方法。默认情况下,JDBC会自动提交每个独立的SQL操作。为了实现事务,我们需要显式地控制提交和回滚。
Java事务管理基础
上一节我们介绍了事务管理的概念,本节中我们来看看Java是如何具体实现它的。
在Java中,通过JDBC操作数据库时,默认行为是“自动提交”。这意味着每执行一条UPDATE、INSERT或DELETE语句,其结果会立即永久保存到数据库中。
为了实现事务,我们需要将多个操作捆绑在一起。以下是实现步骤:
- 获取数据库连接。
- 关闭自动提交模式。
- 执行一系列SQL语句。
- 根据所有操作的成功与否,选择提交或回滚事务。
- 恢复自动提交模式。
以下是核心的Java代码框架:
Connection conn = DriverManager.getConnection(databaseURL);
conn.setAutoCommit(false); // 开始事务

try {
// 执行多个SQL操作,例如:
// statement.executeUpdate("UPDATE ...");
// statement.executeUpdate("INSERT ...");
conn.commit(); // 所有操作成功,提交事务
System.out.println("事务提交成功。");
} catch (SQLException e) {
conn.rollback(); // 任何操作失败,回滚事务
System.out.println("事务失败,已回滚。");
} finally {
conn.setAutoCommit(true); // 恢复自动提交
}
示例场景:库存与订单系统
为了更具体地说明,我们引入一个包含三个表的订单系统。
part表:存储零件信息,包含part_sold(已售数量)和total_parts(总库存)等字段。store表:存储商店信息。order表:作为连接表,记录商店与零件之间的销售关系(多对多)。
其关系如下图所示:


业务逻辑是:当处理一笔订单时,需要同时更新part表中对应零件的已售数量,并在order表中插入一条新的销售记录。这两个操作必须作为一个原子单元。
分步Java代码解析
上一节我们了解了系统结构,本节中我们来看看实现这个事务的详细Java代码逻辑。

第一步:建立连接并开启事务
首先,获取数据库连接并关闭自动提交,以开启一个新事务。
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/mydb", "user", "password");
conn.setAutoCommit(false);
第二步:检查库存
执行查询,获取目标零件的当前库存状态。

Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT part_sold, total_parts FROM part WHERE part_id = 153");
if (rs.next()) {
int partSold = rs.getInt("part_sold");
int totalParts = rs.getInt("total_parts");
第三步:判断并执行更新
根据库存情况决定是继续处理还是回滚。
以下是处理逻辑的关键判断:
if (partSold == totalParts) {
// 库存已售罄
System.out.println("零件已售罄。");
conn.rollback(); // 回滚事务,之前对part_sold的增量更新将被撤销
} else {
// 库存充足,执行更新
stmt.executeUpdate("UPDATE part SET part_sold = part_sold + 1 WHERE part_id = 153");
// 插入订单记录
stmt.executeUpdate("INSERT INTO order (part_id, store_id) VALUES (153, 11)");
conn.commit(); // 提交事务,使两个更新永久生效
System.out.println("订单处理成功。");
}
}
第四步:清理与恢复
无论事务提交还是回滚,最后都应恢复连接状态。
conn.setAutoCommit(true); // 恢复自动提交模式
stmt.close();
conn.close();
重要注意事项
在事务处理中,信息提示的时机至关重要,它关系到用户对系统状态的正确理解。
- 回滚前的提示:在调用
rollback()之前输出错误信息(如“零件售罄”)。这是因为如果回滚操作本身失败,用户至少已收到明确的状态反馈。 - 提交后的提示:在调用
commit()之后输出成功信息(如“订单处理成功”)。这是因为只有在提交成功后,操作才算真正完成。如果在提交前提示,系统可能在提示后崩溃,导致用户收到成功信息但数据并未保存。
总结

本节课中我们一起学习了Java中使用JDBC进行事务管理的完整流程。我们掌握了如何通过setAutoCommit(false)开启事务,将多个数据库操作捆绑;如何使用commit()使所有更改生效,或使用rollback()在出错时撤销所有更改。通过一个具体的库存订单示例,我们理解了事务对于保证数据一致性的重要性,以及信息提示与事务操作顺序的关键细节。
031:事务管理 - 事务的形式化定义 📚

在本节课中,我们将学习事务的形式化定义。我们将介绍用于描述事务的符号和概念,包括部分排序、操作以及终止条件。理解这些形式化定义是后续深入学习事务管理的基础。

欢迎来到事务管理
本单元将涵盖事务的形式化定义。请耐心理解,这里有一些特殊的符号,但我会解释这些符号的含义,因为在讨论事务管理的过程中,我们将持续使用它们。
首先,我们定义一个事务 Tᵢ,它代表某个事务。例如,可能存在三个事务:T₁、T₂、T₃。
事务的定义
事务 Tᵢ 被定义为其所有操作及其终止条件的部分排序。
我们只关心特定的操作。请记住,在任何事务中,软件内部会发生许多操作。我们真正关心的操作只有读和写。
因此,当我说“所有操作”时,我实际上指的是该事务的读和写操作,以及终止条件。终止条件要么是提交,要么是中止。


什么是部分排序?
部分排序由操作集合 Σ 定义。这个弯曲的小于符号 ≺ 表示存在某种排序关系。“小于”意味着存在排序,而“弯曲的小于”符号则表示这是一种部分排序。
我们可以通过一个直观的例子来理解部分排序:穿袜子和鞋子。你可以先穿右袜,然后左袜,再穿右鞋,最后左鞋;或者先穿右袜、右鞋,再穿左袜、左鞋;也可以先穿左袜、左鞋,再穿右袜、右鞋。穿袜子和鞋子的顺序有很多种。
然而,袜子的穿戴顺序并不总是严格的。你可以先穿右袜或先穿左袜,也可以先穿右鞋或先穿左鞋。但右袜必须在右鞋之前穿上,左袜也必须在左鞋之前穿上。它不一定需要紧挨在左鞋之前穿上,但必须在某个时间点先于左鞋穿上。
因此,部分排序定义了所有必须保持的顺序,同时允许那些不一定需要保持的顺序保持开放。穿袜子和鞋子的部分排序是:左袜必须在左鞋之前穿上,右袜必须在右鞋之前穿上。没有其他顺序是隐含的。这是一种部分排序,而不是严格排序。
这个弯曲的小于符号 ≺ 不具有自反性,但具有传递性。所以,A ≺ B 并不意味着 B ≺ A,但 A ≺ B 且 B ≺ C 则肯定意味着 A ≺ C。
事务的形式化表示
因此,事务 Tᵢ 是其操作(读或写,或两者)的部分排序 σᵢ,加上其终止条件 τᵢ。
Σ 是调度中所有操作的并集,以及其终止条件的并集。注意,对于任何两个我们考虑的操作 Oⱼ 和 Oₖ(在此事务 Tᵢ 内),Oⱼ 必须是读或写,Oₖ 必须是写。请记住,读与读之间没有冲突,只有读与写以及写与写之间存在冲突。
对于所有操作,存在某种部分排序,使得操作必须在其终止条件之前发生。同样,冲突操作之间必须存在某种排序,即对相同数据值的读和写以及写和写必须有某种顺序。



一个简单的例子
这里有一个简单的例子。假设有一个简单的事务:我们读取 X,将 X 和 Y 相加得到新的 X,我们希望将 X 的新值写入内存并提交。
我们用一个称为有向无环图的图来表示这个事务,它代表了我们关心的操作。请注意,我们只关心操作 1、2、4 和 5,因为它们是读、写或提交。我们不关心加法操作,只关心读、写和提交。
因此,你可以用一个非常简单的部分排序来表示这个事务:你可以读取 X,也可以读取 Y。你不在乎读操作的顺序,只要它们都被读取即可。两个读操作都必须在写操作之前完成。你不能在写操作之前读一个,然后在写操作之后读另一个,这没有任何意义。

在读操作和写操作之间存在严格的排序,但在读操作彼此之间存在部分排序。最后,在写操作和提交操作之间存在严格的排序。这是一个用有向无环图表示的部分排序图。
部分排序符号详解
现在,让我们更详细地看看这个部分排序符号。在前面的简单例子中,Σ(操作集合)包括:读 X、读 Y、写 X 和提交。

如果你查看部分排序,你会注意到在读 X 和写 X 之间、写 X 和提交之间、读 X 和提交之间以及读 Y 和提交之间都存在部分排序关系。

但既然我们理解了部分排序规则,我们实际上可以用更简单的排序符号来表示这一点。如果你注意到本幻灯片第三行列出的所有部分排序关系,你当然可以推导出读 X 必须在提交之前发生,这来自于我们已经指定的另外两个部分排序关系:即读 X 必须在写 X 之前发生,而写 X 必须在提交之前发生。
同样,通过查看读 Y 在写 X 之前、写 X 在提交之前的部分排序关系,你也可以推断出读 Y 在提交之前发生。
了解了这些信息,你实际上可以用更简单的术语来表示一个事务。你可以这样表示这个事务:读 X 和读 Y 以某种顺序在写 X 之前发生,而写 X 在提交之前发生。
因为我们知道读操作彼此之间没有冲突,它们可以以任何顺序发生。但这些读操作与写操作存在冲突,它们与提交操作也存在冲突,写操作与提交操作也存在冲突。因此,在写操作和提交操作之间必须有严格的排序,在读操作和写操作之间也必须有严格的排序。然而,读操作之间不一定有严格的排序。
在这种符号表示下,基于我们对冲突的了解,我们实际上可以通过交换读操作的顺序来创建一个对数据库产生相同效果的事务。这完全不会改变事务的含义,它只是另一种类型的事务,但会对数据库产生相同的效果。
总结

在本节课中,我们一起学习了事务的形式化定义。我们介绍了事务 Tᵢ 作为其读、写操作及终止条件(提交或中止)的部分排序 σᵢ。我们解释了部分排序的概念,并通过穿袜子鞋子的例子进行了说明。我们还学习了如何使用有向无环图来表示事务,以及如何利用对操作冲突的理解,用更简洁的符号来有效地表示事务。掌握这些形式化概念是理解后续事务调度、并发控制和恢复机制的基础。
032:ACID属性详解 🧱


在本节课中,我们将学习数据库事务管理的核心基石——ACID属性。我们将逐一拆解原子性、一致性、隔离性和持久性的含义,并通过具体例子理解它们为何对保障数据正确性至关重要。
概述 📋
ACID是事务处理系统的四个关键属性,它们共同确保了数据库在并发操作和系统故障情况下的可靠性与数据完整性。理解这些属性是掌握后续并发控制和恢复机制的基础。
原子性 ⚛️
原子性是指事务作为一个不可分割的操作单元。事务中的所有操作要么全部成功完成,要么全部不执行,不存在部分完成的状态。


因为当人们将一组操作定义为一个事务时,他们希望这些操作作为一个整体生效。我们稍后会举例说明。

如果发生故障,数据库管理系统必须撤销事务中已执行的部分,或者在以后某个时间完成剩余操作。
例如,在更新工资或银行账户时,如果有一系列操作需要同时执行,我们必须一次性完成所有操作。如果中途失败,就必须撤销已更改的部分,或者暂停事务,待数据库恢复运行后在某个时间点完成剩余操作。
本质上,被称为事务的一组操作必须一次性全部发生。如果失败,它们将同时全部失败。
故障分为两类:逻辑错误(如数据错误、死锁或一致性问题)和系统崩溃(如介质错误、系统崩溃、网络问题)。对于逻辑错误,事务通常会自行中止或由数据库管理系统中止。对于系统崩溃,我们需要崩溃恢复机制,这将在后续单元讨论。

无论在何种情况下,即使有人拔掉电源、磁盘崩溃或在事务中途出现除零错误,我们都必须确保到目前为止所做的所有更改要么从数据库中移除,要么在以后某个时间完成剩余操作。


一致性 ✅
一致性属性意味着正确性。一个事务,即一组操作,必须将数据库从一个一致状态映射到另一个一致状态。
我们之前定义过一致性检查。如果有人通过一个操作进行更改并使数据库变得不一致,那么在该操作或事务结束时,我们必须回滚所有操作,并声明数据库未处于一致状态。因此,这些操作都不能通过。

在事务执行过程中,可能会出现不一致的情况,但在事务开始和结束时,数据库必须处于某个一致状态。
例如,如果我们坚持所有项目必须至少有一人参与,我们可能会暂时移除一个人,然后向该项目添加另一个人。在此期间,数据库是不一致的,但在开始时,项目上有人,在结束时,项目上也有一个人。因此,根据“项目必须有人”这一约束,我们知道数据库在事务前后都是一致的。
隔离性 🚧

隔离性可能是最难理解的属性,它构成了即将讨论的并发控制的重要内容。每个事务在任何时候都必须看到一个一致的数据库。
每个数据库用户都知道同时有其他用户在使用数据库,但当他们操作数据库时,他们希望在自己的事务执行过程中始终看到一个一致的、不受其他用户影响的数据库副本。他们不希望在执行事务时看到其他用户操作的效果。
实际上,数据库需要通过允许多个人同时操作数据来高效运行。但必须有限制,我们将讨论这些限制,因为用户不希望看到自己操作写入的数据被另一个事务操作覆盖。
因此,用户需要看到一致性,并需要感觉到在事务期间,他们是唯一影响他们需要影响的数据库数据的人。所以数据库需要执行并发事务,就好像这些事务以某种线性顺序执行一样。
遇到的问题可以通过脏读、不可重复读、幻读和覆盖未提交数据等问题来说明。



以下是脏读问题的一个例子,有时也称为丢失更新问题。假设有两个事务:T1从其一个账户向另一个账户转账100美元,这是一个非常简单的操作。T2为每个账户增加6%的利息。
想象一下,如果数据库调度程序以下列方式调度这些事务:事务T1从某个账户读取一个值,从中减去100美元,然后将该值写回该账户。假设我从支票账户中取出100美元。与此同时,银行有一个在后台为每个账户计算利息的操作。现在调度程序调度以下操作:银行的交易现在读取X的值,为其增加6%,然后写出X的值;它读取Y(可能是储蓄账户),为其增加6%的利息,写出该值,并提交该操作。同时,事务T1的剩余部分开始操作,事务T1读取储蓄账户中的金额,现在加上之前从支票账户取出的100美元,并将该值写入数据库。
这里的问题在于,注意箭头表示T1的写操作与T2的读操作之间存在冲突。假设账户X有200美元,账户Y有100美元。如果T1完全在T2之前运行,那么账户X向账户Y转账100美元。现在X将有100美元,Y将有200美元。当T2来为X和Y增加6%时,X将有106美元,Y将有212美元,X加Y的总值为318美元,这完全没问题。如果T2完全在T1之前运行,效果也是一样的:T2为X增加6%,使X的值增加到212美元;Y也增加6%,变为106美元;然后当T1运行时,账户X向账户Y转账100美元,现在X有112美元,Y有206美元,X和Y的总和为318美元,完全没问题。无论先运行事务1还是事务2,它们对数据库的影响是相同的。
然而,在我们的场景中,T2打断了T1的操作。在T1的第一个操作之后,T1从其支票账户中取出了100美元。现在支票账户有100美元,储蓄账户有100美元。T2过来为每个账户增加6%。现在每个账户有106美元。然后T1将100美元加回储蓄账户。所以最终支票账户是106美元,储蓄账户是206美元,总和是312美元,少了6美元。原因在于存在脏读,最终事务1会损失金钱。

下一类冲突是不可重复读或模糊读问题。这由两个不同的事务说明。事务1将从其账户读取余额并减去100美元,即取出100美元。但事务1在其操作的更深处,如果余额小于50,它将再次读取余额。它看到自己没有对数据库做任何更改,尝试再次读取那100美元,并将其写回数据库。它以为自己会写回100美元。然而,T2过来读取余额,取出50美元,并将余额50美元写出。当事务操作以下列方式交错时:T1读取100美元;与此同时,T2认为那里仍有100美元,取出50美元;然后T1现在读取它认为是100美元的余额,但T2刚刚将其改为50美元,并将余额50美元写出并提交其值。注意,T2中止了其事务,所以它实际上并不想取出那50美元。因此,如果T2从未发生,运行事务T1后,余额值应该是100美元。不幸的是,由于存在不可重复读或模糊读问题,该账户只剩下50美元,事务1损失了金钱。
幻读问题属于读写冲突。问题是,T1正在读取一个值,而T2将要向该值写入数据。但T1仍然认为自己拥有读取到的值,即使T2在T1“眼皮底下”更改了该值。T1正在读取存款列表并打印它。出于某种原因,它再次读取然后写出。然而,T2进来并写入了一笔新存款,并提交了该值。所以事务最初读取和打印的内容,与它在T2写入新存款后最终写出的内容将会不同。事务1将不会拥有存款单上指示的内容,因为会有一笔新存款被插入,即使存款列表没有捕获到那笔新存款。这是由于幻读的读写冲突造成的。
最后是覆盖未提交数据问题。假设一家公司规定,所有相同类别的员工必须拥有相同的工资。如果不看事务2,事务1将X的工资设置为4000美元,将其工资写出为4000美元,并将Y的工资设置为4000美元,然后提交。T2将X和Y的工资写出为5000美元,将X的工资设置为5000美元并写出。两个事务各自都是一致的,它们遵守数据库的一致性规则。然而,由于这些操作之间存在写-写冲突,并且它们相互交错执行,最终结果是Y的工资为4000美元,X的工资为5000美元,这将使数据库处于不一致状态。在下一个模块讨论并发控制时,我们将更详细地了解如何解决这些问题。

持久性 💾
ACID属性的最后一个属性是持久性。一旦事务提交,它就是永久性的。该事务将在所有后续故障中幸存。

这意味着当你向数据库写出一个值时,你可能不会立即将其写入数据库,而是可能将其写入操作系统或数据库系统的缓存,以便稍后将缓存中的值写入数据库。你期望该数据在未来某个时间点出现在磁盘上。
想象一下,数据库告诉你该事务已提交并已写出,但实际上它所做的只是将其写入缓存。但你期望它出现在磁盘上。在缓存崩溃且数据从未写入磁盘的情况下,持久性确保下次我访问磁盘上的数据库时,该数据会神奇地出现在磁盘上。我们稍后将讨论数据库如何实现这种持久性。
总结 🎯

本节课我们一起学习了构成事务管理基石的ACID属性。原子性确保了事务的“全有或全无”;一致性保证了数据的正确性;隔离性使并发事务互不干扰;持久性则承诺已提交的数据永不丢失。这些属性共同为并发控制和恢复机制提供了理论基础,其中一致性和隔离性是并发控制的核心,而原子性和持久性则由恢复机制来保障。理解这些概念是进一步学习复杂数据库系统操作的关键。
033:分布式事务管理架构 🏗️

在本节课中,我们将要学习分布式事务管理架构的基本组成和工作原理。我们将通过一个核心架构图,了解分布式执行监控器、事务管理器与调度器如何协同工作,以管理跨多个数据库的事务操作。
上一节我们介绍了分布式事务管理的背景,本节中我们来看看其核心架构组件。


想象在分布式数据库系统中,每个数据库都关联着一个这样的“盒子”,即分布式执行监控器。
在分布式执行监控器内部,定义了一个事务管理器和一个调度器。
接下来,我们详细看看这个架构如何处理操作。
分布式执行监控器会接收到诸如 BEGIN、READ、WRITE、COMMIT 或 ABORT 等操作指令。
操作首先会生效,并开始在其关联的数据处理器上调度这些操作。
由于存在与其他数据库关联的并行调度器,它还需要将自己必须执行的调度活动通知给其他调度器。
因此,它将与许多其他调度器保持联系,可能是所有分布式数据库的所有调度器。
以下是操作在架构中的流转过程:
当有一个 BEGIN、READ、WRITE、COMMIT 或 ABORT 指令时,该信息被发送给事务管理器。
事务管理器与其需要通信的调度器进行沟通。
调度器根据指令排队操作,然后将这些操作发送给数据处理器,并将结果返回给事务管理器。
调度器的指令来源有两个,理解这一点很重要。
调度器既可以接收来自其自身事务管理器的命令,也可以接收来自与其他分布式执行监控器关联的分布式事务管理器的命令。
然后,它会将返回值发送给其他分布式执行监控器,具体是发送给其中的事务管理器。
最终,结果会被传回给发起调用的应用程序。
当我们谈论事务管理器架构时,我们讨论的就是一个分布式执行监控器,并且通常是每个分布式数据库系统都关联一个这样的监控器。
我们将在后续讨论分布式数据库系统的事务管理时进一步探讨这一点。
分布式数据库系统的事务管理与集中式系统类似,但分布式执行监控器现在必须考虑控制多个事务和多个调度器。
我们将在下一个模块中更详细地了解这一点。


本节课中我们一起学习了分布式事务管理架构。我们了解了分布式执行监控器作为核心组件,其内部的事务管理器和调度器如何协作,以处理跨数据库的事务操作,并与系统中的其他监控器进行通信。这是理解大规模分布式数据库如何保证事务一致性与可靠性的基础。
034:两阶段锁协议 🔒


在本节课中,我们将要学习并发控制中的一个核心协议:两阶段锁协议。我们将探讨其基本概念、工作原理、潜在问题以及一个更严格的变体。
概述
并发控制旨在确保多个事务同时访问数据库时,数据的一致性和正确性。两阶段锁协议是实现事务隔离性的一种常用方法。本节将详细介绍该协议的两个阶段,并通过一个示例说明为何需要它。
锁的兼容性矩阵

首先,我们需要理解不同事务对同一数据项加锁时的兼容性规则。以下是读锁和写锁的兼容性矩阵。
| 当前持有的锁 | 请求读锁 | 请求写锁 |
|---|---|---|
| 读锁 | ✅ 允许 | ❌ 不允许 |
| 写锁 | ❌ 不允许 | ❌ 不允许 |
只要多个事务只想对某个值加读锁以确保该值不被更改,这是完全可以的。只要没有人更改值,不同的读锁是兼容的。
但是,一旦有人想对某个数据项加写锁,而该数据项已被其他事务加了读锁(或写锁),则必须等待该事务释放其锁。例如,如果事务1持有写锁,那么其他事务的读锁和写锁请求都不被允许,直到事务1释放其写锁。

一个示例场景
为了理解锁协议的必要性,我们来看一个例子。假设亚马逊要将其所有礼品卡账户(包括图书和DVD礼品卡)的价值翻倍。同时,一位顾客在一次交易中使用了他礼品卡中的25美元和50美元。
初始状态是:图书礼品卡有50美元,DVD礼品卡有60美元。
以下是两种可能的正确执行顺序及其结果:

顺序 1:亚马逊先执行,顾客后执行
- 亚马逊将礼品卡价值翻倍:图书卡变为100美元,DVD卡变为120美元。
- 顾客消费:图书卡减去25美元变为75美元,DVD卡减去50美元变为70美元。
- 最终结果:图书卡 = 75美元, DVD卡 = 70美元。
顺序 2:顾客先执行,亚马逊后执行
- 顾客消费:图书卡减去25美元变为25美元,DVD卡减去50美元变为10美元。
- 亚马逊将礼品卡价值翻倍:图书卡变为50美元,DVD卡变为20美元。
- 最终结果:图书卡 = 50美元, DVD卡 = 20美元。
以上两种结果都是正确的,因为事务看起来是隔离执行的。任何其他结果都是不一致或不正确的。

无锁控制下的问题
现在,假设数据库管理系统交错执行这两个事务,但没有正确的锁控制。事务操作如下:
- 事务1 (亚马逊):读图书卡值 -> 翻倍 -> 写回;读DVD卡值 -> 翻倍 -> 写回。
- 事务2 (顾客):读图书卡值 -> 减25 -> 写回;读DVD卡值 -> 减50 -> 写回。
如果执行顺序如下:
- 事务1锁定并更新图书卡(50 -> 100),然后释放锁。
- 事务2锁定图书卡(100 -> 75),然后锁定并更新DVD卡(60 -> 10),提交。
- 事务1锁定并更新DVD卡(10 -> 20),提交。

我们得到了一个错误的结果:图书卡 = 75美元, DVD卡 = 20美元。这个结果既不是(75, 70),也不是(50, 20)。

问题根源:事务1在完成所有操作之前就释放了锁(例如图书卡的锁),导致事务2能够介入并基于事务1的中间修改进行进一步操作,破坏了事务的原子性和隔离性。
两阶段锁协议
为了解决上述问题,我们引入两阶段锁协议。该协议要求每个事务必须分两个阶段处理锁:
- 增长阶段:事务可以获取锁,但不能释放任何锁。
- 缩减阶段:事务可以释放锁,但不能获取任何新锁。
协议的核心思想是:事务必须在开始释放任何锁之前,获取其所需的所有锁。这确保了在事务修改数据期间,没有其他事务能干扰这些数据。
两阶段锁的问题:级联中止
然而,基本的2PL也存在问题。在缩减阶段释放锁后,其他事务可能读取或修改这些已释放但尚未提交的数据。
如果最初修改数据的事务随后中止并回滚,那么所有依赖于这些已回滚数据的事务也必须中止并回滚。这种现象称为级联中止,会严重影响系统效率。
严格两阶段锁协议

为了消除级联中止的问题,我们使用严格两阶段锁协议。它对基本2PL增加了一个关键限制:
事务持有的所有锁,都必须等到事务提交或中止后才统一释放。
这意味着事务在整个执行期间都持有锁,没有单独的“缩减阶段”。这彻底防止了其他事务读取未提交的数据,从而避免了级联中止。
在严格2PL下,事务的锁持有情况如下图所示:锁数量只增不减,直到提交点时一次性全部释放。
总结
本节课我们一起学习了并发控制中的两阶段锁协议。
- 我们首先通过一个示例看到了无锁控制下事务交错执行可能导致数据不一致。
- 然后,我们介绍了两阶段锁协议,它通过将锁操作分为增长和缩减两个阶段来保证可串行化。
- 接着,我们指出了基本2PL可能引发级联中止的问题。
- 最后,我们引入了严格两阶段锁协议,它要求事务在提交前持有所有锁,从而有效避免了级联中止,是实践中更常用的方法。

理解并正确应用锁协议,是构建可靠、一致的大型数据库系统的基石。
035:分布式两阶段锁协议 🔒
在本节课中,我们将学习分布式数据库系统中的并发控制,重点介绍分布式两阶段锁协议。我们将探讨几种不同的实现方案,分析它们的架构、工作流程以及各自的优缺点。
分布式事务架构回顾
上一节我们介绍了分布式事务的基本架构。本节中,我们来看看在这个架构下如何实现并发控制。
分布式事务架构包含分布在企业各处的分布式执行监视器。该架构中包含了事务管理器和调度器。
- 操作被发送给事务管理器。
- 事务管理器会识别正确的调度器,并将操作发送给它们。
- 调度器启动数据库处理器执行操作。
- 结果被发送回相应的调度器和事务管理器。
- 最后,结果被返回给应用程序。
集中式(主站点)两阶段锁
对于分布式两阶段锁,我们需要一个锁管理器。首先介绍的是集中式两阶段锁算法,也称为主站点两阶段锁。


以下是其工作原理示意图:

请注意示意图中的流程:
- 事务管理器收到某个操作请求。
- 事务管理器向锁管理器请求锁。
- 锁管理器回复“锁已授予”的消息。
- 事务管理器随后将操作发送给数据处理器。
- 数据处理器处理该操作。
- 操作结束后,数据处理器发送消息回事务管理器。
- 事务管理器再发送消息给锁管理器以释放锁。
在此方案中,一个站点被指定为托管锁管理器,另一个站点被指定为托管事务管理器。锁管理器不直接与处理器交互。因此,事务管理器承担了整个系统中所有消息协调的负担。
同时要认识到,集中式锁管理器会成为系统的瓶颈。如果锁管理器宕机,数据库系统中的任何操作都无法进行。锁管理器站点的故障会导致系统瘫痪。同样,如果只有一个事务管理器,它的故障也会导致系统瘫痪。但目前我们讨论的是只有一个锁管理器的集中式方案。
主副本两阶段锁
主副本两阶段锁算法与之前描述的集中式两阶段锁算法几乎相同。


其操作顺序是相同的,区别在于我们将锁管理器分布在整个企业中。
以下是其关键特点:
- 每个锁管理器负责管理不同的资源
X。 - 存在一个锁管理器负责资源
X的主位置。这意味着系统中可能存在该资源的副本,但锁管理器管理的是该资源的主位置。
工作流程如下:
- 当事务管理器需要更新或修改某个值时,它会向相应的锁管理器发送锁请求。
- 锁管理器授予锁。
- 然后,操作被发送到存有资源
X的多个数据处理器。 - 处理器将“操作结束”的响应发送回事务管理器。
- 事务管理器随后释放锁。
这种方案确实减轻了中心锁管理器站点的负载,并且没有在事务管理器和锁管理器之间引起过多的额外通信。
分布式两阶段锁管理
我们意识到上述方案仍然存在某种集中化的负担或锁管理器的负担。因此,我们继续探讨分布式两阶段锁管理。

在这种情况下,我们将锁管理器分布到每个站点。

如果数据没有复制,这实际上就退化成了之前的主副本两阶段锁算法。但在当前方案中,每个站点都有一个锁管理器。
如果数据被复制了,那么我们需要实现一种 “读一次,写全部” 的方法。
- 读操作:可以从任何复制了该数据的站点读取。
- 写操作:必须写入每一个站点。
锁管理器负责传递数据消息。具体流程如下(参考顶部示意图):
- 当事务管理器请求锁时,锁管理器会授予锁,但不会立即通知事务管理器。
- 锁管理器会继续向数据处理器发送消息以执行操作。
- 数据处理器完成后,发送消息回事务管理器。
- 事务管理器转而向锁管理器释放锁。
进一步优化:事务管理器信任锁管理器
我们可以更进一步。让事务管理器将所有操作委托给锁管理器。

工作流程如下:

- 当事务管理器通过锁请求发起一个操作时,它会发送消息给锁管理器。
- 锁管理器转而让数据处理器执行操作。
- 当操作结束时,数据处理器会通知锁管理器释放锁,并通知事务管理器锁已释放。
总结
本节课中,我们一起学习了分布式两阶段锁协议。
我们介绍了集中式(主站点)、主副本和完全分布式等几种不同的两阶段锁方案。这些方案各有不同的优缺点,从控制度的高低到瓶颈的多少各不相同。
选择正确的方案,很大程度上取决于你的分布式数据库系统的具体特性和使用场景。所有方案都是有效的,但具体使用哪一种,需要根据实际情况来决定。


036:时间戳排序算法 ⏱️


在本节课中,我们将学习并发控制中的时间戳排序算法。这是一种基于时间戳而非锁的机制,用于确保多个事务并发执行时的正确性。我们将详细解析算法的每一步决策,并通过具体例子说明其工作原理。
概述
时间戳排序算法是一种非锁定的并发控制方法。它通过为每个事务分配一个唯一的时间戳,并依据时间戳的先后顺序来调度读写操作,从而解决冲突。如果调度器安排的执行顺序违反了时间戳顺序,算法将中止并重启相应的事务。
算法原理详解
上一节我们概述了算法的基本思想,本节中我们来看看算法处理读写操作的具体规则。
读操作的处理
以下是处理读请求的两种情况。

情况一:较旧事务尝试读取已被较新事务写入的对象
假设事务 T1 的时间戳比事务 T2 更早(即 T1 更旧)。理论上,T1 的操作应发生在 T2 之前。然而,调度器可能将 T2 的写操作安排在 T1 的读操作之前执行。
- 问题:T1(旧事务)试图读取对象 X 的值。但 T2(新事务)已经写入了 X。这意味着 T1 读取了一个本应由更晚事务写入的值,这与时间戳顺序矛盾。
- 处理:由于 T1 的读操作时间戳小于 X 的写时间戳(即
T1.timestamp < X.write_timestamp),算法将中止并重启 T1 事务,并为其分配一个更新的时间戳。

情况二:较新事务读取已被较旧事务写入的对象
假设事务 T1 的时间戳比事务 T2 更新(即 T1 更新)。调度器可能将 T1 的读操作安排在 T2 的写操作之前。
- 处理:T1(新事务)读取对象 X。T2(旧事务)已经写入了 X。这符合逻辑顺序,因为新事务读取旧事务写入的值是允许的。因此,读操作可以正常进行。
写操作的处理
处理完读操作,我们接下来分析写操作的几种情形。

情况一:较旧事务尝试写入已被较新事务读取的对象
事务 T1(旧)尝试写入对象 X,但事务 T2(新)已经读取了 X 的旧值。
- 问题:T2 本应读取 T1 写入后的值,但它“抢先”读取了更早的值。如果此时 T1 再写入,会破坏 T2 已读取数据的一致性。
- 处理:由于存在冲突(较新事务已读取),T1 的写操作将被中止。
情况二:较旧事务尝试写入已被较新事务写入的对象(托马斯写规则)
事务 T1(旧)尝试写入对象 X,但事务 T2(新)已经写入了一个值,且 T2 从未读取过 X。
- 分析:T2 的写操作是“盲写”,不依赖于 X 的先前值。即使 T1 先执行并写入,其值最终也会被 T2 覆盖。
- 处理:应用托马斯写规则。T1 的写操作可以被安全地忽略,事务可以继续执行,因为其写入效果最终无效。规则可表示为:若
T1.timestamp < X.write_timestamp且X.read_timestamp < T1.timestamp不成立(即无新事务读过),则忽略 T1 的写。

情况三:较新事务写入对象
事务 T1(新)尝试写入对象 X,而事务 T2(旧)已经读取或写入过 X。
- 处理:这是最直接的情况。较新事务的写入总是被允许,因为它发生在逻辑时间线的后面。
总结


本节课我们一起学习了时间戳排序算法的核心机制。该算法利用时间戳决定操作顺序,通过比较事务时间戳与数据项的读写时间戳来处理冲突。关键点包括:当旧事务读取到新事务写入的值时需中止;当新事务读取旧事务写入的值时允许;写操作需特别考虑是否被较新事务读取过(决定中止)或是否会被较新事务覆盖(应用托马斯写规则忽略)。这种方法避免了锁的使用,但可能导致更多的事务重启。
037:多版本时间戳排序算法 🕒


在本节课中,我们将要学习并发控制中的一个重要算法——多版本时间戳排序算法。这是一种非常巧妙的算法,旨在优化传统时间戳排序算法中的读操作阻塞问题。
上一节我们介绍了传统时间戳排序算法,它存在读操作和写操作被阻塞的情况。本节中我们来看看多版本时间戳排序算法如何解决读阻塞的问题。
在传统时间戳排序算法中,当一个较晚的事务写入新值时,较早事务的读操作可能会被阻塞。然而,在多版本时间戳排序算法中,我们为每个数据资源的所有历史值及其写入时间戳都保留了记录。
因此,读操作永远不会被阻塞。以下是其工作原理:
假设我想读取数据项 X 的值,而我的事务时间戳是晚上7点。在数据库中,X 在不同时间有多个版本:
X在下午1点的值是3。X在下午4点的值是6。X在晚上8点的值是5。
当我的事务(时间戳为晚上7点)想要读取 X 时,我不必担心有更新的事务(例如晚上8点的事务)在我之后写入了新值。我只需查找在当前事务时间戳(晚上7点)之前,最后一次写入 X 的时间戳所对应的值。在这个例子中,晚上7点之前最后一次写入发生在下午4点,因此我会读取到值 6。这样,读操作就不会被阻塞。

然而,虽然读操作得到了优化,但写操作在某些情况下仍然可能被阻塞。接下来我们看看写操作是如何被阻塞的。
在这个算法中,我们需要维护一个称为 最大读时间戳 的概念。以下是一个例子:
假设 X 的版本历史如下:
X在下午1点的值是3。X在下午4点的值是6。
现在,一个时间戳为晚上8点的事务想要写入 X = 5。每个 X 的版本都有自己的写时间戳,我们添加新版本 X = 5,其写时间戳为晚上8点。
同时,我们记录一个 最大读时间戳。假设在晚上6点,有某个事务读取了 X 的值(当时读取的是下午4点的版本 6),那么此时 X 的最大读时间戳就是晚上6点。

如果一个时间戳为晚上10点的事务来读取 X,这没有问题。它会读取晚上10点之前最新的版本,即晚上8点写入的版本 5。并且,由于这次读取发生在晚上10点,我们会将 X 的最大读时间戳更新为晚上10点。
现在,问题出现了。假设另一个时间戳为晚上9点的事务(比晚上10点早)现在想要写入 X 的一个新值。不幸的是,此时 X 的最大读时间戳已经是晚上10点(由更晚的事务设置)。
由于这个写事务的时间戳(晚上9点)小于当前的最大读时间戳(晚上10点),该写操作会被拒绝。原因在于,我们知道已经有一个更晚的事务(晚上10点)读取了 X 的值。如果允许这个时间戳为晚上9点的写操作生效并插入到历史记录中,那么晚上10点的事务读取到的值将不是在其时间戳下应该看到的“正确”历史状态(因为它会看到晚上9点写入的值,而非晚上8点的值),这违反了事务的序列化顺序。因此,我们必须拒绝这个时间戳为晚上9点的写操作。
总结
本节课中我们一起学习了多版本时间戳排序算法。我们来总结一下它的核心特点:
- 优点:该算法通过维护数据的多个历史版本,完全消除了读操作被阻塞的可能性,从而提高了系统的并发读性能。
- 缺点:
- 写操作仍可能被阻塞或拒绝:当试图写入的事务时间戳早于该数据的最大读时间戳时,写操作会被拒绝。
- 空间开销:算法在时间效率上的提升,是以空间消耗为代价的。系统需要为每个数据项存储多个历史版本。

核心概念公式/描述:
- 读操作:
read(X, TS(T))-> 返回满足write_timestamp(X_version) ≤ TS(T)的最大write_timestamp对应的X_version值。 - 写操作检查:
write(X, TS(T))被允许的条件是TS(T) ≥ max_read_timestamp(X),否则拒绝。
038:乐观并发控制 🚀

在本节课中,我们将学习乐观并发控制(Optimistic Concurrency Control, OCC)的基本概念和工作原理。这是一种与常见的悲观并发控制不同的方法,它假设事务冲突很少发生,并采用“先执行,后验证”的策略来管理并发操作。
概述

大多数并发控制算法是悲观的。这意味着它们在事务读取、计算或写入数据之前,会先验证操作是否安全。乐观并发控制则采取不同的思路:它假设冲突很少发生,因此允许事务先读取和计算数据,只在最终写入前进行验证,以确认是否存在冲突。
乐观并发控制的核心思想
乐观并发控制仅在写入操作前进行验证。与悲观方法不同,OCC算法主要在集中式数据库系统中进行研究。实际上,目前尚无商业或原型数据库系统完全采用OCC实现。但其设计理念对理解并发控制仍有重要价值。

在OCC中,数据本身不附加任何标记。相反,每个事务会维护两个列表:一个记录已读取的数据,另一个记录将要写入的数据。OCC通过三条简单规则来判断一个较晚开始的事务T2能否成功提交。请记住,事务的执行在时间上是可以重叠的,但最终效果必须看起来像是T2在T1完全完成后才进行修改,从而避免相互干扰。
OCC的三条验证规则
以下是决定事务T2能否通过验证、成功完成的三条核心规则。
规则一:T2在T1写入后读取
如果事务T2的所有读取操作都发生在事务T1完成写入之后,则不会产生任何问题。因为T2理应读取到T1所写入的最新值。在这种情况下,验证步骤会顺利通过。

公式表示:
如果 Read(T2) 的时间 > Write(T1) 的时间,则验证通过。
规则二:T2在T1写入期间读取


这种情况稍复杂。如图所示,T1先进行读取、计算和验证,然后开始写入一组变量(记为集合X)。在T1写入期间,T2开始并完成了其读取、计算和验证阶段。
T2能否通过验证,取决于T1写入的资源(集合X)与T2读取的资源(集合Y)是否有重叠。
- 如果
X ∩ Y = ∅(即两个集合没有交集),则T2验证通过,可以完成。 - 如果
X ∩ Y ≠ ∅(即存在重叠的资源),则T2验证失败。
规则三:T1与T2的执行高度重叠
这是重叠最显著的情况。我们假设T1完成了读取、计算、验证,并正在写入集合X。T2在T1刚完成读取后不久也开始执行,并完成了自身的读取、计算、验证,准备写入集合Z。
此时,T2读取的资源集合为Y,欲写入的资源集合为Z。T1写入的资源集合为X。验证规则如下:
如果T1写入的资源(X)与T2读取的资源(Y)或 T2欲写入的资源(Z)有任何重叠,则必须中止T2。
公式表示:
如果 (X ∩ Y) ≠ ∅ 或 (X ∩ Z) ≠ ∅,则中止 T2。
否则,T2验证通过。
总结

本节课我们一起学习了乐观并发控制(OCC)。OCC是一种基于“冲突稀少”假设的并发控制方法,其核心是让事务先不受阻碍地执行读取和计算阶段,仅在最终写入前进行一次验证。我们详细介绍了其三条核心验证规则,这些规则确保了无论事务在时间上如何重叠,较晚事务的冲突操作总是在逻辑上晚于较早事务的操作执行。尽管OCC未被主流数据库系统广泛采用,但它为理解事务验证和并发控制机制提供了一个独特而有益的视角。
039:等待图检测 🔄

在本节课中,我们将要学习并发控制中的一个重要概念:等待图。等待图是一种用于检测和处理数据库系统中死锁问题的有效方法。
什么是等待图? 🤔
上一节我们介绍了并发控制的基本概念,本节中我们来看看等待图的具体作用。等待图是一种处理死锁条件的绝佳方法。
请注意,在我们的示意图中有两个事务。事务1正在等待事务2已锁定的资源,同时,事务2正在等待事务1已锁定的资源。除非我们处理这个死锁,否则这些事务将无限期地等待下去。
处理死锁的一种方法是创建等待图。如果你的分布式数据库系统,甚至是一个中心化数据库系统,能够跟踪各个事务正在等待什么,你就可以检测到图中存在一个循环:事务1在等待事务2,事务2在等待事务1。在图中检测循环是一件非常容易的事情。这是一个有两个节点的图,并且这两个节点之间存在一个循环。
因此,系统可以立即介入并处理死锁。它可以中止其中一个事务或两个事务,它有这些选项。但等待图向它提示了存在一个需要处理的死锁。
多站点环境下的挑战 🌐
当你处理多个站点时,情况会变得稍微复杂一些。你可能在一个站点有事务1和事务2,而在另一个不同的站点管理着事务3和事务4,并且你在每个站点创建了本地等待图。

以下是各站点已知的信息:
- 在站点1,我们知道T1在等待T2。
- 在站点2,我们知道T3在等待T4。
然而,跨站点来看,T4在等待T1,并且T2在等待T3。因此,实际上,在我们的整个企业或全局系统中,存在一个循环,但在本地并不存在任何循环。
所以,我们必须创建一个全局等待图来确定是否存在循环,并判断是否存在全局死锁。
如何管理全局死锁检测? 🛠️
我们如何管理这个问题呢?实际上,有多种方法可以在多个层级进行死锁检测和处理死锁。

在下面的示意图中,你注意到站点1、站点2、站点3和站点4各自都有自己的本地等待图,以及一个用于检测任何循环并在确实检测到循环时处理死锁的算法。
然后,在站点1,你可以有一个全局等待图,仅代表站点1和站点2的图。你甚至可以将这个全局图存放在站点1。这样做的好处是,如果站点1和站点2没有检测到任何循环,你可以将它们的等待图合并,形成一个处理站点1和站点2的全局等待图,并在站点1处理它。这样,你就可以看到两个站点之间是否存在死锁,并在那里处理它。
类似地,在站点4,你可以开始构建一个代表站点3和站点4的全局等待图,并在站点4处理站点3和站点4之间的任何死锁。
最终,你仍然需要担心企业范围内的死锁循环。因此,在站点3,你可能会存放一个真正全局的等待图,其中汇集了所有本地的等待图,并在站点3进行处理。这是在分布式系统中处理分层死锁检测的一种方法。
总结 📝

本节课中我们一起学习了等待图检测。我们了解到等待图是检测死锁的有效工具,它通过识别事务间的等待循环来工作。在单站点系统中,这相对简单。然而,在分布式多站点环境中,挑战在于本地可能没有循环,但跨站点却可能形成全局死锁。为了解决这个问题,我们介绍了分层死锁检测的方法,即通过构建不同层级的全局等待图(如站点间、企业级)来汇总信息,从而在更高的层级上识别和处理那些在本地不可见的死锁循环。
040:死锁规避 🚫


在本节课中,我们将学习并发控制中的死锁规避策略。死锁是多个事务因相互等待对方释放资源而无法继续执行的状态。为了避免这种情况,数据库系统采用了多种算法。我们将探讨资源排序法、等死算法和伤等算法这三种主要策略,并理解它们如何通过不同的机制来预防死锁的发生。
资源排序法
上一节我们介绍了死锁的基本概念,本节中我们来看看第一种规避策略:资源排序法。
这种方案的核心思想是为系统中的所有资源规定一个全局的、固定的顺序。当事务需要锁定多个资源时,必须严格按照这个顺序(例如,从编号最小的资源到编号最大的资源)来申请锁。
以下是该方法的运作原理:
- 系统为所有资源(例如数据项 V, W, X, Y, Z)分配一个唯一的编号。
- 任何事务(如
Trans1或Trans2)在申请锁时,都必须从它所需资源中编号最小的那个开始,依次申请到编号最大的。 - 由于所有事务都遵循相同的加锁顺序,因此永远不会出现事务A持有资源R1并等待资源R2,而事务B持有资源R2并等待资源R1的循环等待情况,从而避免了死锁。
这种方法在大型数据库系统中可能不太实用,因为需要管理和维护大量资源的全局顺序。

等死算法
了解了基于静态顺序的方法后,我们来看一种基于动态时间戳的策略:等死算法。
该算法为每个事务分配一个唯一的时间戳。当一个事务 Ti 请求一个已被另一个事务 Tk 锁定的资源时,系统会比较两者的时间戳(即“年龄”),并根据比较结果采取不同行动。

以下是具体的决策规则:
- 如果
Ti比Tk更老(即Ti的时间戳更小):Ti会等待Tk完成并释放锁。 - 如果
Ti比Tk更年轻(即Ti的时间戳更大):Ti会中止(死亡) 自身,稍后以相同的时间戳重新启动。


举例说明:
- 事务A(较老)读取了“刀”。
- 事务B(较年轻)读取了“叉”。
- 事务A尝试读取“叉”,发现已被B锁定。由于A更老,它选择等待B。
- 事务B尝试读取“刀”,发现已被A锁定。由于B更年轻,它根据规则中止自身,释放“叉”的锁。
- 事务A获得“叉”的锁,完成操作后释放所有锁。
- 事务B重启,此时可以顺利获得“刀”和“叉”的锁并完成操作。
等死算法的一个潜在问题是,较老的事务可能需要等待很长时间,甚至可能“饿死”。
伤等算法

为了解决等死算法中老事务可能长时间等待的问题,我们引入另一种基于时间戳的算法:伤等算法。
当一个事务 Ti 请求一个已被事务 Tk 锁定的资源时,算法同样比较两者的时间戳,但行为规则不同。
以下是该算法的决策逻辑:
- 如果
Ti比Tk更老:Ti会中止(伤害) 较年轻的事务Tk,迫使Tk释放锁,然后Ti获得锁并继续。 - 如果
Ti比Tk更年轻:Ti会等待Tk完成。
举例说明:
- 事务A(较老)读取了“刀”。
- 事务B(较年轻)读取了“叉”。
- 事务B尝试读取“刀”,发现已被A锁定。由于B更年轻,它选择等待。
- 事务A尝试读取“叉”,发现已被B锁定。由于A更老,它会中止事务B。
- 事务A获得“叉”的锁,完成操作后释放所有锁。
- 事务B重启后,可以顺利获得“刀”和“叉”的锁并完成操作。
在这种策略下,老事务永远不会等待年轻事务,从而保证了老事务能够优先完成。
总结



本节课中我们一起学习了三种主要的死锁规避策略。
- 资源排序法通过强制所有事务遵循统一的加锁顺序来预防循环等待。
- 等死算法利用时间戳,让年轻的事务在冲突时牺牲自己,以避免死锁。
- 伤等算法同样利用时间戳,但让老事务在冲突时中止年轻的事务,以优先保证老事务的执行。

每种策略都有其适用场景和权衡,数据库系统设计者会根据具体需求选择合适的机制来管理并发并避免死锁。
041:缓存管理方法 🗄️


在本节课中,我们将要学习数据库系统中的缓存管理方法。缓存是提升数据库性能的关键组件,它通过在快速的主内存中暂存数据,减少对慢速磁盘的访问。我们将探讨两种核心的缓存管理策略:“窃取”与“强制”,并理解它们如何影响数据库的可靠性与性能。
缓存管理方案概述
上一节我们介绍了缓存的基本概念,本节中我们来看看一个典型的缓存管理方案。
下图展示了一个典型的缓存管理方案。

在主内存中,同时存在本地恢复管理器和一个数据库缓冲区管理器。数据库缓冲区管理器负责管理左侧的二级存储(如磁盘)与右侧的数据库缓存之间的数据移动。
由于数据库缓存位于主内存中,它是易失性的。主内存可能崩溃,系统本身也可能崩溃。因此,我们预期数据库缓存会因系统问题而偶尔被清空。这是一个易失性数据库。
在左侧,我们有二级存储,它是一个稳定的数据库。这是一个磁盘系统,虽然它偶尔也会崩溃,但比主内存稳定得多。
缓存的核心思想是将页面从稳定的存储中调入数据库缓存,以便应用程序能快速访问数据,并在主内存中更快地修改数据。通过将数据移入数据库缓存,应用程序的性能可以得到显著提升。
缓存管理的两个核心方面
缓存管理主要涉及两个不同的方面。
第一个方面是“窃取”页面。当本地恢复管理器请求一个页面(例如页面 P‘)时,我们从稳定数据库中提取 P’。当我们将它移动到数据库缓存时,数据库缓存可能已满。因此,我们需要将数据库缓存中的一个页面替换回稳定存储,以便为新页面 P‘ 腾出空间。
这看起来没问题,但问题在于,如果我们写回稳定存储的页面包含尚未提交的数据,我们就是在将一些未提交的数据写入稳定存储。这可能是有问题的。因为如果系统崩溃,我们将在稳定存储中留下未提交的事务数据,而这些数据本不应该存在于稳定存储中。
如果我们确实将未提交的数据从易失性内存(数据库缓存)移动到稳定存储,我们使用的就是“窃取”方法,有时也称为“非固定”方法。

理想情况下,我们希望只在二级存储中保留已提交的事务,而在数据库缓存中保留未提交的事务。
第二个方面是“强制”页面。当我们把一个页面调入易失性内存(数据库缓存)后,我们可能执行一个事务并修改数据。当该事务完成后,我们希望将数据强制写回二级存储,因为我们不再需要它了。这被称为“强制”方法或“刷新”方法。
无窃取与强制方法
显然,“无窃取”和“强制”是最容易实现的组合。“无窃取”意味着我们不将任何未提交的事务移动到稳定存储。同时,它意味着当一个事务提交后,我们确实会将其移动或刷新回稳定存储。

这种方法实际上非常有用,因为我们总是可以确定:在缓存内存中,我们只有未提交的数据;而在稳定存储中,我们只有已提交的数据。这意味着如果系统宕机,我们在主内存中丢失的只是未提交的数据,而在稳定存储中保留的数据都是已提交的。能够将两者分开是非常有用的。
但不幸的是,这种方法在实际中并不总是运行良好。因为通常我们希望调入的页面数量会超过缓存的容量,而且缓存中可能充满了未提交的事务。然而,为了确保数据库事务的良好性能,我们仍然需要将页面调入缓存。因此,我们常常不得不实际“窃取”数据,即将未提交的事务数据放回稳定存储,这并不是我们想要做的。但我们能做什么呢?我们通常以性能为首要目标,有时这就意味着需要将未提交的数据移回稳定存储。
另一个问题是,总是将已提交的事务页面强制写回稳定存储,对于I/O操作来说并不理想,因为这需要过多的磁盘I/O。如果你总是在提交事务后立即将数据推回磁盘,你会占用大量系统资源,最终导致应用程序响应时间变慢,因为每次提交事务时都会发生大量的磁盘I/O。
现实中的恢复算法
因此,现实中使用的恢复算法类型是“窃取/非强制”算法。在这种算法下,我们允许已提交的事务数据保留在易失性内存中,也允许未提交的事务页面存在于稳定内存中。
当然,这里的问题是当系统崩溃时我们该怎么办。
以下是正常执行期间的恢复步骤:
- 我们记录所有已完成的事务和所有已完成的修改。我们将这些日志保存在稳定存储中,并保证其是永久性的。我们拥有一个永久性的日志,记录了所有对数据库进行的修改或更新,包括所有事务的更新、删除等。
- 这些日志条目必须在实际更改发生之前写入。因为你不会希望出现这样的情况:实际进行了更改,然后系统崩溃了,但由于没有写入日志条目,导致没有恢复依据。因此,我们真正想做的是先写入日志条目。
日志使得本地恢复管理器能够执行以下操作:
- 对于稳定存储中存在的未提交数据,我们希望撤销那些已中止和未完成事务的操作。这意味着我们希望访问稳定存储并撤销那些操作,以便我们实际上可以用最后一次已提交的事务数据来替换稳定存储中的信息。
- 最后,对于那些已提交但在被写出到稳定存储之前一直保留在易失性内存中的数据,我们希望重做那些操作。这样我们就可以在缓存中的稳定存储区域重建数据,以便将其移出到稳定存储。
因此,我们希望撤销那些已中止或未完成的事务,以便从稳定存储中移除未提交的数据;同时,我们希望重做已提交事务的操作,以便重建存在于易失性内存中的已提交数据。这将成为我们本地恢复管理操作的基础,随着课程的深入,我们将看到如何具体实现。
总结
本节课中我们一起学习了数据库缓存管理的核心方法。我们首先了解了典型的缓存管理架构,然后深入探讨了“窃取”与“强制”这两个关键概念及其对数据一致性和性能的影响。我们认识到,理想的“无窃取+强制”方法虽然简单且能保证数据一致性,但在实际高性能场景中往往不可行。因此,现实系统多采用“窃取+非强制”策略,并依赖预写日志机制来实现崩溃恢复,通过撤销未完成事务和重做已提交事务来保证数据的最终一致性。
042:事务案例 📊


概述
在本节课中,我们将通过一个具体的事务操作序列示例,学习数据库系统如何利用日志(Log)、脏页表(Dirty Page Table)和事务表(Transaction Table)来追踪数据变化,并理解系统崩溃后如何利用这些信息进行恢复。我们将一步步解析每个操作对这三个核心数据结构的影响。
事务操作序列示例
假设我们有一个按时间顺序发生的事务操作序列。左侧是日志序列号(Log Sequence Number, LSN),它按顺序单调递增分配。右侧代表不同事务在一段时间内的操作。
以下是操作序列:
- LSN 10: 事务 T1 更新内存数据库的第 50 页。
- LSN 20: 事务 T2 更新第 60 页。
- LSN 30: 事务 T3 更新第 70 页。
- LSN 40: 事务 T2 再次更新第 50 页。
- LSN 50: 事务 T3 提交。
- LSN 60: 事务 T1 更新第 55 页。
- 随后,系统发生崩溃。
接下来,我们将详细分析每个步骤后,日志、脏页表和事务表的状态变化。

第一步:T1 更新页 50 (LSN 10)
当第一个事务 T1 更新第 50 页时,系统需要记录这次操作。
日志表 中新增一条记录。由于这是事务 T1 的第一次操作,其“前一个LSN”字段为空。记录内容如下:
- LSN: 10
- PrevLSN:
NULL - TID: T1
- Type:
UPDATE - PageID: 50
- 修改描述: 例如,在偏移量 21 处修改了 3 个字符,将 “ABC” 改为 “DEF”。
脏页表 用于追踪哪些页被未提交的事务修改过。由于第 50 页首次变“脏”,我们在表中添加一条记录:
- PageID: 50
- RecLSN: 10 (指向使该页变脏的第一条日志记录)
事务表 用于追踪所有活跃(未提交)事务的最新日志位置。我们为 T1 添加一条记录:
- TID: T1
- LastLSN: 10 (指向该事务最新的日志记录)
第二步:T2 更新页 60 (LSN 20)
现在,事务 T2 更新第 60 页。
在 日志表 中为 T2 创建第一条记录:
- LSN: 20
- PrevLSN:
NULL(T2 的第一个操作) - TID: T2
- Type:
UPDATE - PageID: 60
- 修改描述: 例如,在偏移量 41 处将 “HIJ” 改为 “KLM”。
在 脏页表 中,第 60 页首次变脏,添加记录:
- PageID: 60
- RecLSN: 20
在 事务表 中,添加事务 T2 的记录:
- TID: T2
- LastLSN: 20
第三步:T3 更新页 70 (LSN 30)
事务 T3 开始并更新第 70 页。
日志表 新增 T3 的记录:
- LSN: 30
- PrevLSN:
NULL - TID: T3
- Type:
UPDATE - PageID: 70
- 修改描述: 例如,在偏移量 16 处将 “12345” 改为 “67890”。
脏页表 新增第 70 页的记录:
- PageID: 70
- RecLSN: 30
事务表 新增 T3 的记录:
- TID: T3
- LastLSN: 30
第四步:T2 更新页 50 (LSN 40)
事务 T2 再次执行操作,这次更新第 50 页。
在 日志表 中为 T2 新增一条记录。此时,T2 已有一条记录(LSN 20)。我们需要从 事务表 中查找 T2 的 LastLSN(当前是 20),并将其作为新记录的 PrevLSN,以维护事务内的操作链。
- LSN: 40
- PrevLSN: 20 (来自事务表中 T2 的 LastLSN)
- TID: T2
- Type:
UPDATE - PageID: 50
- 修改描述: 例如,在偏移量 20 处将 “ZDE” 改为 “QRS”。
脏页表 无需更新。因为第 50 页已经在 LSN 10 时被标记为脏页,RecLSN 仍然指向最早的修改记录 10。
事务表 需要更新 T2 的 LastLSN 指针,指向其最新的操作:
- TID: T2
- LastLSN: 40 (更新为新的 LSN)
第五步:T3 提交 (LSN 50)
事务 T3 成功提交。
在 日志表 中记录提交操作:
- LSN: 50
- PrevLSN: 30 (T3 上一条操作的 LSN)
- TID: T3
- Type:
COMMIT
脏页表 不变。提交操作不产生新的脏页。
事务表 需要移除已提交的事务 T3。因此,T3 的记录被删除。

第六步:T1 更新页 55 (LSN 60) 与系统崩溃
事务 T1 更新第 55 页,随后系统崩溃。
日志表 记录 T1 的新操作。从事务表查得 T1 的 LastLSN 为 10,将其作为 PrevLSN:
- LSN: 60
- PrevLSN: 10
- TID: T1
- Type:
UPDATE - PageID: 55
- 修改描述: 例如,在偏移量 20 处将 “TUV” 改为 “WXY”。
脏页表 新增第 55 页的记录,因为这是它第一次被修改:
- PageID: 55
- RecLSN: 60
事务表 更新 T1 的 LastLSN:
- TID: T1
- LastLSN: 60

此时系统崩溃。内存中的脏页表和事务表丢失,只有持久化存储中的 日志 得以保存。
崩溃恢复的目标与挑战
系统重启后,恢复机制开始工作。目标是利用日志重建崩溃前的状态,但确保 未提交事务(T1, T2)的所有修改都不会生效。已提交事务(T3)的修改必须生效。
面临的挑战是,日志可能非常庞大,从头开始扫描重建脏页表和事务表效率低下。
检查点(Checkpoint)机制

为了解决上述挑战,数据库系统定期执行 检查点 操作。
检查点的过程如下:
- 向日志写入一条
BEGIN_CHECKPOINT记录。 - 将当前内存中的 脏页表 和 事务表 的快照写入稳定存储(通常是追加到日志中)。
- 向日志写入一条
END_CHECKPOINT记录。
这样,当系统崩溃后恢复时,可以从最近一个检查点开始读取日志,快速重建崩溃时刻的脏页表和事务表,而无需扫描全部历史日志,大大提高了恢复效率。
总结
本节课中,我们一起学习了一个完整的事务操作案例。我们看到了:
- 每个数据修改操作如何被记录到 日志 中。
- 脏页表 如何追踪每个数据页的第一次未提交修改。
- 事务表 如何链式追踪每个未提交事务的所有操作。
- 系统崩溃后,持久化的日志是恢复的唯一依据。
- 检查点 机制如何优化恢复过程,避免从日志开头进行全量扫描。

在接下来的课程中,我们将深入探讨如何利用重建后的脏页表和事务表,具体执行 重做(Redo) 和 撤销(Undo) 阶段,从而将数据库恢复到一致的正确状态。
043:大规模数据库系统 - 分析与重做阶段 📊


在本节课中,我们将学习数据库系统崩溃恢复过程中的前两个关键阶段:分析阶段与重做阶段。我们将了解系统如何通过日志文件重建崩溃前的状态,并重新执行必要的操作以确保数据一致性。
概述
当数据库系统从崩溃中恢复时,会执行一个包含三个阶段的算法。本节课将重点讲解前两个阶段:分析阶段和重做阶段。分析阶段负责确定需要重做和撤销哪些操作,而重做阶段则负责重新执行这些操作,将数据库恢复到崩溃前的状态。
分析阶段 🔍

上一节我们介绍了恢复过程的整体框架,本节中我们来看看第一个阶段——分析阶段的具体工作。
分析阶段是恢复过程的第一步。它的核心目标是:通过检查日志,重建崩溃发生时脏页表和事务表的状态。这两个表记录了哪些数据页被修改过(脏页)以及哪些事务正在进行中。
分析阶段的具体步骤如下:

- 定位并读取最近的检查点记录。检查点记录了某个时刻数据库的完整状态,包括当时的脏页表和事务表。
- 基于该检查点记录,在内存中重建脏页表和事务表。
- 从检查点开始,向前扫描日志文件,直到日志末尾(即崩溃点)。
- 在扫描过程中,动态更新这两个表:
- 如果遇到一个使新页面变“脏”的日志记录,就将该页面及其对应的日志序列号加入脏页表。
- 如果遇到一个新事务开始的记录,就将该事务加入事务表。
- 如果遇到一个事务提交的记录,就将该从事务表中移除。
通过这个过程,在分析阶段结束时,内存中的脏页表和事务表就与系统崩溃瞬间的状态完全一致。这为后续的重做和撤销阶段提供了准确的依据。
以下是分析阶段的一个简化示例。我们假设最近的检查点发生在所有操作之前,并且初始的脏页表和事务表为空。
我们有一条在崩溃前生成的日志,分析过程将逐条处理这些记录:
- 第一条日志记录被分析:事务T1更新了页面P50。因此,在脏页表中添加P50及其LSN,在事务表中添加T1并指向此日志记录。
- 第二条日志记录被分析:事务T2更新了页面P60。因此,在脏页表中添加P60及其LSN,在事务表中添加T2并指向此日志记录。
- 第三条日志记录被分析:事务T3更新了页面P70。因此,在脏页表中添加P70及其LSN,在事务表中添加T3并指向此日志记录。
- 第四条日志记录被分析:事务T2进行了另一次更新。因此,更新事务表中T2的指针,使其指向这条新的日志记录。脏页表无变化。
- 第五条日志记录被分析:事务T3提交。因此,从事务表中移除T3。
- 第六条日志记录被分析:事务T1更新了页面P55。因此,更新事务表中T1的指针,并在脏页表中添加P55及其LSN。
至此,分析阶段结束。我们得到了与崩溃时刻完全一致的脏页表和事务表,可以进入下一个阶段。
重做阶段 🔄
在分析阶段,我们成功重建了系统崩溃时的状态信息。现在,我们进入恢复的第二个阶段——重做阶段。
重做阶段的目标是:重新执行所有必要的修改,确保所有已提交和未提交事务对数据库的更新都反映在磁盘上。它从脏页表中最早的日志序列号开始,顺序重做日志中的所有操作。
重做阶段的具体规则如下:
- 起点:从脏页表中所有LSN的最小值对应的日志记录开始。
- 操作:对于每一条可重做的日志记录(通常是更新记录),无论其对应的事务是否已提交,都重新执行一次该操作。例如,一条日志记录是
UPDATE Page 50, offset 21: ‘ABC’ -> ‘DEF’,那么重做阶段就会再次将页面50的偏移量21处的内容改为‘DEF’。 - 优化:为了提高效率,重做操作有一个重要的避免重复写入的规则。在应用一条日志记录前,系统会检查目标页面的当前LSN。如果该页面的当前LSN 大于或等于 要重做的日志记录的LSN,则说明这个修改可能已经被写入磁盘(例如,由检查点或缓冲区管理器提前写回),此时就跳过这条重做记录。这可以用以下逻辑判断:
if (page.current_lsn >= log_record.lsn) { skip_redo(); }
通过重做阶段,数据库被推进到了崩溃前那一刻所有已完成操作所应达到的状态。即使某些已提交事务的修改在崩溃前未能写回磁盘,现在也被重新执行并持久化了。

总结
本节课中我们一起学习了ARIES恢复算法中的前两个阶段。
- 分析阶段:通过从最近检查点开始扫描日志,精确重建了崩溃瞬间的脏页表和事务表,明确了哪些操作需要重做,哪些事务需要撤销。
- 重做阶段:从最早的脏页操作开始,重新执行日志中的所有更新操作(遵循LSN检查以避免冗余),确保所有数据修改都持久化到磁盘上,将数据库恢复到崩溃前的物理状态。

完成重做阶段后,数据库包含了所有已提交和未提交事务的修改。下一个阶段——撤销阶段,将负责回滚那些未提交的事务,以维护数据库的原子性和逻辑一致性。我们将在下一课中进行讲解。
044:Aries 算法之撤销阶段 🗂️


在本节课中,我们将学习 Aries 恢复算法的最后一个关键步骤——撤销阶段。我们将了解如何识别并回滚那些在系统崩溃时尚未提交的事务,以确保数据库最终只包含已提交事务的结果,从而维持数据的一致性与持久性。
撤销阶段概述
上一节我们介绍了重做阶段,它负责将数据库恢复到崩溃前的物理状态。然而,此时数据库中可能还包含一些未提交事务(即“脏事务”)所做的修改。本节中我们来看看撤销阶段,它的核心目标就是回滚所有未提交事务,消除它们对数据库的影响。

撤销阶段是 Aries 算法的最后一步。我们已经通过重做阶段将数据库重建到了崩溃发生前的检查点状态。现在,我们需要移除数据库中所有未提交事务所对应的数据条目。
撤销阶段的工作原理
在撤销阶段,我们首先查找所有未完成的事务,并将它们放入一个待撤销列表中。然后,算法会反向遍历这些事务的日志记录,逐一撤销它们对数据库所做的修改。
算法总是从日志序列号最大的记录开始处理,即最后发生的操作最先被撤销。以下将用一个具体例子来说明这个过程。

撤销阶段示例分析
假设在崩溃后,我们已通过分析阶段和重做阶段,重建了如下状态:
- 脏页表 和 事务表 已恢复到崩溃前的状态。
- 数据库中的数据也通过重做,恢复到了崩溃前的样子。
但此时,事务 T1 和 T2 尚未提交,我们需要撤销它们的影响。
首先,我们根据事务表创建 待撤销列表。该列表包含了所有需要被回滚的事务及其对应的最后一条日志记录位置。
以下是初始状态:
- 事务 T1(未提交)指向日志的第 6 条记录。因此,我们将
(T1, LSN=6)加入待撤销列表。 - 事务 T2(未提交)指向日志的第 4 条记录。因此,我们将
(T2, LSN=4)加入待撤销列表。
撤销执行过程
撤销过程是一个循环,每次处理待撤销列表中日志序列号最大的记录。
第一步:撤销 T1 的最后操作 (LSN=6)
- 当前待撤销列表中,LSN 最大的是 6(属于 T1)。
- 查看日志第 6 条记录:这是一次更新操作,将页面 P55 偏移量 20 处的值从
TUV改为了WXY。 - 执行撤销:将页面 P55 对应位置的值从
WXY恢复为TUV。 - 操作完成后,根据该日志记录的“前一LSN”指针(值为1),更新待撤销列表中 T1 的指针。现在 T1 对应的条目变为
(T1, LSN=1)。
第二步:撤销 T2 的最后操作 (LSN=4)
- 此时待撤销列表中,LSN 最大的是 4(属于 T2)。
- 查看日志第 4 条记录:这是一次更新操作,将值
ZDE改为了QRS。 - 执行撤销:将
QRS恢复为ZDE。 - 根据该记录的“前一LSN”指针(值为2),更新待撤销列表中 T2 的指针。现在 T2 对应的条目变为
(T2, LSN=2)。
第三步:撤销 T2 的上一操作 (LSN=2)
- 此时待撤销列表中,LSN 最大的是 2(属于 T2)。
- 查看日志第 2 条记录:这是一次更新操作,将值
HJ改为了KLM。 - 执行撤销:将
KLM恢复为HJ。 - 根据该记录的“前一LSN”指针,发现其值为
NULL。这表明 T2 的所有操作都已回滚完毕。因此,将 T2 从待撤销列表中移除。
第四步:撤销 T1 的上一操作 (LSN=1)
- 此时待撤销列表中只剩下 T1,对应 LSN=1。
- 查看日志第 1 条记录:这是一次更新操作,将值
ABC改为了DF。 - 执行撤销:将
DF恢复为ABC。 - 根据该记录的“前一LSN”指针,发现其值为
NULL。这表明 T1 的所有操作也已回滚完毕。因此,将 T1 从待撤销列表中移除。
撤销完成后的状态
至此,待撤销列表已空,撤销阶段结束。此时数据库的状态是:
- 已提交事务 T3 的所有修改均被保留(在重做阶段已恢复)。
- 未提交事务 T1 和 T2 的所有修改均被彻底回滚,如同从未发生过。
数据库现在处于一个稳定且一致的状态,只包含已提交事务的结果。系统可以安全地接受新的事务,继续正常运行。
本节总结
本节课中我们一起学习了 Aries 恢复算法的撤销阶段。我们了解到:
- 目标:回滚所有在崩溃时未提交的事务,确保数据库一致性。
- 关键机制:使用待撤销列表来跟踪需要回滚的事务,并反向遍历日志(从最新的未提交操作开始)来执行撤销。
- 核心操作:对于每一条更新日志记录,执行其逆操作,将数据项恢复到此事务修改之前的值。
- 最终结果:数据库仅保留已提交事务的修改,为后续操作提供了一个干净、一致的起点。

撤销阶段与之前介绍的分析阶段、重做阶段共同构成了完整的 Aries 恢复算法,确保了数据库系统在发生故障后能够可靠地恢复到正确状态。
045:终止协议 🛑


在本节课中,我们将学习两阶段提交协议中的终止协议。当协调者或参与者发生超时或故障时,系统需要特定的流程来恢复并决定事务的最终状态,以确保数据的一致性。我们将详细探讨各种情况下的处理逻辑。
协调者与参与者的状态转换图
上一节我们介绍了两阶段提交的基本流程,本节中我们来看看协调者和参与者的状态转换图,这是理解终止协议的基础。
协调者的状态转换如下:
- 初始状态:协调者向所有参与者发送
PREPARE消息。 - 等待状态:协调者等待所有参与者的投票。
- 提交状态:如果所有参与者都投票“提交”,协调者发送
GLOBAL-COMMIT消息。 - 中止状态:如果任一参与者投票“中止”,协调者发送
GLOBAL-ABORT消息。

参与者的状态转换如下:
- 初始状态:参与者等待
PREPARE消息。 - 就绪状态:如果参与者可以提交,它投票“提交”并进入此状态,等待协调者的最终决定。
- 提交状态:收到
GLOBAL-COMMIT消息后提交事务。 - 中止状态:在初始状态投票“中止”,或收到
GLOBAL-ABORT消息后中止事务。
协调者超时处理
了解了基本状态后,我们来看看当协调者发生超时时应如何处理。协调者超时可能发生在等待状态,也可能发生在提交或中止状态。
以下是协调者超时的处理逻辑:
- 在等待状态下超时:协调者尚未收到所有参与者的投票。由于无法单方面提交(需要全体一致),协调者可以单方面决定中止。它会向所有参与者发送
GLOBAL-ABORT消息。 - 在提交或中止状态下超时:协调者已发出最终指令(
GLOBAL-COMMIT或GLOBAL-ABORT),但不确定所有参与者是否已执行完毕。它会重复发送最终指令,直到收到所有参与者的确认。
参与者超时处理
现在,我们转向参与者的超时情况。参与者的超时可能发生在初始状态或就绪状态,处理方式有所不同。
以下是参与者超时的处理逻辑:
- 在初始状态下超时:参与者一直在等待
PREPARE消息但未收到,推测协调者可能已故障。参与者可以单方面中止事务,并向协调者发送“中止”投票。 - 在就绪状态下超时:参与者已投票“提交”,但未收到协调者的最终决定。此时,参与者不能单方面做出决定,否则可能破坏原子性。它必须询问其他参与者以决定下一步行动。

参与者查询协议
当参与者在就绪状态下超时且联系不上协调者时,它可以通过查询其他参与者来收集信息并做出决定。这个过程称为参与者查询协议。
以下是参与者根据查询结果采取的行动:
- 如果任何参与者处于“初始”或“中止”状态:这意味着至少有一个参与者会投票或已经决定中止。因此,查询者可以安全地中止事务。
- 如果任何参与者处于“提交”状态:这意味着协调者已发出
GLOBAL-COMMIT指令。因此,查询者可以安全地提交事务。 - 如果所有被查询的参与者都处于“就绪”状态:这是一个阻塞情况。所有参与者都投票同意提交,但都不知道最终决定。此时,参与者们需要选举一个新的协调者,由新协调者重新发起提交协议。
- 如果协调者与一个参与者同时故障:且所有存活参与者均处于“就绪”状态,则协议将永久阻塞,因为无法确定故障参与者的投票和协调者的决定。这是两阶段提交协议的一个严重缺点。
故障恢复协议

最后,我们探讨当协调者或参与者的站点发生故障并重启后,应如何恢复。恢复行动取决于故障发生时实体所处的状态。
协调者站点故障恢复:

- 在初始状态故障:重启后无事可做,可重新开始事务。
- 在等待状态故障:重启后重新向所有参与者发送
PREPARE消息,并等待投票。 - 在提交/中止状态故障:重启后,向未确认的参与者重复发送最终的
GLOBAL-COMMIT或GLOBAL-ABORT消息。
参与者站点故障恢复:
- 在初始状态故障:重启后,参与者应单方面中止事务。
- 在就绪状态故障:重启后,参与者发现自己处于“就绪”状态但无协调者消息。此时,应启动参与者查询协议(如上节所述)来决定提交或中止。
- 在提交/中止状态故障:事务已经完成,无需特殊操作。

总结
本节课中我们一起学习了两阶段提交协议中的终止协议。我们分析了协调者和参与者在各种超时及站点故障场景下的处理逻辑,包括状态转换、单方面中止决策、参与者查询协议以及故障恢复步骤。关键点在于,两阶段提交协议在协调者与一个参与者同时故障且其他参与者均处于“就绪”状态时,可能导致系统阻塞,这揭示了该协议的一个主要缺陷。在接下来的课程中,我们将介绍三阶段提交协议,它被设计用来解决这种阻塞问题。
046:三阶段提交协议的超时与终止协议 🕒


在本节课中,我们将学习三阶段提交协议(3PC)的超时处理与终止协议。我们将了解3PC如何通过引入一个额外的“预提交”阶段来解决两阶段提交协议(2PC)中的阻塞问题,并详细探讨在超时发生时,协调者和参与者如何采取行动以确保系统能够继续运行。
概述
三阶段提交协议是对两阶段提交协议的改进,旨在解决参与者可能被阻塞的问题。它通过引入一个“预提交”阶段,使得即使在协调者或其他参与者发生故障时,参与者也能独立地决定事务的最终状态,从而避免无限期等待。
三阶段提交协议的状态转换
上一节我们介绍了2PC的阻塞问题,本节中我们来看看3PC如何通过修改状态转换来解决它。
3PC在协调者和参与者的状态机中增加了一个“预提交”状态。这个状态的关键作用是,它使得协调者的“等待”状态或参与者的“就绪”状态不再直接与“提交”或“中止”状态相邻。在2PC中,正是这种相邻关系导致了阻塞。
在预提交阶段,协调者和参与者已经投票决定“是,我们打算提交”,但尚未最终提交。这给了它们最后一次机会来确认是否真的要提交。这个过程可以概括为:“是的,我会提交。我真的要提交吗?是的,真的要提交。好的,那就提交。” 这使得参与者可以在“你真的要提交吗?”这个阶段选择中止,从而允许参与者在协调者或其他参与者宕机时能够独立地继续推进,而不会被阻塞。
以下是协调者和参与者的状态转换流程:

协调者视角
- 初始状态:协调者发起事务,向所有参与者发送“准备”消息,询问它们是否准备提交。
- 等待状态:协调者等待所有参与者的投票。
- 如果任何参与者投票“否”,协调者将全局中止事务,并向所有参与者发送“中止”消息。
- 如果所有参与者都投票“是”,协调者则进入预提交状态。
- 预提交状态:协调者向所有参与者发送“准备提交”消息,并等待它们确认。
- 提交状态:当收到所有参与者的确认后,协调者发送“全局提交”消息,并进入提交状态。
参与者视角
- 初始状态:参与者等待协调者的指令。
- 收到“准备”消息:参与者决定是中止还是提交。
- 如果决定中止,则进入中止状态,并通知协调者。
- 如果决定提交,则进入就绪状态,并通知协调者,然后等待协调者的进一步指令。
- 预提交状态:当参与者收到协调者的“准备提交”消息时,它知道所有参与者都已投票同意提交。参与者进入预提交状态,等待最终的“提交”指令。
- 提交状态:收到协调者的“全局提交”消息后,参与者执行提交。
接下来,我们将具体分析在这种设计下,超时发生时系统如何应对。
超时处理
当协调者或参与者在某个状态等待超时,它们需要有一套规则来决定下一步行动,以避免阻塞。
协调者超时
- 在等待状态超时:协调者已发送“准备”消息,但未能在超时前收到所有参与者的回复。此时,协调者可以单方面中止事务,因为它尚未收到任何“同意提交”的承诺。它会发送全局中止消息。
- 在预提交状态超时:协调者已收到所有参与者的“同意提交”投票,并进入了预提交状态。此时,它知道所有参与者至少处于“就绪”状态。因此,协调者可以安全地决定全局提交事务,并向所有参与者发送“全局提交”消息。
- 在提交或中止状态超时:协调者已发出最终指令,但可能不知道参与者是否已执行。此时,它需要依靠接下来要介绍的终止协议来恢复。
参与者超时
- 在初始状态超时:参与者等待“准备”消息超时。它假设协调者在初始状态就失败了,因此可以单方面中止事务。
- 在就绪状态超时:这是2PC中的阻塞状态。在3PC中,参与者已经投票同意提交,但尚未收到全局决定。此时,参与者可以选举一个新的协调者,由新协调者来终止事务。因为参与者尚未进入“预提交”状态,它仍有第二次机会(在预提交阶段)改变决定,因此它不会被阻塞。
- 在预提交状态超时:参与者已两次确认要提交(一次在投票时,一次在进入预提交时),并且知道所有其他参与者也同意提交。此时,它只需等待最终的“提交”指令。如果超时,它可以选举新协调者,而新协调者将确保事务被正确终止(提交或中止)。关键点在于,参与者在任何状态下超时,都不会进入一个永久阻塞的状态。

终止协议
当参与者因协调者超时而选举出新协调者后,终止协议将被启动,以确保所有节点就事务的最终状态达成一致。
以下是终止协议的步骤:
- 新选举出的协调者将自己的状态发送给所有参与者。
- 参与者收到消息后:
- 如果参与者的状态领先于或等于协调者发送的状态,则忽略此消息。
- 否则,参与者将自己的状态更新为协调者发送的状态,并回复相应的消息。
- 新协调者根据收集到的参与者状态做出最终决定:
- 如果任何参与者处于“中止”状态,或者协调者自己处于“等待”状态,则全局中止事务。
- 如果所有参与者都处于“预提交”或“提交”状态,并且协调者处于“预提交”状态,则全局提交事务。
- 需要特别处理的一种情况是:如果一个参与者处于“预提交”状态,但新协调者发现事务需要中止(例如因为另一个参与者在更早阶段失败了),那么必须创建一条从“预提交”到“中止”的状态转换路径。这在标准状态图中原本不存在,但在终止协议中是必要的。
这个协议确保了所有参与者和协调者都不会被阻塞。事务最终将要么全部提交,要么全部中止,从而实现了我们所需的原子性,同时又避免了阻塞。
总结

本节课中我们一起学习了三阶段提交协议的超时与终止机制。3PC通过引入“预提交”阶段,有效解决了2PC中参与者可能被阻塞的核心问题。我们详细分析了协调者和参与者在各种状态下的超时处理逻辑,并介绍了在新协调者被选举出来时,系统如何通过终止协议来恢复并达成一致状态。这使得3PC成为一个非阻塞的原子提交协议,提高了分布式数据库系统在部分节点故障时的可用性。
047:MapReduce的驱动性问题 🚀
概述
在本节课中,我们将学习MapReduce编程模型,并探讨其背后的核心驱动性问题。我们将通过两个具体实例——文档词频统计和网页反向链接分析——来理解MapReduce如何将大规模计算问题分解为可并行处理的步骤,从而显著提升处理效率。
1. 驱动性问题一:文档词频统计

上一节我们介绍了MapReduce的基本概念,本节中我们来看看第一个驱动性问题:如何统计海量文档中每个单词的出现次数。

我们拥有一个庞大的文档语料库。目标是构建一个数组,记录每个唯一单词及其在所有文档中出现的总次数。
原始算法分析
原始算法是一个嵌套循环结构。
算法开始时,我们有一个用于记录单词和计数的数组。对于语料库中的每个文档,我们执行以下操作:检查文档中的每个单词,在计数数组中查找该单词。如果找到,则将该单词的计数加一。如果未找到,则将数组大小增加一,插入新单词并将其计数设为一。
因此,对于每个文档,我们统计其中出现的单词及其频率。对于整个语料库,我们最终得到每个单词在所有文档中出现的总次数。

如果平均每个文档有 n 个单词,内层循环将迭代 n 次。如果语料库包含 M 个文档,外层循环将迭代 M 次。因此,总迭代次数为 n * M,算法的时间复杂度为 O(n * M)。
假设 n 为1000,M 为10⁹,那么算法将需要大约10¹²次操作,这将耗费很长时间。我们需要一种方法来加速这个过程。
MapReduce解决方案
我们注意到,内层循环(统计单个文档的词频)可以独立地为每个文档并行执行。唯一需要的是,在最后阶段,我们必须合并所有独立的单词计数结果。
从MapReduce的视角来看,每个文档的词频统计可以独立并行计算。为每个文档生成词频数组后,结果可以在最后阶段合并为一个最终结果。
以下是MapReduce步骤:

Map阶段:为每个文档分配一个Map进程。每个Map进程统计其对应文档中的单词,并输出中间结果(单词, 出现次数)。
Shuffle/Sort阶段:将所有Map输出的中间结果按键(单词)进行排序和分组。
Reduce阶段:为每个单词分配一个Reduce进程。Reduce进程接收该单词对应的所有计数列表,并求和,最终输出该单词在整个语料库中的总次数。
以下是Map和Reduce函数的伪代码描述:

Map函数:输入是文档内容。对于文档中的每个单词,输出一个中间键值对。
# 输入: (document_id, document_text)
# 输出:中间键值对列表 [(word, 1), ...]
def map_function(document_text):
for word in document_text.split():
emit(word, 1)
Reduce函数:输入是同一个单词对应的所有计数值列表。
# 输入: (word, [count1, count2, ...])
# 输出:最终键值对 (word, total_count)
def reduce_function(word, counts):
total = sum(counts)
emit(word, total)
性能分析
- Map时间:操作总数仍为
M * n,但被分摊到多个并行进程中执行,因此实际时间大大缩短。 - 排序时间:取决于需要排序的中间键值对数量,复杂度在 O(N log N) 到 O(Mn log(Mn)) 之间,但排序可以在多台机器上高效进行。
- Reduce时间:同样,求和操作可以在多个Reduce进程中并行执行。
总操作数虽然仍是 M * n,但通过并行化,实际执行时间得到了显著降低。这正是MapReduce的关键:通过大量并行进程执行计算,经过中间排序,最后归约结果。

2. 驱动性问题二:网页反向链接分析
在理解了词频统计的MapReduce实现后,我们来看另一个驱动性问题,这对许多网络搜索引擎至关重要:如何计算互联网上每个网页被其他网页链接的次数(反向链接数)。
对于互联网上的每个页面,我们想知道有多少其他页面指向它。任何单个页面本身并不知道谁链接了它,因此必须检查互联网上的所有其他页面。
原始算法分析
我们希望得到一个(目标页面, 来源页面列表)的列表。对于网络上的每个来源页面,检查该来源页面中包含的所有目标链接。对于每个目标链接,在目标-来源列表中查找该目标URL。如果找到,则将当前来源页面添加到该目标的来源列表中;如果未找到,则将该目标URL添加到列表中,并初始化其来源列表。

因此,内层循环处理每个来源页面的 n 个链接,外层循环遍历 M 个来源页面。算法时间复杂度为 O(n * M)。假设每个页面平均有10个链接 (n=10),要分析10⁹个页面 (M=10⁹),则需要大约10¹⁰次操作,计算量巨大。
MapReduce解决方案
MapReduce算法则简单高效:
- Map阶段:每个Map进程处理一个网页(来源URL)。它提取该页面中的所有出站链接(目标URL),并为每个链接输出一个中间键值对:
(target_url, source_url)。 - Shuffle/Sort阶段:将所有中间结果按键(目标URL)进行排序和分组。
- Reduce阶段:每个Reduce进程接收一个目标URL及其对应的所有来源URL列表。它只需整理这个列表,并输出
(target_url, [source_url1, source_url2, ...])。通过统计来源列表的长度,即可得到该目标页面的反向链接数。
以下是该问题的Map和Reduce函数描述:
Map函数:输入是网页内容。
# 输入: (source_url, page_html)
# 输出:中间键值对列表 [(target_url, source_url), ...]
def map_function(source_url, page_html):
for target_url in extract_links(page_html):
emit(target_url, source_url)
Reduce函数:输入是同一个目标URL对应的所有来源URL列表。
# 输入: (target_url, [source1, source2, ...])
# 输出:最终键值对 (target_url, source_list)
def reduce_function(target_url, source_list):
# 可以直接输出列表,也可以计算列表长度作为入度
emit(target_url, source_list)
这个过程非常直观。我们并行化了Map和Reduce过程,最终得到一个按目标URL排序的列表,其中包含了指向它的所有来源URL。通过统计来源列表的大小,可以快速高效地确定每个网页的流行度(被链接数),这远比线性的嵌套循环算法要快。

总结
本节课中我们一起学习了MapReduce模型解决大规模计算问题的两个经典案例。
首先,我们探讨了文档词频统计问题,看到了如何将串行的嵌套循环转化为并行的Map和Reduce阶段,从而高效处理海量文本数据。

接着,我们分析了网页反向链接分析问题,理解了MapReduce如何通过“目标-来源”的映射与归约,快速计算出互联网页面的入度,这是网页排名等算法的基石。

这两个实例共同揭示了MapReduce的核心优势:将计算任务分解为独立的map和reduce阶段,通过数据排序和分组实现高效的并行处理与结果聚合,从而能够应对传统串行算法难以处理的大规模数据集。

浙公网安备 33010602011771号